From 34936c6d45d4c8fab9a34e6e66366173113022b5 Mon Sep 17 00:00:00 2001 From: Romuald Lemesle Date: Mon, 3 Jun 2024 09:46:43 +0200 Subject: [PATCH] [backend/frontend] Improv scenario pagination (#898) --- .../V3_16__Add_array_union_agg_method.java | 32 + .../io/openbas/rest/scenario/ScenarioApi.java | 3 +- .../io/openbas/service/ScenarioService.java | 638 ++++++++++-------- .../utils/pagination/PaginationUtils.java | 50 +- .../pagination/SortUtilsCriteriaBuilder.java | 33 + .../main/java/io/openbas/utils/JpaUtils.java | 3 +- .../admin/components/scenarios/Scenarios.tsx | 2 +- .../database/raw/RawPaginationScenario.java | 50 +- 8 files changed, 482 insertions(+), 329 deletions(-) create mode 100644 openbas-api/src/main/java/io/openbas/migration/V3_16__Add_array_union_agg_method.java create mode 100644 openbas-api/src/main/java/io/openbas/utils/pagination/SortUtilsCriteriaBuilder.java diff --git a/openbas-api/src/main/java/io/openbas/migration/V3_16__Add_array_union_agg_method.java b/openbas-api/src/main/java/io/openbas/migration/V3_16__Add_array_union_agg_method.java new file mode 100644 index 0000000000..d331470c53 --- /dev/null +++ b/openbas-api/src/main/java/io/openbas/migration/V3_16__Add_array_union_agg_method.java @@ -0,0 +1,32 @@ +package io.openbas.migration; + +import org.flywaydb.core.api.migration.BaseJavaMigration; +import org.flywaydb.core.api.migration.Context; +import org.springframework.stereotype.Component; + +import java.sql.Connection; +import java.sql.Statement; + +@Component +public class V3_16__Add_array_union_agg_method extends BaseJavaMigration { + + @Override + public void migrate(Context context) throws Exception { + Connection connection = context.getConnection(); + Statement select = connection.createStatement(); + select.execute("CREATE FUNCTION array_union(a ANYARRAY, b ANYARRAY)" + + " RETURNS ANYARRAY AS" + + " $$" + + "SELECT array_agg(DISTINCT x)" + + "FROM (" + + " SELECT unnest(a) x" + + " UNION ALL SELECT unnest(b)" + + " ) AS u" + + " $$ LANGUAGE SQL;" + + "CREATE AGGREGATE array_union_agg(ANYARRAY) (" + + " SFUNC = array_union," + + " STYPE = ANYARRAY," + + " INITCOND = '{}'" + + ");"); + } +} diff --git a/openbas-api/src/main/java/io/openbas/rest/scenario/ScenarioApi.java b/openbas-api/src/main/java/io/openbas/rest/scenario/ScenarioApi.java index 7a2d935722..9784739cb3 100644 --- a/openbas-api/src/main/java/io/openbas/rest/scenario/ScenarioApi.java +++ b/openbas-api/src/main/java/io/openbas/rest/scenario/ScenarioApi.java @@ -75,8 +75,7 @@ public List scenarios() { @PostMapping("/api/scenarios/search") public Page scenarios(@RequestBody @Valid final SearchPaginationInput searchPaginationInput) { - return this.scenarioService.scenarios(searchPaginationInput) - .map(RawPaginationScenario::new); + return this.scenarioService.scenarios(searchPaginationInput); } @GetMapping(SCENARIO_URI + "/{scenarioId}") diff --git a/openbas-api/src/main/java/io/openbas/service/ScenarioService.java b/openbas-api/src/main/java/io/openbas/service/ScenarioService.java index e93a29e819..24ba9d5157 100644 --- a/openbas-api/src/main/java/io/openbas/service/ScenarioService.java +++ b/openbas-api/src/main/java/io/openbas/service/ScenarioService.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.openbas.config.OpenBASConfig; import io.openbas.database.model.*; +import io.openbas.database.raw.RawPaginationScenario; import io.openbas.database.raw.RawScenario; import io.openbas.database.repository.*; import io.openbas.database.specification.ScenarioSpecification; @@ -16,6 +17,11 @@ import io.openbas.rest.scenario.form.ScenarioSimple; import io.openbas.utils.pagination.SearchPaginationInput; import jakarta.annotation.Resource; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Tuple; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.*; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -23,6 +29,7 @@ import lombok.extern.java.Log; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.http.HttpHeaders; @@ -45,7 +52,8 @@ import static io.openbas.helper.StreamHelper.fromIterable; import static io.openbas.service.ImportService.EXPORT_ENTRY_ATTACHMENT; import static io.openbas.service.ImportService.EXPORT_ENTRY_SCENARIO; -import static io.openbas.utils.pagination.PaginationUtils.buildPaginationJPA; +import static io.openbas.utils.pagination.PaginationUtils.buildPaginationCriteriaBuilder; +import static io.openbas.utils.pagination.SortUtilsCriteriaBuilder.toSortCriteriaBuilder; import static java.time.Instant.now; @RequiredArgsConstructor @@ -53,289 +61,381 @@ @Log public class ScenarioService { - @Value("${openbas.mail.imap.enabled}") - private boolean imapEnabled; - - @Value("${openbas.mail.imap.username}") - private String imapUsername; - - @Resource - private OpenBASConfig openBASConfig; - - @Resource - protected ObjectMapper mapper; - - private final ScenarioRepository scenarioRepository; - private final GrantService grantService; - private final VariableService variableService; - private final ChallengeService challengeService; - private final DocumentRepository documentRepository; - private final TeamRepository teamRepository; - private final UserRepository userRepository; - private final ScenarioTeamUserRepository scenarioTeamUserRepository; - private final FileService fileService; - - @Transactional - public Scenario createScenario(@NotNull final Scenario scenario) { - if (this.imapEnabled) { - scenario.setFrom(this.imapUsername); - scenario.setReplyTos(List.of(this.imapUsername)); - } else { - scenario.setFrom(this.openBASConfig.getDefaultMailer()); - scenario.setReplyTos(List.of(this.openBASConfig.getDefaultReplyTo())); - } + @Value("${openbas.mail.imap.enabled}") + private boolean imapEnabled; + + @Value("${openbas.mail.imap.username}") + private String imapUsername; + + @Resource + private OpenBASConfig openBASConfig; + + @Resource + protected ObjectMapper mapper; + + @PersistenceContext + private EntityManager entityManager; + + private final ScenarioRepository scenarioRepository; + private final GrantService grantService; + private final VariableService variableService; + private final ChallengeService challengeService; + private final DocumentRepository documentRepository; + private final TeamRepository teamRepository; + private final UserRepository userRepository; + private final ScenarioTeamUserRepository scenarioTeamUserRepository; + private final FileService fileService; + + @Transactional + public Scenario createScenario(@NotNull final Scenario scenario) { + if (this.imapEnabled) { + scenario.setFrom(this.imapUsername); + scenario.setReplyTos(List.of(this.imapUsername)); + } else { + scenario.setFrom(this.openBASConfig.getDefaultMailer()); + scenario.setReplyTos(List.of(this.openBASConfig.getDefaultReplyTo())); + } - this.grantService.computeGrant(scenario); + this.grantService.computeGrant(scenario); - return this.scenarioRepository.save(scenario); - } + return this.scenarioRepository.save(scenario); + } - public List scenarios() { - List scenarios; - if (currentUser().isAdmin()) { - scenarios = fromIterable(this.scenarioRepository.rawAll()); - } else { - scenarios = this.scenarioRepository.rawAllGranted(currentUser().getId()); - } - return scenarios.stream().map(ScenarioSimple::fromRawScenario).toList(); + public List scenarios() { + List scenarios; + if (currentUser().isAdmin()) { + scenarios = fromIterable(this.scenarioRepository.rawAll()); + } else { + scenarios = this.scenarioRepository.rawAllGranted(currentUser().getId()); } - - public Page scenarios(SearchPaginationInput searchPaginationInput) { - if (currentUser().isAdmin()) { - return buildPaginationJPA( - this.scenarioRepository::findAll, - searchPaginationInput, - Scenario.class - ); - } else { - return buildPaginationJPA( - (Specification specification, Pageable pageable) -> this.scenarioRepository.findAll( - findGrantedFor(currentUser().getId()).and(specification), - pageable - ), - searchPaginationInput, - Scenario.class - ); - } + return scenarios.stream().map(ScenarioSimple::fromRawScenario).toList(); + } + + public Page scenarios(final SearchPaginationInput searchPaginationInput) { + if (currentUser().isAdmin()) { + return buildPaginationCriteriaBuilder( + this::findAllWithCriteriaBuilder, + searchPaginationInput, + Scenario.class + ); + } else { + return buildPaginationCriteriaBuilder( + (Specification specification, Pageable pageable) -> this.findAllWithCriteriaBuilder( + findGrantedFor(currentUser().getId()).and(specification), + pageable + ), + searchPaginationInput, + Scenario.class + ); } - - public List recurringScenarios(@NotNull final Instant instant) { - return this.scenarioRepository.findAll( - ScenarioSpecification.isRecurring() - .and(ScenarioSpecification.recurrenceStartDateAfter(instant)) - .and(ScenarioSpecification.recurrenceStopDateBefore(instant)) + } + + private Page findAllWithCriteriaBuilder( + Specification specification, + Pageable pageable) { + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + + // -- Create Query -- + CriteriaQuery cq = cb.createTupleQuery(); + // FROM + Root scenarioRoot = cq.from(Scenario.class); + // Join on TAG + Join scenarioTagsJoin = scenarioRoot.join("tags", JoinType.LEFT); + Expression tagIdsExpression = + cb.function( + "array_remove", + String[].class, + cb.function("array_agg", String[].class, scenarioTagsJoin.get("id")), + cb.nullLiteral(String.class) + ); + // Join on INJECT and INJECTOR CONTRACT + Join injectsJoin = scenarioRoot.join("injects", JoinType.LEFT); + Join injectorsContractsJoin = injectsJoin.join("injectorContract", JoinType.LEFT); + Expression platformExpression = + cb.function( + "array_union_agg", + String[].class, + injectorsContractsJoin.get("platforms") ); + // SELECT + cq.multiselect( + scenarioRoot.get("id").alias("scenario_id"), + scenarioRoot.get("name").alias("scenario_name"), + scenarioRoot.get("severity").alias("scenario_severity"), + scenarioRoot.get("category").alias("scenario_category"), + scenarioRoot.get("recurrence").alias("scenario_recurrence"), + scenarioRoot.get("updatedAt").alias("scenario_updated_at"), + tagIdsExpression.alias("scenario_tags"), + platformExpression.alias("scenario_platforms") + ).distinct(true); + // Group By + cq.groupBy(scenarioRoot.get("id")); + + // -- Text Search and Filters -- + if (specification != null) { + Predicate predicate = specification.toPredicate(scenarioRoot, cq, cb); + if (predicate != null) { + cq.where(predicate); + } } - public Scenario scenario(@NotBlank final String scenarioId) { - return this.scenarioRepository.findById(scenarioId) - .orElseThrow(() -> new ElementNotFoundException("Scenario not found")); + // -- Sorting -- + List orders = toSortCriteriaBuilder(cb, scenarioRoot, pageable.getSort()); + cq.orderBy(orders); + + // Type Query + TypedQuery query = entityManager.createQuery(cq); + + // -- Pagination -- + query.setFirstResult((int) pageable.getOffset()); + query.setMaxResults(pageable.getPageSize()); + + // -- EXECUTION -- + List scenarios = query.getResultList() + .stream() + .map(tuple -> new RawPaginationScenario( + tuple.get("scenario_id", String.class), + tuple.get("scenario_name", String.class), + tuple.get("scenario_severity", String.class), + tuple.get("scenario_category", String.class), + tuple.get("scenario_recurrence", String.class), + tuple.get("scenario_updated_at", Instant.class), + tuple.get("scenario_tags", String[].class), + tuple.get("scenario_platforms", String[].class) + )) + .toList(); + + // -- Count Query -- + CriteriaQuery countQuery = cb.createQuery(Long.class); + Root countRoot = countQuery.from(Scenario.class); + countQuery.select(cb.count(countRoot)); + Long total = entityManager.createQuery(countQuery).getSingleResult(); + + return new PageImpl<>(scenarios, pageable, total); + } + + public List recurringScenarios(@NotNull final Instant instant) { + return this.scenarioRepository.findAll( + ScenarioSpecification.isRecurring() + .and(ScenarioSpecification.recurrenceStartDateAfter(instant)) + .and(ScenarioSpecification.recurrenceStopDateBefore(instant)) + ); + } + + public Scenario scenario(@NotBlank final String scenarioId) { + return this.scenarioRepository.findById(scenarioId) + .orElseThrow(() -> new ElementNotFoundException("Scenario not found")); + } + + public Scenario scenarioByExternalReference(@NotBlank final String scenarioExternalReference) { + List scenarios = this.scenarioRepository.findByExternalReference(scenarioExternalReference); + if (!scenarios.isEmpty()) { + return scenarios.getFirst(); + } else { + throw new ElementNotFoundException("Scenario not found"); } - - public Scenario scenarioByExternalReference(@NotBlank final String scenarioExternalReference) { - List scenarios = this.scenarioRepository.findByExternalReference(scenarioExternalReference); - if (!scenarios.isEmpty()) { - return scenarios.getFirst(); - } else { - throw new ElementNotFoundException("Scenario not found"); - } + } + + public Scenario updateScenario(@NotNull final Scenario scenario) { + scenario.setUpdatedAt(now()); + return this.scenarioRepository.save(scenario); + } + + public void deleteScenario(@NotBlank final String scenarioId) { + this.scenarioRepository.deleteById(scenarioId); + } + + // -- EXPORT -- + + // TODO: we can do better + public void exportScenario( + @NotBlank final String scenarioId, + final boolean isWithTeams, + final boolean isWithPlayers, + final boolean isWithVariableValues, + HttpServletResponse response) + throws IOException { + Scenario scenario = this.scenario(scenarioId); + List scenarioTags = new ArrayList<>(); + List documentIds = new ArrayList<>(); + + // Start exporting scenario + ScenarioFileExport scenarioFileExport = new ScenarioFileExport(); + scenarioFileExport.setVersion(1); + // Add Scenario + scenarioFileExport.setScenario(scenario); + mapper.addMixIn(Scenario.class, ScenarioExportMixins.Scenario.class); + scenarioTags.addAll(scenario.getTags()); + // Add Objectives + scenarioFileExport.setObjectives(scenario.getObjectives()); + mapper.addMixIn(Objective.class, ExerciseExportMixins.Objective.class); + // Add Lesson Categories + scenarioFileExport.setLessonsCategories(scenario.getLessonsCategories()); + mapper.addMixIn(LessonsCategory.class, ExerciseExportMixins.LessonsCategory.class); + // Add Lessons Questions + List lessonsQuestions = scenario.getLessonsCategories() + .stream() + .flatMap(category -> category.getQuestions().stream()) + .toList(); + scenarioFileExport.setLessonsQuestions(lessonsQuestions); + mapper.addMixIn(LessonsQuestion.class, ExerciseExportMixins.LessonsQuestion.class); + // Add Variables + List variables = this.variableService.variablesFromScenario(scenarioId); + scenarioFileExport.setVariables(variables); + if (isWithVariableValues) { + mapper.addMixIn(Variable.class, VariableWithValueMixin.class); + } else { + mapper.addMixIn(Variable.class, VariableMixin.class); } - public Scenario updateScenario(@NotNull final Scenario scenario) { - scenario.setUpdatedAt(now()); - return this.scenarioRepository.save(scenario); + // Add Documents + scenarioFileExport.setDocuments(scenario.getDocuments()); + mapper.addMixIn(Document.class, ExerciseExportMixins.Document.class); + scenarioTags.addAll(scenario.getDocuments().stream().flatMap(doc -> doc.getTags().stream()).toList()); + documentIds.addAll(scenario.getDocuments().stream().map(Document::getId).toList()); + + if (isWithTeams) { + // Add Teams + scenarioFileExport.setTeams(scenario.getTeams()); + mapper.addMixIn(Team.class, + isWithPlayers ? ExerciseExportMixins.Team.class : ExerciseExportMixins.EmptyTeam.class); + scenarioTags.addAll(scenario.getTeams().stream().flatMap(team -> team.getTags().stream()).toList()); } - public void deleteScenario(@NotBlank final String scenarioId) { - this.scenarioRepository.deleteById(scenarioId); + if (isWithPlayers) { + // Add players + List players = scenario.getTeams().stream().flatMap(team -> team.getUsers().stream()).distinct().toList(); + scenarioFileExport.setUsers(players); + mapper.addMixIn(User.class, ExerciseExportMixins.User.class); + scenarioTags.addAll(players.stream().flatMap(user -> user.getTags().stream()).toList()); + // organizations + List organizations = players.stream() + .map(User::getOrganization) + .filter(Objects::nonNull) + .toList(); + scenarioFileExport.setOrganizations(organizations); + mapper.addMixIn(Organization.class, ExerciseExportMixins.Organization.class); + scenarioTags.addAll(organizations.stream().flatMap(org -> org.getTags().stream()).toList()); + } else { + mapper.addMixIn(ExerciseFileExport.class, ScenarioExportMixins.ScenarioWithoutPlayers.class); } - // -- EXPORT -- - - // TODO: we can do better - public void exportScenario( - @NotBlank final String scenarioId, - final boolean isWithTeams, - final boolean isWithPlayers, - final boolean isWithVariableValues, - HttpServletResponse response) - throws IOException { - Scenario scenario = this.scenario(scenarioId); - List scenarioTags = new ArrayList<>(); - List documentIds = new ArrayList<>(); - - // Start exporting scenario - ScenarioFileExport scenarioFileExport = new ScenarioFileExport(); - scenarioFileExport.setVersion(1); - // Add Scenario - scenarioFileExport.setScenario(scenario); - mapper.addMixIn(Scenario.class, ScenarioExportMixins.Scenario.class); - scenarioTags.addAll(scenario.getTags()); - // Add Objectives - scenarioFileExport.setObjectives(scenario.getObjectives()); - mapper.addMixIn(Objective.class, ExerciseExportMixins.Objective.class); - // Add Lesson Categories - scenarioFileExport.setLessonsCategories(scenario.getLessonsCategories()); - mapper.addMixIn(LessonsCategory.class, ExerciseExportMixins.LessonsCategory.class); - // Add Lessons Questions - List lessonsQuestions = scenario.getLessonsCategories() - .stream() - .flatMap(category -> category.getQuestions().stream()) - .toList(); - scenarioFileExport.setLessonsQuestions(lessonsQuestions); - mapper.addMixIn(LessonsQuestion.class, ExerciseExportMixins.LessonsQuestion.class); - // Add Variables - List variables = this.variableService.variablesFromScenario(scenarioId); - scenarioFileExport.setVariables(variables); - if (isWithVariableValues) { - mapper.addMixIn(Variable.class, VariableWithValueMixin.class); - } else { - mapper.addMixIn(Variable.class, VariableMixin.class); - } - - // Add Documents - scenarioFileExport.setDocuments(scenario.getDocuments()); - mapper.addMixIn(Document.class, ExerciseExportMixins.Document.class); - scenarioTags.addAll(scenario.getDocuments().stream().flatMap(doc -> doc.getTags().stream()).toList()); - documentIds.addAll(scenario.getDocuments().stream().map(Document::getId).toList()); - - if (isWithTeams) { - // Add Teams - scenarioFileExport.setTeams(scenario.getTeams()); - mapper.addMixIn(Team.class, - isWithPlayers ? ExerciseExportMixins.Team.class : ExerciseExportMixins.EmptyTeam.class); - scenarioTags.addAll(scenario.getTeams().stream().flatMap(team -> team.getTags().stream()).toList()); + // Add Injects + mapper.addMixIn(Inject.class, ExerciseExportMixins.Inject.class); + scenarioFileExport.setInjects(scenario.getInjects()); + scenarioTags.addAll(scenario.getInjects().stream().flatMap(inject -> inject.getTags().stream()).toList()); + + // Add Articles + mapper.addMixIn(Article.class, ExerciseExportMixins.Article.class); + scenarioFileExport.setArticles(scenario.getArticles()); + // Add Channels + mapper.addMixIn(Channel.class, ExerciseExportMixins.Channel.class); + List channels = scenario.getArticles().stream().map(Article::getChannel).distinct().toList(); + scenarioFileExport.setChannels(channels); + documentIds.addAll( + channels.stream().flatMap(channel -> channel.getLogos().stream()).map(Document::getId).toList() + ); + + // Add Challenges + mapper.addMixIn(Challenge.class, ExerciseExportMixins.Challenge.class); + List challenges = fromIterable(this.challengeService.getScenarioChallenges(scenario)); + scenarioFileExport.setChallenges(challenges); + scenarioTags.addAll(challenges.stream().flatMap(challenge -> challenge.getTags().stream()).toList()); + documentIds.addAll( + challenges.stream().flatMap(challenge -> challenge.getDocuments().stream()).map(Document::getId).toList()); + + // Tags + scenarioFileExport.setTags(scenarioTags.stream().distinct().toList()); + mapper.addMixIn(Tag.class, ExerciseExportMixins.Tag.class); + + // Build the response + String infos = "(" + + (isWithTeams ? "with_teams" : "no_teams") + + " & " + + (isWithPlayers ? "with_players" : "no_players") + + " & " + + (isWithVariableValues ? "with_variable_values" : "no_variable_values") + + ")"; + String zipName = (scenario.getName() + "_" + now().toString()) + "_" + infos + ".zip"; + response.addHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + zipName); + response.addHeader(HttpHeaders.CONTENT_TYPE, "application/zip"); + response.setStatus(HttpServletResponse.SC_OK); + ZipOutputStream zipExport = new ZipOutputStream(response.getOutputStream()); + ZipEntry zipEntry = new ZipEntry(scenario.getName() + ".json"); + zipEntry.setComment(EXPORT_ENTRY_SCENARIO); + zipExport.putNextEntry(zipEntry); + zipExport.write(mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(scenarioFileExport)); + zipExport.closeEntry(); + // Add the documents + documentIds.stream().distinct().forEach(docId -> { + Document doc = this.documentRepository.findById(docId).orElseThrow(); + Optional docStream = this.fileService.getFile(doc); + if (docStream.isPresent()) { + try { + ZipEntry zipDoc = new ZipEntry(doc.getTarget()); + zipDoc.setComment(EXPORT_ENTRY_ATTACHMENT); + byte[] data = docStream.get().readAllBytes(); + zipExport.putNextEntry(zipDoc); + zipExport.write(data); + zipExport.closeEntry(); + } catch (IOException e) { + log.log(Level.SEVERE, e.getMessage(), e); } - - if (isWithPlayers) { - // Add players - List players = scenario.getTeams().stream().flatMap(team -> team.getUsers().stream()).distinct().toList(); - scenarioFileExport.setUsers(players); - mapper.addMixIn(User.class, ExerciseExportMixins.User.class); - scenarioTags.addAll(players.stream().flatMap(user -> user.getTags().stream()).toList()); - // organizations - List organizations = players.stream().map(User::getOrganization).filter(Objects::nonNull).toList(); - scenarioFileExport.setOrganizations(organizations); - mapper.addMixIn(Organization.class, ExerciseExportMixins.Organization.class); - scenarioTags.addAll(organizations.stream().flatMap(org -> org.getTags().stream()).toList()); - } else { - mapper.addMixIn(ExerciseFileExport.class, ScenarioExportMixins.ScenarioWithoutPlayers.class); - } - - // Add Injects - mapper.addMixIn(Inject.class, ExerciseExportMixins.Inject.class); - scenarioFileExport.setInjects(scenario.getInjects()); - scenarioTags.addAll(scenario.getInjects().stream().flatMap(inject -> inject.getTags().stream()).toList()); - - // Add Articles - mapper.addMixIn(Article.class, ExerciseExportMixins.Article.class); - scenarioFileExport.setArticles(scenario.getArticles()); - // Add Channels - mapper.addMixIn(Channel.class, ExerciseExportMixins.Channel.class); - List channels = scenario.getArticles().stream().map(Article::getChannel).distinct().toList(); - scenarioFileExport.setChannels(channels); - documentIds.addAll(channels.stream().flatMap(channel -> channel.getLogos().stream()).map(Document::getId).toList()); - - // Add Challenges - mapper.addMixIn(Challenge.class, ExerciseExportMixins.Challenge.class); - List challenges = fromIterable(this.challengeService.getScenarioChallenges(scenario)); - scenarioFileExport.setChallenges(challenges); - scenarioTags.addAll(challenges.stream().flatMap(challenge -> challenge.getTags().stream()).toList()); - documentIds.addAll( - challenges.stream().flatMap(challenge -> challenge.getDocuments().stream()).map(Document::getId).toList()); - - // Tags - scenarioFileExport.setTags(scenarioTags.stream().distinct().toList()); - mapper.addMixIn(Tag.class, ExerciseExportMixins.Tag.class); - - // Build the response - String infos = "(" + - (isWithTeams ? "with_teams" : "no_teams") + - " & " + - (isWithPlayers ? "with_players" : "no_players") + - " & " + - (isWithVariableValues ? "with_variable_values" : "no_variable_values") - + ")"; - String zipName = (scenario.getName() + "_" + now().toString()) + "_" + infos + ".zip"; - response.addHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + zipName); - response.addHeader(HttpHeaders.CONTENT_TYPE, "application/zip"); - response.setStatus(HttpServletResponse.SC_OK); - ZipOutputStream zipExport = new ZipOutputStream(response.getOutputStream()); - ZipEntry zipEntry = new ZipEntry(scenario.getName() + ".json"); - zipEntry.setComment(EXPORT_ENTRY_SCENARIO); - zipExport.putNextEntry(zipEntry); - zipExport.write(mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(scenarioFileExport)); - zipExport.closeEntry(); - // Add the documents - documentIds.stream().distinct().forEach(docId -> { - Document doc = this.documentRepository.findById(docId).orElseThrow(); - Optional docStream = this.fileService.getFile(doc); - if (docStream.isPresent()) { - try { - ZipEntry zipDoc = new ZipEntry(doc.getTarget()); - zipDoc.setComment(EXPORT_ENTRY_ATTACHMENT); - byte[] data = docStream.get().readAllBytes(); - zipExport.putNextEntry(zipDoc); - zipExport.write(data); - zipExport.closeEntry(); - } catch (IOException e) { - log.log(Level.SEVERE, e.getMessage(), e); - } - } - }); - zipExport.finish(); - zipExport.close(); - } - - // -- TEAMS -- - - public Iterable addTeams(@NotBlank final String scenarioId, @NotNull final List teamIds) { - Scenario scenario = this.scenario(scenarioId); - List teams = scenario.getTeams(); - List teamsToAdd = fromIterable(this.teamRepository.findAllById(teamIds)); - List existingTeamIds = teams.stream().map(Team::getId).toList(); - teams.addAll(teamsToAdd.stream().filter(t -> !existingTeamIds.contains(t.getId())).toList()); - scenario.setTeams(teams); - scenario.setUpdatedAt(now()); - return teamsToAdd; - } - - public Iterable removeTeams(@NotBlank final String scenarioId, @NotNull final List teamIds) { - Scenario scenario = this.scenario(scenarioId); - List teams = scenario.getTeams().stream().filter(team -> !teamIds.contains(team.getId())).toList(); - scenario.setTeams(new ArrayList<>() {{ - addAll(teams); - }}); - this.updateScenario(scenario); - // Remove all association between users / exercises / teams - teamIds.forEach(this.scenarioTeamUserRepository::deleteTeamFromAllReferences); - return teamRepository.findAllById(teamIds); - } - - public Scenario enablePlayers(@NotBlank final String scenarioId, @NotBlank final String teamId, - @NotNull final List playerIds) { - Scenario scenario = this.scenario(scenarioId); - Team team = this.teamRepository.findById(teamId).orElseThrow(); - playerIds.forEach(playerId -> { - ScenarioTeamUser scenarioTeamUser = new ScenarioTeamUser(); - scenarioTeamUser.setScenario(scenario); - scenarioTeamUser.setTeam(team); - scenarioTeamUser.setUser(this.userRepository.findById(playerId).orElseThrow()); - this.scenarioTeamUserRepository.save(scenarioTeamUser); - }); - return scenario; - } - - public Scenario disablePlayers(@NotBlank final String scenarioId, @NotBlank final String teamId, - @NotNull final List playerIds) { - playerIds.forEach(playerId -> { - ScenarioTeamUserId scenarioTeamUserId = new ScenarioTeamUserId(); - scenarioTeamUserId.setScenarioId(scenarioId); - scenarioTeamUserId.setTeamId(teamId); - scenarioTeamUserId.setUserId(playerId); - this.scenarioTeamUserRepository.deleteById(scenarioTeamUserId); - }); - return this.scenario(scenarioId); - } + } + }); + zipExport.finish(); + zipExport.close(); + } + + // -- TEAMS -- + + public Iterable addTeams(@NotBlank final String scenarioId, @NotNull final List teamIds) { + Scenario scenario = this.scenario(scenarioId); + List teams = scenario.getTeams(); + List teamsToAdd = fromIterable(this.teamRepository.findAllById(teamIds)); + List existingTeamIds = teams.stream().map(Team::getId).toList(); + teams.addAll(teamsToAdd.stream().filter(t -> !existingTeamIds.contains(t.getId())).toList()); + scenario.setTeams(teams); + scenario.setUpdatedAt(now()); + return teamsToAdd; + } + + public Iterable removeTeams(@NotBlank final String scenarioId, @NotNull final List teamIds) { + Scenario scenario = this.scenario(scenarioId); + List teams = scenario.getTeams().stream().filter(team -> !teamIds.contains(team.getId())).toList(); + scenario.setTeams(new ArrayList<>() {{ + addAll(teams); + }}); + this.updateScenario(scenario); + // Remove all association between users / exercises / teams + teamIds.forEach(this.scenarioTeamUserRepository::deleteTeamFromAllReferences); + return teamRepository.findAllById(teamIds); + } + + public Scenario enablePlayers(@NotBlank final String scenarioId, @NotBlank final String teamId, + @NotNull final List playerIds) { + Scenario scenario = this.scenario(scenarioId); + Team team = this.teamRepository.findById(teamId).orElseThrow(); + playerIds.forEach(playerId -> { + ScenarioTeamUser scenarioTeamUser = new ScenarioTeamUser(); + scenarioTeamUser.setScenario(scenario); + scenarioTeamUser.setTeam(team); + scenarioTeamUser.setUser(this.userRepository.findById(playerId).orElseThrow()); + this.scenarioTeamUserRepository.save(scenarioTeamUser); + }); + return scenario; + } + + public Scenario disablePlayers(@NotBlank final String scenarioId, @NotBlank final String teamId, + @NotNull final List playerIds) { + playerIds.forEach(playerId -> { + ScenarioTeamUserId scenarioTeamUserId = new ScenarioTeamUserId(); + scenarioTeamUserId.setScenarioId(scenarioId); + scenarioTeamUserId.setTeamId(teamId); + scenarioTeamUserId.setUserId(playerId); + this.scenarioTeamUserRepository.deleteById(scenarioTeamUserId); + }); + return this.scenario(scenarioId); + } } diff --git a/openbas-api/src/main/java/io/openbas/utils/pagination/PaginationUtils.java b/openbas-api/src/main/java/io/openbas/utils/pagination/PaginationUtils.java index 7692320b39..8fefed80bf 100644 --- a/openbas-api/src/main/java/io/openbas/utils/pagination/PaginationUtils.java +++ b/openbas-api/src/main/java/io/openbas/utils/pagination/PaginationUtils.java @@ -2,60 +2,38 @@ import jakarta.validation.constraints.NotNull; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; -import java.util.Collections; -import java.util.List; import java.util.function.BiFunction; import static io.openbas.utils.FilterUtilsJpa.computeFilterGroupJpa; -import static io.openbas.utils.FilterUtilsRuntime.computeFilterGroupRuntime; import static io.openbas.utils.pagination.SearchUtilsJpa.computeSearchJpa; -import static io.openbas.utils.pagination.SearchUtilsRuntime.computeSearchRuntime; import static io.openbas.utils.pagination.SortUtilsJpa.toSortJpa; -import static io.openbas.utils.pagination.SortUtilsRuntime.computeSortRuntime; -import static io.openbas.utils.pagination.SortUtilsRuntime.toSortRuntime; public class PaginationUtils { - // -- RUNTIME -- - - public static Page buildPaginationRuntime(List values, SearchPaginationInput input) { - int currentPage = input.getPage(); - int pageSize = input.getSize(); - int startItem = currentPage * pageSize; - - Pageable pageable = PageRequest.of(input.getPage(), input.getSize(), toSortRuntime(input.getSorts())); - - List results = computePagination(values, input); - int totalElements = results.size(); + // -- JPA -- - // Offset - if (startItem >= totalElements) { - return new PageImpl<>(Collections.emptyList(), pageable, totalElements); - } + public static Page buildPaginationJPA( + @NotNull final BiFunction, Pageable, Page> findAll, + @NotNull final SearchPaginationInput input, + @NotNull final Class clazz) { + // Specification + Specification filterSpecifications = computeFilterGroupJpa(input.getFilterGroup()); + Specification searchSpecifications = computeSearchJpa(input.getTextSearch()); - int toIndex = Math.min(startItem + pageSize, totalElements); - List paginatedContracts = results.subList(startItem, toIndex); - return new PageImpl<>(paginatedContracts, pageable, totalElements); - } + // Pageable + Pageable pageable = PageRequest.of(input.getPage(), input.getSize(), toSortJpa(input.getSorts(), clazz)); - private static List computePagination(List values, SearchPaginationInput input) { - return values - .stream() - .filter(computeFilterGroupRuntime(input.getFilterGroup())) - .filter(computeSearchRuntime(input.getTextSearch())) - .sorted(computeSortRuntime(input.getSorts())) - .toList(); + return findAll.apply(filterSpecifications.and(searchSpecifications), pageable); } - // -- JPA -- + // -- CRITERIA BUILDER -- - public static Page buildPaginationJPA( - @NotNull final BiFunction, Pageable, Page> findAll, + public static Page buildPaginationCriteriaBuilder( + @NotNull final BiFunction, Pageable, Page> findAll, @NotNull final SearchPaginationInput input, @NotNull final Class clazz) { // Specification diff --git a/openbas-api/src/main/java/io/openbas/utils/pagination/SortUtilsCriteriaBuilder.java b/openbas-api/src/main/java/io/openbas/utils/pagination/SortUtilsCriteriaBuilder.java new file mode 100644 index 0000000000..596a3ed0ce --- /dev/null +++ b/openbas-api/src/main/java/io/openbas/utils/pagination/SortUtilsCriteriaBuilder.java @@ -0,0 +1,33 @@ +package io.openbas.utils.pagination; + +import io.openbas.database.model.Scenario; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Order; +import jakarta.persistence.criteria.Root; +import org.flywaydb.core.internal.util.StringUtils; +import org.springframework.data.domain.Sort; + +import java.util.ArrayList; +import java.util.List; + +public class SortUtilsCriteriaBuilder { + + public static List toSortCriteriaBuilder(CriteriaBuilder cb, Root root, Sort sort) { + List orders = new ArrayList<>(); + if (sort.isSorted()) { + sort.forEach(order -> { + if (StringUtils.hasText(order.getProperty())) { + if (order.isAscending()) { + orders.add(cb.asc(root.get(order.getProperty()))); + } else { + orders.add(cb.desc(root.get(order.getProperty()))); + } + } + }); + } else { + orders.add(cb.asc(root.get("id"))); // Default order by scenario_id + } + return orders; + } + +} diff --git a/openbas-framework/src/main/java/io/openbas/utils/JpaUtils.java b/openbas-framework/src/main/java/io/openbas/utils/JpaUtils.java index 923a5813f7..3f653546ce 100644 --- a/openbas-framework/src/main/java/io/openbas/utils/JpaUtils.java +++ b/openbas-framework/src/main/java/io/openbas/utils/JpaUtils.java @@ -20,7 +20,8 @@ public static Expression toPath( // Join if (propertySchema.getJoinTable() != null) { PropertySchema.JoinTable joinTable = propertySchema.getJoinTable(); - return root.join(joinTable.getJoinOn(), JoinType.LEFT).get(Optional.ofNullable(propertySchema.getPropertyRepresentative()).orElse("id")); + return root.join(joinTable.getJoinOn(), JoinType.LEFT) + .get(Optional.ofNullable(propertySchema.getPropertyRepresentative()).orElse("id")); } // Search on child else if (propertySchema.isFilterable() && hasText(propertySchema.getPropertyRepresentative())) { diff --git a/openbas-front/src/admin/components/scenarios/Scenarios.tsx b/openbas-front/src/admin/components/scenarios/Scenarios.tsx index 87cadd22bc..8f4ed5dc66 100644 --- a/openbas-front/src/admin/components/scenarios/Scenarios.tsx +++ b/openbas-front/src/admin/components/scenarios/Scenarios.tsx @@ -116,7 +116,7 @@ const Scenarios = () => { { field: 'scenario_category', label: 'Category', isSortable: true }, { field: 'scenario_recurrence', label: 'Status', isSortable: true }, { field: 'scenario_platforms', label: 'Platforms', isSortable: false }, - { field: 'scenario_tags', label: 'Tags', isSortable: true }, + { field: 'scenario_tags', label: 'Tags', isSortable: false }, { field: 'scenario_updated_at', label: 'Updated', isSortable: true }, ]; diff --git a/openbas-model/src/main/java/io/openbas/database/raw/RawPaginationScenario.java b/openbas-model/src/main/java/io/openbas/database/raw/RawPaginationScenario.java index 18946e4a9d..7637042d5c 100644 --- a/openbas-model/src/main/java/io/openbas/database/raw/RawPaginationScenario.java +++ b/openbas-model/src/main/java/io/openbas/database/raw/RawPaginationScenario.java @@ -1,32 +1,42 @@ package io.openbas.database.raw; -import io.openbas.database.model.Scenario; -import io.openbas.database.model.Tag; import lombok.Data; import java.time.Instant; -import java.util.List; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; @Data public class RawPaginationScenario { - String scenario_id; - String scenario_name; - String scenario_severity; - String scenario_category; - String scenario_recurrence; - List scenario_platforms; - List scenario_tags; - Instant scenario_updated_at; + private String scenario_id; + private String scenario_name; + private String scenario_severity; + private String scenario_category; + private String scenario_recurrence; + private Instant scenario_updated_at; + private Set scenario_tags; + private Set scenario_platforms; - public RawPaginationScenario(final Scenario scenario) { - this.scenario_id = scenario.getId(); - this.scenario_name = scenario.getName(); - this.scenario_severity = scenario.getSeverity(); - this.scenario_category = scenario.getCategory(); - this.scenario_recurrence = scenario.getRecurrence(); - this.scenario_platforms = scenario.getPlatforms(); - this.scenario_tags = scenario.getTags().stream().map(Tag::getId).toList(); - this.scenario_updated_at = scenario.getUpdatedAt(); + public RawPaginationScenario( + String id, + String name, + String severity, + String category, + String recurrence, + Instant updatedAt, + String[] tags, + String[] platforms + ) { + this.scenario_id = id; + this.scenario_name = name; + this.scenario_severity = severity; + this.scenario_category = category; + this.scenario_recurrence = recurrence; + this.scenario_updated_at = updatedAt; + this.scenario_tags = tags != null ? new HashSet<>(Arrays.asList(tags)) : new HashSet<>(); + this.scenario_platforms = platforms != null ? new HashSet<>(Arrays.asList(platforms)) : new HashSet<>(); } + }