Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions internal/dto/assignment_dto.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package dto

import (
"mime/multipart"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -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 {
Expand Down
93 changes: 62 additions & 31 deletions internal/handlers/assignments/assignments.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"courses-microservice/internal/models"
assignmentsServices "courses-microservice/internal/services/assignments"
"courses-microservice/internal/utils"
"fmt"
"net/http"
"path/filepath"
"strconv"
Expand Down Expand Up @@ -396,7 +395,7 @@
// @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"
Expand All @@ -422,11 +421,6 @@
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()))
Expand All @@ -439,40 +433,77 @@
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
}

Check warning on line 440 in internal/handlers/assignments/assignments.go

View check run for this annotation

Codecov / codecov/patch

internal/handlers/assignments/assignments.go#L438-L440

Added lines #L438 - L440 were not covered by tests

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

Check warning on line 483 in internal/handlers/assignments/assignments.go

View check run for this annotation

Codecov / codecov/patch

internal/handlers/assignments/assignments.go#L482-L483

Added lines #L482 - L483 were not covered by tests
} else if fileDTO.File == nil {
_ = context.Error(errors.NewBadRequestError("File is required"))
return

Check warning on line 486 in internal/handlers/assignments/assignments.go

View check run for this annotation

Codecov / codecov/patch

internal/handlers/assignments/assignments.go#L485-L486

Added lines #L485 - L486 were not covered by tests
} else if fileDTO.File.Size > a.conf.MAX_FILE_SIZE {
_ = context.Error(errors.NewBadRequestError("File size exceeds the maximum limit"))
return
}

Check warning on line 490 in internal/handlers/assignments/assignments.go

View check run for this annotation

Codecov / codecov/patch

internal/handlers/assignments/assignments.go#L488-L490

Added lines #L488 - L490 were not covered by tests

file, err := fileDTO.File.Open()
if err != nil {
_ = context.Error(errors.NewBadRequestError("Invalid file"))
return
}

Check warning on line 496 in internal/handlers/assignments/assignments.go

View check run for this annotation

Codecov / codecov/patch

internal/handlers/assignments/assignments.go#L494-L496

Added lines #L494 - L496 were not covered by tests
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
Expand Down
45 changes: 41 additions & 4 deletions internal/handlers/assignments/assignments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions internal/handlers/assignments/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions internal/router/assignments_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
63 changes: 49 additions & 14 deletions internal/services/assignments/assignments.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,21 +209,9 @@
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
Expand All @@ -234,12 +222,37 @@
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
}

Check warning on line 247 in internal/services/assignments/assignments.go

View check run for this annotation

Codecov / codecov/patch

internal/services/assignments/assignments.go#L246-L247

Added lines #L246 - L247 were not covered by tests

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)
Expand Down Expand Up @@ -338,3 +351,25 @@
}
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")
}

Check warning on line 366 in internal/services/assignments/assignments.go

View check run for this annotation

Codecov / codecov/patch

internal/services/assignments/assignments.go#L365-L366

Added lines #L365 - L366 were not covered by tests

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
}
25 changes: 25 additions & 0 deletions internal/services/assignments/assignments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions internal/services/assignments/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading