diff --git a/pom.xml b/pom.xml index c2c4bc1d53910e4c7691ea374c6b6c4c0fec6932..e372339da515df616f6d0ebcc41bc1d67186e204 100755 --- a/pom.xml +++ b/pom.xml @@ -266,6 +266,21 @@ <version>2.0.11</version> <scope>provided</scope> </dependency> + <dependency> + <groupId>org.apache.poi</groupId> + <artifactId>poi</artifactId> + <version>5.3.0</version> + </dependency> + <dependency> + <groupId>org.apache.poi</groupId> + <artifactId>poi-ooxml</artifactId> + <version>5.3.0</version> + </dependency> + <dependency> + <groupId>commons-io</groupId> + <artifactId>commons-io</artifactId> + <version>2.13.0</version> + </dependency> </dependencies> <build> diff --git a/src/main/java/no/nibio/vips/logic/controller/session/ObservationBean.java b/src/main/java/no/nibio/vips/logic/controller/session/ObservationBean.java index e205348f4a9e392b952760fb525d084c284033a6..7c79e6dd1ea1a0e21c8cdb622a191246708ec01e 100755 --- a/src/main/java/no/nibio/vips/logic/controller/session/ObservationBean.java +++ b/src/main/java/no/nibio/vips/logic/controller/session/ObservationBean.java @@ -520,10 +520,6 @@ public class ObservationBean { .filter(o -> o.getObservationTimeSeriesId() != null) .forEach(o -> o.setObservationTimeSeries(timeSeriesMap.get(o.getObservationTimeSeriesId()))); - for (Observation o : observations) { - LOGGER.info("{}", o); - } - return observations; } private List<Observation> getObservationsWithObservers(List<Observation> observations) { diff --git a/src/main/java/no/nibio/vips/logic/service/ObservationService.java b/src/main/java/no/nibio/vips/logic/service/ObservationService.java index daf359258690e26b9c4a7393a5b2ce1e945554c3..128a5bf9c241cb2103a9bc04d34486a1d313def7 100755 --- a/src/main/java/no/nibio/vips/logic/service/ObservationService.java +++ b/src/main/java/no/nibio/vips/logic/service/ObservationService.java @@ -32,6 +32,7 @@ import no.nibio.vips.logic.entity.rest.ObservationListItem; import no.nibio.vips.logic.entity.rest.PointMappingResponse; import no.nibio.vips.logic.entity.rest.ReferencedPoint; import no.nibio.vips.logic.messaging.MessagingBean; +import no.nibio.vips.logic.util.ExcelFileGenerator; import no.nibio.vips.logic.util.GISEntityUtil; import no.nibio.vips.logic.util.Globals; import org.jboss.resteasy.annotations.GZIP; @@ -59,6 +60,8 @@ import java.net.URI; import java.text.ParseException; import java.text.SimpleDateFormat; import java.time.Instant; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; @@ -130,12 +133,13 @@ public class ObservationService { } /** - * @param organizationId Database ID of the organization - * @param pestId Database ID of the pest - * @param cropId Database ID of the crop - * @param cropCategoryId cropCategoryId Database IDs of the crop category/categories - * @param fromStr format "yyyy-MM-dd" - * @param toStr format "yyyy-MM-dd" + * @param organizationId Database ID of the organization + * @param observationTimeSeriesId Database ID of the observation time series + * @param pestId Database ID of the pest + * @param cropId Database ID of the crop + * @param cropCategoryId cropCategoryId Database IDs of the crop category/categories + * @param fromStr format "yyyy-MM-dd" + * @param toStr format "yyyy-MM-dd" * @return Observation objects for which the user is authorized to observe with properties relevant for lists */ @GET @@ -158,6 +162,56 @@ public class ObservationService { return Response.ok().entity(this.getFilteredObservationListItems(organizationId, observationTimeSeriesId, pestId, cropId, cropCategoryId, fromStr, toStr, userUUID, localeStr, isPositive)).build(); } + /** + * @param organizationId Database ID of the organization + * @param observationTimeSeriesId Database ID of the observation time series + * @param pestId Database ID of the pest + * @param cropId Database ID of the crop + * @param cropCategoryId cropCategoryId Database IDs of the crop category/categories + * @param fromStr format "yyyy-MM-dd" + * @param toStr format "yyyy-MM-dd" + * @return Observation objects for which the user is authorized to observe with properties relevant for lists + */ + @GET + @Path("list/filter/{organizationId}/xlsx") + @GZIP + @Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + public Response getFilteredObservationListItemsAsXlsx( + @PathParam("organizationId") Integer organizationId, + @QueryParam("observationTimeSeriesId") Integer observationTimeSeriesId, + @QueryParam("pestId") Integer pestId, + @QueryParam("cropId") Integer cropId, + @QueryParam("cropCategoryId") List<Integer> cropCategoryId, + @QueryParam("from") String fromStr, + @QueryParam("to") String toStr, + @QueryParam("userUUID") String userUUID, + @QueryParam("locale") String localeStr, + @QueryParam("isPositive") Boolean isPositive + ) { + VipsLogicUser user = getVipsLogicUser(userUUID); + ULocale locale = new ULocale(localeStr != null ? localeStr : + user != null ? user.getOrganizationId().getDefaultLocale() : + userBean.getOrganization(organizationId).getDefaultLocale()); + LOGGER.info("Generate xlsx file for observations for user {}", user != null ? user.getUserId() : "unregistered"); + + LocalDateTime now = LocalDateTime.now(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + String filenameTimestamp = now.format(formatter); + + try { + List<ObservationListItem> observations = getFilteredObservationListItems(organizationId, observationTimeSeriesId, pestId, cropId, cropCategoryId, fromStr, toStr, userUUID, localeStr, isPositive); + byte[] excelFile = ExcelFileGenerator.generateExcel(observations, locale); + + return Response + .ok(excelFile) + .header("Content-Disposition", "attachment; filename=\"" + filenameTimestamp + "-observations.xlsx\"") + .build(); + } catch (IOException e) { + LOGGER.error(e.getMessage()); + return Response.serverError().entity("Error generating Excel file: " + e.getMessage()).build(); + } + } + private List<ObservationListItem> getFilteredObservationListItems( Integer organizationId, Integer observationTimeSeriesId, @@ -169,11 +223,7 @@ public class ObservationService { String userUUID, String localeStr, Boolean isPositive) { - VipsLogicUser user = (VipsLogicUser) httpServletRequest.getSession().getAttribute("user"); - - if (user == null && userUUID != null) { - user = userBean.findVipsLogicUser(UUID.fromString(userUUID)); - } + VipsLogicUser user = getVipsLogicUser(userUUID); ULocale locale = new ULocale(localeStr != null ? localeStr : user != null ? user.getOrganizationId().getDefaultLocale() : userBean.getOrganization(organizationId).getDefaultLocale()); @@ -766,14 +816,14 @@ public class ObservationService { } /** - * @param organizationId Database id of the organization + * @param organizationId Database id of the organization * @param observationTimeSeriesId Database id of the observation time series - * @param pestId Database id of the pest - * @param cropId Database id of the crop - * @param cropCategoryId Database ids of the crop categories - * @param fromStr format "yyyy-MM-dd" - * @param toStr format "yyyy-MM-dd" - * @param user The user that requests this (used for authorization) + * @param pestId Database id of the pest + * @param cropId Database id of the crop + * @param cropCategoryId Database ids of the crop categories + * @param fromStr format "yyyy-MM-dd" + * @param toStr format "yyyy-MM-dd" + * @param user The user that requests this (used for authorization) * @return A list of observations that meets the filter criteria */ private List<Observation> getFilteredObservationsFromBackend( @@ -803,10 +853,10 @@ public class ObservationService { LOGGER.info("Return {} masked public observations for unregistered user", retVal.size()); return sortObservationsByDateAndId(retVal); } - // Else: This is a registered user without special privileges. Show public observations + user's own + // Else: This is a registered user without special privileges. Show public observations + user's own // Making sure we don't add duplicates - Set<Integer> obsIds = retVal.stream().map(o->o.getObservationId()).collect(Collectors.toSet()); - retVal.addAll(observationBean.getObservationsForUser(user).stream().filter(o->!obsIds.contains(o.getObservationId())).collect(Collectors.toList())); + Set<Integer> obsIds = retVal.stream().map(o -> o.getObservationId()).collect(Collectors.toSet()); + retVal.addAll(observationBean.getObservationsForUser(user).stream().filter(o -> !obsIds.contains(o.getObservationId())).collect(Collectors.toList())); LOGGER.info("Return {} masked public observations and user's own observations for registered user {}", retVal.size(), user.getUserId()); return sortObservationsByDateAndId(retVal); } @@ -1085,6 +1135,21 @@ public class ObservationService { .collect(Collectors.toList()); } + /** + * Find VipsLogic user from session or given userUUID + * + * @param userUUID the UUID of the user + * @return the corresponding VipsLogicUser + */ + private VipsLogicUser getVipsLogicUser(String userUUID) { + VipsLogicUser user = (VipsLogicUser) httpServletRequest.getSession().getAttribute("user"); + if (user == null && userUUID != null) { + user = userBean.findVipsLogicUser(UUID.fromString(userUUID)); + } + return user; + } + + /** * Utility method for getting the string value of a given property in a given map. * diff --git a/src/main/java/no/nibio/vips/logic/util/ExcelFileGenerator.java b/src/main/java/no/nibio/vips/logic/util/ExcelFileGenerator.java new file mode 100644 index 0000000000000000000000000000000000000000..0e927d28cfd17357b6d7d3ad2633cd6ff51cc045 --- /dev/null +++ b/src/main/java/no/nibio/vips/logic/util/ExcelFileGenerator.java @@ -0,0 +1,201 @@ +package no.nibio.vips.logic.util; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ibm.icu.util.ULocale; +import no.nibio.vips.logic.entity.rest.ObservationListItem; +import no.nibio.vips.observationdata.ObservationDataSchema; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.apache.poi.ss.usermodel.*; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; + +public final class ExcelFileGenerator { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private static final int COL_INDEX_DATE = 0; + private static final int COL_INDEX_LOCATION = 1; + private static final int COL_INDEX_ORGANISM = 2; + private static final int COL_INDEX_CROP_ORGANISM = 3; + private static final int COL_INDEX_HEADING = 4; + private static final int COL_START_INDEX_DATA = 5; + + public static byte[] generateExcel(List<ObservationListItem> observations, ULocale locale) throws IOException { + ResourceBundle rb = ResourceBundle.getBundle("no.nibio.vips.logic.i18n.vipslogictexts", locale.toLocale()); + + try (XSSFWorkbook workbook = new XSSFWorkbook(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + + // Create main mainSheet for all observations, with header row + Sheet mainSheet = workbook.createSheet(rb.getString("allObservations")); + createHeaderRow(mainSheet, rb); + + int mainSheetRowIndex = 1; + // Add one row for each observation in list of all observations + for (ObservationListItem item : observations) { + createItemRow(mainSheet, mainSheetRowIndex++, item); + } + autoSizeColumns(mainSheet, 0, COL_INDEX_HEADING); + + // Prepare list of observations for each type of pest + Map<Integer, List<ObservationListItem>> pestObservations = getObservationsForEachPest(observations); + + // Create sheets for each individual pest type + for (Integer pestId : pestObservations.keySet()) { + List<ObservationListItem> observationsForPest = pestObservations.get(pestId); + ObservationListItem firstObservationForPest = observationsForPest.get(0); + String pestName = firstObservationForPest.getOrganismName(); + Sheet pestSheet = workbook.createSheet(pestName); + Row headerRow = createHeaderRow(pestSheet, rb); + + // Add column titles for observation data + Map<String, String> dataColumnTitles = getObservationDataColumnTitles(firstObservationForPest.getObservationDataSchema()); + int pestSheetColIndex = COL_START_INDEX_DATA; + for (String key : dataColumnTitles.keySet()) { + headerRow.createCell(pestSheetColIndex++).setCellValue(dataColumnTitles.get(key)); + } + + int pestSheetRowIndex = 1; + for (ObservationListItem item : observationsForPest) { + Row row = createItemRow(pestSheet, pestSheetRowIndex++, item); + + if (item.getObservationData() != null) { + Map<String, Object> observationDataMap = objectMapper.readValue(item.getObservationData(), HashMap.class); + if (observationDataMap != null) { + pestSheetColIndex = COL_START_INDEX_DATA; + for (String key : dataColumnTitles.keySet()) { + Object value = observationDataMap.get(key); + if (value instanceof Number) { + row.createCell(pestSheetColIndex++).setCellValue(((Number) value).intValue()); + } else { + row.createCell(pestSheetColIndex++).setCellValue(value != null ? value.toString() : ""); + } + } + } + } + } + autoSizeColumns(pestSheet, COL_INDEX_DATE, COL_START_INDEX_DATA + dataColumnTitles.size()); + } + + workbook.write(out); + return out.toByteArray(); + } + } + + /** + * Auto-size columns of given sheet, from startIndex to endIndex, to ensure that content fits + * + * @param sheet The sheet of which to auto-size columns + * @param startIndex The index of the first column to auto-size + * @param endIndex The index of the last column to auto-size + */ + private static void autoSizeColumns(Sheet sheet, int startIndex, int endIndex) { + for (int i = startIndex; i <= endIndex; i++) { + sheet.autoSizeColumn(i); + } + } + + /** + * Create map with data property name as key, and corresponding localized name as value + * + * @param observationDataSchema The observation data schema which contains localized names and other info + * @return result map + */ + private static Map<String, String> getObservationDataColumnTitles(ObservationDataSchema observationDataSchema) throws JsonProcessingException { + JsonNode rootNode = objectMapper.readTree(observationDataSchema.getDataSchema()); + JsonNode propertiesNode = rootNode.path("properties"); + Map<String, String> resultMap = new HashMap<>(); + Iterator<Map.Entry<String, JsonNode>> fields = propertiesNode.fields(); + while (fields.hasNext()) { + Map.Entry<String, JsonNode> field = fields.next(); + String key = field.getKey(); + String value = field.getValue().path("title").asText(); + resultMap.put(key, value); + } + return resultMap; + } + + /** + * Find the name of the point of interest in given geoInfo string + * + * @param geoInfo The geoInfo which might contain the name of the point of interest + * @return the point of interest name, or empty string + */ + private static String getPointOfInterestName(String geoInfo) throws JsonProcessingException { + JsonNode rootNode = objectMapper.readTree(geoInfo); + JsonNode featuresNode = rootNode.path("features"); + if (featuresNode.isArray() && !featuresNode.isEmpty()) { + JsonNode firstFeature = featuresNode.get(0); + JsonNode propertiesNode = firstFeature.path("properties"); + return propertiesNode.path("pointOfInterestName").asText(); + } + return ""; + } + + /** + * Create map with pestId as key, and list of corresponding observations as value + * + * @param observations The original list of observations + * @return result map + */ + private static Map<Integer, List<ObservationListItem>> getObservationsForEachPest(List<ObservationListItem> observations) { + Map<Integer, List<ObservationListItem>> pestObservations = new HashMap<>(); + for (ObservationListItem observation : observations) { + Integer pestId = observation.getOrganismId(); + if (!pestObservations.containsKey(pestId)) { + List<ObservationListItem> observationList = new ArrayList<>(); + observationList.add(observation); + pestObservations.put(pestId, observationList); + } else { + pestObservations.get(pestId).add(observation); + } + } + return pestObservations; + } + + /** + * Create row with given index, for given observation list item + * + * @param sheet The sheet to which a row will be added + * @param rowIndex The index of the row + * @param item The item of which to add data + * @return the newly created row + */ + private static Row createItemRow(Sheet sheet, int rowIndex, ObservationListItem item) throws JsonProcessingException { + LocalDate localDateOfObservation = item.getTimeOfObservation().toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + String pointOfInterestName = getPointOfInterestName(item.getGeoInfo()); + + Row row = sheet.createRow(rowIndex); + row.createCell(COL_INDEX_DATE).setCellValue(localDateOfObservation.format(DATE_FORMATTER)); + row.createCell(COL_INDEX_LOCATION).setCellValue(pointOfInterestName); + row.createCell(COL_INDEX_ORGANISM).setCellValue(item.getOrganismName()); + row.createCell(COL_INDEX_CROP_ORGANISM).setCellValue(item.getCropOrganismName()); + row.createCell(COL_INDEX_HEADING).setCellValue(item.getObservationHeading()); + return row; + } + + /** + * Create first row of given sheet, with standard set of column titles + * + * @param sheet The sheet to which a row will be added + * @param rb A resource bundle enabling localized messages + * @return the newly created header row + */ + public static Row createHeaderRow(Sheet sheet, ResourceBundle rb) { + Row headerRow = sheet.createRow(0); + headerRow.createCell(COL_INDEX_DATE).setCellValue(rb.getString("timeOfObservation")); + headerRow.createCell(COL_INDEX_LOCATION).setCellValue(rb.getString("location")); + headerRow.createCell(COL_INDEX_ORGANISM).setCellValue(rb.getString("organism")); + headerRow.createCell(COL_INDEX_CROP_ORGANISM).setCellValue(rb.getString("cropOrganismId")); + headerRow.createCell(COL_INDEX_HEADING).setCellValue(rb.getString("observationHeading")); + return headerRow; + } +} diff --git a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties index ea2b3d71b173ccf69b53bbcdf2fb17817f0cf4fb..e7caec3ae87ef744e17f826821f0ca3da4b91607 100755 --- a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties +++ b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties @@ -556,6 +556,8 @@ observationDataField_trapCountCropInside = Number of trap counts inside the fiel observationDataField_unit = Measuring unit +allObservations = All observations + observationDeleted = Observation was deleted observationHeading = Observation heading diff --git a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_nb.properties b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_nb.properties index 8629648d65bb2c78e259818059c644696ecfdbd6..1d3db33c3426184e4df91b2ccd1dca2d3e632b81 100755 --- a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_nb.properties +++ b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_nb.properties @@ -354,7 +354,7 @@ greeting = Velkommen til groupMembers = Gruppemedlemmer -heading = Overskrift +heading = Tittel help = Hjelp @@ -556,9 +556,11 @@ observationDataField_trapCountCropInside = Antall insekter, fellefangst inne i f observationDataField_unit = M\u00e5leenhet +allObservations = Alle observasjoner + observationDeleted = Observasjonen ble slettet -observationHeading = Observasjons-overskrift +observationHeading = Observasjonstittel observationMap = Observasjonskart @@ -894,7 +896,7 @@ thresholdRelativeHumidity = Terskelverdi relativ luftfuktighet (%) tillageMethod = Jordarbeiding -timeOfObservation = Observasjonstidspunkt +timeOfObservation = Observasjonsdato timeZone = Tidssone