Build: enable ktlint+detekt, format sources, add core unit tests
This commit is contained in:
@@ -18,23 +18,28 @@ class AuthController(
|
||||
private val authService: AuthService,
|
||||
private val rsaKey: RSAKey,
|
||||
) {
|
||||
|
||||
@GetMapping("/health")
|
||||
fun health(): Map<String, Any> = mapOf(
|
||||
"ok" to true,
|
||||
"service" to "identity-service",
|
||||
"adminExists" to authService.adminExists(),
|
||||
)
|
||||
fun health(): Map<String, Any> =
|
||||
mapOf(
|
||||
"ok" to true,
|
||||
"service" to "identity-service",
|
||||
"adminExists" to authService.adminExists(),
|
||||
)
|
||||
|
||||
@PostMapping("/setup")
|
||||
fun setup(@Valid @RequestBody body: SetupRequest): AuthResponse = authService.setup(body)
|
||||
fun setup(
|
||||
@Valid @RequestBody body: SetupRequest,
|
||||
): AuthResponse = authService.setup(body)
|
||||
|
||||
@PostMapping("/login")
|
||||
fun login(@Valid @RequestBody body: LoginRequest): AuthResponse = authService.login(body)
|
||||
fun login(
|
||||
@Valid @RequestBody body: LoginRequest,
|
||||
): AuthResponse = authService.login(body)
|
||||
|
||||
@GetMapping("/me")
|
||||
fun me(@AuthenticationPrincipal jwt: Jwt): MeResponse =
|
||||
authService.me(UUID.fromString(jwt.subject))
|
||||
fun me(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): MeResponse = authService.me(UUID.fromString(jwt.subject))
|
||||
|
||||
@PostMapping("/change-password")
|
||||
fun changePassword(
|
||||
|
||||
@@ -28,7 +28,6 @@ class AuthService(
|
||||
private val jwtEncoder: JwtEncoder,
|
||||
private val jwtProperties: JwtProperties,
|
||||
) {
|
||||
|
||||
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
|
||||
|
||||
@Transactional
|
||||
@@ -41,20 +40,22 @@ class AuthService(
|
||||
throw BadRequestException("Логин: минимум 3 символа")
|
||||
}
|
||||
validatePassword(request.password, request.password2)
|
||||
val account = Account(
|
||||
login = login,
|
||||
passwordHash = passwordEncoder.encode(request.password) ?: "",
|
||||
role = LmsRole.ADMIN,
|
||||
mustChangePassword = false,
|
||||
)
|
||||
val account =
|
||||
Account(
|
||||
login = login,
|
||||
passwordHash = passwordEncoder.encode(request.password) ?: "",
|
||||
role = LmsRole.ADMIN,
|
||||
mustChangePassword = false,
|
||||
)
|
||||
accounts.save(account)
|
||||
return toResponse(account, account.login)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun login(request: LoginRequest): AuthResponse {
|
||||
val account = accounts.findByLogin(request.login.trim())
|
||||
?: throw UnauthorizedException("Неверный логин или пароль")
|
||||
val account =
|
||||
accounts.findByLogin(request.login.trim())
|
||||
?: throw UnauthorizedException("Неверный логин или пароль")
|
||||
if (!passwordEncoder.matches(request.password, account.passwordHash)) {
|
||||
throw UnauthorizedException("Неверный логин или пароль")
|
||||
}
|
||||
@@ -63,8 +64,10 @@ class AuthService(
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun me(accountId: UUID): MeResponse {
|
||||
val account = accounts.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
val account =
|
||||
accounts
|
||||
.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
return MeResponse(
|
||||
id = account.id.toString(),
|
||||
login = account.login,
|
||||
@@ -75,9 +78,14 @@ class AuthService(
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun changePassword(accountId: UUID, request: ChangePasswordRequest) {
|
||||
val account = accounts.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
fun changePassword(
|
||||
accountId: UUID,
|
||||
request: ChangePasswordRequest,
|
||||
) {
|
||||
val account =
|
||||
accounts
|
||||
.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
if (!passwordEncoder.matches(request.old, account.passwordHash)) {
|
||||
throw UnauthorizedException("Текущий пароль неверный")
|
||||
}
|
||||
@@ -89,7 +97,10 @@ class AuthService(
|
||||
accounts.save(account)
|
||||
}
|
||||
|
||||
private fun validatePassword(password: String, confirmation: String) {
|
||||
private fun validatePassword(
|
||||
password: String,
|
||||
confirmation: String,
|
||||
) {
|
||||
if (password.length < 8) {
|
||||
throw BadRequestException("Пароль: минимум 8 символов")
|
||||
}
|
||||
@@ -100,33 +111,43 @@ class AuthService(
|
||||
|
||||
private fun displayName(account: Account): String =
|
||||
if (account.role == LmsRole.STUDENT) {
|
||||
students.findById(account.id)
|
||||
students
|
||||
.findById(account.id)
|
||||
.map { it.fullName.ifBlank { account.login } }
|
||||
.orElse(account.login)
|
||||
} else {
|
||||
account.login
|
||||
}
|
||||
|
||||
private fun toResponse(account: Account, name: String): AuthResponse = AuthResponse(
|
||||
token = issueToken(account, name),
|
||||
id = account.id.toString(),
|
||||
login = account.login,
|
||||
role = account.role.name,
|
||||
name = name,
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
)
|
||||
private fun toResponse(
|
||||
account: Account,
|
||||
name: String,
|
||||
): AuthResponse =
|
||||
AuthResponse(
|
||||
token = issueToken(account, name),
|
||||
id = account.id.toString(),
|
||||
login = account.login,
|
||||
role = account.role.name,
|
||||
name = name,
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
)
|
||||
|
||||
private fun issueToken(account: Account, name: String): String {
|
||||
private fun issueToken(
|
||||
account: Account,
|
||||
name: String,
|
||||
): String {
|
||||
val now = Instant.now()
|
||||
val claims = JwtClaimsSet.builder()
|
||||
.issuer(jwtProperties.issuer)
|
||||
.issuedAt(now)
|
||||
.expiresAt(now.plus(jwtProperties.accessTokenTtl))
|
||||
.subject(account.id.toString())
|
||||
.claim("role", account.role.name)
|
||||
.claim("login", account.login)
|
||||
.claim("name", name)
|
||||
.build()
|
||||
val claims =
|
||||
JwtClaimsSet
|
||||
.builder()
|
||||
.issuer(jwtProperties.issuer)
|
||||
.issuedAt(now)
|
||||
.expiresAt(now.plus(jwtProperties.accessTokenTtl))
|
||||
.subject(account.id.toString())
|
||||
.claim("role", account.role.name)
|
||||
.claim("login", account.login)
|
||||
.claim("name", name)
|
||||
.build()
|
||||
val header = JwsHeader.with(SignatureAlgorithm.RS256).build()
|
||||
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
|
||||
}
|
||||
|
||||
@@ -16,26 +16,19 @@ import java.util.UUID
|
||||
class Account(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
|
||||
@Column(nullable = false, unique = true, length = 64)
|
||||
var login: String = "",
|
||||
|
||||
@Column(name = "password_hash", nullable = false)
|
||||
var passwordHash: String = "",
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
var role: LmsRole = LmsRole.STUDENT,
|
||||
|
||||
@Column(name = "must_change_password", nullable = false)
|
||||
var mustChangePassword: Boolean = true,
|
||||
|
||||
@Column(name = "temp_password", length = 64)
|
||||
var tempPassword: String? = null,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: Instant = Instant.now(),
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
var updatedAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
@@ -16,15 +16,12 @@ import java.util.UUID
|
||||
class Enrollment(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "group_id", nullable = false)
|
||||
var group: GroupEntity? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "student_id", nullable = false)
|
||||
var student: Student? = null,
|
||||
|
||||
@Column(name = "enrolled_at", nullable = false)
|
||||
var enrolledAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
@@ -13,13 +13,10 @@ import java.util.UUID
|
||||
class GroupEntity(
|
||||
@Id
|
||||
var id: UUID = Ids.v7(),
|
||||
|
||||
@Column(nullable = false, unique = true, length = 100)
|
||||
var name: String = "",
|
||||
|
||||
@Column(name = "task_duration_min", nullable = false)
|
||||
var taskDurationMin: Int = 30,
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
var createdAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ class Setting(
|
||||
@Id
|
||||
@Column(name = "key", length = 100)
|
||||
var key: String = "",
|
||||
|
||||
@Column(name = "value", nullable = false)
|
||||
var value: String = "",
|
||||
)
|
||||
|
||||
@@ -16,32 +16,26 @@ class Student(
|
||||
@Id
|
||||
@Column(name = "account_id")
|
||||
var accountId: UUID? = null,
|
||||
|
||||
@Column(name = "student_no", nullable = false, unique = true, length = 32)
|
||||
var studentNo: String = "",
|
||||
|
||||
@Column(name = "last_name", nullable = false, length = 100)
|
||||
var lastName: String = "",
|
||||
|
||||
@Column(name = "first_name", nullable = false, length = 100)
|
||||
var firstName: String = "",
|
||||
|
||||
@Column(name = "middle_name", nullable = false, length = 100)
|
||||
var middleName: String = "",
|
||||
|
||||
@Column(name = "basis", nullable = false, length = 100)
|
||||
var basis: String = "",
|
||||
|
||||
@Column(name = "note", nullable = false)
|
||||
var note: String = "",
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@MapsId
|
||||
@JoinColumn(name = "account_id")
|
||||
var account: Account? = null,
|
||||
) {
|
||||
val fullName: String
|
||||
get() = listOf(lastName, firstName, middleName)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
get() =
|
||||
listOf(lastName, firstName, middleName)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import java.util.UUID
|
||||
|
||||
interface AccountRepository : JpaRepository<Account, UUID> {
|
||||
fun findByLogin(login: String): Account?
|
||||
|
||||
fun existsByLogin(login: String): Boolean
|
||||
|
||||
fun existsByRole(role: LmsRole): Boolean
|
||||
|
||||
fun countByRole(role: LmsRole): Long
|
||||
}
|
||||
|
||||
@@ -7,16 +7,18 @@ import ru.ysu.lms.identity.domain.Enrollment
|
||||
import java.util.UUID
|
||||
|
||||
interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
|
||||
|
||||
@Query(
|
||||
"select e from Enrollment e join fetch e.student s join fetch s.account where e.group.id = :groupId",
|
||||
)
|
||||
fun findAllByGroupId(@Param("groupId") groupId: UUID): List<Enrollment>
|
||||
fun findAllByGroupId(
|
||||
@Param("groupId") groupId: UUID,
|
||||
): List<Enrollment>
|
||||
|
||||
fun findByStudent_AccountId(accountId: UUID): Enrollment?
|
||||
fun findFirstByStudent_AccountId(accountId: UUID): Enrollment?
|
||||
fun findByStudentAccountId(accountId: UUID): Enrollment?
|
||||
|
||||
fun deleteByStudent_AccountId(accountId: UUID)
|
||||
fun findFirstByStudentAccountId(accountId: UUID): Enrollment?
|
||||
|
||||
fun countByGroup_Id(groupId: UUID): Long
|
||||
fun deleteByStudentAccountId(accountId: UUID)
|
||||
|
||||
fun countByGroupId(groupId: UUID): Long
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import java.util.UUID
|
||||
|
||||
interface GroupRepository : JpaRepository<GroupEntity, UUID> {
|
||||
fun findByName(name: String): GroupEntity?
|
||||
|
||||
fun existsByName(name: String): Boolean
|
||||
|
||||
fun findAllByOrderByNameAsc(): List<GroupEntity>
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@ import java.util.UUID
|
||||
|
||||
interface StudentRepository : JpaRepository<Student, UUID> {
|
||||
fun findByStudentNo(studentNo: String): Student?
|
||||
|
||||
fun existsByStudentNo(studentNo: String): Boolean
|
||||
}
|
||||
|
||||
@@ -4,21 +4,21 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
object CredentialsExporter {
|
||||
|
||||
private val HEADERS = listOf("ФИО", "Логин", "Пароль", "Статус")
|
||||
|
||||
fun csv(rows: List<StudentRow>): ByteArray {
|
||||
val builder = StringBuilder()
|
||||
builder.append(HEADERS.joinToString(";")).append("\r\n")
|
||||
rows.forEach { row ->
|
||||
builder.append(
|
||||
listOf(
|
||||
row.fullName,
|
||||
row.studentNo,
|
||||
row.tempPassword ?: "—",
|
||||
status(row),
|
||||
).joinToString(";") { escapeCsv(it) },
|
||||
).append("\r\n")
|
||||
builder
|
||||
.append(
|
||||
listOf(
|
||||
row.fullName,
|
||||
row.studentNo,
|
||||
row.tempPassword ?: "—",
|
||||
status(row),
|
||||
).joinToString(";") { escapeCsv(it) },
|
||||
).append("\r\n")
|
||||
}
|
||||
return ("\uFEFF" + builder).toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
@@ -41,8 +41,7 @@ object CredentialsExporter {
|
||||
}
|
||||
}
|
||||
|
||||
private fun status(row: StudentRow): String =
|
||||
if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
|
||||
private fun status(row: StudentRow): String = if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
|
||||
|
||||
private fun escapeCsv(value: String): String =
|
||||
if (value.contains(';') || value.contains('"') || value.contains('\n')) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
@@ -17,20 +19,21 @@ import org.springframework.web.multipart.MultipartFile
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/groups")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class GroupController(private val groupService: GroupService) {
|
||||
|
||||
class GroupController(
|
||||
private val groupService: GroupService,
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(): List<GroupSummary> = groupService.list()
|
||||
|
||||
@PostMapping
|
||||
fun create(@Valid @RequestBody body: CreateGroupRequest): GroupSummary = groupService.create(body)
|
||||
fun create(
|
||||
@Valid @RequestBody body: CreateGroupRequest,
|
||||
): GroupSummary = groupService.create(body)
|
||||
|
||||
@PostMapping("/{id}/students")
|
||||
fun createStudent(
|
||||
@@ -51,25 +54,34 @@ class GroupController(private val groupService: GroupService) {
|
||||
|
||||
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
|
||||
@GetMapping("/{id}/candidates")
|
||||
fun candidates(@PathVariable id: UUID): List<StudentCandidate> = groupService.candidates(id)
|
||||
fun candidates(
|
||||
@PathVariable id: UUID,
|
||||
): List<StudentCandidate> = groupService.candidates(id)
|
||||
|
||||
/** Зачислить существующего студента. */
|
||||
@PostMapping("/{id}/students/{studentId}/enroll")
|
||||
fun enroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
|
||||
groupService.enroll(id, studentId)
|
||||
fun enroll(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable studentId: UUID,
|
||||
): GroupDetail = groupService.enroll(id, studentId)
|
||||
|
||||
/** Отчислить студента из группы. */
|
||||
@DeleteMapping("/{id}/students/{studentId}")
|
||||
fun unenroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
|
||||
groupService.unenroll(id, studentId)
|
||||
fun unenroll(
|
||||
@PathVariable id: UUID,
|
||||
@PathVariable studentId: UUID,
|
||||
): GroupDetail = groupService.unenroll(id, studentId)
|
||||
|
||||
@GetMapping("/me")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
fun myGroup(@AuthenticationPrincipal jwt: Jwt): List<GroupSummary> =
|
||||
groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
|
||||
fun myGroup(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
): List<GroupSummary> = groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id)
|
||||
fun detail(
|
||||
@PathVariable id: UUID,
|
||||
): GroupDetail = groupService.detail(id)
|
||||
|
||||
@GetMapping("/{id}/credentials")
|
||||
fun credentials(
|
||||
@@ -78,7 +90,8 @@ class GroupController(private val groupService: GroupService) {
|
||||
): ResponseEntity<ByteArray> {
|
||||
val export = groupService.credentials(id, format)
|
||||
val encodedName = URLEncoder.encode(export.filename, StandardCharsets.UTF_8).replace("+", "%20")
|
||||
return ResponseEntity.ok()
|
||||
return ResponseEntity
|
||||
.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedName")
|
||||
.contentType(MediaType.parseMediaType(export.contentType))
|
||||
.body(export.bytes)
|
||||
|
||||
@@ -27,12 +27,14 @@ class GroupService(
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val parser: StudentImportParser,
|
||||
) {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
|
||||
|
||||
@Transactional
|
||||
fun createStudent(groupId: UUID, request: CreateStudentRequest): Credential {
|
||||
fun createStudent(
|
||||
groupId: UUID,
|
||||
request: CreateStudentRequest,
|
||||
): Credential {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val studentNo = request.studentNo.trim()
|
||||
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
|
||||
@@ -40,27 +42,29 @@ class GroupService(
|
||||
throw ConflictException("Студент с номером $studentNo уже существует")
|
||||
}
|
||||
val password = Passwords.generate()
|
||||
val account = accounts.save(
|
||||
Account(
|
||||
login = studentNo,
|
||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||
role = LmsRole.STUDENT,
|
||||
mustChangePassword = true,
|
||||
tempPassword = password,
|
||||
),
|
||||
)
|
||||
val account =
|
||||
accounts.save(
|
||||
Account(
|
||||
login = studentNo,
|
||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||
role = LmsRole.STUDENT,
|
||||
mustChangePassword = true,
|
||||
tempPassword = password,
|
||||
),
|
||||
)
|
||||
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
|
||||
val student = students.save(
|
||||
Student(
|
||||
studentNo = studentNo,
|
||||
lastName = request.lastName.trim(),
|
||||
firstName = request.firstName.trim(),
|
||||
middleName = request.middleName.trim(),
|
||||
basis = request.basis.trim(),
|
||||
note = request.note.trim(),
|
||||
account = account,
|
||||
),
|
||||
)
|
||||
val student =
|
||||
students.save(
|
||||
Student(
|
||||
studentNo = studentNo,
|
||||
lastName = request.lastName.trim(),
|
||||
firstName = request.firstName.trim(),
|
||||
middleName = request.middleName.trim(),
|
||||
basis = request.basis.trim(),
|
||||
note = request.note.trim(),
|
||||
account = account,
|
||||
),
|
||||
)
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
return Credential(
|
||||
id = account.id.toString(),
|
||||
@@ -91,7 +95,8 @@ class GroupService(
|
||||
val name = e.group?.name ?: continue
|
||||
currentByStudent[sid] = gid to name
|
||||
}
|
||||
return students.findAll()
|
||||
return students
|
||||
.findAll()
|
||||
.filter { it.accountId !in memberIds }
|
||||
.map { s ->
|
||||
val current = s.accountId?.let { currentByStudent[it] }
|
||||
@@ -102,16 +107,18 @@ class GroupService(
|
||||
currentGroupId = current?.first?.toString(),
|
||||
currentGroupName = current?.second,
|
||||
)
|
||||
}
|
||||
.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
|
||||
}.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
|
||||
}
|
||||
|
||||
/** Зачислить существующего студента; если он в другой группе — переводим. */
|
||||
@Transactional
|
||||
fun enroll(groupId: UUID, studentId: UUID): GroupDetail {
|
||||
fun enroll(
|
||||
groupId: UUID,
|
||||
studentId: UUID,
|
||||
): GroupDetail {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
|
||||
val existing = enrollments.findByStudent_AccountId(studentId)
|
||||
val existing = enrollments.findByStudentAccountId(studentId)
|
||||
if (existing != null) {
|
||||
if (existing.group?.id == group.id) {
|
||||
throw ConflictException("Студент уже в этой группе")
|
||||
@@ -127,15 +134,23 @@ class GroupService(
|
||||
|
||||
/** Отчислить из группы (студент и его аккаунт сохраняются). */
|
||||
@Transactional
|
||||
fun unenroll(groupId: UUID, studentId: UUID): GroupDetail {
|
||||
val enrollment = enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
||||
?: throw NotFoundException("Студент не входит в эту группу")
|
||||
fun unenroll(
|
||||
groupId: UUID,
|
||||
studentId: UUID,
|
||||
): GroupDetail {
|
||||
val enrollment =
|
||||
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
|
||||
?: throw NotFoundException("Студент не входит в эту группу")
|
||||
enrollments.delete(enrollment)
|
||||
return detail(groupId)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun import(requestedName: String?, filename: String, bytes: ByteArray): ImportResult {
|
||||
fun import(
|
||||
requestedName: String?,
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
): ImportResult {
|
||||
val parsed = parser.parse(filename, bytes)
|
||||
val groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName
|
||||
val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName))
|
||||
@@ -144,49 +159,52 @@ class GroupService(
|
||||
|
||||
parsed.students.forEach { parsedStudent ->
|
||||
val existing = students.findByStudentNo(parsedStudent.studentNo)
|
||||
val student = if (existing != null) {
|
||||
existing.lastName = parsedStudent.lastName
|
||||
existing.firstName = parsedStudent.firstName
|
||||
existing.middleName = parsedStudent.middleName
|
||||
existing.basis = parsedStudent.basis
|
||||
existing.note = parsedStudent.note
|
||||
students.save(existing)
|
||||
updated.add(parsedStudent.studentNo)
|
||||
existing
|
||||
} else {
|
||||
val password = Passwords.generate()
|
||||
val account = accounts.save(
|
||||
Account(
|
||||
login = parsedStudent.studentNo,
|
||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||
role = LmsRole.STUDENT,
|
||||
mustChangePassword = true,
|
||||
tempPassword = password,
|
||||
),
|
||||
)
|
||||
val created = Student(
|
||||
studentNo = parsedStudent.studentNo,
|
||||
lastName = parsedStudent.lastName,
|
||||
firstName = parsedStudent.firstName,
|
||||
middleName = parsedStudent.middleName,
|
||||
basis = parsedStudent.basis,
|
||||
note = parsedStudent.note,
|
||||
)
|
||||
created.account = account
|
||||
students.save(created)
|
||||
added.add(
|
||||
Credential(
|
||||
id = account.id.toString(),
|
||||
studentNo = parsedStudent.studentNo,
|
||||
fullName = parsedStudent.fullName,
|
||||
password = password,
|
||||
),
|
||||
)
|
||||
created
|
||||
}
|
||||
val student =
|
||||
if (existing != null) {
|
||||
existing.lastName = parsedStudent.lastName
|
||||
existing.firstName = parsedStudent.firstName
|
||||
existing.middleName = parsedStudent.middleName
|
||||
existing.basis = parsedStudent.basis
|
||||
existing.note = parsedStudent.note
|
||||
students.save(existing)
|
||||
updated.add(parsedStudent.studentNo)
|
||||
existing
|
||||
} else {
|
||||
val password = Passwords.generate()
|
||||
val account =
|
||||
accounts.save(
|
||||
Account(
|
||||
login = parsedStudent.studentNo,
|
||||
passwordHash = passwordEncoder.encode(password) ?: "",
|
||||
role = LmsRole.STUDENT,
|
||||
mustChangePassword = true,
|
||||
tempPassword = password,
|
||||
),
|
||||
)
|
||||
val created =
|
||||
Student(
|
||||
studentNo = parsedStudent.studentNo,
|
||||
lastName = parsedStudent.lastName,
|
||||
firstName = parsedStudent.firstName,
|
||||
middleName = parsedStudent.middleName,
|
||||
basis = parsedStudent.basis,
|
||||
note = parsedStudent.note,
|
||||
)
|
||||
created.account = account
|
||||
students.save(created)
|
||||
added.add(
|
||||
Credential(
|
||||
id = account.id.toString(),
|
||||
studentNo = parsedStudent.studentNo,
|
||||
fullName = parsedStudent.fullName,
|
||||
password = password,
|
||||
),
|
||||
)
|
||||
created
|
||||
}
|
||||
|
||||
val accountId = student.accountId!!
|
||||
val enrollment = enrollments.findByStudent_AccountId(accountId)
|
||||
val enrollment = enrollments.findByStudentAccountId(accountId)
|
||||
if (enrollment == null) {
|
||||
enrollments.save(Enrollment(group = group, student = student))
|
||||
} else {
|
||||
@@ -199,7 +217,7 @@ class GroupService(
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun groupOfStudent(accountId: UUID): GroupSummary? {
|
||||
val group = enrollments.findByStudent_AccountId(accountId)?.group ?: return null
|
||||
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
|
||||
return GroupSummary(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
@@ -211,25 +229,30 @@ class GroupService(
|
||||
@Transactional(readOnly = true)
|
||||
fun detail(groupId: UUID): GroupDetail {
|
||||
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
|
||||
val rows = enrollments.findAllByGroupId(groupId).map { enrollment ->
|
||||
val student = enrollment.student!!
|
||||
val account = student.account!!
|
||||
StudentRow(
|
||||
id = account.id.toString(),
|
||||
studentNo = student.studentNo,
|
||||
fullName = student.fullName,
|
||||
basis = student.basis,
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
tempPassword = account.tempPassword,
|
||||
)
|
||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
||||
val rows =
|
||||
enrollments
|
||||
.findAllByGroupId(groupId)
|
||||
.map { enrollment ->
|
||||
val student = enrollment.student!!
|
||||
val account = student.account!!
|
||||
StudentRow(
|
||||
id = account.id.toString(),
|
||||
studentNo = student.studentNo,
|
||||
fullName = student.fullName,
|
||||
basis = student.basis,
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
tempPassword = account.tempPassword,
|
||||
)
|
||||
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
|
||||
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
|
||||
val student = students.findById(studentAccountId)
|
||||
.orElseThrow { NotFoundException("Студент не найден") }
|
||||
val student =
|
||||
students
|
||||
.findById(studentAccountId)
|
||||
.orElseThrow { NotFoundException("Студент не найден") }
|
||||
val account = student.account ?: throw NotFoundException("Аккаунт студента не найден")
|
||||
val password = Passwords.generate()
|
||||
account.passwordHash = passwordEncoder.encode(password) ?: account.passwordHash
|
||||
@@ -240,28 +263,34 @@ class GroupService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun credentials(groupId: UUID, format: String): ExportFile {
|
||||
fun credentials(
|
||||
groupId: UUID,
|
||||
format: String,
|
||||
): ExportFile {
|
||||
val detail = detail(groupId)
|
||||
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
|
||||
return when (format.lowercase()) {
|
||||
"csv" -> ExportFile(
|
||||
bytes = CredentialsExporter.csv(detail.students),
|
||||
filename = "credentials_$safeName.csv",
|
||||
contentType = "text/csv; charset=utf-8",
|
||||
)
|
||||
"xlsx" -> ExportFile(
|
||||
bytes = CredentialsExporter.xlsx(detail.students),
|
||||
filename = "credentials_$safeName.xlsx",
|
||||
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
"csv" ->
|
||||
ExportFile(
|
||||
bytes = CredentialsExporter.csv(detail.students),
|
||||
filename = "credentials_$safeName.csv",
|
||||
contentType = "text/csv; charset=utf-8",
|
||||
)
|
||||
"xlsx" ->
|
||||
ExportFile(
|
||||
bytes = CredentialsExporter.xlsx(detail.students),
|
||||
filename = "credentials_$safeName.xlsx",
|
||||
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
else -> throw BadRequestException("Формат должен быть xlsx или csv")
|
||||
}
|
||||
}
|
||||
|
||||
private fun summary(group: GroupEntity): GroupSummary = GroupSummary(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
studentCount = enrollments.countByGroup_Id(group.id),
|
||||
taskDurationMin = group.taskDurationMin,
|
||||
)
|
||||
private fun summary(group: GroupEntity): GroupSummary =
|
||||
GroupSummary(
|
||||
id = group.id.toString(),
|
||||
name = group.name,
|
||||
studentCount = enrollments.countByGroupId(group.id),
|
||||
taskDurationMin = group.taskDurationMin,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import java.util.UUID
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/students")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class StudentController(private val groupService: GroupService) {
|
||||
|
||||
class StudentController(
|
||||
private val groupService: GroupService,
|
||||
) {
|
||||
@PostMapping("/{id}/reset-password")
|
||||
fun resetPassword(@PathVariable id: UUID): ResetPasswordResponse =
|
||||
groupService.resetPassword(id)
|
||||
fun resetPassword(
|
||||
@PathVariable id: UUID,
|
||||
): ResetPasswordResponse = groupService.resetPassword(id)
|
||||
}
|
||||
|
||||
@@ -25,8 +25,10 @@ data class ParsedGroup(
|
||||
|
||||
@Component
|
||||
class StudentImportParser {
|
||||
|
||||
fun parse(filename: String, bytes: ByteArray): ParsedGroup {
|
||||
fun parse(
|
||||
filename: String,
|
||||
bytes: ByteArray,
|
||||
): ParsedGroup {
|
||||
val lower = filename.lowercase()
|
||||
return when {
|
||||
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
|
||||
@@ -65,7 +67,10 @@ class StudentImportParser {
|
||||
return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';'
|
||||
}
|
||||
|
||||
private fun splitCsvLine(line: String, delimiter: Char): List<String> {
|
||||
private fun splitCsvLine(
|
||||
line: String,
|
||||
delimiter: Char,
|
||||
): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var inQuotes = false
|
||||
@@ -83,7 +88,10 @@ class StudentImportParser {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseRows(filename: String, rows: List<List<String>>): ParsedGroup {
|
||||
private fun parseRows(
|
||||
filename: String,
|
||||
rows: List<List<String>>,
|
||||
): ParsedGroup {
|
||||
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
|
||||
if (headerIndex < 0) {
|
||||
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
|
||||
@@ -92,25 +100,26 @@ class StudentImportParser {
|
||||
if (columns["studentNo"] == null) {
|
||||
throw BadRequestException("Не найден столбец «Студенческий номер»")
|
||||
}
|
||||
val students = rows.drop(headerIndex + 1).mapNotNull { row ->
|
||||
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
|
||||
val studentNo = normalizeNumber(cell("studentNo"))
|
||||
val lastName = cell("lastName")
|
||||
if (studentNo.isBlank() && lastName.isBlank()) {
|
||||
return@mapNotNull null
|
||||
val students =
|
||||
rows.drop(headerIndex + 1).mapNotNull { row ->
|
||||
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
|
||||
val studentNo = normalizeNumber(cell("studentNo"))
|
||||
val lastName = cell("lastName")
|
||||
if (studentNo.isBlank() && lastName.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (studentNo.isBlank()) {
|
||||
throw BadRequestException("В одной из строк отсутствует студенческий номер")
|
||||
}
|
||||
ParsedStudent(
|
||||
studentNo = studentNo,
|
||||
lastName = lastName,
|
||||
firstName = cell("firstName"),
|
||||
middleName = cell("middleName"),
|
||||
basis = cell("basis"),
|
||||
note = cell("note"),
|
||||
)
|
||||
}
|
||||
if (studentNo.isBlank()) {
|
||||
throw BadRequestException("В одной из строк отсутствует студенческий номер")
|
||||
}
|
||||
ParsedStudent(
|
||||
studentNo = studentNo,
|
||||
lastName = lastName,
|
||||
firstName = cell("firstName"),
|
||||
middleName = cell("middleName"),
|
||||
basis = cell("basis"),
|
||||
note = cell("note"),
|
||||
)
|
||||
}
|
||||
if (students.isEmpty()) {
|
||||
throw BadRequestException("В таблице не найдено ни одного студента")
|
||||
}
|
||||
@@ -119,15 +128,21 @@ class StudentImportParser {
|
||||
|
||||
private fun isStudentHeader(value: String): Boolean {
|
||||
val normalized = value.lowercase().replace("\u00a0", " ").trim()
|
||||
return normalized.startsWith("студен") || normalized.startsWith("студ.") ||
|
||||
normalized.startsWith("зачёт") || normalized.startsWith("зачет")
|
||||
return normalized.startsWith("студен") ||
|
||||
normalized.startsWith("студ.") ||
|
||||
normalized.startsWith("зачёт") ||
|
||||
normalized.startsWith("зачет")
|
||||
}
|
||||
|
||||
private fun mapColumns(header: List<String>): Map<String, Int> {
|
||||
val result = mutableMapOf<String, Int>()
|
||||
header.forEachIndexed { index, raw ->
|
||||
val value = raw.lowercase().replace("\u00a0", " ").trim()
|
||||
fun putIfAbsent(key: String, vararg prefixes: String) {
|
||||
|
||||
fun putIfAbsent(
|
||||
key: String,
|
||||
vararg prefixes: String,
|
||||
) {
|
||||
if (key !in result && prefixes.any { value.startsWith(it) }) {
|
||||
result[key] = index
|
||||
}
|
||||
@@ -142,10 +157,12 @@ class StudentImportParser {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun normalizeNumber(value: String): String =
|
||||
if (value.endsWith(".0")) value.dropLast(2) else value
|
||||
private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
|
||||
|
||||
private fun groupNameFrom(filename: String, rows: List<List<String>>): String {
|
||||
private fun groupNameFrom(
|
||||
filename: String,
|
||||
rows: List<List<String>>,
|
||||
): String {
|
||||
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
|
||||
for (row in rows.take(12)) {
|
||||
for (cell in row) {
|
||||
|
||||
@@ -17,17 +17,19 @@ import ru.ysu.lms.security.JwtProperties
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(JwtProperties::class)
|
||||
class JwtConfig(private val keyStore: JwtKeyStore) {
|
||||
class JwtConfig(
|
||||
private val keyStore: JwtKeyStore,
|
||||
) {
|
||||
@Bean
|
||||
fun rsaKey(): RSAKey =
|
||||
RSAKey
|
||||
.Builder(keyStore.publicKey)
|
||||
.privateKey(keyStore.privateKey)
|
||||
.keyID("lms")
|
||||
.build()
|
||||
|
||||
@Bean
|
||||
fun rsaKey(): RSAKey = RSAKey.Builder(keyStore.publicKey)
|
||||
.privateKey(keyStore.privateKey)
|
||||
.keyID("lms")
|
||||
.build()
|
||||
|
||||
@Bean
|
||||
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder =
|
||||
NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
|
||||
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder = NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withPublicKey(keyStore.publicKey).build()
|
||||
|
||||
@@ -15,8 +15,9 @@ import java.util.Base64
|
||||
* in the `settings` table, so no private key ever lives in the repository.
|
||||
*/
|
||||
@Service
|
||||
class JwtKeyStore(private val settings: SettingRepository) {
|
||||
|
||||
class JwtKeyStore(
|
||||
private val settings: SettingRepository,
|
||||
) {
|
||||
lateinit var privateKey: RSAPrivateKey
|
||||
|
||||
lateinit var publicKey: RSAPublicKey
|
||||
@@ -38,7 +39,10 @@ class JwtKeyStore(private val settings: SettingRepository) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun toPem(label: String, der: ByteArray): String {
|
||||
private fun toPem(
|
||||
label: String,
|
||||
der: ByteArray,
|
||||
): String {
|
||||
val encoded = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(der)
|
||||
return "-----BEGIN $label-----\n$encoded\n-----END $label-----\n"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import org.springframework.security.web.SecurityFilterChain
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http
|
||||
@@ -26,19 +25,19 @@ class SecurityConfig {
|
||||
.cors { it.disable() }
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.requestMatchers(
|
||||
"/api/v1/auth/health",
|
||||
"/api/v1/auth/setup",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/jwks.json",
|
||||
"/actuator/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
).permitAll()
|
||||
auth
|
||||
.requestMatchers(
|
||||
"/api/v1/auth/health",
|
||||
"/api/v1/auth/setup",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/jwks.json",
|
||||
"/actuator/**",
|
||||
"/v3/api-docs/**",
|
||||
"/swagger-ui/**",
|
||||
"/swagger-ui.html",
|
||||
).permitAll()
|
||||
auth.anyRequest().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { resource ->
|
||||
}.oauth2ResourceServer { resource ->
|
||||
resource.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) }
|
||||
}
|
||||
return http.build()
|
||||
|
||||
@@ -8,20 +8,20 @@ import org.springframework.test.context.DynamicPropertySource
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import ru.ysu.lms.identity.auth.AuthService
|
||||
import ru.ysu.lms.common.UnauthorizedException
|
||||
import ru.ysu.lms.identity.auth.AuthService
|
||||
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
class IdentitySmokeTest {
|
||||
|
||||
companion object {
|
||||
@Container
|
||||
val pg = PostgreSQLContainer("postgres:18").apply {
|
||||
withDatabaseName("lms")
|
||||
withUsername("lms")
|
||||
withPassword("lms")
|
||||
}
|
||||
val pg =
|
||||
PostgreSQLContainer("postgres:18").apply {
|
||||
withDatabaseName("lms")
|
||||
withUsername("lms")
|
||||
withPassword("lms")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
@@ -42,21 +42,31 @@ class IdentitySmokeTest {
|
||||
|
||||
@Test
|
||||
fun `setup creates admin and login works`() {
|
||||
val token = authService.setup(
|
||||
ru.ysu.lms.identity.auth.SetupRequest("admin", "test12345", "test12345")
|
||||
)
|
||||
val token =
|
||||
authService.setup(
|
||||
ru.ysu.lms.identity.auth
|
||||
.SetupRequest("admin", "test12345", "test12345"),
|
||||
)
|
||||
assert(token.token.isNotBlank()) { "token should not be blank" }
|
||||
assert(token.role == "ADMIN")
|
||||
|
||||
val login = authService.login(
|
||||
ru.ysu.lms.identity.auth.LoginRequest("admin", "test12345")
|
||||
)
|
||||
val login =
|
||||
authService.login(
|
||||
ru.ysu.lms.identity.auth
|
||||
.LoginRequest("admin", "test12345"),
|
||||
)
|
||||
assert(login.token.isNotBlank())
|
||||
|
||||
val ex = try {
|
||||
authService.login(ru.ysu.lms.identity.auth.LoginRequest("admin", "bad"))
|
||||
null
|
||||
} catch (e: UnauthorizedException) { e }
|
||||
val ex =
|
||||
try {
|
||||
authService.login(
|
||||
ru.ysu.lms.identity.auth
|
||||
.LoginRequest("admin", "bad"),
|
||||
)
|
||||
null
|
||||
} catch (e: UnauthorizedException) {
|
||||
e
|
||||
}
|
||||
assert(ex != null) { "bad password should throw" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user