From a7aafccd19e95e101c1e79fe263337fdce8754e0 Mon Sep 17 00:00:00 2001 From: jay-tux Date: Thu, 27 Mar 2025 18:18:15 +0100 Subject: [PATCH] Added evaluation criteria --- composeApp/build.gradle.kts | 1 + .../kotlin/com/jaytux/grader/data/DSL.kt | 16 +- .../kotlin/com/jaytux/grader/data/Database.kt | 4 +- .../kotlin/com/jaytux/grader/data/Entities.kt | 42 +- .../com/jaytux/grader/ui/Assignments.kt | 483 ++++++++++++++---- .../com/jaytux/grader/viewmodel/DbState.kt | 176 +++++-- 6 files changed, 566 insertions(+), 156 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index ecfbd76..76c6c9e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -48,6 +48,7 @@ compose.desktop { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "com.jaytux.grader" packageVersion = "1.0.0" + includeAllModules = true } } } diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/DSL.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/DSL.kt index e595594..2570352 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/DSL.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/DSL.kt @@ -1,6 +1,5 @@ package com.jaytux.grader.data -import kotlinx.datetime.* import org.jetbrains.exposed.dao.id.CompositeIdTable import org.jetbrains.exposed.dao.id.UUIDTable import org.jetbrains.exposed.sql.Table @@ -59,6 +58,12 @@ object GroupAssignments : UUIDTable("grpAssgmts") { val deadline = datetime("deadline") } +object GroupAssignmentCriteria : UUIDTable("grpAsCr") { + val assignmentId = reference("group_assignment_id", GroupAssignments.id) + val name = varchar("name", 50) + val desc = text("description") +} + object SoloAssignments : UUIDTable("soloAssgmts") { val editionId = reference("edition_id", Editions.id) val number = integer("number").nullable() @@ -67,6 +72,12 @@ object SoloAssignments : UUIDTable("soloAssgmts") { val deadline = datetime("deadline") } +object SoloAssignmentCriteria : UUIDTable("soloAsCr") { + val assignmentId = reference("solo_assignment_id", SoloAssignments.id) + val name = varchar("name", 50) + val desc = text("description") +} + object PeerEvaluations : UUIDTable("peerEvals") { val editionId = reference("edition_id", Editions.id) val number = integer("number").nullable() @@ -75,6 +86,7 @@ object PeerEvaluations : UUIDTable("peerEvals") { object GroupFeedbacks : CompositeIdTable("grpFdbks") { val groupAssignmentId = reference("group_assignment_id", GroupAssignments.id) + val criterionId = reference("criterion_id", GroupAssignments.id).nullable() val groupId = reference("group_id", Groups.id) val feedback = text("feedback") val grade = varchar("grade", 32) @@ -84,6 +96,7 @@ object GroupFeedbacks : CompositeIdTable("grpFdbks") { object IndividualFeedbacks : CompositeIdTable("indivFdbks") { val groupAssignmentId = reference("group_assignment_id", GroupAssignments.id) + val criterionId = reference("criterion_id", GroupAssignments.id).nullable() val groupId = reference("group_id", Groups.id) val studentId = reference("student_id", Students.id) val feedback = text("feedback") @@ -94,6 +107,7 @@ object IndividualFeedbacks : CompositeIdTable("indivFdbks") { object SoloFeedbacks : CompositeIdTable("soloFdbks") { val soloAssignmentId = reference("solo_assignment_id", SoloAssignments.id) + val criterionId = reference("criterion_id", SoloAssignments.id).nullable() val studentId = reference("student_id", Students.id) val feedback = text("feedback") val grade = varchar("grade", 32) diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Database.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Database.kt index 7fac47a..d848672 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Database.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Database.kt @@ -11,7 +11,7 @@ object Database { SchemaUtils.create( Courses, Editions, Groups, Students, GroupStudents, EditionStudents, - GroupAssignments, SoloAssignments, + GroupAssignments, SoloAssignments, GroupAssignmentCriteria, SoloAssignmentCriteria, GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks, PeerEvaluations, PeerEvaluationContents, StudentToStudentEvaluation, StudentToGroupEvaluation @@ -20,7 +20,7 @@ object Database { val addMissing = SchemaUtils.addMissingColumnsStatements( Courses, Editions, Groups, Students, GroupStudents, EditionStudents, - GroupAssignments, SoloAssignments, + GroupAssignments, SoloAssignments, GroupAssignmentCriteria, SoloAssignmentCriteria, GroupFeedbacks, IndividualFeedbacks, SoloFeedbacks, PeerEvaluations, PeerEvaluationContents, StudentToStudentEvaluation, StudentToGroupEvaluation diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Entities.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Entities.kt index 9ecbd8b..b822c85 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Entities.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/data/Entities.kt @@ -1,10 +1,9 @@ package com.jaytux.grader.data +import com.jaytux.grader.data.GroupAssignment.Companion.referrersOn import org.jetbrains.exposed.dao.Entity import org.jetbrains.exposed.dao.EntityClass -import org.jetbrains.exposed.dao.id.CompositeID import org.jetbrains.exposed.dao.id.EntityID -import org.jetbrains.exposed.sql.transactions.transaction import java.util.UUID class Course(id: EntityID) : Entity(id) { @@ -61,6 +60,16 @@ class GroupAssignment(id: EntityID) : Entity(id) { var name by GroupAssignments.name var assignment by GroupAssignments.assignment var deadline by GroupAssignments.deadline + + val criteria by GroupAssignmentCriterion referrersOn GroupAssignmentCriteria.assignmentId +} + +class GroupAssignmentCriterion(id: EntityID) : Entity(id) { + companion object : EntityClass(GroupAssignmentCriteria) + + var assignment by GroupAssignment referencedOn GroupAssignmentCriteria.assignmentId + var name by GroupAssignmentCriteria.name + var description by GroupAssignmentCriteria.desc } class SoloAssignment(id: EntityID) : Entity(id) { @@ -71,6 +80,35 @@ class SoloAssignment(id: EntityID) : Entity(id) { var name by SoloAssignments.name var assignment by SoloAssignments.assignment var deadline by SoloAssignments.deadline + + val criteria by SoloAssignmentCriterion referrersOn SoloAssignmentCriteria.assignmentId +} + +class SoloAssignmentCriterion(id: EntityID) : Entity(id) { + companion object : EntityClass(SoloAssignmentCriteria) + + var assignment by SoloAssignment referencedOn SoloAssignmentCriteria.assignmentId + var name by SoloAssignmentCriteria.name + var description by SoloAssignmentCriteria.desc + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SoloAssignmentCriterion + + if (name != other.name) return false + if (description != other.description) return false + + return true + } + + override fun hashCode(): Int { + var result = assignment.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + description.hashCode() + return result + } } class PeerEvaluation(id: EntityID) : Entity(id) { diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt index fdde435..82ee529 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/ui/Assignments.kt @@ -1,7 +1,5 @@ package com.jaytux.grader.ui -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* @@ -9,8 +7,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.layout @@ -20,19 +16,23 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp +import com.jaytux.grader.data.GroupAssignment +import com.jaytux.grader.data.GroupAssignmentCriterion +import com.jaytux.grader.data.SoloAssignmentCriterion import com.jaytux.grader.data.Student import com.jaytux.grader.viewmodel.GroupAssignmentState import com.jaytux.grader.viewmodel.PeerEvaluationState import com.jaytux.grader.viewmodel.SoloAssignmentState import com.mohamedrejeb.richeditor.model.rememberRichTextState import com.mohamedrejeb.richeditor.ui.material3.OutlinedRichTextEditor +import kotlinx.datetime.LocalDateTime -@OptIn(ExperimentalMaterial3Api::class) @Composable fun GroupAssignmentView(state: GroupAssignmentState) { val task by state.task val deadline by state.deadline val allFeedback by state.feedback.entities + val criteria by state.criteria.entities var idx by remember(state) { mutableStateOf(0) } @@ -45,7 +45,7 @@ fun GroupAssignmentView(state: GroupAssignmentState) { } TabRow(idx) { - Tab(idx == 0, { idx = 0 }) { Text("Assignment") } + Tab(idx == 0, { idx = 0 }) { Text("Task and Criteria") } allFeedback.forEachIndexed { i, it -> val (group, feedback) = it Tab(idx == i + 1, { idx = i + 1 }) { @@ -55,22 +55,14 @@ fun GroupAssignmentView(state: GroupAssignmentState) { } if(idx == 0) { - val updTask = rememberRichTextState() - - LaunchedEffect(task) { updTask.setMarkdown(task) } - - Row { - DateTimePicker(deadline, { state.updateDeadline(it) }) - } - RichTextStyleRow(state = updTask) - OutlinedRichTextEditor( - state = updTask, - modifier = Modifier.fillMaxWidth().weight(1f), - singleLine = false, - minLines = 5, - label = { Text("Task") } + groupTaskWidget( + task, deadline, criteria, + onSetTask = { state.updateTask(it) }, + onSetDeadline = { state.updateDeadline(it) }, + onAddCriterion = { state.addCriterion(it) }, + onModCriterion = { c, n, d -> state.updateCriterion(c, n, d) }, + onRmCriterion = { state.deleteCriterion(it) } ) - CancelSaveRow(true, { updTask.setMarkdown(task) }, "Reset", "Update") { state.updateTask(updTask.toMarkdown()) } } else { groupFeedback(state, allFeedback[idx - 1].second) @@ -78,12 +70,126 @@ fun GroupAssignmentView(state: GroupAssignmentState) { } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun groupTaskWidget( + taskMD: String, + deadline: LocalDateTime, + criteria: List, + onSetTask: (String) -> Unit, + onSetDeadline: (LocalDateTime) -> Unit, + onAddCriterion: (name: String) -> Unit, + onModCriterion: (cr: GroupAssignmentCriterion, name: String, desc: String) -> Unit, + onRmCriterion: (cr: GroupAssignmentCriterion) -> Unit +) { + var critIdx by remember { mutableStateOf(0) } + var adding by remember { mutableStateOf(false) } + var confirming by remember { mutableStateOf(false) } + + Row { + Surface(Modifier.weight(0.25f), tonalElevation = 10.dp) { + Column(Modifier.padding(10.dp)) { + LazyColumn(Modifier.weight(1f)) { + item { + Surface( + Modifier.fillMaxWidth().clickable { critIdx = 0 }, + tonalElevation = if (critIdx == 0) 50.dp else 0.dp, + shape = MaterialTheme.shapes.medium + ) { + Text("Assignment", Modifier.padding(5.dp), fontStyle = FontStyle.Italic) + } + } + + itemsIndexed(criteria) { i, crit -> + Surface( + Modifier.fillMaxWidth().clickable { critIdx = i + 1 }, + tonalElevation = if (critIdx == i + 1) 50.dp else 0.dp, + shape = MaterialTheme.shapes.medium + ) { + Text(crit.name, Modifier.padding(5.dp)) + } + } + } + Button({ adding = true }, Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) { + Text("Add evaluation criterion") + } + } + } + Box(Modifier.weight(0.75f).padding(10.dp)) { + if (critIdx == 0) { + val updTask = rememberRichTextState() + + LaunchedEffect(taskMD) { updTask.setMarkdown(taskMD) } + + Column { + Row { + DateTimePicker(deadline, onSetDeadline) + } + RichTextStyleRow(state = updTask) + OutlinedRichTextEditor( + state = updTask, + modifier = Modifier.fillMaxWidth().weight(1f), + singleLine = false, + minLines = 5, + label = { Text("Task") } + ) + CancelSaveRow( + true, + { updTask.setMarkdown(taskMD) }, + "Reset", + "Update" + ) { onSetTask(updTask.toMarkdown()) } + } + } + else { + val crit = criteria[critIdx - 1] + var name by remember(crit) { mutableStateOf(crit.name) } + var desc by remember(crit) { mutableStateOf(crit.description) } + + Column { + Row { + OutlinedTextField(name, { name = it }, Modifier.weight(0.8f)) + Spacer(Modifier.weight(0.1f)) + Button({ onModCriterion(crit, name, desc) }, Modifier.weight(0.1f)) { + Text("Update") + } + } + OutlinedTextField( + desc, { desc = it }, Modifier.fillMaxWidth().weight(1f), + label = { Text("Description") }, + singleLine = false, + minLines = 5 + ) + Button({ confirming = true }, Modifier.fillMaxWidth()) { + Text("Remove criterion") + } + } + } + } + } + + if(adding) { + AddStringDialog( + "Evaluation criterion name", criteria.map{ it.name }, { adding = false } + ) { onAddCriterion(it) } + } + + if(confirming && critIdx != 0) { + ConfirmDeleteDialog( + "an evaluation criterion", + { confirming = false }, { onRmCriterion(criteria[critIdx - 1]); critIdx = 0 } + ) { + Text(criteria[critIdx - 1].name) + } + } +} + @Composable fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalGFeedback) { val (group, feedback, individual) = fdbk - var grade by remember(fdbk) { mutableStateOf(feedback?.grade ?: "") } - var msg by remember(fdbk) { mutableStateOf(TextFieldValue(feedback?.feedback ?: "")) } var idx by remember(fdbk) { mutableStateOf(0) } + var critIdx by remember(fdbk) { mutableStateOf(0) } + val criteria by state.criteria.entities val suggestions by state.autofill.entities Row { @@ -112,45 +218,87 @@ fun groupFeedback(state: GroupAssignmentState, fdbk: GroupAssignmentState.LocalG } } - Column(Modifier.weight(0.75f).padding(10.dp)) { + val updateGrade = { grade: String -> if(idx == 0) { - Row { - Text("Grade: ", Modifier.align(Alignment.CenterVertically)) - OutlinedTextField(grade, { grade = it }, Modifier.weight(0.2f)) - Spacer(Modifier.weight(0.6f)) - Button({ state.upsertGroupFeedback(group, msg.text, grade) }, Modifier.weight(0.2f).align(Alignment.CenterVertically), - enabled = grade.isNotBlank() || msg.text.isNotBlank()) { - Text("Save") - } - } + state.upsertGroupFeedback(group, feedback.global?.feedback ?: "", grade) + } + else { + val ind = individual[idx - 1] + val glob = ind.second.second.global + state.upsertIndividualFeedback(ind.first, group, glob?.feedback ?: "", grade) + } + } - AutocompleteLineField( - msg, { msg = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") } - ) { filter -> - suggestions.filter { x -> x.trim().startsWith(filter.trim()) } + val updateFeedback = { fdbk: String -> + if(idx == 0) { + if(critIdx == 0) { + state.upsertGroupFeedback(group, fdbk, feedback.global?.grade ?: "", null) + } + else { + val current = feedback.byCriterion[critIdx - 1] + state.upsertGroupFeedback(group, fdbk, current.entry?.grade ?: "", current.criterion) } } else { - val (student, details) = individual[idx - 1] - var sGrade by remember { mutableStateOf(details.second?.grade ?: "") } - var sMsg by remember { mutableStateOf(TextFieldValue(details.second?.feedback ?: "")) } - Row { - Text("Grade: ", Modifier.align(Alignment.CenterVertically)) - OutlinedTextField(sGrade, { sGrade = it }, Modifier.weight(0.2f)) - Spacer(Modifier.weight(0.6f)) - Button({ state.upsertIndividualFeedback(student, group, sMsg.text, sGrade) }, Modifier.weight(0.2f).align(Alignment.CenterVertically), - enabled = sGrade.isNotBlank() || sMsg.text.isNotBlank()) { - Text("Save") - } + val ind = individual[idx - 1] + if(critIdx == 0) { + val entry = ind.second.second + state.upsertIndividualFeedback(ind.first, group, fdbk, entry.global?.grade ?: "", null) } - - AutocompleteLineField( - sMsg, { sMsg = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") } - ) { filter -> - suggestions.filter { x -> x.trim().startsWith(filter.trim()) } + else { + val entry = ind.second.second.byCriterion[critIdx - 1] + state.upsertIndividualFeedback(ind.first, group, fdbk, entry.entry?.grade ?: "", entry.criterion) } } } + + groupFeedbackPane( + criteria, critIdx, { critIdx = it }, feedback.global, + if(critIdx == 0) feedback.global else feedback.byCriterion[critIdx - 1].entry, + suggestions, updateGrade, updateFeedback, Modifier.weight(0.75f).padding(10.dp) + ) + } +} + +@Composable +fun groupFeedbackPane( + criteria: List, + currentCriterion: Int, + onSelectCriterion: (Int) -> Unit, + globFeedback: GroupAssignmentState.FeedbackEntry?, + criterionFeedback: GroupAssignmentState.FeedbackEntry?, + autofill: List, + onSetGrade: (String) -> Unit, + onSetFeedback: (String) -> Unit, + modifier: Modifier = Modifier +) { + var grade by remember(globFeedback) { mutableStateOf(globFeedback?.grade ?: "") } + var feedback by remember(currentCriterion, criteria) { mutableStateOf(TextFieldValue(criterionFeedback?.feedback ?: "")) } + Column(modifier) { + Row { + Text("Overall grade: ", Modifier.align(Alignment.CenterVertically)) + OutlinedTextField(grade, { grade = it }, Modifier.weight(0.2f)) + Spacer(Modifier.weight(0.6f)) + Button( + { onSetGrade(grade); onSetFeedback(feedback.text) }, + Modifier.weight(0.2f).align(Alignment.CenterVertically), + enabled = grade.isNotBlank() || feedback.text.isNotBlank() + ) { + Text("Save") + } + } + TabRow(currentCriterion) { + Tab(currentCriterion == 0, { onSelectCriterion(0) }) { Text("General feedback", fontStyle = FontStyle.Italic) } + criteria.forEachIndexed { i, c -> + Tab(currentCriterion == i + 1, { onSelectCriterion(i + 1) }) { Text(c.name) } + } + } + Spacer(Modifier.height(5.dp)) + AutocompleteLineField( + feedback, { feedback = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") } + ) { filter -> + autofill.filter { x -> x.trim().startsWith(filter.trim()) } + } } } @@ -161,84 +309,201 @@ fun SoloAssignmentView(state: SoloAssignmentState) { val deadline by state.deadline val suggestions by state.autofill.entities val grades by state.feedback.entities + val criteria by state.criteria.entities - var idx by remember(state) { mutableStateOf(0) } + var tab by remember(state) { mutableStateOf(0) } + var idx by remember(state, tab) { mutableStateOf(0) } + var critIdx by remember(state, tab, idx) { mutableStateOf(0) } + var adding by remember(state, tab) { mutableStateOf(false) } + var confirming by remember(state, tab) { mutableStateOf(false) } + + val updateGrade = { grade: String -> + state.upsertFeedback( + grades[idx].first, + if(critIdx == 0) grades[idx].second.global?.feedback else grades[idx].second.byCriterion[critIdx - 1].second?.feedback, + grade, + if(critIdx == 0) null else criteria[critIdx - 1] + ) + } + + val updateFeedback = { feedback: String -> + state.upsertFeedback( + grades[idx].first, + feedback, + if(critIdx == 0) grades[idx].second.global?.grade else grades[idx].second.byCriterion[critIdx - 1].second?.grade, + if(critIdx == 0) null else criteria[critIdx - 1] + ) + } Column(Modifier.padding(10.dp)) { Row { Surface(Modifier.weight(0.25f), tonalElevation = 10.dp) { - LazyColumn(Modifier.fillMaxHeight().padding(10.dp)) { - item { - Surface( - Modifier.fillMaxWidth().clickable { idx = 0 }, - tonalElevation = if (idx == 0) 50.dp else 0.dp, - shape = MaterialTheme.shapes.medium - ) { - Text("Assignment", Modifier.padding(5.dp), fontStyle = FontStyle.Italic) + Column(Modifier.padding(10.dp)) { + TabRow(tab) { + Tab(tab == 0, { tab = 0 }) { Text("Task/Criteria") } + Tab(tab == 1, { tab = 1 }) { Text("Students") } + } + + LazyColumn(Modifier.weight(1f)) { + if (tab == 0) { + item { + Surface( + Modifier.fillMaxWidth().clickable { idx = 0 }, + tonalElevation = if (idx == 0) 50.dp else 0.dp, + shape = MaterialTheme.shapes.medium + ) { + Text("Assignment", Modifier.padding(5.dp), fontStyle = FontStyle.Italic) + } + } + + itemsIndexed(criteria) { i, crit -> + Surface( + Modifier.fillMaxWidth().clickable { idx = i + 1 }, + tonalElevation = if (idx == i + 1) 50.dp else 0.dp, + shape = MaterialTheme.shapes.medium + ) { + Text(crit.name, Modifier.padding(5.dp)) + } + } + } else { + itemsIndexed(grades.toList()) { i, (student, _) -> + Surface( + Modifier.fillMaxWidth().clickable { idx = i }, + tonalElevation = if (idx == i) 50.dp else 0.dp, + shape = MaterialTheme.shapes.medium + ) { + Text(student.name, Modifier.padding(5.dp)) + } + } } } - itemsIndexed(grades.toList()) { i, (student, _) -> - Surface( - Modifier.fillMaxWidth().clickable { idx = i + 1 }, - tonalElevation = if (idx == i + 1) 50.dp else 0.dp, - shape = MaterialTheme.shapes.medium - ) { - Text(student.name, Modifier.padding(5.dp)) + if (tab == 0) { + Button({ adding = true }, Modifier.align(Alignment.CenterHorizontally).fillMaxWidth()) { + Text("Add evaluation criterion") } } } } Column(Modifier.weight(0.75f).padding(10.dp)) { - if (idx == 0) { - val updTask = rememberRichTextState() + if(tab == 0) { + if (idx == 0) { + val updTask = rememberRichTextState() - LaunchedEffect(task) { updTask.setMarkdown(task) } + LaunchedEffect(task) { updTask.setMarkdown(task) } - Row { - DateTimePicker(deadline, { state.updateDeadline(it) }) - } - RichTextStyleRow(state = updTask) - OutlinedRichTextEditor( - state = updTask, - modifier = Modifier.fillMaxWidth().weight(1f), - singleLine = false, - minLines = 5, - label = { Text("Task") } - ) - CancelSaveRow( - true, - { updTask.setMarkdown(task) }, - "Reset", - "Update" - ) { state.updateTask(updTask.toMarkdown()) } - } else { - val (student, fg) = grades[idx - 1] - var sGrade by remember(idx) { mutableStateOf(fg?.grade ?: "") } - var sMsg by remember(idx) { mutableStateOf(TextFieldValue(fg?.feedback ?: "")) } - Row { - Text("Grade: ", Modifier.align(Alignment.CenterVertically)) - OutlinedTextField(sGrade, { sGrade = it }, Modifier.weight(0.2f)) - Spacer(Modifier.weight(0.6f)) - Button( - { state.upsertFeedback(student, sMsg.text, sGrade) }, - Modifier.weight(0.2f).align(Alignment.CenterVertically), - enabled = sGrade.isNotBlank() || sMsg.text.isNotBlank() - ) { - Text("Save") + Row { + DateTimePicker(deadline, { state.updateDeadline(it) }) + } + RichTextStyleRow(state = updTask) + OutlinedRichTextEditor( + state = updTask, + modifier = Modifier.fillMaxWidth().weight(1f), + singleLine = false, + minLines = 5, + label = { Text("Task") } + ) + CancelSaveRow( + true, + { updTask.setMarkdown(task) }, + "Reset", + "Update" + ) { state.updateTask(updTask.toMarkdown()) } + } else { + val crit = criteria[idx - 1] + var name by remember(crit) { mutableStateOf(crit.name) } + var desc by remember(crit) { mutableStateOf(crit.description) } + + Column { + Row { + OutlinedTextField(name, { name = it }, Modifier.weight(0.8f)) + Spacer(Modifier.weight(0.1f)) + Button({ state.updateCriterion(crit, name, desc) }, Modifier.weight(0.1f)) { + Text("Update") + } + } + OutlinedTextField( + desc, { desc = it }, Modifier.fillMaxWidth().weight(1f), + label = { Text("Description") }, + singleLine = false, + minLines = 5 + ) + Button({ confirming = true }, Modifier.fillMaxWidth()) { + Text("Remove criterion") + } } } - - AutocompleteLineField( - sMsg, { sMsg = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") } - ) { filter -> - suggestions.filter { x -> x.trim().startsWith(filter.trim()) } - } + } + else { + soloFeedbackPane( + criteria, critIdx, { critIdx = it }, grades[idx].second.global, + if(critIdx == 0) grades[idx].second.global else grades[idx].second.byCriterion[critIdx - 1].second, + suggestions, updateGrade, updateFeedback, + key = tab to idx + ) } } } } + + if(adding) { + AddStringDialog( + "Evaluation criterion name", criteria.map{ it.name }, { adding = false } + ) { state.addCriterion(it) } + } + + if(confirming && idx != 0) { + ConfirmDeleteDialog( + "an evaluation criterion", + { confirming = false }, { state.deleteCriterion(criteria[idx - 1]); idx = 0 } + ) { + Text(criteria[idx - 1].name) + } + } +} + +@Composable +fun soloFeedbackPane( + criteria: List, + currentCriterion: Int, + onSelectCriterion: (Int) -> Unit, + globFeedback: SoloAssignmentState.LocalFeedback?, + criterionFeedback: SoloAssignmentState.LocalFeedback?, + autofill: List, + onSetGrade: (String) -> Unit, + onSetFeedback: (String) -> Unit, + modifier: Modifier = Modifier, + key: Any? = null +) { + var grade by remember(globFeedback, key) { mutableStateOf(globFeedback?.grade ?: "") } + var feedback by remember(currentCriterion, criteria, key) { mutableStateOf(TextFieldValue(criterionFeedback?.feedback ?: "")) } + Column(modifier) { + Row { + Text("Overall grade: ", Modifier.align(Alignment.CenterVertically)) + OutlinedTextField(grade, { grade = it }, Modifier.weight(0.2f)) + Spacer(Modifier.weight(0.6f)) + Button( + { onSetGrade(grade); onSetFeedback(feedback.text) }, + Modifier.weight(0.2f).align(Alignment.CenterVertically), + enabled = grade.isNotBlank() || feedback.text.isNotBlank() + ) { + Text("Save") + } + } + TabRow(currentCriterion) { + Tab(currentCriterion == 0, { onSelectCriterion(0) }) { Text("General feedback", fontStyle = FontStyle.Italic) } + criteria.forEachIndexed { i, c -> + Tab(currentCriterion == i + 1, { onSelectCriterion(i + 1) }) { Text(c.name) } + } + } + Spacer(Modifier.height(5.dp)) + AutocompleteLineField( + feedback, { feedback = it }, Modifier.fillMaxWidth().weight(1f), { Text("Feedback") } + ) { filter -> + autofill.filter { x -> x.trim().startsWith(filter.trim()) } + } + } } @Composable diff --git a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt index 5be3ffc..e4ef136 100644 --- a/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt +++ b/composeApp/src/desktopMain/kotlin/com/jaytux/grader/viewmodel/DbState.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf import com.jaytux.grader.data.* import com.jaytux.grader.data.EditionStudents.editionId import com.jaytux.grader.data.EditionStudents.studentId +import com.jaytux.grader.viewmodel.GroupAssignmentState.* import kotlinx.datetime.* import kotlinx.datetime.TimeZone import org.jetbrains.exposed.dao.id.EntityID @@ -445,18 +446,27 @@ class GroupState(val group: Group) { } class GroupAssignmentState(val assignment: GroupAssignment) { - data class LocalFeedback(val feedback: String, val grade: String) + data class FeedbackEntry(val feedback: String, val grade: String) + data class LocalCriterionFeedback( + val criterion: GroupAssignmentCriterion, val entry: FeedbackEntry? + ) + data class LocalFeedback( + val global: FeedbackEntry?, val byCriterion: List + ) data class LocalGFeedback( val group: Group, - val feedback: LocalFeedback?, - val individuals: List>> + val feedback: LocalFeedback, + val individuals: List>> ) val editionCourse = transaction { assignment.edition.course to assignment.edition } private val _name = mutableStateOf(assignment.name); val name = _name.immutable() private val _task = mutableStateOf(assignment.assignment); val task = _task.immutable() - val feedback = RawDbState { loadFeedback() } private val _deadline = mutableStateOf(assignment.deadline); val deadline = _deadline.immutable() + val criteria = RawDbState { + assignment.criteria.orderBy(GroupAssignmentCriteria.name to SortOrder.ASC).toList() + } + val feedback = RawDbState { loadFeedback() } val autofill = RawDbState { val forGroups = GroupFeedbacks.selectAll().where { GroupFeedbacks.groupAssignmentId eq assignment.id }.flatMap { @@ -471,50 +481,63 @@ class GroupAssignmentState(val assignment: GroupAssignment) { } private fun Transaction.loadFeedback(): List> { - val individuals = IndividualFeedbacks.selectAll().where { - IndividualFeedbacks.groupAssignmentId eq assignment.id - }.map { - it[IndividualFeedbacks.studentId] to LocalFeedback(it[IndividualFeedbacks.feedback], it[IndividualFeedbacks.grade]) - }.associate { it } - - val groupFeedbacks = GroupFeedbacks.selectAll().where { - GroupFeedbacks.groupAssignmentId eq assignment.id - }.map { - it[GroupFeedbacks.groupId] to (it[GroupFeedbacks.feedback] to it[GroupFeedbacks.grade]) - }.associate { it } - - val groups = Group.find { + return Group.find { (Groups.editionId eq assignment.edition.id) }.sortAsc(Groups.name).map { group -> - val students = group.studentRoles.sortedBy { it.student.name }.map { sR -> - val student = sR.student - val role = sR.role - val feedback = individuals[student.id] - - student to (role to feedback) + // step 1: group-level feedback, including criteria + val forGroup = GroupFeedbacks.selectAll().where { + (GroupFeedbacks.groupAssignmentId eq assignment.id) and + (GroupFeedbacks.groupId eq group.id) + }.associate { + val criterion = it[GroupFeedbacks.criterionId]?.let { id -> GroupAssignmentCriterion[id] } + val fe = FeedbackEntry(it[GroupFeedbacks.feedback], it[GroupFeedbacks.grade]) + criterion to fe } + val feedback = LocalFeedback( + global = forGroup[null], + byCriterion = criteria.entities.value.map { c -> LocalCriterionFeedback(c, forGroup[c]) } + ) - groupFeedbacks[group.id]?.let { (f, g) -> - group to LocalGFeedback(group, LocalFeedback(f, g), students) - } ?: (group to LocalGFeedback(group, null, students)) + // step 2: individual feedback + val individuals = group.studentRoles.map { sr -> + val student = sr.student + val role = sr.role + + val forStudent = IndividualFeedbacks.selectAll().where { + (IndividualFeedbacks.groupAssignmentId eq assignment.id) and + (IndividualFeedbacks.groupId eq group.id) and + (IndividualFeedbacks.studentId eq student.id) + }.associate { + val criterion = it[IndividualFeedbacks.criterionId]?.let { id -> GroupAssignmentCriterion[id] } + val fe = FeedbackEntry(it[IndividualFeedbacks.feedback], it[IndividualFeedbacks.grade]) + criterion to fe + } + val studentFeedback = LocalFeedback( + global = forStudent[null], + byCriterion = criteria.entities.value.map { c -> LocalCriterionFeedback(c, forStudent[c]) } + ) + + student to (role to studentFeedback) + }.sortedBy { it.first.name } + + group to LocalGFeedback(group, feedback, individuals) } - - return groups } - fun upsertGroupFeedback(group: Group, msg: String, grd: String) { + fun upsertGroupFeedback(group: Group, msg: String, grd: String, criterion: GroupAssignmentCriterion? = null) { transaction { GroupFeedbacks.upsert { it[groupAssignmentId] = assignment.id it[groupId] = group.id it[this.feedback] = msg it[this.grade] = grd + it[criterionId] = criterion?.id } } feedback.refresh(); autofill.refresh() } - fun upsertIndividualFeedback(student: Student, group: Group, msg: String, grd: String) { + fun upsertIndividualFeedback(student: Student, group: Group, msg: String, grd: String, criterion: GroupAssignmentCriterion? = null) { transaction { IndividualFeedbacks.upsert { it[groupAssignmentId] = assignment.id @@ -522,6 +545,7 @@ class GroupAssignmentState(val assignment: GroupAssignment) { it[studentId] = student.id it[this.feedback] = msg it[this.grade] = grd + it[criterionId] = criterion?.id } } feedback.refresh(); autofill.refresh() @@ -540,16 +564,48 @@ class GroupAssignmentState(val assignment: GroupAssignment) { } _deadline.value = d } + + fun addCriterion(name: String) { + transaction { + GroupAssignmentCriterion.new { + this.name = name; + this.description = ""; + this.assignment = this@GroupAssignmentState.assignment + } + criteria.refresh() + } + } + + fun updateCriterion(criterion: GroupAssignmentCriterion, name: String, desc: String) { + transaction { + criterion.name = name + criterion.description = desc + } + criteria.refresh() + } + + fun deleteCriterion(criterion: GroupAssignmentCriterion) { + transaction { + GroupFeedbacks.deleteWhere { criterionId eq criterion.id } + IndividualFeedbacks.deleteWhere { criterionId eq criterion.id } + criterion.delete() + } + criteria.refresh() + } } class SoloAssignmentState(val assignment: SoloAssignment) { data class LocalFeedback(val feedback: String, val grade: String) + data class FullFeedback(val global: LocalFeedback?, val byCriterion: List>) val editionCourse = transaction { assignment.edition.course to assignment.edition } private val _name = mutableStateOf(assignment.name); val name = _name.immutable() private val _task = mutableStateOf(assignment.assignment); val task = _task.immutable() - val feedback = RawDbState { loadFeedback() } private val _deadline = mutableStateOf(assignment.deadline); val deadline = _deadline.immutable() + val criteria = RawDbState { + assignment.criteria.orderBy(SoloAssignmentCriteria.name to SortOrder.ASC).toList() + } + val feedback = RawDbState { loadFeedback() } val autofill = RawDbState { SoloFeedbacks.selectAll().where { SoloFeedbacks.soloAssignmentId eq assignment.id }.map { @@ -557,24 +613,33 @@ class SoloAssignmentState(val assignment: SoloAssignment) { }.flatten().distinct().sorted() } - private fun Transaction.loadFeedback(): List> { - val students = editionCourse.second.soloStudents - val feedbacks = SoloFeedbacks.selectAll().where { - SoloFeedbacks.soloAssignmentId eq assignment.id - }.associate { - it[SoloFeedbacks.studentId] to LocalFeedback(it[SoloFeedbacks.feedback], it[SoloFeedbacks.grade]) - } + private fun Transaction.loadFeedback(): List> { + return editionCourse.second.soloStudents.sortAsc(Students.name).map { student -> + val each = SoloFeedbacks.selectAll().where { + (SoloFeedbacks.soloAssignmentId eq assignment.id) and + (SoloFeedbacks.studentId eq student.id) + }.associate { + val criterion = it[SoloFeedbacks.criterionId]?.let { id -> SoloAssignmentCriterion[id] } + val fe = LocalFeedback(it[SoloFeedbacks.feedback], it[SoloFeedbacks.grade]) + criterion to fe + } + val feedback = FullFeedback( + global = each[null], + byCriterion = criteria.entities.value.map { c -> c to each[c] } + ) - return students.map { s -> s to feedbacks[s.id] } + student to feedback + } } - fun upsertFeedback(student: Student, msg: String, grd: String) { + fun upsertFeedback(student: Student, msg: String?, grd: String?, criterion: SoloAssignmentCriterion? = null) { transaction { SoloFeedbacks.upsert { it[soloAssignmentId] = assignment.id it[studentId] = student.id - it[this.feedback] = msg - it[this.grade] = grd + it[this.feedback] = msg ?: "" + it[this.grade] = grd ?: "" + it[criterionId] = criterion?.id } } feedback.refresh(); autofill.refresh() @@ -593,6 +658,33 @@ class SoloAssignmentState(val assignment: SoloAssignment) { } _deadline.value = d } + + fun addCriterion(name: String) { + transaction { + SoloAssignmentCriterion.new { + this.name = name; + this.description = ""; + this.assignment = this@SoloAssignmentState.assignment + } + criteria.refresh() + } + } + + fun updateCriterion(criterion: SoloAssignmentCriterion, name: String, desc: String) { + transaction { + criterion.name = name + criterion.description = desc + } + criteria.refresh() + } + + fun deleteCriterion(criterion: SoloAssignmentCriterion) { + transaction { + SoloFeedbacks.deleteWhere { criterionId eq criterion.id } + criterion.delete() + } + criteria.refresh() + } } class PeerEvaluationState(val evaluation: PeerEvaluation) {