From ff19ddc34486526930d6a2e08cde831e11057c16 Mon Sep 17 00:00:00 2001 From: Florent MILLOT Date: Fri, 29 Mar 2024 15:27:28 +0100 Subject: [PATCH 1/2] Add and manage announcements Signed-off-by: Florent MILLOT --- .../useradmin/server/UserAdminController.java | 36 ++++++---- .../server/repository/AnnouncementEntity.java | 50 ++++++++++++++ .../repository/AnnouncementRepository.java | 20 ++++++ .../server/service/AnnouncementService.java | 61 ++++++++++++++++ .../server/service/NotificationService.java | 2 +- .../server/service/UserAdminService.java | 37 +++++----- .../changesets/changelog_20240329T134444Z.xml | 17 +++++ .../db/changelog/db.changelog-master.yaml | 3 + .../useradmin/server/UserAdminTest.java | 69 ++++++++++++++----- 9 files changed, 242 insertions(+), 53 deletions(-) create mode 100644 src/main/java/org/gridsuite/useradmin/server/repository/AnnouncementEntity.java create mode 100644 src/main/java/org/gridsuite/useradmin/server/repository/AnnouncementRepository.java create mode 100644 src/main/java/org/gridsuite/useradmin/server/service/AnnouncementService.java create mode 100644 src/main/resources/db/changelog/changesets/changelog_20240329T134444Z.xml diff --git a/src/main/java/org/gridsuite/useradmin/server/UserAdminController.java b/src/main/java/org/gridsuite/useradmin/server/UserAdminController.java index c69b9c9..32655b0 100644 --- a/src/main/java/org/gridsuite/useradmin/server/UserAdminController.java +++ b/src/main/java/org/gridsuite/useradmin/server/UserAdminController.java @@ -7,13 +7,13 @@ package org.gridsuite.useradmin.server; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotEmpty; import org.gridsuite.useradmin.server.dto.UserConnection; import org.gridsuite.useradmin.server.dto.UserInfos; +import org.gridsuite.useradmin.server.repository.AnnouncementEntity; import org.gridsuite.useradmin.server.service.UserAdminService; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -21,6 +21,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.UUID; /** * @author Etienne Homer @@ -109,27 +110,38 @@ public ResponseEntity> getConnections(@RequestHeader("userI return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(service.getConnections(userId)); } - @PostMapping(value = "/messages/maintenance") - @Operation(summary = "send a message to all users connected") + @PostMapping(value = "/announcements") + @Operation(summary = "Send a message to all the connected users") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "message sent"), @ApiResponse(responseCode = "403", description = "user is not an admin") }) public ResponseEntity sendMaintenanceMessage(@RequestHeader("userId") String userId, - @Parameter(description = "the display time of the message in seconds") @RequestParam(value = "durationInSeconds", required = false) Integer duration, - @Parameter(description = "the message to display") @RequestBody String message) { - service.sendMaintenanceMessage(userId, duration, message); + @RequestBody AnnouncementEntity announcement) { + service.sendAnnouncement(announcement, userId); return ResponseEntity.ok().build(); } - @PostMapping(value = "/messages/cancel-maintenance") - @Operation(summary = "send a message to all users connected") + @DeleteMapping(value = "/announcements/{announcementId}") + @Operation(summary = "Cancel and delete a message") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "message sent"), - @ApiResponse(responseCode = "403", description = "user is not an admin") + @ApiResponse(responseCode = "200", description = "message canceled"), + @ApiResponse(responseCode = "403", description = "user is not an admin"), + @ApiResponse(responseCode = "404", description = "message not found") }) - public ResponseEntity sendCancelMaintenanceMessage(@RequestHeader("userId") String userId) { - service.sendCancelMaintenanceMessage(userId); + public ResponseEntity sendCancelMaintenanceMessage(@RequestHeader("userId") String userId, + @PathVariable("announcementId") UUID announcementId) { + service.cancelAnnouncement(announcementId, userId); return ResponseEntity.ok().build(); } + + @GetMapping(value = "/announcements") + @Operation(summary = "Get all the messages") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "messages retrieved"), + @ApiResponse(responseCode = "403", description = "user is not an admin") + }) + public ResponseEntity> sendCancelMaintenanceMessage(@RequestHeader("userId") String userId) { + return ResponseEntity.ok(service.getAllAnnouncements(userId)); + } } diff --git a/src/main/java/org/gridsuite/useradmin/server/repository/AnnouncementEntity.java b/src/main/java/org/gridsuite/useradmin/server/repository/AnnouncementEntity.java new file mode 100644 index 0000000..a26eed7 --- /dev/null +++ b/src/main/java/org/gridsuite/useradmin/server/repository/AnnouncementEntity.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package org.gridsuite.useradmin.server.repository; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; + +/** + * @author Florent MILLOT + */ +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "announcement") +public class AnnouncementEntity { + + public AnnouncementEntity(String message, Duration duration) { + this.message = message; + this.duration = duration; + } + + @Id + @GeneratedValue + @Column + private UUID id; + + @Column(nullable = false) + private Instant creationDate = Instant.now(); + + @Column(nullable = false) + private String message; + + @Column + private Duration duration; + +} diff --git a/src/main/java/org/gridsuite/useradmin/server/repository/AnnouncementRepository.java b/src/main/java/org/gridsuite/useradmin/server/repository/AnnouncementRepository.java new file mode 100644 index 0000000..d13be24 --- /dev/null +++ b/src/main/java/org/gridsuite/useradmin/server/repository/AnnouncementRepository.java @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package org.gridsuite.useradmin.server.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +/** + * @author Florent MILLOT + */ +@Repository +public interface AnnouncementRepository extends JpaRepository { +} diff --git a/src/main/java/org/gridsuite/useradmin/server/service/AnnouncementService.java b/src/main/java/org/gridsuite/useradmin/server/service/AnnouncementService.java new file mode 100644 index 0000000..727a6e7 --- /dev/null +++ b/src/main/java/org/gridsuite/useradmin/server/service/AnnouncementService.java @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +package org.gridsuite.useradmin.server.service; + +import org.gridsuite.useradmin.server.repository.AnnouncementEntity; +import org.gridsuite.useradmin.server.repository.AnnouncementRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** + * @author Florent MILLOT + */ +@Service +public class AnnouncementService { + + private final AnnouncementRepository announcementRepository; + private final NotificationService notificationService; + + public AnnouncementService(final AnnouncementRepository announcementRepository, + final NotificationService notificationService) { + this.announcementRepository = Objects.requireNonNull(announcementRepository); + this.notificationService = Objects.requireNonNull(notificationService); + } + + @Transactional + public void sendAnnouncement(AnnouncementEntity announcement) { + this.announcementRepository.deleteAll(); // for now, only one message at a time + this.announcementRepository.save(announcement); + if (announcement.getDuration() == null) { + notificationService.emitMaintenanceMessage(announcement.getMessage()); + } else { + notificationService.emitMaintenanceMessage(announcement.getMessage(), announcement.getDuration().toSeconds()); + } + } + + @Transactional + public void cancelAnnouncement(UUID announcementId) { + if (!announcementRepository.existsById(announcementId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Unable to find announcement"); + } + this.announcementRepository.deleteById(announcementId); + notificationService.emitCancelMaintenanceMessage(); + } + + @Transactional(readOnly = true) + public List getAllAnnouncements() { + return this.announcementRepository.findAll(); + } + +} diff --git a/src/main/java/org/gridsuite/useradmin/server/service/NotificationService.java b/src/main/java/org/gridsuite/useradmin/server/service/NotificationService.java index f414a43..0112f92 100644 --- a/src/main/java/org/gridsuite/useradmin/server/service/NotificationService.java +++ b/src/main/java/org/gridsuite/useradmin/server/service/NotificationService.java @@ -42,7 +42,7 @@ private void sendMessage(Message message) { updatePublisher.send("publishMessage-out-0", message); } - public void emitMaintenanceMessage(String message, int duration) { + public void emitMaintenanceMessage(String message, long duration) { sendMessage(MessageBuilder.withPayload(message) .setHeader(HEADER_MESSAGE_TYPE, MESSAGE_TYPE_MAINTENANCE) .setHeader(HEADER_DURATION, duration) diff --git a/src/main/java/org/gridsuite/useradmin/server/service/UserAdminService.java b/src/main/java/org/gridsuite/useradmin/server/service/UserAdminService.java index 0152e27..3ac8663 100644 --- a/src/main/java/org/gridsuite/useradmin/server/service/UserAdminService.java +++ b/src/main/java/org/gridsuite/useradmin/server/service/UserAdminService.java @@ -10,16 +10,14 @@ import org.gridsuite.useradmin.server.UserAdminException; import org.gridsuite.useradmin.server.dto.UserConnection; import org.gridsuite.useradmin.server.dto.UserInfos; +import org.gridsuite.useradmin.server.repository.AnnouncementEntity; import org.gridsuite.useradmin.server.repository.UserAdminRepository; import org.gridsuite.useradmin.server.repository.UserInfosEntity; import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collection; -import java.util.List; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import static org.gridsuite.useradmin.server.UserAdminException.Type.FORBIDDEN; @@ -30,17 +28,17 @@ public class UserAdminService { private final UserAdminRepository userAdminRepository; private final ConnectionsService connectionsService; + private final AnnouncementService announcementService; private final UserAdminApplicationProps applicationProps; - private final NotificationService notificationService; public UserAdminService(final UserAdminApplicationProps applicationProps, final UserAdminRepository userAdminRepository, final ConnectionsService connectionsService, - final NotificationService notificationService) { + final AnnouncementService announcementService) { this.applicationProps = Objects.requireNonNull(applicationProps); this.userAdminRepository = Objects.requireNonNull(userAdminRepository); this.connectionsService = Objects.requireNonNull(connectionsService); - this.notificationService = Objects.requireNonNull(notificationService); + this.announcementService = Objects.requireNonNull(announcementService); } private boolean isAdmin(@lombok.NonNull String sub) { @@ -109,21 +107,18 @@ public boolean userIsAdmin(@NonNull String userId) { return isAdmin(userId); } - public void sendMaintenanceMessage(String userId, Integer durationInSeconds, String message) { - if (!isAdmin(userId)) { - throw new UserAdminException(FORBIDDEN); - } - if (durationInSeconds == null) { - notificationService.emitMaintenanceMessage(message); - } else { - notificationService.emitMaintenanceMessage(message, durationInSeconds); - } + public void sendAnnouncement(AnnouncementEntity announcement, String userId) { + assertIsAdmin(userId); + announcementService.sendAnnouncement(announcement); } - public void sendCancelMaintenanceMessage(String userId) { - if (!isAdmin(userId)) { - throw new UserAdminException(FORBIDDEN); - } - notificationService.emitCancelMaintenanceMessage(); + public void cancelAnnouncement(UUID announcementId, String userId) { + assertIsAdmin(userId); + announcementService.cancelAnnouncement(announcementId); + } + + public List getAllAnnouncements(String userId) { + assertIsAdmin(userId); + return announcementService.getAllAnnouncements(); } } diff --git a/src/main/resources/db/changelog/changesets/changelog_20240329T134444Z.xml b/src/main/resources/db/changelog/changesets/changelog_20240329T134444Z.xml new file mode 100644 index 0000000..beabc65 --- /dev/null +++ b/src/main/resources/db/changelog/changesets/changelog_20240329T134444Z.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index c03daae..9d0d4b5 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -8,3 +8,6 @@ databaseChangeLog: - include: file: changesets/changelog_20240202T000000Z.xml relativeToChangelogFile: true + - include: + file: changesets/changelog_20240329T134444Z.xml + relativeToChangelogFile: true diff --git a/src/test/java/org/gridsuite/useradmin/server/UserAdminTest.java b/src/test/java/org/gridsuite/useradmin/server/UserAdminTest.java index 17e3d0e..1329ef2 100644 --- a/src/test/java/org/gridsuite/useradmin/server/UserAdminTest.java +++ b/src/test/java/org/gridsuite/useradmin/server/UserAdminTest.java @@ -8,11 +8,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import org.gridsuite.useradmin.server.repository.ConnectionEntity; -import org.gridsuite.useradmin.server.repository.ConnectionRepository; -import org.gridsuite.useradmin.server.repository.UserAdminRepository; -import org.gridsuite.useradmin.server.repository.UserInfosEntity; -import org.gridsuite.useradmin.server.service.NotificationService; +import org.gridsuite.useradmin.server.repository.*; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -25,10 +21,13 @@ import org.springframework.messaging.MessageHeaders; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import java.time.Duration; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.List; +import java.util.UUID; import static org.gridsuite.useradmin.server.service.NotificationService.*; import static org.junit.jupiter.api.Assertions.*; @@ -56,7 +55,7 @@ class UserAdminTest { private ConnectionRepository connectionRepository; @Autowired - private NotificationService notificationService; + private AnnouncementRepository announcementRepository; @Autowired private OutputDestination output; @@ -69,6 +68,7 @@ class UserAdminTest { public void cleanDB() { userAdminRepository.deleteAll(); connectionRepository.deleteAll(); + announcementRepository.deleteAll(); } private static final String USER_SUB = "user1"; @@ -254,49 +254,80 @@ void testGetConnections() throws Exception { @Test public void testSendMaintenanceMessage() throws Exception { //Send a maintenance message and expect everything to be ok - String requestBody = objectMapper.writeValueAsString("The application will be on maintenance until the end of the maintenance"); - Integer duration = 300; - mockMvc.perform(post("/" + UserAdminApi.API_VERSION + "/messages/maintenance", USER_SUB) - .queryParam("durationInSeconds", duration.toString()) + String message = "The application will be on maintenance until the end of the maintenance"; + Duration duration = Duration.ofSeconds(300); + AnnouncementEntity announcement = new AnnouncementEntity(message, duration); + String requestBody = objectMapper.writeValueAsString(announcement); + mockMvc.perform(post("/" + UserAdminApi.API_VERSION + "/announcements") .header("userId", ADMIN_USER) .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isOk()); - assertMaintenanceMessageSent(requestBody, duration); + assertMaintenanceMessageSent(message, duration.toSeconds()); + assertEquals(1, announcementRepository.findAll().size()); //Send a maintenance message without duration and expect everything to be ok - mockMvc.perform(post("/" + UserAdminApi.API_VERSION + "/messages/maintenance") + announcement = new AnnouncementEntity(message, null); + requestBody = objectMapper.writeValueAsString(announcement); + mockMvc.perform(post("/" + UserAdminApi.API_VERSION + "/announcements") .header("userId", ADMIN_USER) .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isOk()); - assertMaintenanceMessageSent(requestBody, null); + assertMaintenanceMessageSent(message, null); + assertEquals(1, announcementRepository.findAll().size()); // only one at a time for now //Send a maintenance message with a user that's not an admin and expect 403 FORBIDDEN - mockMvc.perform(post("/" + UserAdminApi.API_VERSION + "/messages/maintenance") - .queryParam("durationInSeconds", String.valueOf(duration)) + mockMvc.perform(post("/" + UserAdminApi.API_VERSION + "/announcements") .header("userId", NOT_ADMIN) .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isForbidden()); - } @Test public void testCancelMaintenanceMessage() throws Exception { + AnnouncementEntity announcement = announcementRepository.save(new AnnouncementEntity("I think dangling line is a funny name for a line", Duration.ofSeconds(60))); + assertEquals(1, announcementRepository.findAll().size()); + //Send a cancel maintenance message and expect everything to be ok - mockMvc.perform(post("/" + UserAdminApi.API_VERSION + "/messages/cancel-maintenance") + mockMvc.perform(delete("/" + UserAdminApi.API_VERSION + "/announcements/" + announcement.getId()) .header("userId", ADMIN_USER)) .andExpect(status().isOk()); assertCancelMaintenanceMessageSent(); + assertEquals(0, announcementRepository.findAll().size()); + + // With a non-existing ID, expect 404 + mockMvc.perform(delete("/" + UserAdminApi.API_VERSION + "/announcements/" + UUID.randomUUID()) + .header("userId", ADMIN_USER)) + .andExpect(status().isNotFound()); //Send a cancel maintenance message with a user that's not an admin and expect 403 FORBIDDEN - mockMvc.perform(post("/" + UserAdminApi.API_VERSION + "/messages/cancel-maintenance") + mockMvc.perform(delete("/" + UserAdminApi.API_VERSION + "/announcements/" + UUID.randomUUID()) + .header("userId", NOT_ADMIN)) + .andExpect(status().isForbidden()); + } + + @Test + void testGetAllMaintenanceMessages() throws Exception { + announcementRepository.save(new AnnouncementEntity("I think dangling line is a funny name for a line", Duration.ofSeconds(60))); + assertEquals(1, announcementRepository.findAll().size()); + + // Try to retrieve all the messages and expect everything to be ok + MvcResult mvcResult = mockMvc.perform(get("/" + UserAdminApi.API_VERSION + "/announcements") + .header("userId", ADMIN_USER)) + .andExpect(status().isOk()).andReturn(); + List announcements = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), new TypeReference<>() { + }); + assertEquals(1, announcements.size()); + + // Try to retrieve all the messages with a user that's not an admin and expect 403 FORBIDDEN + mockMvc.perform(get("/" + UserAdminApi.API_VERSION + "/announcements") .header("userId", NOT_ADMIN)) .andExpect(status().isForbidden()); } - private void assertMaintenanceMessageSent(String maintenanceMessage, Integer duration) { + private void assertMaintenanceMessageSent(String maintenanceMessage, Long duration) { Message message = output.receive(TIMEOUT, maintenanceMessageDestination); assertEquals(maintenanceMessage, new String(message.getPayload())); MessageHeaders headers = message.getHeaders(); From 3445b04da049dff05c50e5cfcdc8506d3a8a02ad Mon Sep 17 00:00:00 2001 From: Florent MILLOT Date: Wed, 3 Apr 2024 15:26:45 +0200 Subject: [PATCH 2/2] Refactor operation names to clarify functionality. Signed-off-by: Florent MILLOT --- .../useradmin/server/UserAdminController.java | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/gridsuite/useradmin/server/UserAdminController.java b/src/main/java/org/gridsuite/useradmin/server/UserAdminController.java index 32655b0..d2b0127 100644 --- a/src/main/java/org/gridsuite/useradmin/server/UserAdminController.java +++ b/src/main/java/org/gridsuite/useradmin/server/UserAdminController.java @@ -111,37 +111,37 @@ public ResponseEntity> getConnections(@RequestHeader("userI } @PostMapping(value = "/announcements") - @Operation(summary = "Send a message to all the connected users") + @Operation(summary = "Send an announcement to all the connected users") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "message sent"), + @ApiResponse(responseCode = "200", description = "announcement sent"), @ApiResponse(responseCode = "403", description = "user is not an admin") }) - public ResponseEntity sendMaintenanceMessage(@RequestHeader("userId") String userId, - @RequestBody AnnouncementEntity announcement) { + public ResponseEntity sendAnnouncement(@RequestHeader("userId") String userId, + @RequestBody AnnouncementEntity announcement) { service.sendAnnouncement(announcement, userId); return ResponseEntity.ok().build(); } @DeleteMapping(value = "/announcements/{announcementId}") - @Operation(summary = "Cancel and delete a message") + @Operation(summary = "Cancel and delete an announcement") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "message canceled"), + @ApiResponse(responseCode = "200", description = "announcement canceled"), @ApiResponse(responseCode = "403", description = "user is not an admin"), - @ApiResponse(responseCode = "404", description = "message not found") + @ApiResponse(responseCode = "404", description = "announcement not found") }) - public ResponseEntity sendCancelMaintenanceMessage(@RequestHeader("userId") String userId, - @PathVariable("announcementId") UUID announcementId) { + public ResponseEntity cancelAnnouncement(@RequestHeader("userId") String userId, + @PathVariable("announcementId") UUID announcementId) { service.cancelAnnouncement(announcementId, userId); return ResponseEntity.ok().build(); } @GetMapping(value = "/announcements") - @Operation(summary = "Get all the messages") + @Operation(summary = "Get all the announcements") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "messages retrieved"), + @ApiResponse(responseCode = "200", description = "announcements retrieved"), @ApiResponse(responseCode = "403", description = "user is not an admin") }) - public ResponseEntity> sendCancelMaintenanceMessage(@RequestHeader("userId") String userId) { + public ResponseEntity> getAllAnnouncements(@RequestHeader("userId") String userId) { return ResponseEntity.ok(service.getAllAnnouncements(userId)); } }