feat(academic): состав групп курса для преподавателя
This commit is contained in:
@@ -18,6 +18,8 @@ import ru.ysu.lms.academic.course.CourseBootstrapService
|
|||||||
import ru.ysu.lms.academic.course.CourseCreateRequest
|
import ru.ysu.lms.academic.course.CourseCreateRequest
|
||||||
import ru.ysu.lms.academic.course.CourseDetail
|
import ru.ysu.lms.academic.course.CourseDetail
|
||||||
import ru.ysu.lms.academic.course.CourseGroupRequest
|
import ru.ysu.lms.academic.course.CourseGroupRequest
|
||||||
|
import ru.ysu.lms.academic.course.CourseGroupRoster
|
||||||
|
import ru.ysu.lms.academic.course.CourseRosterService
|
||||||
import ru.ysu.lms.academic.course.CourseService
|
import ru.ysu.lms.academic.course.CourseService
|
||||||
import ru.ysu.lms.academic.course.CourseSummary
|
import ru.ysu.lms.academic.course.CourseSummary
|
||||||
import ru.ysu.lms.academic.course.CourseTeacherRequest
|
import ru.ysu.lms.academic.course.CourseTeacherRequest
|
||||||
@@ -32,6 +34,7 @@ class CourseController(
|
|||||||
private val courseService: CourseService,
|
private val courseService: CourseService,
|
||||||
private val bootstrapService: CourseBootstrapService,
|
private val bootstrapService: CourseBootstrapService,
|
||||||
private val accessService: CourseAccessService,
|
private val accessService: CourseAccessService,
|
||||||
|
private val rosterService: CourseRosterService,
|
||||||
) {
|
) {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
@@ -44,6 +47,7 @@ class CourseController(
|
|||||||
): List<CourseSummary> = accessService.myCourses(jwt)
|
): List<CourseSummary> = accessService.myCourses(jwt)
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun create(
|
fun create(
|
||||||
@RequestBody body: CourseCreateRequest,
|
@RequestBody body: CourseCreateRequest,
|
||||||
@AuthenticationPrincipal jwt: Jwt,
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
@@ -58,6 +62,15 @@ class CourseController(
|
|||||||
return courseService.detail(id)
|
return courseService.detail(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Состав группы, активно привязанной к курсу. */
|
||||||
|
@GetMapping("/{courseId}/groups/{groupId}/students")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
fun groupStudents(
|
||||||
|
@PathVariable courseId: UUID,
|
||||||
|
@PathVariable groupId: UUID,
|
||||||
|
@AuthenticationPrincipal jwt: Jwt,
|
||||||
|
): CourseGroupRoster = rosterService.groupRoster(courseId, groupId, jwt)
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||||
fun update(
|
fun update(
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package ru.ysu.lms.academic.course
|
||||||
|
|
||||||
|
/** Состав группы в разрезе курса — данные берутся из identity, привязка проверяется в academic. */
|
||||||
|
data class CourseGroupRoster(
|
||||||
|
val groupId: String,
|
||||||
|
val groupName: String,
|
||||||
|
val students: List<CourseGroupStudent>,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CourseGroupStudent(
|
||||||
|
val id: String,
|
||||||
|
val studentNo: String,
|
||||||
|
val fullName: String,
|
||||||
|
val status: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package ru.ysu.lms.academic.course
|
||||||
|
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import ru.ysu.lms.academic.identity.IdentityClient
|
||||||
|
import ru.ysu.lms.academic.repo.CourseGroupRepository
|
||||||
|
import ru.ysu.lms.common.ForbiddenException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состав группы, привязанной к курсу. Доступ к курсу проверяется той же курсовой
|
||||||
|
* областью видимости, что и у остальных academic-эндпоинтов; сама группа должна быть
|
||||||
|
* активной привязкой курса.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class CourseRosterService(
|
||||||
|
private val accessService: CourseAccessService,
|
||||||
|
private val groups: CourseGroupRepository,
|
||||||
|
private val identity: IdentityClient,
|
||||||
|
) {
|
||||||
|
fun groupRoster(
|
||||||
|
courseId: UUID,
|
||||||
|
groupId: UUID,
|
||||||
|
jwt: Jwt,
|
||||||
|
): CourseGroupRoster {
|
||||||
|
accessService.requireCourseAccess(courseId, jwt)
|
||||||
|
groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(courseId, groupId)
|
||||||
|
?: throw ForbiddenException("Группа не привязана к курсу")
|
||||||
|
val roster = identity.getGroupRoster(groupId.toString())
|
||||||
|
return CourseGroupRoster(
|
||||||
|
groupId = roster.id,
|
||||||
|
groupName = roster.name,
|
||||||
|
students =
|
||||||
|
roster.students.map {
|
||||||
|
CourseGroupStudent(
|
||||||
|
id = it.id,
|
||||||
|
studentNo = it.studentNo,
|
||||||
|
fullName = it.fullName,
|
||||||
|
status = it.status,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,12 +15,13 @@ import java.time.Duration
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Клиент identity-service. Академическому сервису нужны только текущие группы пользователя,
|
* Клиент identity-service. Академическому сервису нужны текущие группы пользователя
|
||||||
* поэтому из ответа `/api/v1/groups/me` читается минимальный контракт `{id}`.
|
* и состав группы по сервисному токену.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
class IdentityClient(
|
class IdentityClient(
|
||||||
@Value("\${lms.identity.base-url:http://localhost:8081}") baseUrl: String,
|
@Value("\${lms.identity.base-url:http://localhost:8081}") baseUrl: String,
|
||||||
|
@Value("\${lms.internal.token:dev-internal-token}") private val internalToken: String,
|
||||||
) {
|
) {
|
||||||
private val client = restClient(baseUrl)
|
private val client = restClient(baseUrl)
|
||||||
|
|
||||||
@@ -40,6 +41,20 @@ class IdentityClient(
|
|||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
throw IdentityUnavailableException("Сервис идентификации вернул некорректный ответ").apply { initCause(e) }
|
throw IdentityUnavailableException("Сервис идентификации вернул некорректный ответ").apply { initCause(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Текущий состав группы через внутренний endpoint identity. */
|
||||||
|
fun getGroupRoster(groupId: String): GroupRosterInfo =
|
||||||
|
try {
|
||||||
|
client
|
||||||
|
.get()
|
||||||
|
.uri("/api/v1/internal/groups/{groupId}", groupId)
|
||||||
|
.header(INTERNAL_TOKEN_HEADER, internalToken)
|
||||||
|
.retrieve()
|
||||||
|
.body(GroupRosterInfo::class.java)
|
||||||
|
?: throw IdentityUnavailableException("Сервис идентификации вернул пустой ответ")
|
||||||
|
} catch (e: RestClientException) {
|
||||||
|
throw IdentityUnavailableException().apply { initCause(e) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,8 +70,26 @@ internal data class GroupRef(
|
|||||||
val id: String = "",
|
val id: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Минимальный контракт `GET /api/v1/internal/groups/{groupId}`. */
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class GroupRosterInfo(
|
||||||
|
val id: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
val students: List<GroupRosterStudent> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class GroupRosterStudent(
|
||||||
|
val id: String = "",
|
||||||
|
val studentNo: String = "",
|
||||||
|
val fullName: String = "",
|
||||||
|
val status: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
private val GROUP_LIST = object : ParameterizedTypeReference<List<GroupRef>>() {}
|
private val GROUP_LIST = object : ParameterizedTypeReference<List<GroupRef>>() {}
|
||||||
|
|
||||||
|
private const val INTERNAL_TOKEN_HEADER = "X-Internal-Token"
|
||||||
|
|
||||||
/** Таймауты обязательны: зависший downstream держит поток запроса и вешает сервис. */
|
/** Таймауты обязательны: зависший downstream держит поток запроса и вешает сервис. */
|
||||||
private const val CONNECT_TIMEOUT_SECONDS = 3L
|
private const val CONNECT_TIMEOUT_SECONDS = 3L
|
||||||
private const val READ_TIMEOUT_SECONDS = 30L
|
private const val READ_TIMEOUT_SECONDS = 30L
|
||||||
|
|||||||
@@ -45,3 +45,5 @@ lms:
|
|||||||
assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets}
|
assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets}
|
||||||
identity:
|
identity:
|
||||||
base-url: ${LMS_IDENTITY_URI:http://localhost:8081}
|
base-url: ${LMS_IDENTITY_URI:http://localhost:8081}
|
||||||
|
internal:
|
||||||
|
token: ${LMS_INTERNAL_TOKEN:dev-internal-token}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package ru.ysu.lms.academic.course
|
||||||
|
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||||
|
import org.assertj.core.api.Assertions.catchThrowable
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import ru.ysu.lms.academic.domain.Course
|
||||||
|
import ru.ysu.lms.academic.domain.CourseGroup
|
||||||
|
import ru.ysu.lms.academic.domain.CourseTeacher
|
||||||
|
import ru.ysu.lms.academic.identity.GroupRosterInfo
|
||||||
|
import ru.ysu.lms.academic.identity.GroupRosterStudent
|
||||||
|
import ru.ysu.lms.academic.identity.IdentityClient
|
||||||
|
import ru.ysu.lms.academic.identity.IdentityUnavailableException
|
||||||
|
import ru.ysu.lms.academic.repo.CourseGroupRepository
|
||||||
|
import ru.ysu.lms.academic.repo.CourseRepository
|
||||||
|
import ru.ysu.lms.academic.repo.CourseTeacherRepository
|
||||||
|
import ru.ysu.lms.academic.repo.LabRepository
|
||||||
|
import ru.ysu.lms.common.ForbiddenException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class CourseRosterServiceTest {
|
||||||
|
private val courses = mockk<CourseRepository>()
|
||||||
|
private val teachers = mockk<CourseTeacherRepository>()
|
||||||
|
private val groups = mockk<CourseGroupRepository>()
|
||||||
|
private val labs = mockk<LabRepository>()
|
||||||
|
private val identity = mockk<IdentityClient>()
|
||||||
|
private val courseService = mockk<CourseService>()
|
||||||
|
|
||||||
|
private val accessService = CourseAccessService(courses, teachers, groups, labs, identity, courseService)
|
||||||
|
private val service = CourseRosterService(accessService, groups, identity)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `teacher can load roster of an attached group`() {
|
||||||
|
val courseId = UUID.randomUUID()
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
assignTeacher(activeCourse(courseId), teacherId)
|
||||||
|
attachGroup(courseId, groupId)
|
||||||
|
every { identity.getGroupRoster(groupId.toString()) } returns roster(groupId)
|
||||||
|
|
||||||
|
val result = service.groupRoster(courseId, groupId, jwt("TEACHER", teacherId))
|
||||||
|
|
||||||
|
assertThat(result.groupId).isEqualTo(groupId.toString())
|
||||||
|
assertThat(result.groupName).isEqualTo("ПИ-101")
|
||||||
|
assertThat(result.students)
|
||||||
|
.containsExactly(CourseGroupStudent("student-1", "001", "Иванов Иван", "ACTIVE"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `student without course access is forbidden`() {
|
||||||
|
val courseId = UUID.randomUUID()
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
every { identity.currentGroupIds(TOKEN) } returns emptyList()
|
||||||
|
|
||||||
|
assertThatThrownBy { service.groupRoster(courseId, groupId, jwt("STUDENT")) }
|
||||||
|
.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
verify(exactly = 0) { groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(any(), any()) }
|
||||||
|
verify(exactly = 0) { identity.getGroupRoster(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `foreign teacher is forbidden`() {
|
||||||
|
val courseId = UUID.randomUUID()
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
assignTeacher(activeCourse(UUID.randomUUID()), teacherId)
|
||||||
|
|
||||||
|
assertThatThrownBy { service.groupRoster(courseId, groupId, jwt("TEACHER", teacherId)) }
|
||||||
|
.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
verify(exactly = 0) { groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(any(), any()) }
|
||||||
|
verify(exactly = 0) { identity.getGroupRoster(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `group not attached to the course is forbidden`() {
|
||||||
|
val courseId = UUID.randomUUID()
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
assignTeacher(activeCourse(courseId), teacherId)
|
||||||
|
every { groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(courseId, groupId) } returns null
|
||||||
|
|
||||||
|
assertThatThrownBy { service.groupRoster(courseId, groupId, jwt("TEACHER", teacherId)) }
|
||||||
|
.isInstanceOf(ForbiddenException::class.java)
|
||||||
|
.hasMessageContaining("не привязана")
|
||||||
|
verify(exactly = 0) { identity.getGroupRoster(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `admin can load roster of an attached group`() {
|
||||||
|
val courseId = UUID.randomUUID()
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
attachGroup(courseId, groupId)
|
||||||
|
every { identity.getGroupRoster(groupId.toString()) } returns roster(groupId)
|
||||||
|
|
||||||
|
assertThat(service.groupRoster(courseId, groupId, jwt("ADMIN")).students).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `identity outage is reported as bad gateway`() {
|
||||||
|
val courseId = UUID.randomUUID()
|
||||||
|
val groupId = UUID.randomUUID()
|
||||||
|
val teacherId = UUID.randomUUID()
|
||||||
|
assignTeacher(activeCourse(courseId), teacherId)
|
||||||
|
attachGroup(courseId, groupId)
|
||||||
|
every { identity.getGroupRoster(groupId.toString()) } throws IdentityUnavailableException()
|
||||||
|
|
||||||
|
val thrown = catchThrowable { service.groupRoster(courseId, groupId, jwt("TEACHER", teacherId)) }
|
||||||
|
|
||||||
|
assertThat(thrown).isInstanceOf(IdentityUnavailableException::class.java)
|
||||||
|
assertThat((thrown as IdentityUnavailableException).status).isEqualTo(HttpStatus.BAD_GATEWAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun activeCourse(id: UUID): Course = Course(id = id, title = "Курс", status = Course.STATUS_ACTIVE)
|
||||||
|
|
||||||
|
private fun assignTeacher(
|
||||||
|
course: Course,
|
||||||
|
teacherId: UUID,
|
||||||
|
) {
|
||||||
|
every { teachers.findByAccountIdAndRemovedAtIsNull(teacherId) } returns
|
||||||
|
listOf(CourseTeacher(course = course, accountId = teacherId))
|
||||||
|
every { courses.findAllById(any()) } returns listOf(course)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attachGroup(
|
||||||
|
courseId: UUID,
|
||||||
|
groupId: UUID,
|
||||||
|
) {
|
||||||
|
every { groups.findByCourseIdAndGroupIdAndRemovedAtIsNull(courseId, groupId) } returns
|
||||||
|
CourseGroup(course = Course(id = courseId), groupId = groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun roster(groupId: UUID): GroupRosterInfo =
|
||||||
|
GroupRosterInfo(
|
||||||
|
id = groupId.toString(),
|
||||||
|
name = "ПИ-101",
|
||||||
|
students = listOf(GroupRosterStudent("student-1", "001", "Иванов Иван", "ACTIVE")),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun jwt(
|
||||||
|
role: String,
|
||||||
|
subject: UUID = UUID.randomUUID(),
|
||||||
|
): Jwt =
|
||||||
|
Jwt
|
||||||
|
.withTokenValue(TOKEN)
|
||||||
|
.header("alg", "none")
|
||||||
|
.subject(subject.toString())
|
||||||
|
.claim("role", role)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TOKEN = "token"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user