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() {}
}