diff --git a/internal/api/http/routes/contests_management.go b/internal/api/http/routes/contests_management.go index af90a598..0ba0b81b 100644 --- a/internal/api/http/routes/contests_management.go +++ b/internal/api/http/routes/contests_management.go @@ -15,32 +15,34 @@ import ( ) type ContestsManagementRoute interface { + AddGroupToContest(w http.ResponseWriter, r *http.Request) + AddParticipantsToContest(w http.ResponseWriter, r *http.Request) + AddTaskToContest(w http.ResponseWriter, r *http.Request) + ApproveRegistrationRequest(w http.ResponseWriter, r *http.Request) CreateContest(w http.ResponseWriter, r *http.Request) - EditContest(w http.ResponseWriter, r *http.Request) DeleteContest(w http.ResponseWriter, r *http.Request) - GetContestTasks(w http.ResponseWriter, r *http.Request) + EditContest(w http.ResponseWriter, r *http.Request) + EditContestTask(w http.ResponseWriter, r *http.Request) + GetAllContests(w http.ResponseWriter, r *http.Request) + GetAssignableGroups(w http.ResponseWriter, r *http.Request) + GetAssignableParticipants(w http.ResponseWriter, r *http.Request) GetAssignableTasks(w http.ResponseWriter, r *http.Request) - AddTaskToContest(w http.ResponseWriter, r *http.Request) - RemoveTaskFromContest(w http.ResponseWriter, r *http.Request) - GetRegistrationRequests(w http.ResponseWriter, r *http.Request) - ApproveRegistrationRequest(w http.ResponseWriter, r *http.Request) - RejectRegistrationRequest(w http.ResponseWriter, r *http.Request) + GetContestGroups(w http.ResponseWriter, r *http.Request) + GetContestParticipants(w http.ResponseWriter, r *http.Request) GetContestSubmissions(w http.ResponseWriter, r *http.Request) - GetCreatedContests(w http.ResponseWriter, r *http.Request) - GetManageableContests(w http.ResponseWriter, r *http.Request) - GetAllContests(w http.ResponseWriter, r *http.Request) + GetContestTask(w http.ResponseWriter, r *http.Request) GetContestTaskStats(w http.ResponseWriter, r *http.Request) GetContestTaskUserStats(w http.ResponseWriter, r *http.Request) GetContestTaskUserSubmissions(w http.ResponseWriter, r *http.Request) + GetContestTasks(w http.ResponseWriter, r *http.Request) GetContestUserStats(w http.ResponseWriter, r *http.Request) - AddGroupToContest(w http.ResponseWriter, r *http.Request) + GetCreatedContests(w http.ResponseWriter, r *http.Request) + GetManageableContests(w http.ResponseWriter, r *http.Request) + GetRegistrationRequests(w http.ResponseWriter, r *http.Request) + RejectRegistrationRequest(w http.ResponseWriter, r *http.Request) RemoveGroupFromContest(w http.ResponseWriter, r *http.Request) - GetContestGroups(w http.ResponseWriter, r *http.Request) - GetAssignableGroups(w http.ResponseWriter, r *http.Request) - GetContestParticipants(w http.ResponseWriter, r *http.Request) - GetAssignableParticipants(w http.ResponseWriter, r *http.Request) - AddParticipantsToContest(w http.ResponseWriter, r *http.Request) RemoveParticipantsFromContest(w http.ResponseWriter, r *http.Request) + RemoveTaskFromContest(w http.ResponseWriter, r *http.Request) } type contestsManagementRouteImpl struct { @@ -1304,6 +1306,123 @@ func (cr *contestsManagementRouteImpl) GetAllContests(w http.ResponseWriter, r * httputils.ReturnSuccess(w, http.StatusOK, response) } +// EditContestTask godoc +// +// @Tags contests-management +// @Summary Edit a specific task in a contest +// @Description Edit the settings/details for a specific task assigned to a contest +// @Accept json +// @Produce json +// @Param id path int true "Contest ID" +// @Param taskID path int true "Task ID" +// @Param body body schemas.ContestTaskSettings true "Contest Task Settings" +// @Success 200 {object} httputils.APIResponse[schemas.ContestTaskSettings] +// @Failure 400 {object} httputils.APIError +// @Failure 403 {object} httputils.APIError +// @Failure 404 {object} httputils.APIError +// @Failure 405 {object} httputils.APIError +// @Failure 500 {object} httputils.APIError +// @Router /contests-management/contests/{id}/tasks/{taskID} [put] +func (cr *contestsManagementRouteImpl) EditContestTask(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + httputils.ReturnError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + contestStr := httputils.GetPathValue(r, "id") + if contestStr == "" { + httputils.ReturnError(w, http.StatusBadRequest, "Contest ID cannot be empty") + return + } + contestID, err := strconv.ParseInt(contestStr, 10, 64) + if err != nil { + httputils.ReturnError(w, http.StatusBadRequest, "Invalid contest ID") + return + } + + taskIDStr := httputils.GetPathValue(r, "taskID") + if taskIDStr == "" { + httputils.ReturnError(w, http.StatusBadRequest, "Task ID cannot be empty") + return + } + taskID, err := strconv.ParseInt(taskIDStr, 10, 64) + if err != nil { + httputils.ReturnError(w, http.StatusBadRequest, "Invalid task ID") + return + } + + var request schemas.ContestTaskSettings + err = httputils.ShouldBindJSON(r.Body, &request) + if err != nil { + httputils.HandleValidationError(w, err) + return + } + + currentUser := httputils.GetCurrentUser(r) + db := httputils.GetDatabase(r) + + response, err := cr.contestService.EditContestTask(db, currentUser, contestID, taskID, &request) + if err != nil { + httputils.HandleServiceError(w, err, db, cr.logger) + return + } + httputils.ReturnSuccess(w, http.StatusOK, response) +} + +// GetContestTask godoc +// +// @Tags contests-management +// @Summary Get a specific task in a contest +// @Description Get the settings/details for a specific task assigned to a contest +// @Produce json +// @Param id path int true "Contest ID" +// @Param taskID path int true "Task ID" +// @Success 200 {object} httputils.APIResponse[schemas.ContestTaskSettings] +// @Failure 400 {object} httputils.APIError +// @Failure 403 {object} httputils.APIError +// @Failure 404 {object} httputils.APIError +// @Failure 500 {object} httputils.APIError +// @Router /contests-management/contests/{id}/tasks/{taskID} [get] +func (cr *contestsManagementRouteImpl) GetContestTask(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + httputils.ReturnError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + contestStr := httputils.GetPathValue(r, "id") + if contestStr == "" { + httputils.ReturnError(w, http.StatusBadRequest, "Contest ID cannot be empty") + return + } + contestID, err := strconv.ParseInt(contestStr, 10, 64) + if err != nil { + httputils.ReturnError(w, http.StatusBadRequest, "Invalid contest ID") + return + } + + taskIDStr := httputils.GetPathValue(r, "taskID") + if taskIDStr == "" { + httputils.ReturnError(w, http.StatusBadRequest, "Task ID cannot be empty") + return + } + taskID, err := strconv.ParseInt(taskIDStr, 10, 64) + if err != nil { + httputils.ReturnError(w, http.StatusBadRequest, "Invalid task ID") + return + } + + currentUser := httputils.GetCurrentUser(r) + db := httputils.GetDatabase(r) + + settings, err := cr.contestService.GetContestTaskSettings(db, currentUser, contestID, taskID) + if err != nil { + httputils.HandleServiceError(w, err, db, cr.logger) + return + } + + httputils.ReturnSuccess(w, http.StatusOK, settings) +} + func RegisterContestsManagementRoute(mux *mux.Router, route ContestsManagementRoute) { mux.HandleFunc("/contests", func(w http.ResponseWriter, r *http.Request) { switch r.Method { @@ -1354,6 +1473,17 @@ func RegisterContestsManagementRoute(mux *mux.Router, route ContestsManagementRo } }) + mux.HandleFunc("/contests/{id}/tasks/{taskID}", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + route.GetContestTask(w, r) + case http.MethodPut: + route.EditContestTask(w, r) + default: + httputils.ReturnError(w, http.StatusMethodNotAllowed, "Method not allowed") + } + }) + mux.HandleFunc("/contests/{id}/registration-requests", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/internal/api/http/routes/tasks_management.go b/internal/api/http/routes/tasks_management.go index 7f95294c..868b835b 100644 --- a/internal/api/http/routes/tasks_management.go +++ b/internal/api/http/routes/tasks_management.go @@ -35,13 +35,13 @@ type tasksManagementRoute struct { // @Description Uploads a task to the FileStorage service // @Accept multipart/form-data // @Produce json -// @Param title formData string true "Name of the task" +// @Param title formData string true "Name of the task" // @Param isVisible formData boolean false "Task visibility (default: false)" -// @Param archive formData file true "Task archive" -// @Failure 405 {object} httputils.APIError -// @Failure 400 {object} httputils.APIError -// @Failure 500 {object} httputils.APIError -// @Success 200 {object} httputils.APIResponse[schemas.TaskCreateResponse] +// @Param archive formData file true "Task archive" +// @Failure 405 {object} httputils.APIError +// @Failure 400 {object} httputils.APIError +// @Failure 500 {object} httputils.APIError +// @Success 200 {object} httputils.APIResponse[schemas.TaskCreateResponse] // @Router /tasks-management/tasks/ [post] func (tr *tasksManagementRoute) UploadTask(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/package/domain/schemas/contest.go b/package/domain/schemas/contest.go index 71f38cee..6c059e8e 100644 --- a/package/domain/schemas/contest.go +++ b/package/domain/schemas/contest.go @@ -118,3 +118,9 @@ type ContestResults struct { Contest BaseContest `json:"contest"` TaskResults []TaskResult `json:"taskResults"` } + +type ContestTaskSettings struct { + StartAt time.Time `json:"startAt"` + EndAt *time.Time `json:"endAt,omitempty"` + IsSubmissionOpen bool `json:"isSubmissionOpen"` +} diff --git a/package/repository/contest.go b/package/repository/contest.go index 4ac36341..386b07d1 100644 --- a/package/repository/contest.go +++ b/package/repository/contest.go @@ -64,8 +64,8 @@ type ContestRepository interface { GetAssignableTasks(db database.Database, contestID int64) ([]models.Task, error) // GetContestsForUserWithStats retrieves contests with stats a user is participating in GetContestsForUserWithStats(db database.Database, userID int64) ([]models.ParticipantContestStats, error) - // AddTasksToContest assigns tasks to a contest - AddTaskToContest(db database.Database, taskContest models.ContestTask) error + // SaveContestTask creates or updates a contest-task assignment + SaveContestTask(db database.Database, taskContest models.ContestTask) error // RemoveTaskFromContest removes a task from a contest RemoveTaskFromContest(db database.Database, contestID, taskID int64) error // GetRegistrationRequests retrieves 'status' registration requests for a contest @@ -825,9 +825,9 @@ func (cr *contestRepository) GetContestsForUserWithStats(db database.Database, u return contests, nil } -func (cr *contestRepository) AddTaskToContest(db database.Database, taskContest models.ContestTask) error { +func (cr *contestRepository) SaveContestTask(db database.Database, taskContest models.ContestTask) error { tx := db.GetInstance() - err := tx.Create(&taskContest).Error + err := tx.Save(&taskContest).Error if err != nil { return err } diff --git a/package/repository/mocks/mockgen.go b/package/repository/mocks/mockgen.go index e536dfac..a7d53d18 100644 --- a/package/repository/mocks/mockgen.go +++ b/package/repository/mocks/mockgen.go @@ -1529,20 +1529,6 @@ func (mr *MockContestRepositoryMockRecorder) AddParticipantsToContest(db, contes return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddParticipantsToContest", reflect.TypeOf((*MockContestRepository)(nil).AddParticipantsToContest), db, contestID, userIDs) } -// AddTaskToContest mocks base method. -func (m *MockContestRepository) AddTaskToContest(db database.Database, taskContest models.ContestTask) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AddTaskToContest", db, taskContest) - ret0, _ := ret[0].(error) - return ret0 -} - -// AddTaskToContest indicates an expected call of AddTaskToContest. -func (mr *MockContestRepositoryMockRecorder) AddTaskToContest(db, taskContest any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddTaskToContest", reflect.TypeOf((*MockContestRepository)(nil).AddTaskToContest), db, taskContest) -} - // Create mocks base method. func (m *MockContestRepository) Create(db database.Database, contest *models.Contest) (int64, error) { m.ctrl.T.Helper() @@ -2085,6 +2071,20 @@ func (mr *MockContestRepositoryMockRecorder) RemoveTaskFromContest(db, contestID return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveTaskFromContest", reflect.TypeOf((*MockContestRepository)(nil).RemoveTaskFromContest), db, contestID, taskID) } +// SaveContestTask mocks base method. +func (m *MockContestRepository) SaveContestTask(db database.Database, taskContest models.ContestTask) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SaveContestTask", db, taskContest) + ret0, _ := ret[0].(error) + return ret0 +} + +// SaveContestTask indicates an expected call of SaveContestTask. +func (mr *MockContestRepositoryMockRecorder) SaveContestTask(db, taskContest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveContestTask", reflect.TypeOf((*MockContestRepository)(nil).SaveContestTask), db, taskContest) +} + // UpdateRegistrationRequestStatus mocks base method. func (m *MockContestRepository) UpdateRegistrationRequestStatus(db database.Database, requestID int64, status types.RegistrationRequestStatus) error { m.ctrl.T.Helper() diff --git a/package/service/contest_service.go b/package/service/contest_service.go index 4c7e38f0..40e58160 100644 --- a/package/service/contest_service.go +++ b/package/service/contest_service.go @@ -86,6 +86,11 @@ type ContestService interface { AddParticipantsToContest(db database.Database, currentUser *schemas.User, contestID int64, userIDs []int64) error // RemoveParticipantsFromContest removes multiple users from contest participants (only accessible by contest collaborators with edit permission) RemoveParticipantsFromContest(db database.Database, currentUser *schemas.User, contestID int64, userIDs []int64) error + + // GetContestTaskSettings retrieves settings for a specific task within a contest (only accessible by contest collaborators) + GetContestTaskSettings(db database.Database, currentUser *schemas.User, contestID, taskID int64) (*schemas.ContestTaskSettings, error) + // EditContestTask updates settings for a specific task within a contest (only accessible by contest collaborators with edit permission) + EditContestTask(db database.Database, currentUser *schemas.User, contestID, taskID int64, settings *schemas.ContestTaskSettings) (*schemas.ContestTaskSettings, error) } const defaultContestSort = "created_at:desc" @@ -726,7 +731,7 @@ func (cs *contestService) AddTaskToContest(db database.Database, currentUser *sc IsSubmissionOpen: true, } - return cs.contestRepository.AddTaskToContest(db, taskContest) + return cs.contestRepository.SaveContestTask(db, taskContest) } func (cs *contestService) RemoveTaskFromContest(db database.Database, currentUser *schemas.User, contestID, taskID int64) error { @@ -1491,6 +1496,57 @@ func (cs *contestService) RemoveParticipantsFromContest(db database.Database, cu return cs.contestRepository.RemoveParticipantsFromContest(db, contestID, userIDs) } +func (cs *contestService) GetContestTaskSettings(db database.Database, currentUser *schemas.User, contestID, taskID int64) (*schemas.ContestTaskSettings, error) { + err := cs.hasContestPermission(db, contestID, currentUser, types.PermissionEdit) + if err != nil { + return nil, err + } + + settingsModel, err := cs.contestRepository.GetContestTask(db, contestID, taskID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.ErrTaskNotInContest + } + return nil, err + } + settings := &schemas.ContestTaskSettings{ + StartAt: settingsModel.StartAt, + EndAt: settingsModel.EndAt, + IsSubmissionOpen: settingsModel.IsSubmissionOpen, + } + return settings, nil +} + +func (cs *contestService) EditContestTask(db database.Database, currentUser *schemas.User, contestID, taskID int64, settings *schemas.ContestTaskSettings) (*schemas.ContestTaskSettings, error) { + err := cs.hasContestPermission(db, contestID, currentUser, types.PermissionEdit) + if err != nil { + return nil, err + } + + settingsModel, err := cs.contestRepository.GetContestTask(db, contestID, taskID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.ErrTaskNotInContest + } + return nil, err + } + settingsModel.StartAt = settings.StartAt + settingsModel.EndAt = settings.EndAt + settingsModel.IsSubmissionOpen = settings.IsSubmissionOpen + + err = cs.contestRepository.SaveContestTask(db, *settingsModel) + if err != nil { + return nil, err + } + + response := &schemas.ContestTaskSettings{ + StartAt: settingsModel.StartAt, + EndAt: settingsModel.EndAt, + IsSubmissionOpen: settingsModel.IsSubmissionOpen, + } + return response, nil +} + func NewContestService(contestRepository repository.ContestRepository, userRepository repository.UserRepository, submissionRepository repository.SubmissionRepository, taskRepository repository.TaskRepository, groupRepository repository.GroupRepository, accessControlService AccessControlService, taskService TaskService) ContestService { return &contestService{ contestRepository: contestRepository, diff --git a/package/service/mocks/mockgen.go b/package/service/mocks/mockgen.go index ace9165e..e3cb05a2 100644 --- a/package/service/mocks/mockgen.go +++ b/package/service/mocks/mockgen.go @@ -282,6 +282,21 @@ func (mr *MockContestServiceMockRecorder) Edit(db, currentUser, contestID, editI return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Edit", reflect.TypeOf((*MockContestService)(nil).Edit), db, currentUser, contestID, editInfo) } +// EditContestTask mocks base method. +func (m *MockContestService) EditContestTask(db database.Database, currentUser *schemas.User, contestID, taskID int64, settings *schemas.ContestTaskSettings) (*schemas.ContestTaskSettings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EditContestTask", db, currentUser, contestID, taskID, settings) + ret0, _ := ret[0].(*schemas.ContestTaskSettings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EditContestTask indicates an expected call of EditContestTask. +func (mr *MockContestServiceMockRecorder) EditContestTask(db, currentUser, contestID, taskID, settings any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditContestTask", reflect.TypeOf((*MockContestService)(nil).EditContestTask), db, currentUser, contestID, taskID, settings) +} + // GetAllContests mocks base method. func (m *MockContestService) GetAllContests(db database.Database, currentUser *schemas.User, paginationParams schemas.PaginationParams) (schemas.PaginatedResult[[]schemas.CreatedContest], error) { m.ctrl.T.Helper() @@ -387,6 +402,21 @@ func (mr *MockContestServiceMockRecorder) GetContestTask(db, currentUser, contes return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContestTask", reflect.TypeOf((*MockContestService)(nil).GetContestTask), db, currentUser, contestID, taskID) } +// GetContestTaskSettings mocks base method. +func (m *MockContestService) GetContestTaskSettings(db database.Database, currentUser *schemas.User, contestID, taskID int64) (*schemas.ContestTaskSettings, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetContestTaskSettings", db, currentUser, contestID, taskID) + ret0, _ := ret[0].(*schemas.ContestTaskSettings) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetContestTaskSettings indicates an expected call of GetContestTaskSettings. +func (mr *MockContestServiceMockRecorder) GetContestTaskSettings(db, currentUser, contestID, taskID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetContestTaskSettings", reflect.TypeOf((*MockContestService)(nil).GetContestTaskSettings), db, currentUser, contestID, taskID) +} + // GetContestsCreatedByUser mocks base method. func (m *MockContestService) GetContestsCreatedByUser(db database.Database, userID int64, paginationParams schemas.PaginationParams) (schemas.PaginatedResult[[]schemas.CreatedContest], error) { m.ctrl.T.Helper() diff --git a/package/utils/utils.go b/package/utils/utils.go index e442c671..03dc82f5 100644 --- a/package/utils/utils.go +++ b/package/utils/utils.go @@ -6,8 +6,10 @@ import ( "regexp" "slices" "strings" + "time" "github.com/go-playground/validator/v10" + "github.com/mini-maxit/backend/package/domain/schemas" "github.com/mini-maxit/backend/package/domain/types" "github.com/mini-maxit/backend/package/errors" "gorm.io/gorm" @@ -65,6 +67,23 @@ func PasswordValidator(fl validator.FieldLevel) bool { specialChar.MatchString(password) } +func EndAtAfterStartAt(sl validator.StructLevel) { + endAtField := sl.Current().FieldByName("EndAt").Interface().(*time.Time) + if endAtField == nil { + return + } + startAtField := sl.Current().FieldByName("StartAt").Interface().(time.Time) + if endAtField.Before(startAtField) { + sl.ReportError( + sl.Current().FieldByName("EndAt").Interface(), + "EndAt", + "end_at", + "end_at_after_start_at", + "", + ) + } +} + // NewValidator creates a new validator with custom validators. func NewValidator() (*validator.Validate, error) { validate := validator.New(validator.WithRequiredStructEnabled()) @@ -86,6 +105,8 @@ func NewValidator() (*validator.Validate, error) { if err != nil { return nil, err } + + validate.RegisterStructValidation(EndAtAfterStartAt, schemas.ContestTaskSettings{}) return validate, nil }