Skip to content

Commit

Permalink
feat: classroom control (#52)
Browse files Browse the repository at this point in the history
* update docs

* save classroom in pupilProfile

* fix

* fix tests

* fix tests

* let removepupil remove classroom from pupilprofile

* change id to classroomId in response

* spelling

* update classroom response to send teacher emails

* update classroom detail response to send pupil status

* Add membership status to pupil profile

* update imports

* move adding classroom to pupil profile to happen only when pupil has been approved

* update function name

* fix merge breaks

* update tests

* spotlessApply

* Assume only one membership per pupil

* Assume only one membership per pupil

* fix test

* spotless

* update tests

* fix merge issue

* move repository stuff to service

* spotless

---------

Co-authored-by: Maria <145002050+marikolafs@users.noreply.github.com>
  • Loading branch information
marikola and Maria authored Apr 30, 2026
1 parent f338964 commit 2b39beb
Show file tree
Hide file tree
Showing 14 changed files with 269 additions and 109 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import edu.ntnu.idi.idatt.backend.classroom.application.JoinClassroomResult
import edu.ntnu.idi.idatt.backend.classroom.application.OwnedClassroomSummary
import edu.ntnu.idi.idatt.backend.classroom.application.UpdateClassroomCommand
import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom
import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomPupilStatus
import edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress
import edu.ntnu.idi.idatt.backend.iam.domain.User

/**
Expand Down Expand Up @@ -74,6 +76,10 @@ fun Classroom.toResponse(): ClassroomResponse =
title = title,
description = description,
joinCode = joinCode ?: "",
teacherEmails = teacherMemberships.mapNotNull { it.teacher?.email },
pupilCount = pupilMemberships.count { it.status == ClassroomPupilStatus.ACTIVE },
pendingPupilCount = pupilMemberships.count { it.status == ClassroomPupilStatus.PENDING },
teacherCount = teacherMemberships.size,
status = status.name,
ownerTeacherId = ownerTeacherId,
teachers = teacherResponses(),
Expand All @@ -87,13 +93,16 @@ fun Classroom.toResponse(): ClassroomResponse =
*/
fun Classroom.toDetailResponse(
pupilDisplayNames: Map<Long, String> = emptyMap(),
pupilProgressByUserId: Map<Long, edu.ntnu.idi.idatt.backend.game.gameProgress.domain.PupilGameProgress> = emptyMap(),
pupilProgressByUserId: Map<Long, PupilGameProgress> = emptyMap(),
): ClassroomDetailResponse =
ClassroomDetailResponse(
id = id,
classroomId = id,
title = title,
description = description,
joinCode = joinCode ?: "",
pupilCount = pupilMemberships.count { it.status == ClassroomPupilStatus.ACTIVE },
pendingPupilCount = pupilMemberships.count { it.status == ClassroomPupilStatus.PENDING },
teacherCount = teacherMemberships.size,
status = status.name,
ownerTeacherId = ownerTeacherId,
teachers = teacherResponses(),
Expand All @@ -106,6 +115,7 @@ fun Classroom.toDetailResponse(
ClassroomPupilResponse(
userId = pupilId,
displayName = pupilDisplayNames[pupilId] ?: pupil.username ?: "Unknown",
pupilStatus = membership.status,
currentLevel = progress?.currentLevel ?: 1,
completedStopsCount = progress?.completedStopsCount ?: 0,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package edu.ntnu.idi.idatt.backend.classroom.api
/**
* DTO for response to retrieving classroom details.
*
* @param id the id of the classroom.
* @param classroomId the id of the classroom.
* @param title the title of the classroom.
* @param description the description of the classroom.
* @param joinCode the code for joining the classroom.
Expand All @@ -13,10 +13,13 @@ package edu.ntnu.idi.idatt.backend.classroom.api
* @param pupils a list of the pupils that are members of the classroom.
*/
data class ClassroomDetailResponse(
val id: Long,
val classroomId: Long,
val title: String,
val description: String?,
val joinCode: String,
val pupilCount: Int,
val pendingPupilCount: Int,
val teacherCount: Int,
val status: String,
val ownerTeacherId: Long,
val teachers: List<ClassroomTeacherResponse>,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package edu.ntnu.idi.idatt.backend.classroom.api

import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomPupilStatus

/**
* DTO for response to retrieving pupil information for a classroom.
*
* @param userId the id of a pupil in the classroom.
* @param displayName the name of a pupil in the classroom.
* @param pupilStatus the pupils pending/approved status.
*/
data class ClassroomPupilResponse(
val userId: Long,
val displayName: String,
val pupilStatus: ClassroomPupilStatus,
val currentLevel: Int,
val completedStopsCount: Int,
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package edu.ntnu.idi.idatt.backend.classroom.api
* @param title the title of a classroom.
* @param description the optional description of a classroom.
* @param joinCode the code to join the classroom.
* @param teacherEmails a list of ids of teahcers belonging to the classroom by email.
* @param status whether the classroom is active or inactive.
* @param ownerTeacherId the id of the teacher who owns the classroom.
* @param teachers a list of teachers belonging to the classroom.
Expand All @@ -17,6 +18,10 @@ data class ClassroomResponse(
val title: String,
val description: String?,
val joinCode: String,
val teacherEmails: List<String>,
val pupilCount: Int,
val pendingPupilCount: Int,
val teacherCount: Int,
val status: String,
val ownerTeacherId: Long,
val teachers: List<ClassroomTeacherResponse>,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package edu.ntnu.idi.idatt.backend.classroom.application

import edu.ntnu.idi.idatt.backend.classroom.domain.Classroom
import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomPupilMembership
import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomPupilStatus
import edu.ntnu.idi.idatt.backend.classroom.domain.ClassroomStatus
import edu.ntnu.idi.idatt.backend.classroom.infrastructure.ClassroomRepository
Expand Down Expand Up @@ -124,7 +125,7 @@ class ClassroomService(
*
* @param command command with classroom code and authenticated user subject
* @throws ResponseStatusException when classroom or user does not exist, user is not a pupil,
* user is already a member, or classroom is inactive
* user is already a member of the classroom, user is already a member of a classroom, or classroom is inactive
*/
@Transactional
fun joinClassroom(command: JoinClassroomCommand): JoinClassroomResult {
Expand All @@ -144,12 +145,18 @@ class ClassroomService(
throw ResponseStatusException(HttpStatus.CONFLICT, "User already a member")
}

val hasMembership = classroomRepository.findAllByPupilMembershipsPupilId(userId).flatMap { it.pupilMemberships }.any { it.pupil?. id == userId }
if (hasMembership) {
throw ResponseStatusException(HttpStatus.CONFLICT, "User is already a member in a classroom")
}

classroom.addPupilMembership(user)
try {
userProfileService.updateCurrentClassroom(userId, classroom)
} catch (_: NoSuchElementException) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Profile not found")
}

classroomRepository.save(classroom)
pupilGameProgressService.createPupilGameProgressEntry(
pupilId = userId,
Expand Down Expand Up @@ -192,6 +199,8 @@ class ClassroomService(
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Pupil not found in classroom")
}

userProfileService.updateCurrentClassroom(pupilUserId, classroom)

classroomRepository.save(classroom)
}

Expand All @@ -216,6 +225,8 @@ class ClassroomService(
classroomRepository.findById(classroomId).orElse(null)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Classroom not found")

userProfileService.updateCurrentClassroom(pupilUserId, null)

try {
classroom.removePupil(pupilUserId)
} catch (e: IllegalArgumentException) {
Expand Down Expand Up @@ -352,6 +363,28 @@ class ClassroomService(
classroomRepository.deleteById(classroomId)
}

/**
* Finds a pupil membership based on the pupil's id.
*
* @param userId persisted pupil user id
* @return pupil membership, or `null`when no membership exists
* @throws IllegalStateException if pupil has more than one classroom membership
*/
@Transactional(readOnly = true)
fun findPupilMembership(userId: Long): ClassroomPupilMembership? {
val memberships =
classroomRepository
.findAllByPupilMembershipsPupilId(userId)
.flatMap { it.pupilMemberships }
.filter { it.pupil?.id == userId }

if (memberships.size > 1) {
throw IllegalStateException("Invariant violated: Pupil has multiple classroom memberships")
}

return memberships.firstOrNull()
}

/**
* Normalizes a classroom join code by trimming whitespace and converting to uppercase.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,12 @@ interface ClassroomRepository : JpaRepository<Classroom, Long> {
nativeQuery = true,
)
fun findAllWithAverageTotalScore(): List<ClassroomAverageTotalScoreView>

/**
* Returns classroom memberships for a pupil based on the pupils id.
*
* @param userId the id of the pupil being checked.
* @return a list of classrooms the pupil is a member of.
*/
fun findAllByPupilMembershipsPupilId(userId: Long): List<Classroom>
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package edu.ntnu.idi.idatt.backend.users.profile.api

import edu.ntnu.idi.idatt.backend.users.profile.application.CurrentUserProfile
import edu.ntnu.idi.idatt.backend.users.profile.application.PupilProfileDetails
import edu.ntnu.idi.idatt.backend.users.profile.application.CurrentUserProfileService.CurrentUserProfile
import edu.ntnu.idi.idatt.backend.users.profile.application.CurrentUserProfileService.PupilProfileDetails
import edu.ntnu.idi.idatt.backend.users.profile.application.UpdateCurrentUserDisplayNameCommand

/**
Expand All @@ -20,6 +20,7 @@ fun CurrentUserProfile.toCurrentUserProfileResponse(): CurrentUserProfileRespons
PupilProfileResponse(
displayName = it.displayName,
currentClassroomId = it.currentClassroomId,
membershipStatus = it.membershipStatus,
totalXp = it.totalXp,
currentLevel = it.currentLevel,
hasCompletedOnboarding = it.hasCompletedOnboarding,
Expand All @@ -43,6 +44,7 @@ fun PupilProfileDetails.toPupilProfileResponse(): PupilProfileResponse =
PupilProfileResponse(
displayName = displayName,
currentClassroomId = currentClassroomId,
membershipStatus = membershipStatus,
totalXp = totalXp,
currentLevel = currentLevel,
hasCompletedOnboarding = hasCompletedOnboarding,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ data class CurrentUserProfileResponse(
data class PupilProfileResponse(
val displayName: String,
val currentClassroomId: Long?,
val membershipStatus: String? = null,
val totalXp: Int,
val currentLevel: Int,
val hasCompletedOnboarding: Boolean,
Expand Down
Loading

0 comments on commit 2b39beb

Please sign in to comment.