github.com/quickfeed/quickfeed@v0.0.0-20240507093252-ed8ca812a09c/web/quickfeed_service.go (about) 1 package web 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 8 "go.uber.org/zap" 9 10 "connectrpc.com/connect" 11 "github.com/quickfeed/quickfeed/assignments" 12 "github.com/quickfeed/quickfeed/ci" 13 "github.com/quickfeed/quickfeed/database" 14 "github.com/quickfeed/quickfeed/qf" 15 "github.com/quickfeed/quickfeed/qf/qfconnect" 16 "github.com/quickfeed/quickfeed/scm" 17 "github.com/quickfeed/quickfeed/web/stream" 18 ) 19 20 // QuickFeedService holds references to the database and 21 // other shared data structures. 22 type QuickFeedService struct { 23 logger *zap.SugaredLogger 24 db database.Database 25 scmMgr *scm.Manager 26 bh BaseHookOptions 27 runner ci.Runner 28 qfconnect.UnimplementedQuickFeedServiceHandler 29 streams *stream.StreamServices 30 } 31 32 // NewQuickFeedService returns a QuickFeedService object. 33 func NewQuickFeedService(logger *zap.Logger, db database.Database, mgr *scm.Manager, bh BaseHookOptions, runner ci.Runner) *QuickFeedService { 34 return &QuickFeedService{ 35 logger: logger.Sugar(), 36 db: db, 37 scmMgr: mgr, 38 bh: bh, 39 runner: runner, 40 streams: stream.NewStreamServices(), 41 } 42 } 43 44 // GetUser will return current user with active course enrollments 45 // to use in separating teacher and admin roles 46 func (s *QuickFeedService) GetUser(ctx context.Context, _ *connect.Request[qf.Void]) (*connect.Response[qf.User], error) { 47 userInfo, err := s.db.GetUserWithEnrollments(userID(ctx)) 48 if err != nil { 49 s.logger.Errorf("GetUser(%d) failed: %v", userID(ctx), err) 50 return nil, connect.NewError(connect.CodeNotFound, errors.New("unknown user")) 51 } 52 return connect.NewResponse(userInfo), nil 53 } 54 55 // GetUsers returns a list of all users. 56 // Frontend note: This method is called from AdminPage. 57 func (s *QuickFeedService) GetUsers(_ context.Context, _ *connect.Request[qf.Void]) (*connect.Response[qf.Users], error) { 58 users, err := s.db.GetUsers() 59 if err != nil { 60 s.logger.Errorf("GetUsers failed: %v", err) 61 return nil, connect.NewError(connect.CodeNotFound, errors.New("failed to get users")) 62 } 63 return connect.NewResponse(&qf.Users{ 64 Users: users, 65 }), nil 66 } 67 68 // UpdateUser updates the current users's information and returns the updated user. 69 // This function can also promote a user to admin or demote a user. 70 func (s *QuickFeedService) UpdateUser(ctx context.Context, in *connect.Request[qf.User]) (*connect.Response[qf.Void], error) { 71 usr, err := s.db.GetUser(userID(ctx)) 72 if err != nil { 73 s.logger.Errorf("UpdateUser(userID=%d) failed: %v", userID(ctx), err) 74 return nil, connect.NewError(connect.CodeNotFound, errors.New("unknown user")) 75 } 76 if _, err = s.updateUser(usr, in.Msg); err != nil { 77 s.logger.Errorf("UpdateUser failed to update user %d: %v", in.Msg.GetID(), err) 78 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to update user")) 79 } 80 return &connect.Response[qf.Void]{}, nil 81 } 82 83 // CreateCourse creates a new course. 84 func (s *QuickFeedService) CreateCourse(ctx context.Context, in *connect.Request[qf.Course]) (*connect.Response[qf.Course], error) { 85 scmClient, err := s.getSCM(ctx, in.Msg.ScmOrganizationName) 86 if err != nil { 87 s.logger.Errorf("CreateCourse failed: could not create scm client for organization %s: %v", in.Msg.ScmOrganizationName, err) 88 return nil, connect.NewError(connect.CodeNotFound, err) 89 } 90 // make sure that the current user is set as course creator 91 in.Msg.CourseCreatorID = userID(ctx) 92 course, err := s.createCourse(ctx, scmClient, in.Msg) 93 if err != nil { 94 s.logger.Errorf("CreateCourse failed: %v", err) 95 if ctxErr := ctxErr(ctx); ctxErr != nil { 96 s.logger.Error(ctxErr) 97 return nil, ctxErr 98 } 99 if err == scm.ErrAlreadyExists { 100 return nil, connect.NewError(connect.CodeAlreadyExists, err) 101 } 102 if ok, parsedErr := parseSCMError(err); ok { 103 return nil, parsedErr 104 } 105 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to create course")) 106 } 107 return connect.NewResponse(course), nil 108 } 109 110 // UpdateCourse changes the course information details. 111 func (s *QuickFeedService) UpdateCourse(ctx context.Context, in *connect.Request[qf.Course]) (*connect.Response[qf.Void], error) { 112 scmClient, err := s.getSCM(ctx, in.Msg.ScmOrganizationName) 113 if err != nil { 114 s.logger.Errorf("UpdateCourse failed: could not create scm client for organization %s: %v", in.Msg.ScmOrganizationName, err) 115 return nil, connect.NewError(connect.CodeNotFound, err) 116 } 117 if err = s.updateCourse(ctx, scmClient, in.Msg); err != nil { 118 s.logger.Errorf("UpdateCourse failed: %v", err) 119 if ctxErr := ctxErr(ctx); ctxErr != nil { 120 s.logger.Error(ctxErr) 121 return nil, ctxErr 122 } 123 if ok, parsedErr := parseSCMError(err); ok { 124 return nil, parsedErr 125 } 126 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to update course")) 127 } 128 return &connect.Response[qf.Void]{}, nil 129 } 130 131 // GetCourse returns course information for the given course. 132 func (s *QuickFeedService) GetCourse(_ context.Context, in *connect.Request[qf.CourseRequest]) (*connect.Response[qf.Course], error) { 133 course, err := s.db.GetCourse(in.Msg.GetCourseID(), false) 134 if err != nil { 135 s.logger.Errorf("GetCourse failed: %v", err) 136 return nil, connect.NewError(connect.CodeNotFound, errors.New("course not found")) 137 } 138 return connect.NewResponse(course), nil 139 } 140 141 // GetCourses returns a list of all courses. 142 func (s *QuickFeedService) GetCourses(_ context.Context, _ *connect.Request[qf.Void]) (*connect.Response[qf.Courses], error) { 143 courses, err := s.db.GetCourses() 144 if err != nil { 145 s.logger.Errorf("GetCourses failed: %v", err) 146 return nil, connect.NewError(connect.CodeNotFound, errors.New("no courses found")) 147 } 148 return connect.NewResponse(&qf.Courses{ 149 Courses: courses, 150 }), nil 151 } 152 153 // UpdateCourseVisibility allows to edit what courses are visible in the sidebar. 154 func (s *QuickFeedService) UpdateCourseVisibility(ctx context.Context, in *connect.Request[qf.Enrollment]) (*connect.Response[qf.Void], error) { 155 enrollment, err := s.db.GetEnrollmentByCourseAndUser(in.Msg.GetCourseID(), userID(ctx)) 156 if err != nil { 157 s.logger.Errorf("UpdateCourseVisibility failed: %v", err) 158 return nil, connect.NewError(connect.CodeNotFound, errors.New("failed to get enrollment")) 159 } 160 161 enrollment.State = in.Msg.GetState() 162 if err := s.db.UpdateEnrollment(enrollment); err != nil { 163 s.logger.Errorf("ChangeCourseVisibility failed: %v", err) 164 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to update course visibility")) 165 } 166 return &connect.Response[qf.Void]{}, nil 167 } 168 169 // CreateEnrollment enrolls a new student for the course specified in the request. 170 func (s *QuickFeedService) CreateEnrollment(_ context.Context, in *connect.Request[qf.Enrollment]) (*connect.Response[qf.Void], error) { 171 enrollment := &qf.Enrollment{ 172 UserID: in.Msg.GetUserID(), 173 CourseID: in.Msg.GetCourseID(), 174 Status: qf.Enrollment_PENDING, 175 } 176 if err := s.db.CreateEnrollment(enrollment); err != nil { 177 s.logger.Errorf("CreateEnrollment failed: %v", err) 178 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to create enrollment")) 179 } 180 return &connect.Response[qf.Void]{}, nil 181 } 182 183 // UpdateEnrollments changes status of all pending enrollments for the specified course to approved. 184 // If the request contains a single enrollment, it will be updated to the specified status. 185 func (s *QuickFeedService) UpdateEnrollments(ctx context.Context, in *connect.Request[qf.Enrollments]) (*connect.Response[qf.Void], error) { 186 usr, err := s.db.GetUser(userID(ctx)) 187 if err != nil { 188 s.logger.Errorf("UpdateEnrollments(userID=%d) failed: %v", userID(ctx), err) 189 return nil, connect.NewError(connect.CodeNotFound, errors.New("unknown user")) 190 } 191 scmClient, err := s.getSCMForCourse(ctx, in.Msg.GetCourseID()) 192 if err != nil { 193 s.logger.Errorf("UpdateEnrollments failed: could not create scm client for course %d: %v", in.Msg.GetCourseID(), err) 194 return nil, connect.NewError(connect.CodeNotFound, err) 195 } 196 for _, enrollment := range in.Msg.GetEnrollments() { 197 if s.isCourseCreator(enrollment.CourseID, enrollment.UserID) { 198 s.logger.Errorf("UpdateEnrollments failed: user %s attempted to demote course creator", usr.GetName()) 199 return nil, connect.NewError(connect.CodePermissionDenied, errors.New("course creator cannot be demoted")) 200 } 201 if err = s.updateEnrollment(ctx, scmClient, usr.GetLogin(), enrollment); err != nil { 202 s.logger.Errorf("UpdateEnrollments failed: %v", err) 203 if ctxErr := ctxErr(ctx); ctxErr != nil { 204 s.logger.Error(ctxErr) 205 return nil, ctxErr 206 } 207 if ok, parsedErr := parseSCMError(err); ok { 208 return nil, parsedErr 209 } 210 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to update enrollments")) 211 } 212 } 213 return &connect.Response[qf.Void]{}, nil 214 } 215 216 // GetEnrollments returns all enrollments for the given course ID or user ID and enrollment status. 217 func (s *QuickFeedService) GetEnrollments(_ context.Context, in *connect.Request[qf.EnrollmentRequest]) (*connect.Response[qf.Enrollments], error) { 218 var enrollments []*qf.Enrollment 219 var err error 220 switch in.Msg.GetFetchMode().(type) { 221 case *qf.EnrollmentRequest_UserID: 222 enrollments, err = s.db.GetEnrollmentsByUser(in.Msg.GetUserID(), in.Msg.GetStatuses()...) 223 if err != nil { 224 s.logger.Errorf("GetEnrollments failed: user %d: %v", in.Msg.GetUserID(), err) 225 return nil, connect.NewError(connect.CodeNotFound, errors.New("no enrollments found for user")) 226 } 227 case *qf.EnrollmentRequest_CourseID: 228 enrollments, err = s.getEnrollmentsByCourse(in.Msg) 229 if err != nil { 230 s.logger.Errorf("GetEnrollments failed: course %d: %v", in.Msg.GetCourseID(), err) 231 return nil, connect.NewError(connect.CodeNotFound, errors.New("failed to get enrollments for course")) 232 } 233 default: 234 s.logger.Errorf("GetEnrollments failed: unknown message type: %v", in.Msg.GetFetchMode()) 235 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to get enrollments")) 236 } 237 return connect.NewResponse(&qf.Enrollments{ 238 Enrollments: enrollments, 239 }), nil 240 } 241 242 // GetGroup returns information about the given group ID, or the given user's course group if group ID is 0. 243 func (s *QuickFeedService) GetGroup(_ context.Context, in *connect.Request[qf.GroupRequest]) (*connect.Response[qf.Group], error) { 244 var ( 245 group *qf.Group 246 err error 247 groupID = in.Msg.GetGroupID() 248 ) 249 if groupID > 0 { 250 group, err = s.db.GetGroup(groupID) 251 } else { 252 group, err = s.getGroupByUserAndCourse(in.Msg) 253 } 254 if err != nil { 255 s.logger.Errorf("GetGroup failed: group %d: %v", in.Msg, err) 256 return nil, connect.NewError(connect.CodeNotFound, errors.New("failed to get group")) 257 } 258 return connect.NewResponse(group), nil 259 } 260 261 // GetGroupsByCourse returns groups created for the given course. 262 func (s *QuickFeedService) GetGroupsByCourse(_ context.Context, in *connect.Request[qf.CourseRequest]) (*connect.Response[qf.Groups], error) { 263 groups, err := s.db.GetGroupsByCourse(in.Msg.GetCourseID()) 264 if err != nil { 265 s.logger.Errorf("GetGroups failed: course %d: %v", in.Msg.GetCourseID(), err) 266 return nil, connect.NewError(connect.CodeNotFound, errors.New("failed to get groups")) 267 } 268 return connect.NewResponse(&qf.Groups{ 269 Groups: groups, 270 }), nil 271 } 272 273 // CreateGroup creates a new group in the database. 274 // Access policy: Any User enrolled in course and specified as member of the group or a course teacher. 275 func (s *QuickFeedService) CreateGroup(_ context.Context, in *connect.Request[qf.Group]) (*connect.Response[qf.Group], error) { 276 group, err := s.createGroup(in.Msg) 277 if err != nil { 278 s.logger.Errorf("CreateGroup failed: %v", err) 279 if connect.CodeOf(err) != connect.CodeUnknown { 280 // err was already a status error; return it to client. 281 return nil, err 282 } 283 // err was not a status error; return a generic error to client. 284 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to create group")) 285 } 286 return connect.NewResponse(group), nil 287 } 288 289 // UpdateGroup updates group information, and returns the updated group. 290 func (s *QuickFeedService) UpdateGroup(ctx context.Context, in *connect.Request[qf.Group]) (*connect.Response[qf.Group], error) { 291 scmClient, err := s.getSCMForCourse(ctx, in.Msg.GetCourseID()) 292 if err != nil { 293 s.logger.Errorf("UpdateGroup failed: could not create scm client for group %s and course %d: %v", in.Msg.GetName(), in.Msg.GetCourseID(), err) 294 return nil, connect.NewError(connect.CodeNotFound, err) 295 } 296 err = s.updateGroup(ctx, scmClient, in.Msg) 297 if err != nil { 298 s.logger.Errorf("UpdateGroup failed: %v", err) 299 if ctxErr := ctxErr(ctx); ctxErr != nil { 300 s.logger.Error(ctxErr) 301 return nil, ctxErr 302 } 303 if ok, parsedErr := parseSCMError(err); ok { 304 return nil, parsedErr 305 } 306 if connect.CodeOf(err) != connect.CodeUnknown { 307 // err was already a status error; return it to client. 308 return nil, err 309 } 310 // err was not a status error; return a generic error to client. 311 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to update group")) 312 } 313 group, err := s.db.GetGroup(in.Msg.ID) 314 if err != nil { 315 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to get group")) 316 } 317 return connect.NewResponse(group), nil 318 } 319 320 // DeleteGroup removes group record from the database. 321 func (s *QuickFeedService) DeleteGroup(ctx context.Context, in *connect.Request[qf.GroupRequest]) (*connect.Response[qf.Void], error) { 322 scmClient, err := s.getSCMForCourse(ctx, in.Msg.GetCourseID()) 323 if err != nil { 324 s.logger.Errorf("DeleteGroup failed: could not create scm client for group %d and course %d: %v", in.Msg.GetGroupID(), in.Msg.GetCourseID(), err) 325 return nil, connect.NewError(connect.CodeNotFound, err) 326 } 327 if err = s.deleteGroup(ctx, scmClient, in.Msg); err != nil { 328 s.logger.Errorf("DeleteGroup failed: %v", err) 329 if ctxErr := ctxErr(ctx); ctxErr != nil { 330 s.logger.Error(ctxErr) 331 return nil, ctxErr 332 } 333 if ok, parsedErr := parseSCMError(errors.Unwrap(err)); ok { 334 return nil, parsedErr 335 } 336 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to delete group")) 337 } 338 return &connect.Response[qf.Void]{}, nil 339 } 340 341 // GetSubmission returns a fully populated submission matching the given submission ID if it exists for the given course ID. 342 // Used in the frontend to fetch a full submission for a given submission ID and course ID. 343 func (s *QuickFeedService) GetSubmission(_ context.Context, in *connect.Request[qf.SubmissionRequest]) (*connect.Response[qf.Submission], error) { 344 submission, err := s.db.GetLastSubmission(in.Msg.GetCourseID(), &qf.Submission{ID: in.Msg.GetSubmissionID()}) 345 if err != nil { 346 s.logger.Errorf("GetSubmission failed: %v", err) 347 return nil, connect.NewError(connect.CodeNotFound, errors.New("failed to get submission")) 348 } 349 return connect.NewResponse(submission), nil 350 } 351 352 // GetSubmissions returns the submissions matching the query encoded in the action request. 353 func (s *QuickFeedService) GetSubmissions(ctx context.Context, in *connect.Request[qf.SubmissionRequest]) (*connect.Response[qf.Submissions], error) { 354 s.logger.Debugf("GetSubmissions: %v", in.Msg) 355 submissions, err := s.getSubmissions(in.Msg) 356 if err != nil { 357 s.logger.Errorf("GetSubmissions failed: %v", err) 358 return nil, connect.NewError(connect.CodeNotFound, errors.New("no submissions found")) 359 } 360 // If the user is not a teacher, remove score and reviews from submissions that are not released. 361 if !s.isTeacher(userID(ctx), in.Msg.CourseID) { 362 submissions.Clean() 363 } 364 return connect.NewResponse(submissions), nil 365 } 366 367 // GetSubmissionsByCourse returns a map of submissions for the given course ID. 368 // The map is keyed by either the group ID or enrollment ID depending on request type. 369 // SubmissionRequest_GROUP returns a map keyed by group ID. 370 // SubmissionRequest_ALL and SubmissionRequest_USER return a map keyed by enrollment ID. 371 // The map values are lists of all submissions for the given group or enrollment. 372 func (s *QuickFeedService) GetSubmissionsByCourse(_ context.Context, in *connect.Request[qf.SubmissionRequest]) (*connect.Response[qf.CourseSubmissions], error) { 373 s.logger.Debugf("GetSubmissionsByCourse: %v", in) 374 375 courseLinks, err := s.getAllCourseSubmissions(in.Msg) 376 if err != nil { 377 s.logger.Errorf("GetSubmissionsByCourse failed: %v", err) 378 return nil, connect.NewError(connect.CodeNotFound, errors.New("no submissions found")) 379 } 380 return connect.NewResponse(courseLinks), nil 381 } 382 383 // UpdateSubmission is called to approve the given submission or to undo approval. 384 func (s *QuickFeedService) UpdateSubmission(_ context.Context, in *connect.Request[qf.UpdateSubmissionRequest]) (*connect.Response[qf.Void], error) { 385 err := s.updateSubmission(in.Msg.GetSubmissionID(), in.Msg.GetStatus(), in.Msg.GetReleased(), in.Msg.GetScore()) 386 if err != nil { 387 s.logger.Errorf("UpdateSubmission failed: %v", err) 388 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to approve submission")) 389 } 390 return &connect.Response[qf.Void]{}, nil 391 } 392 393 // RebuildSubmissions re-runs the tests for the given assignment and course. 394 // A single submission is executed again if the request specifies a submission ID 395 // or all submissions if no submission ID is specified. 396 func (s *QuickFeedService) RebuildSubmissions(_ context.Context, in *connect.Request[qf.RebuildRequest]) (*connect.Response[qf.Void], error) { 397 if in.Msg.GetSubmissionID() > 0 { 398 // Submission ID > 0 ==> rebuild single submission for given CourseID and AssignmentID 399 if _, err := s.rebuildSubmission(in.Msg); err != nil { 400 s.logger.Errorf("RebuildSubmission failed: %v", err) 401 return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to rebuild submission: %w", err)) 402 } 403 } else { 404 // Submission ID == 0 ==> rebuild all for given CourseID and AssignmentID 405 if err := s.rebuildSubmissions(in.Msg); err != nil { 406 s.logger.Errorf("RebuildSubmissions failed: %v", err) 407 return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to rebuild submissions: %w", err)) 408 } 409 } 410 return &connect.Response[qf.Void]{}, nil 411 } 412 413 // CreateBenchmark adds a new grading benchmark for an assignment. 414 func (s *QuickFeedService) CreateBenchmark(_ context.Context, in *connect.Request[qf.GradingBenchmark]) (*connect.Response[qf.GradingBenchmark], error) { 415 bm, err := s.createBenchmark(in.Msg) 416 if err != nil { 417 s.logger.Errorf("CreateBenchmark failed for %+v: %v", in, err) 418 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to add benchmark")) 419 } 420 return connect.NewResponse(bm), nil 421 } 422 423 // UpdateBenchmark edits a grading benchmark for an assignment. 424 func (s *QuickFeedService) UpdateBenchmark(_ context.Context, in *connect.Request[qf.GradingBenchmark]) (*connect.Response[qf.Void], error) { 425 if err := s.db.UpdateBenchmark(in.Msg); err != nil { 426 s.logger.Errorf("UpdateBenchmark failed for %+v: %v", in, err) 427 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to update benchmark")) 428 } 429 return &connect.Response[qf.Void]{}, nil 430 } 431 432 // DeleteBenchmark removes a grading benchmark. 433 func (s *QuickFeedService) DeleteBenchmark(_ context.Context, in *connect.Request[qf.GradingBenchmark]) (*connect.Response[qf.Void], error) { 434 if err := s.db.DeleteBenchmark(in.Msg); err != nil { 435 s.logger.Errorf("DeleteBenchmark failed for %+v: %v", in, err) 436 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to delete benchmark")) 437 } 438 return &connect.Response[qf.Void]{}, nil 439 } 440 441 // CreateCriterion adds a new grading criterion for an assignment. 442 func (s *QuickFeedService) CreateCriterion(_ context.Context, in *connect.Request[qf.GradingCriterion]) (*connect.Response[qf.GradingCriterion], error) { 443 if err := s.db.CreateCriterion(in.Msg); err != nil { 444 s.logger.Errorf("CreateCriterion failed for %+v: %v", in, err) 445 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to add criterion")) 446 } 447 return connect.NewResponse(in.Msg), nil 448 } 449 450 // UpdateCriterion edits a grading criterion for an assignment. 451 func (s *QuickFeedService) UpdateCriterion(_ context.Context, in *connect.Request[qf.GradingCriterion]) (*connect.Response[qf.Void], error) { 452 if err := s.db.UpdateCriterion(in.Msg); err != nil { 453 s.logger.Errorf("UpdateCriterion failed for %+v: %v", in, err) 454 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to update criterion")) 455 } 456 return &connect.Response[qf.Void]{}, nil 457 } 458 459 // DeleteCriterion removes a grading criterion for an assignment. 460 func (s *QuickFeedService) DeleteCriterion(_ context.Context, in *connect.Request[qf.GradingCriterion]) (*connect.Response[qf.Void], error) { 461 if err := s.db.DeleteCriterion(in.Msg); err != nil { 462 s.logger.Errorf("DeleteCriterion failed for %+v: %v", in, err) 463 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to delete criterion")) 464 } 465 return &connect.Response[qf.Void]{}, nil 466 } 467 468 // CreateReview adds a new submission review. 469 func (s *QuickFeedService) CreateReview(_ context.Context, in *connect.Request[qf.ReviewRequest]) (*connect.Response[qf.Review], error) { 470 review, err := s.createReview(in.Msg.Review) 471 if err != nil { 472 s.logger.Errorf("CreateReview failed for review %+v: %v", in, err) 473 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to create review")) 474 } 475 return connect.NewResponse(review), nil 476 } 477 478 // UpdateReview updates a submission review. 479 func (s *QuickFeedService) UpdateReview(_ context.Context, in *connect.Request[qf.ReviewRequest]) (*connect.Response[qf.Review], error) { 480 review, err := s.updateReview(in.Msg.Review) 481 if err != nil { 482 s.logger.Errorf("UpdateReview failed for review %+v: %v", in, err) 483 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to update review")) 484 } 485 return connect.NewResponse(review), nil 486 } 487 488 // UpdateSubmissions approves and/or releases all manual reviews for student submission for the given assignment 489 // with the given score. 490 func (s *QuickFeedService) UpdateSubmissions(_ context.Context, in *connect.Request[qf.UpdateSubmissionsRequest]) (*connect.Response[qf.Void], error) { 491 err := s.updateSubmissions(in.Msg) 492 if err != nil { 493 s.logger.Errorf("UpdateSubmissions failed for request %+v: %v", in, err) 494 return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("failed to update submissions")) 495 } 496 return &connect.Response[qf.Void]{}, nil 497 } 498 499 // GetAssignments returns a list of all assignments for the given course. 500 func (s *QuickFeedService) GetAssignments(_ context.Context, in *connect.Request[qf.CourseRequest]) (*connect.Response[qf.Assignments], error) { 501 assignments, err := s.getAssignments(in.Msg.GetCourseID()) 502 if err != nil { 503 s.logger.Errorf("GetAssignments failed: %v", err) 504 return nil, connect.NewError(connect.CodeNotFound, errors.New("no assignments found for course")) 505 } 506 return connect.NewResponse(assignments), nil 507 } 508 509 // UpdateAssignments updates the course's assignments record in the database 510 // by fetching assignment information from the course's test repository. 511 func (s *QuickFeedService) UpdateAssignments(ctx context.Context, in *connect.Request[qf.CourseRequest]) (*connect.Response[qf.Void], error) { 512 course, err := s.db.GetCourse(in.Msg.GetCourseID(), false) 513 if err != nil { 514 s.logger.Errorf("UpdateAssignments failed: course %d: %v", in.Msg.GetCourseID(), err) 515 return nil, connect.NewError(connect.CodeNotFound, errors.New("course not found")) 516 } 517 scmClient, err := s.getSCM(ctx, course.GetScmOrganizationName()) 518 if err != nil { 519 s.logger.Errorf("UpdateAssignments failed: could not create scm client for organization %s: %v", course.GetScmOrganizationName(), err) 520 return nil, connect.NewError(connect.CodeNotFound, err) 521 } 522 assignments.UpdateFromTestsRepo(s.logger, s.runner, s.db, scmClient, course) 523 524 clonedAssignmentsRepo, err := scmClient.Clone(ctx, &scm.CloneOptions{ 525 Organization: course.GetScmOrganizationName(), 526 Repository: qf.AssignmentsRepo, 527 DestDir: course.CloneDir(), 528 }) 529 if err != nil { 530 s.logger.Errorf("Failed to clone '%s' repository: %v", qf.AssignmentsRepo, err) 531 return nil, err 532 } 533 s.logger.Debugf("Successfully cloned assignments repository to: %s", clonedAssignmentsRepo) 534 535 return &connect.Response[qf.Void]{}, nil 536 } 537 538 // GetOrganization fetches a github organization by name. 539 func (s *QuickFeedService) GetOrganization(ctx context.Context, in *connect.Request[qf.Organization]) (*connect.Response[qf.Organization], error) { 540 usr, err := s.db.GetUser(userID(ctx)) 541 if err != nil { 542 s.logger.Errorf("GetOrganization(userID=%d) failed: %v", userID(ctx), err) 543 return nil, connect.NewError(connect.CodeNotFound, errors.New("unknown user")) 544 } 545 scmClient, err := s.getSCM(ctx, in.Msg.GetScmOrganizationName()) 546 if err != nil { 547 s.logger.Errorf("GetOrganization failed: could not create scm client for organization %s: %v", in.Msg.GetScmOrganizationName(), err) 548 return nil, connect.NewError(connect.CodeNotFound, err) 549 } 550 org, err := scmClient.GetOrganization(ctx, &scm.OrganizationOptions{Name: in.Msg.GetScmOrganizationName(), Username: usr.GetLogin(), NewCourse: true}) 551 if err != nil { 552 s.logger.Errorf("GetOrganization failed: %v", err) 553 if ctxErr := ctxErr(ctx); ctxErr != nil { 554 s.logger.Error(ctxErr) 555 return nil, ctxErr 556 } 557 if err == scm.ErrNotMember { 558 return nil, connect.NewError(connect.CodeNotFound, errors.New("organization membership not confirmed, please enable third-party access")) 559 } 560 if ok, parsedErr := parseSCMError(err); ok { 561 return nil, parsedErr 562 } 563 return nil, connect.NewError(connect.CodeNotFound, errors.New("organization not found. Please make sure that 3rd-party access is enabled for your organization")) 564 } 565 return connect.NewResponse(org), nil 566 } 567 568 // GetRepositories returns URL strings for repositories of given type for the given course. 569 func (s *QuickFeedService) GetRepositories(ctx context.Context, in *connect.Request[qf.CourseRequest]) (*connect.Response[qf.Repositories], error) { 570 course, err := s.db.GetCourse(in.Msg.GetCourseID(), false) 571 if err != nil { 572 s.logger.Errorf("GetRepositories failed: course %d not found: %v", in.Msg.GetCourseID(), err) 573 return nil, connect.NewError(connect.CodeNotFound, errors.New("course not found")) 574 } 575 usrID := userID(ctx) 576 enrol, err := s.db.GetEnrollmentByCourseAndUser(course.GetID(), usrID) 577 if err != nil { 578 s.logger.Error("GetRepositories failed: enrollment for user %d and course %d not found: v", usrID, course.GetID(), err) 579 return nil, connect.NewError(connect.CodeNotFound, errors.New("enrollment not found")) 580 } 581 582 urls := make(map[uint32]string) 583 for _, repoType := range repoTypes(enrol) { 584 var id uint64 585 switch repoType { 586 case qf.Repository_USER: 587 id = usrID 588 case qf.Repository_GROUP: 589 id = enrol.GetGroupID() // will be 0 if not enrolled in a group 590 } 591 repo, _ := s.getRepo(course, id, repoType) 592 // for repo == nil: will result in an empty URL string, which will be ignored by the frontend 593 urls[uint32(repoType)] = repo.GetHTMLURL() 594 } 595 return connect.NewResponse(&qf.Repositories{URLs: urls}), nil 596 } 597 598 // IsEmptyRepo ensures that group repository is empty and can be deleted. 599 func (s *QuickFeedService) IsEmptyRepo(ctx context.Context, in *connect.Request[qf.RepositoryRequest]) (*connect.Response[qf.Void], error) { 600 course, err := s.db.GetCourse(in.Msg.GetCourseID(), false) 601 if err != nil { 602 s.logger.Errorf("IsEmptyRepo failed: course %d not found: %v", in.Msg.GetCourseID(), err) 603 return nil, connect.NewError(connect.CodeNotFound, errors.New("course not found")) 604 } 605 repos, err := s.db.GetRepositories(&qf.Repository{ 606 ScmOrganizationID: course.GetScmOrganizationID(), 607 UserID: in.Msg.GetUserID(), 608 GroupID: in.Msg.GetGroupID(), 609 }) 610 if err != nil { 611 s.logger.Errorf("IsEmptyRepo failed: could not get repositories for course %d, user %d, group %d: %v", in.Msg.GetCourseID(), in.Msg.GetUserID(), in.Msg.GetGroupID(), err) 612 return nil, connect.NewError(connect.CodeNotFound, errors.New("repositories not found")) 613 } 614 if len(repos) < 1 { 615 // No repository found, nothing to delete 616 return &connect.Response[qf.Void]{}, nil 617 } 618 scmClient, err := s.getSCM(ctx, course.GetScmOrganizationName()) 619 if err != nil { 620 s.logger.Errorf("IsEmptyRepo failed: could not create scm client for course %d: %v", in.Msg.GetCourseID(), err) 621 return nil, connect.NewError(connect.CodeNotFound, err) 622 } 623 624 if err := isEmpty(ctx, scmClient, repos); err != nil { 625 s.logger.Errorf("IsEmptyRepo failed: %v", err) 626 if ctxErr := ctxErr(ctx); ctxErr != nil { 627 s.logger.Error(ctxErr) 628 return nil, ctxErr 629 } 630 return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("group repository is not empty")) 631 } 632 return &connect.Response[qf.Void]{}, nil 633 } 634 635 // SubmissionStream adds the the created stream to the stream service. 636 // The stream may be used to send the submission results to the frontend. 637 // The stream is closed when the client disconnects. 638 func (s *QuickFeedService) SubmissionStream(ctx context.Context, _ *connect.Request[qf.Void], st *connect.ServerStream[qf.Submission]) error { 639 stream := stream.NewStream(ctx, st) 640 s.streams.Submission.Add(stream, userID(ctx)) 641 return stream.Run() 642 }