diff --git a/angular-ui.app.config.ts b/angular-ui.app.config.ts new file mode 100644 index 0000000..24f2355 --- /dev/null +++ b/angular-ui.app.config.ts @@ -0,0 +1,12 @@ +import { ApplicationConfig } from '@angular/core'; +import { provideRouter } from '@angular/router'; + +import { provideHttpClient } from '@angular/common/http'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideRouter(routes), provideHttpClient()], +}; + +export const reverseProxyUri = 'http://LOCALHOST_NAME:7080'; +export const baseUri = `${reverseProxyUri}/angular-ui/`; diff --git a/api/bff/src/main/java/com/c4soft/quiz/BffApplication.java b/api/bff/src/main/java/com/c4soft/quiz/BffApplication.java index 033febb..a44327a 100644 --- a/api/bff/src/main/java/com/c4soft/quiz/BffApplication.java +++ b/api/bff/src/main/java/com/c4soft/quiz/BffApplication.java @@ -6,7 +6,7 @@ @SpringBootApplication public class BffApplication { - public static void main(String[] args) { - SpringApplication.run(BffApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(BffApplication.class, args); + } } diff --git a/api/bff/src/main/java/com/c4soft/quiz/BffController.java b/api/bff/src/main/java/com/c4soft/quiz/BffController.java index ed61ddc..57c5ead 100644 --- a/api/bff/src/main/java/com/c4soft/quiz/BffController.java +++ b/api/bff/src/main/java/com/c4soft/quiz/BffController.java @@ -2,7 +2,6 @@ import java.net.URISyntaxException; import java.util.List; - import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; @@ -10,9 +9,7 @@ import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; - import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotEmpty; @@ -21,24 +18,28 @@ @RestController @Tag(name = "BFF") public class BffController { - private final List loginOptions; - - public BffController( - OAuth2ClientProperties clientProps, - SpringAddonsOidcProperties addonsProperties, - ReactiveClientRegistrationRepository clientRegistrationRepository) { - this.loginOptions = clientProps.getRegistration().entrySet().stream().filter(e -> "authorization_code".equals(e.getValue().getAuthorizationGrantType())) - .map(e -> new LoginOptionDto(e.getValue().getProvider(), "%s/oauth2/authorization/%s".formatted(addonsProperties.getClient().getClientUri(), e.getKey()))) - .toList(); - } - - @GetMapping(path = "/login-options", produces = MediaType.APPLICATION_JSON_VALUE) - @Operation(operationId = "getLoginOptions") - public Mono> getLoginOptions(Authentication auth) throws URISyntaxException { - final boolean isAuthenticated = auth instanceof OAuth2AuthenticationToken; - return Mono.just(isAuthenticated ? List.of() : this.loginOptions); - } - - public static record LoginOptionDto(@NotEmpty String label, @NotEmpty String loginUri) { - } + private final List loginOptions; + + public BffController(OAuth2ClientProperties clientProps, + SpringAddonsOidcProperties addonsProperties, + ReactiveClientRegistrationRepository clientRegistrationRepository) { + this.loginOptions = + clientProps.getRegistration().entrySet().stream() + .filter(e -> "authorization_code".equals(e.getValue().getAuthorizationGrantType())) + .map( + e -> new LoginOptionDto(e.getValue().getProvider(), + "%s/oauth2/authorization/%s" + .formatted(addonsProperties.getClient().getClientUri(), e.getKey()))) + .toList(); + } + + @GetMapping(path = "/login-options", produces = MediaType.APPLICATION_JSON_VALUE) + @Operation(operationId = "getLoginOptions") + public Mono> getLoginOptions(Authentication auth) throws URISyntaxException { + final boolean isAuthenticated = auth instanceof OAuth2AuthenticationToken; + return Mono.just(isAuthenticated ? List.of() : this.loginOptions); + } + + public static record LoginOptionDto(@NotEmpty String label, @NotEmpty String loginUri) { + } } diff --git a/api/bff/src/main/resources/application.yml b/api/bff/src/main/resources/application.yml index 759f0d8..249aae3 100644 --- a/api/bff/src/main/resources/application.yml +++ b/api/bff/src/main/resources/application.yml @@ -1,17 +1,15 @@ scheme: http -keycloak-host: https://oidc.c4-soft.com/auth +keycloak-host: http://localhost/auth keycloak-realm: quiz oauth2-issuer: ${keycloak-host}/realms/${keycloak-realm} oauth2-client-id: quiz-bff -oauth2-client-secret: change-me +oauth2-client-secret: secret gateway-uri: ${scheme}://localhost:${server.port} quiz-api-uri: ${scheme}://localhost:7084 -ui-host: https://localhost:4200 server: - port: 8080 - shutdown: graceful + port: 7080 ssl: enabled: false @@ -35,29 +33,12 @@ spring: client-id: ${oauth2-client-id} client-secret: ${oauth2-client-secret} authorization-grant-type: authorization_code - scope: - - openid - - profile - - email - - offline_access + scope: openid profile email offline_access cloud: gateway: default-filters: - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin Access-Control-Request-Method Access-Control-Request-Headers routes: - # Redirection from / to /ui/ - - id: home - uri: ${gateway-uri} - predicates: - - Path=/ - filters: - - RedirectTo=301,${gateway-uri}/ui/ - # Serve the Angular app through the gateway - - id: ui - uri: ${ui-host} - predicates: - - Path=/ui/** - # Access the quiz API with BFF pattern - id: quiz-bff uri: ${quiz-api-uri} predicates: @@ -66,19 +47,6 @@ spring: - TokenRelay= - SaveSession - StripPrefix=2 - # Access the quiz API with OAuth2 clients like Postman - - id: quiz-resource-server - uri: ${quiz-api-uri} - predicates: - - Path=/resource-server/v1/** - filters: - - SaveSession - - StripPrefix=2 - # Cert-manager http01 challenge for SSL certificates on K8s - - id: letsencrypt - uri: https://cert-manager-webhook - predicates: - - Path=/.well-known/acme-challenge/** com: c4-soft: @@ -95,11 +63,12 @@ com: security-matchers: - /login/** - /oauth2/** - - /logout + - /logout/** - /bff/** permit-all: - /login/** - /oauth2/** + - /logout/connect/back-channel/quiz-bff - /bff/** csrf: cookie-accessible-from-js post-login-redirect-path: /ui/ @@ -107,12 +76,14 @@ com: oauth2-redirections: rp-initiated-logout: ACCEPTED pkce-forced: true + back-channel-logout: + enabled: true + internal-logout-uri: ${scheme}://localhost:${serve.port}/logout # OAuth2 resource server configuration resourceserver: permit-all: - /login-options - /ui/** - - /resource-server/** - /v3/api-docs/** - /actuator/health/readiness - /actuator/health/liveness diff --git a/api/pom.xml b/api/pom.xml index 5cd3657..7605ffa 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -18,7 +18,7 @@ 22 2023.0.1 - 7.8.0 + 7.8.2 0.2.0 1.6.0.Beta2 7.6.0 @@ -99,6 +99,11 @@ spring-addons-starter-oidc-test ${com.c4-soft.springaddons.version} + + com.c4-soft.springaddons + spring-addons-starter-rest + ${com.c4-soft.springaddons.version} + com.c4-soft.springaddons spring-addons-starter-openapi @@ -175,6 +180,36 @@ false + + org.openapitools + openapi-generator-maven-plugin + ${openapi-generator-maven-plugin.version} + + spring + false + true + false + false + false + false + + none + none + true + false + true + spring-http-interface + false + true + true + true + true + + + true + + + diff --git a/api/quiz-api/pom.xml b/api/quiz-api/pom.xml index 2789257..952ed07 100644 --- a/api/quiz-api/pom.xml +++ b/api/quiz-api/pom.xml @@ -24,6 +24,11 @@ org.springframework.boot spring-boot-starter-data-jpa + + jakarta.data + jakarta.data-api + 1.0.0 + org.springframework.boot spring-boot-starter-mail @@ -51,6 +56,12 @@ spring-addons-starter-oidc + + + com.c4-soft.springaddons + spring-addons-starter-rest + + org.springframework.boot @@ -122,6 +133,50 @@ + + org.openapitools + openapi-generator-maven-plugin + + + + generate + + + true + + ${project.basedir}/../keycloak-admin-api.openapi.json + org.keycloak.admin.api + org.keycloak.admin.model + + auth_time=authTimeLong + + + + + + com.spotify.fmt + fmt-maven-plugin + 2.23 + + target/generated-sources + true + .*\.java + false + false + true + false + + + + + + format + + + + diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/ExceptionHandlers.java b/api/quiz-api/src/main/java/com/c4soft/quiz/ExceptionHandlers.java new file mode 100644 index 0000000..dbe3f04 --- /dev/null +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/ExceptionHandlers.java @@ -0,0 +1,104 @@ +/* (C)2024 */ +package com.c4soft.quiz; + +import java.net.URI; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingPathVariableException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import com.c4soft.quiz.domain.exception.DraftAlreadyExistsException; +import com.c4soft.quiz.domain.exception.InvalidQuizException; +import com.c4soft.quiz.domain.exception.NotADraftException; +import com.c4soft.quiz.domain.exception.QuizAlreadyHasAnAnswerException; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.persistence.EntityNotFoundException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; + +@RestControllerAdvice +public class ExceptionHandlers { + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ApiResponse(responseCode = "422", + content = {@Content(schema = @Schema(implementation = ValidationProblemDetail.class))}) + public ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex) { + final var detail = new ValidationProblemDetail(ex.getMessage(), ex.getFieldErrors().stream() + .collect(Collectors.toMap(FieldError::getField, FieldError::getCode))); + return ResponseEntity.status(detail.getStatus()).body(detail); + } + + @ExceptionHandler(ConstraintViolationException.class) + @ApiResponse(responseCode = "422", + content = {@Content(schema = @Schema(implementation = ValidationProblemDetail.class))}) + public ResponseEntity handleConstraintViolation( + ConstraintViolationException ex) { + final var problem = new ValidationProblemDetail(ex.getMessage(), + ex.getConstraintViolations().stream().collect(Collectors + .toMap(cv -> cv.getPropertyPath().toString(), ConstraintViolation::getMessage))); + return ResponseEntity.status(problem.getStatus()).body(problem); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + @ApiResponse(responseCode = "422", + content = {@Content(schema = @Schema(implementation = ProblemDetail.class))}) + public ResponseEntity handleDataIntegrityViolation( + DataIntegrityViolationException ex) { + final var detail = + ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage()); + return ResponseEntity.status(detail.getStatus()).body(detail); + } + + @ExceptionHandler(EntityNotFoundException.class) + @ApiResponse(responseCode = "404", + content = {@Content(schema = @Schema(implementation = ProblemDetail.class))}) + public ResponseEntity handleEntityNotFound(EntityNotFoundException ex) { + final var problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + return ResponseEntity.status(problem.getStatus()).body(problem); + } + + @ExceptionHandler(MissingPathVariableException.class) + @ApiResponse(responseCode = "404", + content = {@Content(schema = @Schema(implementation = ProblemDetail.class))}) + public ResponseEntity handleMissingPathVariableException( + MissingPathVariableException ex) { + final var problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + return ResponseEntity.status(problem.getStatus()).body(problem); + } + + @ExceptionHandler({InvalidQuizException.class, QuizAlreadyHasAnAnswerException.class, + DraftAlreadyExistsException.class, NotADraftException.class}) + @ApiResponse(responseCode = "409", + content = {@Content(schema = @Schema(implementation = ProblemDetail.class))}) + public ResponseEntity handleConflicts(NotADraftException ex) { + final var problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); + return ResponseEntity.status(problem.getStatus()).body(problem); + } + + public static class ValidationProblemDetail extends ProblemDetail { + public static final URI TYPE = URI.create("https://quiz.c4-soft.com/problems/validation"); + public static final String INVALID_FIELDS_PROPERTY = "invalidFields"; + + public ValidationProblemDetail(String message, Map invalidFields) { + super(ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, message)); + super.setType(TYPE); + super.setProperty(INVALID_FIELDS_PROPERTY, invalidFields); + } + + @SuppressWarnings("unchecked") + @JsonSerialize + Map getInvalidFields() { + return (Map) super.getProperties().get(INVALID_FIELDS_PROPERTY); + } + } +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/PersistenceConfig.java b/api/quiz-api/src/main/java/com/c4soft/quiz/PersistenceConfig.java index 90b6514..d4abfb7 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/PersistenceConfig.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/PersistenceConfig.java @@ -1,3 +1,4 @@ +/* (C)2024 */ package com.c4soft.quiz; import org.springframework.boot.autoconfigure.domain.EntityScan; @@ -10,5 +11,4 @@ @EnableJpaRepositories @EntityScan public class PersistenceConfig { - } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/QuizApiApplication.java b/api/quiz-api/src/main/java/com/c4soft/quiz/QuizApiApplication.java index 7215bcd..50b71dc 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/QuizApiApplication.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/QuizApiApplication.java @@ -1,3 +1,4 @@ +/* (C)2024 */ package com.c4soft.quiz; import org.springframework.boot.SpringApplication; @@ -6,7 +7,7 @@ @SpringBootApplication public class QuizApiApplication { - public static void main(String[] args) { - SpringApplication.run(QuizApiApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(QuizApiApplication.class, args); + } } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/RestConfig.java b/api/quiz-api/src/main/java/com/c4soft/quiz/RestConfig.java new file mode 100644 index 0000000..a44303c --- /dev/null +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/RestConfig.java @@ -0,0 +1,15 @@ +package com.c4soft.quiz; + +import org.keycloak.admin.api.UsersApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import com.c4_soft.springaddons.rest.SpringAddonsRestClientSupport; + +@Configuration +public class RestConfig { + @Bean + UsersApi usersApi(SpringAddonsRestClientSupport restSupport) { + return restSupport.service("keycloak-admin-api", UsersApi.class); + } + +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/SecurityConfig.java b/api/quiz-api/src/main/java/com/c4soft/quiz/SecurityConfig.java index c01ab20..3182004 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/SecurityConfig.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/SecurityConfig.java @@ -1,14 +1,13 @@ +/* (C)2024 */ package com.c4soft.quiz; import java.util.Collection; import java.util.Map; - import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.convert.converter.Converter; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.core.GrantedAuthority; - import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; @@ -18,14 +17,16 @@ @Configuration @EnableMethodSecurity() public class SecurityConfig { - @Bean - JwtAbstractAuthenticationTokenConverter authenticationFactory( - Converter, Collection> authoritiesConverter, - OpenidProviderPropertiesResolver addonsPropertiesResolver) { - return jwt -> { - final var opProperties = addonsPropertiesResolver.resolve(jwt.getClaims()).orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())); - final var claims = new OpenidClaimSet(jwt.getClaims(), opProperties.getUsernameClaim()); - return new QuizAuthentication(claims, authoritiesConverter.convert(claims), jwt.getTokenValue()); - }; - } + @Bean + JwtAbstractAuthenticationTokenConverter authenticationFactory( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver addonsPropertiesResolver) { + return jwt -> { + final var opProperties = addonsPropertiesResolver.resolve(jwt.getClaims()) + .orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())); + final var claims = new OpenidClaimSet(jwt.getClaims(), opProperties.getUsernameClaim()); + return new QuizAuthentication(claims, authoritiesConverter.convert(claims), + jwt.getTokenValue()); + }; + } } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/Choice.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/Choice.java index 70a56cd..86389a3 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/Choice.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/Choice.java @@ -1,3 +1,4 @@ +/* (C)2024 */ package com.c4soft.quiz.domain; import jakarta.persistence.Column; @@ -7,34 +8,41 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +import lombok.ToString; @Entity @Data @NoArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString(onlyExplicitlyIncluded = true) public class Choice { - public Choice(String label, Boolean isGood) { - this.label = label; - this.isGood = isGood; - } - - public Choice(Choice other) { - this.label = other.label; - this.isGood = other.isGood; - } + public Choice(String label, Boolean isGood) { + this.label = label; + this.isGood = isGood; + } - @Id - @GeneratedValue - private Long id; - - @ManyToOne(optional = false) - @JoinColumn(name = "question_id", updatable = false, nullable = false) - private Question question; - - @Column - private String label; - - @Column - private Boolean isGood; + public Choice(Choice other) { + this.label = other.label; + this.isGood = other.isGood; + } + @Id + @GeneratedValue + @EqualsAndHashCode.Include + @ToString.Include + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "question_id", updatable = false, nullable = false) + private Question question; + + @Column + @ToString.Include + private String label; + + @Column + @ToString.Include + private Boolean isGood; } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/DraftAlreadyExistsException.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/DraftAlreadyExistsException.java deleted file mode 100644 index 0b918d2..0000000 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/DraftAlreadyExistsException.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.c4soft.quiz.domain; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ResponseStatus; - -@ResponseStatus(HttpStatus.CONFLICT) -public class DraftAlreadyExistsException extends RuntimeException { - private static final long serialVersionUID = -8566841487060787834L; - - public DraftAlreadyExistsException(Long quizId) { - super("A draft already exists for quiz %d".formatted(quizId)); - } - - -} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/NotADraftException.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/NotADraftException.java deleted file mode 100644 index b02ae6f..0000000 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/NotADraftException.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.c4soft.quiz.domain; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ResponseStatus; - -@ResponseStatus(HttpStatus.CONFLICT) -public class NotADraftException extends RuntimeException { - private static final long serialVersionUID = -8566841487060787834L; - - public NotADraftException(Long quizId) { - super("Quiz %d is not a draft".formatted(quizId)); - } - - -} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/Question.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/Question.java index c6132cd..4f2a7f6 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/Question.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/Question.java @@ -1,9 +1,9 @@ +/* (C)2024 */ package com.c4soft.quiz.domain; import java.util.ArrayList; import java.util.Collections; import java.util.List; - import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -14,87 +14,95 @@ import jakarta.persistence.OneToMany; import lombok.AccessLevel; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.Setter; +import lombok.ToString; @Entity @Data @NoArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString(onlyExplicitlyIncluded = true) public class Question { - public Question(String label, String formattedBody, Integer priority, String comment, Choice... choices) { - this.label = label; - this.formattedBody = formattedBody; - this.priority = priority; - this.comment = comment; - this.choices = new ArrayList<>(choices.length); - for (var c : choices) { - this.add(c); - } - } - - public Question(Question other) { - this.comment = other.comment; - this.label = other.label; - this.formattedBody = other.formattedBody; - this.priority = other.priority; - this.choices = new ArrayList<>(other.choices.size()); - for (var c : other.choices) { - final var choice = new Choice(c); - this.add(choice); - } - } - - @Id - @GeneratedValue - private Long id; - - @ManyToOne(optional = false) - @JoinColumn(name = "quiz_id", updatable = false, nullable = false) - private Quiz quiz; - - @Column - private String label; - - @Column(length = 2047) - private String formattedBody; - - @Column(nullable = false, updatable = true) - private Integer priority; - - @Setter(AccessLevel.NONE) - @OneToMany(mappedBy = "question", cascade = CascadeType.ALL, orphanRemoval = true) - private List choices = new ArrayList<>(); - - @Column(length = 2048) - private String comment; - - public List getChoices() { - return Collections.unmodifiableList(choices); - } - - public Choice getChoice(Long choiceId) { - if (choiceId == null) { - return null; - } - return choices.stream().filter(q -> choiceId.equals(q.getId())).findAny().orElse(null); - } - - public Question add(Choice choice) { - if (choice.getQuestion() != null && choice.getQuestion() != this) { - throw new RuntimeException("Choice already belongs to another question"); - } - choice.setQuestion(this); - choices.add(choice); - return this; - } - - public Question remove(Choice choice) { - if (choice.getQuestion() != this) { - throw new RuntimeException("Choice does not belongs to another question"); - } - choices.remove(choice); - choice.setQuestion(null); - return this; - } - + public Question(String label, String formattedBody, Integer priority, String comment, + Choice... choices) { + this.label = label; + this.formattedBody = formattedBody; + this.priority = priority; + this.comment = comment; + this.choices = new ArrayList<>(choices.length); + for (var c : choices) { + this.add(c); + } + } + + public Question(Question other) { + this.comment = other.comment; + this.label = other.label; + this.formattedBody = other.formattedBody; + this.priority = other.priority; + this.choices = new ArrayList<>(other.choices.size()); + for (var c : other.choices) { + final var choice = new Choice(c); + this.add(choice); + } + } + + @Id + @GeneratedValue + @EqualsAndHashCode.Include + @ToString.Include + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "quiz_id", updatable = false, nullable = false) + private Quiz quiz; + + @Column + @ToString.Include + private String label; + + @Column(length = 2047) + private String formattedBody; + + @Column(nullable = false, updatable = true) + @ToString.Include + private Integer priority; + + @Setter(AccessLevel.NONE) + @OneToMany(mappedBy = "question", cascade = CascadeType.ALL, orphanRemoval = true) + private List choices = new ArrayList<>(); + + @Column(length = 2048) + private String comment; + + public List getChoices() { + return Collections.unmodifiableList(choices); + } + + public Choice getChoice(Long choiceId) { + if (choiceId == null) { + return null; + } + return choices.stream().filter(q -> choiceId.equals(q.getId())).findAny().orElse(null); + } + + public Question add(Choice choice) { + if (choice.getQuestion() != null && choice.getQuestion() != this) { + throw new RuntimeException("Choice already belongs to another question"); + } + choice.setQuestion(this); + choices.add(choice); + return this; + } + + public Question remove(Choice choice) { + if (choice.getQuestion() != this) { + throw new RuntimeException("Choice does not belongs to another question"); + } + choices.remove(choice); + choice.setQuestion(null); + return this; + } } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/Quiz.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/Quiz.java index fc95049..2d30bf7 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/Quiz.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/Quiz.java @@ -1,8 +1,8 @@ +/* (C)2024 */ package com.c4soft.quiz.domain; import java.util.ArrayList; import java.util.List; - import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -12,116 +12,125 @@ import jakarta.persistence.OneToOne; import lombok.AccessLevel; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.Setter; +import lombok.ToString; @Entity @Data @NoArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString(onlyExplicitlyIncluded = true) public class Quiz { - public Quiz(String title, String authorName, Question... questions) { - this.title = title; - this.authorName = authorName; - this.questions = new ArrayList<>(questions.length); - for (var q : questions) { - this.add(q); - } - } - - public Quiz(Quiz other, String authorName) { - this.authorName = authorName; - this.questions = new ArrayList<>(other.questions.size()); - for (var q : other.questions) { - final var question = new Question(q); - this.add(question); - } - this.isPublished = false; - this.moderatedBy = null; - this.title = other.title; - } - - @Id - @GeneratedValue - private Long id; - - @Column(nullable = false) - private String title; - - @Setter(AccessLevel.NONE) - @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true) - private List questions = new ArrayList<>(); - - @Column(nullable = false, updatable = false) - private String authorName; - - @Column(nullable = false, columnDefinition = "boolean default false") - private Boolean isSubmitted = false; - - @Column(nullable = false, columnDefinition = "boolean default false") - private Boolean isPublished = false; - - @OneToOne(optional = true) - private Quiz draft; - - @OneToOne(optional = true) - private Quiz replaces; - - @OneToOne(optional = true) - private Quiz replacedBy; - - @Column() - private String moderatorComment; - - @Column() - private String moderatedBy; - - @Column(nullable = false, columnDefinition = "boolean default true") - private Boolean isChoicesShuffled = true; - - @Column(nullable = false, columnDefinition = "boolean default true") - private Boolean isReplayEnabled = true; - - @Column(nullable = false, columnDefinition = "boolean default true") - private Boolean isPerQuestionResult = true; - - @Column(nullable = false, columnDefinition = "boolean default false") - private Boolean isTrainerNotifiedOfNewTests = false; - - @Setter(AccessLevel.NONE) - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - private List skillTests = new ArrayList<>(); - - public Question getQuestion(Long questionId) { - if(questionId == null) { - return null; - } - return questions.stream().filter(q -> questionId.equals(q.getId())).findAny() - .orElse(null); - } - - public Quiz add(Question question) { - if (question.getQuiz() != null && question.getQuiz() != this) { - throw new RuntimeException("Question already belongs to another quiz"); - } - questions.stream().filter(q -> q.getPriority() >= question.getPriority()) - .forEach(q -> q.setPriority(q.getPriority() + 1)); - question.setQuiz(this); - questions.add(question); - questions.sort((a, b) -> a.getPriority() - b.getPriority()); - return this; - } - - public Quiz remove(Question question) { - if (question.getQuiz() != this) { - throw new RuntimeException("Question does not belong to this quiz"); - } - final var isRemoved = questions.remove(question); - question.setQuiz(null); - if (isRemoved) { - questions.stream().filter(q -> q.getPriority() >= question.getPriority()) - .forEach(q -> q.setPriority(q.getPriority() - 1)); - } - return this; - } + public Quiz(String title, String authorName, Question... questions) { + this.title = title; + this.authorName = authorName; + this.questions = new ArrayList<>(questions.length); + for (var q : questions) { + this.add(q); + } + } + + public Quiz(Quiz other, String authorName) { + this.authorName = authorName; + this.questions = new ArrayList<>(other.questions.size()); + for (var q : other.questions) { + final var question = new Question(q); + this.add(question); + } + this.isPublished = false; + this.moderatedBy = null; + this.title = other.title; + } + + @Id + @GeneratedValue + @EqualsAndHashCode.Include + @ToString.Include + private Long id; + + @Column(nullable = false) + @ToString.Include + private String title; + + @Setter(AccessLevel.NONE) + @OneToMany(mappedBy = "quiz", cascade = CascadeType.ALL, orphanRemoval = true) + private List questions = new ArrayList<>(); + + @Column(nullable = false, updatable = false) + @ToString.Include + private String authorName; + + @Column(nullable = false, columnDefinition = "boolean default false") + @ToString.Include + private Boolean isSubmitted = false; + + @Column(nullable = false, columnDefinition = "boolean default false") + @ToString.Include + private Boolean isPublished = false; + + @OneToOne(optional = true) + private Quiz draft; + + @OneToOne(optional = true) + private Quiz replaces; + + @OneToOne(optional = true) + private Quiz replacedBy; + + @Column() + private String moderatorComment; + + @Column() + private String moderatedBy; + + @Column(nullable = false, columnDefinition = "boolean default true") + private Boolean isChoicesShuffled = true; + + @Column(nullable = false, columnDefinition = "boolean default true") + private Boolean isReplayEnabled = true; + + @Column(nullable = false, columnDefinition = "boolean default true") + private Boolean isPerQuestionResult = true; + + @Column(nullable = false, columnDefinition = "boolean default false") + private Boolean isTrainerNotifiedOfNewTests = false; + + @Setter(AccessLevel.NONE) + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + private List skillTests = new ArrayList<>(); + + public Question getQuestion(Long questionId) { + if (questionId == null) { + return null; + } + return questions.stream().filter(q -> questionId.equals(q.getId())).findAny().orElse(null); + } + + public Quiz add(Question question) { + if (question.getQuiz() != null && question.getQuiz() != this) { + throw new RuntimeException("Question already belongs to another quiz"); + } + questions.stream().filter(q -> q.getPriority() >= question.getPriority()) + .forEach(q -> q.setPriority(q.getPriority() + 1)); + question.setQuiz(this); + questions.add(question); + questions.sort((a, b) -> a.getPriority() - b.getPriority()); + return this; + } + + public Quiz remove(Question question) { + if (question.getQuiz() != this) { + throw new RuntimeException("Question does not belong to this quiz"); + } + final var isRemoved = questions.remove(question); + question.setQuiz(null); + if (isRemoved) { + questions.stream().filter(q -> q.getPriority() >= question.getPriority()) + .forEach(q -> q.setPriority(q.getPriority() - 1)); + } + return this; + } } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuizAuthentication.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuizAuthentication.java index 9caaab3..44aecc2 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuizAuthentication.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuizAuthentication.java @@ -1,31 +1,32 @@ +/* (C)2024 */ package com.c4soft.quiz.domain; import java.util.Collection; import java.util.stream.Collectors; - import org.springframework.security.core.GrantedAuthority; - import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; - import lombok.Getter; @Getter public class QuizAuthentication extends OAuthentication { - private static final long serialVersionUID = 1L; - public static final String AUTHORITY_MODERATOR = "moderator"; - public static final String AUTHORITY_TRAINER = "trainer"; - public static final String SPEL_IS_MODERATOR_OR_TRAINER = "hasAnyAuthority('moderator', 'trainer')"; - public static final String SPEL_IS_MODERATOR = "hasAuthority('moderator')"; - public static final String SPEL_IS_TRAINER = "hasAuthority('trainer')"; + private static final long serialVersionUID = 1L; + public static final String AUTHORITY_MODERATOR = "moderator"; + public static final String AUTHORITY_TRAINER = "trainer"; + public static final String SPEL_IS_MODERATOR_OR_TRAINER = + "hasAnyAuthority('moderator', 'trainer')"; + public static final String SPEL_IS_MODERATOR = "hasAuthority('moderator')"; + public static final String SPEL_IS_TRAINER = "hasAuthority('trainer')"; - private final boolean isModerator; - private final boolean isTrainer; + private final boolean isModerator; + private final boolean isTrainer; - public QuizAuthentication(OpenidClaimSet claims, Collection authorities, String tokenString) { - super(claims, authorities, tokenString); - final var authoritiesStrings = authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet()); - this.isModerator = authoritiesStrings.contains(AUTHORITY_MODERATOR); - this.isTrainer = authoritiesStrings.contains(AUTHORITY_TRAINER); - } + public QuizAuthentication(OpenidClaimSet claims, + Collection authorities, String tokenString) { + super(claims, authorities, tokenString); + final var authoritiesStrings = + authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet()); + this.isModerator = authoritiesStrings.contains(AUTHORITY_MODERATOR); + this.isTrainer = authoritiesStrings.contains(AUTHORITY_TRAINER); + } } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuizRejectionDto.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuizRejectionDto.java index 42df467..8653270 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuizRejectionDto.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuizRejectionDto.java @@ -1,7 +1,8 @@ +/* (C)2024 */ package com.c4soft.quiz.domain; import jakarta.validation.constraints.NotEmpty; -public record QuizRejectionDto(@NotEmpty(message = "A non-empty message is mandatory") String message) { - +public record QuizRejectionDto( + @NotEmpty(message = "A non-empty message is mandatory") String message) { } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuizRepository.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuizRepository.java deleted file mode 100644 index 5eac295..0000000 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuizRepository.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.c4soft.quiz.domain; - -import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.domain.Specification; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.JpaSpecificationExecutor; -import org.springframework.security.core.context.SecurityContextHolder; - -public interface QuizRepository extends JpaRepository, JpaSpecificationExecutor { - - Optional findByReplacesId(Long replacedQuizId); - - List findByIsSubmitted(boolean isSubmitted); - - static Specification searchSpec(Optional authorName, Optional quizTitle) { - final var currentUserName = SecurityContextHolder.getContext().getAuthentication().getName(); - var spec = Specification.where(isPublished()).or(authoredBy(currentUserName)); - if (authorName.isPresent()) { - spec = spec.and(authoredBy(authorName.get())); - } - if (quizTitle.isPresent()) { - spec = spec.and(titleContains(quizTitle.get())); - } - return orderByTitleDesc(spec); - } - - static Specification isPublished() { - return (root, query, cb) -> cb.isTrue(root.get(Quiz_.isPublished)); - } - - static Specification authoredBy(String authorName) { - return (root, query, cb) -> cb.like(cb.upper(root.get(Quiz_.authorName)), "%%%s%%".formatted(authorName.toUpperCase())); - } - - static Specification titleContains(String title) { - return (root, query, cb) -> cb.like(cb.upper(root.get(Quiz_.title)), "%%%s%%".formatted(title.toUpperCase())); - } - - static Specification orderByTitleDesc(Specification spec) { - return (root, query, cb) -> { - query.orderBy(cb.asc(root.get(Quiz_.title))); - return spec.toPredicate(root, query, cb); - }; - } -} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/SkillTest.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/SkillTest.java index 05e0fc7..8300955 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/SkillTest.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/SkillTest.java @@ -1,3 +1,4 @@ +/* (C)2024 */ package com.c4soft.quiz.domain; import java.time.Instant; @@ -5,10 +6,8 @@ import java.util.Collection; import java.util.List; import java.util.Objects; - import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; - import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import jakarta.persistence.EmbeddedId; @@ -17,59 +16,66 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.Setter; +import lombok.ToString; @Entity @Data @NoArgsConstructor +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString(onlyExplicitlyIncluded = true) public class SkillTest { - public SkillTest(Quiz quiz, String traineeName, Collection choices) { - this.submittedOn = Instant.now().toEpochMilli(); - this.choices = new ArrayList<>(choices); - this.id.traineeName = traineeName; - this.choices.forEach(c -> { - if (c.getQuestion() == null || c.getQuestion().getQuiz() == null || !Objects.equals(c.getQuestion().getQuiz().getId(), quiz.getId())) { - throw new NotAcceptableSkillTestException("All choices must target the same quiz (%s)".formatted(quiz.getTitle())); - } - }); - this.id.quizId = quiz.getId(); - } + public SkillTest(Quiz quiz, String traineeName, Collection choices) { + this.submittedOn = Instant.now().toEpochMilli(); + this.choices = new ArrayList<>(choices); + this.id.traineeName = traineeName; + this.choices.forEach(c -> { + if (c.getQuestion() == null || c.getQuestion().getQuiz() == null + || !Objects.equals(c.getQuestion().getQuiz().getId(), quiz.getId())) { + throw new NotAcceptableSkillTestException( + "All choices must target the same quiz (%s)".formatted(quiz.getTitle())); + } + }); + this.id.quizId = quiz.getId(); + } - @Setter(AccessLevel.NONE) - @EmbeddedId - private final SkillTestPk id = new SkillTestPk(); + @Setter(AccessLevel.NONE) + @EmbeddedId + private final SkillTestPk id = new SkillTestPk(); - @Column - private Long submittedOn; + @Column + private Long submittedOn; - @ManyToMany - private List choices = new ArrayList<>(); + @ManyToMany + private List choices = new ArrayList<>(); - public List getChoices(Long questionId) { - return choices.stream().filter(c -> Objects.equals(c.getQuestion().getId(), questionId)).toList(); - } + public List getChoices(Long questionId) { + return choices.stream().filter(c -> Objects.equals(c.getQuestion().getId(), questionId)) + .toList(); + } - @ResponseStatus(HttpStatus.NOT_ACCEPTABLE) - static class NotAcceptableSkillTestException extends RuntimeException { - private static final long serialVersionUID = -6754084213295394103L; + @ResponseStatus(HttpStatus.NOT_ACCEPTABLE) + static class NotAcceptableSkillTestException extends RuntimeException { + private static final long serialVersionUID = -6754084213295394103L; - public NotAcceptableSkillTestException(String message) { - super(message); - } - } + public NotAcceptableSkillTestException(String message) { + super(message); + } + } - @Embeddable - @Data - @NoArgsConstructor - @AllArgsConstructor - public static class SkillTestPk { - @Setter(AccessLevel.NONE) - @Column - private Long quizId; + @Embeddable + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class SkillTestPk { + @Setter(AccessLevel.NONE) + @Column + private Long quizId; - @Setter(AccessLevel.NONE) - @Column - private String traineeName; - } + @Setter(AccessLevel.NONE) + @Column + private String traineeName; + } } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/SkillTestRepository.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/SkillTestRepository.java deleted file mode 100644 index 4e770db..0000000 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/SkillTestRepository.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.c4soft.quiz.domain; - -import java.time.Instant; -import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.domain.Specification; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.JpaSpecificationExecutor; - -import com.c4soft.quiz.domain.SkillTest.SkillTestPk; - -public interface SkillTestRepository extends JpaRepository, JpaSpecificationExecutor { - Optional findByIdQuizIdAndIdTraineeName(Long quizId, String traineeName); - - List findByIdQuizId(Long quizId); - - void deleteByIdQuizId(Long quizId); - - static Specification quizIdSpec(Long quizId) { - return (skillTest, cq, cb) -> cb.equal(skillTest.get("id").get("quizId"), quizId); - } - - static Specification traineeNameSpec(String traineeName) { - return (skillTest, cq, cb) -> cb.equal(skillTest.get("id").get("traineeName"), traineeName); - } - - static Specification sinceSpec(Long since) { - return (skillTest, cq, cb) -> cb.ge(skillTest.get("submittedOn"), since); - } - - static Specification untilSpec(Long until) { - return (skillTest, cq, cb) -> cb.le(skillTest.get("submittedOn"), until); - } - - static Specification orderByIdDesc(Specification spec) { - return (root, query, cb) -> { - query.orderBy(cb.desc(root.get(SkillTest_.id))); - return spec.toPredicate(root, query, cb); - }; - } - - static Specification spec(Long quizId, Long since, Optional until) { - return orderByIdDesc(Specification.where(quizIdSpec(quizId)).and(sinceSpec(since)).and(untilSpec(until.orElse(Instant.now().toEpochMilli())))); - } -} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/exception/DraftAlreadyExistsException.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/exception/DraftAlreadyExistsException.java new file mode 100644 index 0000000..aca2be2 --- /dev/null +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/exception/DraftAlreadyExistsException.java @@ -0,0 +1,10 @@ +/* (C)2024 */ +package com.c4soft.quiz.domain.exception; + +public class DraftAlreadyExistsException extends RuntimeException { + private static final long serialVersionUID = -8566841487060787834L; + + public DraftAlreadyExistsException(Long quizId) { + super("A draft already exists for quiz %d".formatted(quizId)); + } +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/exception/InvalidQuizException.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/exception/InvalidQuizException.java new file mode 100644 index 0000000..f018868 --- /dev/null +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/exception/InvalidQuizException.java @@ -0,0 +1,9 @@ +package com.c4soft.quiz.domain.exception; + +public class InvalidQuizException extends RuntimeException { + private static final long serialVersionUID = 8816930385638385805L; + + public InvalidQuizException(Long quizId) { + super("Quiz %d doesn't accept answers anymore.".formatted(quizId)); + } +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/exception/NotADraftException.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/exception/NotADraftException.java new file mode 100644 index 0000000..3bd8f93 --- /dev/null +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/exception/NotADraftException.java @@ -0,0 +1,14 @@ +/* (C)2024 */ +package com.c4soft.quiz.domain.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.CONFLICT) +public class NotADraftException extends RuntimeException { + private static final long serialVersionUID = -8566841487060787834L; + + public NotADraftException(Long quizId) { + super("Quiz %d is not a draft".formatted(quizId)); + } +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/exception/QuizAlreadyHasAnAnswerException.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/exception/QuizAlreadyHasAnAnswerException.java new file mode 100644 index 0000000..01a1b08 --- /dev/null +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/exception/QuizAlreadyHasAnAnswerException.java @@ -0,0 +1,12 @@ +package com.c4soft.quiz.domain.exception; + +public class QuizAlreadyHasAnAnswerException extends RuntimeException { + + private static final long serialVersionUID = 6171083302507116601L; + + public QuizAlreadyHasAnAnswerException(Long quizId, String traineeName) { + super( + "Quiz %d already has an answer for %s and doesn't accept replay. Ask the trainer to delete the answer before submitting a new one." + .formatted(quizId, traineeName)); + } +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/ChoiceRepository.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/jpa/ChoiceRepository.java similarity index 61% rename from api/quiz-api/src/main/java/com/c4soft/quiz/domain/ChoiceRepository.java rename to api/quiz-api/src/main/java/com/c4soft/quiz/domain/jpa/ChoiceRepository.java index 0c35f78..c15e3bc 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/ChoiceRepository.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/jpa/ChoiceRepository.java @@ -1,6 +1,8 @@ -package com.c4soft.quiz.domain; +/* (C)2024 */ +package com.c4soft.quiz.domain.jpa; import org.springframework.data.jpa.repository.JpaRepository; +import com.c4soft.quiz.domain.Choice; public interface ChoiceRepository extends JpaRepository { } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuestionRepository.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/jpa/QuestionRepository.java similarity index 61% rename from api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuestionRepository.java rename to api/quiz-api/src/main/java/com/c4soft/quiz/domain/jpa/QuestionRepository.java index 2d18bc0..4b61889 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/QuestionRepository.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/jpa/QuestionRepository.java @@ -1,6 +1,8 @@ -package com.c4soft.quiz.domain; +/* (C)2024 */ +package com.c4soft.quiz.domain.jpa; import org.springframework.data.jpa.repository.JpaRepository; +import com.c4soft.quiz.domain.Question; public interface QuestionRepository extends JpaRepository { } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/jpa/QuizRepository.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/jpa/QuizRepository.java new file mode 100644 index 0000000..02b3f10 --- /dev/null +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/jpa/QuizRepository.java @@ -0,0 +1,51 @@ +/* (C)2024 */ +package com.c4soft.quiz.domain.jpa; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.security.core.context.SecurityContextHolder; +import com.c4soft.quiz.domain.Quiz; +import com.c4soft.quiz.domain.Quiz_; + +public interface QuizRepository extends JpaRepository, JpaSpecificationExecutor { + + Optional findByReplacesId(Long replacedQuizId); + + List findByIsSubmitted(boolean isSubmitted); + + static Specification searchSpec(Optional authorName, Optional quizTitle) { + final var currentUserName = SecurityContextHolder.getContext().getAuthentication().getName(); + var spec = Specification.where(isPublished()).or(authoredBy(currentUserName)); + if (authorName.isPresent()) { + spec = spec.and(authoredBy(authorName.get())); + } + if (quizTitle.isPresent()) { + spec = spec.and(titleContains(quizTitle.get())); + } + return orderByTitleDesc(spec); + } + + static Specification isPublished() { + return (root, query, cb) -> cb.isTrue(root.get(Quiz_.isPublished)); + } + + static Specification authoredBy(String authorName) { + return (root, query, cb) -> cb.like(cb.upper(root.get(Quiz_.authorName)), + "%%%s%%".formatted(authorName.toUpperCase())); + } + + static Specification titleContains(String title) { + return (root, query, cb) -> cb.like(cb.upper(root.get(Quiz_.title)), + "%%%s%%".formatted(title.toUpperCase())); + } + + static Specification orderByTitleDesc(Specification spec) { + return (root, query, cb) -> { + query.orderBy(cb.asc(root.get(Quiz_.title))); + return spec.toPredicate(root, query, cb); + }; + } +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/domain/jpa/SkillTestRepository.java b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/jpa/SkillTestRepository.java new file mode 100644 index 0000000..80deb0a --- /dev/null +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/domain/jpa/SkillTestRepository.java @@ -0,0 +1,49 @@ +/* (C)2024 */ +package com.c4soft.quiz.domain.jpa; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import com.c4soft.quiz.domain.SkillTest; +import com.c4soft.quiz.domain.SkillTest.SkillTestPk; +import com.c4soft.quiz.domain.SkillTest_; + +public interface SkillTestRepository + extends JpaRepository, JpaSpecificationExecutor { + Optional findByIdQuizIdAndIdTraineeName(Long quizId, String traineeName); + + List findByIdQuizId(Long quizId); + + void deleteByIdQuizId(Long quizId); + + static Specification quizIdSpec(Long quizId) { + return (skillTest, cq, cb) -> cb.equal(skillTest.get("id").get("quizId"), quizId); + } + + static Specification traineeNameSpec(String traineeName) { + return (skillTest, cq, cb) -> cb.equal(skillTest.get("id").get("traineeName"), traineeName); + } + + static Specification sinceSpec(Long since) { + return (skillTest, cq, cb) -> cb.ge(skillTest.get("submittedOn"), since); + } + + static Specification untilSpec(Long until) { + return (skillTest, cq, cb) -> cb.le(skillTest.get("submittedOn"), until); + } + + static Specification orderByIdDesc(Specification spec) { + return (root, query, cb) -> { + query.orderBy(cb.desc(root.get(SkillTest_.id))); + return spec.toPredicate(root, query, cb); + }; + } + + static Specification spec(Long quizId, Long since, Optional until) { + return orderByIdDesc(Specification.where(quizIdSpec(quizId)).and(sinceSpec(since)) + .and(untilSpec(until.orElse(Instant.now().toEpochMilli())))); + } +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/feign/FeignConfig.java b/api/quiz-api/src/main/java/com/c4soft/quiz/feign/FeignConfig.java deleted file mode 100644 index 3d08c17..0000000 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/feign/FeignConfig.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.c4soft.quiz.feign; - -import org.springframework.cloud.openfeign.EnableFeignClients; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableFeignClients -public class FeignConfig { - -} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/feign/KeycloakAdminApiClient.java b/api/quiz-api/src/main/java/com/c4soft/quiz/feign/KeycloakAdminApiClient.java deleted file mode 100644 index 12a596a..0000000 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/feign/KeycloakAdminApiClient.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.c4soft.quiz.feign; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; - -@FeignClient(name = "quiz-admin") -public interface KeycloakAdminApiClient { - @GetMapping(value = "/users") - List getUser(@RequestParam(value="username") String username, @RequestParam(value="exact") boolean exact); - - public static class UserRepresentation extends HashMap { - - private static final long serialVersionUID = -2288285379516753557L; - - public UserRepresentation() { - super(); - } - - public UserRepresentation(Map m) { - super(m); - } - - public String getEmail() { - return Optional.ofNullable(this.get("email")).map(Object::toString).orElse(null); - } - - public String getFirtsName() { - return Optional.ofNullable(this.get("firstName")).map(Object::toString).orElse(null); - } - - public String getLastName() { - return Optional.ofNullable(this.get("lastName")).map(Object::toString).orElse(null); - } - } -} \ No newline at end of file diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/ExceptionHandlers.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/ExceptionHandlers.java deleted file mode 100644 index ce8d9e2..0000000 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/ExceptionHandlers.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.c4soft.quiz.web; - -import java.net.URI; -import java.util.Map; -import java.util.stream.Collectors; - -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.http.HttpStatus; -import org.springframework.http.ProblemDetail; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.MissingPathVariableException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import com.c4soft.quiz.web.SkillTestController.InvalidQuizException; -import com.c4soft.quiz.web.SkillTestController.QuizAlreadyHasAnAnswerException; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; - -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import jakarta.persistence.EntityNotFoundException; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; - -@RestControllerAdvice -public class ExceptionHandlers { - - @ExceptionHandler(MethodArgumentNotValidException.class) - @ApiResponse(responseCode = "422", content = { @Content(schema = @Schema(implementation = ValidationProblemDetail.class)) }) - public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { - final var detail = new ValidationProblemDetail( - ex.getMessage(), - ex.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getCode))); - return ResponseEntity.status(detail.getStatus()).body(detail); - } - - @ExceptionHandler(ConstraintViolationException.class) - @ApiResponse(responseCode = "422", content = { @Content(schema = @Schema(implementation = ValidationProblemDetail.class)) }) - public ResponseEntity handleConstraintViolation(ConstraintViolationException ex) { - final var problem = new ValidationProblemDetail( - ex.getMessage(), - ex.getConstraintViolations().stream().collect(Collectors.toMap(cv -> cv.getPropertyPath().toString(), ConstraintViolation::getMessage))); - return ResponseEntity.status(problem.getStatus()).body(problem); - } - - @ExceptionHandler(DataIntegrityViolationException.class) - @ApiResponse(responseCode = "422", content = { @Content(schema = @Schema(implementation = ProblemDetail.class)) }) - public ResponseEntity handleDataIntegrityViolation(DataIntegrityViolationException ex) { - final var detail = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage()); - return ResponseEntity.status(detail.getStatus()).body(detail); - } - - @ExceptionHandler(EntityNotFoundException.class) - @ApiResponse(responseCode = "404", content = { @Content(schema = @Schema(implementation = ProblemDetail.class)) }) - public ResponseEntity handleEntityNotFound(EntityNotFoundException ex) { - final var problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); - return ResponseEntity.status(problem.getStatus()).body(problem); - } - - @ExceptionHandler(MissingPathVariableException.class) - @ApiResponse(responseCode = "404", content = { @Content(schema = @Schema(implementation = ProblemDetail.class)) }) - public ResponseEntity handleMissingPathVariableException(MissingPathVariableException ex) { - final var problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); - return ResponseEntity.status(problem.getStatus()).body(problem); - } - - @ExceptionHandler(InvalidQuizException.class) - @ApiResponse(responseCode = "409", content = { @Content(schema = @Schema(implementation = ProblemDetail.class)) }) - public ResponseEntity handleInvalidQuiz(InvalidQuizException ex) { - final var problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); - return ResponseEntity.status(problem.getStatus()).body(problem); - } - - @ExceptionHandler(QuizAlreadyHasAnAnswerException.class) - @ApiResponse(responseCode = "409", content = { @Content(schema = @Schema(implementation = ProblemDetail.class)) }) - public ResponseEntity handleQuizAlreadyHasAnAnswer(QuizAlreadyHasAnAnswerException ex) { - final var problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); - return ResponseEntity.status(problem.getStatus()).body(problem); - } - - public static class ValidationProblemDetail extends ProblemDetail { - public static final URI TYPE = URI.create("https://quiz.c4-soft.com/problems/validation"); - public static final String INVALID_FIELDS_PROPERTY = "invalidFields"; - - public ValidationProblemDetail(String message, Map invalidFields) { - super(ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, message)); - super.setType(TYPE); - super.setProperty(INVALID_FIELDS_PROPERTY, invalidFields); - } - - @SuppressWarnings("unchecked") - @JsonSerialize - Map getInvalidFields() { - return (Map) super.getProperties().get(INVALID_FIELDS_PROPERTY); - } - } -} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/QuizController.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/QuizController.java index 221476b..6a645a3 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/QuizController.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/QuizController.java @@ -1,11 +1,37 @@ +/* (C)2024 */ package com.c4soft.quiz.web; +import com.c4soft.quiz.domain.Choice; +import com.c4soft.quiz.domain.Question; +import com.c4soft.quiz.domain.Quiz; +import com.c4soft.quiz.domain.QuizAuthentication; +import com.c4soft.quiz.domain.QuizRejectionDto; +import com.c4soft.quiz.domain.exception.DraftAlreadyExistsException; +import com.c4soft.quiz.domain.exception.NotADraftException; +import com.c4soft.quiz.domain.jpa.ChoiceRepository; +import com.c4soft.quiz.domain.jpa.QuestionRepository; +import com.c4soft.quiz.domain.jpa.QuizRepository; +import com.c4soft.quiz.domain.jpa.SkillTestRepository; +import com.c4soft.quiz.web.dto.ChoiceDto; +import com.c4soft.quiz.web.dto.ChoiceUpdateDto; +import com.c4soft.quiz.web.dto.QuestionDto; +import com.c4soft.quiz.web.dto.QuestionUpdateDto; +import com.c4soft.quiz.web.dto.QuizDto; +import com.c4soft.quiz.web.dto.QuizUpdateDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; import java.net.URI; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; - +import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -24,467 +50,533 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.c4soft.quiz.domain.Choice; -import com.c4soft.quiz.domain.ChoiceRepository; -import com.c4soft.quiz.domain.DraftAlreadyExistsException; -import com.c4soft.quiz.domain.NotADraftException; -import com.c4soft.quiz.domain.Question; -import com.c4soft.quiz.domain.QuestionRepository; -import com.c4soft.quiz.domain.Quiz; -import com.c4soft.quiz.domain.QuizAuthentication; -import com.c4soft.quiz.domain.QuizRejectionDto; -import com.c4soft.quiz.domain.QuizRepository; -import com.c4soft.quiz.domain.SkillTestRepository; -import com.c4soft.quiz.web.dto.ChoiceDto; -import com.c4soft.quiz.web.dto.ChoiceUpdateDto; -import com.c4soft.quiz.web.dto.QuestionDto; -import com.c4soft.quiz.web.dto.QuestionUpdateDto; -import com.c4soft.quiz.web.dto.QuizDto; -import com.c4soft.quiz.web.dto.QuizUpdateDto; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.headers.Header; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotEmpty; -import lombok.RequiredArgsConstructor; - @RestController @RequestMapping(path = "/quizzes") @RequiredArgsConstructor @Validated @Tag(name = "Quizzes") public class QuizController { - private final QuizRepository quizRepo; - private final QuestionRepository questionRepo; - private final ChoiceRepository choiceRepo; - private final SkillTestRepository skillTestRepo; - - @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) - @Transactional(readOnly = true) - public List getQuizList( - @RequestParam(name = "authorLike", required = false) Optional author, - @RequestParam(name = "titleLike", required = false) Optional title) { - final var spec = QuizRepository.searchSpec(author, title); - final var quizzes = quizRepo.findAll(spec); - return quizzes.stream().map(QuizController::toDto).toList(); - } - - @GetMapping(path = "/submitted", produces = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasAuthority('moderator')") - @Transactional(readOnly = true) - public List getSubmittedQuizzes() { - final var quizzes = quizRepo.findByIsSubmitted(true); - return quizzes.stream().map(QuizController::toDto).toList(); - } - - @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasAuthority('trainer')") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "201", headers = @Header(name = HttpHeaders.LOCATION, description = "ID of the created quiz")) }) - public ResponseEntity createQuiz(@RequestBody @Valid QuizUpdateDto dto, QuizAuthentication auth) { - final var quiz = new Quiz(dto.title(), auth.getName()); - quiz.setIsChoicesShuffled(dto.isChoicesShuffled()); - quiz.setIsPerQuestionResult(dto.isPerQuestionResult()); - quiz.setIsReplayEnabled(dto.isReplayEnabled()); - quiz.setIsTrainerNotifiedOfNewTests(dto.isTrainerNotifiedOfNewTests()); - final var created = quizRepo.save(quiz); - return ResponseEntity.created(URI.create("%d".formatted(created.getId()))).build(); - } - - @GetMapping(path = "/{quiz-id}", produces = MediaType.APPLICATION_JSON_VALUE) - @Transactional(readOnly = true) - public QuizDto getQuiz(@Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz) { - return toDto(quiz); - } - - @PutMapping(path = "/{quiz-id}", consumes = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "202") }) - public ResponseEntity updateQuiz( - @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, - @RequestBody @Valid QuizUpdateDto dto, - QuizAuthentication auth) { - updateTitle(quiz, dto.title(), auth); - quiz.setIsChoicesShuffled(dto.isChoicesShuffled()); - quiz.setIsReplayEnabled(dto.isReplayEnabled()); - quiz.setIsPerQuestionResult(dto.isPerQuestionResult()); - quiz.setIsTrainerNotifiedOfNewTests(dto.isTrainerNotifiedOfNewTests()); - quizRepo.save(quiz); - return ResponseEntity.accepted().build(); - } - - void updateTitle(Quiz quiz, String title, QuizAuthentication auth) { - if (Objects.equals(quiz.getTitle(), title)) { - return; - } - if (quiz.getIsPublished() && !auth.isModerator()) { - throw new NotADraftException(quiz.getId()); - } - if (quiz.getReplacedBy() != null) { - throw new NotADraftException(quiz.getId()); - } - quiz.setTitle(title); - } - - @PostMapping(path = "/{quiz-id}/duplicate") - @PreAuthorize("hasAuthority('trainer')") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "201", headers = @Header(name = HttpHeaders.LOCATION, description = "ID of the created quiz")) }) - public ResponseEntity createCopy(@Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, Authentication auth) { - final var draft = new Quiz(quiz, auth.getName()); - final var created = quizRepo.save(draft); - return ResponseEntity.created(URI.create("%d".formatted(created.getId()))).build(); - } - - @PostMapping(path = "/{quiz-id}/draft") - @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "201", headers = @Header(name = HttpHeaders.LOCATION, description = "ID of the created quiz")) }) - public ResponseEntity createDraft(@Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, Authentication auth) { - if (quiz.getDraft() != null) { - throw new DraftAlreadyExistsException(quiz.getId()); - } - if (!quiz.getIsPublished()) { - throw new DraftAlreadyExistsException(quiz.getId()); - } - final var draft = new Quiz(quiz, auth.getName()); - draft.setReplaces(quiz); - final var created = quizRepo.save(draft); - quiz.setDraft(created); - quizRepo.save(quiz); - return ResponseEntity.created(URI.create("%d".formatted(created.getId()))).build(); - } - - @PutMapping(path = "/{quiz-id}/submit") - @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "202") }) - public ResponseEntity submitDraft(@Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, QuizAuthentication auth) { - if (auth.isModerator()) { - return publishDraft(quiz, auth); - } - quiz.setIsSubmitted(!auth.isModerator()); - quizRepo.save(quiz); - return ResponseEntity.accepted().build(); - } - - @PutMapping(path = "/{quiz-id}/publish") - @PreAuthorize("hasAuthority('moderator')") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "202") }) - public ResponseEntity publishDraft(@Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, Authentication auth) { - quiz.setIsSubmitted(false); - quiz.setIsPublished(true); - quiz.setModeratorComment(null); - quiz.setModeratedBy(auth.getName()); - if (quiz.getReplaces() != null) { - final var replaced = quiz.getReplaces(); - replaced.setReplacedBy(quiz); - replaced.setIsPublished(false); - quiz.setReplaces(null); - quizRepo.save(replaced); - } - - quizRepo.save(quiz); - return ResponseEntity.accepted().build(); - } - - @PutMapping(path = "/{quiz-id}/reject", consumes = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasAuthority('moderator')") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "202") }) - public ResponseEntity rejectDraft( - @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, - @Valid QuizRejectionDto dto, - Authentication auth) { - quiz.setIsSubmitted(false); - quiz.setIsPublished(false); - quiz.setModeratorComment(dto.message()); - quiz.setModeratedBy(auth.getName()); - quizRepo.save(quiz); - return ResponseEntity.accepted().build(); - } - - @DeleteMapping(path = "/{quiz-id}") - @PreAuthorize("hasAuthority('moderator') || (hasAuthority('trainer') && #quiz.authorName == authentication.name)") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "202") }) - public ResponseEntity deleteQuiz(@Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz) { - final var draft = quiz.getDraft(); - if (draft != null) { - draft.setReplaces(null); - quiz.setDraft(null); - quizRepo.save(draft); - } - - final var replacedBy = quiz.getReplacedBy(); - if (replacedBy != null) { - replacedBy.setReplaces(null); - quiz.setReplacedBy(null); - quizRepo.save(replacedBy); - } - - final var replaces = quiz.getReplaces(); - if (replaces != null) { - if (replaces.getReplacedBy() != null && Objects.equals(replaces.getReplacedBy().getId(), quiz.getId())) { - replaces.setReplacedBy(null); - } - if (replaces.getDraft().getId() != null && Objects.equals(replaces.getDraft().getId(), quiz.getId())) { - replaces.setDraft(null); - } - quiz.setReplaces(null); - quizRepo.save(replaces); - } - - skillTestRepo.deleteByIdQuizId(quiz.getId()); - questionRepo.deleteAll(quiz.getQuestions()); - quiz.getQuestions().clear(); - - quizRepo.delete(quiz); - return ResponseEntity.accepted().build(); - } - - @PostMapping(path = "/{quiz-id}/questions", consumes = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "201", headers = @Header(name = HttpHeaders.LOCATION, description = "ID of the created question")) }) - public ResponseEntity addQuestion( - @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, - @RequestBody @Valid QuestionUpdateDto dto, - QuizAuthentication auth) { - if (quiz.getReplacedBy() != null) { - throw new NotADraftException(quiz.getId()); - } - if (quiz.getIsPublished() && !auth.isModerator()) { - throw new NotADraftException(quiz.getId()); - } - - final var question = new Question(dto.label(), dto.formattedBody(), quiz.getQuestions().size(), dto.comment()); - quiz.add(question); - final var created = questionRepo.save(question); - return ResponseEntity.created(URI.create("%d".formatted(created.getId()))).build(); - } - - @PutMapping(path = "/{quiz-id}/questions", consumes = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "202") }) - public ResponseEntity updateQuestionsOrder( - @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, - @RequestBody @NotEmpty List questionIds) { - if (quiz.getReplacedBy() != null) { - throw new NotADraftException(quiz.getId()); - } - - if (quiz.getQuestions().size() != questionIds.size()) { - return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).build(); - } - final var quizQuestionIds = quiz.getQuestions().stream().map(Question::getId).collect(Collectors.toSet()); - for (var id : quizQuestionIds) { - if (!questionIds.contains(id)) { - return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).build(); - } - } - quiz.getQuestions().stream().forEach(q -> q.setPriority(questionIds.indexOf(q.getId()))); - quiz = quizRepo.saveAndFlush(quiz); - return ResponseEntity.accepted().build(); - } - - @PutMapping(path = "/{quiz-id}/questions/{question-id}", consumes = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "202") }) - public ResponseEntity updateQuestion( - @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, - @Parameter(schema = @Schema(type = "integer")) @PathVariable("question-id") Long questionId, - @RequestBody @Valid QuestionUpdateDto dto, - QuizAuthentication auth) { - if (quiz.getReplacedBy() != null) { - throw new NotADraftException(quiz.getId()); - } - if (quiz.getIsPublished() && !auth.isModerator()) { - throw new NotADraftException(quiz.getId()); - } - - final var question = quiz.getQuestion(questionId); - if (question == null) { - return ResponseEntity.notFound().build(); - } - question.setComment(dto.comment()); - question.setLabel(dto.label()); - question.setFormattedBody(dto.formattedBody()); - questionRepo.save(question); - return ResponseEntity.accepted().build(); - } - - @DeleteMapping(path = "/{quiz-id}/questions/{question-id}") - @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "202") }) - public ResponseEntity deleteQuestion( - @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, - @Parameter(schema = @Schema(type = "integer")) @PathVariable("question-id") Long questionId, - QuizAuthentication auth) { - if (quiz.getReplacedBy() != null) { - throw new NotADraftException(quiz.getId()); - } - - final var question = quiz.getQuestion(questionId); - if (question == null) { - return ResponseEntity.notFound().build(); - } - - final var tests = skillTestRepo.findByIdQuizId(quiz.getId()); - tests.forEach(t -> { - final var filtered = t.getChoices().stream().filter(c -> { - return c.getQuestion().getId() != questionId; - }).toList(); - t.setChoices(filtered); - }); - skillTestRepo.saveAll(tests); - - quiz.remove(question); - quizRepo.save(quiz); - choiceRepo.deleteAll(question.getChoices()); - questionRepo.delete(question); - return ResponseEntity.accepted().build(); - } - - @PostMapping(path = "/{quiz-id}/questions/{question-id}/choices", consumes = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "201", headers = @Header(name = HttpHeaders.LOCATION, description = "ID of the created choice")) }) - public ResponseEntity addChoice( - @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, - @Parameter(schema = @Schema(type = "integer")) @PathVariable("question-id") Long questionId, - @RequestBody @Valid ChoiceUpdateDto dto, - QuizAuthentication auth) { - if (quiz.getReplacedBy() != null) { - throw new NotADraftException(quiz.getId()); - } - if (quiz.getIsPublished() && !auth.isModerator()) { - throw new NotADraftException(quiz.getId()); - } - - final var question = quiz.getQuestion(questionId); - if (question == null) { - return ResponseEntity.notFound().build(); - } - final var choice = new Choice(dto.label(), dto.isGood()); - question.add(choice); - final var created = choiceRepo.save(choice); - return ResponseEntity.created(URI.create("%d".formatted(created.getId()))).build(); - } - - @PutMapping(path = "/{quiz-id}/questions/{question-id}/choices/{choice-id}", consumes = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "202") }) - public ResponseEntity updateChoice( - @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, - @Parameter(schema = @Schema(type = "integer")) @PathVariable("question-id") Long questionId, - @Parameter(schema = @Schema(type = "integer")) @PathVariable("choice-id") Long choiceId, - @RequestBody @Valid ChoiceUpdateDto dto, - QuizAuthentication auth) { - if (quiz.getReplacedBy() != null) { - throw new NotADraftException(quiz.getId()); - } - if (quiz.getIsPublished() && !auth.isModerator()) { - throw new NotADraftException(quiz.getId()); - } - - final var question = quiz.getQuestion(questionId); - if (question == null) { - return ResponseEntity.notFound().build(); - } - final var choice = question.getChoice(choiceId); - if (choice == null) { - return ResponseEntity.notFound().build(); - } - if (!Objects.equals(dto.label(), choice.getLabel()) && !auth.isModerator() && (quiz.getIsPublished() || quiz.getReplacedBy() != null)) { - throw new NotADraftException(quiz.getId()); - } - choice.setIsGood(dto.isGood()); - choice.setLabel(dto.label()); - choiceRepo.save(choice); - return ResponseEntity.accepted().build(); - } - - @DeleteMapping(path = "/{quiz-id}/questions/{question-id}/choices/{choice-id}") - @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "202") }) - public ResponseEntity deleteChoice( - @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, - @Parameter(schema = @Schema(type = "integer")) @PathVariable("question-id") Long questionId, - @Parameter(schema = @Schema(type = "integer")) @PathVariable("choice-id") Long choiceId, - QuizAuthentication auth) { - if (quiz.getReplacedBy() != null) { - throw new NotADraftException(quiz.getId()); - } - - final var question = quiz.getQuestion(questionId); - if (question == null) { - return ResponseEntity.notFound().build(); - } - final var choice = question.getChoice(choiceId); - if (choice == null) { - return ResponseEntity.notFound().build(); - } - - final var tests = skillTestRepo.findByIdQuizId(quiz.getId()); - tests.forEach(t -> { - final var filtered = t.getChoices().stream().filter(c -> { - return c.getId() != choiceId; - }).toList(); - t.setChoices(filtered); - }); - skillTestRepo.saveAll(tests); - - question.remove(choice); - choiceRepo.delete(choice); - questionRepo.save(question); - return ResponseEntity.accepted().build(); - } - - private static QuizDto toDto(Quiz q) { - return q == null - ? null - : new QuizDto( - q.getId(), - q.getTitle(), - q.getQuestions().stream().sorted((a, b) -> a.getPriority() - b.getPriority()).map(QuizController::toDto).toList(), - q.getAuthorName(), - q.getIsPublished(), - q.getIsSubmitted(), - q.getReplacedBy() != null, - q.getIsChoicesShuffled(), - q.getIsReplayEnabled(), - q.getIsPerQuestionResult(), - q.getIsTrainerNotifiedOfNewTests(), - q.getModeratorComment(), - q.getDraft() == null ? null : q.getDraft().getId(), - q.getReplaces() == null ? null : q.getReplaces().getId()); - } - - private static QuestionDto toDto(Question q) { - return q == null - ? null - : new QuestionDto( - q.getQuiz().getId(), - q.getId(), - q.getLabel(), - q.getFormattedBody(), - q.getChoices().stream().map(QuizController::toDto).toList(), - q.getComment()); - } - - private static ChoiceDto toDto(Choice c) { - return c == null ? null : new ChoiceDto(c.getQuestion().getQuiz().getId(), c.getQuestion().getId(), c.getId(), c.getLabel(), c.getIsGood()); - } + private final QuizRepository quizRepo; + private final QuestionRepository questionRepo; + private final ChoiceRepository choiceRepo; + private final SkillTestRepository skillTestRepo; + + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + @Transactional(readOnly = true) + public List getQuizList( + @RequestParam(name = "authorLike", required = false) Optional author, + @RequestParam(name = "titleLike", required = false) Optional title) { + final var spec = QuizRepository.searchSpec(author, title); + final var quizzes = quizRepo.findAll(spec); + return quizzes.stream().map(QuizController::toDto).toList(); + } + + @GetMapping(path = "/submitted", produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('moderator')") + @Transactional(readOnly = true) + public List getSubmittedQuizzes() { + final var quizzes = quizRepo.findByIsSubmitted(true); + return quizzes.stream().map(QuizController::toDto).toList(); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('trainer')") + @Transactional(readOnly = false) + @Operation( + responses = { + @ApiResponse( + responseCode = "201", + headers = + @Header( + name = HttpHeaders.LOCATION, + description = "ID of the created quiz")) + }) + public ResponseEntity createQuiz( + @RequestBody @Valid QuizUpdateDto dto, QuizAuthentication auth) { + final var quiz = new Quiz(dto.title(), auth.getName()); + quiz.setIsChoicesShuffled(dto.isChoicesShuffled()); + quiz.setIsPerQuestionResult(dto.isPerQuestionResult()); + quiz.setIsReplayEnabled(dto.isReplayEnabled()); + quiz.setIsTrainerNotifiedOfNewTests(dto.isTrainerNotifiedOfNewTests()); + final var created = quizRepo.save(quiz); + return ResponseEntity.created(URI.create("%d".formatted(created.getId()))).build(); + } + + @GetMapping(path = "/{quiz-id}", produces = MediaType.APPLICATION_JSON_VALUE) + @Transactional(readOnly = true) + public QuizDto getQuiz( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz) { + return toDto(quiz); + } + + @PutMapping(path = "/{quiz-id}", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") + @Transactional(readOnly = false) + @Operation(responses = {@ApiResponse(responseCode = "202")}) + public ResponseEntity updateQuiz( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + @RequestBody @Valid QuizUpdateDto dto, + QuizAuthentication auth) { + updateTitle(quiz, dto.title(), auth); + quiz.setIsChoicesShuffled(dto.isChoicesShuffled()); + quiz.setIsReplayEnabled(dto.isReplayEnabled()); + quiz.setIsPerQuestionResult(dto.isPerQuestionResult()); + quiz.setIsTrainerNotifiedOfNewTests(dto.isTrainerNotifiedOfNewTests()); + quizRepo.save(quiz); + return ResponseEntity.accepted().build(); + } + + void updateTitle(Quiz quiz, String title, QuizAuthentication auth) { + if (Objects.equals(quiz.getTitle(), title)) { + return; + } + if (quiz.getIsPublished() && !auth.isModerator()) { + throw new NotADraftException(quiz.getId()); + } + if (quiz.getReplacedBy() != null) { + throw new NotADraftException(quiz.getId()); + } + quiz.setTitle(title); + } + + @PostMapping(path = "/{quiz-id}/duplicate") + @PreAuthorize("hasAuthority('trainer')") + @Transactional(readOnly = false) + @Operation( + responses = { + @ApiResponse( + responseCode = "201", + headers = + @Header( + name = HttpHeaders.LOCATION, + description = "ID of the created quiz")) + }) + public ResponseEntity createCopy( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + Authentication auth) { + final var draft = new Quiz(quiz, auth.getName()); + final var created = quizRepo.save(draft); + return ResponseEntity.created(URI.create("%d".formatted(created.getId()))).build(); + } + + @PostMapping(path = "/{quiz-id}/draft") + @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") + @Transactional(readOnly = false) + @Operation( + responses = { + @ApiResponse( + responseCode = "201", + headers = + @Header( + name = HttpHeaders.LOCATION, + description = "ID of the created quiz")) + }) + public ResponseEntity createDraft( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + Authentication auth) { + if (quiz.getDraft() != null) { + throw new DraftAlreadyExistsException(quiz.getId()); + } + if (!quiz.getIsPublished()) { + throw new DraftAlreadyExistsException(quiz.getId()); + } + final var draft = new Quiz(quiz, auth.getName()); + draft.setReplaces(quiz); + final var created = quizRepo.save(draft); + quiz.setDraft(created); + quizRepo.save(quiz); + return ResponseEntity.created(URI.create("%d".formatted(created.getId()))).build(); + } + + @PutMapping(path = "/{quiz-id}/submit") + @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") + @Transactional(readOnly = false) + @Operation(responses = {@ApiResponse(responseCode = "202")}) + public ResponseEntity submitDraft( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + QuizAuthentication auth) { + if (auth.isModerator()) { + return publishDraft(quiz, auth); + } + quiz.setIsSubmitted(!auth.isModerator()); + quizRepo.save(quiz); + return ResponseEntity.accepted().build(); + } + + @PutMapping(path = "/{quiz-id}/publish") + @PreAuthorize("hasAuthority('moderator')") + @Transactional(readOnly = false) + @Operation(responses = {@ApiResponse(responseCode = "202")}) + public ResponseEntity publishDraft( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + Authentication auth) { + quiz.setIsSubmitted(false); + quiz.setIsPublished(true); + quiz.setModeratorComment(null); + quiz.setModeratedBy(auth.getName()); + if (quiz.getReplaces() != null) { + final var replaced = quiz.getReplaces(); + replaced.setReplacedBy(quiz); + replaced.setIsPublished(false); + quiz.setReplaces(null); + quizRepo.save(replaced); + } + + quizRepo.save(quiz); + return ResponseEntity.accepted().build(); + } + + @PutMapping(path = "/{quiz-id}/reject", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('moderator')") + @Transactional(readOnly = false) + @Operation(responses = {@ApiResponse(responseCode = "202")}) + public ResponseEntity rejectDraft( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + @Valid QuizRejectionDto dto, + Authentication auth) { + quiz.setIsSubmitted(false); + quiz.setIsPublished(false); + quiz.setModeratorComment(dto.message()); + quiz.setModeratedBy(auth.getName()); + quizRepo.save(quiz); + return ResponseEntity.accepted().build(); + } + + @DeleteMapping(path = "/{quiz-id}") + @PreAuthorize( + "hasAuthority('moderator') || (hasAuthority('trainer') && #quiz.authorName ==" + + " authentication.name)") + @Transactional(readOnly = false) + @Operation(responses = {@ApiResponse(responseCode = "202")}) + public ResponseEntity deleteQuiz( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz) { + final var draft = quiz.getDraft(); + if (draft != null) { + draft.setReplaces(null); + quiz.setDraft(null); + quizRepo.save(draft); + } + + final var replacedBy = quiz.getReplacedBy(); + if (replacedBy != null) { + replacedBy.setReplaces(null); + quiz.setReplacedBy(null); + quizRepo.save(replacedBy); + } + + final var replaces = quiz.getReplaces(); + if (replaces != null) { + if (replaces.getReplacedBy() != null + && Objects.equals(replaces.getReplacedBy().getId(), quiz.getId())) { + replaces.setReplacedBy(null); + } + if (replaces.getDraft().getId() != null + && Objects.equals(replaces.getDraft().getId(), quiz.getId())) { + replaces.setDraft(null); + } + quiz.setReplaces(null); + quizRepo.save(replaces); + } + + skillTestRepo.deleteByIdQuizId(quiz.getId()); + questionRepo.deleteAll(quiz.getQuestions()); + quiz.getQuestions().clear(); + + quizRepo.delete(quiz); + return ResponseEntity.accepted().build(); + } + + @PostMapping(path = "/{quiz-id}/questions", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") + @Transactional(readOnly = false) + @Operation( + responses = { + @ApiResponse( + responseCode = "201", + headers = + @Header( + name = HttpHeaders.LOCATION, + description = "ID of the created question")) + }) + public ResponseEntity addQuestion( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + @RequestBody @Valid QuestionUpdateDto dto, + QuizAuthentication auth) { + if (quiz.getReplacedBy() != null) { + throw new NotADraftException(quiz.getId()); + } + if (quiz.getIsPublished() && !auth.isModerator()) { + throw new NotADraftException(quiz.getId()); + } + + final var question = + new Question( + dto.label(), + dto.formattedBody(), + quiz.getQuestions().size(), + dto.comment()); + quiz.add(question); + final var created = questionRepo.save(question); + return ResponseEntity.created(URI.create("%d".formatted(created.getId()))).build(); + } + + @PutMapping(path = "/{quiz-id}/questions", consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") + @Transactional(readOnly = false) + @Operation(responses = {@ApiResponse(responseCode = "202")}) + public ResponseEntity updateQuestionsOrder( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + @RequestBody @NotEmpty List questionIds) { + if (quiz.getReplacedBy() != null) { + throw new NotADraftException(quiz.getId()); + } + + if (quiz.getQuestions().size() != questionIds.size()) { + return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).build(); + } + final var quizQuestionIds = + quiz.getQuestions().stream().map(Question::getId).collect(Collectors.toSet()); + for (var id : quizQuestionIds) { + if (!questionIds.contains(id)) { + return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).build(); + } + } + quiz.getQuestions().stream().forEach(q -> q.setPriority(questionIds.indexOf(q.getId()))); + quiz = quizRepo.saveAndFlush(quiz); + return ResponseEntity.accepted().build(); + } + + @PutMapping( + path = "/{quiz-id}/questions/{question-id}", + consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") + @Transactional(readOnly = false) + @Operation(responses = {@ApiResponse(responseCode = "202")}) + public ResponseEntity updateQuestion( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + @Parameter(schema = @Schema(type = "integer")) @PathVariable("question-id") + Long questionId, + @RequestBody @Valid QuestionUpdateDto dto, + QuizAuthentication auth) { + if (quiz.getReplacedBy() != null) { + throw new NotADraftException(quiz.getId()); + } + if (quiz.getIsPublished() && !auth.isModerator()) { + throw new NotADraftException(quiz.getId()); + } + + final var question = quiz.getQuestion(questionId); + if (question == null) { + return ResponseEntity.notFound().build(); + } + question.setComment(dto.comment()); + question.setLabel(dto.label()); + question.setFormattedBody(dto.formattedBody()); + questionRepo.save(question); + return ResponseEntity.accepted().build(); + } + + @DeleteMapping(path = "/{quiz-id}/questions/{question-id}") + @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") + @Transactional(readOnly = false) + @Operation(responses = {@ApiResponse(responseCode = "202")}) + public ResponseEntity deleteQuestion( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + @Parameter(schema = @Schema(type = "integer")) @PathVariable("question-id") + Long questionId, + QuizAuthentication auth) { + if (quiz.getReplacedBy() != null) { + throw new NotADraftException(quiz.getId()); + } + + final var question = quiz.getQuestion(questionId); + if (question == null) { + return ResponseEntity.notFound().build(); + } + + final var tests = skillTestRepo.findByIdQuizId(quiz.getId()); + tests.forEach( + t -> { + final var filtered = + t.getChoices().stream() + .filter( + c -> { + return c.getQuestion().getId() != questionId; + }) + .toList(); + t.setChoices(filtered); + }); + skillTestRepo.saveAll(tests); + + quiz.remove(question); + quizRepo.save(quiz); + choiceRepo.deleteAll(question.getChoices()); + questionRepo.delete(question); + return ResponseEntity.accepted().build(); + } + + @PostMapping( + path = "/{quiz-id}/questions/{question-id}/choices", + consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") + @Transactional(readOnly = false) + @Operation( + responses = { + @ApiResponse( + responseCode = "201", + headers = + @Header( + name = HttpHeaders.LOCATION, + description = "ID of the created choice")) + }) + public ResponseEntity addChoice( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + @Parameter(schema = @Schema(type = "integer")) @PathVariable("question-id") + Long questionId, + @RequestBody @Valid ChoiceUpdateDto dto, + QuizAuthentication auth) { + if (quiz.getReplacedBy() != null) { + throw new NotADraftException(quiz.getId()); + } + if (quiz.getIsPublished() && !auth.isModerator()) { + throw new NotADraftException(quiz.getId()); + } + + final var question = quiz.getQuestion(questionId); + if (question == null) { + return ResponseEntity.notFound().build(); + } + final var choice = new Choice(dto.label(), dto.isGood()); + question.add(choice); + final var created = choiceRepo.save(choice); + return ResponseEntity.created(URI.create("%d".formatted(created.getId()))).build(); + } + + @PutMapping( + path = "/{quiz-id}/questions/{question-id}/choices/{choice-id}", + consumes = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") + @Transactional(readOnly = false) + @Operation(responses = {@ApiResponse(responseCode = "202")}) + public ResponseEntity updateChoice( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + @Parameter(schema = @Schema(type = "integer")) @PathVariable("question-id") + Long questionId, + @Parameter(schema = @Schema(type = "integer")) @PathVariable("choice-id") Long choiceId, + @RequestBody @Valid ChoiceUpdateDto dto, + QuizAuthentication auth) { + if (quiz.getReplacedBy() != null) { + throw new NotADraftException(quiz.getId()); + } + if (quiz.getIsPublished() && !auth.isModerator()) { + throw new NotADraftException(quiz.getId()); + } + + final var question = quiz.getQuestion(questionId); + if (question == null) { + return ResponseEntity.notFound().build(); + } + final var choice = question.getChoice(choiceId); + if (choice == null) { + return ResponseEntity.notFound().build(); + } + if (!Objects.equals(dto.label(), choice.getLabel()) + && !auth.isModerator() + && (quiz.getIsPublished() || quiz.getReplacedBy() != null)) { + throw new NotADraftException(quiz.getId()); + } + choice.setIsGood(dto.isGood()); + choice.setLabel(dto.label()); + choiceRepo.save(choice); + return ResponseEntity.accepted().build(); + } + + @DeleteMapping(path = "/{quiz-id}/questions/{question-id}/choices/{choice-id}") + @PreAuthorize("hasAuthority('trainer') && #quiz.authorName == authentication.name") + @Transactional(readOnly = false) + @Operation(responses = {@ApiResponse(responseCode = "202")}) + public ResponseEntity deleteChoice( + @Parameter(schema = @Schema(type = "integer")) @PathVariable("quiz-id") Quiz quiz, + @Parameter(schema = @Schema(type = "integer")) @PathVariable("question-id") + Long questionId, + @Parameter(schema = @Schema(type = "integer")) @PathVariable("choice-id") Long choiceId, + QuizAuthentication auth) { + if (quiz.getReplacedBy() != null) { + throw new NotADraftException(quiz.getId()); + } + + final var question = quiz.getQuestion(questionId); + if (question == null) { + return ResponseEntity.notFound().build(); + } + final var choice = question.getChoice(choiceId); + if (choice == null) { + return ResponseEntity.notFound().build(); + } + + final var tests = skillTestRepo.findByIdQuizId(quiz.getId()); + tests.forEach( + t -> { + final var filtered = + t.getChoices().stream() + .filter( + c -> { + return c.getId() != choiceId; + }) + .toList(); + t.setChoices(filtered); + }); + skillTestRepo.saveAll(tests); + + question.remove(choice); + choiceRepo.delete(choice); + questionRepo.save(question); + return ResponseEntity.accepted().build(); + } + + private static QuizDto toDto(Quiz q) { + return q == null + ? null + : new QuizDto( + q.getId(), + q.getTitle(), + q.getQuestions().stream() + .sorted((a, b) -> a.getPriority() - b.getPriority()) + .map(QuizController::toDto) + .toList(), + q.getAuthorName(), + q.getIsPublished(), + q.getIsSubmitted(), + q.getReplacedBy() != null, + q.getIsChoicesShuffled(), + q.getIsReplayEnabled(), + q.getIsPerQuestionResult(), + q.getIsTrainerNotifiedOfNewTests(), + q.getModeratorComment(), + q.getDraft() == null ? null : q.getDraft().getId(), + q.getReplaces() == null ? null : q.getReplaces().getId()); + } + + private static QuestionDto toDto(Question q) { + return q == null + ? null + : new QuestionDto( + q.getQuiz().getId(), + q.getId(), + q.getLabel(), + q.getFormattedBody(), + q.getChoices().stream().map(QuizController::toDto).toList(), + q.getComment()); + } + + private static ChoiceDto toDto(Choice c) { + return c == null + ? null + : new ChoiceDto( + c.getQuestion().getQuiz().getId(), + c.getQuestion().getId(), + c.getId(), + c.getLabel(), + c.getIsGood()); + } } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/SkillTestController.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/SkillTestController.java index fb5f5c6..c2b9685 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/SkillTestController.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/SkillTestController.java @@ -1,3 +1,4 @@ +/* (C)2024 */ package com.c4soft.quiz.web; import java.net.URI; @@ -6,7 +7,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; - +import org.keycloak.admin.api.UsersApi; +import org.keycloak.admin.model.UserRepresentation; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -23,21 +25,19 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; - import com.c4soft.quiz.domain.Choice; import com.c4soft.quiz.domain.Quiz; import com.c4soft.quiz.domain.QuizAuthentication; -import com.c4soft.quiz.domain.QuizRepository; import com.c4soft.quiz.domain.SkillTest; import com.c4soft.quiz.domain.SkillTest.SkillTestPk; -import com.c4soft.quiz.domain.SkillTestRepository; -import com.c4soft.quiz.feign.KeycloakAdminApiClient; -import com.c4soft.quiz.feign.KeycloakAdminApiClient.UserRepresentation; +import com.c4soft.quiz.domain.exception.InvalidQuizException; +import com.c4soft.quiz.domain.exception.QuizAlreadyHasAnAnswerException; +import com.c4soft.quiz.domain.jpa.QuizRepository; +import com.c4soft.quiz.domain.jpa.SkillTestRepository; import com.c4soft.quiz.web.dto.SkillTestDto; import com.c4soft.quiz.web.dto.SkillTestQuestionDto; import com.c4soft.quiz.web.dto.SkillTestResultDetailsDto; import com.c4soft.quiz.web.dto.SkillTestResultPreviewDto; - import feign.FeignException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -56,181 +56,181 @@ @Tag(name = "SkillTest") @Log4j2 public class SkillTestController { - private final SkillTestRepository testRepo; - private final QuizRepository quizRepo; - private final KeycloakAdminApiClient keycloakAdminApi; - private final JavaMailSender mailSender; - @Value("${ui-external-uri}") - URI uiUri; - - @GetMapping(path = "/{quizId}", produces = MediaType.APPLICATION_JSON_VALUE) - @Transactional(readOnly = true) - @Operation(description = "Returns the answers to a quiz, by default for all trainees over the last 2 weeks") - public List getSkillTestList( - @PathVariable(value = "quizId", required = true) Long quizId, - @RequestParam(value = "since", required = false) Optional since, - @RequestParam(value = "until", required = false) Optional until) { - final var resolvedSince = since.map(sec -> 1000 * sec).orElse(Instant.now().minus(14, ChronoUnit.DAYS).toEpochMilli()); - final var tests = testRepo.findAll(SkillTestRepository.spec(quizId, resolvedSince, until.map(sec -> 1000 * sec))).stream(); - return tests.map(this::toPreviewDto).toList(); - } - - @GetMapping(path = "/{quizId}/{traineeName}", produces = MediaType.APPLICATION_JSON_VALUE) - @Transactional(readOnly = true) - @Operation(description = "Returns the answers to a quiz, by default for all trainees over the last 2 weeks") - public SkillTestResultDetailsDto getSkillTest( - @PathVariable(value = "quizId", required = true) Long quizId, - @PathVariable(value = "traineeName", required = true) String traineeName) { - final var skillTest = testRepo.findById(new SkillTestPk(quizId, traineeName)); - if (skillTest.isEmpty()) { - throw new EntityNotFoundException("No skill-test for quiz %d and trainee %s".formatted(quizId, traineeName)); - } - return toDetailsDto(skillTest.get()); - } - - @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("isAuthenticated()") - @Transactional(readOnly = false) - @Operation(responses = { @ApiResponse(responseCode = "202") }) - public ResponseEntity submitSkillTest(@RequestBody @Valid SkillTestDto dto, QuizAuthentication auth) { - final var quiz = quizRepo.findById(dto.quizId()).orElseThrow(() -> new InvalidQuizException(dto.quizId())); - if (!quiz.getIsPublished()) { - throw new InvalidQuizException(dto.quizId()); - } - if (!quiz.getIsReplayEnabled() && testRepo.findByIdQuizIdAndIdTraineeName(dto.quizId(), auth.getName()).isPresent()) { - throw new QuizAlreadyHasAnAnswerException(dto.quizId(), auth.getName()); - } - final var traineeChoices = new ArrayList(); - for (var question : quiz.getQuestions()) { - final var questionDto = dto.getQuestion(question.getId()); - for (var choice : question.getChoices()) { - if (questionDto.choices().contains(choice.getId())) { - traineeChoices.add(choice); - } - } - } - final var test = testRepo.findByIdQuizIdAndIdTraineeName(quiz.getId(), auth.getName()).orElse(new SkillTest(quiz, auth.getName(), traineeChoices)); - test.setSubmittedOn(Instant.now().toEpochMilli()); - test.setChoices(traineeChoices); - final var saved = testRepo.save(test); - - final var testUri = "%s/tests/%d/%s".formatted(uiUri, quiz.getId(), auth.getName()); - try { - SimpleMailMessage message = new SimpleMailMessage(); - message.setFrom("noreply@c4-soft.com"); - message.setTo(auth.getAttributes().getEmail()); - message.setSubject("C4 - Quiz: your answer to %s".formatted(quiz.getTitle())); - message.setText(testUri); - mailSender.send(message); - - if (quiz.getIsTrainerNotifiedOfNewTests()) { - final var authors = keycloakAdminApi.getUser(quiz.getAuthorName(), true); - if (authors.size() == 1) { - message.setTo(authors.get(0).getEmail()); - message.setSubject("C4 - Quiz: New answer to %s by %s".formatted(quiz.getTitle(), auth.getName())); - mailSender.send(message); - } - } - } catch (Exception e) { - log.error(e); - } - - return ResponseEntity.accepted().location(URI.create(testUri)).body(toPreviewDto(saved)); - } - - @DeleteMapping(path = "/{quizId}/{traineeName}") - @PreAuthorize("authentication.name == #quiz.authorName") - @Transactional(readOnly = false) - @Operation( - responses = { @ApiResponse(responseCode = "202") }, - description = "Deletes the answer to given quiz for given trainee. Only the author of a quiz can delete skill-tests.") - public ResponseEntity deleteSkillTest( - @Parameter(schema = @Schema(type = "integer")) @PathVariable(value = "quizId", required = true) Quiz quiz, - @PathVariable(value = "traineeName", required = true) String traineeName) { - testRepo.deleteById(new SkillTestPk(quiz.getId(), traineeName)); - return ResponseEntity.accepted().build(); - } - - private SkillTestResultPreviewDto toPreviewDto(SkillTest traineeAnswer) { - var score = 0; - var totalChoices = 0; - final var quiz = quizRepo - .findById(traineeAnswer.getId().getQuizId()) - .orElseThrow(() -> new EntityNotFoundException("Unknown quiz: %d".formatted(traineeAnswer.getId().getQuizId()))); - final var testDto = new SkillTestDto(traineeAnswer.getId().getQuizId(), new ArrayList<>()); - for (var question : quiz.getQuestions()) { - final var traineeQuestionChoices = traineeAnswer.getChoices(question.getId()); - final var questionDto = new SkillTestQuestionDto(question.getId(), new ArrayList<>()); - testDto.questions().add(questionDto); - for (var choice : question.getChoices()) { - totalChoices += 1; - if (traineeQuestionChoices.contains(choice)) { - questionDto.choices().add(choice.getId()); - score += choice.getIsGood() ? 1 : -1; - } else { - score += choice.getIsGood() ? 0 : 1; - } - } - } - - return new SkillTestResultPreviewDto(traineeAnswer.getId().getTraineeName(), totalChoices == 0 ? null : 100.0 * score / totalChoices); - } - - private SkillTestResultDetailsDto toDetailsDto(SkillTest traineeAnswer) { - var score = 0; - var totalChoices = 0; - final var quiz = quizRepo - .findById(traineeAnswer.getId().getQuizId()) - .orElseThrow(() -> new EntityNotFoundException("Unknown quiz: %d".formatted(traineeAnswer.getId().getQuizId()))); - final var testDto = new SkillTestDto(traineeAnswer.getId().getQuizId(), new ArrayList<>()); - for (var question : quiz.getQuestions()) { - final var traineeQuestionChoices = traineeAnswer.getChoices(question.getId()); - final var questionDto = new SkillTestQuestionDto(question.getId(), new ArrayList<>()); - testDto.questions().add(questionDto); - for (var choice : question.getChoices()) { - totalChoices += 1; - if (traineeQuestionChoices.contains(choice)) { - questionDto.choices().add(choice.getId()); - score += choice.getIsGood() ? 1 : -1; - } else { - score += choice.getIsGood() ? 0 : 1; - } - } - } - List users = List.of(); - try { - users = keycloakAdminApi.getUser(traineeAnswer.getId().getTraineeName(), true); - } catch (FeignException e) { - log.error("Failed to fetch trainee data", e); - } - final var email = users.size() == 1 ? users.get(0).getEmail() : ""; - final var firstName = users.size() == 1 ? users.get(0).getFirtsName() : ""; - final var lastName = users.size() == 1 ? users.get(0).getLastName() : ""; - return new SkillTestResultDetailsDto( - testDto, - traineeAnswer.getId().getTraineeName(), - firstName, - lastName, - email, - totalChoices == 0 ? null : 100.0 * score / totalChoices); - } - - static class InvalidQuizException extends RuntimeException { - private static final long serialVersionUID = 8816930385638385805L; - - InvalidQuizException(Long quizId) { - super("Quiz %d doesn't accept answers anymore.".formatted(quizId)); - } - } - - static class QuizAlreadyHasAnAnswerException extends RuntimeException { - - private static final long serialVersionUID = 6171083302507116601L; - - QuizAlreadyHasAnAnswerException(Long quizId, String traineeName) { - super( - "Quiz %d already has an answer for %s and doesn't accept replay. Ask the trainer to delete the answer before submitting a new one." - .formatted(quizId, traineeName)); - } - } + private final SkillTestRepository testRepo; + private final QuizRepository quizRepo; + private final UsersApi keycloakUsersApi; + private final JavaMailSender mailSender; + + @Value("${ui-external-uri}") + URI uiUri; + + @GetMapping(path = "/{quizId}", produces = MediaType.APPLICATION_JSON_VALUE) + @Transactional(readOnly = true) + @Operation( + description = "Returns the answers to a quiz, by default for all trainees over the last 2" + + " weeks") + public List getSkillTestList( + @PathVariable(value = "quizId", required = true) Long quizId, + @RequestParam(value = "since", required = false) Optional since, + @RequestParam(value = "until", required = false) Optional until) { + final var resolvedSince = since.map(sec -> 1000 * sec) + .orElse(Instant.now().minus(14, ChronoUnit.DAYS).toEpochMilli()); + final var tests = testRepo + .findAll(SkillTestRepository.spec(quizId, resolvedSince, until.map(sec -> 1000 * sec))) + .stream(); + return tests.map(this::toPreviewDto).toList(); + } + + @GetMapping(path = "/{quizId}/{traineeName}", produces = MediaType.APPLICATION_JSON_VALUE) + @Transactional(readOnly = true) + @Operation( + description = "Returns the answers to a quiz, by default for all trainees over the last 2" + + " weeks") + public SkillTestResultDetailsDto getSkillTest( + @PathVariable(value = "quizId", required = true) Long quizId, + @PathVariable(value = "traineeName", required = true) String traineeName) { + final var skillTest = testRepo.findById(new SkillTestPk(quizId, traineeName)); + if (skillTest.isEmpty()) { + throw new EntityNotFoundException( + "No skill-test for quiz %d and trainee %s".formatted(quizId, traineeName)); + } + return toDetailsDto(skillTest.get()); + } + + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + @PreAuthorize("isAuthenticated()") + @Transactional(readOnly = false) + @Operation(responses = {@ApiResponse(responseCode = "202")}) + public ResponseEntity submitSkillTest( + @RequestBody @Valid SkillTestDto dto, QuizAuthentication auth) { + final var quiz = + quizRepo.findById(dto.quizId()).orElseThrow(() -> new InvalidQuizException(dto.quizId())); + if (!quiz.getIsPublished()) { + throw new InvalidQuizException(dto.quizId()); + } + if (!quiz.getIsReplayEnabled() + && testRepo.findByIdQuizIdAndIdTraineeName(dto.quizId(), auth.getName()).isPresent()) { + throw new QuizAlreadyHasAnAnswerException(dto.quizId(), auth.getName()); + } + final var traineeChoices = new ArrayList(); + for (var question : quiz.getQuestions()) { + final var questionDto = dto.getQuestion(question.getId()); + for (var choice : question.getChoices()) { + if (questionDto.choices().contains(choice.getId())) { + traineeChoices.add(choice); + } + } + } + final var test = testRepo.findByIdQuizIdAndIdTraineeName(quiz.getId(), auth.getName()) + .orElse(new SkillTest(quiz, auth.getName(), traineeChoices)); + test.setSubmittedOn(Instant.now().toEpochMilli()); + test.setChoices(traineeChoices); + final var saved = testRepo.save(test); + + final var testUri = "%s/tests/%d/%s".formatted(uiUri, quiz.getId(), auth.getName()); + try { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom("noreply@c4-soft.com"); + message.setTo(auth.getAttributes().getEmail()); + message.setSubject("C4 - Quiz: your answer to %s".formatted(quiz.getTitle())); + message.setText(testUri); + mailSender.send(message); + + if (quiz.getIsTrainerNotifiedOfNewTests()) { + final var authors = getUsers(quiz.getAuthorName()); + if (authors.size() == 1) { + message.setTo(authors.get(0).getEmail()); + message.setSubject( + "C4 - Quiz: New answer to %s by %s".formatted(quiz.getTitle(), auth.getName())); + mailSender.send(message); + } + } + } catch (Exception e) { + log.error(e); + } + + return ResponseEntity.accepted().location(URI.create(testUri)).body(toPreviewDto(saved)); + } + + @DeleteMapping(path = "/{quizId}/{traineeName}") + @PreAuthorize("authentication.name == #quiz.authorName") + @Transactional(readOnly = false) + @Operation(responses = {@ApiResponse(responseCode = "202")}, + description = "Deletes the answer to given quiz for given trainee. Only the author of a quiz" + + " can delete skill-tests.") + public ResponseEntity deleteSkillTest( + @Parameter(schema = @Schema(type = "integer")) @PathVariable(value = "quizId", + required = true) Quiz quiz, + @PathVariable(value = "traineeName", required = true) String traineeName) { + testRepo.deleteById(new SkillTestPk(quiz.getId(), traineeName)); + return ResponseEntity.accepted().build(); + } + + private SkillTestResultPreviewDto toPreviewDto(SkillTest traineeAnswer) { + var score = 0; + var totalChoices = 0; + final var quiz = quizRepo.findById(traineeAnswer.getId().getQuizId()) + .orElseThrow(() -> new EntityNotFoundException( + "Unknown quiz: %d".formatted(traineeAnswer.getId().getQuizId()))); + final var testDto = new SkillTestDto(traineeAnswer.getId().getQuizId(), new ArrayList<>()); + for (var question : quiz.getQuestions()) { + final var traineeQuestionChoices = traineeAnswer.getChoices(question.getId()); + final var questionDto = new SkillTestQuestionDto(question.getId(), new ArrayList<>()); + testDto.questions().add(questionDto); + for (var choice : question.getChoices()) { + totalChoices += 1; + if (traineeQuestionChoices.contains(choice)) { + questionDto.choices().add(choice.getId()); + score += choice.getIsGood() ? 1 : -1; + } else { + score += choice.getIsGood() ? 0 : 1; + } + } + } + + return new SkillTestResultPreviewDto(traineeAnswer.getId().getTraineeName(), + totalChoices == 0 ? null : 100.0 * score / totalChoices); + } + + private SkillTestResultDetailsDto toDetailsDto(SkillTest traineeAnswer) { + var score = 0; + var totalChoices = 0; + final var quiz = quizRepo.findById(traineeAnswer.getId().getQuizId()) + .orElseThrow(() -> new EntityNotFoundException( + "Unknown quiz: %d".formatted(traineeAnswer.getId().getQuizId()))); + final var testDto = new SkillTestDto(traineeAnswer.getId().getQuizId(), new ArrayList<>()); + for (var question : quiz.getQuestions()) { + final var traineeQuestionChoices = traineeAnswer.getChoices(question.getId()); + final var questionDto = new SkillTestQuestionDto(question.getId(), new ArrayList<>()); + testDto.questions().add(questionDto); + for (var choice : question.getChoices()) { + totalChoices += 1; + if (traineeQuestionChoices.contains(choice)) { + questionDto.choices().add(choice.getId()); + score += choice.getIsGood() ? 1 : -1; + } else { + score += choice.getIsGood() ? 0 : 1; + } + } + } + List users = List.of(); + try { + users = getUsers(traineeAnswer.getId().getTraineeName()); + } catch (FeignException e) { + log.error("Failed to fetch trainee data", e); + } + final var email = users.size() == 1 ? users.get(0).getEmail() : ""; + final var firstName = users.size() == 1 ? users.get(0).getFirstName() : ""; + final var lastName = users.size() == 1 ? users.get(0).getLastName() : ""; + return new SkillTestResultDetailsDto(testDto, traineeAnswer.getId().getTraineeName(), firstName, + lastName, email, totalChoices == 0 ? null : 100.0 * score / totalChoices); + } + + private List getUsers(final String username) { + return keycloakUsersApi.adminRealmsRealmUsersGet("quiz", Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.of(true), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.of(username)).getBody(); + } } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/UsersController.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/UsersController.java index f10f5c4..579164e 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/UsersController.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/UsersController.java @@ -1,3 +1,4 @@ +/* (C)2024 */ package com.c4soft.quiz.web; import org.springframework.http.MediaType; @@ -7,10 +8,8 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; - import com.c4soft.quiz.domain.QuizAuthentication; import com.c4soft.quiz.web.dto.UserInfoDto; - import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -21,14 +20,14 @@ @Tag(name = "Users") public class UsersController { - @GetMapping(path = "/me", produces = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_PROBLEM_JSON_VALUE }) - public UserInfoDto getMe(Authentication auth) { - if (auth instanceof QuizAuthentication quizAuth) { - return new UserInfoDto( - quizAuth.getName(), - quizAuth.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(), - quizAuth.getAttributes().getExpiresAt().getEpochSecond()); - } - return UserInfoDto.ANONYMOUS; - } + @GetMapping(path = "/me", + produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_PROBLEM_JSON_VALUE}) + public UserInfoDto getMe(Authentication auth) { + if (auth instanceof QuizAuthentication quizAuth) { + return new UserInfoDto(quizAuth.getName(), + quizAuth.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(), + quizAuth.getAttributes().getExpiresAt().getEpochSecond()); + } + return UserInfoDto.ANONYMOUS; + } } diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/ChoiceDto.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/ChoiceDto.java index 3bede8d..b53f612 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/ChoiceDto.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/ChoiceDto.java @@ -1,8 +1,9 @@ +/* (C)2024 */ package com.c4soft.quiz.web.dto; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; -public record ChoiceDto(@NotNull Long quizId, @NotNull Long questionId, @NotNull Long choiceId, @NotEmpty String label, - boolean isGood) { -} \ No newline at end of file +public record ChoiceDto(@NotNull Long quizId, @NotNull Long questionId, @NotNull Long choiceId, + @NotEmpty String label, boolean isGood) { +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/ChoiceUpdateDto.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/ChoiceUpdateDto.java index 7b095de..469d73c 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/ChoiceUpdateDto.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/ChoiceUpdateDto.java @@ -1,7 +1,8 @@ +/* (C)2024 */ package com.c4soft.quiz.web.dto; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Size; public record ChoiceUpdateDto(@NotEmpty @Size(max = 255) String label, boolean isGood) { -} \ No newline at end of file +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuestionDto.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuestionDto.java index 53bd78c..fc73df3 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuestionDto.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuestionDto.java @@ -1,14 +1,9 @@ +/* (C)2024 */ package com.c4soft.quiz.web.dto; import java.util.List; - import jakarta.validation.constraints.NotNull; -public record QuestionDto( - @NotNull Long quizId, - @NotNull Long questionId, - String label, - String formattedBody, - List choices, - @NotNull String comment) { -} \ No newline at end of file +public record QuestionDto(@NotNull Long quizId, @NotNull Long questionId, String label, + String formattedBody, List choices, @NotNull String comment) { +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuestionUpdateDto.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuestionUpdateDto.java index c05e661..2f1247f 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuestionUpdateDto.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuestionUpdateDto.java @@ -1,3 +1,4 @@ +/* (C)2024 */ package com.c4soft.quiz.web.dto; import jakarta.validation.constraints.NotEmpty; @@ -8,8 +9,6 @@ * @parameter label the new label for the question * @parameter comment a new explanation for the right answer */ -public record QuestionUpdateDto( - @NotEmpty @Size(max = 255) String label, - @NotNull @Size(max = 2047) String formattedBody, - @NotNull @Size(max = 2047) String comment) { -} \ No newline at end of file +public record QuestionUpdateDto(@NotEmpty @Size(max = 255) String label, + @NotNull @Size(max = 2047) String formattedBody, @NotNull @Size(max = 2047) String comment) { +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuizDto.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuizDto.java index f2791e1..af918d7 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuizDto.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuizDto.java @@ -1,40 +1,35 @@ +/* (C)2024 */ package com.c4soft.quiz.web.dto; import java.util.List; - import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; /** - * @param id unique identifier for the quiz - * @param title the quiz title - * @param questions an array with all of the quiz questions - * @param authorName name of the trainer who authored the quiz - * @param isPublished is the quiz available for trainees - * @param isSubmitted is the quiz submitted to moderation - * @param isReplaced was a new version of this quiz published (and replaces this one) - * @param isChoicesShuffled should the choices display order be shuffled from a question display to another - * @param isReplayEnabled can a trainee submit a new answer before his former one was deleted by the trainer - * @param isPerQuestionResult should the right answer as well as comment be displayed as soon as choices are validated for a question or only when the - * skill test was accepted by the server - * @param isTrainerNotifiedOfNewTests if true, trainers receive an email each time a new skill-test is submitted (for quizzes they authored only) - * @param ModeratorComment an explanation why this quiz version was rejected by a moderator - * @param draftId unique identifier for a modified version of this quiz - * @param replacesId identifier of the former version of this quiz + * @param id unique identifier for the quiz + * @param title the quiz title + * @param questions an array with all of the quiz questions + * @param authorName name of the trainer who authored the quiz + * @param isPublished is the quiz available for trainees + * @param isSubmitted is the quiz submitted to moderation + * @param isReplaced was a new version of this quiz published (and replaces this one) + * @param isChoicesShuffled should the choices display order be shuffled from a question display to + * another + * @param isReplayEnabled can a trainee submit a new answer before his former one was deleted by the + * trainer + * @param isPerQuestionResult should the right answer as well as comment be displayed as soon as + * choices are validated for a question or only when the skill test was accepted by the + * server + * @param isTrainerNotifiedOfNewTests if true, trainers receive an email each time a new skill-test + * is submitted (for quizzes they authored only) + * @param ModeratorComment an explanation why this quiz version was rejected by a moderator + * @param draftId unique identifier for a modified version of this quiz + * @param replacesId identifier of the former version of this quiz */ -public record QuizDto( - @NotNull Long id, - @NotEmpty String title, - @NotNull List questions, - @NotEmpty String authorName, - @NotNull Boolean isPublished, - @NotNull Boolean isSubmitted, - @NotNull Boolean isReplaced, - @NotNull Boolean isChoicesShuffled, - @NotNull Boolean isReplayEnabled, - @NotNull Boolean isPerQuestionResult, - @NotNull Boolean isTrainerNotifiedOfNewTests, - String ModeratorComment, - Long draftId, - Long replacesId) { -} \ No newline at end of file +public record QuizDto(@NotNull Long id, @NotEmpty String title, + @NotNull List questions, @NotEmpty String authorName, @NotNull Boolean isPublished, + @NotNull Boolean isSubmitted, @NotNull Boolean isReplaced, @NotNull Boolean isChoicesShuffled, + @NotNull Boolean isReplayEnabled, @NotNull Boolean isPerQuestionResult, + @NotNull Boolean isTrainerNotifiedOfNewTests, String ModeratorComment, Long draftId, + Long replacesId) { +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuizUpdateDto.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuizUpdateDto.java index f99b394..89ff7b2 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuizUpdateDto.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/QuizUpdateDto.java @@ -1,3 +1,4 @@ +/* (C)2024 */ package com.c4soft.quiz.web.dto; import jakarta.validation.constraints.NotEmpty; @@ -5,17 +6,17 @@ import jakarta.validation.constraints.Size; /** - * @param title the new title for the quiz - * @param isChoicesShuffled should choices display order be randomized from a display to another. - * @param isReplayEnabled can a trainee submit a new skill test before the former one was deleted by the trainer. - * @param isPerQuestionResult if true, the right answer as well as comment should be displayed as soon as choices for a question are validated. - * Otherwise, it should be displayed only when the test was accepted by the server. - * @param isTrainerNotifiedOfNewTests if true, trainers receive an email each time a new skill-test is submitted (for quizzes they authored only) + * @param title the new title for the quiz + * @param isChoicesShuffled should choices display order be randomized from a display to another. + * @param isReplayEnabled can a trainee submit a new skill test before the former one was deleted by + * the trainer. + * @param isPerQuestionResult if true, the right answer as well as comment should be displayed as + * soon as choices for a question are validated. Otherwise, it should be displayed only when + * the test was accepted by the server. + * @param isTrainerNotifiedOfNewTests if true, trainers receive an email each time a new skill-test + * is submitted (for quizzes they authored only) */ -public record QuizUpdateDto( - @NotEmpty @Size(max = 255) String title, - @NotNull Boolean isChoicesShuffled, - @NotNull Boolean isReplayEnabled, - @NotNull Boolean isPerQuestionResult, - @NotNull Boolean isTrainerNotifiedOfNewTests) { -} \ No newline at end of file +public record QuizUpdateDto(@NotEmpty @Size(max = 255) String title, + @NotNull Boolean isChoicesShuffled, @NotNull Boolean isReplayEnabled, + @NotNull Boolean isPerQuestionResult, @NotNull Boolean isTrainerNotifiedOfNewTests) { +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestDto.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestDto.java index bfdd3f0..a7d135f 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestDto.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestDto.java @@ -1,13 +1,14 @@ +/* (C)2024 */ package com.c4soft.quiz.web.dto; import java.util.List; import java.util.Objects; - import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; public record SkillTestDto(@NotNull Long quizId, @NotEmpty List questions) { - public SkillTestQuestionDto getQuestion(Long questionId) { - return questions.stream().filter(q -> Objects.equals(q.questionId(), questionId)).findAny().orElse(new SkillTestQuestionDto(questionId, List.of())); - } -} \ No newline at end of file + public SkillTestQuestionDto getQuestion(Long questionId) { + return questions.stream().filter(q -> Objects.equals(q.questionId(), questionId)).findAny() + .orElse(new SkillTestQuestionDto(questionId, List.of())); + } +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestQuestionDto.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestQuestionDto.java index 15b69ce..da3bbef 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestQuestionDto.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestQuestionDto.java @@ -1,8 +1,7 @@ +/* (C)2024 */ package com.c4soft.quiz.web.dto; -import java.util.List; - import jakarta.validation.constraints.NotNull; +import java.util.List; -public record SkillTestQuestionDto(@NotNull Long questionId, List choices) { -} \ No newline at end of file +public record SkillTestQuestionDto(@NotNull Long questionId, List choices) {} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestResultDetailsDto.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestResultDetailsDto.java index afbcf8e..0cf1690 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestResultDetailsDto.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestResultDetailsDto.java @@ -1,12 +1,9 @@ +/* (C)2024 */ package com.c4soft.quiz.web.dto; import jakarta.validation.constraints.NotNull; -public record SkillTestResultDetailsDto( - @NotNull SkillTestDto test, - @NotNull String traineeUsername, - @NotNull String traineeFirstName, - @NotNull String traineeLastName, - @NotNull String traineeEmail, - @NotNull Double score) { -} \ No newline at end of file +public record SkillTestResultDetailsDto(@NotNull SkillTestDto test, @NotNull String traineeUsername, + @NotNull String traineeFirstName, @NotNull String traineeLastName, @NotNull String traineeEmail, + @NotNull Double score) { +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestResultPreviewDto.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestResultPreviewDto.java index 5bbc194..833a4d1 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestResultPreviewDto.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/SkillTestResultPreviewDto.java @@ -1,5 +1,7 @@ +/* (C)2024 */ package com.c4soft.quiz.web.dto; import jakarta.validation.constraints.NotNull; -public record SkillTestResultPreviewDto(@NotNull String traineeName, @NotNull Double score) {} \ No newline at end of file +public record SkillTestResultPreviewDto(@NotNull String traineeName, @NotNull Double score) { +} diff --git a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/UserInfoDto.java b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/UserInfoDto.java index 4a74faa..dc20b0b 100644 --- a/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/UserInfoDto.java +++ b/api/quiz-api/src/main/java/com/c4soft/quiz/web/dto/UserInfoDto.java @@ -1,14 +1,15 @@ +/* (C)2024 */ package com.c4soft.quiz.web.dto; -import java.util.List; - import jakarta.validation.constraints.NotNull; +import java.util.List; /** * @param username the user unique name * @param roles the user roles * @param exp seconds since epoch time at which access will expire */ -public record UserInfoDto(@NotNull String username, @NotNull List roles, @NotNull Long exp) { - public static final UserInfoDto ANONYMOUS = new UserInfoDto("", List.of(), Long.MAX_VALUE); -} \ No newline at end of file +public record UserInfoDto( + @NotNull String username, @NotNull List roles, @NotNull Long exp) { + public static final UserInfoDto ANONYMOUS = new UserInfoDto("", List.of(), Long.MAX_VALUE); +} diff --git a/api/quiz-api/src/main/resources/application.yml b/api/quiz-api/src/main/resources/application.yml index 5f26a9b..4aee2c6 100644 --- a/api/quiz-api/src/main/resources/application.yml +++ b/api/quiz-api/src/main/resources/application.yml @@ -84,6 +84,13 @@ com: - "/actuator/health/readiness" - "/actuator/health/liveness" - "/v3/api-docs/**" + rest: + client: + keycloak-admin-api: + base-url: ${issuer}api/v2 + authorization: + oauth2: + oauth2-registration-id: quiz-admin management: endpoint: diff --git a/api/quiz-api/src/test/java/com/c4soft/quiz/EnableSpringDataWebSupportTestConf.java b/api/quiz-api/src/test/java/com/c4soft/quiz/EnableSpringDataWebSupportTestConf.java index a25276f..99694f2 100644 --- a/api/quiz-api/src/test/java/com/c4soft/quiz/EnableSpringDataWebSupportTestConf.java +++ b/api/quiz-api/src/test/java/com/c4soft/quiz/EnableSpringDataWebSupportTestConf.java @@ -1,5 +1,12 @@ +/* (C)2024 */ package com.c4soft.quiz; +import com.c4soft.quiz.domain.Choice; +import com.c4soft.quiz.domain.Question; +import com.c4soft.quiz.domain.Quiz; +import com.c4soft.quiz.domain.jpa.ChoiceRepository; +import com.c4soft.quiz.domain.jpa.QuestionRepository; +import com.c4soft.quiz.domain.jpa.QuizRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.AutoConfigureDataJpa; import org.springframework.boot.test.context.TestConfiguration; @@ -7,13 +14,6 @@ import org.springframework.format.FormatterRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -import com.c4soft.quiz.domain.Choice; -import com.c4soft.quiz.domain.ChoiceRepository; -import com.c4soft.quiz.domain.Question; -import com.c4soft.quiz.domain.QuestionRepository; -import com.c4soft.quiz.domain.Quiz; -import com.c4soft.quiz.domain.QuizRepository; - /** * Avoid MethodArgumentConversionNotSupportedException with mocked repos * @@ -22,25 +22,25 @@ @TestConfiguration @AutoConfigureDataJpa public class EnableSpringDataWebSupportTestConf { - @Autowired - QuizRepository quizRepo; - - @Autowired - QuestionRepository questionRepo; - - @Autowired - ChoiceRepository choiceRepo; + @Autowired QuizRepository quizRepo; + + @Autowired QuestionRepository questionRepo; + + @Autowired ChoiceRepository choiceRepo; - @Bean - WebMvcConfigurer configurer() { - return new WebMvcConfigurer() { + @Bean + WebMvcConfigurer configurer() { + return new WebMvcConfigurer() { - @Override - public void addFormatters(FormatterRegistry registry) { - registry.addConverter(Long.class, Quiz.class, id -> quizRepo.findById(id).orElse(null)); - registry.addConverter(Long.class, Question.class, id -> questionRepo.findById(id).orElse(null)); - registry.addConverter(Long.class, Choice.class, id -> choiceRepo.findById(id).orElse(null)); - } - }; - } -} \ No newline at end of file + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverter( + Long.class, Quiz.class, id -> quizRepo.findById(id).orElse(null)); + registry.addConverter( + Long.class, Question.class, id -> questionRepo.findById(id).orElse(null)); + registry.addConverter( + Long.class, Choice.class, id -> choiceRepo.findById(id).orElse(null)); + } + }; + } +} diff --git a/api/quiz-api/src/test/java/com/c4soft/quiz/QuizApiApplicationTest.java b/api/quiz-api/src/test/java/com/c4soft/quiz/QuizApiApplicationTest.java index fbca4df..a7e40d8 100644 --- a/api/quiz-api/src/test/java/com/c4soft/quiz/QuizApiApplicationTest.java +++ b/api/quiz-api/src/test/java/com/c4soft/quiz/QuizApiApplicationTest.java @@ -1,3 +1,4 @@ +/* (C)2024 */ package com.c4soft.quiz; import static org.hamcrest.CoreMatchers.hasItems; @@ -12,16 +13,19 @@ import java.io.UnsupportedEncodingException; import java.util.ArrayList; import java.util.List; -import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.keycloak.admin.api.UsersApi; +import org.keycloak.admin.model.UserRepresentation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MvcResult; @@ -29,13 +33,11 @@ import com.c4_soft.springaddons.security.oauth2.test.webmvc.AddonsWebmvcTestConf; import com.c4_soft.springaddons.security.oauth2.test.webmvc.MockMvcSupport; import com.c4soft.quiz.domain.Choice; -import com.c4soft.quiz.domain.ChoiceRepository; import com.c4soft.quiz.domain.Question; -import com.c4soft.quiz.domain.QuestionRepository; import com.c4soft.quiz.domain.Quiz; -import com.c4soft.quiz.domain.QuizRepository; -import com.c4soft.quiz.feign.KeycloakAdminApiClient; -import com.c4soft.quiz.feign.KeycloakAdminApiClient.UserRepresentation; +import com.c4soft.quiz.domain.jpa.ChoiceRepository; +import com.c4soft.quiz.domain.jpa.QuestionRepository; +import com.c4soft.quiz.domain.jpa.QuizRepository; import com.c4soft.quiz.web.dto.ChoiceUpdateDto; import com.c4soft.quiz.web.dto.QuestionUpdateDto; import com.c4soft.quiz.web.dto.QuizUpdateDto; @@ -65,7 +67,7 @@ class QuizApiApplicationTest { MockMvcSupport api; @MockBean - KeycloakAdminApiClient keycloakAdminApi; + UsersApi keycloakAdminApi; @BeforeEach void setUp() { @@ -207,7 +209,26 @@ void givenUserIsATrainee_whenGoingThroughSkillTestNominalOperations_thenOk() thr } api.put(worstPossibleAnswer, "/skill-tests").andExpect(status().isAccepted()).andExpect(jsonPath("$.score", is(-50.0))); - when(keycloakAdminApi.getUser("tonton-pirate", true)).thenReturn(List.of(new UserRepresentation(Map.of("email", "tonton-pirate@c4-soft.com")))); + final var tontonPirate = new UserRepresentation(); + tontonPirate.setEmail("tonton-pirate@c4-soft.com"); + when( + keycloakAdminApi + .adminRealmsRealmUsersGet( + "quiz", + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.of(true), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.of("tonton-pirate"))).thenReturn(ResponseEntity.ok(List.of(tontonPirate))); api .perform(get("/skill-tests/{quizId}/{traineeName}", quizId.toString(), "tonton-pirate")) .andExpect(status().isOk()) @@ -218,7 +239,6 @@ void givenUserIsATrainee_whenGoingThroughSkillTestNominalOperations_thenOk() thr .perform(get("/skill-tests/{quizId}/{traineeName}", quizId.toString(), "tonton-pirate")) .andExpect(status().isOk()) .andExpect(jsonPath("$.score", is(100.0))); - } private T parse(MvcResult result, Class clazz) throws UnsupportedEncodingException, ParseException { @@ -240,5 +260,4 @@ public static Quiz openIdTraingQuiz() { return quiz; } } - } diff --git a/api/quiz-api/src/test/java/com/c4soft/quiz/SecuredTest.java b/api/quiz-api/src/test/java/com/c4soft/quiz/SecuredTest.java index d942ba9..c30a829 100644 --- a/api/quiz-api/src/test/java/com/c4soft/quiz/SecuredTest.java +++ b/api/quiz-api/src/test/java/com/c4soft/quiz/SecuredTest.java @@ -1,14 +1,13 @@ +/* (C)2024 */ package com.c4soft.quiz; +import com.c4_soft.springaddons.security.oauth2.test.webmvc.AutoConfigureAddonsWebmvcResourceServerSecurity; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; - import org.springframework.context.annotation.Import; -import com.c4_soft.springaddons.security.oauth2.test.webmvc.AutoConfigureAddonsWebmvcResourceServerSecurity; - /** * Avoid MethodArgumentConversionNotSupportedException with repos MockBean * @@ -17,7 +16,5 @@ @AutoConfigureAddonsWebmvcResourceServerSecurity @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) -@Import({ SecurityConfig.class }) -public @interface SecuredTest { - -} \ No newline at end of file +@Import({SecurityConfig.class}) +public @interface SecuredTest {} diff --git a/api/quiz-api/src/test/java/com/c4soft/quiz/web/QuizControllerTest.java b/api/quiz-api/src/test/java/com/c4soft/quiz/web/QuizControllerTest.java index 55acebb..0c60239 100644 --- a/api/quiz-api/src/test/java/com/c4soft/quiz/web/QuizControllerTest.java +++ b/api/quiz-api/src/test/java/com/c4soft/quiz/web/QuizControllerTest.java @@ -1,40 +1,40 @@ +/* (C)2024 */ package com.c4soft.quiz.web; import static org.hamcrest.CoreMatchers.is; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; - import com.c4_soft.springaddons.security.oauth2.test.annotations.WithJwt; import com.c4_soft.springaddons.security.oauth2.test.webmvc.AutoConfigureAddonsWebmvcResourceServerSecurity; import com.c4_soft.springaddons.security.oauth2.test.webmvc.MockMvcSupport; import com.c4soft.quiz.EnableSpringDataWebSupportTestConf; +import com.c4soft.quiz.ExceptionHandlers; import com.c4soft.quiz.SecurityConfig; import com.c4soft.quiz.web.dto.QuizUpdateDto; @WebMvcTest(controllers = QuizController.class) @ActiveProfiles("h2") -@Import({ EnableSpringDataWebSupportTestConf.class, ExceptionHandlers.class, SecurityConfig.class }) +@Import({EnableSpringDataWebSupportTestConf.class, ExceptionHandlers.class, SecurityConfig.class}) @AutoConfigureAddonsWebmvcResourceServerSecurity class QuizControllerTest { - @Autowired - MockMvcSupport api; + @Autowired + MockMvcSupport api; - @Test - @WithJwt("ch4mp.json") - void givenUserIsCh4mp_whenPayloadIsInvalid_then422WithValidationExceptionsInProblemDetails() throws Exception { - api - .post(new QuizUpdateDto("", null, null, null, null), "/quizzes") - .andExpect(status().isUnprocessableEntity()) - .andExpect(jsonPath("$.invalidFields.title", is("NotEmpty"))) - .andExpect(jsonPath("$.invalidFields.isChoicesShuffled", is("NotNull"))) - .andExpect(jsonPath("$.invalidFields.isReplayEnabled", is("NotNull"))) - .andExpect(jsonPath("$.invalidFields.isPerQuestionResult", is("NotNull"))) - .andExpect(jsonPath("$.invalidFields.isTrainerNotifiedOfNewTests", is("NotNull"))); - } + @Test + @WithJwt("ch4mp.json") + void givenUserIsCh4mp_whenPayloadIsInvalid_then422WithValidationExceptionsInProblemDetails() + throws Exception { + api.post(new QuizUpdateDto("", null, null, null, null), "/quizzes") + .andExpect(status().isUnprocessableEntity()) + .andExpect(jsonPath("$.invalidFields.title", is("NotEmpty"))) + .andExpect(jsonPath("$.invalidFields.isChoicesShuffled", is("NotNull"))) + .andExpect(jsonPath("$.invalidFields.isReplayEnabled", is("NotNull"))) + .andExpect(jsonPath("$.invalidFields.isPerQuestionResult", is("NotNull"))) + .andExpect(jsonPath("$.invalidFields.isTrainerNotifiedOfNewTests", is("NotNull"))); + } } diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..9951e6a --- /dev/null +++ b/build.sh @@ -0,0 +1,85 @@ +#!/bin/sh +echo "***************************************************************************************************************************************" +echo "* To build Spring Boot native images, run with the \"native\" argument: \"sh ./build.sh native\" (images will take much longer to build). *" +echo "* *" +echo "* This build script tries to auto-detect ARM64 (Apple Silicon) to build the appropriate Spring Boot Docker images. *" +echo "***************************************************************************************************************************************" +echo "" + +if [[ "$OSTYPE" == "darwin"* ]]; then + SED="sed -i '' -e" +else + SED="sed -i -e" +fi + +MAVEN_PROFILES=() +if [[ `uname -m` == "arm64" ]]; then + MAVEN_PROFILES+=("arm64") +fi +if [[ " $@ " =~ [[:space:]]native[[:space:]] ]]; then + MAVEN_PROFILES+=("native") +fi +if [ ${#MAVEN_PROFILES[@]} -eq 0 ]; then + MAVEN_PROFILE_ARG="" +else + MAVEN_PROFILE_ARG=-P$(IFS=, ; echo "${MAVEN_PROFILES[*]}") +fi + +host=$(echo $HOSTNAME | tr '[A-Z]' '[a-z]') + +cd api +echo "***********************" +echo "sh ./mvnw clean install" +echo "***********************" +echo "" +sh ./mvnw clean install + +echo "" +echo "*****************************************************************************************************************************************" +echo "sh ./mvnw -pl quiz-api spring-boot:build-image -Dspring-boot.build-image.imageName=quiz/api $MAVEN_PROFILE_ARG" +echo "*****************************************************************************************************************************************" +echo "" +sh ./mvnw -pl quiz-api spring-boot:build-image -Dspring-boot.build-image.imageName=quiz/api $MAVEN_PROFILE_ARG + +echo "" +echo "*****************************************************************************************************************" +echo "sh ./mvnw -pl bff spring-boot:build-image -Dspring-boot.build-image.imageName=quiz/bff $MAVEN_PROFILE_ARG" +echo "*****************************************************************************************************************" +echo "" +sh ./mvnw -pl bff spring-boot:build-image -Dspring-boot.build-image.imageName=quiz/bff $MAVEN_PROFILE_ARG +cd .. + +rm -f "compose-${host}.yml" +cp compose.yml "compose-${host}.yml" +$SED "s/LOCALHOST_NAME/${host}/g" "compose-${host}.yml" +rm -f "compose-${host}.yml''" + +rm keycloak/import/quiz-realm.json +cp quiz-realm.json keycloak/import/quiz-realm.json +$SED "s/LOCALHOST_NAME/${host}/g" keycloak/import/quiz-realm.json +rm "keycloak/import/quiz-realm.json''" + +cd angular-ui/ +rm src/app/app.config.ts +cp ../angular-ui.app.config.ts src/app/app.config.ts +$SED "s/LOCALHOST_NAME/${host}/g" src/app/app.config.ts +rm "src/app/app.config.ts''" +npm i +npm run build +cd .. + +docker build -t quiz/nginx-reverse-proxy ./nginx-reverse-proxy +docker build -t quiz/ui ./angular-ui + +docker compose -f compose-${host}.yml up -d + +echo "" +echo "Open the following in a new private navigation window." + +echo "" +echo "Keycloak as admin / admin:" +echo "http://${host}/auth/admin/master/console/#/quiz" + +echo "" +echo "Sample user author / author" +echo http://${host}/ui/ \ No newline at end of file diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..736ac4b --- /dev/null +++ b/compose.yml @@ -0,0 +1,82 @@ +services: + + nginx-reverse-proxy: + container_name: quiz.nginx-reverse-proxy + image: quiz/nginx-reverse-proxy + ports: + - 80:80 + extra_hosts: + - "host.docker.internal:host-gateway" + - "LOCALHOST_NAME:host-gateway" + + keycloak: + container_name: quiz.auth + image: quay.io/keycloak/keycloak:24.0.0 + command: + - start-dev + - --import-realm + ports: + - 8080:8080 + volumes: + - ./keycloak/import/:/opt/keycloak/data/import/ + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + KC_HTTP_PORT: 8080 + KC_HOSTNAME_URL: http://LOCALHOST_NAME/auth + KC_HOSTNAME_ADMIN_URL: http://LOCALHOST_NAME/auth + KC_HOSTNAME_STRICT_BACKCHANNEL: true + #KC_HOSTNAME_DEBUG: true + KC_HTTP_RELATIVE_PATH: /auth/ + KC_HTTP_ENABLED: true + KC_HEALTH_ENABLED: true + KC_METRICS_ENABLED: true + #KC_LOG_LEVEL: DEBUG + extra_hosts: + - "host.docker.internal:host-gateway" + - "LOCALHOST_NAME:host-gateway" + healthcheck: + test: ['CMD-SHELL', '[ -f /tmp/HealthCheck.java ] || echo "public class HealthCheck { public static void main(String[] args) throws java.lang.Throwable { System.exit(java.net.HttpURLConnection.HTTP_OK == ((java.net.HttpURLConnection)new java.net.URL(args[0]).openConnection()).getResponseCode() ? 0 : 1); } }" > /tmp/HealthCheck.java && java /tmp/HealthCheck.java http://localhost/auth/health/live'] + interval: 5s + timeout: 5s + retries: 20 + + angular-ui: + container_name: quiz.ui + image: quiz/ui + ports: + - 4200:80 + extra_hosts: + - "host.docker.internal:host-gateway" + - "LOCALHOST_NAME:host-gateway" + + resource-server: + container_name: quiz.api + image: quiz/api + ports: + - 7084:7084 + environment: + HOSTNAME: LOCALHOST_NAME + SERVER_ADDRESS: 0.0.0.0 + depends_on: + keycloak: + condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" + - "LOCALHOST_NAME:host-gateway" + + bff: + container_name: quiz.bff + image: quiz/bff + ports: + - 7080:7080 + environment: + HOSTNAME: LOCALHOST_NAME + SERVER_ADDRESS: 0.0.0.0 + CLIENT_SECRET: secret + depends_on: + keycloak: + condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" + - "LOCALHOST_NAME:host-gateway" diff --git a/nginx-reverse-proxy/404.html b/nginx-reverse-proxy/404.html new file mode 100644 index 0000000..ad55ee8 --- /dev/null +++ b/nginx-reverse-proxy/404.html @@ -0,0 +1,10 @@ + + + Page Not Found + + + +

Proxy Backend Not Found +

+ + \ No newline at end of file diff --git a/nginx-reverse-proxy/Dockerfile b/nginx-reverse-proxy/Dockerfile new file mode 100644 index 0000000..0ac01aa --- /dev/null +++ b/nginx-reverse-proxy/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:stable +COPY nginx.conf /etc/nginx/nginx.conf diff --git a/nginx-reverse-proxy/nginx.conf b/nginx-reverse-proxy/nginx.conf new file mode 100644 index 0000000..ec18f0f --- /dev/null +++ b/nginx-reverse-proxy/nginx.conf @@ -0,0 +1,70 @@ +worker_processes 1; +error_log /var/log/nginx/error.log warn; +pid /tmp/nginx.pid; +events { + worker_connections 1024; +} +http { + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp_path; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + include /etc/nginx/mime.types; + default_type application/octet-stream; + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + sendfile on; + keepalive_timeout 65; + + gzip on; + gzip_static on; + gzip_vary on; + gzip_proxied no-cache no-store private expired auth; + gzip_min_length 10240; + gzip_types + application/javascript + application/json + font/woff2 + text/css + text/plain; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_request_buffering off; + proxy_http_version 1.1; + proxy_intercept_errors on; + + server { + listen 80; + server_name localhost; + error_page 404 /404.html; + location = /404.html { + root /usr/share/nginx/html; + } + location / { + rewrite ^/$ /ui/ permanent; + } + location /ui { + proxy_pass http://host.docker.internal:4200/ui; + } + location /bff { + rewrite /bff/(.*) /$1 break; + proxy_pass http://host.docker.internal:7080; + } + location /resource-server { + rewrite /resource-server/(.*) /$1 break; + proxy_pass http://host.docker.internal:7084; + } + location /auth { + proxy_pass http://host.docker.internal:8080/auth; + } + location /.well-known/acme-challenge/** { + proxy_pass https://cert-manager-webhook; + } + } +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..a429140 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,69 @@ +worker_processes 1; +error_log /var/log/nginx/error.log warn; +pid /tmp/nginx.pid; +events { + worker_connections 1024; +} +http { + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp_path; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + include /etc/nginx/mime.types; + default_type application/octet-stream; + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + sendfile on; + keepalive_timeout 65; + + gzip on; + gzip_static on; + gzip_vary on; + gzip_proxied no-cache no-store private expired auth; + gzip_min_length 10240; + gzip_types + application/javascript + application/json + font/woff2 + text/css + text/plain; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_request_buffering off; + proxy_http_version 1.1; + proxy_intercept_errors on; + + server { + listen 80; + server_name LOCALHOST_NAME; + error_page 404 /404.html; + location = /404.html { + root /usr/share/nginx/html; + } + location / { + rewrite ^/$ /ui/ permanent; + } + location /ui { + proxy_pass http://LOCALHOST_NAME:4200/ui; + } + location /bff { + rewrite /bff/(.*) /$1 break; + proxy_pass http://LOCALHOST_NAME:7080; + } + location /resource-server { + rewrite /resource-server/(.*) /$1 break; + proxy_pass http://LOCALHOST_NAME:7084; + } + location /auth { + proxy_pass http://LOCALHOST_NAME:8080/auth; + } + location /.well-known/acme-challenge/** { + proxy_pass https://cert-manager-webhook; + } +}