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  }