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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user