feat(academic): состав групп курса для преподавателя

This commit is contained in:
2026-10-27 17:45:00 +03:00
parent c11a308ff8
commit a970fe62c4
6 changed files with 267 additions and 2 deletions

View File

@@ -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(

View File

@@ -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,
)

View File

@@ -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,
)
},
)
}
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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"
}
}