From 6b4cfbd3c0a7ae8148ee932495e6eb23d01f2f2e Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Thu, 30 Jan 2025 10:02:58 +0100 Subject: [PATCH 01/16] adjusting middleware to imply the student roles --- server/db/query/permission_strings.sql | 10 ++ server/db/sqlc/permission_strings.sql.go | 35 +++++++ server/keycloak/main.go | 5 +- server/keycloak/middleware.go | 118 +++++++++++++++++------ 4 files changed, 137 insertions(+), 31 deletions(-) diff --git a/server/db/query/permission_strings.sql b/server/db/query/permission_strings.sql index b33c90af..41b1daf2 100644 --- a/server/db/query/permission_strings.sql +++ b/server/db/query/permission_strings.sql @@ -21,3 +21,13 @@ FROM course c JOIN course_participation cp ON c.id = cp.course_id JOIN course_phase_participation cpp ON cp.id = cpp.course_participation_id WHERE cpp.id = $1; + + + +-- name: GetStudentRoleStrings :many +SELECT CONCAT(c.name, '-', c.semester_tag, '-Student')::text AS student_role +FROM course c +JOIN course_participation cp ON c.id = cp.course_id +JOIN student s ON cp.student_id = s.id +WHERE s.matriculation_number = $1 +AND s.university_login = $2; \ No newline at end of file diff --git a/server/db/sqlc/permission_strings.sql.go b/server/db/sqlc/permission_strings.sql.go index d8d01b24..6121cebf 100644 --- a/server/db/sqlc/permission_strings.sql.go +++ b/server/db/sqlc/permission_strings.sql.go @@ -9,6 +9,7 @@ import ( "context" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) const getPermissionStringByCourseID = `-- name: GetPermissionStringByCourseID :one @@ -66,3 +67,37 @@ func (q *Queries) GetPermissionStringByCoursePhaseParticipationID(ctx context.Co err := row.Scan(&course_identifier) return course_identifier, err } + +const getStudentRoleStrings = `-- name: GetStudentRoleStrings :many +SELECT CONCAT(c.name, '-', c.semester_tag, '-Student')::text AS student_role +FROM course c +JOIN course_participation cp ON c.id = cp.course_id +JOIN student s ON cp.student_id = s.id +WHERE s.matriculation_number = $1 +AND s.university_login = $2 +` + +type GetStudentRoleStringsParams struct { + MatriculationNumber pgtype.Text `json:"matriculation_number"` + UniversityLogin pgtype.Text `json:"university_login"` +} + +func (q *Queries) GetStudentRoleStrings(ctx context.Context, arg GetStudentRoleStringsParams) ([]string, error) { + rows, err := q.db.Query(ctx, getStudentRoleStrings, arg.MatriculationNumber, arg.UniversityLogin) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var student_role string + if err := rows.Scan(&student_role); err != nil { + return nil, err + } + items = append(items, student_role) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/server/keycloak/main.go b/server/keycloak/main.go index e7f57708..d9f6635f 100644 --- a/server/keycloak/main.go +++ b/server/keycloak/main.go @@ -4,6 +4,7 @@ import ( "context" "github.com/Nerzal/gocloak/v13" + db "github.com/niclasheun/prompt2.0/db/sqlc" ) type KeycloakClientManager struct { @@ -14,11 +15,12 @@ type KeycloakClientManager struct { ClientSecret string idOfClient string expectedAuthorizedParty string + queries db.Queries } var KeycloakSingleton *KeycloakClientManager -func InitKeycloak(ctx context.Context, BaseURL, Realm, ClientID, ClientSecret, idOfClient, expectedAuthorizedParty string) error { +func InitKeycloak(ctx context.Context, BaseURL, Realm, ClientID, ClientSecret, idOfClient, expectedAuthorizedParty string, queries db.Queries) error { KeycloakSingleton = &KeycloakClientManager{ client: gocloak.NewClient(BaseURL), BaseURL: BaseURL, @@ -27,6 +29,7 @@ func InitKeycloak(ctx context.Context, BaseURL, Realm, ClientID, ClientSecret, i ClientSecret: ClientSecret, idOfClient: idOfClient, expectedAuthorizedParty: expectedAuthorizedParty, + queries: queries, } // Test Login connection diff --git a/server/keycloak/middleware.go b/server/keycloak/middleware.go index 2ae8067e..6d17af0b 100644 --- a/server/keycloak/middleware.go +++ b/server/keycloak/middleware.go @@ -1,12 +1,16 @@ package keycloak import ( + "context" + "errors" "fmt" "net/http" "strings" "github.com/coreos/go-oidc/v3/oidc" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgtype" + db "github.com/niclasheun/prompt2.0/db/sqlc" log "github.com/sirupsen/logrus" ) @@ -47,59 +51,61 @@ func KeycloakMiddleware() gin.HandlerFunc { return } - // manually check the audience, as the it is disabled in the verifier config (for allowing students to apply) - if !checkAudience(claims, KeycloakSingleton.ClientID) { - log.Error("Token audience mismatch") - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token audience mismatch"}) + // extract user Id + userID, ok := claims["sub"].(string) + if !ok { + log.Error("Failed to extract user ID (sub) from token claims") + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID"}) return } - resourceAccess, err := extractResourceAccess(claims) - if err != nil { - log.Error("Failed to extract resource access: ", err) - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return + userEmail, ok := claims["email"].(string) + if !ok { + log.Error("Failed to extract user ID (sub) from token claims") } - rolesInterface, ok := resourceAccess[KeycloakSingleton.ClientID].(map[string]interface{})["roles"] + matriculationNumber, ok := claims["matriculation_number"].(string) if !ok { - log.Error("Failed to extract roles from resource access") - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Failed to extract roles"}) - return + log.Error("Failed to extract user matriculation number (sub) from token claims") } - roles, ok := rolesInterface.([]interface{}) + universityLogin, ok := claims["university_login"].(string) if !ok { - log.Error("Roles are not in expected format") - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid roles format"}) + log.Error("Failed to extract user university login (sub) from token claims") + } + + // Retrieve all user's roles from the token (if any) for the audience prompt-server (clientID) + userRoles, err := checkKeycloakRoles(claims) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "could not authenticate user"}) return } - // Convert roles to map[string]bool for easier downstream usage - userRoles := make(map[string]bool) - for _, role := range roles { - if roleStr, ok := role.(string); ok { - userRoles[roleStr] = true - } + // Retrieve all student roles from the DB + studentRoles, err := getStudentRoles(matriculationNumber, universityLogin) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "could not authenticate user"}) + return } - // extract user Id - userID, ok := claims["sub"].(string) - if !ok { - log.Error("Failed to extract user ID (sub) from token claims") - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID"}) + if len(studentRoles) == 0 && len(userRoles) == 0 { + log.Error("User has no roles") + c.AbortWithStatus(http.StatusUnauthorized) return } - userEmail, ok := claims["email"].(string) - if !ok { - log.Error("Failed to extract user ID (sub) from token claims") + // store also the student roles in the userRoles map + for _, role := range studentRoles { + userRoles[role] = true } + log.Info("User roles: ", userRoles) // Store the extracted roles in the context c.Set("userRoles", userRoles) c.Set("userID", userID) c.Set("userEmail", userEmail) + c.Set("matriculationNumber", matriculationNumber) + c.Set("universityLogin", universityLogin) c.Next() } } @@ -141,6 +147,41 @@ func checkAudience(claims map[string]interface{}, expectedClientID string) bool return false } +func checkKeycloakRoles(claims map[string]interface{}) (map[string]bool, error) { + userRoles := make(map[string]bool) + if !checkAudience(claims, KeycloakSingleton.ClientID) { + log.Debug("No keycloak roles found for ClientID") + return userRoles, nil + } + + // user has Prompt keycloak roles + resourceAccess, err := extractResourceAccess(claims) + if err != nil { + log.Error("Failed to extract resource access: ", err) + return nil, errors.New("could not authenticate user") + } + + rolesInterface, ok := resourceAccess[KeycloakSingleton.ClientID].(map[string]interface{})["roles"] + if !ok { + log.Error("Failed to extract roles from resource access") + return nil, errors.New("could not authenticate user") + } + + roles, ok := rolesInterface.([]interface{}) + if !ok { + log.Error("Roles are not in expected format") + return nil, errors.New("could not authenticate user") + } + + // Convert roles to map[string]bool for easier downstream usage + for _, role := range roles { + if roleStr, ok := role.(string); ok { + userRoles[roleStr] = true + } + } + return userRoles, nil +} + // extractResourceAccess retrieves the "resource_access" claim, which contains role information. func extractResourceAccess(claims map[string]interface{}) (map[string]interface{}, error) { resourceAccess, ok := claims["resource_access"].(map[string]interface{}) @@ -157,3 +198,20 @@ func checkAuthorizedParty(claims map[string]interface{}, expectedAuthorizedParty } return azp == expectedAuthorizedParty } + +func getStudentRoles(matriculationNumber, universityLogin string) ([]string, error) { + ctx := context.Background() + ctxWithTimeout, cancel := db.GetTimeoutContext(ctx) + defer cancel() + // Retrieve course roles from the DB + studentRoles, err := KeycloakSingleton.queries.GetStudentRoleStrings(ctxWithTimeout, db.GetStudentRoleStringsParams{ + MatriculationNumber: pgtype.Text{String: matriculationNumber, Valid: true}, + UniversityLogin: pgtype.Text{String: universityLogin, Valid: true}, + }) + if err != nil { + log.Error("Failed to retrieve student roles: ", err) + return nil, errors.New("could retrieve student roles") + } + + return studentRoles, nil +} From 9d817733a4fad81008f5b1949a879d0540a02b70 Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Thu, 30 Jan 2025 10:39:13 +0100 Subject: [PATCH 02/16] adding end point to get student --- .../get_cpp_student.go | 46 ++++++ .../coursePhaseParticipation/router.go | 25 +++ .../coursePhaseParticipation/service.go | 20 +++ .../db/query/course_phase_participation.sql | 99 ++++++++++++ .../db/sqlc/course_phase_participation.sql.go | 147 ++++++++++++++++++ 5 files changed, 337 insertions(+) create mode 100644 server/coursePhase/coursePhaseParticipation/coursePhaseParticipationDTO/get_cpp_student.go diff --git a/server/coursePhase/coursePhaseParticipation/coursePhaseParticipationDTO/get_cpp_student.go b/server/coursePhase/coursePhaseParticipation/coursePhaseParticipationDTO/get_cpp_student.go new file mode 100644 index 00000000..e4199031 --- /dev/null +++ b/server/coursePhase/coursePhaseParticipation/coursePhaseParticipationDTO/get_cpp_student.go @@ -0,0 +1,46 @@ +package coursePhaseParticipationDTO + +import ( + "github.com/google/uuid" + db "github.com/niclasheun/prompt2.0/db/sqlc" + "github.com/niclasheun/prompt2.0/meta" + "github.com/niclasheun/prompt2.0/student/studentDTO" + log "github.com/sirupsen/logrus" +) + +// this version does not contain any restricted data +// and student should also not see the pass status +type CoursePhaseParticipationStudent struct { + ID uuid.UUID `json:"id"` + CourseParticipationID uuid.UUID `json:"courseParticipationID"` + StudentReadableData meta.MetaData `json:"studentReadableData"` + Student studentDTO.Student `json:"student"` +} + +func GetCoursePhaseParticipationStudent(model db.GetCoursePhaseParticipationByUniversityLoginAndCoursePhaseRow) (CoursePhaseParticipationStudent, error) { + studentReadableData, err := meta.GetMetaDataDTOFromDBModel(model.StudentReadableData) + if err != nil { + log.Error("failed to create CoursePhaseParticipation DTO from DB model") + return CoursePhaseParticipationStudent{}, err + } + + return CoursePhaseParticipationStudent{ + ID: model.CoursePhaseParticipationID, + CourseParticipationID: model.CourseParticipationID, + StudentReadableData: studentReadableData, + Student: studentDTO.GetStudentDTOFromDBModel(db.Student{ + ID: model.StudentID, + FirstName: model.FirstName, + LastName: model.LastName, + Email: model.Email, + MatriculationNumber: model.MatriculationNumber, + UniversityLogin: model.UniversityLogin, + HasUniversityAccount: model.HasUniversityAccount, + Gender: model.Gender, + Nationality: model.Nationality, + StudyDegree: model.StudyDegree, + StudyProgram: model.StudyProgram, + CurrentSemester: model.CurrentSemester, + }), + }, nil +} diff --git a/server/coursePhase/coursePhaseParticipation/router.go b/server/coursePhase/coursePhaseParticipation/router.go index e32af3bf..73563e23 100644 --- a/server/coursePhase/coursePhaseParticipation/router.go +++ b/server/coursePhase/coursePhaseParticipation/router.go @@ -11,6 +11,7 @@ import ( func setupCoursePhaseParticipationRouter(routerGroup *gin.RouterGroup, authMiddleware func() gin.HandlerFunc, permissionIDMiddleware func(allowedRoles ...string) gin.HandlerFunc) { courseParticipation := routerGroup.Group("/course_phases/:uuid/participations", authMiddleware()) + courseParticipation.GET("/self", getOwnCoursePhaseParticipation) courseParticipation.GET("", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer, keycloak.CourseEditor), getParticipationsForCoursePhase) courseParticipation.POST("", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer), createCoursePhaseParticipation) courseParticipation.GET("/:participation_uuid", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer, keycloak.CourseEditor), getParticipation) @@ -19,6 +20,30 @@ func setupCoursePhaseParticipationRouter(routerGroup *gin.RouterGroup, authMiddl courseParticipation.PUT("", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer), updateBatchCoursePhaseParticipation) } +func getOwnCoursePhaseParticipation(c *gin.Context) { + id, err := uuid.Parse(c.Param("uuid")) + if err != nil { + handleError(c, http.StatusBadRequest, err) + return + } + + matriculationNumber := c.GetString("matriculationNumber") + universityLogin := c.GetString("universityLogin") + + if matriculationNumber == "" || universityLogin == "" { + handleError(c, http.StatusUnauthorized, err) + return + } + + courseParticipation, err := GetOwnCoursePhaseParticipation(c, id, matriculationNumber, universityLogin) + if err != nil { + handleError(c, http.StatusInternalServerError, err) + return + } + + c.IndentedJSON(http.StatusOK, courseParticipation) +} + func getParticipationsForCoursePhase(c *gin.Context) { id, err := uuid.Parse(c.Param("uuid")) if err != nil { diff --git a/server/coursePhase/coursePhaseParticipation/service.go b/server/coursePhase/coursePhaseParticipation/service.go index fec0b8f6..2c60483f 100644 --- a/server/coursePhase/coursePhaseParticipation/service.go +++ b/server/coursePhase/coursePhaseParticipation/service.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/niclasheun/prompt2.0/coursePhase/coursePhaseParticipation/coursePhaseParticipationDTO" db "github.com/niclasheun/prompt2.0/db/sqlc" @@ -21,6 +22,25 @@ type CoursePhaseParticipationService struct { var CoursePhaseParticipationServiceSingleton *CoursePhaseParticipationService +func GetOwnCoursePhaseParticipation(ctx context.Context, coursePhaseID uuid.UUID, matriculationNumber string, universityLogin string) (coursePhaseParticipationDTO.CoursePhaseParticipationStudent, error) { + coursePhaseParticipation, err := CoursePhaseParticipationServiceSingleton.queries.GetCoursePhaseParticipationByUniversityLoginAndCoursePhase(ctx, db.GetCoursePhaseParticipationByUniversityLoginAndCoursePhaseParams{ + ToCoursePhaseID: coursePhaseID, + MatriculationNumber: pgtype.Text{String: matriculationNumber, Valid: true}, + UniversityLogin: pgtype.Text{String: universityLogin, Valid: true}, + }) + + if err != nil { + return coursePhaseParticipationDTO.CoursePhaseParticipationStudent{}, err + } + + participationDTO, err := coursePhaseParticipationDTO.GetCoursePhaseParticipationStudent(coursePhaseParticipation) + if err != nil { + return coursePhaseParticipationDTO.CoursePhaseParticipationStudent{}, err + } + + return participationDTO, nil +} + func GetAllParticipationsForCoursePhase(ctx context.Context, coursePhaseID uuid.UUID) ([]coursePhaseParticipationDTO.GetAllCPPsForCoursePhase, error) { coursePhaseParticipations, err := CoursePhaseParticipationServiceSingleton.queries.GetAllCoursePhaseParticipationsForCoursePhaseIncludingPrevious(ctx, coursePhaseID) if err != nil { diff --git a/server/db/query/course_phase_participation.sql b/server/db/query/course_phase_participation.sql index 5efe2d1b..ac9e6f56 100644 --- a/server/db/query/course_phase_participation.sql +++ b/server/db/query/course_phase_participation.sql @@ -327,3 +327,102 @@ FROM SELECT * FROM qualified_non_participants ) AS main ORDER BY main.last_name, main.first_name; + + +-- name: GetCoursePhaseParticipationByUniversityLoginAndCoursePhase :one +WITH +----------------------------------------------------------------------- +-- A) Phases a student must have 'passed' (per course_phase_graph) +-- Identify the single previous phase (if any) required for PASS +----------------------------------------------------------------------- +direct_predecessor_for_pass AS ( + SELECT cpg.from_course_phase_id AS phase_id + FROM course_phase_graph cpg + WHERE cpg.to_course_phase_id = $1 +), + +----------------------------------------------------------------------- +-- 1) Existing participants in the current phase +----------------------------------------------------------------------- +current_phase_participation AS ( + SELECT + cpp.id AS course_phase_participation_id, + cpp.student_readable_data AS student_readable_data, + s.id AS student_id, + s.first_name, + s.last_name, + s.email, + s.matriculation_number, + s.university_login, + s.has_university_account, + s.gender, + s.nationality, + s.study_degree, + s.study_program, + s.current_semester, + cp.id AS course_participation_id + FROM course_phase_participation cpp + INNER JOIN course_participation cp + ON cpp.course_participation_id = cp.id + INNER JOIN student s + ON cp.student_id = s.id + WHERE cpp.course_phase_id = $1 + AND s.university_login = $2 + AND s.matriculation_number = $3 +), + +----------------------------------------------------------------------- +-- 2) Would-be participants: +-- - They do NOT yet have a course_phase_participation for $1 +-- - Must have passed ALL direct_predecessors_for_pass +----------------------------------------------------------------------- +qualified_non_participant AS ( + SELECT + NULL::uuid AS course_phase_participation_id, + '{}'::jsonb AS student_readable_data, + s.id AS student_id, + s.first_name, + s.last_name, + s.email, + s.matriculation_number, + s.university_login, + s.has_university_account, + s.gender, + s.nationality, + s.study_degree, + s.study_program, + s.current_semester, + cp.id AS course_participation_id + FROM course_participation cp + JOIN student s + ON cp.student_id = s.id + + WHERE + s.university_login = $2 + AND s.matriculation_number = $3 + -- Exclude if they already have a participation in the current phase + AND NOT EXISTS ( + SELECT 1 + FROM course_phase_participation new_cpp + WHERE new_cpp.course_phase_id = $1 + AND new_cpp.course_participation_id = cp.id + ) + -- And ensure they have 'passed' in the previous phase + -- We filter just previous, not all since phase order might change or allow for non-linear courses at some point + AND EXISTS ( + SELECT 1 + FROM direct_predecessor_for_pass dpp + JOIN course_phase_participation pcpp + ON pcpp.course_phase_id = dpp.phase_id + AND pcpp.course_participation_id = cp.id + WHERE (pcpp.pass_status = 'passed') + ) +) +SELECT main.* +FROM +( + SELECT * FROM current_phase_participation + UNION + SELECT * FROM qualified_non_participant +) AS main +LIMIT 1; \ No newline at end of file diff --git a/server/db/sqlc/course_phase_participation.sql.go b/server/db/sqlc/course_phase_participation.sql.go index edd1000e..5b5f3332 100644 --- a/server/db/sqlc/course_phase_participation.sql.go +++ b/server/db/sqlc/course_phase_participation.sql.go @@ -535,6 +535,153 @@ func (q *Queries) GetCoursePhaseParticipationByCourseParticipationAndCoursePhase return i, err } +const getCoursePhaseParticipationByUniversityLoginAndCoursePhase = `-- name: GetCoursePhaseParticipationByUniversityLoginAndCoursePhase :one +WITH +direct_predecessor_for_pass AS ( + SELECT cpg.from_course_phase_id AS phase_id + FROM course_phase_graph cpg + WHERE cpg.to_course_phase_id = $1 +), + +current_phase_participation AS ( + SELECT + cpp.id AS course_phase_participation_id, + cpp.student_readable_data AS student_readable_data, + s.id AS student_id, + s.first_name, + s.last_name, + s.email, + s.matriculation_number, + s.university_login, + s.has_university_account, + s.gender, + s.nationality, + s.study_degree, + s.study_program, + s.current_semester, + cp.id AS course_participation_id + FROM course_phase_participation cpp + INNER JOIN course_participation cp + ON cpp.course_participation_id = cp.id + INNER JOIN student s + ON cp.student_id = s.id + WHERE cpp.course_phase_id = $1 + AND s.university_login = $2 + AND s.matriculation_number = $3 +), + +qualified_non_participant AS ( + SELECT + NULL::uuid AS course_phase_participation_id, + '{}'::jsonb AS student_readable_data, + s.id AS student_id, + s.first_name, + s.last_name, + s.email, + s.matriculation_number, + s.university_login, + s.has_university_account, + s.gender, + s.nationality, + s.study_degree, + s.study_program, + s.current_semester, + cp.id AS course_participation_id + FROM course_participation cp + JOIN student s + ON cp.student_id = s.id + + WHERE + s.university_login = $2 + AND s.matriculation_number = $3 + -- Exclude if they already have a participation in the current phase + AND NOT EXISTS ( + SELECT 1 + FROM course_phase_participation new_cpp + WHERE new_cpp.course_phase_id = $1 + AND new_cpp.course_participation_id = cp.id + ) + -- And ensure they have 'passed' in the previous phase + -- We filter just previous, not all since phase order might change or allow for non-linear courses at some point + AND EXISTS ( + SELECT 1 + FROM direct_predecessor_for_pass dpp + JOIN course_phase_participation pcpp + ON pcpp.course_phase_id = dpp.phase_id + AND pcpp.course_participation_id = cp.id + WHERE (pcpp.pass_status = 'passed') + ) +) +SELECT main.course_phase_participation_id, main.student_readable_data, main.student_id, main.first_name, main.last_name, main.email, main.matriculation_number, main.university_login, main.has_university_account, main.gender, main.nationality, main.study_degree, main.study_program, main.current_semester, main.course_participation_id +FROM +( + SELECT course_phase_participation_id, student_readable_data, student_id, first_name, last_name, email, matriculation_number, university_login, has_university_account, gender, nationality, study_degree, study_program, current_semester, course_participation_id FROM current_phase_participation + UNION + SELECT course_phase_participation_id, student_readable_data, student_id, first_name, last_name, email, matriculation_number, university_login, has_university_account, gender, nationality, study_degree, study_program, current_semester, course_participation_id FROM qualified_non_participant +) AS main +LIMIT 1 +` + +type GetCoursePhaseParticipationByUniversityLoginAndCoursePhaseParams struct { + ToCoursePhaseID uuid.UUID `json:"to_course_phase_id"` + UniversityLogin pgtype.Text `json:"university_login"` + MatriculationNumber pgtype.Text `json:"matriculation_number"` +} + +type GetCoursePhaseParticipationByUniversityLoginAndCoursePhaseRow struct { + CoursePhaseParticipationID uuid.UUID `json:"course_phase_participation_id"` + StudentReadableData []byte `json:"student_readable_data"` + StudentID uuid.UUID `json:"student_id"` + FirstName pgtype.Text `json:"first_name"` + LastName pgtype.Text `json:"last_name"` + Email pgtype.Text `json:"email"` + MatriculationNumber pgtype.Text `json:"matriculation_number"` + UniversityLogin pgtype.Text `json:"university_login"` + HasUniversityAccount pgtype.Bool `json:"has_university_account"` + Gender Gender `json:"gender"` + Nationality pgtype.Text `json:"nationality"` + StudyDegree StudyDegree `json:"study_degree"` + StudyProgram pgtype.Text `json:"study_program"` + CurrentSemester pgtype.Int4 `json:"current_semester"` + CourseParticipationID uuid.UUID `json:"course_participation_id"` +} + +// --------------------------------------------------------------------- +// A) Phases a student must have 'passed' (per course_phase_graph) +// Identify the single previous phase (if any) required for PASS +// --------------------------------------------------------------------- +// --------------------------------------------------------------------- +// 1) Existing participants in the current phase +// --------------------------------------------------------------------- +// --------------------------------------------------------------------- +// 2) Would-be participants: +// - They do NOT yet have a course_phase_participation for $1 +// - Must have passed ALL direct_predecessors_for_pass +// +// --------------------------------------------------------------------- +func (q *Queries) GetCoursePhaseParticipationByUniversityLoginAndCoursePhase(ctx context.Context, arg GetCoursePhaseParticipationByUniversityLoginAndCoursePhaseParams) (GetCoursePhaseParticipationByUniversityLoginAndCoursePhaseRow, error) { + row := q.db.QueryRow(ctx, getCoursePhaseParticipationByUniversityLoginAndCoursePhase, arg.ToCoursePhaseID, arg.UniversityLogin, arg.MatriculationNumber) + var i GetCoursePhaseParticipationByUniversityLoginAndCoursePhaseRow + err := row.Scan( + &i.CoursePhaseParticipationID, + &i.StudentReadableData, + &i.StudentID, + &i.FirstName, + &i.LastName, + &i.Email, + &i.MatriculationNumber, + &i.UniversityLogin, + &i.HasUniversityAccount, + &i.Gender, + &i.Nationality, + &i.StudyDegree, + &i.StudyProgram, + &i.CurrentSemester, + &i.CourseParticipationID, + ) + return i, err +} + const updateCoursePhaseParticipation = `-- name: UpdateCoursePhaseParticipation :one UPDATE course_phase_participation SET From 23e7ff1d81d19f8595e2bb4c5a45d31bcff4a134 Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Thu, 30 Jan 2025 10:40:50 +0100 Subject: [PATCH 03/16] restricting endpoint to course students (not necessarily required) --- server/coursePhase/coursePhaseParticipation/router.go | 2 +- server/keycloak/middleware.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/server/coursePhase/coursePhaseParticipation/router.go b/server/coursePhase/coursePhaseParticipation/router.go index 73563e23..1236383c 100644 --- a/server/coursePhase/coursePhaseParticipation/router.go +++ b/server/coursePhase/coursePhaseParticipation/router.go @@ -11,7 +11,7 @@ import ( func setupCoursePhaseParticipationRouter(routerGroup *gin.RouterGroup, authMiddleware func() gin.HandlerFunc, permissionIDMiddleware func(allowedRoles ...string) gin.HandlerFunc) { courseParticipation := routerGroup.Group("/course_phases/:uuid/participations", authMiddleware()) - courseParticipation.GET("/self", getOwnCoursePhaseParticipation) + courseParticipation.GET("/self", permissionIDMiddleware(keycloak.CourseStudent), getOwnCoursePhaseParticipation) courseParticipation.GET("", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer, keycloak.CourseEditor), getParticipationsForCoursePhase) courseParticipation.POST("", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer), createCoursePhaseParticipation) courseParticipation.GET("/:participation_uuid", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer, keycloak.CourseEditor), getParticipation) diff --git a/server/keycloak/middleware.go b/server/keycloak/middleware.go index 6d17af0b..6558e070 100644 --- a/server/keycloak/middleware.go +++ b/server/keycloak/middleware.go @@ -99,7 +99,6 @@ func KeycloakMiddleware() gin.HandlerFunc { userRoles[role] = true } - log.Info("User roles: ", userRoles) // Store the extracted roles in the context c.Set("userRoles", userRoles) c.Set("userID", userID) From 8577735eec863c2140072c9d32f5c6094464df75 Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Thu, 30 Jan 2025 10:59:47 +0100 Subject: [PATCH 04/16] adding missing changes --- server/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/main.go b/server/main.go index adbe48da..e7a36704 100644 --- a/server/main.go +++ b/server/main.go @@ -45,7 +45,7 @@ func runMigrations(databaseURL string) { } } -func initKeycloak() { +func initKeycloak(queries db.Queries) { baseURL := utils.GetEnv("KEYCLOAK_HOST", "http://localhost:8081") if !strings.HasPrefix(baseURL, "http") { baseURL = "https://" + baseURL @@ -59,7 +59,7 @@ func initKeycloak() { log.Info("Debugging: baseURL: ", baseURL, " realm: ", realm, " clientID: ", clientID, " idOfClient: ", idOfClient, " expectedAuthorizedParty: ", expectedAuthorizedParty) - err := keycloak.InitKeycloak(context.Background(), baseURL, realm, clientID, clientSecret, idOfClient, expectedAuthorizedParty) + err := keycloak.InitKeycloak(context.Background(), baseURL, realm, clientID, clientSecret, idOfClient, expectedAuthorizedParty, queries) if err != nil { log.Error("Failed to initialize keycloak: ", err) } @@ -93,7 +93,7 @@ func main() { query := db.New(conn) - initKeycloak() + initKeycloak(*query) permissionValidation.InitValidationService(*query, conn) router := gin.Default() From 7796b249cfe6fc41ed4e6d10db4a273b87caaade Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Fri, 31 Jan 2025 10:21:33 +0100 Subject: [PATCH 05/16] adjusting course api --- .../ExternalSidebars/ApplicationSidebar.tsx | 1 + .../courseDTO/get_course_with_phases.go | 2 +- server/course/router.go | 30 +---- server/course/service.go | 72 +++++++++-- server/db/query/course.sql | 73 ++++++++++- server/db/sqlc/course.sql.go | 118 +++++++++++++++++- 6 files changed, 255 insertions(+), 41 deletions(-) diff --git a/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/ApplicationSidebar.tsx b/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/ApplicationSidebar.tsx index 14f85f53..5ba8b892 100644 --- a/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/ApplicationSidebar.tsx +++ b/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/ApplicationSidebar.tsx @@ -8,6 +8,7 @@ export const ApplicationSidebar = ({ rootPath, title }: { rootPath: string; titl title: 'Application', icon: , goToPath: '', + requiredPermissions: [Role.PROMPT_ADMIN, Role.COURSE_LECTURER], subitems: [ { title: 'Applications', diff --git a/server/course/courseDTO/get_course_with_phases.go b/server/course/courseDTO/get_course_with_phases.go index 526f5eee..c341dd14 100644 --- a/server/course/courseDTO/get_course_with_phases.go +++ b/server/course/courseDTO/get_course_with_phases.go @@ -22,7 +22,7 @@ type CourseWithPhases struct { CoursePhases []coursePhaseDTO.CoursePhaseSequence `json:"coursePhases"` } -func GetCourseByIDFromDBModel(course db.Course) (CourseWithPhases, error) { +func GetCourseWithPhasesDTOFromDBModel(course db.Course) (CourseWithPhases, error) { restrictedData, err := meta.GetMetaDataDTOFromDBModel(course.RestrictedData) if err != nil { log.Error("failed to create Course DTO from DB model") diff --git a/server/course/router.go b/server/course/router.go index 8ab60973..14b4ad5a 100644 --- a/server/course/router.go +++ b/server/course/router.go @@ -2,7 +2,6 @@ package course import ( "errors" - "fmt" "net/http" "github.com/gin-gonic/gin" @@ -27,38 +26,21 @@ func setupCourseRouter(router *gin.RouterGroup, authMiddleware func() gin.Handle } func getAllCourses(c *gin.Context) { - courses, err := GetAllCourses(c) - if err != nil { - handleError(c, http.StatusInternalServerError, err) - return - } - rolesVal, exists := c.Get("userRoles") if !exists { handleError(c, http.StatusForbidden, errors.New("missing user roles")) return } + userRoles := rolesVal.(map[string]bool) - if userRoles[keycloak.PromptAdmin] { - c.IndentedJSON(http.StatusOK, courses) - return - } - // TODO: move this to DB request - // Filtern Sie die Kurse basierend auf den Berechtigungen - filteredCourses := []courseDTO.CourseWithPhases{} - allowedUsers := []string{keycloak.CourseLecturer, keycloak.CourseEditor, keycloak.CourseStudent} - for _, course := range courses { - for _, role := range allowedUsers { - desiredRole := fmt.Sprintf("%s-%s-%s", course.Name, course.SemesterTag, role) - if userRoles[desiredRole] { - filteredCourses = append(filteredCourses, course) - break - } - } + courses, err := GetAllCourses(c, userRoles) + if err != nil { + handleError(c, http.StatusInternalServerError, err) + return } - c.IndentedJSON(http.StatusOK, filteredCourses) + c.IndentedJSON(http.StatusOK, courses) } func getCourseByID(c *gin.Context) { diff --git a/server/course/service.go b/server/course/service.go index 9d52e9a6..9dadb6cf 100644 --- a/server/course/service.go +++ b/server/course/service.go @@ -10,6 +10,7 @@ import ( "github.com/niclasheun/prompt2.0/course/courseDTO" "github.com/niclasheun/prompt2.0/coursePhase/coursePhaseDTO" db "github.com/niclasheun/prompt2.0/db/sqlc" + "github.com/niclasheun/prompt2.0/keycloak" log "github.com/sirupsen/logrus" ) @@ -23,27 +24,82 @@ type CourseService struct { var CourseServiceSingleton *CourseService -func GetAllCourses(ctx context.Context) ([]courseDTO.CourseWithPhases, error) { +func GetAllCourses(ctx context.Context, userRoles map[string]bool) ([]courseDTO.CourseWithPhases, error) { ctxWithTimeout, cancel := db.GetTimeoutContext(ctx) defer cancel() - courses, err := CourseServiceSingleton.queries.GetAllActiveCourses(ctxWithTimeout) - if err != nil { - return nil, err + // TODO: make distinction if admin or not admin here!!! + var courses []db.Course + var err error + if userRoles[keycloak.PromptAdmin] { + // get all courses + courses, err = CourseServiceSingleton.queries.GetAllActiveCoursesAdmin(ctxWithTimeout) + if err != nil { + return nil, err + } + } else { + // get restricted courses + userRolesArray := []string{} + for key, value := range userRoles { + if value { + userRolesArray = append(userRolesArray, key) + } + } + coursesRestricted, err := CourseServiceSingleton.queries.GetAllActiveCoursesRestricted(ctxWithTimeout, userRolesArray) + if err != nil { + return nil, err + } + + for _, course := range coursesRestricted { + courses = append(courses, db.Course(course)) + } } - // TODO rewrite this cleaner!!! dtoCourses := make([]courseDTO.CourseWithPhases, 0, len(courses)) for _, course := range courses { - dtoCourse, err := GetCourseByID(ctxWithTimeout, course.ID) + // Get all course phases for the course + coursePhases, err := GetCoursePhasesForCourseID(ctx, course.ID) + if err != nil { + return nil, err + } + + courseWithPhases, err := courseDTO.GetCourseWithPhasesDTOFromDBModel(course) + if err != nil { + return nil, err + } + + courseWithPhases.CoursePhases = coursePhases + if err != nil { return nil, err } - dtoCourses = append(dtoCourses, dtoCourse) + dtoCourses = append(dtoCourses, courseWithPhases) } + return dtoCourses, nil } +func GetCoursePhasesForCourseID(ctx context.Context, courseID uuid.UUID) ([]coursePhaseDTO.CoursePhaseSequence, error) { + // Get all course phases in order + coursePhasesOrder, err := CourseServiceSingleton.queries.GetCoursePhaseSequence(ctx, courseID) + if err != nil { + return nil, err + } + + // get all coursePhases out of order + coursePhasesNoOrder, err := CourseServiceSingleton.queries.GetNotOrderedCoursePhases(ctx, courseID) + if err != nil { + return nil, err + } + + coursePhaseDTO, err := coursePhaseDTO.GetCoursePhaseSequenceDTO(coursePhasesOrder, coursePhasesNoOrder) + if err != nil { + return nil, err + + } + return coursePhaseDTO, nil +} + func GetCourseByID(ctx context.Context, id uuid.UUID) (courseDTO.CourseWithPhases, error) { ctxWithTimeout, cancel := db.GetTimeoutContext(ctx) defer cancel() @@ -70,7 +126,7 @@ func GetCourseByID(ctx context.Context, id uuid.UUID) (courseDTO.CourseWithPhase } - CourseWithPhases, err := courseDTO.GetCourseByIDFromDBModel(course) + CourseWithPhases, err := courseDTO.GetCourseWithPhasesDTOFromDBModel(course) if err != nil { return courseDTO.CourseWithPhases{}, err } diff --git a/server/db/query/course.sql b/server/db/query/course.sql index c5681c2e..fb661d2f 100644 --- a/server/db/query/course.sql +++ b/server/db/query/course.sql @@ -2,9 +2,76 @@ SELECT * FROM course WHERE id = $1 LIMIT 1; --- name: GetAllActiveCourses :many -SELECT * FROM course -WHERE end_date >= NOW() - INTERVAL '1 month';; + +-- name: GetAllActiveCoursesAdmin :many +SELECT + c.* +FROM + course c +WHERE + c.end_date >= NOW() - INTERVAL '1 month' +ORDER BY + c.semester_tag, c.name DESC; + +-- name: GetAllActiveCoursesRestricted :many +-- struct: Course +WITH parsed_roles AS ( + SELECT + split_part(role, '-', 1) AS course_name, + split_part(role, '-', 2) AS semester_tag, + split_part(role, '-', 3) AS user_role + FROM + unnest($1::text[]) AS role +), +user_course_roles AS ( + SELECT + c.id, + c.name, + c.semester_tag, + c.start_date, + c.end_date, + c.course_type, + c.student_readable_data, + c.restricted_data, + c.ects, + pr.user_role + FROM + course c + INNER JOIN + parsed_roles pr + ON c.name = pr.course_name + AND c.semester_tag = pr.semester_tag + WHERE + c.end_date >= NOW() - INTERVAL '1 month' +) +SELECT + ucr.id, + ucr.name, + ucr.start_date, + ucr.end_date, + ucr.semester_tag, + ucr.course_type, + ucr.ects, + CASE + WHEN COUNT(ucr.user_role) = 1 AND MAX(ucr.user_role) = 'Student' THEN '{}'::jsonb + ELSE ucr.restricted_data::jsonb + END AS restricted_data, + ucr.student_readable_data +FROM + user_course_roles ucr +GROUP BY + ucr.id, + ucr.name, + ucr.semester_tag, + ucr.start_date, + ucr.end_date, + ucr.course_type, + ucr.student_readable_data, + ucr.ects, + ucr.restricted_data +ORDER BY + ucr.semester_tag, ucr.name DESC; + -- name: CreateCourse :one INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, restricted_data, student_readable_data) diff --git a/server/db/sqlc/course.sql.go b/server/db/sqlc/course.sql.go index af70fb4c..feb42ddd 100644 --- a/server/db/sqlc/course.sql.go +++ b/server/db/sqlc/course.sql.go @@ -83,13 +83,19 @@ func (q *Queries) CreateCourse(ctx context.Context, arg CreateCourseParams) (Cou return i, err } -const getAllActiveCourses = `-- name: GetAllActiveCourses :many -SELECT id, name, start_date, end_date, semester_tag, course_type, ects, restricted_data, student_readable_data FROM course -WHERE end_date >= NOW() - INTERVAL '1 month' +const getAllActiveCoursesAdmin = `-- name: GetAllActiveCoursesAdmin :many +SELECT + c.id, c.name, c.start_date, c.end_date, c.semester_tag, c.course_type, c.ects, c.restricted_data, c.student_readable_data +FROM + course c +WHERE + c.end_date >= NOW() - INTERVAL '1 month' +ORDER BY + c.semester_tag, c.name DESC ` -func (q *Queries) GetAllActiveCourses(ctx context.Context) ([]Course, error) { - rows, err := q.db.Query(ctx, getAllActiveCourses) +func (q *Queries) GetAllActiveCoursesAdmin(ctx context.Context) ([]Course, error) { + rows, err := q.db.Query(ctx, getAllActiveCoursesAdmin) if err != nil { return nil, err } @@ -118,6 +124,108 @@ func (q *Queries) GetAllActiveCourses(ctx context.Context) ([]Course, error) { return items, nil } +const getAllActiveCoursesRestricted = `-- name: GetAllActiveCoursesRestricted :many +WITH parsed_roles AS ( + SELECT + split_part(role, '-', 1) AS course_name, + split_part(role, '-', 2) AS semester_tag, + split_part(role, '-', 3) AS user_role + FROM + unnest($1::text[]) AS role +), +user_course_roles AS ( + SELECT + c.id, + c.name, + c.semester_tag, + c.start_date, + c.end_date, + c.course_type, + c.student_readable_data, + c.restricted_data, + c.ects, + pr.user_role + FROM + course c + INNER JOIN + parsed_roles pr + ON c.name = pr.course_name + AND c.semester_tag = pr.semester_tag + WHERE + c.end_date >= NOW() - INTERVAL '1 month' +) +SELECT + ucr.id, + ucr.name, + ucr.start_date, + ucr.end_date, + ucr.semester_tag, + ucr.course_type, + ucr.ects, + CASE + WHEN COUNT(ucr.user_role) = 1 AND MAX(ucr.user_role) = 'Student' THEN '{}'::jsonb + ELSE ucr.restricted_data::jsonb + END AS restricted_data, + ucr.student_readable_data +FROM + user_course_roles ucr +GROUP BY + ucr.id, + ucr.name, + ucr.semester_tag, + ucr.start_date, + ucr.end_date, + ucr.course_type, + ucr.student_readable_data, + ucr.ects, + ucr.restricted_data +ORDER BY + ucr.semester_tag, ucr.name DESC +` + +type GetAllActiveCoursesRestrictedRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + StartDate pgtype.Date `json:"start_date"` + EndDate pgtype.Date `json:"end_date"` + SemesterTag pgtype.Text `json:"semester_tag"` + CourseType CourseType `json:"course_type"` + Ects pgtype.Int4 `json:"ects"` + RestrictedData []byte `json:"restricted_data"` + StudentReadableData []byte `json:"student_readable_data"` +} + +// struct: Course +func (q *Queries) GetAllActiveCoursesRestricted(ctx context.Context, dollar_1 []string) ([]GetAllActiveCoursesRestrictedRow, error) { + rows, err := q.db.Query(ctx, getAllActiveCoursesRestricted, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllActiveCoursesRestrictedRow + for rows.Next() { + var i GetAllActiveCoursesRestrictedRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.StartDate, + &i.EndDate, + &i.SemesterTag, + &i.CourseType, + &i.Ects, + &i.RestrictedData, + &i.StudentReadableData, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getCourse = `-- name: GetCourse :one SELECT id, name, start_date, end_date, semester_tag, course_type, ects, restricted_data, student_readable_data FROM course WHERE id = $1 LIMIT 1 From 987d3fab6623aa4e22faaab7592a884f1ac9a42f Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Fri, 31 Jan 2025 10:34:29 +0100 Subject: [PATCH 06/16] fixing tests --- server/course/service_test.go | 16 ++++++++++++++-- server/database_dumps/course_test.sql | 18 +++++++++--------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/server/course/service_test.go b/server/course/service_test.go index eeefa29a..458e9b70 100644 --- a/server/course/service_test.go +++ b/server/course/service_test.go @@ -12,6 +12,7 @@ import ( "github.com/niclasheun/prompt2.0/coursePhase" "github.com/niclasheun/prompt2.0/coursePhase/coursePhaseDTO" db "github.com/niclasheun/prompt2.0/db/sqlc" + "github.com/niclasheun/prompt2.0/keycloak" "github.com/niclasheun/prompt2.0/meta" "github.com/niclasheun/prompt2.0/testutils" "github.com/stretchr/testify/assert" @@ -65,9 +66,20 @@ func (suite *CourseServiceTestSuite) TearDownSuite() { } func (suite *CourseServiceTestSuite) TestGetAllCourses() { - courses, err := GetAllCourses(suite.ctx) + courses, err := GetAllCourses(suite.ctx, map[string]bool{keycloak.PromptAdmin: true}) assert.NoError(suite.T(), err) - assert.Greater(suite.T(), len(courses), 0, "Expected at least one course") + assert.Equal(suite.T(), len(courses), 10, "Expected all courses") + + for _, course := range courses { + assert.NotEmpty(suite.T(), course.ID, "Course ID should not be empty") + assert.NotEmpty(suite.T(), course.Name, "Course Name should not be empty") + } +} + +func (suite *CourseServiceTestSuite) TestGetAllCoursesWithRestriction() { + courses, err := GetAllCourses(suite.ctx, map[string]bool{"Another TEst-ios2425-Lecturer": true}) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), 1, len(courses), "Expected to get only one course") for _, course := range courses { assert.NotEmpty(suite.T(), course.ID, "Course ID should not be empty") diff --git a/server/database_dumps/course_test.sql b/server/database_dumps/course_test.sql index 9ea5de79..3991f95e 100644 --- a/server/database_dumps/course_test.sql +++ b/server/database_dumps/course_test.sql @@ -43,15 +43,15 @@ CREATE TABLE course ( -- Data for Name: course; Type: TABLE DATA; Schema: public; Owner: prompt-postgres -- -INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('3f42d322-e5bf-4faa-b576-51f2cab14c2e', 'iPraktikum', '2024-10-01', '2025-01-01', 'ios24245', 'practical course', 10, '{"icon": "apple", "bg-color": "bg-orange-100"}'); -INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('918977e1-2d27-4b55-9064-8504ff027a1a', 'New fancy course', '2024-10-01', '2025-01-01', 'ios24245', 'practical course', 10, '{}'); -INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('fe672868-3d07-4bdd-af41-121fd05e2d0d', 'iPraktikum', '2024-10-01', '2025-01-01', 'ios24245', 'lecture', 5, '{"icon": "home", "color": "green"}'); -INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('0bb5c8dc-a4df-4d64-a9fd-fe8840760d6b', 'Test5', '2025-01-13', '2025-01-18', 'ios2425', 'seminar', 5, '{"icon": "smartphone", "bg-color": "bg-blue-100"}'); -INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('55856fdc-fc2f-456a-a5a5-726d60aaae7c', 'iPraktikum3', '2025-01-07', '2025-01-24', 'ios2425', 'practical course', 10, '{"icon": "smartphone", "bg-color": "bg-green-100"}'); -INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('64a12e61-a238-4cea-a36a-5eaf89d7a940', 'Another TEst', '2024-12-15', '2025-01-17', 'ios2425', 'seminar', 5, '{"icon": "folder", "bg-color": "bg-red-100"}'); -INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('07d0664c-6116-4897-97c9-521c8d73dd9f', 'Further Testing', '2024-12-17', '2025-01-15', 'ios24', 'practical course', 10, '{"icon": "monitor", "bg-color": "bg-cyan-100"}'); -INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('00f6d242-9716-487c-a8de-5e02112ea131', 'Test150', '2024-12-17', '2025-01-17', 'test', 'practical course', 10, '{"icon": "book-open-text", "bg-color": "bg-orange-100"}'); -INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('894cb6fc-9407-4642-b4de-2e0b4e893126', 'iPraktikum-Test', '2025-03-10', '2025-08-01', 'ios2425', 'practical course', 10, '{"icon": "gamepad-2", "bg-color": "bg-green-100"}'); +INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('3f42d322-e5bf-4faa-b576-51f2cab14c2e', 'iPraktikum', '2024-10-01', '2030-01-01', 'ios24245', 'practical course', 10, '{"icon": "apple", "bg-color": "bg-orange-100"}'); +INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('918977e1-2d27-4b55-9064-8504ff027a1a', 'New fancy course', '2024-10-01', '2030-01-01', 'ios24245', 'practical course', 10, '{}'); +INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('fe672868-3d07-4bdd-af41-121fd05e2d0d', 'iPraktikum', '2024-10-01', '2030-01-01', 'ios24245', 'lecture', 5, '{"icon": "home", "color": "green"}'); +INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('0bb5c8dc-a4df-4d64-a9fd-fe8840760d6b', 'Test5', '2025-01-13', '2030-01-18', 'ios2425', 'seminar', 5, '{"icon": "smartphone", "bg-color": "bg-blue-100"}'); +INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('55856fdc-fc2f-456a-a5a5-726d60aaae7c', 'iPraktikum3', '2025-01-07', '2030-01-24', 'ios2425', 'practical course', 10, '{"icon": "smartphone", "bg-color": "bg-green-100"}'); +INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('64a12e61-a238-4cea-a36a-5eaf89d7a940', 'Another TEst', '2024-12-15', '2030-01-17', 'ios2425', 'seminar', 5, '{"icon": "folder", "bg-color": "bg-red-100"}'); +INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('07d0664c-6116-4897-97c9-521c8d73dd9f', 'Further Testing', '2024-12-17', '2030-01-15', 'ios24', 'practical course', 10, '{"icon": "monitor", "bg-color": "bg-cyan-100"}'); +INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('00f6d242-9716-487c-a8de-5e02112ea131', 'Test150', '2024-12-17', '2030-01-17', 'test', 'practical course', 10, '{"icon": "book-open-text", "bg-color": "bg-orange-100"}'); +INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('894cb6fc-9407-4642-b4de-2e0b4e893126', 'iPraktikum-Test', '2025-03-10', '2030-08-01', 'ios2425', 'practical course', 10, '{"icon": "gamepad-2", "bg-color": "bg-green-100"}'); -- From 7e9ba20ae2684be8df30d09ee28d9af311d9cb4d Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Fri, 31 Jan 2025 10:37:44 +0100 Subject: [PATCH 07/16] restricting semester tag to not have a '-' in the name --- clients/core/src/validations/course.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/clients/core/src/validations/course.ts b/clients/core/src/validations/course.ts index 81b7df81..8acd9af9 100644 --- a/clients/core/src/validations/course.ts +++ b/clients/core/src/validations/course.ts @@ -1,14 +1,20 @@ import * as z from 'zod' export const courseFormSchema = z.object({ - name: z.string().min(1, 'Course name is required'), + name: z + .string() + .min(1, 'Course name is required') + .refine((val) => !val.includes('-'), 'Course name cannot contain a "-" character'), dateRange: z.object({ from: z.date(), to: z.date(), }), courseType: z.string().min(1, 'Course type is required'), ects: z.number().min(0, 'ECTS must be a positive number'), - semesterTag: z.string().min(1, 'Semester tag is required'), + semesterTag: z + .string() + .min(1, 'Semester tag is required') + .refine((val) => !val.includes('-'), 'Semester tag cannot contain a "-" character'), }) export type CourseFormValues = z.infer From 23c87082baadefa32531840ab4f5ed4805d521be Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Fri, 31 Jan 2025 10:48:09 +0100 Subject: [PATCH 08/16] adding another test case for the restricted data --- server/course/service_test.go | 13 +++++++++++++ server/database_dumps/course_test.sql | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/server/course/service_test.go b/server/course/service_test.go index 458e9b70..7f671cf5 100644 --- a/server/course/service_test.go +++ b/server/course/service_test.go @@ -87,6 +87,19 @@ func (suite *CourseServiceTestSuite) TestGetAllCoursesWithRestriction() { } } +func (suite *CourseServiceTestSuite) TestGetAllCoursesWithStudent() { + courses, err := GetAllCourses(suite.ctx, map[string]bool{"Another TEst-ios2425-Student": true}) + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), 1, len(courses), "Expected to get only one course") + + for _, course := range courses { + assert.NotEmpty(suite.T(), course.ID, "Course ID should not be empty") + assert.NotEmpty(suite.T(), course.Name, "Course Name should not be empty") + // Ensure that restricted data is not present + assert.Empty(suite.T(), course.RestrictedData, "Course should have restricted data") + } +} + func (suite *CourseServiceTestSuite) TestGetCourseByID() { courseID := uuid.MustParse("3f42d322-e5bf-4faa-b576-51f2cab14c2e") diff --git a/server/database_dumps/course_test.sql b/server/database_dumps/course_test.sql index 3991f95e..0188b487 100644 --- a/server/database_dumps/course_test.sql +++ b/server/database_dumps/course_test.sql @@ -48,7 +48,7 @@ INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, e INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('fe672868-3d07-4bdd-af41-121fd05e2d0d', 'iPraktikum', '2024-10-01', '2030-01-01', 'ios24245', 'lecture', 5, '{"icon": "home", "color": "green"}'); INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('0bb5c8dc-a4df-4d64-a9fd-fe8840760d6b', 'Test5', '2025-01-13', '2030-01-18', 'ios2425', 'seminar', 5, '{"icon": "smartphone", "bg-color": "bg-blue-100"}'); INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('55856fdc-fc2f-456a-a5a5-726d60aaae7c', 'iPraktikum3', '2025-01-07', '2030-01-24', 'ios2425', 'practical course', 10, '{"icon": "smartphone", "bg-color": "bg-green-100"}'); -INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('64a12e61-a238-4cea-a36a-5eaf89d7a940', 'Another TEst', '2024-12-15', '2030-01-17', 'ios2425', 'seminar', 5, '{"icon": "folder", "bg-color": "bg-red-100"}'); +INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('64a12e61-a238-4cea-a36a-5eaf89d7a940', 'Another TEst', '2024-12-15', '2030-01-17', 'ios2425', 'seminar', 5, '{"icon": "folder", "bg-color": "bg-red-100", "some-secret-data": "secret"}'); INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('07d0664c-6116-4897-97c9-521c8d73dd9f', 'Further Testing', '2024-12-17', '2030-01-15', 'ios24', 'practical course', 10, '{"icon": "monitor", "bg-color": "bg-cyan-100"}'); INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('00f6d242-9716-487c-a8de-5e02112ea131', 'Test150', '2024-12-17', '2030-01-17', 'test', 'practical course', 10, '{"icon": "book-open-text", "bg-color": "bg-orange-100"}'); INSERT INTO course (id, name, start_date, end_date, semester_tag, course_type, ects, meta_data) VALUES ('894cb6fc-9407-4642-b4de-2e0b4e893126', 'iPraktikum-Test', '2025-03-10', '2030-08-01', 'ios2425', 'practical course', 10, '{"icon": "gamepad-2", "bg-color": "bg-green-100"}'); From a6c8f61a825dc90fca2d58319348f070dcc340ef Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Fri, 31 Jan 2025 10:50:20 +0100 Subject: [PATCH 09/16] cleaning --- server/course/service.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/course/service.go b/server/course/service.go index 9dadb6cf..20925ca2 100644 --- a/server/course/service.go +++ b/server/course/service.go @@ -28,9 +28,9 @@ func GetAllCourses(ctx context.Context, userRoles map[string]bool) ([]courseDTO. ctxWithTimeout, cancel := db.GetTimeoutContext(ctx) defer cancel() - // TODO: make distinction if admin or not admin here!!! var courses []db.Course var err error + // Get all active courses the user is allowed to see if userRoles[keycloak.PromptAdmin] { // get all courses courses, err = CourseServiceSingleton.queries.GetAllActiveCoursesAdmin(ctxWithTimeout) @@ -55,6 +55,7 @@ func GetAllCourses(ctx context.Context, userRoles map[string]bool) ([]courseDTO. } } + // Get all course phases for each course dtoCourses := make([]courseDTO.CourseWithPhases, 0, len(courses)) for _, course := range courses { // Get all course phases for the course From 681a8091561b3c5b8c1d402477c6ea05baf9e855 Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Fri, 31 Jan 2025 10:53:10 +0100 Subject: [PATCH 10/16] fixing one comment --- server/course/service_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/course/service_test.go b/server/course/service_test.go index 7f671cf5..c4b967d6 100644 --- a/server/course/service_test.go +++ b/server/course/service_test.go @@ -96,7 +96,7 @@ func (suite *CourseServiceTestSuite) TestGetAllCoursesWithStudent() { assert.NotEmpty(suite.T(), course.ID, "Course ID should not be empty") assert.NotEmpty(suite.T(), course.Name, "Course Name should not be empty") // Ensure that restricted data is not present - assert.Empty(suite.T(), course.RestrictedData, "Course should have restricted data") + assert.Empty(suite.T(), course.RestrictedData, "Course should not have restricted data") } } From e968666bd451ef23b12dc95afb0feab49351001d Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Fri, 31 Jan 2025 11:40:23 +0100 Subject: [PATCH 11/16] getting own current course participation with own course phases --- .../get_own_course_participation.go | 22 ++++++++ server/course/courseParticipation/main.go | 2 +- server/course/courseParticipation/router.go | 26 +++++++++- server/course/courseParticipation/service.go | 14 ++++++ .../coursePhaseParticipation/router.go | 4 +- server/db/query/course_participation.sql | 25 +++++++++- server/db/sqlc/course_participation.sql.go | 50 +++++++++++++++++++ 7 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 server/course/courseParticipation/courseParticipationDTO/get_own_course_participation.go diff --git a/server/course/courseParticipation/courseParticipationDTO/get_own_course_participation.go b/server/course/courseParticipation/courseParticipationDTO/get_own_course_participation.go new file mode 100644 index 00000000..195eb12a --- /dev/null +++ b/server/course/courseParticipation/courseParticipationDTO/get_own_course_participation.go @@ -0,0 +1,22 @@ +package courseParticipationDTO + +import ( + "github.com/google/uuid" + db "github.com/niclasheun/prompt2.0/db/sqlc" +) + +type GetOwnCourseParticipation struct { + ID uuid.UUID `json:"id"` + CourseID uuid.UUID `json:"courseID"` + StudentID uuid.UUID `json:"studentID"` + ActiveCoursePhases []uuid.UUID `json:"activeCoursePhases"` +} + +func GetOwnCourseParticipationDTOFromDBModel(model db.GetCourseParticipationByCourseIDAndMatriculationRow) GetOwnCourseParticipation { + return GetOwnCourseParticipation{ + ID: model.ID, + CourseID: model.CourseID, + StudentID: model.StudentID, + ActiveCoursePhases: model.ActiveCoursePhases, + } +} diff --git a/server/course/courseParticipation/main.go b/server/course/courseParticipation/main.go index 89a77f71..bbd55c92 100644 --- a/server/course/courseParticipation/main.go +++ b/server/course/courseParticipation/main.go @@ -18,5 +18,5 @@ func InitCourseParticipationModule(routerGroup *gin.RouterGroup, queries db.Quer // initializes the handler func with CheckCoursePermissions func checkAccessControlByIDWrapper(allowedRoles ...string) gin.HandlerFunc { - return permissionValidation.CheckAccessControlByID(permissionValidation.CheckCourseParticipationPermission, "uuid", allowedRoles...) + return permissionValidation.CheckAccessControlByID(permissionValidation.CheckCoursePermission, "uuid", allowedRoles...) } diff --git a/server/course/courseParticipation/router.go b/server/course/courseParticipation/router.go index 73dacbb8..80becc72 100644 --- a/server/course/courseParticipation/router.go +++ b/server/course/courseParticipation/router.go @@ -12,11 +12,35 @@ import ( func setupCourseParticipationRouter(router *gin.RouterGroup, authMiddleware func() gin.HandlerFunc, permissionIDMiddleware func(allowedRoles ...string) gin.HandlerFunc) { // incoming path should be /course/:uuid/ courseParticipation := router.Group("/courses/:uuid/participations", authMiddleware()) + courseParticipation.GET("/self", permissionIDMiddleware(keycloak.CourseStudent), getOwnCourseParticipation) courseParticipation.GET("", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer, keycloak.CourseEditor), getCourseParticipationsForCourse) courseParticipation.POST("/enroll", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer), createCourseParticipation) } -// TODO: in future think about how to integrate / create "passed" students from previous phases +func getOwnCourseParticipation(c *gin.Context) { + id, err := uuid.Parse(c.Param("uuid")) + if err != nil { + handleError(c, http.StatusBadRequest, err) + return + } + + matriculationNumber := c.GetString("matriculationNumber") + universityLogin := c.GetString("universityLogin") + + if matriculationNumber == "" || universityLogin == "" { + handleError(c, http.StatusUnauthorized, err) + return + } + + courseParticipation, err := GetOwnCourseParticipation(c, id, matriculationNumber, universityLogin) + if err != nil { + handleError(c, http.StatusInternalServerError, err) + return + } + + c.IndentedJSON(http.StatusOK, courseParticipation) +} + func getCourseParticipationsForCourse(c *gin.Context) { id, err := uuid.Parse(c.Param("uuid")) if err != nil { diff --git a/server/course/courseParticipation/service.go b/server/course/courseParticipation/service.go index 67160399..f7cb90f4 100644 --- a/server/course/courseParticipation/service.go +++ b/server/course/courseParticipation/service.go @@ -6,6 +6,7 @@ import ( "errors" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/niclasheun/prompt2.0/course/courseParticipation/courseParticipationDTO" db "github.com/niclasheun/prompt2.0/db/sqlc" @@ -81,3 +82,16 @@ func CreateIfNotExistingCourseParticipation(ctx context.Context, transactionQuer return courseParticipationDTO.GetCourseParticipation{}, err } } + +func GetOwnCourseParticipation(ctx context.Context, courseId uuid.UUID, matriculationNumber, universityLogin string) (courseParticipationDTO.GetOwnCourseParticipation, error) { + participation, err := CourseParticipationServiceSingleton.queries.GetCourseParticipationByCourseIDAndMatriculation(ctx, db.GetCourseParticipationByCourseIDAndMatriculationParams{ + CourseID: courseId, + MatriculationNumber: pgtype.Text{String: matriculationNumber, Valid: true}, + UniversityLogin: pgtype.Text{String: universityLogin, Valid: true}, + }) + if err != nil { + return courseParticipationDTO.GetOwnCourseParticipation{}, err + } + + return courseParticipationDTO.GetOwnCourseParticipationDTOFromDBModel(participation), nil +} diff --git a/server/coursePhase/coursePhaseParticipation/router.go b/server/coursePhase/coursePhaseParticipation/router.go index 1236383c..3dcf0105 100644 --- a/server/coursePhase/coursePhaseParticipation/router.go +++ b/server/coursePhase/coursePhaseParticipation/router.go @@ -35,13 +35,13 @@ func getOwnCoursePhaseParticipation(c *gin.Context) { return } - courseParticipation, err := GetOwnCoursePhaseParticipation(c, id, matriculationNumber, universityLogin) + coursePhaseParticipation, err := GetOwnCoursePhaseParticipation(c, id, matriculationNumber, universityLogin) if err != nil { handleError(c, http.StatusInternalServerError, err) return } - c.IndentedJSON(http.StatusOK, courseParticipation) + c.IndentedJSON(http.StatusOK, coursePhaseParticipation) } func getParticipationsForCoursePhase(c *gin.Context) { diff --git a/server/db/query/course_participation.sql b/server/db/query/course_participation.sql index a8b7a3fd..7da7baea 100644 --- a/server/db/query/course_participation.sql +++ b/server/db/query/course_participation.sql @@ -17,4 +17,27 @@ RETURNING *; -- name: GetCourseParticipationByStudentAndCourseID :one SELECT * FROM course_participation -WHERE student_id = $1 AND course_id = $2 LIMIT 1; \ No newline at end of file +WHERE student_id = $1 AND course_id = $2 LIMIT 1; + +-- name: GetCourseParticipationByCourseIDAndMatriculation :one +SELECT + cp.id, + cp.course_id, + cp.student_id, + ARRAY_AGG(cpp.course_phase_id)::uuid[] AS active_course_phases +FROM + course_participation cp +JOIN + course_phase_participation cpp ON cpp.course_participation_id = cp.id +JOIN + student s ON s.id = cp.student_id +JOIN + course_phase cphase ON cphase.id = cpp.course_phase_id +WHERE + cp.course_id = $1 + AND s.matriculation_number = $2 + AND s.university_login = $3 +GROUP BY + cp.id, + cp.course_id, + cp.student_id; \ No newline at end of file diff --git a/server/db/sqlc/course_participation.sql.go b/server/db/sqlc/course_participation.sql.go index 93d43dcc..dfef4118 100644 --- a/server/db/sqlc/course_participation.sql.go +++ b/server/db/sqlc/course_participation.sql.go @@ -9,6 +9,7 @@ import ( "context" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) const createCourseParticipation = `-- name: CreateCourseParticipation :one @@ -92,6 +93,55 @@ func (q *Queries) GetCourseParticipation(ctx context.Context, id uuid.UUID) (Cou return i, err } +const getCourseParticipationByCourseIDAndMatriculation = `-- name: GetCourseParticipationByCourseIDAndMatriculation :one +SELECT + cp.id, + cp.course_id, + cp.student_id, + ARRAY_AGG(cpp.course_phase_id)::uuid[] AS active_course_phases +FROM + course_participation cp +JOIN + course_phase_participation cpp ON cpp.course_participation_id = cp.id +JOIN + student s ON s.id = cp.student_id +JOIN + course_phase cphase ON cphase.id = cpp.course_phase_id +WHERE + cp.course_id = $1 + AND s.matriculation_number = $2 + AND s.university_login = $3 +GROUP BY + cp.id, + cp.course_id, + cp.student_id +` + +type GetCourseParticipationByCourseIDAndMatriculationParams struct { + CourseID uuid.UUID `json:"course_id"` + MatriculationNumber pgtype.Text `json:"matriculation_number"` + UniversityLogin pgtype.Text `json:"university_login"` +} + +type GetCourseParticipationByCourseIDAndMatriculationRow struct { + ID uuid.UUID `json:"id"` + CourseID uuid.UUID `json:"course_id"` + StudentID uuid.UUID `json:"student_id"` + ActiveCoursePhases []uuid.UUID `json:"active_course_phases"` +} + +func (q *Queries) GetCourseParticipationByCourseIDAndMatriculation(ctx context.Context, arg GetCourseParticipationByCourseIDAndMatriculationParams) (GetCourseParticipationByCourseIDAndMatriculationRow, error) { + row := q.db.QueryRow(ctx, getCourseParticipationByCourseIDAndMatriculation, arg.CourseID, arg.MatriculationNumber, arg.UniversityLogin) + var i GetCourseParticipationByCourseIDAndMatriculationRow + err := row.Scan( + &i.ID, + &i.CourseID, + &i.StudentID, + &i.ActiveCoursePhases, + ) + return i, err +} + const getCourseParticipationByStudentAndCourseID = `-- name: GetCourseParticipationByStudentAndCourseID :one SELECT id, course_id, student_id FROM course_participation WHERE student_id = $1 AND course_id = $2 LIMIT 1 From 30ead272e5419dd990dc00341f8d5708b1dc8a4e Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Fri, 31 Jan 2025 12:12:27 +0100 Subject: [PATCH 12/16] adjusting this to filter for the passed phases only --- server/db/query/course_participation.sql | 50 +++++++++++++++++++--- server/db/sqlc/course_participation.sql.go | 48 ++++++++++++++++++--- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/server/db/query/course_participation.sql b/server/db/query/course_participation.sql index 7da7baea..0eb71229 100644 --- a/server/db/query/course_participation.sql +++ b/server/db/query/course_participation.sql @@ -20,19 +20,55 @@ SELECT * FROM course_participation WHERE student_id = $1 AND course_id = $2 LIMIT 1; -- name: GetCourseParticipationByCourseIDAndMatriculation :one +WITH existing_phases AS ( + SELECT cpp.course_phase_id + FROM course_participation cp + JOIN course_phase_participation cpp + ON cpp.course_participation_id = cp.id + JOIN student s + ON s.id = cp.student_id + WHERE cp.course_id = $1 + AND s.matriculation_number = $2 + AND s.university_login = $3 +), +passed_phases AS ( + SELECT cpp.course_phase_id + FROM course_participation cp + JOIN course_phase_participation cpp + ON cpp.course_participation_id = cp.id + JOIN student s + ON s.id = cp.student_id + WHERE cp.course_id = $1 + AND s.matriculation_number = $2 + AND s.university_login = $3 + AND cpp.pass_status = 'passed' +), +next_phases AS ( + SELECT cpg.to_course_phase_id + FROM course_phase_graph cpg + JOIN passed_phases pp + ON cpg.from_course_phase_id = pp.course_phase_id + WHERE cpg.to_course_phase_id NOT IN ( + SELECT course_phase_id FROM existing_phases + ) +) SELECT cp.id, cp.course_id, cp.student_id, - ARRAY_AGG(cpp.course_phase_id)::uuid[] AS active_course_phases + ARRAY_AGG(DISTINCT cp_ph.course_phase_id)::uuid[] AS active_course_phases FROM course_participation cp JOIN - course_phase_participation cpp ON cpp.course_participation_id = cp.id -JOIN - student s ON s.id = cp.student_id -JOIN - course_phase cphase ON cphase.id = cpp.course_phase_id + student s + ON s.id = cp.student_id +LEFT JOIN ( + -- Combine existing and eligible next phases + SELECT course_phase_id FROM existing_phases + UNION + SELECT to_course_phase_id AS course_phase_id FROM next_phases +) AS cp_ph + ON TRUE WHERE cp.course_id = $1 AND s.matriculation_number = $2 @@ -40,4 +76,4 @@ WHERE GROUP BY cp.id, cp.course_id, - cp.student_id; \ No newline at end of file + cp.student_id; diff --git a/server/db/sqlc/course_participation.sql.go b/server/db/sqlc/course_participation.sql.go index dfef4118..ff34c69a 100644 --- a/server/db/sqlc/course_participation.sql.go +++ b/server/db/sqlc/course_participation.sql.go @@ -94,19 +94,55 @@ func (q *Queries) GetCourseParticipation(ctx context.Context, id uuid.UUID) (Cou } const getCourseParticipationByCourseIDAndMatriculation = `-- name: GetCourseParticipationByCourseIDAndMatriculation :one +WITH existing_phases AS ( + SELECT cpp.course_phase_id + FROM course_participation cp + JOIN course_phase_participation cpp + ON cpp.course_participation_id = cp.id + JOIN student s + ON s.id = cp.student_id + WHERE cp.course_id = $1 + AND s.matriculation_number = $2 + AND s.university_login = $3 +), +passed_phases AS ( + SELECT cpp.course_phase_id + FROM course_participation cp + JOIN course_phase_participation cpp + ON cpp.course_participation_id = cp.id + JOIN student s + ON s.id = cp.student_id + WHERE cp.course_id = $1 + AND s.matriculation_number = $2 + AND s.university_login = $3 + AND cpp.pass_status = 'passed' +), +next_phases AS ( + SELECT cpg.to_course_phase_id + FROM course_phase_graph cpg + JOIN passed_phases pp + ON cpg.from_course_phase_id = pp.course_phase_id + WHERE cpg.to_course_phase_id NOT IN ( + SELECT course_phase_id FROM existing_phases + ) +) SELECT cp.id, cp.course_id, cp.student_id, - ARRAY_AGG(cpp.course_phase_id)::uuid[] AS active_course_phases + ARRAY_AGG(DISTINCT cp_ph.course_phase_id)::uuid[] AS active_course_phases FROM course_participation cp JOIN - course_phase_participation cpp ON cpp.course_participation_id = cp.id -JOIN - student s ON s.id = cp.student_id -JOIN - course_phase cphase ON cphase.id = cpp.course_phase_id + student s + ON s.id = cp.student_id +LEFT JOIN ( + -- Combine existing and eligible next phases + SELECT course_phase_id FROM existing_phases + UNION + SELECT to_course_phase_id AS course_phase_id FROM next_phases +) AS cp_ph + ON TRUE WHERE cp.course_id = $1 AND s.matriculation_number = $2 From 5efe265bf92fb15d8a29bd74cf1ef380cf531840 Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Fri, 31 Jan 2025 15:03:20 +0100 Subject: [PATCH 13/16] adding endpoint to get own courses for the student endpoint --- .../managementConsole/ManagementConsole.tsx | 37 ++++++++++++++++--- .../components/PermissionRestriction.tsx | 8 +++- .../core/src/network/queries/ownCourseIDs.ts | 10 +++++ clients/package.json | 2 +- clients/yarn.lock | 10 ++--- server/course/courseParticipation/router.go | 3 +- server/course/router.go | 19 ++++++++++ server/course/service.go | 12 ++++++ server/db/query/course.sql | 12 ++++++ server/db/sqlc/course.sql.go | 37 +++++++++++++++++++ 10 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 clients/core/src/network/queries/ownCourseIDs.ts diff --git a/clients/core/src/managementConsole/ManagementConsole.tsx b/clients/core/src/managementConsole/ManagementConsole.tsx index 35697529..d65e83a4 100644 --- a/clients/core/src/managementConsole/ManagementConsole.tsx +++ b/clients/core/src/managementConsole/ManagementConsole.tsx @@ -15,6 +15,7 @@ import DarkModeProvider from '@/contexts/DarkModeProvider' import { useParams } from 'react-router-dom' import CourseNotFound from './shared/components/CourseNotFound' import { Breadcrumbs } from './layout/Breadcrumbs/Breadcrumbs' +import { getOwnCourseIDs } from '@core/network/queries/ownCourseIDs' export const ManagementRoot = ({ children }: { children?: React.ReactNode }): JSX.Element => { const { keycloak, logout } = useKeycloak() @@ -22,21 +23,37 @@ export const ManagementRoot = ({ children }: { children?: React.ReactNode }): JS const courseId = useParams<{ courseId: string }>() const hasChildren = React.Children.count(children) > 0 - const { setCourses } = useCourseStore() + const { setCourses, setOwnCourseIDs } = useCourseStore() // getting the courses const { data: fetchedCourses, error, isPending, - isError, - refetch, + isError: isCourseError, + refetch: refetchCourses, } = useQuery({ queryKey: ['courses'], queryFn: () => getAllCourses(), }) - const isLoading = !(keycloak && user) || isPending + // getting the course ids of the course a user is enrolled in + const { + data: fetchedOwnCourseIDs, + isPending: isOwnCourseIdPending, + isError: isOwnCourseIdError, + refetch: refetchOwnCourseIds, + } = useQuery({ + queryKey: ['own_courses'], + queryFn: () => getOwnCourseIDs(), + }) + + const isLoading = !(keycloak && user) || isPending || isOwnCourseIdPending + const isError = isCourseError || isOwnCourseIdError + const refetch = () => { + refetchOwnCourseIds() + refetchCourses() + } useEffect(() => { if (fetchedCourses) { @@ -44,17 +61,25 @@ export const ManagementRoot = ({ children }: { children?: React.ReactNode }): JS } }, [fetchedCourses, setCourses]) + useEffect(() => { + if (fetchedOwnCourseIDs) { + setOwnCourseIDs(fetchedOwnCourseIDs) + } + }, [fetchedOwnCourseIDs, setOwnCourseIDs]) + if (isLoading) { return } if (isError) { - console.error(error) + if (isCourseError && error.message.includes('401')) { + return + } return refetch()} onLogout={() => logout()} /> } // Check if the user has at least some Prompt rights - if (permissions.length === 0) { + if (permissions.length === 0 && fetchedCourses && fetchedCourses.length === 0) { return } diff --git a/clients/core/src/managementConsole/shared/components/PermissionRestriction.tsx b/clients/core/src/managementConsole/shared/components/PermissionRestriction.tsx index 60c0c1bc..0aaa89aa 100644 --- a/clients/core/src/managementConsole/shared/components/PermissionRestriction.tsx +++ b/clients/core/src/managementConsole/shared/components/PermissionRestriction.tsx @@ -15,7 +15,7 @@ export const PermissionRestriction = ({ children, }: PermissionRestrictionProps): JSX.Element => { const { permissions } = useAuthStore() - const { courses } = useCourseStore() + const { courses, isStudentOfCourse } = useCourseStore() const courseId = useParams<{ courseId: string }>().courseId // This means something /general @@ -33,6 +33,12 @@ export const PermissionRestriction = ({ hasPermission = requiredPermissions.some((role) => { return permissions.includes(getPermissionString(role, course?.name, course?.semesterTag)) }) + + // We need to compare student role with ownCourseIDs -> otherwise we could not hide pages from i.e. instructors + // set hasPermission to true if the user is a student in the course and the page is accessible for students + if (requiredPermissions.includes(Role.COURSE_STUDENT) && isStudentOfCourse(courseId)) { + hasPermission = true + } } return <>{hasPermission ? children : } diff --git a/clients/core/src/network/queries/ownCourseIDs.ts b/clients/core/src/network/queries/ownCourseIDs.ts new file mode 100644 index 00000000..da321efa --- /dev/null +++ b/clients/core/src/network/queries/ownCourseIDs.ts @@ -0,0 +1,10 @@ +import { axiosInstance } from '@/network/configService' + +export const getOwnCourseIDs = async (): Promise => { + try { + return (await axiosInstance.get(`/api/courses/self`)).data + } catch (err) { + console.error(err) + throw err + } +} diff --git a/clients/package.json b/clients/package.json index 51405711..62e8f2bb 100644 --- a/clients/package.json +++ b/clients/package.json @@ -36,7 +36,7 @@ "@tiptap/pm": "^2.10.4", "@tiptap/react": "^2.10.4", "@tiptap/starter-kit": "^2.10.4", - "@tumaet/prompt-shared-state": "^0.0.12", + "@tumaet/prompt-shared-state": "^0.0.13", "autoprefixer": "^10.4.20", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", diff --git a/clients/yarn.lock b/clients/yarn.lock index 4c760789..1da0cab6 100644 --- a/clients/yarn.lock +++ b/clients/yarn.lock @@ -3710,13 +3710,13 @@ __metadata: languageName: node linkType: hard -"@tumaet/prompt-shared-state@npm:^0.0.12": - version: 0.0.12 - resolution: "@tumaet/prompt-shared-state@npm:0.0.12" +"@tumaet/prompt-shared-state@npm:^0.0.13": + version: 0.0.13 + resolution: "@tumaet/prompt-shared-state@npm:0.0.13" dependencies: typescript: "npm:^5.7.3" zustand: "npm:^5.0.3" - checksum: 10c0/42b65ea4ea24fe3b8e58db92f09af47717b50b70f38e5eb4f2e59c2a3a94189857bed2f71aa4252dec4f6c72cd4cbf9210b07dc9adef8beb8b3f7cbdf6cf4114 + checksum: 10c0/1862bbf98819d6dae2a9243434c4a3705b59420a6989f831cb06a7e1e5fe4ff675e9204b29968712419901c53377f67783609326838c6501e38f4e2b19ecf233 languageName: node linkType: hard @@ -12789,7 +12789,7 @@ __metadata: "@tiptap/pm": "npm:^2.10.4" "@tiptap/react": "npm:^2.10.4" "@tiptap/starter-kit": "npm:^2.10.4" - "@tumaet/prompt-shared-state": "npm:^0.0.12" + "@tumaet/prompt-shared-state": "npm:^0.0.13" "@types/copy-webpack-plugin": "npm:^10.1.0" "@types/dotenv-webpack": "npm:^7.0.7" "@types/eslint": "npm:^8.56.10" diff --git a/server/course/courseParticipation/router.go b/server/course/courseParticipation/router.go index 80becc72..82e04a33 100644 --- a/server/course/courseParticipation/router.go +++ b/server/course/courseParticipation/router.go @@ -28,7 +28,8 @@ func getOwnCourseParticipation(c *gin.Context) { universityLogin := c.GetString("universityLogin") if matriculationNumber == "" || universityLogin == "" { - handleError(c, http.StatusUnauthorized, err) + // potentially users without studentIDs are using the system -> no error shall be thrown + c.JSON(http.StatusOK, gin.H{"no courses": "no student authentication provided"}) return } diff --git a/server/course/router.go b/server/course/router.go index 14b4ad5a..21a95151 100644 --- a/server/course/router.go +++ b/server/course/router.go @@ -15,6 +15,7 @@ import ( // Role middleware for all without id -> possible additional filtering in subroutes required func setupCourseRouter(router *gin.RouterGroup, authMiddleware func() gin.HandlerFunc, permissionRoleMiddleware, permissionIDMiddleware func(allowedRoles ...string) gin.HandlerFunc) { course := router.Group("/courses", authMiddleware()) + course.GET("/self", getOwnCourses) course.GET("/", permissionRoleMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer, keycloak.CourseEditor, keycloak.CourseStudent), getAllCourses) course.GET("/:uuid", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer, keycloak.CourseEditor), getCourseByID) course.POST("/", permissionRoleMiddleware(keycloak.PromptAdmin, keycloak.PromptLecturer), createCourse) @@ -25,6 +26,24 @@ func setupCourseRouter(router *gin.RouterGroup, authMiddleware func() gin.Handle course.PUT("/:uuid", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer), updateCourseData) } +func getOwnCourses(c *gin.Context) { + matriculationNumber := c.GetString("matriculationNumber") + universityLogin := c.GetString("universityLogin") + + if matriculationNumber == "" || universityLogin == "" { + handleError(c, http.StatusUnauthorized, errors.New("missing matriculation number or university login")) + return + } + + courseIDs, err := GetOwnCourseIDs(c, matriculationNumber, universityLogin) + if err != nil { + handleError(c, http.StatusInternalServerError, err) + return + } + + c.IndentedJSON(http.StatusOK, courseIDs) +} + func getAllCourses(c *gin.Context) { rolesVal, exists := c.Get("userRoles") if !exists { diff --git a/server/course/service.go b/server/course/service.go index 20925ca2..feb63c3c 100644 --- a/server/course/service.go +++ b/server/course/service.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/niclasheun/prompt2.0/course/courseDTO" "github.com/niclasheun/prompt2.0/coursePhase/coursePhaseDTO" @@ -24,6 +25,17 @@ type CourseService struct { var CourseServiceSingleton *CourseService +func GetOwnCourseIDs(ctx context.Context, matriculationNumber, universityLogin string) ([]uuid.UUID, error) { + ctxWithTimeout, cancel := db.GetTimeoutContext(ctx) + defer cancel() + + courses, err := CourseServiceSingleton.queries.GetOwnCourses(ctxWithTimeout, db.GetOwnCoursesParams{ + MatriculationNumber: pgtype.Text{String: matriculationNumber, Valid: true}, + UniversityLogin: pgtype.Text{String: universityLogin, Valid: true}, + }) + return courses, err +} + func GetAllCourses(ctx context.Context, userRoles map[string]bool) ([]courseDTO.CourseWithPhases, error) { ctxWithTimeout, cancel := db.GetTimeoutContext(ctx) defer cancel() diff --git a/server/db/query/course.sql b/server/db/query/course.sql index fb661d2f..6001bb34 100644 --- a/server/db/query/course.sql +++ b/server/db/query/course.sql @@ -97,3 +97,15 @@ SET restricted_data = restricted_data || $2, student_readable_data = student_readable_data || $3 WHERE id = $1; + + +-- name: GetOwnCourses :many +SELECT + c.id +FROM + course c +JOIN course_participation cp ON c.id = cp.course_id +JOIN student s ON cp.student_id = s.id +WHERE + s.matriculation_number = $1 +AND s.university_login = $2; \ No newline at end of file diff --git a/server/db/sqlc/course.sql.go b/server/db/sqlc/course.sql.go index feb42ddd..8327f142 100644 --- a/server/db/sqlc/course.sql.go +++ b/server/db/sqlc/course.sql.go @@ -248,6 +248,43 @@ func (q *Queries) GetCourse(ctx context.Context, id uuid.UUID) (Course, error) { return i, err } +const getOwnCourses = `-- name: GetOwnCourses :many +SELECT + c.id +FROM + course c +JOIN course_participation cp ON c.id = cp.course_id +JOIN student s ON cp.student_id = s.id +WHERE + s.matriculation_number = $1 +AND s.university_login = $2 +` + +type GetOwnCoursesParams struct { + MatriculationNumber pgtype.Text `json:"matriculation_number"` + UniversityLogin pgtype.Text `json:"university_login"` +} + +func (q *Queries) GetOwnCourses(ctx context.Context, arg GetOwnCoursesParams) ([]uuid.UUID, error) { + rows, err := q.db.Query(ctx, getOwnCourses, arg.MatriculationNumber, arg.UniversityLogin) + if err != nil { + return nil, err + } + defer rows.Close() + var items []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateCourse = `-- name: UpdateCourse :exec UPDATE course SET From 8066e11c817964c6c8ab7288935b43444bf35637 Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Fri, 31 Jan 2025 16:18:21 +0100 Subject: [PATCH 14/16] adjust the endpoint for course phases --- server/coursePhase/router.go | 34 ++++++++++++++++++- .../permissionValidation/checkPermission.go | 3 ++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/server/coursePhase/router.go b/server/coursePhase/router.go index bd515936..6eaf126d 100644 --- a/server/coursePhase/router.go +++ b/server/coursePhase/router.go @@ -1,17 +1,20 @@ package coursePhase import ( + "fmt" "net/http" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/niclasheun/prompt2.0/coursePhase/coursePhaseDTO" "github.com/niclasheun/prompt2.0/keycloak" + "github.com/niclasheun/prompt2.0/meta" + log "github.com/sirupsen/logrus" ) func setupCoursePhaseRouter(router *gin.RouterGroup, authMiddleware func() gin.HandlerFunc, permissionIDMiddleware, permissionCourseIDMiddleware func(allowedRoles ...string) gin.HandlerFunc) { coursePhase := router.Group("/course_phases", authMiddleware()) - coursePhase.GET("/:uuid", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer, keycloak.CourseEditor), getCoursePhaseByID) + coursePhase.GET("/:uuid", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer, keycloak.CourseEditor, keycloak.CourseStudent), getCoursePhaseByID) // getting the course ID here to do correct rights management coursePhase.POST("/course/:courseID", permissionCourseIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer), createCoursePhase) coursePhase.PUT("/:uuid", permissionIDMiddleware(keycloak.PromptAdmin, keycloak.CourseLecturer), updateCoursePhase) @@ -63,6 +66,29 @@ func getCoursePhaseByID(c *gin.Context) { handleError(c, http.StatusInternalServerError, err) return } + + // shadow the restricted data for students + courseTokenIdentifier := c.GetString("courseTokenIdentifier") + + userRoles, exists := c.Get("userRoles") + if !exists { + log.Error("userRoles not found in context") + handleError(c, http.StatusInternalServerError, err) + return + } + + userRolesMap, ok := userRoles.(map[string]bool) + if !ok { + log.Error("invalid roles format in context") + handleError(c, http.StatusInternalServerError, err) + return + } + + if !hasRestrictedDataAccess(userRolesMap, courseTokenIdentifier) { + // Hide restricted data for unauthorized users. + coursePhase.RestrictedData = meta.MetaData{} + } + c.IndentedJSON(http.StatusOK, coursePhase) } @@ -103,6 +129,12 @@ func deleteCoursePhase(c *gin.Context) { c.Status(http.StatusOK) } +func hasRestrictedDataAccess(userRolesMap map[string]bool, courseTokenIdentifier string) bool { + return userRolesMap[keycloak.PromptAdmin] || + userRolesMap[fmt.Sprintf("%s-%s", courseTokenIdentifier, keycloak.CourseLecturer)] || + userRolesMap[fmt.Sprintf("%s-%s", courseTokenIdentifier, keycloak.CourseEditor)] +} + func handleError(c *gin.Context, statusCode int, err error) { c.JSON(statusCode, gin.H{"error": err.Error()}) } diff --git a/server/permissionValidation/checkPermission.go b/server/permissionValidation/checkPermission.go index 1b970b96..10932684 100644 --- a/server/permissionValidation/checkPermission.go +++ b/server/permissionValidation/checkPermission.go @@ -9,6 +9,9 @@ import ( ) func checkUserRole(c *gin.Context, courseIdentifier string, allowedUsers ...string) (bool, error) { + // Inject the course identifier for later use + c.Set("courseTokenIdentifier", courseIdentifier) + // Extract user roles from context rolesVal, exists := c.Get("userRoles") if !exists { From 256866cd523f94d4db15790de44fb67064fe7cb1 Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Fri, 31 Jan 2025 16:28:55 +0100 Subject: [PATCH 15/16] fixing that usage is not affected if no matriculation number or university login is provdided in the token --- server/course/router.go | 5 ++++- server/keycloak/middleware.go | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/server/course/router.go b/server/course/router.go index 21a95151..e15889a6 100644 --- a/server/course/router.go +++ b/server/course/router.go @@ -31,7 +31,10 @@ func getOwnCourses(c *gin.Context) { universityLogin := c.GetString("universityLogin") if matriculationNumber == "" || universityLogin == "" { - handleError(c, http.StatusUnauthorized, errors.New("missing matriculation number or university login")) + // we need to ensure that it is still usable if you do not have a matriculation number or university login + // i.e. prompt admins might not have a student role + log.Debug("no matriculation number or university login found") + c.IndentedJSON(http.StatusOK, []uuid.UUID{}) return } diff --git a/server/keycloak/middleware.go b/server/keycloak/middleware.go index 6558e070..1a79c270 100644 --- a/server/keycloak/middleware.go +++ b/server/keycloak/middleware.go @@ -202,6 +202,13 @@ func getStudentRoles(matriculationNumber, universityLogin string) ([]string, err ctx := context.Background() ctxWithTimeout, cancel := db.GetTimeoutContext(ctx) defer cancel() + + // we do not throw an error, as i.e. admins might not have a student role + if matriculationNumber == "" || universityLogin == "" { + log.Debug("no matriculation number or university login found") + return []string{}, nil + } + // Retrieve course roles from the DB studentRoles, err := KeycloakSingleton.queries.GetStudentRoleStrings(ctxWithTimeout, db.GetStudentRoleStringsParams{ MatriculationNumber: pgtype.Text{String: matriculationNumber, Valid: true}, From 5f87b3d8cd8593309a05fc1964655ea29fb7e9c6 Mon Sep 17 00:00:00 2001 From: Stefan Niclas Heun Date: Fri, 31 Jan 2025 17:19:25 +0100 Subject: [PATCH 16/16] restricting the viewabilty of students to only the currently active phases --- .../ExternalSidebars/ApplicationSidebar.tsx | 11 ++++- .../ExternalSidebars/ExternalSidebar.tsx | 46 ++++++++++++++++++- .../ExternalSidebars/InterviewSidebar.tsx | 4 +- .../ExternalSidebars/MatchingSidebar.tsx | 4 +- .../ExternalSidebars/TemplateSidebar.tsx | 4 +- .../PhaseMapping/PhaseSidebarMapping.tsx | 15 +++--- .../InsideSidebar/InsideCourseSidebar.tsx | 6 ++- .../shared/interfaces/CourseParticipation.ts | 6 +++ .../network/queries/courseParticipation.ts | 11 +++++ server/course/courseParticipation/router.go | 5 +- 10 files changed, 97 insertions(+), 15 deletions(-) create mode 100644 clients/core/src/managementConsole/shared/interfaces/CourseParticipation.ts create mode 100644 clients/core/src/network/queries/courseParticipation.ts diff --git a/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/ApplicationSidebar.tsx b/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/ApplicationSidebar.tsx index 5ba8b892..033b9370 100644 --- a/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/ApplicationSidebar.tsx +++ b/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/ApplicationSidebar.tsx @@ -3,7 +3,15 @@ import { FileUser } from 'lucide-react' import { ExternalSidebarComponent } from './ExternalSidebar' import { SidebarMenuItemProps } from '@/interfaces/sidebar' -export const ApplicationSidebar = ({ rootPath, title }: { rootPath: string; title: string }) => { +export const ApplicationSidebar = ({ + rootPath, + title, + coursePhaseID, +}: { + rootPath: string + title: string + coursePhaseID: string +}) => { const applicationSidebarItems: SidebarMenuItemProps = { title: 'Application', icon: , @@ -32,6 +40,7 @@ export const ApplicationSidebar = ({ rootPath, title }: { rootPath: string; titl title={title} rootPath={rootPath} sidebarElement={applicationSidebarItems} + coursePhaseID={coursePhaseID} /> ) } diff --git a/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/ExternalSidebar.tsx b/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/ExternalSidebar.tsx index 11330c36..3b93c982 100644 --- a/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/ExternalSidebar.tsx +++ b/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/ExternalSidebar.tsx @@ -1,37 +1,79 @@ import { SidebarMenuItemProps } from '@/interfaces/sidebar' -import { useAuthStore, useCourseStore } from '@tumaet/prompt-shared-state' +import { Role, useAuthStore, useCourseStore } from '@tumaet/prompt-shared-state' import { InsideSidebarMenuItem } from '../../layout/Sidebar/InsideSidebar/components/InsideSidebarMenuItem' import { getPermissionString } from '@tumaet/prompt-shared-state' import { useParams } from 'react-router-dom' +import { CourseParticipation } from '@core/managementConsole/shared/interfaces/CourseParticipation' +import { getCourseParticipation } from '@core/network/queries/courseParticipation' +import { useQuery } from '@tanstack/react-query' +import { ErrorPage } from '@/components/ErrorPage' interface ExternalSidebarProps { rootPath: string title?: string sidebarElement: SidebarMenuItemProps + coursePhaseID?: string } export const ExternalSidebarComponent: React.FC = ({ title, rootPath, sidebarElement, + coursePhaseID, }: ExternalSidebarProps) => { // Example of using a custom hook const { permissions } = useAuthStore() // Example of calling your custom hook - const { courses } = useCourseStore() + const { courses, isStudentOfCourse } = useCourseStore() const courseId = useParams<{ courseId: string }>().courseId const course = courses.find((c) => c.id === courseId) + // get the current progression if the user is a student + const { + data: fetchedCourseParticipation, + isError: isCourseParticipationError, + refetch: refetchCourseParitcipation, + } = useQuery({ + queryKey: ['course_participation', courseId], + queryFn: () => getCourseParticipation(courseId ?? ''), + }) + let hasComponentPermission = false if (sidebarElement.requiredPermissions && sidebarElement.requiredPermissions.length > 0) { + // checks if user has access through keycloak roles hasComponentPermission = sidebarElement.requiredPermissions.some((role) => { return permissions.includes(getPermissionString(role, course?.name, course?.semesterTag)) }) + + // case that user is only student + if ( + !hasComponentPermission && + coursePhaseID && // some sidebar items (i.e. Mailing are not connected to a phase) + sidebarElement.requiredPermissions.includes(Role.COURSE_STUDENT) && + isStudentOfCourse(courseId ?? '') && + fetchedCourseParticipation + ) { + hasComponentPermission = fetchedCourseParticipation.activeCoursePhases.some( + (phaseID) => phaseID === coursePhaseID, + ) + } } else { // no permissions required hasComponentPermission = true } + // we ignore this error if the user has access anyway + if (isCourseParticipationError && !hasComponentPermission) { + return ( + <> + + + ) + } + return ( <> {hasComponentPermission && ( diff --git a/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/InterviewSidebar.tsx b/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/InterviewSidebar.tsx index f5c9cbe4..09592c88 100644 --- a/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/InterviewSidebar.tsx +++ b/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/InterviewSidebar.tsx @@ -6,18 +6,20 @@ import { ExternalSidebarComponent } from './ExternalSidebar' interface InterviewSidebarProps { rootPath: string title?: string + coursePhaseID: string } export const InterviewSidebar = React.lazy(() => import('interview_component/sidebar') .then((module): { default: React.FC } => ({ - default: ({ title, rootPath }) => { + default: ({ title, rootPath, coursePhaseID }) => { const sidebarElement: SidebarMenuItemProps = module.default || {} return ( ) }, diff --git a/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/MatchingSidebar.tsx b/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/MatchingSidebar.tsx index 7258b584..6dfd158c 100644 --- a/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/MatchingSidebar.tsx +++ b/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/MatchingSidebar.tsx @@ -6,18 +6,20 @@ import { ExternalSidebarComponent } from './ExternalSidebar' interface MatchingSidebarProps { rootPath: string title?: string + coursePhaseID: string } export const MatchingSidebar = React.lazy(() => import('matching_component/sidebar') .then((module): { default: React.FC } => ({ - default: ({ title, rootPath }) => { + default: ({ title, rootPath, coursePhaseID }) => { const sidebarElement: SidebarMenuItemProps = module.default || {} return ( ) }, diff --git a/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/TemplateSidebar.tsx b/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/TemplateSidebar.tsx index cf9e8e89..ff3e061d 100644 --- a/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/TemplateSidebar.tsx +++ b/clients/core/src/managementConsole/PhaseMapping/ExternalSidebars/TemplateSidebar.tsx @@ -6,18 +6,20 @@ import { ExternalSidebarComponent } from './ExternalSidebar' interface TemplateSidebarProps { rootPath: string title?: string + coursePhaseID: string } export const TemplateSidebar = React.lazy(() => import('template_component/sidebar') .then((module): { default: React.FC } => ({ - default: ({ title, rootPath }) => { + default: ({ title, rootPath, coursePhaseID }) => { const sidebarElement: SidebarMenuItemProps = module.default || {} return ( ) }, diff --git a/clients/core/src/managementConsole/PhaseMapping/PhaseSidebarMapping.tsx b/clients/core/src/managementConsole/PhaseMapping/PhaseSidebarMapping.tsx index 7e635dc7..8a9788b7 100644 --- a/clients/core/src/managementConsole/PhaseMapping/PhaseSidebarMapping.tsx +++ b/clients/core/src/managementConsole/PhaseMapping/PhaseSidebarMapping.tsx @@ -3,10 +3,11 @@ import { InterviewSidebar } from './ExternalSidebars/InterviewSidebar' import { ApplicationSidebar } from './ExternalSidebars/ApplicationSidebar' import { MatchingSidebar } from './ExternalSidebars/MatchingSidebar' -export const PhaseSidebarMapping: { [key: string]: React.FC<{ rootPath: string; title: string }> } = - { - template_component: TemplateSidebar, - Application: ApplicationSidebar, - Interview: InterviewSidebar, - Matching: MatchingSidebar, - } +export const PhaseSidebarMapping: { + [key: string]: React.FC<{ rootPath: string; title: string; coursePhaseID: string }> +} = { + template_component: TemplateSidebar, + Application: ApplicationSidebar, + Interview: InterviewSidebar, + Matching: MatchingSidebar, +} diff --git a/clients/core/src/managementConsole/layout/Sidebar/InsideSidebar/InsideCourseSidebar.tsx b/clients/core/src/managementConsole/layout/Sidebar/InsideSidebar/InsideCourseSidebar.tsx index ab4b859b..2b72f6c2 100644 --- a/clients/core/src/managementConsole/layout/Sidebar/InsideSidebar/InsideCourseSidebar.tsx +++ b/clients/core/src/managementConsole/layout/Sidebar/InsideSidebar/InsideCourseSidebar.tsx @@ -51,7 +51,11 @@ export const InsideCourseSidebar = (): JSX.Element => { key={phase.id} fallback={} > - + ) } else { diff --git a/clients/core/src/managementConsole/shared/interfaces/CourseParticipation.ts b/clients/core/src/managementConsole/shared/interfaces/CourseParticipation.ts new file mode 100644 index 00000000..fda50fee --- /dev/null +++ b/clients/core/src/managementConsole/shared/interfaces/CourseParticipation.ts @@ -0,0 +1,6 @@ +export interface CourseParticipation { + id: string + courseID: string + studentID: string + activeCoursePhases: string[] +} diff --git a/clients/core/src/network/queries/courseParticipation.ts b/clients/core/src/network/queries/courseParticipation.ts new file mode 100644 index 00000000..39874913 --- /dev/null +++ b/clients/core/src/network/queries/courseParticipation.ts @@ -0,0 +1,11 @@ +import { axiosInstance } from '@/network/configService' +import { CourseParticipation } from '@core/managementConsole/shared/interfaces/CourseParticipation' + +export const getCourseParticipation = async (courseId: string): Promise => { + try { + return (await axiosInstance.get(`/api/courses/${courseId}/participations/self`)).data + } catch (err) { + console.error(err) + throw err + } +} diff --git a/server/course/courseParticipation/router.go b/server/course/courseParticipation/router.go index 82e04a33..381b10c4 100644 --- a/server/course/courseParticipation/router.go +++ b/server/course/courseParticipation/router.go @@ -29,7 +29,10 @@ func getOwnCourseParticipation(c *gin.Context) { if matriculationNumber == "" || universityLogin == "" { // potentially users without studentIDs are using the system -> no error shall be thrown - c.JSON(http.StatusOK, gin.H{"no courses": "no student authentication provided"}) + c.JSON(http.StatusOK, courseParticipationDTO.GetOwnCourseParticipation{ + ID: uuid.Nil, + ActiveCoursePhases: []uuid.UUID{}, + }) return }