Initial commit: LMS microservices scaffold

- Gradle multi-module: libs/{common-http,security-jwt,markdown,test-support},
  services/{identity,academic,progress,gateway}, web (Thymeleaf+htmx)
- identity-service: JWT auth (RSA), accounts, students, groups, enrollment,
  import from XLSX/CSV, credentials export, password reset
- academic-service: lab import from JSON/zip (markdown + YAML + variants),
  levels, API
- progress-service: schema for student levels and variant assignments
- gateway: Spring Cloud Gateway, JWT validation, route propagation
- web: Server-side UI with flex/footer layout, light/dark theme toggle,
  admin dashboard, groups CRUD, student portal placeholder
- docker-compose: PostgreSQL 18
- Tests: identity smoke test with Testcontainers
This commit is contained in:
2026-09-10 19:46:13 +03:00
commit 2cee104633
125 changed files with 4752 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
plugins {
id("lms.spring-boot")
}
dependencies {
implementation(platform(libs.springBootBom))
implementation(libs.springBootStarterWeb)
implementation(libs.springBootStarterValidation)
implementation(libs.springBootStarterActuator)
implementation(libs.springBootStarterDataJpa)
implementation(libs.springBootStarterSecurity)
implementation(libs.springBootStarterOauth2ResourceServer)
implementation(libs.springBootStarterFlyway)
implementation(libs.flywayPostgresql)
runtimeOnly(libs.postgresql)
implementation(libs.jacksonYaml)
implementation(libs.jacksonModuleKotlin)
implementation(libs.springdocStarterWebmvcUi)
implementation(project(":libs:common-http"))
implementation(project(":libs:markdown"))
implementation(project(":libs:security-jwt"))
testImplementation(project(":libs:test-support"))
}
springBoot {
mainClass.set("ru.ysu.lms.academic.AcademicServiceApplicationKt")
}

View File

@@ -0,0 +1,11 @@
package ru.ysu.lms.academic
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class AcademicServiceApplication
fun main(args: Array<String>) {
runApplication<AcademicServiceApplication>(*args)
}

View File

@@ -0,0 +1,33 @@
package ru.ysu.lms.academic.controller
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.lab.*
import ru.ysu.lms.common.BadRequestException
import java.util.UUID
@RestController
@RequestMapping("/api/v1/labs")
class LabController(private val labService: LabService) {
@GetMapping
fun list(): List<LabSummary> = labService.list()
@GetMapping("/{id}")
fun detail(@PathVariable id: UUID): LabDetail = labService.detail(id)
@PostMapping("/import")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun importLab(@RequestParam("file") file: MultipartFile): LabImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл")
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes)
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun delete(@PathVariable id: UUID) {
labService.delete(id)
}
}

View File

@@ -0,0 +1,40 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.OrderBy
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "labs")
class Lab(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false)
var number: Int = 0,
@Column(nullable = false, length = 200)
var title: String = "",
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "semester_id", nullable = false)
var semester: Semester? = null,
@Column(name = "description_md", nullable = false)
var descriptionMd: String = "",
@Column(name = "deadline_at")
var deadlineAt: Instant? = null,
@Column(name = "defense_at")
var defenseAt: Instant? = null,
@Column(name = "points_max", nullable = false, columnDefinition = "numeric(4,1)")
var pointsMax: java.math.BigDecimal = java.math.BigDecimal.valueOf(5.0),
@Column(nullable = false, length = 16)
var status: String = "draft",
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,31 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.util.UUID
@Entity
@Table(name = "lab_levels")
class LabLevel(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lab_id", nullable = false)
var lab: Lab? = null,
@Column(nullable = false, length = 32)
var code: String = "",
@Column(nullable = false, length = 100)
var title: String = "",
@Column(nullable = false)
var description: String = "",
@Column(nullable = false)
var position: Int = 0,
@Column(name = "points_max", columnDefinition = "numeric(4,1)")
var pointsMax: java.math.BigDecimal? = null,
)

View File

@@ -0,0 +1,21 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.util.UUID
@Entity
@Table(name = "semesters")
class Semester(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false, length = 100)
var title: String = "",
@Column(nullable = false)
var year: Int = 0,
@Column(nullable = false)
var active: Boolean = false,
)

View File

@@ -0,0 +1,32 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.util.UUID
@Entity
@Table(name = "variants")
class Variant(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lab_id", nullable = false)
var lab: Lab? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lab_level_id", nullable = false)
var labLevel: LabLevel? = null,
@Column(nullable = false, length = 32)
var number: String = "",
@Column(nullable = false, length = 200)
var title: String = "",
@Column(name = "statement_md", nullable = false)
var statementMd: String = "",
@Column(nullable = false)
var position: Int = 0,
)

View File

@@ -0,0 +1,80 @@
package ru.ysu.lms.academic.lab
import java.math.BigDecimal
data class LabMeta(
val number: Int,
val title: String,
val pointsMax: BigDecimal = BigDecimal.valueOf(5.0),
val deadlineAt: String? = null,
val defenseAt: String? = null,
val status: String = "draft",
val levels: List<LabLevelMeta> = emptyList(),
)
data class LabLevelMeta(
val code: String,
val title: String,
val description: String = "",
val position: Int = 0,
val pointsMax: BigDecimal? = null,
)
data class VariantMeta(
val level: String,
val number: String,
val title: String = "",
val statement: String = "",
)
data class LabImportBundle(
val meta: LabMeta,
val descriptionMd: String,
val variants: List<VariantMeta>,
val assets: Map<String, ByteArray> = emptyMap(),
)
data class LabImportResult(
val labId: String,
val number: Int,
val title: String,
val levelCount: Int,
val variantCount: Int,
)
data class LabSummary(
val id: String,
val number: Int,
val title: String,
val status: String,
val semesterTitle: String,
val deadlineAt: String?,
val defenseAt: String?,
val pointsMax: BigDecimal,
val variantCount: Int,
)
data class LabDetail(
val id: String,
val number: Int,
val title: String,
val status: String,
val descriptionMd: String,
val deadlineAt: String?,
val defenseAt: String?,
val pointsMax: BigDecimal,
val levels: List<LabLevelDetail>,
)
data class LabLevelDetail(
val code: String,
val title: String,
val variants: List<VariantDetail>,
)
data class VariantDetail(
val id: String,
val number: String,
val title: String,
val statementMd: String,
)

View File

@@ -0,0 +1,165 @@
package ru.ysu.lms.academic.lab
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.springframework.stereotype.Component
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
import java.math.BigDecimal
import java.util.zip.ZipInputStream
@Component
class LabImportParser {
private val yaml = ObjectMapper(YAMLFactory())
.registerModule(KotlinModule.Builder().build())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
private val json = ObjectMapper()
fun parse(filename: String, bytes: ByteArray): LabImportBundle {
val lower = filename.lowercase()
if (!(lower.endsWith(".zip") || lower.endsWith(".json"))) {
throw BadRequestException("Формат должен быть .zip (рекомендуется) или .json")
}
return if (lower.endsWith(".json")) {
parseJson(bytes)
} else {
parseZip(bytes)
}
}
private fun parseJson(bytes: ByteArray): LabImportBundle {
val tree = json.readTree(bytes)
val meta = LabMeta(
number = tree.path("number").asInt(),
title = tree.path("title").asText(),
pointsMax = BigDecimal.valueOf(tree.path("points_max").asDouble(5.0)),
deadlineAt = tree.path("deadline").asText("").ifBlank { null },
defenseAt = tree.path("defense_at").asText("").ifBlank { null },
status = tree.path("status").asText("published"),
levels = parseLevels(tree),
)
val descriptionMd = tree.path("common_requirements").asText("")
val variants = parseVariantsFromTree(tree, meta.levels)
return LabImportBundle(meta, descriptionMd, variants)
}
private fun parseZip(bytes: ByteArray): LabImportBundle {
val entries = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
entries[entry.name] = zis.readAllBytes()
}
entry = zis.nextEntry
}
}
val metaEntry = entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
?: throw BadRequestException("В архиве не найден lab.yaml")
val meta = yaml.readValue(metaEntry.value, LabMeta::class.java)
val readmeEntry = entries.entries.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
val readmeMd = readmeEntry?.let { String(it.value, Charsets.UTF_8) } ?: ""
val variants = mutableListOf<VariantMeta>()
val assets = mutableMapOf<String, ByteArray>()
for ((path, data) in entries) {
val p = path.replace('\\', '/')
when {
p.contains("/variants/") || p.startsWith("variants/") -> {
val content = String(data, Charsets.UTF_8)
val variant = parseVariantFile(p, content, meta.levels)
if (variant != null) variants.add(variant)
}
p.contains("/assets/") || p.startsWith("assets/") -> {
assets[p] = data
}
}
}
if (variants.isEmpty()) {
throw BadRequestException("В архиве не найдены варианты (папка variants/)")
}
return LabImportBundle(meta, readmeMd, variants, assets)
}
private fun parseLevels(tree: JsonNode): List<LabLevelMeta> {
val levels = tree.path("levels")
if (levels.isArray && levels.size() > 0) {
return levels.map { n ->
LabLevelMeta(
code = n.path("code").asText(),
title = n.path("title").asText(""),
description = n.path("description").asText(""),
position = n.path("position").asInt(0),
)
}
}
return listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
)
}
private fun parseVariantsFromTree(tree: JsonNode, levels: List<LabLevelMeta>): List<VariantMeta> {
val variants = tree.path("variants")
if (!variants.isArray) return emptyList()
val levelMap = levels.associateBy { it.code }
return variants.map { v ->
val levelCode = normalizeLevel(v.path("level").asText("basic"))
if (!levelMap.containsKey(levelCode)) {
throw BadRequestException("Неизвестный уровень: ${v.path("level").asText()}")
}
VariantMeta(
level = levelCode,
number = v.path("number").asText(),
title = v.path("title").asText(""),
statement = v.path("statement").asText(""),
)
}
}
private fun parseVariantFile(path: String, content: String, levels: List<LabLevelMeta>): VariantMeta? {
val levelMap = levels.associateBy { it.code }
val lines = content.lines()
val firstSep = lines.indexOfFirst { it.trim() == "---" }
if (firstSep != 0) {
return VariantMeta(
level = "basic",
number = path.substringAfterLast("/").removeSuffix(".md"),
title = "",
statement = content.trim(),
)
}
val secondSep = lines.withIndex().firstOrNull { it.index != firstSep && it.value.trim() == "---" }?.index ?: -1
if (secondSep < 0) return null
val fmLines = lines.subList(1, secondSep)
val fm = mutableMapOf<String, String>()
for (line in fmLines) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) fm[parts[0].trim()] = parts[1].trim()
}
val levelCode = normalizeLevel(fm["level"] ?: "basic")
if (levelMap[levelCode] == null) return null
val body = lines.drop(secondSep + 1).joinToString("\n").trim()
return VariantMeta(
level = levelCode,
number = fm["number"] ?: return null,
title = fm["title"] ?: "",
statement = body.ifBlank { "—" },
)
}
private fun normalizeLevel(s: String): String = when (s.lowercase().trim()) {
"main", "basic", "обычная", "основная" -> "basic"
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
else -> s.lowercase().trim()
}
}

View File

@@ -0,0 +1,137 @@
package ru.ysu.lms.academic.lab
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Lab
import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Variant
import ru.ysu.lms.academic.repo.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.academic.repo.SemesterRepository
import ru.ysu.lms.academic.repo.VariantRepository
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.BadRequestException
import java.math.BigDecimal
import java.util.UUID
@Service
class LabService(
private val labs: LabRepository,
private val labLevels: LabLevelRepository,
private val variantsRepo: VariantRepository,
private val semesters: SemesterRepository,
private val parser: LabImportParser,
) {
@Transactional
fun importFromFile(filename: String, bytes: ByteArray): LabImportResult {
val bundle = parser.parse(filename, bytes)
val semester = semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
val lab = labs.save(Lab(
number = bundle.meta.number,
title = bundle.meta.title,
semester = semester,
descriptionMd = bundle.descriptionMd,
pointsMax = bundle.meta.pointsMax,
status = bundle.meta.status,
))
val levelMetas = bundle.meta.levels.ifEmpty {
listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
)
}
val levelEntities = levelMetas.map { meta ->
labLevels.save(LabLevel(
lab = lab,
code = meta.code,
title = meta.title,
description = meta.description,
position = meta.position,
pointsMax = meta.pointsMax,
))
}
val levelByCode = levelEntities.associateBy { it.code }
bundle.variants.forEach { vmeta ->
val level = levelByCode[vmeta.level]
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
variantsRepo.save(Variant(
lab = lab,
labLevel = level,
number = vmeta.number,
title = vmeta.title,
statementMd = vmeta.statement,
))
}
return LabImportResult(
labId = lab.id.toString(),
number = lab.number,
title = lab.title,
levelCount = levelEntities.size,
variantCount = bundle.variants.size,
)
}
@Transactional(readOnly = true)
fun list(): List<LabSummary> =
labs.findAllByOrderByNumberAsc().map { lab ->
val variants = variantsRepo.findByLabIdOrderByNumber(lab.id)
LabSummary(
id = lab.id.toString(),
number = lab.number,
title = lab.title,
status = lab.status,
semesterTitle = lab.semester?.let { "${it.title} ${it.year}" } ?: "—",
deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
variantCount = variants.size,
)
}
@Transactional(readOnly = true)
fun detail(labId: UUID): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
val levels = labLevels.findByLabIdOrderByPosition(lab.id)
return LabDetail(
id = lab.id.toString(),
number = lab.number,
title = lab.title,
status = lab.status,
descriptionMd = lab.descriptionMd,
deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
levels = levels.map { level ->
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id)
.filter { it.labLevel?.id == level.id }
LabLevelDetail(
code = level.code,
title = level.title,
variants = vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
)
}
@Transactional
fun delete(labId: UUID) {
if (!labs.existsById(labId)) {
throw NotFoundException("Лабораторная не найдена")
}
labs.deleteById(labId)
}
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.Lab
import java.util.UUID
interface LabRepository : JpaRepository<Lab, UUID> {
fun findAllByOrderByNumberAsc(): List<Lab>
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.Semester
import java.util.UUID
interface SemesterRepository : JpaRepository<Semester, UUID> {
fun findFirstByActiveTrue(): Semester?
}

View File

@@ -0,0 +1,14 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Variant
import java.util.UUID
interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
fun findByLabIdOrderByPosition(labId: UUID): List<LabLevel>
}
interface VariantRepository : JpaRepository<Variant, UUID> {
fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
}

View File

@@ -0,0 +1,60 @@
package ru.ysu.lms.academic.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun jwtDecoder(): JwtDecoder =
NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
@Bean
fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter { jwt ->
val role = jwt.getClaimAsString("role")
if (role.isNullOrBlank()) emptyList()
else listOf(SimpleGrantedAuthority("ROLE_$role"))
}
return converter
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth ->
oauth.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
}
}
return http.build()
}
companion object {
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
}
}

View File

@@ -0,0 +1,37 @@
server:
port: ${SERVER_PORT:8082}
spring:
application:
name: academic-service
datasource:
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=academic}
username: ${LMS_DB_USER:lms}
password: ${LMS_DB_PASSWORD:lms}
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
properties:
hibernate:
default_schema: academic
flyway:
enabled: true
locations: classpath:db/migration
schemas: academic
default-schema: academic
create-schemas: true
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true
springdoc:
api-docs:
path: /v3/api-docs

View File

@@ -0,0 +1,2 @@
-- Baseline for the academic schema (courses, articles, labs, levels, variants).
CREATE SCHEMA IF NOT EXISTS academic;

View File

@@ -0,0 +1,88 @@
-- Courses, articles, semesters, labs, levels, variants for the academic service.
CREATE TABLE courses (
id uuid PRIMARY KEY DEFAULT uuidv7(),
title varchar(200) NOT NULL,
summary text NOT NULL DEFAULT '',
position integer NOT NULL DEFAULT 0,
published boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE articles (
id uuid PRIMARY KEY DEFAULT uuidv7(),
course_id uuid NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title varchar(200) NOT NULL,
slug varchar(200) NOT NULL,
summary text NOT NULL DEFAULT '',
body_md text NOT NULL DEFAULT '',
status varchar(16) NOT NULL DEFAULT 'draft',
position integer NOT NULL DEFAULT 0,
published_at timestamptz,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT articles_course_slug_unique UNIQUE (course_id, slug)
);
CREATE TABLE article_assets (
id uuid PRIMARY KEY DEFAULT uuidv7(),
article_id uuid REFERENCES articles(id) ON DELETE CASCADE,
filename varchar(255) NOT NULL,
stored_path varchar(512) NOT NULL,
mime varchar(128) NOT NULL DEFAULT '',
size bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE semesters (
id uuid PRIMARY KEY DEFAULT uuidv7(),
title varchar(100) NOT NULL,
year integer NOT NULL,
active boolean NOT NULL DEFAULT false
);
CREATE TABLE labs (
id uuid PRIMARY KEY DEFAULT uuidv7(),
number integer NOT NULL,
title varchar(200) NOT NULL,
semester_id uuid NOT NULL REFERENCES semesters(id) ON DELETE RESTRICT,
description_md text NOT NULL DEFAULT '',
deadline_at timestamptz,
defense_at timestamptz,
points_max numeric(4,1) NOT NULL DEFAULT 5,
status varchar(16) NOT NULL DEFAULT 'draft',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT labs_number_semester_unique UNIQUE (number, semester_id)
);
CREATE TABLE lab_levels (
id uuid PRIMARY KEY DEFAULT uuidv7(),
lab_id uuid NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
code varchar(32) NOT NULL,
title varchar(100) NOT NULL,
description text NOT NULL DEFAULT '',
position integer NOT NULL DEFAULT 0,
points_max numeric(4,1),
CONSTRAINT lab_levels_lab_code_unique UNIQUE (lab_id, code)
);
CREATE TABLE variants (
id uuid PRIMARY KEY DEFAULT uuidv7(),
lab_id uuid NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
lab_level_id uuid NOT NULL REFERENCES lab_levels(id) ON DELETE CASCADE,
number varchar(32) NOT NULL,
title varchar(200) NOT NULL DEFAULT '',
statement_md text NOT NULL DEFAULT '',
position integer NOT NULL DEFAULT 0,
CONSTRAINT variants_lab_level_number_unique UNIQUE (lab_id, lab_level_id, number)
);
CREATE TABLE variant_examples (
id uuid PRIMARY KEY DEFAULT uuidv7(),
variant_id uuid NOT NULL REFERENCES variants(id) ON DELETE CASCADE,
position integer NOT NULL DEFAULT 0,
input text NOT NULL DEFAULT '',
output text NOT NULL DEFAULT '',
explanation text NOT NULL DEFAULT ''
);

View File

@@ -0,0 +1,4 @@
-- Seed data: default semester for development.
INSERT INTO semesters (id, title, year, active)
VALUES ('00000000-0000-7000-8000-000000000001', 'осень', 2026, true)
ON CONFLICT DO NOTHING;