TeamsController.java
package edu.ucsb.cs156.frontiers.controllers;
import com.opencsv.CSVReader;
import com.opencsv.exceptions.CsvException;
import edu.ucsb.cs156.frontiers.entities.Course;
import edu.ucsb.cs156.frontiers.entities.RosterStudent;
import edu.ucsb.cs156.frontiers.entities.Team;
import edu.ucsb.cs156.frontiers.entities.TeamMember;
import edu.ucsb.cs156.frontiers.errors.EntityNotFoundException;
import edu.ucsb.cs156.frontiers.repositories.CourseRepository;
import edu.ucsb.cs156.frontiers.repositories.RosterStudentRepository;
import edu.ucsb.cs156.frontiers.repositories.TeamMemberRepository;
import edu.ucsb.cs156.frontiers.repositories.TeamRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@Tag(name = "Teams")
@RequestMapping("/api/teams")
@RestController
@Slf4j
public class TeamsController extends ApiController {
@Autowired private TeamRepository teamRepository;
@Autowired private TeamMemberRepository teamMemberRepository;
@Autowired private CourseRepository courseRepository;
@Autowired private RosterStudentRepository rosterStudentRepository;
public record TeamMemberResult(
TeamMember teamMember, TeamMemberStatus status, String rejectedEmail) {
public TeamMemberResult(TeamMember teamMember, TeamMemberStatus status) {
this(teamMember, status, null);
}
public TeamMemberResult(String rejectedEmail) {
this(null, TeamMemberStatus.MISSING, rejectedEmail);
}
}
public record TeamCreationResponse(
TeamSourceType typeMatched, Integer created, Integer existing, List<String> rejected) {}
public record TeamMemberMapping(
Long teamId,
String teamName,
Long rosterStudentId,
String email,
String firstName,
String lastName,
String githubLogin) {
public static TeamMemberMapping from(TeamMember member) {
return new TeamMemberMapping(
member.getTeam().getId(),
member.getTeam().getName(),
member.getRosterStudent().getId(),
member.getRosterStudent().getEmail(),
member.getRosterStudent().getFirstName(),
member.getRosterStudent().getLastName(),
member.getRosterStudent().getGithubLogin());
}
}
/**
* This method creates a new Team.
*
* @param name the name of the team
* @param courseId the ID of the course this team belongs to
* @return the created team
*/
@Operation(summary = "Create a new team")
@PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
@PostMapping("/post")
public Team postTeam(
@Parameter(name = "name") @RequestParam String name,
@Parameter(name = "courseId") @RequestParam Long courseId) {
Course course =
courseRepository
.findById(courseId)
.orElseThrow(() -> new EntityNotFoundException(Course.class, courseId));
Team team = Team.builder().name(name).course(course).build();
if (teamRepository.findByCourseIdAndName(course.getId(), name).isPresent()) {
throw new ResponseStatusException(
HttpStatus.CONFLICT, "Team with name %s already exists".formatted(name));
} else {
team = teamRepository.save(team);
}
return team;
}
/**
* Upload teams in CSV format (team, email) It is important to keep the code in this method
* consistent with the code for adding a single roster student
*
* @param courseId course the teams are for
* @param file csv file with roster student emails and team assignments
* @return Count of students added to teams, already existing, and rejected students
*/
@Operation(summary = "Upload team assignments; CSV in format team,email")
@PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
@PostMapping(
value = "/upload/csv",
consumes = {"multipart/form-data"})
public TeamCreationResponse uploadTeamsCsv(
@Parameter(name = "courseId") @RequestParam Long courseId,
@Parameter(name = "file") @RequestParam("file") MultipartFile file)
throws IOException, CsvException {
Course course =
courseRepository
.findById(courseId)
.orElseThrow(() -> new EntityNotFoundException(Course.class, courseId.toString()));
int counts[] = {0, 0};
List<String> failed = new ArrayList<>();
try (InputStream inputStream = new BufferedInputStream(file.getInputStream());
InputStreamReader reader = new InputStreamReader(inputStream);
CSVReader csvReader = new CSVReader(reader); ) {
String[] headers = csvReader.readNext();
TeamSourceType sourceType = getRosterSourceType(headers);
List<String[]> myEntries = csvReader.readAll();
for (String[] row : myEntries) {
TeamMemberResult rowResult = fromCSVRow(row, sourceType, course);
if (rowResult.status == TeamMemberStatus.MISSING) {
failed.add(rowResult.rejectedEmail);
} else {
counts[rowResult.status.ordinal()]++;
}
}
return new TeamCreationResponse(
sourceType,
counts[TeamMemberStatus.CREATED.ordinal()],
counts[TeamMemberStatus.EXISTS.ordinal()],
failed);
}
}
/**
* This method returns a list of all teams for a course
*
* @return a list of all teams for a course
*/
@Operation(summary = "List all teams")
@PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
@GetMapping("/all")
public Iterable<Team> allTeams(@RequestParam Long courseId) {
Iterable<Team> teams = teamRepository.findByCourseId(courseId);
return teams;
}
/**
* Retrieves a list of mappings between roster students and teams for a given course. Each mapping
* represents a relationship between a team and its members.
*
* @param courseId the unique identifier of the course for which team mappings are retrieved
* @return an iterable collection of {@code TeamMemberMapping} objects representing the mappings
*/
@Operation(summary = "List the mapping of Roster Students to Teams")
@PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
@GetMapping("/mapping")
public Iterable<TeamMemberMapping> teamMemberMapping(@RequestParam Long courseId) {
List<Team> teams =
courseRepository
.findById(courseId)
.orElseThrow(() -> new EntityNotFoundException(Course.class, courseId))
.getTeams();
List<TeamMemberMapping> mappings = new ArrayList<>();
for (Team team : teams) {
for (TeamMember member : team.getTeamMembers()) {
mappings.add(TeamMemberMapping.from(member));
}
}
return mappings;
}
/**
* This method returns a single team by its id
*
* @param id the id of the team
* @return the team
*/
@Operation(summary = "Get a single team")
@PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
@GetMapping("")
public Team getTeamById(
@Parameter(name = "id") @RequestParam Long id, @RequestParam Long courseId) {
Team team =
teamRepository.findById(id).orElseThrow(() -> new EntityNotFoundException(Team.class, id));
return team;
}
/**
* This method deletes a team by its id
*
* @param id the id of the team to delete
* @return a message indicating the team was deleted
*/
@Operation(summary = "Delete a team")
@PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
@DeleteMapping("")
public Object deleteTeam(
@Parameter(name = "id") @RequestParam Long id, @RequestParam Long courseId) {
Team team =
teamRepository.findById(id).orElseThrow(() -> new EntityNotFoundException(Team.class, id));
teamRepository.delete(team);
return genericMessage("Team with id %s deleted".formatted(id));
}
/**
* This method adds a roster student as a team member
*
* @param teamId the ID of the team
* @param rosterStudentId the ID of the roster student to add
* @return the created team member
*/
@Operation(summary = "Add a roster student to a team")
@PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
@PostMapping("/addMember")
public TeamMember addTeamMember(
@Parameter(name = "teamId") @RequestParam Long teamId,
@Parameter(name = "rosterStudentId") @RequestParam Long rosterStudentId,
@Parameter(name = "courseId") @RequestParam Long courseId) {
Team team =
teamRepository
.findById(teamId)
.orElseThrow(() -> new EntityNotFoundException(Team.class, teamId));
RosterStudent rosterStudent =
rosterStudentRepository
.findById(rosterStudentId)
.orElseThrow(() -> new EntityNotFoundException(RosterStudent.class, rosterStudentId));
if (!team.getCourse().getId().equals(courseId)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Team is not from course %d".formatted(courseId));
}
if (!rosterStudent.getCourse().getId().equals(courseId)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Roster student is not from course %d".formatted(courseId));
}
if (teamMemberRepository.findByTeamAndRosterStudent(team, rosterStudent).isPresent()) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"Team member already exists for team %s and roster student %s"
.formatted(team.getName(), rosterStudent.getEmail()));
}
TeamMember teamMember = TeamMember.builder().team(team).rosterStudent(rosterStudent).build();
TeamMember savedTeamMember = teamMemberRepository.save(teamMember);
return savedTeamMember;
}
/**
* This method removes a team member
*
* @param teamMemberId the ID of the team member to remove
* @return a message indicating the team member was removed
*/
@Operation(summary = "Remove a team member")
@PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
@DeleteMapping("/removeMember")
@Transactional
public Object removeTeamMember(
@Parameter(name = "teamMemberId") @RequestParam Long teamMemberId,
@Parameter(name = "courseId") @RequestParam Long courseId) {
TeamMember teamMember =
teamMemberRepository
.findById(teamMemberId)
.orElseThrow(() -> new EntityNotFoundException(TeamMember.class, teamMemberId));
Team team = teamMember.getTeam();
RosterStudent rosterStudent = teamMember.getRosterStudent();
team.getTeamMembers().remove(teamMember);
rosterStudent.getTeamMembers().remove(teamMember);
teamMemberRepository.delete(teamMember);
teamRepository.save(team);
rosterStudentRepository.save(rosterStudent);
return genericMessage("Team member with id %s deleted".formatted(teamMemberId));
}
public enum TeamSourceType {
SIMPLE,
UNKNOWN
}
public enum TeamMemberStatus {
CREATED,
EXISTS,
MISSING
}
public static final String SIMPLE_HEADERS = "team,email";
public TeamSourceType getRosterSourceType(String[] headers) {
Map<TeamSourceType, String[]> sourceTypeToHeaders = new HashMap<>();
sourceTypeToHeaders.put(TeamSourceType.SIMPLE, SIMPLE_HEADERS.split(","));
for (Map.Entry<TeamSourceType, String[]> entry : sourceTypeToHeaders.entrySet()) {
TeamSourceType type = entry.getKey();
String[] expectedHeaders = entry.getValue();
if (headers.length >= expectedHeaders.length) {
boolean matches = true;
for (int i = 0; i < expectedHeaders.length; i++) {
if (!expectedHeaders[i].equalsIgnoreCase(headers[i])) {
matches = false;
break;
}
}
if (matches) {
return type;
}
}
}
// If no known type matches, throw
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown Roster Source Type");
}
public TeamMemberResult fromCSVRow(String[] row, TeamSourceType sourceType, Course course) {
// No if statements because this is the only possible value to enter here at the moment. Replace
// with if when more
// Formats are added.
return teamMemberFromSimpleCsv(row, course);
}
public TeamMemberResult teamMemberFromSimpleCsv(String[] row, Course course) {
Optional<RosterStudent> student =
rosterStudentRepository.findByCourseIdAndEmail(course.getId(), row[1]);
Optional<Team> team = teamRepository.findByCourseIdAndName(course.getId(), row[0]);
if (student.isPresent() && team.isPresent()) {
Optional<TeamMember> teamMember =
teamMemberRepository.findByTeamAndRosterStudent(team.get(), student.get());
if (teamMember.isPresent()) {
return new TeamMemberResult(teamMember.get(), TeamMemberStatus.EXISTS);
} else {
TeamMember teamMemberToSave =
TeamMember.builder().team(team.get()).rosterStudent(student.get()).build();
TeamMember savedTeamMember = teamMemberRepository.save(teamMemberToSave);
return new TeamMemberResult(savedTeamMember, TeamMemberStatus.CREATED);
}
} else if (student.isPresent()) {
Team teamToSave = Team.builder().name(row[0]).course(course).build();
teamRepository.save(teamToSave);
TeamMember saveTeamMember =
TeamMember.builder().team(teamToSave).rosterStudent(student.get()).build();
teamMemberRepository.save(saveTeamMember);
return new TeamMemberResult(saveTeamMember, TeamMemberStatus.CREATED);
} else {
return new TeamMemberResult(row[1]);
}
}
}