diff --git a/.github/workflows/deploy-application.yaml b/.github/workflows/deploy-application.yaml new file mode 100644 index 00000000..35b3bdaa --- /dev/null +++ b/.github/workflows/deploy-application.yaml @@ -0,0 +1,31 @@ +name: deploy-to-k8s + +on: + push: + branches: + - main + - edit-exam-ASE-436 + - create-exam-ASE-435 + +permissions: + contents: read + packages: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v5 + + - name: Upload image + uses: Agile-Software-Engineering-25/build-and-publish-image@v1 + with: + push: true + extra_tags: ${{ github.sha }} + + - name: Deploy to Namespace + uses: Agile-Software-Engineering-25/deploy-to-k8s@v1 + with: + kubeconfig: ${{ secrets.KUBECONFIG }} + namespace: ${{ vars.K8S_NAMESPACE }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..5dd8fd5e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# ---- Build stage +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /app +COPY pom.xml . +COPY src ./src +RUN mvn -q -DskipTests clean package + +# ---- Run stage +FROM eclipse-temurin:21-jre +WORKDIR /app +ENV JAVA_OPTS="-Xms256m -Xmx512m" +EXPOSE 8080 +COPY --from=build /app/target/*.jar app.jar +ENTRYPOINT ["sh","-c","java $JAVA_OPTS -jar /app/app.jar"] diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 00000000..231f44f4 --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,36 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: exam-service +spec: + replicas: 1 + selector: + matchLabels: + app: exam-service + template: + metadata: + labels: + app: exam-service + spec: + containers: + - name: app + image: ghcr.io/agile-software-engineering-25/team-14-backend-examination-office:latest + ports: + - containerPort: 8080 + readinessProbe: + tcpSocket: + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + tcpSocket: + port: 8080 + initialDelaySeconds: 20 + periodSeconds: 20 + resources: + requests: + cpu: "100m" + memory: "256Mi" + limits: + cpu: "500m" + memory: "512Mi" diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 00000000..b6db69ba --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml + +images: + - name: ghcr.io/agile-software-engineering-25/team-14-backend-examination-office + newTag: latest diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 00000000..b86c0d15 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: exam-service +spec: + type: ClusterIP + selector: + app: exam-service + ports: + - name: http + port: 80 + targetPort: 8080 diff --git a/pom.xml b/pom.xml index e26b2f8a..7b4d5af8 100644 --- a/pom.xml +++ b/pom.xml @@ -64,5 +64,44 @@ h2 runtime + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + com.h2database + h2 + runtime + + + org.postgresql + postgresql + runtime + + + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/src/main/java/com/ase/userservice/controllers/ExamController.java b/src/main/java/com/ase/userservice/controllers/ExamController.java new file mode 100644 index 00000000..11432ac5 --- /dev/null +++ b/src/main/java/com/ase/userservice/controllers/ExamController.java @@ -0,0 +1,59 @@ +package com.ase.userservice.controllers; + +import com.ase.userservice.dto.CreateExamRequest; +import com.ase.userservice.dto.ExamResponse; +import com.ase.userservice.services.ExamService; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.List; + +@CrossOrigin( + origins = "http://localhost:5173", + allowedHeaders = "*", + allowCredentials = "true", + maxAge = 3600, + methods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS } +) +@RestController +@RequestMapping("/api/exams") +public class ExamController { + private final ExamService service; + + public ExamController(ExamService service) { + this.service = service; + } + + @PostMapping + public ResponseEntity createExam(@Valid @RequestBody CreateExamRequest req, + UriComponentsBuilder uri) { + ExamResponse created = service.create(req); + return ResponseEntity + .created(uri.path("/api/exams/{id}").buildAndExpand(created.id()).toUri()) + .body(created); + } + + @PutMapping("/{id}") + public ResponseEntity updateExam(@PathVariable Long id, + @Valid @RequestBody CreateExamRequest req) { + return ResponseEntity.ok(service.update(id, req)); + } + + @GetMapping("/{id}") + public ResponseEntity getExam(@PathVariable Long id) { + return ResponseEntity.ok(service.get(id)); + } + + @GetMapping + public ResponseEntity> listExams() { + return ResponseEntity.ok(service.list()); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteExam(@PathVariable Long id) { + service.delete(id); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/ase/userservice/controllers/GlobalExceptionHandler.java b/src/main/java/com/ase/userservice/controllers/GlobalExceptionHandler.java new file mode 100644 index 00000000..2f1bb6c6 --- /dev/null +++ b/src/main/java/com/ase/userservice/controllers/GlobalExceptionHandler.java @@ -0,0 +1,39 @@ +package com.ase.userservice.controllers; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.util.Map; +import java.util.stream.Collectors; + +@ControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + var errors = ex.getBindingResult().getFieldErrors().stream() + .collect(Collectors.toMap( + fe -> fe.getField(), + fe -> fe.getDefaultMessage(), + (a, b) -> a + )); + return ResponseEntity.badRequest().body(Map.of("message", "Validation failed", "errors", errors)); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArg(IllegalArgumentException ex) { + return ResponseEntity.badRequest().body(Map.of("message", ex.getMessage())); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleIllegalState(IllegalStateException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("message", ex.getMessage())); + } + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFound(NotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", ex.getMessage())); + } +} diff --git a/src/main/java/com/ase/userservice/controllers/NotFoundException.java b/src/main/java/com/ase/userservice/controllers/NotFoundException.java new file mode 100644 index 00000000..38e6b311 --- /dev/null +++ b/src/main/java/com/ase/userservice/controllers/NotFoundException.java @@ -0,0 +1,5 @@ +package com.ase.userservice.controllers; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { super(message); } +} diff --git a/src/main/java/com/ase/userservice/dto/CreateExamRequest.java b/src/main/java/com/ase/userservice/dto/CreateExamRequest.java new file mode 100644 index 00000000..6054287f --- /dev/null +++ b/src/main/java/com/ase/userservice/dto/CreateExamRequest.java @@ -0,0 +1,29 @@ +package com.ase.userservice.dto; + +import jakarta.validation.constraints.*; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Duration; +import java.util.List; + +public record CreateExamRequest( + @NotBlank String title, + @NotBlank String moduleCode, + @NotNull LocalDateTime examDate, + @NotBlank String room, + @NotBlank String examType, + @NotBlank String semester, + @NotNull @PositiveOrZero Integer ects, + @NotNull @Positive Integer maxPoints, + @NotNull @Positive Integer duration, // Minuten + @NotNull @Positive Integer attemptNumber, // 1..n + boolean fileUploadRequired, + @NotNull @Size(max = 20) List<@NotBlank String> tools +) { + public void validateBusinessRules() { + if (tools != null && tools.stream().anyMatch(s -> s == null || s.isBlank())) { + throw new IllegalArgumentException("tools must not contain blank items"); + } + } +} diff --git a/src/main/java/com/ase/userservice/dto/ExamResponse.java b/src/main/java/com/ase/userservice/dto/ExamResponse.java new file mode 100644 index 00000000..873c6fb3 --- /dev/null +++ b/src/main/java/com/ase/userservice/dto/ExamResponse.java @@ -0,0 +1,20 @@ +package com.ase.userservice.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public record ExamResponse( + Long id, + String title, + String moduleCode, + LocalDateTime examDate, + String room, + String examType, + String semester, + Integer ects, + Integer maxPoints, + Integer duration, + Integer attemptNumber, + boolean fileUploadRequired, + List tools +) {} diff --git a/src/main/java/com/ase/userservice/entities/Exam.java b/src/main/java/com/ase/userservice/entities/Exam.java new file mode 100644 index 00000000..21f0414a --- /dev/null +++ b/src/main/java/com/ase/userservice/entities/Exam.java @@ -0,0 +1,105 @@ +package com.ase.userservice.entities; + +import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table( + name = "exams", + uniqueConstraints = { + @UniqueConstraint(name = "uk_exam_modcode_date_attempt", columnNames = {"module_code", "exam_date", "attempt_number"}) + } +) +public class Exam { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 160) + private String title; + + @Column(name = "module_code", nullable = false, length = 40) + private String moduleCode; + + @Column(name = "exam_date", nullable = false) + private LocalDateTime examDate; + + @Column(nullable = false, length = 80) + private String room; + + @Column(nullable = false, length = 40) + private String examType; + + @Column(nullable = false, length = 20) + private String semester; + + @Column(nullable = false) + private Integer ects; + + @Column(nullable = false) + private Integer maxPoints; + + @Column(nullable = false) + private Integer duration; // Minuten + + @Column(name = "attempt_number", nullable = false) + private Integer attemptNumber; + + @Column(nullable = false) + private boolean fileUploadRequired; + + @ElementCollection + @CollectionTable(name = "exam_tools", joinColumns = @JoinColumn(name = "exam_id")) + @Column(name = "tool", nullable = false, length = 60) + private List tools = new ArrayList<>(); + + protected Exam() {} + + public Exam(String title, String moduleCode, LocalDateTime examDate, String room, String examType, + String semester, Integer ects, Integer maxPoints, Integer duration, + Integer attemptNumber, boolean fileUploadRequired, List tools) { + this.title = title; + this.moduleCode = moduleCode; + this.examDate = examDate; + this.room = room; + this.examType = examType; + this.semester = semester; + this.ects = ects; + this.maxPoints = maxPoints; + this.duration = duration; + this.attemptNumber = attemptNumber; + this.fileUploadRequired = fileUploadRequired; + if (tools != null) this.tools = new ArrayList<>(tools); + } + + public Long getId() { return id; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getModuleCode() { return moduleCode; } + public void setModuleCode(String moduleCode) { this.moduleCode = moduleCode; } + public LocalDateTime getExamDate() { return examDate; } + public void setExamDate(LocalDateTime examDate) { this.examDate = examDate; } + public String getRoom() { return room; } + public void setRoom(String room) { this.room = room; } + public String getExamType() { return examType; } + public void setExamType(String examType) { this.examType = examType; } + public String getSemester() { return semester; } + public void setSemester(String semester) { this.semester = semester; } + public Integer getEcts() { return ects; } + public void setEcts(Integer ects) { this.ects = ects; } + public Integer getMaxPoints() { return maxPoints; } + public void setMaxPoints(Integer maxPoints) { this.maxPoints = maxPoints; } + public Integer getDuration() { return duration; } + public void setDuration(Integer duration) { this.duration = duration; } + public Integer getAttemptNumber() { return attemptNumber; } + public void setAttemptNumber(Integer attemptNumber) { this.attemptNumber = attemptNumber; } + public boolean isFileUploadRequired() { return fileUploadRequired; } + public void setFileUploadRequired(boolean fileUploadRequired) { this.fileUploadRequired = fileUploadRequired; } + public List getTools() { return tools; } + public void setTools(List tools) { + this.tools = tools != null ? new ArrayList<>(tools) : new ArrayList<>(); + } +} diff --git a/src/main/java/com/ase/userservice/repositories/ExamRepository.java b/src/main/java/com/ase/userservice/repositories/ExamRepository.java new file mode 100644 index 00000000..4293ae59 --- /dev/null +++ b/src/main/java/com/ase/userservice/repositories/ExamRepository.java @@ -0,0 +1,17 @@ +package com.ase.userservice.repositories; + +import com.ase.userservice.entities.Exam; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; + +@Repository +public interface ExamRepository extends JpaRepository { + + boolean existsByModuleCodeAndExamDateAndAttemptNumber( + String moduleCode, LocalDateTime examDate, Integer attemptNumber); + + boolean existsByModuleCodeAndExamDateAndAttemptNumberAndIdNot( + String moduleCode, LocalDateTime examDate, Integer attemptNumber, Long id); +} diff --git a/src/main/java/com/ase/userservice/services/ExamService.java b/src/main/java/com/ase/userservice/services/ExamService.java new file mode 100644 index 00000000..5c4a9cb6 --- /dev/null +++ b/src/main/java/com/ase/userservice/services/ExamService.java @@ -0,0 +1,109 @@ +package com.ase.userservice.services; + +import com.ase.userservice.controllers.NotFoundException; +import com.ase.userservice.dto.CreateExamRequest; +import com.ase.userservice.dto.ExamResponse; +import com.ase.userservice.entities.Exam; +import com.ase.userservice.repositories.ExamRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +@Service +public class ExamService { + private final ExamRepository repo; + + public ExamService(ExamRepository repo) { + this.repo = repo; + } + + @Transactional + public ExamResponse create(CreateExamRequest req) { + req.validateBusinessRules(); + + if (repo.existsByModuleCodeAndExamDateAndAttemptNumber( + req.moduleCode(), req.examDate(), req.attemptNumber())) { + throw new IllegalStateException("Exam with same moduleCode, examDate and attemptNumber already exists"); + } + + Exam exam = new Exam( + req.title(), + req.moduleCode(), + req.examDate(), + req.room(), + req.examType(), + req.semester(), + req.ects(), + req.maxPoints(), + req.duration(), + req.attemptNumber(), + req.fileUploadRequired(), + req.tools() + ); + + return toResponse(repo.save(exam)); + } + + @Transactional + public ExamResponse update(Long id, CreateExamRequest req) { + req.validateBusinessRules(); + + Exam exam = repo.findById(id) + .orElseThrow(() -> new NotFoundException("Exam " + id + " not found")); + + if (repo.existsByModuleCodeAndExamDateAndAttemptNumberAndIdNot( + req.moduleCode(), req.examDate(), req.attemptNumber(), id)) { + throw new IllegalStateException("Exam with same moduleCode, examDate and attemptNumber already exists"); + } + + exam.setTitle(req.title()); + exam.setModuleCode(req.moduleCode()); + exam.setExamDate(req.examDate()); + exam.setRoom(req.room()); + exam.setExamType(req.examType()); + exam.setSemester(req.semester()); + exam.setEcts(req.ects()); + exam.setMaxPoints(req.maxPoints()); + exam.setDuration(req.duration()); + exam.setAttemptNumber(req.attemptNumber()); + exam.setFileUploadRequired(req.fileUploadRequired()); + exam.setTools(req.tools()); + + return toResponse(repo.save(exam)); + } + + public static ExamResponse toResponse(Exam e) { + return new ExamResponse( + e.getId(), + e.getTitle(), + e.getModuleCode(), + e.getExamDate(), + e.getRoom(), + e.getExamType(), + e.getSemester(), + e.getEcts(), + e.getMaxPoints(), + e.getDuration(), + e.getAttemptNumber(), + e.isFileUploadRequired(), + e.getTools() + ); + } + + @Transactional(readOnly = true) + public ExamResponse get(Long id) { + var exam = repo.findById(id) + .orElseThrow(() -> new NotFoundException("Exam " + id + " not found")); + return toResponse(exam); + } + + @Transactional(readOnly = true) + public List list() { + return repo.findAll().stream().map(ExamService::toResponse).toList(); + } + + @Transactional + public void delete(Long id) { + repo.deleteById(id); + } +} diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml new file mode 100644 index 00000000..bab4c7b8 --- /dev/null +++ b/src/main/resources/application-prod.yaml @@ -0,0 +1,14 @@ +spring: + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:exams} + username: ${DB_USER} + password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + +server: + port: 8080 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 4b5711b9..680cfb01 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,19 +1,20 @@ spring: - application: - name: ASE-UserService + datasource: + url: jdbc:h2:mem:examdb;DB_CLOSE_DELAY=-1;MODE=PostgreSQL + username: sa + password: '' + driver-class-name: org.h2.Driver jpa: - database-platform: org.hibernate.dialect.H2Dialect - show-sql: false hibernate: ddl-auto: update properties: hibernate: format_sql: true - datasource: - url: jdbc:h2:file:./data/mydb - driverClassName: org.h2.Driver - username: sa - password: password + jackson: + serialization: + write-dates-as-timestamps: false + server: - error: - include-message: always + port: 8080 + +logging.level.org.hibernate.SQL: INFO diff --git a/src/test/java/com/ase/userservice/ApplicationTests.java b/src/test/java/com/ase/userservice/ApplicationTests.java index be5b4249..98bfac6c 100644 --- a/src/test/java/com/ase/userservice/ApplicationTests.java +++ b/src/test/java/com/ase/userservice/ApplicationTests.java @@ -3,11 +3,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest +@SpringBootTest(properties = { + "spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost/fake-jwks" +}) class ApplicationTests { - - @Test - void contextLoads() { - } - + @Test + void contextLoads() {} }