diff --git a/internal/dto/assignment_dto.go b/internal/dto/assignment_dto.go index 493bb5d..4d7f054 100644 --- a/internal/dto/assignment_dto.go +++ b/internal/dto/assignment_dto.go @@ -1,7 +1,6 @@ package dto import ( - "mime/multipart" "time" "github.com/google/uuid" @@ -33,8 +32,7 @@ type AssignmentResponseDTO struct { } type AssignmentSubmissionCreateDTO struct { - Text string `form:"text" binding:"required"` - Attachment *multipart.FileHeader `form:"attachment" binding:"omitempty" swaggertype:"string" format:"binary"` + Text string `json:"text" binding:"required"` } type AssignmentSubmissionReviewDTO struct { diff --git a/internal/handlers/assignments/assignments.go b/internal/handlers/assignments/assignments.go index e07fce0..6918ff1 100644 --- a/internal/handlers/assignments/assignments.go +++ b/internal/handlers/assignments/assignments.go @@ -8,7 +8,6 @@ import ( "courses-microservice/internal/models" assignmentsServices "courses-microservice/internal/services/assignments" "courses-microservice/internal/utils" - "fmt" "net/http" "path/filepath" "strconv" @@ -396,7 +395,7 @@ func (a *assignmentsHandler) GetAssignmentByID(context *gin.Context) { // @Summary Submit assignment // @Description Submit an assignment as a student // @Tags assignment_submissions -// @Accept multipart/form-data +// @Accept json // @Produce json // @Param id path string true "Assignment ID" // @Param submission body dto.AssignmentSubmissionCreateDTO true "Assignment submission data" @@ -422,11 +421,6 @@ func (a *assignmentsHandler) SubmitAssignment(context *gin.Context) { return } - if !strings.HasPrefix(context.ContentType(), "multipart/") { - _ = context.Error(errors.NewBadRequestError("Expected multipart form data")) - return - } - var submissionDTO dto.AssignmentSubmissionCreateDTO if err := context.ShouldBind(&submissionDTO); err != nil { _ = context.Error(errors.NewBadRequestError(err.Error())) @@ -439,40 +433,77 @@ func (a *assignmentsHandler) SubmitAssignment(context *gin.Context) { return } - // Upload file and set the file path in submission - if submissionDTO.Attachment != nil { - assignment, err := a.service.GetAssignmentByID(parseAssignmentID) - if err != nil { - _ = context.Error(err) - return - } + err = a.service.SubmitAssignment(parseAssignmentID, parseUserID, submission) + if err != nil { + _ = context.Error(err) + return + } - file, err := submissionDTO.Attachment.Open() - if err != nil { - _ = context.Error(errors.NewBadRequestError("Invalid file")) - return - } - defer file.Close() //nolint:errcheck + resDTO := utils.NewAssignmentSubmissionResponseDTO(submission, true) + handlers.HandleSuccessResponse(context, http.StatusCreated, resDTO) +} - ext := filepath.Ext(submissionDTO.Attachment.Filename) - filename := fmt.Sprintf("submissions/%s/%s/%s/submission%s", parseUserID.String(), assignment.CourseId.String(), assignment.Id.String(), ext) - attachment, err := utils.UploadFileToBucket(&file, filename, a.conf.ASSIGNMENTS_BUCKET_NAME, a.conf) - if err != nil { - _ = context.Error(errors.NewInternalServerError("Failed to upload attachment: " + err.Error())) - return - } +// @Summary Add attachment to assignment submission +// @Description Add an attachment to an assignment submission +// @Tags assignment_submissions +// @Accept multipart/form-data +// @Produce json +// @Param id path string true "Submission ID" +// @Param attachment formData file true "File to attach" +// @Success 204 "Attachment added successfully" +// @Failure 400 {object} dto.ErrorResponse "Invalid file or submission ID format" +// @Failure 401 {object} dto.ErrorResponse "Unauthorized to add attachment" +// @Failure 403 {object} dto.ErrorResponse "Bad session" +// @Failure 404 {object} dto.ErrorResponse "Submission not found" +// @Failure 500 {object} dto.ErrorResponse "Internal server error" +// @Router /assignments/submission/attachment/{id} [put] +func (a *assignmentsHandler) AddSubmissionAttachment(context *gin.Context) { + userID := context.GetString("userID") + parseUserID, err := uuid.Parse(userID) + if err != nil { + _ = context.Error(errors.NewBadRequestError("Invalid Authorization header")) + return + } - submission.Attachment = attachment + submissionID := context.Param("id") + parseSubmissionID, err := uuid.Parse(submissionID) + if err != nil { + _ = context.Error(errors.NewBadRequestError("Invalid assignment ID format")) + return } - err = a.service.SubmitAssignment(parseAssignmentID, parseUserID, submission) + if !strings.HasPrefix(context.ContentType(), "multipart/") { + _ = context.Error(errors.NewBadRequestError("Expected multipart form data")) + return + } + + var fileDTO dto.IncomingFileDTO + if err := context.ShouldBind(&fileDTO); err != nil { + _ = context.Error(errors.NewBadRequestError(err.Error())) + return + } else if fileDTO.File == nil { + _ = context.Error(errors.NewBadRequestError("File is required")) + return + } else if fileDTO.File.Size > a.conf.MAX_FILE_SIZE { + _ = context.Error(errors.NewBadRequestError("File size exceeds the maximum limit")) + return + } + + file, err := fileDTO.File.Open() + if err != nil { + _ = context.Error(errors.NewBadRequestError("Invalid file")) + return + } + defer file.Close() //nolint:errcheck + + ext := filepath.Ext(fileDTO.File.Filename) + err = a.service.AddAttachmentToSubmission(parseSubmissionID, parseUserID, &file, ext) if err != nil { _ = context.Error(err) return } - resDTO := utils.NewAssignmentSubmissionResponseDTO(submission, true) - handlers.HandleSuccessResponse(context, http.StatusCreated, resDTO) + handlers.HandleBodilessResponse(context, http.StatusNoContent) } // @Summary Review assignment submission diff --git a/internal/handlers/assignments/assignments_test.go b/internal/handlers/assignments/assignments_test.go index 8b1de0e..68d065b 100644 --- a/internal/handlers/assignments/assignments_test.go +++ b/internal/handlers/assignments/assignments_test.go @@ -504,13 +504,51 @@ func TestAddAssignmentSubmission_Error(t *testing.T) { func TestAddAssignmentSubmission_Success(t *testing.T) { router := createRouterWithJWT(presetStudentID.String()) + submission := dto.AssignmentSubmissionCreateDTO{ + Text: "This is a test submission", + } + body, _ := json.Marshal(submission) + req, _ := http.NewRequest(http.MethodPost, "/assignments/submission/"+presetAssignmentIDs[0].String(), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + assert.Equal(t, http.StatusCreated, resp.Code) +} + +func TestAddAssignmentSubmissionAttachment_Error(t *testing.T) { + router := createRouterWithJWT("invalid-uuid") + // Invalid user id + req, _ := http.NewRequest(http.MethodPut, "/assignments/submission/attachment/"+presetSubmissionID.String(), nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + assert.Equal(t, http.StatusBadRequest, resp.Code) + + // Invalid submission id + router = createRouterWithJWT(presetUserID.String()) + req, _ = http.NewRequest(http.MethodPut, "/assignments/submission/attachment/"+"invalid-uuid", nil) + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + assert.Equal(t, http.StatusBadRequest, resp.Code) + + // No multipart form Data + req, _ = http.NewRequest(http.MethodPut, "/assignments/submission/attachment/"+presetSubmissionID.String(), nil) + resp = httptest.NewRecorder() + router.ServeHTTP(resp, req) + assert.Equal(t, http.StatusBadRequest, resp.Code) +} + +func TestAddAssignmentSubmissionAttachment_Success(t *testing.T) { + router := createRouterWithJWT(presetStudentID.String()) + + // Create multipart form data body, contentType := getMultipartAssignmentSubmissionBody() - req, _ := http.NewRequest(http.MethodPost, "/assignments/submission/"+presetAssignmentIDs[0].String(), body) + req, _ := http.NewRequest(http.MethodPut, "/assignments/submission/attachment/"+presetSubmissionID.String(), body) req.Header.Set("Content-Type", contentType) resp := httptest.NewRecorder() router.ServeHTTP(resp, req) - assert.Equal(t, http.StatusCreated, resp.Code) + assert.Equal(t, http.StatusNoContent, resp.Code) } func TestReviewAssignmentSubmission_Error(t *testing.T) { @@ -638,6 +676,7 @@ func createRouterWithJWT(userID string) *gin.Engine { r.GET("/assignment/:id", handler.GetAssignmentByID) r.POST("assignments/submission/:id", handler.SubmitAssignment) + r.PUT("assignments/submission/attachment/:id", handler.AddSubmissionAttachment) r.POST("assignments/submission/review/:id", handler.ReviewAssignmentSubmission) r.GET("assignments/submission/states/:id", handler.GetAssignmentStatesByID) r.GET("assignments/submission/:id", handler.GetAssignmentSubmissionByID) @@ -666,8 +705,6 @@ func getMultipartAssignmentSubmissionBody() (*bytes.Buffer, string) { var b bytes.Buffer w := multipart.NewWriter(&b) - _ = w.WriteField("text", "This is a test submission") - fileWriter, _ := w.CreateFormFile("attachment", "testfile.txt") _, _ = io.Copy(fileWriter, strings.NewReader("sample file content")) _ = w.Close() diff --git a/internal/handlers/assignments/handler.go b/internal/handlers/assignments/handler.go index bfb0752..91cff4c 100644 --- a/internal/handlers/assignments/handler.go +++ b/internal/handlers/assignments/handler.go @@ -35,6 +35,9 @@ type AssignmentsHandler interface { // SubmitAssignment allows a student to submit an assignment as form. SubmitAssignment(c *gin.Context) + // AddSubmissionAttachment allows a student to add an attachment to their assignment submission. + AddSubmissionAttachment(c *gin.Context) + // ReviewAssignmentSubmission allows a teacher to review an assignment submission. ReviewAssignmentSubmission(c *gin.Context) diff --git a/internal/router/assignments_router.go b/internal/router/assignments_router.go index eb051d2..a8bb8e4 100644 --- a/internal/router/assignments_router.go +++ b/internal/router/assignments_router.go @@ -27,6 +27,7 @@ func SetupAssignmentsRoutes(router *gin.Engine, conf *config.Config, repo reposi submissionsGroup := router.Group("/assignments/submission") submissionsGroup.POST("/:id", handler.SubmitAssignment) + submissionsGroup.PUT("/attachment/:id", handler.AddSubmissionAttachment) submissionsGroup.POST("/review/:id", handler.ReviewAssignmentSubmission) submissionsGroup.GET("/states/:id", handler.GetAssignmentStatesByID) submissionsGroup.GET("/:id", handler.GetAssignmentSubmissionByID) diff --git a/internal/services/assignments/assignments.go b/internal/services/assignments/assignments.go index 54ecd29..0d648af 100644 --- a/internal/services/assignments/assignments.go +++ b/internal/services/assignments/assignments.go @@ -209,21 +209,9 @@ func (s *assignmentsService) SubmitAssignment(assignmentID, studentID uuid.UUID, return err } - isEnrolled, err := s.repo.IsStudentEnrolled(assignment.CourseId, studentID) - if !isEnrolled || err != nil { - return errors.NewUnauthorizedError("Student is not enrolled in the course or an error occurred") - } - - course, _ := s.repo.GetCourseByID(assignment.CourseId) - if course != nil && course.Status == models.CourseCompleted { - return errors.NewUnauthorizedError("Cannot submit assignments for a completed course") - } - existingSubmission, _ := s.repo.GetAssignmentSubmissionByStudent(assignmentID, studentID) - pastDeadline := assignment.Deadline.Before(time.Now()) - if pastDeadline && existingSubmission == nil || - pastDeadline && existingSubmission != nil && !existingSubmission.IgnoreDeadline { - return errors.NewUnauthorizedError("Submission is past the deadline for this assignment") + if err = s.canSubmitAssignment(assignment, existingSubmission, studentID); err != nil { + return err } submission.AssignmentId = assignmentID @@ -234,12 +222,37 @@ func (s *assignmentsService) SubmitAssignment(assignmentID, studentID uuid.UUID, if existingSubmission != nil { submission.Comment = existingSubmission.Comment submission.Score = existingSubmission.Score + submission.Attachment = existingSubmission.Attachment submission.CreatedAt = existingSubmission.CreatedAt } return s.repo.CreateOrUpdateAssignmentSubmission(assignmentID, studentID, submission) } +func (s *assignmentsService) AddAttachmentToSubmission(submissionID, userID uuid.UUID, file *multipart.File, ext string) error { + existingSubmission, err := s.repo.GetAssignmentSubmissionByID(submissionID) + if err != nil { + return err + } + assignment, _ := s.repo.GetAssignmentByID(existingSubmission.AssignmentId) + + if err = s.canSubmitAssignment(assignment, existingSubmission, userID); err != nil { + return err + } + + filename := fmt.Sprintf("submissions/%s/%s/%s/submission%s", userID.String(), assignment.CourseId.String(), assignment.Id.String(), ext) + attachment, err := utils.UploadFileToBucket(file, filename, s.conf.ASSIGNMENTS_BUCKET_NAME, s.conf) + if err != nil { + return err + } + + existingSubmission.Reviewed = false + existingSubmission.IgnoreDeadline = false // Reset IgnoreDeadline for new submission + existingSubmission.Attachment = attachment + + return s.repo.CreateOrUpdateAssignmentSubmission(assignment.Id, existingSubmission.StudentId, existingSubmission) +} + func (s *assignmentsService) ReviewAssignmentSubmission(submissionID, userID uuid.UUID, reviewDTO *dto.AssignmentSubmissionReviewDTO) (*models.AssignmentSubmission, error) { submission, err := s.repo.GetAssignmentSubmissionByID(submissionID) @@ -338,3 +351,25 @@ func (s *assignmentsService) GetStatusByAssignmentAndStudent(assignmentID, stude } return models.AssignmentSubmitted, &submission.Id } + +/* HELPER FUNCTIONS */ + +func (s *assignmentsService) canSubmitAssignment(assignment *models.Assignment, existingSubmission *models.AssignmentSubmission, studentID uuid.UUID) error { + isEnrolled, err := s.repo.IsStudentEnrolled(assignment.CourseId, studentID) + if !isEnrolled || err != nil { + return errors.NewUnauthorizedError("Student is not enrolled in the course or an error occurred") + } + + course, _ := s.repo.GetCourseByID(assignment.CourseId) + if course != nil && course.Status == models.CourseCompleted { + return errors.NewUnauthorizedError("Cannot submit assignments for a completed course") + } + + pastDeadline := assignment.Deadline.Before(time.Now()) + if pastDeadline && existingSubmission == nil || + pastDeadline && existingSubmission != nil && !existingSubmission.IgnoreDeadline { + return errors.NewUnauthorizedError("Submission is past the deadline for this assignment") + } + + return nil +} diff --git a/internal/services/assignments/assignments_test.go b/internal/services/assignments/assignments_test.go index 02d9bea..9ba96af 100644 --- a/internal/services/assignments/assignments_test.go +++ b/internal/services/assignments/assignments_test.go @@ -299,6 +299,31 @@ func TestSubmitAssignment_Success(t *testing.T) { assert.NotNil(t, getSubmission) } +func TestAddSubmissionAttachment(t *testing.T) { + assignment := &models.Assignment{ + CourseId: courseID, + Deadline: time.Now().Add(48 * time.Hour), + } + _ = repo.AddAssignment(courseID, assignment) + submission := &models.AssignmentSubmission{ + AssignmentId: assignment.Id, + } + _ = repo.CreateOrUpdateAssignmentSubmission(assignment.Id, studentID, submission) + + // Not found + err := service.AddAttachmentToSubmission(uuid.New(), studentID, nil, ".png") + assert.Error(t, err) + assert.IsType(t, err, &errors.NotFoundError{}) + + // Not enrolled + err = service.AddAttachmentToSubmission(submission.Id, uuid.New(), nil, ".png") + assert.Error(t, err) + + // Success + err = service.AddAttachmentToSubmission(submission.Id, studentID, nil, ".png") + assert.NoError(t, err) +} + /* REVIEWS */ func TestReview_Error(t *testing.T) { diff --git a/internal/services/assignments/service.go b/internal/services/assignments/service.go index a973a98..7760dc9 100644 --- a/internal/services/assignments/service.go +++ b/internal/services/assignments/service.go @@ -22,6 +22,7 @@ type AssignmentsService interface { /* --- Submissions --- */ SubmitAssignment(assignmentID, studentID uuid.UUID, submission *models.AssignmentSubmission) error + AddAttachmentToSubmission(submissionID, userID uuid.UUID, file *multipart.File, ext string) error ReviewAssignmentSubmission(submissionID, userID uuid.UUID, reviewDTO *dto.AssignmentSubmissionReviewDTO) (*models.AssignmentSubmission, error) GetAssignmentStatesByID(assignmentID, userID uuid.UUID) ([]dto.AssignmentSubmissionStateDTO, error) diff --git a/internal/utils/validations.go b/internal/utils/validations.go index 4cf3bff..9373821 100644 --- a/internal/utils/validations.go +++ b/internal/utils/validations.go @@ -5,7 +5,6 @@ import ( "courses-microservice/internal/errors" "courses-microservice/internal/models" "fmt" - "mime/multipart" ) /* COURSES */ @@ -103,10 +102,9 @@ func ValidateAssignmentPatchData(data *dto.AssignmentPatchDTO) error { /* ASSIGNMENT SUBMISSIONS */ func ValidateAssignmentSubmissionCreateData(data *dto.AssignmentSubmissionCreateDTO) (*models.AssignmentSubmission, error) { - errors := make([]error, 2) + errors := make([]error, 1) errors = append(errors, validateStringLength(2000, &data.Text, "Text")) - errors = append(errors, validateFileSize(10*1024*1024, data.Attachment, "Attachment")) for _, err := range errors { if err != nil { @@ -115,8 +113,7 @@ func ValidateAssignmentSubmissionCreateData(data *dto.AssignmentSubmissionCreate } return &models.AssignmentSubmission{ - Text: data.Text, - Attachment: "", + Text: data.Text, }, nil } @@ -292,13 +289,3 @@ func validateStringLength(maxLength int, data *string, fieldName string) error { } return nil } - -func validateFileSize(maxSize int64, fileHeader *multipart.FileHeader, fieldName string) error { - if fileHeader == nil { - return nil - } - if fileHeader.Size > maxSize { - return errors.NewBadRequestError(fieldName + " size must be less than " + fmt.Sprintf("%d", maxSize) + " bytes") - } - return nil -}