From bd6e9b07f53f88ca4aa192f4c9daa7629d15a4bc Mon Sep 17 00:00:00 2001 From: Azmi TOUIL <42934070+AzmiTouil@users.noreply.github.com> Date: Thu, 14 Nov 2024 09:38:51 +0100 Subject: [PATCH] feat: Export reward list of a period - MEED-7487 - Meeds-io/MIPs#154 (#611) This PR will implement the ability of export reward list of a period. --- .../reward/service/RewardReportService.java | 15 +++ .../wallet/reward/rest/RewardReportREST.java | 38 ++++++- .../service/WalletRewardReportService.java | 102 +++++++++++++++++- .../WalletRewardReportServiceTest.java | 60 +++++++++++ .../locale/addon/Wallet_en.properties | 5 + .../vue-app/wallet-common/js/RewardService.js | 16 +++ .../components/reward/RewardDetails.vue | 26 ++++- 7 files changed, 254 insertions(+), 8 deletions(-) diff --git a/wallet-api/src/main/java/io/meeds/wallet/reward/service/RewardReportService.java b/wallet-api/src/main/java/io/meeds/wallet/reward/service/RewardReportService.java index 58ef22a4e..ab4469090 100644 --- a/wallet-api/src/main/java/io/meeds/wallet/reward/service/RewardReportService.java +++ b/wallet-api/src/main/java/io/meeds/wallet/reward/service/RewardReportService.java @@ -16,9 +16,11 @@ */ package io.meeds.wallet.reward.service; +import java.io.InputStream; import java.time.LocalDate; import java.time.ZoneId; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -208,4 +210,17 @@ public interface RewardReportService { * */ Map getRewardSettingChanged(); + + /** + * Export wallet rewards into an {@link InputStream} containing a file of format + * XLS + * + * @param periodId Reward Period id + * @param status Wallet reward status + * @param zoneId Wallet reward status + * @param fileName fileName to export + * @param locale locale + * @return {@link InputStream} of a file of format XLS + */ + InputStream exportXlsx(long periodId, String status, ZoneId zoneId, String fileName, Locale locale); } diff --git a/wallet-reward-services/src/main/java/io/meeds/wallet/reward/rest/RewardReportREST.java b/wallet-reward-services/src/main/java/io/meeds/wallet/reward/rest/RewardReportREST.java index b9a8f7abd..3d18a72fe 100644 --- a/wallet-reward-services/src/main/java/io/meeds/wallet/reward/rest/RewardReportREST.java +++ b/wallet-reward-services/src/main/java/io/meeds/wallet/reward/rest/RewardReportREST.java @@ -18,6 +18,7 @@ import static io.meeds.wallet.utils.RewardUtils.timeToSecondsAtDayStart; +import java.io.InputStream; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.ZoneId; @@ -26,7 +27,6 @@ import java.time.temporal.TemporalAdjusters; import java.util.List; import java.util.Objects; -import java.util.stream.Collectors; import java.util.stream.IntStream; import javax.ws.rs.core.*; @@ -48,6 +48,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -147,6 +149,40 @@ public PagedModel> getWalletRewards(Pageable pageable, return assembler.toModel(walletRewards); } + @GetMapping(path = "/export") + @Secured("rewarding") + @Operation( + summary = "Exports wallet rewards for a specified period as an XLSX file", + method = "GET", + description = "Generates and returns an XLSX file containing wallet rewards data for the specified period.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Request fulfilled, file download initiated"), + @ApiResponse(responseCode = "400", description = "Invalid query input"), + @ApiResponse(responseCode = "401", description = "Unauthorized access to export data"), + @ApiResponse(responseCode = "500", description = "Internal server error, file export failed")}) + public ResponseEntity exportXlsx(HttpServletRequest request, + @Parameter(description = "Period id", required = true) + @RequestParam("periodId") + long periodId, + @Parameter(description = "Wallet reward status filtering, possible values: VALId and INVALID. Default value = VALId.") + @RequestParam(value = "status", defaultValue = "VALID") + String status, + @Parameter(description = "Wallet reward exported file name") + @RequestParam(value = "fileName") + String fileName) { + + InputStream inputStream = rewardReportService.exportXlsx(periodId, + status, + rewardSettingsService.getSettings().zoneId(), + fileName, + request.getLocale()); + InputStreamResource resource = new InputStreamResource(inputStream); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=" + fileName + ".xlsx") + .header("Content-Type", "application/vnd.ms-excel") + .body(resource); + } + @PostMapping(path = "forecast") @Secured("rewarding") @Operation( diff --git a/wallet-reward-services/src/main/java/io/meeds/wallet/reward/service/WalletRewardReportService.java b/wallet-reward-services/src/main/java/io/meeds/wallet/reward/service/WalletRewardReportService.java index 6268918b5..8fdbc7f86 100644 --- a/wallet-reward-services/src/main/java/io/meeds/wallet/reward/service/WalletRewardReportService.java +++ b/wallet-reward-services/src/main/java/io/meeds/wallet/reward/service/WalletRewardReportService.java @@ -32,7 +32,13 @@ import static io.meeds.wallet.utils.WalletUtils.getResourceBundleKey; import static io.meeds.wallet.utils.WalletUtils.isUserRewardingAdmin; +import java.io.*; import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.text.SimpleDateFormat; import java.time.LocalDate; import java.time.ZoneId; import java.util.*; @@ -41,10 +47,17 @@ import java.util.stream.Collectors; import io.meeds.gamification.constant.RealizationStatus; +import io.meeds.gamification.utils.Utils; import io.meeds.wallet.model.*; import lombok.Getter; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.SystemUtils; +import org.apache.poi.ss.usermodel.CreationHelper; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.exoplatform.services.resources.ResourceBundleService; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -74,6 +87,12 @@ public class WalletRewardReportService implements RewardReportService { private static final String EMPTY_SETTINGS = "Error computing rewards using empty settings"; + // File header + private static final String[] COLUMNS = new String[] { "fullName", "address", "points", "rewards", + "status", "sentDate", "transactionHash" }; + + private static final String SHEET_NAME = "reward-detail"; + private WalletAccountService walletAccountService; private WalletTokenAdminService walletTokenAdminService; @@ -84,6 +103,8 @@ public class WalletRewardReportService implements RewardReportService { private RealizationService realizationService; + private ResourceBundleService resourceBundleService; + @Setter private boolean rewardSendingInProgress; @@ -94,12 +115,14 @@ public WalletRewardReportService(WalletAccountService walletAccountService, WalletTokenAdminService walletTokenAdminService, RewardSettingsService rewardSettingsService, WalletRewardReportStorage rewardReportStorage, - RealizationService realizationService) { + RealizationService realizationService, + ResourceBundleService resourceBundleService) { this.walletAccountService = walletAccountService; this.walletTokenAdminService = walletTokenAdminService; this.rewardSettingsService = rewardSettingsService; this.rewardReportStorage = rewardReportStorage; this.realizationService = realizationService; + this.resourceBundleService = resourceBundleService; } @Override @@ -394,7 +417,36 @@ public void replaceRewardTransactions(String oldHash, String newHash) { public Page findWalletRewardsByPeriodIdAndStatus(long periodId, String status, ZoneId zoneId, Pageable pageable) { boolean isValid = !status.equals("INVALID"); return rewardReportStorage.findWalletRewardsByPeriodIdAndStatus(periodId, isValid, zoneId, pageable); - } + } + + @Override + public InputStream exportXlsx(long periodId, + String status, + ZoneId zoneId, + String fileName, Locale locale) { + File temp = null; + try { // NOSONAR + temp = createTempFile(fileName); + Page walletRewardPage = findWalletRewardsByPeriodIdAndStatus(periodId, status, zoneId, null); + try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream outputStream = new FileOutputStream(temp)) { + int rowIndex = 0; + CreationHelper helper = workbook.getCreationHelper(); + Sheet sheet = workbook.createSheet(SHEET_NAME); + appendRewardsHeaderRow(sheet, rowIndex++, helper, locale); + for (WalletReward walletReward : walletRewardPage.getContent()) { + appendWalletRewardRow(sheet, rowIndex++, helper, walletReward); + } + workbook.write(outputStream); + } + return new FileInputStream(temp); + } catch (IOException e) { + throw new IllegalStateException("Error exporting XLSX file for wallet rewards ", e); + } finally { + if (temp != null && temp.exists()) { + temp.deleteOnExit(); + } + } + } @Override public double countWalletRewardsPointsByPeriodIdAndStatus(long periodId, boolean isValid) { @@ -405,6 +457,52 @@ public void setRewardSettingChanged(Map updatedSettings) { rewardSettingChanged.putAll(updatedSettings); } + private File createTempFile(String fileName) throws IOException { + SimpleDateFormat formatter = new SimpleDateFormat("yy-MM-dd_HH-mm-ss"); + fileName += formatter.format(new Date()); + if (SystemUtils.IS_OS_UNIX) { + FileAttribute> tempFileAttributes = + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")); + return Files.createTempFile(fileName, ".xlsx", tempFileAttributes).toFile(); + } else { + File temp = Files.createTempFile(fileName, ".xlsx").toFile(); + if (!temp.setReadable(true, true) || !temp.setWritable(true, true)) { + throw new IllegalStateException("Can't write a temp file to export XLS achievements file"); + } + return temp; + } + } + + private void appendRewardsHeaderRow(Sheet sheet, int rowIndex, CreationHelper helper, Locale locale) { + Row row = sheet.createRow(rowIndex); + ResourceBundle resourceBundle = resourceBundleService.getResourceBundle("locale.addon.Wallet", locale); + for (int i = 0; i < COLUMNS.length; i++) { + row.createCell(i).setCellValue(helper.createRichTextString(resourceBundle.getString("wallet.administration.rewardDetails.label." + COLUMNS[i]))); + } + } + + private void appendWalletRewardRow(Sheet sheet, int rowIndex, CreationHelper helper, WalletReward walletReward) { + Row row = sheet.createRow(rowIndex); + try { + int cellIndex = 0; + row.createCell(cellIndex++).setCellValue(Utils.getUserFullName(String.valueOf(walletReward.getIdentityId()))); + row.createCell(cellIndex++).setCellValue(walletReward.getWallet().getAddress()); + row.createCell(cellIndex++).setCellValue(walletReward.getPoints()); + row.createCell(cellIndex++).setCellValue(walletReward.getAmount()); + row.createCell(cellIndex++).setCellValue(walletReward.getStatus()); + appendTransactionCells(row, cellIndex, helper, walletReward.getTransaction()); + } catch (Exception e) { + LOG.error("Error when computing to XLSX ", e); + } + } + + private void appendTransactionCells(Row row, int cellIndex, CreationHelper helper, TransactionDetail transaction) { + if (transaction != null) { + row.createCell(cellIndex++).setCellValue(helper.createRichTextString(String.valueOf(new Date(transaction.getSentTimestamp())))); + row.createCell(cellIndex).setCellValue(transaction.getHash()); + } + } + private RewardPeriod getRewardPeriod(LocalDate date) { RewardSettings rewardSettings = rewardSettingsService.getSettings(); RewardPeriodType periodType = rewardSettings.getPeriodType(); diff --git a/wallet-reward-services/src/test/java/io/meeds/wallet/reward/service/WalletRewardReportServiceTest.java b/wallet-reward-services/src/test/java/io/meeds/wallet/reward/service/WalletRewardReportServiceTest.java index 242d47c0e..418c218d3 100644 --- a/wallet-reward-services/src/test/java/io/meeds/wallet/reward/service/WalletRewardReportServiceTest.java +++ b/wallet-reward-services/src/test/java/io/meeds/wallet/reward/service/WalletRewardReportServiceTest.java @@ -18,6 +18,7 @@ */ package io.meeds.wallet.reward.service; +import java.io.InputStream; import java.math.BigInteger; import java.time.LocalDate; import java.time.YearMonth; @@ -27,9 +28,16 @@ import io.meeds.gamification.model.filter.RealizationFilter; import io.meeds.gamification.service.RealizationService; +import io.meeds.gamification.utils.Utils; import io.meeds.wallet.model.*; import io.meeds.wallet.utils.WalletUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; import org.exoplatform.container.PortalContainer; +import org.exoplatform.services.resources.ResourceBundleService; import org.exoplatform.services.security.Identity; import org.exoplatform.services.security.IdentityRegistry; import org.exoplatform.services.security.MembershipEntry; @@ -45,6 +53,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import static org.junit.Assert.assertThrows; @@ -74,6 +83,9 @@ public class WalletRewardReportServiceTest { // NOSONAR @MockBean private RealizationService realizationService; + @MockBean + private ResourceBundleService resourceBundleService; + @Autowired private RewardReportService rewardReportService; @@ -368,6 +380,54 @@ void testListRewards() { verify(rewardReportStorage, times(1)).countRewards(1); } + @Test + void testExportRewards() throws Exception { + RewardSettings newSettings = new RewardSettings(); + + WalletReward walletReward = new WalletReward(); + walletReward.setIdentityId(1); + walletReward.setPoints(200); + walletReward.setAmount(50); + Wallet wallet = new Wallet(); + wallet.setAddress("address1"); + walletReward.setWallet(new Wallet()); + TransactionDetail transactionDetail = new TransactionDetail(); + transactionDetail.setSucceeded(true); + transactionDetail.setHash("transactionHash"); + transactionDetail.setSentTimestamp(new Date().getTime()); + walletReward.setTransaction(transactionDetail); + + when(rewardReportStorage.findWalletRewardsByPeriodIdAndStatus(1, + true, + newSettings.zoneId(), + null)).thenReturn(new PageImpl<>(List.of(walletReward))); + ResourceBundle resourceBundle = mock(ResourceBundle.class); + when(resourceBundle.getString(anyString())).thenReturn("header"); + when(resourceBundleService.getResourceBundle(anyString(), any(Locale.class))).thenReturn(resourceBundle); + + InputStream exportInputStream = rewardReportService.exportXlsx(1, "VALID", newSettings.zoneId(), "file-name", Locale.ENGLISH); + assertNotNull(exportInputStream); + + Workbook workbook = WorkbookFactory.create(exportInputStream); + assertNotNull(workbook); + Sheet sheet = workbook.getSheetAt(0); + assertNotNull(sheet); + assertEquals(1, sheet.getLastRowNum()); + Row header = sheet.getRow(0); + assertNotNull(header); + assertEquals(7, header.getLastCellNum()); + assertTrue(StringUtils.isNotBlank(header.getCell(header.getFirstCellNum()).getStringCellValue())); + assertTrue(StringUtils.isNotBlank(header.getCell(header.getLastCellNum() - 1).getStringCellValue())); + + Row row1 = sheet.getRow(1); + assertNotNull(row1); + assertEquals(7, row1.getLastCellNum()); + assertEquals(200, row1.getCell(2).getNumericCellValue()); + assertEquals(50, row1.getCell(3).getNumericCellValue()); + assertEquals("success", row1.getCell(4).getStringCellValue()); + assertEquals("transactionHash", row1.getCell(6).getStringCellValue()); + } + protected Wallet newWallet(long identityId) { Wallet wallet = new Wallet(); wallet.setTechnicalId(identityId); diff --git a/wallet-services/src/main/resources/locale/addon/Wallet_en.properties b/wallet-services/src/main/resources/locale/addon/Wallet_en.properties index 2038d9e58..a1b7f379f 100644 --- a/wallet-services/src/main/resources/locale/addon/Wallet_en.properties +++ b/wallet-services/src/main/resources/locale/addon/Wallet_en.properties @@ -567,12 +567,17 @@ wallet.administration.rewardDetails.label.status=Status wallet.administration.rewardDetails.label.points=Points wallet.administration.rewardDetails.label.rewards=Rewards wallet.administration.rewardDetails.label.actions=Actions +wallet.administration.rewardDetails.label.fullName=Full name +wallet.administration.rewardDetails.label.address=Address +wallet.administration.rewardDetails.label.sentDate=Sent Date +wallet.administration.rewardDetails.label.transactionHash=Transaction hash wallet.administration.rewardDetails.label.openTransaction=Open Transaction wallet.administration.rewardDetails.label.seeHistory=See History wallet.administration.rewardDetails.label.latestRewardsSent=Latest Rewards Sent on: wallet.administration.rewardDetails.label.rewardsToSend={0} MEED for {1} points wallet.administration.rewardDetails.label.reward=Reward wallet.administration.rewardDetails.label.notSentYet=Not sent yet +wallet.administration.rewardDetails.label.export=Export wallet.administration.rewardDetails.label=Rewards Details wallet.overview.rewards.title=Wallet History diff --git a/wallet-webapps/src/main/webapp/vue-app/wallet-common/js/RewardService.js b/wallet-webapps/src/main/webapp/vue-app/wallet-common/js/RewardService.js index d81d67e44..d87f9467e 100644 --- a/wallet-webapps/src/main/webapp/vue-app/wallet-common/js/RewardService.js +++ b/wallet-webapps/src/main/webapp/vue-app/wallet-common/js/RewardService.js @@ -188,6 +188,22 @@ export function computeRewardsByUser(date) { }); } +export function exportXlsxUrl(paramsObj) { + const formData = new FormData(); + if (paramsObj) { + Object.keys(paramsObj).forEach(key => { + const value = paramsObj[key]; + if (window.Array && Array.isArray && Array.isArray(value)) { + value.forEach(val => formData.append(key, val)); + } else { + formData.append(key, value); + } + }); + } + const params = new URLSearchParams(formData).toString(); + return `/wallet/rest/reward/export?${params}`; +} + export function getRewardsByUser(limit) { return fetch(`/wallet/rest/reward/list?limit=${limit || 10}`, { method: 'GET', diff --git a/wallet-webapps/src/main/webapp/vue-app/wallet-reward/components/reward/RewardDetails.vue b/wallet-webapps/src/main/webapp/vue-app/wallet-reward/components/reward/RewardDetails.vue index d6f6bb26e..dc2d9bb80 100644 --- a/wallet-webapps/src/main/webapp/vue-app/wallet-reward/components/reward/RewardDetails.vue +++ b/wallet-webapps/src/main/webapp/vue-app/wallet-reward/components/reward/RewardDetails.vue @@ -78,15 +78,26 @@ element="div" /> + @filter-select-change="status = $event"> + +