feat: закрыть остаточные P1/P2 по профилю, когорте и ведомости

- профиль приведён к docs/09: variant/level, updatedBy/name, лимит попыток
- V14: NOT NULL и полная уникальность когорты, preflight legacy-групп
- возврат группы из архива и история членства на странице группы
- пагинация групп и плоского списка материалов
- пакетные сдачи ведомости одним запросом
- XLSX-preflight импорта и audit-тесты ростера
- LDD: 66 claims, 398 тестов, release PASS
This commit is contained in:
2026-10-29 20:51:27 +03:00
parent 7ef5c866a3
commit 82394cc688
48 changed files with 1196 additions and 156 deletions

View File

@@ -359,24 +359,31 @@ class ContentService(
articles.save(a)
}
/** Плоский список материалов; [limit] = 0 — все записи (совместимость API). */
@Transactional(readOnly = true)
fun listAll(): List<ContentArticleView> {
fun listAll(
offset: Int = 0,
limit: Int = 0,
): List<ContentArticleView> {
val chById = chapters.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
val secById = sections.findAllByOrderByPositionAscIdAsc().associateBy { it.id }
return articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
val sec = secById[a.sectionId] ?: return@mapNotNull null
val ch = chById[sec.chapterId] ?: return@mapNotNull null
ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
)
}
val all =
articles.findAllByOrderByPositionAscIdAsc().mapNotNull { a ->
val sec = secById[a.sectionId] ?: return@mapNotNull null
val ch = chById[sec.chapterId] ?: return@mapNotNull null
ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
chapterTitle = ch.title,
sectionSlug = sec.slug,
sectionTitle = sec.title,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
)
}
if (limit <= 0) return all
return all.drop(offset.coerceAtLeast(0)).take(limit.coerceIn(1, MAX_ARTICLE_PAGE))
}
@Transactional
@@ -385,6 +392,10 @@ class ContentService(
bytes: ByteArray,
sync: Boolean = true,
): ContentImportResult = importer.importZip(filename, bytes, sync)
private companion object {
const val MAX_ARTICLE_PAGE = 500
}
}
/**

View File

@@ -158,7 +158,10 @@ class ContentAdminController(
}
@GetMapping("/articles")
fun list() = contentService.listAll()
fun list(
@RequestParam(defaultValue = "0") offset: Int,
@RequestParam(defaultValue = "0") limit: Int,
) = contentService.listAll(offset, limit)
@PostMapping("/chapters")
fun createChapter(

View File

@@ -92,6 +92,21 @@ class CourseContentIntegrationTest {
.build()
}
@Test
fun `admin article list supports offset and limit`() {
repeat(3) { seedMaterial(courseId = null, published = true) }
val all = contentService.listAll()
assertThat(all.size).isGreaterThanOrEqualTo(3)
val first = contentService.listAll(offset = 0, limit = 2)
val second = contentService.listAll(offset = 2, limit = 1)
assertThat(first).hasSize(2)
assertThat(second).hasSize(1)
assertThat(first.map { it.id }).doesNotContainAnyElementsOf(second.map { it.id })
assertThat(contentService.listAll(offset = 0, limit = 0)).hasSize(all.size)
}
@Test
fun `anonymous tree contains only global chapters with ALL audience`() {
val global = seedChapter(title = "Глобальная", courseId = null, audience = "ALL")