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.CourseDetail
|
||||
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.CourseSummary
|
||||
import ru.ysu.lms.academic.course.CourseTeacherRequest
|
||||
@@ -32,6 +34,7 @@ class CourseController(
|
||||
private val courseService: CourseService,
|
||||
private val bootstrapService: CourseBootstrapService,
|
||||
private val accessService: CourseAccessService,
|
||||
private val rosterService: CourseRosterService,
|
||||
) {
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
@@ -44,6 +47,7 @@ class CourseController(
|
||||
): List<CourseSummary> = accessService.myCourses(jwt)
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
fun create(
|
||||
@RequestBody body: CourseCreateRequest,
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@@ -58,6 +62,15 @@ class CourseController(
|
||||
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}")
|
||||
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
|
||||
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
|
||||
|
||||
/**
|
||||
* Клиент identity-service. Академическому сервису нужны только текущие группы пользователя,
|
||||
* поэтому из ответа `/api/v1/groups/me` читается минимальный контракт `{id}`.
|
||||
* Клиент identity-service. Академическому сервису нужны текущие группы пользователя
|
||||
* и состав группы по сервисному токену.
|
||||
*/
|
||||
@Service
|
||||
class IdentityClient(
|
||||
@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)
|
||||
|
||||
@@ -40,6 +41,20 @@ class IdentityClient(
|
||||
} catch (e: IllegalArgumentException) {
|
||||
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 = "",
|
||||
)
|
||||
|
||||
/** Минимальный контракт `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 const val INTERNAL_TOKEN_HEADER = "X-Internal-Token"
|
||||
|
||||
/** Таймауты обязательны: зависший downstream держит поток запроса и вешает сервис. */
|
||||
private const val CONNECT_TIMEOUT_SECONDS = 3L
|
||||
private const val READ_TIMEOUT_SECONDS = 30L
|
||||
|
||||
@@ -45,3 +45,5 @@ lms:
|
||||
assets-dir: ${LMS_CONTENT_ASSETS_DIR:data/content-assets}
|
||||
identity:
|
||||
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