From 21177e6be9473e8a1796edd8e8b1e94bdf901e7f Mon Sep 17 00:00:00 2001 From: Lene Wasskog <lene.wasskog@nibio.no> Date: Thu, 8 May 2025 15:39:56 +0200 Subject: [PATCH] feat: List and form for observation time series --- .../servlet/ObservationController.java | 67 +- .../ObservationTimeSeriesController.java | 259 ++++++++ .../controller/session/ObservationBean.java | 9 + .../session/ObservationTimeSeriesBean.java | 4 +- .../nibio/vips/logic/entity/Observation.java | 1 + .../logic/entity/ObservationTimeSeries.java | 3 + .../vips/logic/i18n/vipslogictexts.properties | 9 + .../logic/i18n/vipslogictexts_nb.properties | 9 + src/main/webapp/WEB-INF/web.xml | 8 + .../formdefinitions/observationForm.json | 5 + .../observationTimeSeriesForm.json | 70 +++ src/main/webapp/templates/master.ftl | 9 +- src/main/webapp/templates/observationForm.ftl | 45 +- src/main/webapp/templates/observationList.ftl | 18 +- .../templates/observationTimeSeriesForm.ftl | 576 ++++++++++++++++++ .../templates/observationTimeSeriesList.ftl | 57 ++ 16 files changed, 1103 insertions(+), 46 deletions(-) create mode 100644 src/main/java/no/nibio/vips/logic/controller/servlet/ObservationTimeSeriesController.java create mode 100644 src/main/webapp/formdefinitions/observationTimeSeriesForm.json create mode 100644 src/main/webapp/templates/observationTimeSeriesForm.ftl create mode 100644 src/main/webapp/templates/observationTimeSeriesList.ftl diff --git a/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationController.java b/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationController.java index e9356c02..ec77d014 100755 --- a/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationController.java +++ b/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationController.java @@ -38,14 +38,13 @@ import no.nibio.vips.util.DateUtil; import no.nibio.vips.util.DateUtilException; import no.nibio.vips.util.ExceptionUtil; import no.nibio.vips.util.ServletUtil; -import no.nibio.web.forms.FormUtil; -import no.nibio.web.forms.FormValidation; -import no.nibio.web.forms.FormValidationException; -import no.nibio.web.forms.FormValidator; +import no.nibio.web.forms.*; import org.apache.commons.fileupload2.core.DiskFileItemFactory; import org.apache.commons.fileupload2.core.FileItem; import org.apache.commons.fileupload2.core.FileUploadException; import org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletFileUpload; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.text.ParseException; @@ -58,6 +57,8 @@ import java.util.stream.Collectors; * @copyright 2014-2022 <a href="http://www.nibio.no/">NIBIO</a> */ public class ObservationController extends HttpServlet { + private static final Logger LOGGER = LoggerFactory.getLogger(ObservationController.class); + @PersistenceContext(unitName = "VIPSLogic-PU") EntityManager em; @@ -246,13 +247,25 @@ public class ObservationController extends HttpServlet { if (userBean.authorizeUser(user, VipsLogicRole.OBSERVER, VipsLogicRole.OBSERVATION_AUTHORITY, VipsLogicRole.ORGANIZATION_ADMINISTRATOR, VipsLogicRole.SUPERUSER)) { try { - Observation observation = new Observation(); + observation.setTimeOfObservation(new Date()); // See if there are presets on the URL string if (request.getParameter("cropOrganismId") != null) { observation.setCropOrganism( em.find(Organism.class, Integer.valueOf(request.getParameter("cropOrganismId")))); } + if (request.getParameter("observationTimeSeriesId") != null) { + ObservationTimeSeries observationTimeSeries = em.find(ObservationTimeSeries.class, + Integer.valueOf(request.getParameter("observationTimeSeriesId"))); + observation.setObservationTimeSeries(observationTimeSeries); + Calendar cal = Calendar.getInstance(); + cal.set(Calendar.YEAR, observationTimeSeries.getYear()); + cal.set(Calendar.MONTH, Calendar.getInstance().get(Calendar.MONTH)); + cal.set(Calendar.DAY_OF_MONTH, Calendar.getInstance().get(Calendar.DAY_OF_MONTH)); + observation.setTimeOfObservation(cal.getTime()); + request.setAttribute("minTimeOfObservation", getMinDate(observationTimeSeries.getYear())); + request.setAttribute("maxTimeOfObservation", getMaxDate(observationTimeSeries.getYear())); + } List<Organism> allCrops; if (request.getParameter("organismId") != null) { @@ -327,6 +340,13 @@ public class ObservationController extends HttpServlet { request.setAttribute("locationVisibilityFormValue", this.getLocationVisibilityFormValue(observation)); request.setAttribute("observation", observation); + + ObservationTimeSeries observationTimeSeries = observation.getObservationTimeSeries(); + if(observationTimeSeries != null) { + request.setAttribute("minTimeOfObservation", getMinDate(observationTimeSeries.getYear())); + request.setAttribute("maxTimeOfObservation", getMaxDate(observationTimeSeries.getYear())); + } + request.setAttribute("polygonServices", polygonServices); request.setAttribute("noBroadcast", request.getParameter("noBroadcast") != null); request.setAttribute("mapLayers", userBean.getMapLayerJSONForUser(user)); @@ -382,7 +402,6 @@ public class ObservationController extends HttpServlet { Map<String, String[]> parameterMap; List<FileItem> items = null; if (JakartaServletFileUpload.isMultipartContent(request)) { - // Create a new file upload handler DiskFileItemFactory dfif = DiskFileItemFactory.builder().get(); JakartaServletFileUpload upload = new JakartaServletFileUpload(dfif); @@ -399,16 +418,12 @@ public class ObservationController extends HttpServlet { Observation observation = observationId > 0 ? em.find(Observation.class, observationId) : new Observation(); - - if (formValidation.isValid()) { // Storing observation // Only new observations can set the organism if (observationId <= 0 || user.isSuperUser() || user.isOrganizationAdmin()) { - observation.setOrganism(em.find(Organism.class, - formValidation.getFormField("organismId").getValueAsInteger())); - observation.setCropOrganism(em.find(Organism.class, - formValidation.getFormField("cropOrganismId").getValueAsInteger())); + observation.setOrganism(em.find(Organism.class, formValidation.getFormField("organismId").getValueAsInteger())); + observation.setCropOrganism(em.find(Organism.class, formValidation.getFormField("cropOrganismId").getValueAsInteger())); } observation.setTimeOfObservation( formValidation.getFormField("timeOfObservation").getValueAsDate()); @@ -417,11 +432,16 @@ public class ObservationController extends HttpServlet { } else { observation.setLastEditedBy(user.getUserId()); } + FormField observationTimeSeriesField = formValidation.getFormField("observationTimeSeriesId"); + if(observationTimeSeriesField != null && !observationTimeSeriesField.isEmpty()) { + Integer otsIdInt = observationTimeSeriesField.getValueAsInteger(); + observation.setObservationTimeSeries(em.find(ObservationTimeSeries.class, otsIdInt)); + } observation.setLastEditedTime(new Date()); observation.setObservationHeading( formValidation.getFormField("observationHeading").getWebValue()); observation.setObservationText( - formValidation.getFormField("observationText").getWebValue()); + formValidation.getFormField("observationText").getWebValue().isEmpty() ? null : formValidation.getFormField("observationText").getWebValue()); observation.setObservationData( formValidation.getFormField("observationData").getWebValue().isEmpty() ? null @@ -528,11 +548,8 @@ public class ObservationController extends HttpServlet { request.getRequestDispatcher("/observationForm.ftl").forward(request, response); } - } catch (NullPointerException | NumberFormatException | FormValidationException | - FileUploadException ex) { - response.sendError(500, ExceptionUtil.getStackTrace(ex)); } catch (Exception ex) { - ex.printStackTrace(); + LOGGER.error("Exception occurred while saving observation", ex); response.sendError(500, ExceptionUtil.getStackTrace(ex)); } } else { @@ -635,6 +652,22 @@ public class ObservationController extends HttpServlet { } } + private Date getMinDate(int year) { + Calendar cal = Calendar.getInstance(); + cal.set(Calendar.YEAR, year); + cal.set(Calendar.MONTH, Calendar.JANUARY); + cal.set(Calendar.DAY_OF_MONTH, 1); + return cal.getTime(); + } + + private Date getMaxDate(int year) { + Calendar cal = Calendar.getInstance(); + cal.set(Calendar.YEAR, year); + cal.set(Calendar.MONTH, Calendar.DECEMBER); + cal.set(Calendar.DAY_OF_MONTH, 31); + return cal.getTime(); + } + public static final String LOCATION_VISIBILITY_FORM_VALUE_PRIVATE = "private"; public static final String LOCATION_VISIBILITY_FORM_VALUE_PUBLIC = "public"; public static final String LOCATION_VISIBILITY_FORM_VALUE_MASK_PREFIX = "mask_"; diff --git a/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationTimeSeriesController.java b/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationTimeSeriesController.java new file mode 100644 index 00000000..a5f60bd0 --- /dev/null +++ b/src/main/java/no/nibio/vips/logic/controller/servlet/ObservationTimeSeriesController.java @@ -0,0 +1,259 @@ +package no.nibio.vips.logic.controller.servlet; + +import jakarta.ejb.EJB; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import no.nibio.vips.logic.controller.session.*; +import no.nibio.vips.logic.entity.*; +import no.nibio.vips.logic.i18n.SessionLocaleUtil; +import no.nibio.vips.logic.messaging.MessagingBean; +import no.nibio.vips.logic.util.Globals; +import no.nibio.vips.util.ExceptionUtil; +import no.nibio.vips.util.ServletUtil; +import no.nibio.web.forms.*; +import org.apache.commons.fileupload2.core.DiskFileItemFactory; +import org.apache.commons.fileupload2.core.FileItem; +import org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletFileUpload; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.time.LocalDate; +import java.util.*; + +public class ObservationTimeSeriesController extends HttpServlet { + private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(ObservationTimeSeriesController.class); + + private static final int START_YEAR = 2016; + public static final String LOCATION_VISIBILITY_FORM_VALUE_PRIVATE = "private"; + public static final String LOCATION_VISIBILITY_FORM_VALUE_PUBLIC = "public"; + public static final String LOCATION_VISIBILITY_FORM_VALUE_MASK_PREFIX = "mask_"; + + @PersistenceContext(unitName = "VIPSLogic-PU") + EntityManager em; + + @EJB + UserBean userBean; + @EJB + ObservationTimeSeriesBean observationTimeSeriesBean; + @EJB + ObservationBean observationBean; + @EJB + MessagingBean messagingBean; + @EJB + OrganismBean organismBean; + @EJB + PointOfInterestBean pointOfInterestBean; + + /** + * Handles the HTTP <code>GET</code> method. + * + * @param request servlet request + * @param response servlet response + * @throws ServletException if a servlet-specific error occurs + * @throws IOException if an I/O error occurs + */ + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + processRequest(request, response); + } + + /** + * Handles the HTTP <code>POST</code> method. + * + * @param request servlet request + * @param response servlet response + * @throws ServletException if a servlet-specific error occurs + * @throws IOException if an I/O error occurs + */ + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + processRequest(request, response); + } + + protected void processRequest(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + LOGGER.info("Process request in ObservationTimeSeriesController"); + response.setContentType("text/html;charset=UTF-8"); + String action = request.getParameter("action"); + VipsLogicUser user = (VipsLogicUser) request.getSession().getAttribute("user"); + List<ObservationTimeSeries> observationTimeSeriesList = observationTimeSeriesBean.getObservationTimeSeriesListForUser(user); + + if (request.getServletPath().endsWith("/observationTimeSeries")) { + LOGGER.info("Action is {}", action); + if (action == null) { + LOGGER.info("Display list of observationTimeSeries"); + request.setAttribute("observationTimeSeriesList", observationTimeSeriesList); + LOGGER.info("Found {} observationTimeSeries", observationTimeSeriesList.size()); + + Map<String, Long> observationCounts = new HashMap<>(); + for (ObservationTimeSeries observationTimeSeries : observationTimeSeriesList) { + Long count = em.createNamedQuery("Observation.findCountByObservationTimeSeries", Long.class) + .setParameter("observationTimeSeries", observationTimeSeries).getSingleResult(); + observationCounts.put(String.valueOf(observationTimeSeries.getObservationTimeSeriesId()), count); + } + request.setAttribute("observationCounts", observationCounts); + request.getRequestDispatcher("/observationTimeSeriesList.ftl").forward(request, response); + return; + } + boolean authorized = + userBean.authorizeUser(user, VipsLogicRole.OBSERVER, VipsLogicRole.ORGANIZATION_ADMINISTRATOR, + VipsLogicRole.SUPERUSER); + if (!authorized) { + LOGGER.info("User {} not authorized", user.getUserId()); + response.sendError(403, "Access not authorized"); + } + if (action.equals("editObservationTimeSeriesForm")) { + Integer observationTimeSeriesId = Integer.valueOf(request.getParameter("observationTimeSeriesId")); + LOGGER.info("Display form for editing observationTimeSeries {}", observationTimeSeriesId); + ObservationTimeSeries observationTimeSeries = + observationTimeSeriesBean.getObservationTimeSeries(observationTimeSeriesId); + Long observationCount = em.createNamedQuery("Observation.findCountByObservationTimeSeries", Long.class) + .setParameter("observationTimeSeries", observationTimeSeries).getSingleResult(); + + request.setAttribute("observationTimeSeries", observationTimeSeries); + request.setAttribute("isEditable", observationCount == 0); + buildFormRequest(request, user, observationTimeSeries); + request.setAttribute("observations", + observationBean.getObservationsForTimeSeries(observationTimeSeries)); + request.getRequestDispatcher("/observationTimeSeriesForm.ftl").forward(request, response); + } else if (action.equals("newObservationTimeSeriesForm")) { + LOGGER.info("Create new observationTimeSeries"); + ObservationTimeSeries observationTimeSeries = new ObservationTimeSeries(); + request.setAttribute("observationTimeSeries", observationTimeSeries); + buildFormRequest(request, user, observationTimeSeries); + request.setAttribute("isEditable", true); + request.getRequestDispatcher("/observationTimeSeriesForm.ftl").forward(request, response); + } else if (action.equals("observationTimeSeriesFormSubmit")) { + LOGGER.info("Submit changes for observationTimeSeries"); + Map<String, String[]> parameterMap; + List<FileItem> items; + if (JakartaServletFileUpload.isMultipartContent(request)) { + DiskFileItemFactory dfif = DiskFileItemFactory.builder().get(); + JakartaServletFileUpload upload = new JakartaServletFileUpload(dfif); + items = upload.parseRequest(request); + parameterMap = FormUtil.getParameterMap(items, "UTF-8"); + } else { + parameterMap = request.getParameterMap(); + } + ObservationTimeSeries observationTimeSeries = new ObservationTimeSeries(); + try { + FormValidation formValidation = FormValidator.validateForm( + "observationTimeSeriesForm", + parameterMap, + SessionLocaleUtil.getI18nBundle(request), + getServletContext() + ); + Integer observationTimeSeriesId = + formValidation.getFormField("observationTimeSeriesId").getValueAsInteger(); + if (observationTimeSeriesId > 0) { + observationTimeSeries = em.find(ObservationTimeSeries.class, observationTimeSeriesId); + } + if (formValidation.isValid()) { + Long observationCount = 0L; + observationTimeSeries.setUserId(user.getUserId()); + if(observationTimeSeriesId >= 0) { + observationTimeSeries.setLastModifiedBy(user.getUserId()); + observationTimeSeries.setLastModified(new Date()); + observationCount = em.createNamedQuery("Observation.findCountByObservationTimeSeries", Long.class) + .setParameter("observationTimeSeries", observationTimeSeries).getSingleResult(); + } + // Only observation time series without observations can set the crop, pest and location + if (observationCount == 0) { + observationTimeSeries.setOrganism(em.find(Organism.class, formValidation.getFormField("organismId").getValueAsInteger())); + observationTimeSeries.setCropOrganism(em.find(Organism.class, formValidation.getFormField("cropOrganismId").getValueAsInteger())); + observationTimeSeries.setYear(formValidation.getFormField("year").getValueAsInteger()); + setObservationLocationVisibility(observationTimeSeries, formValidation.getFormField("locationVisibility").getWebValue()); + observationTimeSeries.setLocationPointOfInterestId( + formValidation.getFormField("locationPointOfInterestId").getValueAsInteger()); + } + observationTimeSeries.setName(formValidation.getFormField("name").getWebValue()); + observationTimeSeries.setDescription(formValidation.getFormField("description").getWebValue()); + observationTimeSeries = observationTimeSeriesBean.storeObservationTimeSeries(observationTimeSeries); + LOGGER.info("Redirect to edit form with observationTimeSeriesId={}", observationTimeSeries.getObservationTimeSeriesId()); + response.sendRedirect(Globals.PROTOCOL + "://" + + ServletUtil.getServerName(request) + + "/observationTimeSeries?action=editObservationTimeSeriesForm&observationTimeSeriesId=" + + observationTimeSeries.getObservationTimeSeriesId() + + "&messageKey=observationTimeSeriesStored" + ); + } else { + // Redirect to form with error messages + buildFormRequest(request, user, observationTimeSeries); + request.setAttribute("formValidation", formValidation); + request.getRequestDispatcher("/observationTimeSeriesForm.ftl").forward(request, response); + } + } catch (FormValidationException e) { + LOGGER.error("Exception at form validation", e); + response.sendError(500, ExceptionUtil.getStackTrace(e)); + } + } else if (action.equals("deleteObservationTimeSeries")) { + Integer observationTimeSeriesId = Integer.valueOf(request.getParameter("observationTimeSeriesId")); + LOGGER.info("Delete observationTimeSeries {}", observationTimeSeriesId); + } + } + } + + private void buildFormRequest(HttpServletRequest request, VipsLogicUser user, ObservationTimeSeries observationTimeSeries) { + request.setAttribute("observationTimeSeries", observationTimeSeries); + request.setAttribute("years", generateYearList()); + request.setAttribute("mapLayers", userBean.getMapLayerJSONForUser(user)); + request.setAttribute("defaultMapCenter", user.getOrganizationId().getDefaultMapCenter()); + request.setAttribute("defaultMapZoom", user.getOrganizationId().getDefaultMapZoom()); + request.setAttribute("locationPointOfInterests", + pointOfInterestBean.getRelevantPointOfInterestsForUser(user)); + request.setAttribute("locationVisibilityFormValue", + getLocationVisibilityFormValue(observationTimeSeries)); + List<PolygonService> polygonServices = + observationBean.getPolygonServicesForOrganization(user.getOrganization_id()); + request.setAttribute("polygonServices", polygonServices); + List<Organism> allPests = em.createNamedQuery("Organism.findAllPests").getResultList(); + request.setAttribute("allPests", organismBean.sortOrganismsByLocalName(allPests, + SessionLocaleUtil.getCurrentLocale(request).getLanguage())); + List<Organism> allCrops = em.createNamedQuery("Organism.findAllCrops").getResultList(); + request.setAttribute("allCrops", organismBean.sortOrganismsByLocalName(allCrops, + SessionLocaleUtil.getCurrentLocale(request).getLanguage())); + request.setAttribute("hierarchyCategories", + organismBean.getHierarchyCategoryNames(SessionLocaleUtil.getCurrentLocale(request))); + } + + private List<Integer> generateYearList() { + List<Integer> years = new ArrayList<>(); + for (int year = START_YEAR; year <= LocalDate.now().getYear(); year++) { + years.add(year); + } + return years; + } + + private String getLocationVisibilityFormValue(ObservationTimeSeries observationTimeSeries) { + // Private is private, no matter what + if (observationTimeSeries.getLocationIsPrivate()) { + return LOCATION_VISIBILITY_FORM_VALUE_PRIVATE; + } + // Public can either be completely public or masked by a polygon layer. e.g. county borders + if (observationTimeSeries.getPolygonService() == null) { + return LOCATION_VISIBILITY_FORM_VALUE_PUBLIC; + } + return LOCATION_VISIBILITY_FORM_VALUE_MASK_PREFIX + observationTimeSeries.getPolygonService() + .getPolygonServiceId(); + } + + private void setObservationLocationVisibility(ObservationTimeSeries observationTimeSeries, String formValue) { + // Private is private, no matter what + observationTimeSeries.setLocationIsPrivate( + formValue.equals(ObservationController.LOCATION_VISIBILITY_FORM_VALUE_PRIVATE)); + observationTimeSeries.setPolygonService( + // If private or public, set no polygon service + formValue.equals(ObservationController.LOCATION_VISIBILITY_FORM_VALUE_PRIVATE) || formValue.equals( + ObservationController.LOCATION_VISIBILITY_FORM_VALUE_PUBLIC) + ? null + // Otherwise, set selected polygon service + : em.find(PolygonService.class, Integer.valueOf(formValue.split("_")[1])) + ); + } +} 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 80417862..e970296e 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 @@ -189,6 +189,15 @@ public class ObservationBean { return retVal; } + public List<Observation> getObservationsForTimeSeries(ObservationTimeSeries observationTimeSeries) { + List<Observation> retVal = this.getObservationsWithGeoInfo(em.createNamedQuery("Observation.findByObservationTimeSeries", Observation.class) + .setParameter("observationTimeSeries", observationTimeSeries) + .getResultList()); + retVal = this.getObservationsWithLocations(retVal); + retVal = this.getObservationsWithObservers(retVal); + return retVal; + } + public Observation getObservation(Integer observationId) { Observation retVal = em.find(Observation.class, observationId); if (retVal != null) { diff --git a/src/main/java/no/nibio/vips/logic/controller/session/ObservationTimeSeriesBean.java b/src/main/java/no/nibio/vips/logic/controller/session/ObservationTimeSeriesBean.java index 627002b9..177b8bb1 100644 --- a/src/main/java/no/nibio/vips/logic/controller/session/ObservationTimeSeriesBean.java +++ b/src/main/java/no/nibio/vips/logic/controller/session/ObservationTimeSeriesBean.java @@ -23,9 +23,7 @@ import jakarta.ejb.EJB; import jakarta.ejb.Stateless; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; -import no.nibio.vips.logic.entity.ObservationTimeSeries; -import no.nibio.vips.logic.entity.PointOfInterest; -import no.nibio.vips.logic.entity.VipsLogicUser; +import no.nibio.vips.logic.entity.*; @Stateless public class ObservationTimeSeriesBean { diff --git a/src/main/java/no/nibio/vips/logic/entity/Observation.java b/src/main/java/no/nibio/vips/logic/entity/Observation.java index 478e8dd6..a845e383 100755 --- a/src/main/java/no/nibio/vips/logic/entity/Observation.java +++ b/src/main/java/no/nibio/vips/logic/entity/Observation.java @@ -53,6 +53,7 @@ import org.hibernate.type.SqlTypes; @NamedQuery(name = "Observation.findByLastEditedBy", query = "SELECT o FROM Observation o WHERE o.lastEditedBy IN(:lastEditedBy)"), @NamedQuery(name = "Observation.findByLocationPointOfInterestId", query = "SELECT o FROM Observation o WHERE o.locationPointOfInterestId = :locationPointOfInterestId"), @NamedQuery(name = "Observation.findByObservationTimeSeries", query = "SELECT o FROM Observation o WHERE o.observationTimeSeries = :observationTimeSeries"), + @NamedQuery(name = "Observation.findCountByObservationTimeSeries", query = "SELECT count(o) FROM Observation o WHERE o.observationTimeSeries = :observationTimeSeries"), @NamedQuery(name = "Observation.findByStatusChangedByUserId", query = "SELECT o FROM Observation o WHERE o.statusChangedByUserId IN(:statusChangedByUserId)"), @NamedQuery(name = "Observation.findByUserIdAndPeriod", query = "SELECT o FROM Observation o WHERE o.timeOfObservation BETWEEN :start AND :end AND o.userId IN(:userId)"), @NamedQuery(name = "Observation.findByUserIdAndStatusTypeId", query = "SELECT o FROM Observation o WHERE o.userId IN(:userId) AND o.statusTypeId= :statusTypeId"), diff --git a/src/main/java/no/nibio/vips/logic/entity/ObservationTimeSeries.java b/src/main/java/no/nibio/vips/logic/entity/ObservationTimeSeries.java index 04db0628..86427c99 100644 --- a/src/main/java/no/nibio/vips/logic/entity/ObservationTimeSeries.java +++ b/src/main/java/no/nibio/vips/logic/entity/ObservationTimeSeries.java @@ -61,9 +61,12 @@ public class ObservationTimeSeries implements Serializable { public ObservationTimeSeries() { this.GISEntityUtil = new GISEntityUtil(); + this.source = "WEB"; + this.created = new Date(); } public ObservationTimeSeries(String source) { + this(); this.source = source; } 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 e8b9b640..28c36c9d 100755 --- a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties +++ b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties @@ -1088,3 +1088,12 @@ observationCount=Observation count weatherDatasource=Weather datasource useWeatherStation=Use weather station multipleNewWarningMsg=This form is for adding the same forecast configuration to many weather stations simultaneously. + +timeSeriesList = Time series +forTimeSeries=for time series +newObservationTimeSeries=New observation time series +editObservationTimeSeries=Edit observation time series +addNewObservationInTimeSeries=Add observation to time series +observationTimeSeriesName=Name +observationTimeSeriesDescription=Description +year=Year 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 69c8db61..a5b139b2 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 @@ -1087,3 +1087,12 @@ observationCount=Antall observasjoner weatherDatasource=V\u00e6rdatakilde useWeatherStation=Bruk v\u00e6rstasjon multipleNewWarningMsg=Dette skjemaet er en snarvei for \u00e5 opprette likelydende varsel p\u00e5 flere v\u00e6rstasjoner. <a href="/forecastConfiguration?action=viewForecastConfiguration&forecastConfigurationId=-1">Klikk her</a> hvis du vil opprette bare ett varsel. + +timeSeriesList = Tidsserier +forTimeSeries=for tidsserie +newObservationTimeSeries=Ny tidsserie +editObservationTimeSeries=Rediger tidsserie +addNewObservationInTimeSeries=Legg til ny observasjon i tidsserien +observationTimeSeriesName=Navn +observationTimeSeriesDescription=Beskrivelse +year=\u00c5r diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index d532ad2e..85ee5b44 100755 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -59,6 +59,10 @@ <servlet-name>MessageController</servlet-name> <servlet-class>no.nibio.vips.logic.controller.servlet.MessageController</servlet-class> </servlet> + <servlet> + <servlet-name>ObservationTimeSeriesServlet</servlet-name> + <servlet-class>no.nibio.vips.logic.controller.servlet.ObservationTimeSeriesController</servlet-class> + </servlet> <servlet> <servlet-name>ObservationServlet</servlet-name> <servlet-class>no.nibio.vips.logic.controller.servlet.ObservationController</servlet-class> @@ -139,6 +143,10 @@ <servlet-name>MessageController</servlet-name> <url-pattern>/message</url-pattern> </servlet-mapping> + <servlet-mapping> + <servlet-name>ObservationTimeSeriesServlet</servlet-name> + <url-pattern>/observationTimeSeries</url-pattern> + </servlet-mapping> <servlet-mapping> <servlet-name>ObservationServlet</servlet-name> <url-pattern>/observation</url-pattern> diff --git a/src/main/webapp/formdefinitions/observationForm.json b/src/main/webapp/formdefinitions/observationForm.json index 82fa5aab..70d4e495 100755 --- a/src/main/webapp/formdefinitions/observationForm.json +++ b/src/main/webapp/formdefinitions/observationForm.json @@ -24,6 +24,11 @@ "dataType" : "INTEGER", "required" : true }, + { + "name" : "observationTimeSeriesId", + "dataType" : "INTEGER", + "required" : false + }, { "name" : "organismId", "dataType" : "INTEGER", diff --git a/src/main/webapp/formdefinitions/observationTimeSeriesForm.json b/src/main/webapp/formdefinitions/observationTimeSeriesForm.json new file mode 100644 index 00000000..1510bfb1 --- /dev/null +++ b/src/main/webapp/formdefinitions/observationTimeSeriesForm.json @@ -0,0 +1,70 @@ +{ + "_licenseNote": [ + "Copyright (c) 2014 NIBIO <http://www.nibio.no/>. ", + "", + "This file is part of VIPSLogic. ", + + "This program is free software: you can redistribute it and/or modify", + "it under the terms of the GNU Affero General Public License as published by", + "the Free Software Foundation, either version 3 of the License, or", + "(at your option) any later version.", + + "This program is distributed in the hope that it will be useful,", + "but WITHOUT ANY WARRANTY; without even the implied warranty of", + "MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the", + "GNU Affero General Public License for more details.", + + "You should have received a copy of the GNU Affero General Public License", + "along with this program. If not, see <https://www.gnu.org/licenses/>." + ], + "_comment" : "Structure of the observationTimeSeriesForm and how to validate it", + "fields": [ + { + "name" : "observationTimeSeriesId", + "dataType" : "INTEGER", + "required" : true + }, + { + "name" : "organismId", + "dataType" : "INTEGER", + "fieldType" : "SELECT_SINGLE", + "required" : true, + "nullValue" : "-1" + }, + { + "name" : "cropOrganismId", + "dataType" : "INTEGER", + "fieldType" : "SELECT_SINGLE", + "required" : true, + "nullValue" : "-1" + }, + { + "name" : "year", + "dataType" : "INTEGER", + "timestampFormat" : "SELECT_SINGLE", + "required" : true + }, + { + "name" : "locationPointOfInterestId", + "dataType" : "INTEGER", + "fieldType" : "SELECT_SINGLE", + "required" : true + }, + { + "name" : "locationVisibility", + "dataType" : "STRING", + "fieldType" : "RADIO", + "required" : true + }, + { + "name" : "name", + "dataType" : "STRING", + "required" : true + }, + { + "name" : "description", + "dataType" : "STRING", + "required" : false + } + ] +} diff --git a/src/main/webapp/templates/master.ftl b/src/main/webapp/templates/master.ftl index 712e6aaf..ef26ccca 100755 --- a/src/main/webapp/templates/master.ftl +++ b/src/main/webapp/templates/master.ftl @@ -66,9 +66,12 @@ <#if user.isOrganizationAdmin() || user.isSuperUser() || user.isMessageAuthor()> <li><a href="/message">${i18nBundle.messages}</a></li> </#if> - <#if user.isOrganizationAdmin() || user.isSuperUser() || user.isAppleFruitMothAdministrator() || user.hasRole(3?int) || user.hasRole(4?int)> - <li><a href="/observation">${i18nBundle.observations}</a></li> - </#if> + <#if user.isOrganizationAdmin() || user.isSuperUser() || user.isAppleFruitMothAdministrator() || user.hasRole(3?int) || user.hasRole(4?int)> + <li><a href="/observationTimeSeries">${i18nBundle.timeSeriesList}</a></li> + </#if> + <#if user.isOrganizationAdmin() || user.isSuperUser() || user.isAppleFruitMothAdministrator() || user.hasRole(3?int) || user.hasRole(4?int)> + <li><a href="/observation">${i18nBundle.observations}</a></li> + </#if> <li><a href="/forecastConfiguration">${i18nBundle.forecasts}</a></li> <#if user.isOrganizationAdmin() || user.isSuperUser() || user.isOrganismEditor()> <li><a href="/organism">${i18nBundle.organisms}</a></li> diff --git a/src/main/webapp/templates/observationForm.ftl b/src/main/webapp/templates/observationForm.ftl index d4cd9e01..152b692a 100755 --- a/src/main/webapp/templates/observationForm.ftl +++ b/src/main/webapp/templates/observationForm.ftl @@ -64,19 +64,18 @@ initGisInfoMap("observationFormMap", [${defaultMapCenter.x?c}, ${defaultMapCenter.y?c}], ${defaultMapZoom}, false, geoInfo, chooseFromMapLayers); </#if> <#if editAccess!="W"> - GISInfoMap.removeInteraction(draw); - document.getElementById("drawOptions").innerHTML = ""; + GISInfoMap.removeInteraction(draw); + document.getElementById("drawOptions").innerHTML = ""; </#if> <#if observation.observationData?has_content> - observationData = ${observation.observationData}; - getDataSchema(${observation.organism.organismId}, organizationId); + observationData = ${observation.observationData}; + getDataSchema(${observation.organism.organismId}, organizationId); <#elseif observation.organism?has_content> - // Setting - initObservationData(${observation.organism.organismId}, organizationId); + // Setting + initObservationData(${observation.organism.organismId}, organizationId); </#if> - <#if observation.observationTimeSeries?has_content> - displayObservationTimeSeriesInfo("${observation.cropOrganismId}", "${observation.organismId}", "${observation.locationPointOfInterestId}") + displayObservationTimeSeriesInfo("${observation.observationTimeSeries.cropOrganismId}", "${observation.observationTimeSeries.organismId}", "${observation.observationTimeSeries.locationPointOfInterestId}") </#if> // Activating file selection @@ -518,7 +517,7 @@ role="button">${i18nBundle.back}</a><#if observation.observationId?has_content><a href="/observation?action=newObservationForm" class="btn btn-default" role="button">${i18nBundle.addNew}</a></#if></p> - <h1><#if observation.observationId?has_content>${i18nBundle.editObservation}<#else>${i18nBundle.newObservation}</#if><#if shortcut?has_content> - ${shortcut.getLocalLabel(currentLocale.language)?lower_case}</#if></h1> + <h1><#if observation.observationId?has_content>${i18nBundle.editObservation}<#else>${i18nBundle.newObservation}</#if><#if observation.observationTimeSeriesId?has_content> ${i18nBundle.forTimeSeries}</#if><#if shortcut?has_content> - ${shortcut.getLocalLabel(currentLocale.language)?lower_case}</#if></h1> <div id="errorMsgEl" class="alert alert-danger" <#if !formValidation?has_content> style="display:none;"</#if>> <#if formValidation?has_content>${formValidation.validationMessages?replace("\n", "<br>")}</#if> </div> @@ -550,7 +549,7 @@ </#if> <#if observation.observationTimeSeries?has_content> <div class="form-group"> - <label>${i18nBundle.timeSeries}: ${observation.observationTimeSeries.name}</label><br> + <label>${i18nBundle.timeSeries}: ${observation.observationTimeSeries.name} (${observation.observationTimeSeries.year})</label><br> <i>${i18nBundle.timeSeriesNonEditable}</i> </div> <div class="form-group"> @@ -565,10 +564,26 @@ <label for="locationPointOfInterestId">${i18nBundle.location}</label> <span id="locationDisplayName"></span> </div> - <input type="hidden" name="cropOrganismId" value="${observation.cropOrganism.organismId}"> - <input type="hidden" name="organismId" value="${observation.organism.organismId}"> - <input type="hidden" name="locationPointOfInterestId" - value="${observation.locationPointOfInterestId}"> + <div class="form-group"> + <label for="locationVisibility">Synlighet</label> + <span id="locationVisibility"> + <#if locationVisibilityFormValue == 'public'> + ${i18nBundle.locationIsPublic} + <#elseif locationVisibilityFormValue == 'private'> + ${i18nBundle.locationIsPrivate} + <#else> + <#list polygonServices as polygonService> + <#if locationVisibilityFormValue == "mask_" + polygonService.polygonServiceId> + ${i18nBundle.maskObservationWith} ${polygonService.polygonServiceName} + </#if> + </#list> + </#if> + </span> + </div> + <input type="hidden" name="observationTimeSeriesId" value="${observation.observationTimeSeries.observationTimeSeriesId}"> + <input type="hidden" name="cropOrganismId" value="${observation.observationTimeSeries.cropOrganism.organismId}"> + <input type="hidden" name="organismId" value="${observation.observationTimeSeries.organism.organismId}"> + <input type="hidden" name="locationPointOfInterestId" value="${observation.observationTimeSeries.locationPointOfInterestId}"> <input type="hidden" name="locationVisibility" value="${locationVisibilityFormValue}"> <#else> <#if ! observation.organism?has_content> @@ -681,7 +696,7 @@ <#setting time_zone=user.organizationId.defaultTimeZone!"UTC"> <div class="form-group"> <label for="timeOfObservation">${i18nBundle.timeOfObservation}</label> - <input type="date" class="form-control" id="timeOfObservation" name="timeOfObservation" placeholder="${i18nBundle.timeOfObservation}" value="${observation.timeOfObservation?string("yyyy-MM-dd")!""}" onblur="validateField(this);" <#if editAccess!="W">readonly="readonly"</#if>/> + <input type="date" <#if observation.observationTimeSeries?has_content>min="${minTimeOfObservation?string("yyyy-MM-dd")}" max="${maxTimeOfObservation?string("yyyy-MM-dd")}"</#if> class="form-control" id="timeOfObservation" name="timeOfObservation" placeholder="${i18nBundle.timeOfObservation}" value="${observation.timeOfObservation?string("yyyy-MM-dd")}" onblur="validateField(this);" <#if editAccess!="W">readonly="readonly"</#if>/> <span class="help-block" id="${formId}_timeOfObservation_validation"></span> </div> <div class="form-group"> diff --git a/src/main/webapp/templates/observationList.ftl b/src/main/webapp/templates/observationList.ftl index 90db0a5a..93845dab 100755 --- a/src/main/webapp/templates/observationList.ftl +++ b/src/main/webapp/templates/observationList.ftl @@ -117,22 +117,24 @@ <thead> <th>${i18nBundle.timeOfObservation}</th> <th>${i18nBundle.organism}</th> - <th>${i18nBundle.cropOrganismId}</th> - <th>${i18nBundle.location}</th> - <th>${i18nBundle.observer}</th> - <th>${i18nBundle.heading}</th> + <th>${i18nBundle.cropOrganismId}</th> + <th>${i18nBundle.location}</th> + <th>${i18nBundle.observer}</th> + <th>${i18nBundle.heading}</th> + <th>Tidsserie</th> <th>${i18nBundle.status}</th> <th></th> </thead> <tbody> <#list observations as observation> <tr> - <td>${observation.timeOfObservation?string("yyyy-MM-dd HH:mmZ")}</td> + <td>${observation.timeOfObservation?string("yyyy-MM-dd")}</td> <td>${observation.organism.latinName!""}/${observation.organism.getLocalName(currentLocale.language)!""}</td> - <td>${observation.cropOrganism.latinName!""}/${observation.cropOrganism.getLocalName(currentLocale.language)!""}</td> + <td>${observation.cropOrganism.latinName!""}/${observation.cropOrganism.getLocalName(currentLocale.language)!""}</td> <td><#if observation.location?has_content>${observation.location.name!""}</#if></td> - <td><#if observation.user?has_content>${observation.user.firstName!""} ${observation.user.lastName!""}</#if></td> - <td>${observation.observationHeading}</td> + <td><#if observation.user?has_content>${observation.user.firstName!""} ${observation.user.lastName!""}</#if></td> + <td>${observation.observationHeading}</td> + <td><#if observation.observationTimeSeries?has_content><a href="/observationTimeSeries?action=editObservationTimeSeriesForm&observationTimeSeriesId=${observation.observationTimeSeriesId}">${observation.observationTimeSeries.name}</a></#if></td> <td><#switch observation.statusTypeId><#case 1>${i18nBundle.pending}<#break><#case 2>${i18nBundle.rejected}<#break><#case 3>${i18nBundle.approved}</#switch></td> <td><#if user.isSuperUser() || user.isOrganizationAdmin() || userIsObservationAuthority || observation.userId == user.userId><a href="/observation?action=editObservationForm&observationId=${observation.observationId}" class="btn btn-default" role="button">${i18nBundle.edit}</a></#if></td> </tr> diff --git a/src/main/webapp/templates/observationTimeSeriesForm.ftl b/src/main/webapp/templates/observationTimeSeriesForm.ftl new file mode 100644 index 00000000..06047181 --- /dev/null +++ b/src/main/webapp/templates/observationTimeSeriesForm.ftl @@ -0,0 +1,576 @@ +<#include "master.ftl"> +<#macro page_head> + <title><#if observationTimeSeries.observationTimeSeriesId?has_content>${i18nBundle.editObservation}<#else>${i18nBundle.newObservation}</#if></title> +</#macro> +<#macro custom_css> + <link rel="stylesheet" type="text/css" href="/css/3rdparty/jquery.datetimepicker.css"/> + <link rel="stylesheet" type="text/css" href="/css/3rdparty/ol.css?t=20170623"/> + <link rel="stylesheet" type="text/css" href="/css/3rdparty/ol-layerswitcher.css"/> + <link rel="stylesheet" type="text/css" href="/css/map.css"/> +</#macro> +<#macro custom_js> +<script type="text/javascript" src="/js/3rdparty/modernizr_custom.js"></script> +<script type="text/javascript" src="/js/3rdparty/moment.min.js"></script> +<script type="text/javascript" src="/js/3rdparty/jsoneditor.js"></script> +<script type="text/javascript" src="/js/3rdparty/ol.js?t=20170623"></script> +<script type="text/javascript" src="js/3rdparty/ol-layerswitcher.js"></script> + +<script type="text/javascript" src="/js/constants.js"></script> +<script type="text/javascript" src="/js/resourcebundle.js"></script> +<script type="text/javascript" src="/js/validateForm.js"></script> +<script type="text/javascript" src="/js/util.js"></script> +<script type="text/javascript" src="/js/objectGISInfoMap.js?t=20170623"></script> +<script type="text/javascript" src="/js/poiFormMap.js"></script> +<script> + let organizationId = ${user.organizationId.organizationId}; + let selectedCropId = <#if observationTimeSeries.cropOrganism?has_content>${observationTimeSeries.cropOrganism.organismId?c}<#else>null</#if>; + + $(document).ready(function () { + loadFormDefinition("observationTimeSeriesForm"); + refreshLocationPointOfInterests(<#if observationTimeSeries.locationPointOfInterestId?has_content>${observationTimeSeries.locationPointOfInterestId}<#else>null</#if>); + <#if !observationTimeSeries.organism?has_content> + initCropCategories(); + </#if> + <#if isEditable?has_content && !isEditable> + displayObservationTimeSeriesInfo("${observationTimeSeries.cropOrganismId}", "${observationTimeSeries.organismId}", "${observationTimeSeries.locationPointOfInterestId}") + </#if> + filterCrops(-1); + }); + + let thePopup; + function addNewLocationPopup() { + thePopup = window.open("/poi?action=newPoiForm&organizationId=1&returnIdCallback=refreshLocationPointOfInterests"); + } + + let allPois = []; + function fetchPOIs(callback) { + $.getJSON("/rest/poi/user", function (json) { + callback(json); + }); + } + + function refreshLocationPointOfInterests(selectedPointOfInterestId) { + console.log("Refresh POIs!", selectedPointOfInterestId) + const poiTypes = { + 0: "${i18nBundle.genericPlaces}", + 2: "${i18nBundle.farms}", + 3: "${i18nBundle.fields}", + 5: "${i18nBundle.traps}" + }; + const poiListElement = document.getElementById("locationPointOfInterestId"); + if (!poiListElement) { + return; + } + + fetchPOIs(function (allPois) { + buildPOIList(poiListElement, allPois, poiTypes, selectedPointOfInterestId); + showCorrectMap(); + }); + } + + function buildPOIList(poiListElement, allPois, poiTypes, selectedPointOfInterestId) { + poiListElement.options.length = 1; + for (const [typeId, typeName] of Object.entries(poiTypes)) { + poiListElement.options[poiListElement.options.length] = new Option("-- " + typeName + " --", "-1"); + + for (let i = 0; i < allPois.length; i++) { + const poi = allPois[i]; + + if (poi.pointOfInterestTypeId == typeId) { + const poiOption = new Option(poi.name, poi.pointOfInterestId); + if (poi.pointOfInterestId === selectedPointOfInterestId) { + poiOption.selected = true; + } + poiListElement.options[poiListElement.options.length] = poiOption; + } + } + } + } + + function initLocationMap(locationPointOfInterestId) { + if (locationPointOfInterestId != null) { + $.getJSON("/rest/poi/" + locationPointOfInterestId, function (json) { + var poi = json; + if (map === undefined || map === null) { + initMap([poi.longitude, poi.latitude], 10, ${user.organizationId.organizationId}, locationPointOfInterestId, true); + } else { + map.getView().setCenter(ol.proj.transform([poi.longitude, poi.latitude], 'EPSG:4326', map.getView().getProjection().getCode())); + map.getView().setZoom(10); + stationMarker.setPosition(ol.proj.transform([poi.longitude, poi.latitude], 'EPSG:4326', map.getView().getProjection().getCode())); + } + } + ); + } else { + initMap([${defaultMapCenter.x?c}, ${defaultMapCenter.y?c}], ${defaultMapZoom}, ${user.organizationId.organizationId}, locationPointOfInterestId, true); + } + } + + function showCorrectMap() { + let locationList = document.getElementById("locationPointOfInterestId"); + if (locationList.options[locationList.options.selectedIndex].value == "-1") { + document.getElementById("poiFormMap").style.display = "block"; + initLocationMap(null); + } else { + document.getElementById("poiFormMap").style.display = "block"; + initLocationMap(locationList.options[locationList.options.selectedIndex].value); + } + } + + /** + * Crop, pest and location should not be editable for time series with observations. We avoid displaying + * form elements in these cases, and rather display the values statically. + */ + function displayObservationTimeSeriesInfo(cropOrganismId, organismId, locationPointOfInterestId) { + console.log("Display info!") + document.getElementById("cropDisplayName").innerHTML = nameForCropOrganismId(cropOrganismId); + document.getElementById("pestDisplayName").innerHTML = nameForOrganismId(organismId); + fetchPOIs(function (allPois) { + const locationName = nameForLocationPointOfInterestId(locationPointOfInterestId, allPois); + document.getElementById("locationDisplayName").innerHTML = locationName || null; + }); + initLocationMap(locationPointOfInterestId) + } + + // Return name for given cropOrganismId + function nameForCropOrganismId(cropOrganismId) { + const crop = cropList.find(crop => crop.organismId == cropOrganismId); + return crop ? crop.displayName : null; + } + + // Return name for given organismId + function nameForOrganismId(organismId) { + const pest = organismList.find(pest => pest.organismId == organismId); + return pest ? pest.displayName : null; + } + + // Return name for given point of interest id + function nameForLocationPointOfInterestId(pointOfInterestId, allPois) { + const location = allPois.find(loc => loc.pointOfInterestId == pointOfInterestId); + return location ? location.name : null; + } + + let cropCategories = []; + + function initCropCategories() { + $.getJSON("/rest/organism/cropcategory/${user.organizationId.organizationId}", function (json) { + cropCategories = json; + renderCropCategories(); + } + ); + } + + function renderCropCategories() { + let cropCategoryIdList = document.getElementById("cropCategoryIdList"); + console.info("renderCropCategories", cropCategories) + for (let i in cropCategories) { + let cropCategory = cropCategories[i]; + // Best effort getting name for crop category + let catName = cropCategory.defaultName; + for (let j in cropCategory.cropCategoryLocalSet) { + let cLocal = cropCategory.cropCategoryLocalSet[j]; + if (cLocal.cropCategoryLocalPK.locale == "${currentLocale}") { + catName = cLocal.localName; + } + } + let cOption = new Option(catName, cropCategory.cropCategoryId); + cropCategoryIdList.options[cropCategoryIdList.options.length] = cOption; + } + } + + function filterCrops(selectedCropCategoryId) { + // Nothing has been selected, render all in one go + if (selectedCropCategoryId < 0) { + var cropOptions = []; + for (var j = 0; j < cropList.length; j++) { + //var cropOption = cropOrganismIdList.options[j]; + var crop = cropList[j]; + var cropOption = new Option(crop.displayName, crop.organismId); + if (selectedCropId != null && selectedCropId == crop.organismId) { + cropOption.selected = true; + } + cropOptions.push(cropOption); + + } + renderCropList(cropOptions, []); + } else { + // Searching for selected crop category + for (var i in cropCategories) { + var cropCategory = cropCategories[i]; + if (cropCategory.cropCategoryId == parseInt(selectedCropCategoryId)) { + // Filter based on match + var matchingCropOrganismOptions = []; + var theRest = []; + //var cropOrganismIdList = document.getElementById("cropOrganismIdList"); + for (var j = 0; j < cropList.length; j++) { + //var cropOption = cropOrganismIdList.options[j]; + var crop = cropList[j]; + var cropOption = new Option(crop.displayName, crop.organismId); + if (selectedCropId != null && selectedCropId == crop.organismId) { + cropOption.selected = true; + } + if (cropCategory.cropOrganismIds.indexOf(crop.organismId) >= 0 && cropCategory.maxHierarchyCategoryId >= crop.hierarchyCategoryId) { + matchingCropOrganismOptions.push(cropOption); + } else if (crop.organismId > 0) { + theRest.push(cropOption); + } + } + renderCropList(matchingCropOrganismOptions, theRest); + } + } + } + } + + function renderCropList(matchingCropOrganismOptions, theRest) { + var cropOrganismIdList = document.getElementById("cropOrganismIdList"); + if (!cropOrganismIdList) { + // HTML element does not exist for observations in time series + return; + } + cropOrganismIdList.options.length = 1; + for (var i in matchingCropOrganismOptions) { + cropOrganismIdList.options[cropOrganismIdList.options.length] = matchingCropOrganismOptions[i]; + } + cropOrganismIdList.options[cropOrganismIdList.options.length] = new Option("----", -1); + for (var i in theRest) { + cropOrganismIdList.options[cropOrganismIdList.options.length] = theRest[i]; + } + } + + var cropList = [ + <#if ! observationTimeSeries.observationTimeSeriesId?has_content || user.isSuperUser() || user.isOrganizationAdmin()> + {organismId: -10, displayName: "${i18nBundle.missingInDatabase}", hierarchyCategoryId: -1}, + <#list allCrops as cropOrganism> + { + organismId: ${cropOrganism.organismId?c}, + displayName: "${cropOrganism.getLocalName(currentLocale.language)!""} (${cropOrganism.latinName!""}) ${hierarchyCategories.getName(cropOrganism.hierarchyCategoryId)?upper_case}", + hierarchyCategoryId: ${cropOrganism.hierarchyCategoryId!"-1"} + }<#sep>, + </#list> + <#else> + <#list allCrops as cropOrganism> + <#if (observationTimeSeries.cropOrganism?has_content && observationTimeSeries.cropOrganism.organismId == cropOrganism.organismId)> + { + organismId: ${cropOrganism.organismId}, + displayName: "${cropOrganism.getLocalName(currentLocale.language)!""} (${cropOrganism.latinName!""}) ${hierarchyCategories.getName(cropOrganism.hierarchyCategoryId)?upper_case}", + hierarchyCategoryId: ${cropOrganism.hierarchyCategoryId} + } + </#if> + </#list> + </#if> + ]; + + var organismList = [ + <#list allPests as organism> + { + organismId: ${organism.organismId}, + displayName: "${organism.getLocalName(currentLocale.language)!""} (${organism.latinName!""})" + }, + </#list> + ]; + + var updateCropPests = function () { + var theForm = document.getElementById('observationTimeSeriesForm'); + var selectedCropId = theForm["cropOrganismId"].options[theForm["cropOrganismId"].options.selectedIndex].value; + // If this is not a new observation, or the selected crop is not in the database, we keep calm + if (theForm["observationTimeSeriesId"].value !== "-1" || selectedCropId == "-10") { + return; + } + $.getJSON("/rest/organism/croppest/" + selectedCropId, function (json) { + updateCropPestsCallback(json); + }) + .fail(function () { + alert("Error getting pests for this crop. Please contact system administrator"); + }); + }; + + var updateCropPestsCallback = function (cropPest) { + var pestList = document.getElementById('observationTimeSeriesForm')["organismId"]; + if (cropPest == null) { + // Need to reorganize pests back to default + var allPests = []; + for (var i = 2; i < pestList.options.length; i++) { + allPests.push(pestList.options[i]); + } + allPests.sort(compareSelectListOptions); + pestList.options.length = 2; // Keeping the top two + for (var i = 0; i < allPests.length; i++) { + pestList.options[pestList.options.length] = allPests[i]; + } + } else { + var prioritatedPests = []; + var unprioritatedPests = [] + for (var i = 2; i < pestList.options.length; i++) { + if (cropPest.pestOrganismIds.indexOf(parseInt(pestList.options[i].value)) >= 0) { + //console.log(pestList.options[i].value + " is prioritated"); + prioritatedPests.push(pestList.options[i]); + } else if (pestList.options[i].value != "-1") // Avoiding the "---" option + { + //console.log(pestList.options[i].value + " is unprioritated"); + unprioritatedPests.push(pestList.options[i]); + } + + } + pestList.options.length = 2; // Keeping the top two + for (var i = 0; i < prioritatedPests.length; i++) { + pestList.options[pestList.options.length] = prioritatedPests[i]; + } + pestList.options[pestList.options.length] = new Option("---", "-1"); + for (var i = 0; i < unprioritatedPests.length; i++) { + pestList.options[pestList.options.length] = unprioritatedPests[i]; + } + } + }; + + function prepareFormSubmit(theForm) { + // Extract GIS info from OpenLayers + theForm['geoInfo'].value = getFeatures(); + try { + // If the form is quantified: Inspect the fields and write + // JSON string to the generic form field "observationData"; + if (theForm.isQuantified.checked) { + const errors = editor.validate(); + if (errors.length) { + alert(errors); + return false; + } + theForm['observationData'].value = JSON.stringify(editor.getValue()); + } + validateGIS(theForm); + //console.info('validateGIS = ' + (validateGIS(this))); + //return false; // DEBUG setting + return validateForm(theForm) && validateGIS(theForm); // PROD setting + } catch (e) { + console.log(e.message); + console.log(e); + return false; + } + } +</script> + +</#macro> + +<#macro page_contents> +<div class="singleBlockContainer"> + <p><a href="/observationTimeSeries" class="btn btn-default back" role="button">${i18nBundle.back}</a><#if observationTimeSeries.observationTimeSeriesId?has_content><a href="/observation?action=newObservationForm&observationTimeSeriesId=${observationTimeSeries.observationTimeSeriesId}" class="btn btn-default" role="button">${i18nBundle.addNewObservationInTimeSeries}</a></#if></p> + <h1><#if observationTimeSeries.observationTimeSeriesId?has_content>${i18nBundle.editObservationTimeSeries}<#else>${i18nBundle.newObservationTimeSeries}</#if><#if shortcut?has_content> - ${shortcut.getLocalLabel(currentLocale.language)?lower_case}</#if></h1> + <p><i>Kultur, organisme, år og sted kan ikke redigeres for tidsserier som inneholder observasjoner.</i></p> + <div id="errorMsgEl" class="alert alert-danger" <#if !formValidation?has_content> style="display:none;"</#if>> + <#if formValidation?has_content>${formValidation.validationMessages?replace("\n", "<br>")}</#if> + </div> + <#if messageKey?has_content> + <div class="alert alert-success">${i18nBundle(messageKey)}</div> + </#if> + <div class="row"> + <div class="col-md-6"> + <#assign formId = "observationTimeSeriesForm"> + <form id="${formId}" role="form" action="/observationTimeSeries?action=observationTimeSeriesFormSubmit" enctype="multipart/form-data" method="POST" onsubmit="return prepareFormSubmit(this);"> + <input type="hidden" name="geoInfo" value=""/> + <input type="hidden" name="observationTimeSeriesId" value="${observationTimeSeries.observationTimeSeriesId!"-1"}"/> + <#if observationTimeSeries.user?has_content> + <div class="form-group"> + <label>Registrert av</label> + <span>${observationTimeSeries.user.firstName} ${observationTimeSeries.user.lastName}</span> + </div> + </#if> + <#if observationTimeSeries.lastModifiedByUser?has_content> + <div class="form-group"> + <label>${i18nBundle.lastEditedBy}</label> + <span>${observationTimeSeries.lastModifiedByUser.firstName} ${observationTimeSeries.lastModifiedByUser.lastName}</span> + </div> + </#if> + <#if isEditable?has_content && !isEditable> + <div class="form-group"> + <label for="cropDisplayName">${i18nBundle.cropOrganismId}</label> + <span id="cropDisplayName"></span> + </div> + <div class="form-group"> + <label for="pestDisplayName">${i18nBundle.organism}</label> + <span id="pestDisplayName"></span> + </div> + <div class="form-group"> + <label>År</label> ${observationTimeSeries.year} + </div> + <div class="form-group"> + <label for="locationDisplayName">${i18nBundle.location}</label> + <span id="locationDisplayName"></span> + </div> + <div class="form-group"> + <label for="locationVisibility">Synlighet</label> + <span id="locationVisibility"> + <#if locationVisibilityFormValue == 'public'> + ${i18nBundle.locationIsPublic} + <#elseif locationVisibilityFormValue == 'private'> + ${i18nBundle.locationIsPrivate} + <#else> + <#list polygonServices as polygonService> + <#if locationVisibilityFormValue == "mask_" + polygonService.polygonServiceId> + ${i18nBundle.maskObservationWith} ${polygonService.polygonServiceName} + </#if> + </#list> + </#if> + </span> + </div> + <input type="hidden" name="observationTimeSeriesId" value="${observationTimeSeries.observationTimeSeriesId}"> + <input type="hidden" name="year" value="${observationTimeSeries.year}"> + <input type="hidden" name="cropOrganismId" value="${observationTimeSeries.cropOrganism.organismId}"> + <input type="hidden" name="organismId" value="${observationTimeSeries.organism.organismId}"> + <input type="hidden" name="locationPointOfInterestId" value="${observationTimeSeries.locationPointOfInterestId}"> + <input type="hidden" name="locationVisibility" value="${locationVisibilityFormValue}"> + <#else> + <div class="form-group"> + <label for="cropCategoryId">${i18nBundle.listSelectedCropCategoryOnTop}</label> + <select class="form-control" id="cropCategoryIdList" name="cropCategoryId" + onchange="filterCrops(this.options[this.options.selectedIndex].value);"> + <option value="-1">${i18nBundle.pleaseSelect} ${i18nBundle.cropCategory?lower_case}</option> + <!-- Options added by JavaScript function renderCropCategories() --> + </select> + </div> + <div class="form-group"> + <label for="cropOrganismId">${i18nBundle.cropOrganismId}</label> + <select class="form-control" id="cropOrganismIdList" name="cropOrganismId" + <#if observationTimeSeries.observationTimeSeriesId?has_content && !user.isSuperUser() && !user.isOrganizationAdmin()>readonly="readonly" <#else> onblur="validateField(this);" onchange="updateCropPests();"</#if>> + <#if !observationTimeSeries.observationTimeSeriesId?has_content || user.isSuperUser() || user.isOrganizationAdmin()> + <option value="-1">${i18nBundle.pleaseSelect} ${i18nBundle.cropOrganismId?lower_case}</option> + <option value="-10" + <#if (observationTimeSeries.cropOrganism?has_content && observationTimeSeries.cropOrganism.organismId == -10)>selected="selected"</#if>>${i18nBundle.missingInDatabase}</option> + </#if> + </select> + <span class="help-block" id="${formId}_cropOrganismId_validation"></span> + </div> + <div class="form-group"> + <label for="organismId">${i18nBundle.organism}</label> + <select class="form-control" id="organismId" name="organismId" + <#if observationTimeSeries.organism?has_content && ! user.isSuperUser() && ! user.isOrganizationAdmin()>readonly="readonly" + onblur="validateField(this);"</#if>> + <#if !observationTimeSeries.organism?has_content> + <option value="-1">${i18nBundle.pleaseSelect} ${i18nBundle.organism?lower_case}</option> + <option value="-10" + <#if (observationTimeSeries.organism?has_content && observationTimeSeries.organism.organismId == -10)>selected="selected"</#if>>${i18nBundle.missingInDatabase}</option> + <#list allPests as organism> + <option value="${organism.organismId}">${organism.getLocalName(currentLocale.language)!""} (${organism.latinName!""}) ${hierarchyCategories.getName(organism.hierarchyCategoryId)?upper_case}</option> + </#list> + <#else> + <#list allPests as organism> + <option value="${organism.organismId}" <#if (observationTimeSeries.organism?has_content && observationTimeSeries.organism.organismId == organism.organismId)>selected="selected"</#if>>${organism.getLocalName(currentLocale.language)!""} + (${organism.latinName!""} + ) ${hierarchyCategories.getName(organism.hierarchyCategoryId)?upper_case}</option> + </#list> + </#if> + </select> + <span class="help-block" id="${formId}_organismId_validation"></span> + </div> + + <div class="form-group"> + <label for="yearList">${i18nBundle.year}</label> + <select class="form-control" id="yearList" name="year"> + <#if !observationTimeSeries.observationTimeSeriesId?has_content> + <option value="-1">${i18nBundle.pleaseSelect} ${i18nBundle.year?lower_case}</option> + </#if> + <#list years as year> + <option value="${year}" <#if observationTimeSeries.observationTimeSeriesId?has_content && observationTimeSeries.year == year>selected</#if>>${year}</option> + </#list> + </select> + </div> + <div class="form-group"> + <label for="locationPointOfInterestId">${i18nBundle.location} <button + role="button" type="button" + onclick="addNewLocationPopup();">${i18nBundle.addNew}</button> + </label> + <select class="form-control" name="locationPointOfInterestId" id="locationPointOfInterestId" onchange="showCorrectMap();"> + <option value="-1">${i18nBundle.pleaseSelect} ${i18nBundle.location?lower_case}</option> + </select> + <span class="help-block" id="${formId}_locationPointOfInterestId_validation"></span> + </div> + <div class="form-group"> + <div class="radio"> + <label> + <input type="radio" name="locationVisibility" value="public" + <#if locationVisibilityFormValue == "public">checked="checked"</#if> + /> + </label> + ${i18nBundle.locationIsPublic} + </div> + <div class="radio"> + + <label> + <input type="radio" name="locationVisibility" value="private" + <#if locationVisibilityFormValue == "private">checked="checked"</#if> + /> + </label> + ${i18nBundle.locationIsPrivate} + </div> + <#list polygonServices as polygonService> + <div class="radio"> + <label> + <input type="radio" name="locationVisibility" + value="mask_${polygonService.polygonServiceId}" + <#if locationVisibilityFormValue == "mask_" + polygonService.polygonServiceId>checked="checked"</#if> + /> + ${i18nBundle.maskObservationWith} ${polygonService.polygonServiceName} + </label> + </div> + </#list> + </div> + + </#if> + <div class="form-group"> + <label for="name">${i18nBundle.observationTimeSeriesName}</label> + <input type="text" class="form-control" id="name" name="name" placeholder="" + value="${observationTimeSeries.name!""}" onblur="validateField(this);" /> + <span class="help-block" id="${formId}_name_validation"></span> + </div> + <div class="form-group"> + <label for="description">${i18nBundle.observationTimeSeriesDescription}</label> + <textarea class="form-control" id="description" name="description" placeholder="">${observationTimeSeries.description!""}</textarea> + <span class="help-block" id="${formId}_description_validation"></span> + </div> + <button type="submit" class="btn btn-default">${i18nBundle.submit}</button> + <#if observationTimeSeries.observationTimeSeriesId?has_content> + <button type="button" class="btn btn-danger" + onclick="if(confirm('${i18nBundle.confirmDelete}')){window.location.href='/observationTimeSeries?action=deleteObservationTimeSeries&observationTimeSeriesId=${observationTimeSeries.observationTimeSeriesId}';}">${i18nBundle.delete}</button> + </#if> + + </form> + </div> + <div class="col-md-6"> + <div id="poiFormMap" class="map" style="height: 400px;"> + <div id="popover"></div> + </div> + </div> + </div> + <#if observations?has_content> + <h2>Registrerte observasjoner</h2> + <div class="row mt-3"> + <div class="table-responsive"> + <table class="table table-striped"> + <thead> + <th>${i18nBundle.timeOfObservation}</th> + <th>${i18nBundle.cropOrganismId}</th> + <th>${i18nBundle.organism}</th> + <th>${i18nBundle.location}</th> + <th>${i18nBundle.observer}</th> + <th>${i18nBundle.heading}</th> + <th>${i18nBundle.status}</th> + <th></th> + </thead> + <tbody> + <#list observations as observation> + <tr> + <td>${observation.timeOfObservation?string("yyyy-MM-dd")}</td> + <td>${observation.cropOrganism.latinName!""}/${observation.cropOrganism.getLocalName(currentLocale.language)!""}</td> + <td>${observation.organism.latinName!""}/${observation.organism.getLocalName(currentLocale.language)!""}</td> + <td><#if observation.location?has_content>${observation.location.name!""}</#if></td> + <td><#if observation.user?has_content>${observation.user.firstName!""} ${observation.user.lastName!""}</#if></td> + <td>${observation.observationHeading}</td> + <td><#switch observation.statusTypeId><#case 1>${i18nBundle.pending}<#break><#case 2>${i18nBundle.rejected}<#break><#case 3>${i18nBundle.approved}</#switch></td> + <td><#if user.isSuperUser() || user.isOrganizationAdmin() || userIsObservationAuthority || observation.userId == user.userId><a href="/observation?action=editObservationForm&observationId=${observation.observationId}" class="btn btn-default" role="button">${i18nBundle.edit}</a></#if></td> + </tr> + </#list> + </tbody> + </table> + </div> + </div> + </#if> +</div> +</#macro> +<@page_html/> diff --git a/src/main/webapp/templates/observationTimeSeriesList.ftl b/src/main/webapp/templates/observationTimeSeriesList.ftl new file mode 100644 index 00000000..6a496e8a --- /dev/null +++ b/src/main/webapp/templates/observationTimeSeriesList.ftl @@ -0,0 +1,57 @@ +<#include "master.ftl"> +<#setting time_zone=user.organizationId.defaultTimeZone!"UTC"> +<#macro page_head> + <title>${i18nBundle.timeSeriesList}</title> +</#macro> +<#macro custom_css> + <link href="/css/3rdparty/chosen.min.css" rel="stylesheet"/> +</#macro> +<#macro custom_js> + <script type="text/javascript" src="/js/3rdparty/chosen.jquery.min.js"></script> + <script type="text/javascript" src="/js/3rdparty/jquery.datetimepicker.js"></script> +</#macro> +<#macro page_contents> + <div class="singleBlockContainer"> + <h1>${i18nBundle.timeSeriesList}</h1> + <#if messageKey?has_content> + <div class="alert alert-success">${i18nBundle(messageKey)}</div> + </#if> + <a href="/observationTimeSeries?action=newObservationTimeSeriesForm" class="btn btn-default" role="button">${i18nBundle.addNew}</a> + + <div class="table-responsive"> + <table class="table table-striped"> + <thead> + <th>Tittel</th> + <th>År</th> + <th>${i18nBundle.cropOrganismId}</th> + <th>${i18nBundle.organism}</th> + <th>${i18nBundle.location}</th> + <th>${i18nBundle.observer}</th> + <th>${i18nBundle.observationCount}</th> + <th></th> + </thead> + <tbody> + <#list observationTimeSeriesList as timeSeries> + <tr> + <td>${timeSeries.name}</td> + <td>${timeSeries.year}</td> + <td>${timeSeries.cropOrganism.latinName!""} + /${timeSeries.cropOrganism.getLocalName(currentLocale.language)!""}</td> + <td>${timeSeries.organism.latinName!""} + /${timeSeries.organism.getLocalName(currentLocale.language)!""}</td> + <td><#if timeSeries.locationPointOfInterest?has_content>${timeSeries.locationPointOfInterest.name!""}</#if></td> + <td><#if timeSeries.user?has_content>${timeSeries.user.firstName!""} ${timeSeries.user.lastName!""}</#if></td> + <td>${observationCounts[timeSeries.observationTimeSeriesId?string]}</td> + <td><#if user.isSuperUser() || user.isOrganizationAdmin() || userIsObservationAuthority || observation.userId == user.userId> + <a + href="/observationTimeSeries?action=editObservationTimeSeriesForm&observationTimeSeriesId=${timeSeries.observationTimeSeriesId}" + class="btn btn-default" role="button">${i18nBundle.edit}</a></#if></td> + </tr> + </#list> + </tbody> + </table> + </div> + + </div> +</#macro> +<@page_html/> -- GitLab