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
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateTaskAssignmentRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.request.UpdateTaskAssignmentRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.DetailedTaskAssignmentResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.LinkedToursResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.TaskAssignmentResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.service.interfaces.AssignmentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
Expand All @@ -23,6 +25,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/v1")
Expand Down Expand Up @@ -112,6 +115,24 @@ public ResponseEntity<PageResponse<TaskAssignmentResponseDTO>> getByTour(@PathVa
return ResponseEntity.ok(PageResponse.from(assignmentService.getAssignmentsByTourId(pageable, tourId)));
}

@Operation(
summary = "Get tours linked to a task",
description = "Retrieves a list of tours linked to the specified task body. Requires ADMIN or ORG role.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Linked tours retrieved successfully",
content = @Content(mediaType = "application/json",
array = @ArraySchema(schema = @Schema(implementation = LinkedToursResponseDTO.class)))),
@ApiResponse(responseCode = "403", description = "Access denied (requires ADMIN or ORG role)",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "404", description = "Task body or linked tour not found",
content = @Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class)))
})
@GetMapping("/tasks/{taskId}/linked-tours")
@PreAuthorize("hasAnyRole('ADMIN', 'ORG')")
public ResponseEntity<List<LinkedToursResponseDTO>> getLinkedTours(@PathVariable Long taskId) {
return ResponseEntity.ok().body(assignmentService.getLinkedToursByTaskId(taskId));
}

@Operation(
summary = "Update a task assignment",
description = "Updates an existing task assignment. Only users with ADMIN or ORG role can perform this action.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;

@Repository
public interface TaskAssignmentRepository extends JpaRepository<TaskAssignment, Long> {
Expand All @@ -13,4 +15,7 @@ public interface TaskAssignmentRepository extends JpaRepository<TaskAssignment,
boolean existsByTaskBodyIdAndTourId(Long taskBodyId, Long tourId);

boolean existsByTaskBodyId(Long taskBodyId);

@Query("SELECT t.tourId FROM TaskAssignment t WHERE t.taskBodyId = :taskBodyId")
List<Long> findTourIdsByTaskBodyId(Long taskBodyId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.itasocialacademy.oitassist.taskassignment.dto.response;

import com.itasocialacademy.oitassist.competition.dao.enums.ExecutionStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

@Builder
@Schema(description = "DTO representing linked tour details of some given task")
public record LinkedToursResponseDTO(
@Schema(
description = "Id of the tour the task is linked to",
example = "1") Long tourId,

@Schema(
description = "Title of the tour",
example = "Тур 1: Алгоритми та структури даних") String title,

@Schema(
description = "Description of the tour",
example = "Розв'язати алгоритмічні задачі") String description,

@Schema(
description = "Physical or virtual location where the tour takes place",
example = "Ліцей №1, Львів") String location,

@Schema(
description = "The current state of execution ",
example = "IN_PROGRESS") ExecutionStatus executionStatus) {
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package com.itasocialacademy.oitassist.taskassignment.mapper;

import com.itasocialacademy.oitassist.competition.api.dto.TourDetail;
import com.itasocialacademy.oitassist.filemanager.api.dto.FileDetailsDTO;
import com.itasocialacademy.oitassist.taskassignment.api.dto.TaskAssignmentDetailDTO;
import com.itasocialacademy.oitassist.taskassignment.dao.model.TaskAssignment;
import com.itasocialacademy.oitassist.taskassignment.dao.model.TaskRequirements;
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateTaskAssignmentRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.request.TaskRequirementsRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.DetailedTaskAssignmentResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.LinkedToursResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.TaskAssignmentResponseDTO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ReportingPolicy;
import java.util.List;

Expand All @@ -24,4 +27,7 @@ public interface TaskAssignmentMapper {

DetailedTaskAssignmentResponseDTO toDetailedResponse(TaskAssignment entity, String taskTitle,
String taskDescription, List<FileDetailsDTO> files);

@Mapping(target = "tourId", source = "id")
LinkedToursResponseDTO toLinkedToursResponse(TourDetail tourDetail);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.itasocialacademy.oitassist.competition.dao.enums.ExecutionStatus;
import com.itasocialacademy.oitassist.competition.exceptions.CompetitionHierarchyValidationException;
import com.itasocialacademy.oitassist.competition.exceptions.TourNotFoundException;
import com.itasocialacademy.oitassist.core.enums.ErrorCode;
import com.itasocialacademy.oitassist.core.exceptions.AuthorizationException;
import com.itasocialacademy.oitassist.filemanager.api.FileManagerFacade;
import com.itasocialacademy.oitassist.filemanager.api.dto.FileDetailsDTO;
import com.itasocialacademy.oitassist.filemanager.dao.enums.FileRole;
Expand All @@ -21,6 +23,7 @@
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateTaskAssignmentRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.request.UpdateTaskAssignmentRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.DetailedTaskAssignmentResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.LinkedToursResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.TaskAssignmentResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.exceptions.TaskAlreadyAssignedException;
import com.itasocialacademy.oitassist.taskassignment.exceptions.TaskAssignmentNotFoundException;
Expand Down Expand Up @@ -212,6 +215,46 @@ public boolean existsByTaskBodyId(Long taskBodyId) {
return taskAssignmentRepository.existsByTaskBodyId(taskBodyId);
}

@Override
@Transactional(readOnly = true)
public List<LinkedToursResponseDTO> getLinkedToursByTaskId(Long taskBodyId) {
TaskBodyDetail taskBody = taskBodyFacade.findTaskBodyById(taskBodyId).orElseThrow(
() -> new TaskNotFoundException(taskBodyId));

checkAdminOrOrg();

List<Long> tourIds = taskAssignmentRepository.findTourIdsByTaskBodyId(taskBody.id());

log.debug("Get LinkedTours by task body Id {}", taskBodyId);

if (tourIds.isEmpty()) {
return List.of();
}

List<TourDetail> tours = competitionFacade.findToursByIds(tourIds);

List<Long> returnedIds = tours.stream()
.map(TourDetail::id)
.toList();

List<Long> missingIds = tourIds.stream()
.filter(id -> !returnedIds.contains(id))
.toList();

if (!missingIds.isEmpty()) {
log.warn(
"Missing linked tours for task body Id {}: requested {} tour(s) but only found {}. "
+ "Missing tour IDs: {}",
taskBodyId,
tourIds.size(),
tours.size(),
missingIds);
}

return tours.stream().map(taskAssignmentMapper::toLinkedToursResponse).toList();
Comment thread
solenuk marked this conversation as resolved.
}

// helpers
private List<FileDetailsDTO> resolveTaskFiles(Long taskBodyId) {
Set<FileRole> allowedFileRoles;

Expand All @@ -229,4 +272,10 @@ private void validateTourStatus(TourDetail tour, String msg) {
throw new CompetitionHierarchyValidationException(msg + " Tour has already started");
}
}

private void checkAdminOrOrg() {
if (!securityFacade.hasRole("ADMIN") && !securityFacade.hasRole("ORG")) {
throw new AuthorizationException("You do not have permission to this operation", ErrorCode.ACCESS_DENIED);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
package com.itasocialacademy.oitassist.taskassignment.service.interfaces;

import com.itasocialacademy.oitassist.competition.exceptions.TourNotFoundException;
import com.itasocialacademy.oitassist.task.exceptions.TaskNotFoundException;
import com.itasocialacademy.oitassist.taskassignment.api.dto.TaskAssignmentDetailDTO;
import com.itasocialacademy.oitassist.taskassignment.dao.enums.AssignmentVisibility;
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateAndAssignTaskRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateTaskAssignmentRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.request.UpdateTaskAssignmentRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.DetailedTaskAssignmentResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.LinkedToursResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.TaskAssignmentResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.exceptions.TaskAlreadyAssignedException;
import com.itasocialacademy.oitassist.taskassignment.exceptions.TaskAssignmentNotFoundException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;

public interface AssignmentService {
Expand Down Expand Up @@ -110,4 +113,15 @@ DetailedTaskAssignmentResponseDTO updateTaskAssignment(Long taskAssignmentId,
* body id, {@code false} otherwise
*/
boolean existsByTaskBodyId(Long taskBodyId);

/**
* Retrieves all tours linked to the specified task body.
*
* @param taskBodyId the ID of the task body
* @return a list of {@link LinkedToursResponseDTO} containing details of the
* tours linked to the task
* @throws TaskNotFoundException if no task body exists with the given ID
* @throws TourNotFoundException if any linked tour cannot be found
*/
List<LinkedToursResponseDTO> getLinkedToursByTaskId(Long taskBodyId);
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
package com.itasocialacademy.oitassist.taskassignment.controller;

import com.itasocialacademy.oitassist.ControllerUnitTest;
import com.itasocialacademy.oitassist.competition.dao.enums.ExecutionStatus;
import com.itasocialacademy.oitassist.competition.exceptions.CompetitionHierarchyValidationException;
import com.itasocialacademy.oitassist.competition.exceptions.TourNotFoundException;
import com.itasocialacademy.oitassist.filemanager.api.dto.FileDetailsDTO;
import com.itasocialacademy.oitassist.task.exceptions.TaskNotFoundException;
import com.itasocialacademy.oitassist.taskassignment.dao.enums.AssignmentVisibility;
import com.itasocialacademy.oitassist.taskassignment.dao.model.TaskRequirements;
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateAndAssignTaskRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.request.CreateTaskAssignmentRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.request.TaskRequirementsRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.request.UpdateTaskAssignmentRequestDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.DetailedTaskAssignmentResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.LinkedToursResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.dto.response.TaskAssignmentResponseDTO;
import com.itasocialacademy.oitassist.taskassignment.exceptions.TaskAlreadyAssignedException;
import com.itasocialacademy.oitassist.taskassignment.exceptions.TaskAssignmentNotFoundException;
Expand Down Expand Up @@ -274,6 +277,49 @@ void getByTour_tourNotFound_shouldReturn404() throws Exception {
.andExpect(status().isNotFound());
}

// GET /api/v1/tasks/{taskId}/linked-tours — getLinkedTours

@Test
void getLinkedTours_shouldReturnListAnd200() throws Exception {
LinkedToursResponseDTO mockTourResponse = LinkedToursResponseDTO.builder()
.tourId(10L)
.title("Tour 1")
.description("Description")
.location("Location")
.executionStatus(ExecutionStatus.SCHEDULED)
.build();

when(assignmentService.getLinkedToursByTaskId(3L))
.thenReturn(List.of(mockTourResponse));

mockMvc.perform(get("/api/v1/tasks/{taskId}/linked-tours", 3L))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].tourId").value(10L))
.andExpect(jsonPath("$[0].title").value("Tour 1"))
.andExpect(jsonPath("$[0].description").value("Description"))
.andExpect(jsonPath("$[0].location").value("Location"))
.andExpect(jsonPath("$[0].executionStatus").value("SCHEDULED"));
}

@Test
void getLinkedTours_taskNotFound_shouldReturn404() throws Exception {
when(assignmentService.getLinkedToursByTaskId(99L))
.thenThrow(new TaskNotFoundException(99L));

mockMvc.perform(get("/api/v1/tasks/{taskId}/linked-tours", 99L))
.andExpect(status().isNotFound());
}

@Test
void getLinkedTours_tourNotFound_shouldReturn404() throws Exception {
when(assignmentService.getLinkedToursByTaskId(3L))
.thenThrow(new TourNotFoundException(10L));

mockMvc.perform(get("/api/v1/tasks/{taskId}/linked-tours", 3L))
.andExpect(status().isNotFound());
}

// PATCH /api/v1/task-assignments/{assignmentId} — update

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.Set;
import com.itasocialacademy.oitassist.competition.dao.enums.ExecutionStatus;
import com.itasocialacademy.oitassist.filemanager.dao.enums.FileRole;
import com.itasocialacademy.oitassist.taskassignment.dto.response.LinkedToursResponseDTO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
Expand Down Expand Up @@ -564,4 +565,52 @@ void existsByTaskBodyId_whenNotExists_shouldReturnFalse() {

assertFalse(assignmentService.existsByTaskBodyId(99L));
}

// ---- getLinkedToursByTaskId ----

@Test
void getLinkedToursByTaskId_whenTaskExistsAndUserAuthorized_shouldReturnLinkedTours() {
LinkedToursResponseDTO mockTourResponse = LinkedToursResponseDTO.builder()
.tourId(10L)
.title("Tour 1")
.description("Description")
.location("Location")
.executionStatus(ExecutionStatus.SCHEDULED)
.build();

when(taskBodyFacade.findTaskBodyById(3L)).thenReturn(Optional.of(taskBodyDetail));
when(securityFacade.hasRole("ADMIN")).thenReturn(true);
when(taskAssignmentRepository.findTourIdsByTaskBodyId(3L)).thenReturn(List.of(10L));
when(competitionFacade.findToursByIds(List.of(10L))).thenReturn(List.of(tourDetail));
when(taskAssignmentMapper.toLinkedToursResponse(tourDetail)).thenReturn(mockTourResponse);

List<LinkedToursResponseDTO> result = assignmentService.getLinkedToursByTaskId(3L);

assertNotNull(result);
assertEquals(1, result.size());
assertEquals(10L, result.getFirst().tourId());
assertEquals("Tour 1", result.getFirst().title());
assertEquals("Description", result.getFirst().description());
assertEquals("Location", result.getFirst().location());
assertEquals(ExecutionStatus.SCHEDULED, result.getFirst().executionStatus());
}

@Test
void getLinkedToursByTaskId_whenTaskDoesNotExist_shouldThrowTaskNotFoundException() {
when(taskBodyFacade.findTaskBodyById(99L)).thenReturn(Optional.empty());

assertThrows(TaskNotFoundException.class, () -> assignmentService.getLinkedToursByTaskId(99L));
verify(taskAssignmentRepository, never()).findTourIdsByTaskBodyId(any());
}

@Test
void getLinkedToursByTaskId_whenUserNotAuthorized_shouldThrowAuthorizationException() {
when(taskBodyFacade.findTaskBodyById(3L)).thenReturn(Optional.of(taskBodyDetail));
when(securityFacade.hasRole("ADMIN")).thenReturn(false);
when(securityFacade.hasRole("ORG")).thenReturn(false);

assertThrows(com.itasocialacademy.oitassist.core.exceptions.AuthorizationException.class,
() -> assignmentService.getLinkedToursByTaskId(3L));
verify(taskAssignmentRepository, never()).findTourIdsByTaskBodyId(any());
}
}
Loading