From 70ad66a18e628a78b11ab040df74f0f84eaf5bc5 Mon Sep 17 00:00:00 2001
From: Tor-Einar Skog <tor-einar.skog@nibio.no>
Date: Mon, 28 Feb 2022 08:25:14 +0000
Subject: [PATCH] Major merge to get develop up to speed before season starts

---
 .../vips/logic/VIPSLogicApplication.java      |   6 +-
 .../servlet/ObservationController.java        |  11 +-
 .../servlet/PointOfInterestController.java    |  28 +--
 .../controller/session/ObservationBean.java   | 108 +++++++--
 .../controller/session/OrganismBean.java      |  12 +
 .../session/PointOfInterestBean.java          |   2 +-
 .../logic/controller/session/UserBean.java    |  17 +-
 .../vips/logic/entity/AppObservation.java     |  72 ++++++
 .../nibio/vips/logic/entity/Observation.java  |  11 +
 .../logic/entity/ObservationSyncInfo.java     |  53 +++++
 .../no/nibio/vips/logic/entity/Organism.java  |  11 +
 .../vips/logic/entity/PointOfInterest.java    |  55 ++++-
 .../entity/PointOfInterestWeatherStation.java |   6 +-
 .../logic/service/AuthenticationService.java  |   4 +-
 .../vips/logic/service/LogicService.java      |  12 +-
 .../logic/service/ObservationService.java     | 194 ++++++++++++++-
 .../nibio/vips/logic/service/POIService.java  | 223 ++++++++++++++++++
 .../observationdata/ObservationDataBean.java  | 114 +++++++++
 .../ObservationDataService.java               |  18 +-
 .../util/weather/WeatherDataSourceUtil.java   |   2 +-
 .../V10__POI_add_last_edited_date.sql         |   7 +
 .../V9__Observation_add_last_edited_date.sql  |   6 +
 .../vips/logic/i18n/vipslogictexts.properties |  24 +-
 .../logic/i18n/vipslogictexts_bs.properties   |  25 +-
 .../logic/i18n/vipslogictexts_hr.properties   |  24 +-
 .../logic/i18n/vipslogictexts_nb.properties   |  23 +-
 .../logic/i18n/vipslogictexts_sr.properties   |  24 +-
 .../i18n/vipslogictexts_zh_CN.properties      |  25 +-
 src/main/webapp/templates/observationForm.ftl |   9 +-
 src/main/webapp/templates/poiForm.ftl         |   2 +-
 src/main/webapp/templates/poiList.ftl         |   2 +-
 31 files changed, 994 insertions(+), 136 deletions(-)
 create mode 100644 src/main/java/no/nibio/vips/logic/entity/AppObservation.java
 create mode 100644 src/main/java/no/nibio/vips/logic/entity/ObservationSyncInfo.java
 create mode 100644 src/main/java/no/nibio/vips/logic/service/POIService.java
 create mode 100644 src/main/java/no/nibio/vips/observationdata/ObservationDataBean.java
 create mode 100644 src/main/resources/db/migration/V10__POI_add_last_edited_date.sql
 create mode 100644 src/main/resources/db/migration/V9__Observation_add_last_edited_date.sql

diff --git a/src/main/java/no/nibio/vips/logic/VIPSLogicApplication.java b/src/main/java/no/nibio/vips/logic/VIPSLogicApplication.java
index 59995d75..97a8feb4 100755
--- a/src/main/java/no/nibio/vips/logic/VIPSLogicApplication.java
+++ b/src/main/java/no/nibio/vips/logic/VIPSLogicApplication.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2014 NIBIO <http://www.nibio.no/>. 
+ * Copyright (c) 2021 NIBIO <http://www.nibio.no/>. 
  * 
  * This file is part of VIPSLogic.
  * VIPSLogic is free software: you can redistribute it and/or modify
@@ -25,7 +25,7 @@ import javax.ws.rs.core.Application;
 
 /**
  * Responsible for adding REST resources
- * @copyright 2016 {@link http://www.nibio.no NIBIO}
+ * @copyright 2016-2021 {@link http://www.nibio.no NIBIO}
  * @author Tor-Einar Skog <tor-einar.skog@nibio.no>
  */
 @ApplicationPath("/rest/")
@@ -46,6 +46,7 @@ public class VIPSLogicApplication extends Application
      */
     private void addRestResourceClassesManually(Set<Class<?>> resources) {
         resources.add(no.nibio.vips.logic.service.LogicService.class);
+        resources.add(no.nibio.vips.logic.service.POIService.class);
         resources.add(no.nibio.vips.logic.service.AuthenticationService.class);
         resources.add(no.nibio.vips.logic.service.VIPSMobileService.class);
         resources.add(no.nibio.vips.logic.modules.barleynetblotch.BarleyNetBlotchModelService.class);
@@ -83,6 +84,7 @@ public class VIPSLogicApplication extends Application
         resources.add(no.nibio.vips.logic.service.LogicService.class);
         resources.add(no.nibio.vips.logic.service.ModelFormService.class);
         resources.add(no.nibio.vips.logic.service.ObservationService.class);
+        resources.add(no.nibio.vips.logic.service.POIService.class);
         resources.add(no.nibio.vips.logic.service.VIPSMobileService.class);
         resources.add(no.nibio.vips.observationdata.ObservationDataService.class);
     }
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 eb18dd3d..8cdcbb17 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
@@ -447,10 +447,7 @@ public class ObservationController extends HttpServlet {
                                 observation.setOrganism(em.find(Organism.class, formValidation.getFormField("organismId").getValueAsInteger()));
                                 observation.setCropOrganism(em.find(Organism.class, formValidation.getFormField("cropOrganismId").getValueAsInteger()));
                             }
-                            //observation.setDenominator(formValidation.getFormField("denominator").getValueAsInteger());
-                            //observation.setObservationMethodId(em.find(ObservationMethod.class, formValidation.getFormField("observationMethodId").getWebValue()));
                             observation.setTimeOfObservation(formValidation.getFormField("timeOfObservation").getValueAsTimestamp());
-                            //observation.setObservedValue(formValidation.getFormField("observedValue").getValueAsDouble());
                             if(observationId <= 0)
                             {
                                 observation.setUserId(user.getUserId());
@@ -459,7 +456,7 @@ public class ObservationController extends HttpServlet {
                             {
                                 observation.setLastEditedBy(user.getUserId());
                             }
-
+                            observation.setLastEditedTime(new Date());
                             observation.setObservationHeading(formValidation.getFormField("observationHeading").getWebValue());
                             observation.setObservationText(formValidation.getFormField("observationText").getWebValue());
                             observation.setObservationData(
@@ -515,10 +512,10 @@ public class ObservationController extends HttpServlet {
 
                             // Image handling
                             // Delete the current illustration
-                            String deleteIllustration = formValidation.getFormField("deleteIllustration").getWebValue();
-                            if(deleteIllustration != null && deleteIllustration.equals("true"))
+                            String[] deleteIllustrations = parameterMap.get("deleteIllustration");
+                            if(deleteIllustrations != null && deleteIllustrations.length > 0)
                             {
-                                observation = observationBean.deleteObservationIllustration(observation);
+                                observation = observationBean.deleteObservationIllustration(observation, deleteIllustrations);
                             }
 
                             // Store the new illustration (replaces former illustration if not already deleted)
diff --git a/src/main/java/no/nibio/vips/logic/controller/servlet/PointOfInterestController.java b/src/main/java/no/nibio/vips/logic/controller/servlet/PointOfInterestController.java
index 97ce9d8d..962930a1 100755
--- a/src/main/java/no/nibio/vips/logic/controller/servlet/PointOfInterestController.java
+++ b/src/main/java/no/nibio/vips/logic/controller/servlet/PointOfInterestController.java
@@ -192,7 +192,7 @@ public class PointOfInterestController extends HttpServlet {
                     request.setAttribute("defaultMapCenter",user.getOrganizationId().getDefaultMapCenter());
                     request.setAttribute("defaultMapZoom", user.getOrganizationId().getDefaultMapZoom());
                     request.setAttribute("messageKey", request.getParameter("messageKey"));
-                    request.setAttribute("returnURL","weatherStation?organizationId=" + weatherStation.getUserId().getOrganizationId().getOrganizationId());
+                    request.setAttribute("returnURL","weatherStation?organizationId=" + weatherStation.getUser().getOrganizationId().getOrganizationId());
                     request.getRequestDispatcher("/weatherstationView.ftl").forward(request, response);
 
                 }
@@ -263,7 +263,7 @@ public class PointOfInterestController extends HttpServlet {
                             Collections.sort(users);
                             request.getSession().setAttribute("users", users);
                         }
-                        request.setAttribute("returnURL","weatherStation?organizationId=" + weatherStation.getUserId().getOrganizationId().getOrganizationId());
+                        request.setAttribute("returnURL","weatherStation?organizationId=" + weatherStation.getUser().getOrganizationId().getOrganizationId());
                         request.getRequestDispatcher("/weatherstationForm.ftl").forward(request, response);
                     }
                     catch(NullPointerException | NumberFormatException ex)
@@ -331,12 +331,12 @@ public class PointOfInterestController extends HttpServlet {
                             
                             if(user.isSuperUser() && !formValidation.getFormField("userId").isEmpty())
                             {
-                                weatherStation.setUserId(em.find(VipsLogicUser.class, formValidation.getFormField("userId").getValueAsInteger()));
+                                weatherStation.setUser(em.find(VipsLogicUser.class, formValidation.getFormField("userId").getValueAsInteger()));
                             }
                             // If user is not set, use current user
-                            else if(weatherStation.getUserId() == null)
+                            else if(weatherStation.getUser() == null)
                             {
-                                weatherStation.setUserId(user);
+                                weatherStation.setUser(user);
                             }
                             // Store
                             weatherStation = pointOfInterestBean.storeWeatherStation(weatherStation);
@@ -530,7 +530,7 @@ public class PointOfInterestController extends HttpServlet {
                     request.setAttribute("defaultMapCenter",user.getOrganizationId().getDefaultMapCenter());
                     request.setAttribute("defaultMapZoom", user.getOrganizationId().getDefaultMapZoom());
                     request.setAttribute("messageKey", request.getParameter("messageKey"));
-                    request.setAttribute("returnURL","poi?organizationId=" + poi.getUserId().getOrganizationId().getOrganizationId());
+                    request.setAttribute("returnURL","poi?organizationId=" + poi.getUser().getOrganizationId().getOrganizationId());
                     request.getRequestDispatcher("/poiView.ftl").forward(request, response);
 
                 }
@@ -585,7 +585,7 @@ public class PointOfInterestController extends HttpServlet {
                     PointOfInterest poi = em.find(PointOfInterest.class, pointOfInterestId);
                     // Does the current user have the rights to edit this poi?
                     if(userBean.authorizeUser(user, VipsLogicRole.ORGANIZATION_ADMINISTRATOR, VipsLogicRole.SUPERUSER)
-                            || Objects.equals(user.getUserId(), poi.getUserId().getUserId())
+                            || Objects.equals(user.getUserId(), poi.getUser().getUserId())
                     )
                     {
                         request.getSession().setAttribute("poi", poi);
@@ -613,7 +613,7 @@ public class PointOfInterestController extends HttpServlet {
                                             .setParameter("organizationId", user.getOrganizationId()).getResultList()
                             );
                         }
-                        request.setAttribute("returnURL","poi?organizationId=" + poi.getUserId().getOrganizationId().getOrganizationId());
+                        request.setAttribute("returnURL","poi?organizationId=" + poi.getUser().getOrganizationId().getOrganizationId());
                         request.getRequestDispatcher("/poiForm.ftl").forward(request, response);
                     }
                     else
@@ -638,7 +638,7 @@ public class PointOfInterestController extends HttpServlet {
                                                                         : PointOfInterestFactory.getPointOfInterest(formValidation.getFormField("pointOfInterestTypeId").getValueAsInteger());
                         if(userBean.authorizeUser(user, VipsLogicRole.ORGANIZATION_ADMINISTRATOR, VipsLogicRole.SUPERUSER)
                             || poi.getPointOfInterestId() == null
-                            || Objects.equals(user.getUserId(), poi.getUserId().getUserId())
+                            || Objects.equals(user.getUserId(), poi.getUser().getUserId())
                             )
                         {
                             Boolean poiNameAlreadyExists = pointOfInterestBean.getPointOfInterest(formValidation.getFormField("name").getWebValue()) != null;
@@ -687,12 +687,12 @@ public class PointOfInterestController extends HttpServlet {
 
                                 if((user.isSuperUser() || user.isOrganizationAdmin()) && !formValidation.getFormField("userId").isEmpty())
                                 {
-                                    poi.setUserId(em.find(VipsLogicUser.class, formValidation.getFormField("userId").getValueAsInteger()));
+                                    poi.setUser(em.find(VipsLogicUser.class, formValidation.getFormField("userId").getValueAsInteger()));
                                 }
                                 // If user is not set, use current user
-                                else if(poi.getUserId() == null)
+                                else if(poi.getUser() == null)
                                 {
-                                    poi.setUserId(user);
+                                    poi.setUser(user);
                                 }
                                 // Store
                                 poi = pointOfInterestBean.storePoi(poi);
@@ -783,7 +783,7 @@ public class PointOfInterestController extends HttpServlet {
                 PointOfInterest poi = em.find(PointOfInterest.class, pointOfInterestId);
                 if(
                         userBean.authorizeUser(user, VipsLogicRole.ORGANIZATION_ADMINISTRATOR, VipsLogicRole.SUPERUSER)
-                        || user.getUserId().equals(poi.getUserId().getUserId())
+                        || user.getUserId().equals(poi.getUser().getUserId())
                 )
                 {
                     try
@@ -832,7 +832,7 @@ public class PointOfInterestController extends HttpServlet {
                 PointOfInterest poi = em.find(PointOfInterest.class, pointOfInterestId);
                 if(
                         userBean.authorizeUser(user, VipsLogicRole.ORGANIZATION_ADMINISTRATOR, VipsLogicRole.SUPERUSER)
-                        || user.getUserId().equals(poi.getUserId().getUserId())
+                        || user.getUserId().equals(poi.getUser().getUserId())
                 )
                 {
                     try
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 412b2332..ddcc2fe2 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
@@ -26,6 +26,9 @@ import com.ibm.icu.util.ULocale;
 
 import java.io.File;
 import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Calendar;
@@ -60,6 +63,7 @@ import no.nibio.vips.logic.entity.PolygonService;
 import no.nibio.vips.logic.entity.VipsLogicUser;
 import no.nibio.vips.logic.i18n.SessionLocaleUtil;
 import no.nibio.vips.logic.util.SystemTime;
+import org.apache.commons.codec.binary.Base64;
 import no.nibio.vips.observationdata.ObservationDataSchema;
 import no.nibio.vips.observationdata.ObservationDataSchemaPK;
 import org.apache.commons.fileupload.FileItem;
@@ -305,11 +309,21 @@ public class ObservationBean {
 
     public void deleteObservation(Integer observationId) {
         Observation observation = em.find(Observation.class, observationId);
-        // Delete all current group memberships
-        em.createNativeQuery("DELETE FROM public.organization_group_observation WHERE observation_id=:observationId")
-                .setParameter("observationId", observation.getObservationId())
-                .executeUpdate();
-        em.remove(observation);
+        if(observation != null)
+        {
+        	// Delete all current group memberships
+            em.createNativeQuery("DELETE FROM public.organization_group_observation WHERE observation_id=:observationId")
+                    .setParameter("observationId", observation.getObservationId())
+                    .executeUpdate();
+            
+            // Delete all illustrations (including removing files on disk)
+            String[] filesToDelete = observation.getObservationIllustrationSet().stream()
+            		.map(ill->ill.getObservationIllustrationPK().getFileName())
+            		.collect(Collectors.toList())
+            		.toArray(new String[0]);
+            this.deleteObservationIllustration(observation, filesToDelete);
+            em.remove(observation);
+        }
     }
 
     /**
@@ -379,18 +393,46 @@ public class ObservationBean {
     
     /**
      * 
-     * @param message
-     * @return [OBSERVATION_ILLUSTRATION_PATH]/[ORGANIZATION_ID]/
+     * @param observation
+     * @return [OBSERVATION_ILLUSTRATION_PATH]/[ORGANISM_ID]/
      */
     private String getFilePath(Observation observation)
     {
         return System.getProperty("no.nibio.vips.logic.OBSERVATION_ILLUSTRATION_PATH") + "/" 
                         + observation.getOrganismId();
     }
+    
+    public Observation storeObservationIllustration(Observation observation, String fileName, String base64Data)
+    {
+    	String[] metaAndData = base64Data.split(",");
+		byte[] imageData = Base64.decodeBase64(metaAndData[1]);
+		java.nio.file.Path path = Paths.get(this.getFilePath(observation) + "/" + fileName);
+		try {
+			// Make sure the directory exists
+			File testDirectoryfile = new File(this.getFilePath(observation));
+	        if(!testDirectoryfile.exists())
+	        {
+	            testDirectoryfile.mkdirs();
+	        }
+			Files.write(path,imageData, StandardOpenOption.CREATE);
+			ObservationIllustration newIllustration = new ObservationIllustration(new ObservationIllustrationPK(observation.getObservationId(), fileName));
+	        newIllustration = em.merge(newIllustration);
+	        
+	        // Add the new illustration 
+	        if(observation.getObservationIllustrationSet() == null)
+	        {
+	            observation.setObservationIllustrationSet(new HashSet<ObservationIllustration>());
+	        }
+	        observation.getObservationIllustrationSet().add(newIllustration);
+
+			return observation;
+		}
+		catch(IOException ex) {ex.printStackTrace(); return observation;}
+    }
 
     public Observation storeObservationIllustration(Observation observation, FileItem item) throws Exception {
         // Create a candidate filename
-        // [MESSAGE_ILLUSTRATION_PATH]/[ORGANIZATION_ID]/[MESSAGE_ID]_illustration.[fileExtension]
+        // [OBSERVATION_ILLUSTRATION_PATH]/[ORGANIZATION_ID]/[OBSERVATION_ID]_illustration.[fileExtension]
         String filePath = this.getFilePath(observation);
         String fileName = observation.getObservationId() + "_illustration." + FilenameUtils.getExtension(item.getName());
         // Check availability, and adapt filename until available
@@ -415,18 +457,7 @@ public class ObservationBean {
         // Update MessageIllustrations
         observation = em.merge(observation);
         // Remove the old illustration(s)
-        List <ObservationIllustration> formerIllustrations = em.createNamedQuery("ObservationIllustration.findByObservationId").setParameter("observationId", observation.getObservationId()).getResultList();
-        for(ObservationIllustration formerIllustration:formerIllustrations)
-        {
-            System.out.println("removing " + formerIllustration.toString());
-            em.remove(formerIllustration);
-        }
-        // Also remove their relation to message.
-        if(observation.getObservationIllustrationSet() != null)
-        {
-            observation.getObservationIllustrationSet().clear();
-        }
-        
+               
         ObservationIllustration newIllustration = new ObservationIllustration(new ObservationIllustrationPK(observation.getObservationId(), fileName));
         em.persist(newIllustration);
         
@@ -436,19 +467,41 @@ public class ObservationBean {
             observation.setObservationIllustrationSet(new HashSet<ObservationIllustration>());
         }
         observation.getObservationIllustrationSet().add(newIllustration);
-        //message.getMessageIllustrationSet().add(newIllustration);
         return observation;
     }
 
-    public Observation deleteObservationIllustration(Observation observation) {
+    public Observation deleteObservationIllustration(Observation observation, String[] deleteIllustrations) {
         observation = em.merge(observation);
         
         Set <ObservationIllustration> formerIllustrations = observation.getObservationIllustrationSet();
-        for(ObservationIllustration formerIllustration:formerIllustrations)
+        if(formerIllustrations == null)
         {
-            em.remove(formerIllustration);
+        	return observation;
+        }
+        
+        Set  <ObservationIllustration> deleteThese = new HashSet<>();
+        
+    	for(String deleteIllustration:deleteIllustrations)
+    	{
+    		for(ObservationIllustration formerIllustration:formerIllustrations)
+            {
+        		if(formerIllustration.getObservationIllustrationPK().getFileName()
+        				.equals(deleteIllustration))
+        		{
+        			deleteThese.add(formerIllustration);
+        		}
+        	}
         }
-        observation.getObservationIllustrationSet().clear();
+    	
+    	for(ObservationIllustration ill: deleteThese)
+    	{
+    		observation.getObservationIllustrationSet().remove(ill);
+    		em.remove(ill);
+    		// Physically remove it too
+    		File fileToDelete = new File(this.getFilePath(observation) + "/" + ill.getObservationIllustrationPK().getFileName());
+    		fileToDelete.delete();
+    	}
+
         return observation;
     }
 
@@ -756,6 +809,11 @@ public class ObservationBean {
                 
     }
 
+    public PolygonService getPolygonService(Integer polygonServiceId)
+    {
+    	return em.find(PolygonService.class, polygonServiceId);
+    }
+    
     public List<PolygonService> getPolygonServicesForOrganization(Integer organizationId) {
         return em.createNativeQuery("SELECT * FROM polygon_service p WHERE p.polygon_service_id IN (SELECT polygon_service_id FROM public.organization_polygon_service WHERE organization_id=:organizationId)", PolygonService.class)
                 .setParameter("organizationId", organizationId)
diff --git a/src/main/java/no/nibio/vips/logic/controller/session/OrganismBean.java b/src/main/java/no/nibio/vips/logic/controller/session/OrganismBean.java
index e6707013..88676131 100755
--- a/src/main/java/no/nibio/vips/logic/controller/session/OrganismBean.java
+++ b/src/main/java/no/nibio/vips/logic/controller/session/OrganismBean.java
@@ -19,6 +19,8 @@
 
 package no.nibio.vips.logic.controller.session;
 
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.ibm.icu.util.ULocale;
 
 import java.time.Instant;
@@ -371,6 +373,7 @@ public class OrganismBean {
                 .getResultList();
     }
     
+    
     /**
      * Searching recursively upwards in organism tree to find a cropPest.
      * @param cropOrganismId id of the crop
@@ -444,6 +447,15 @@ public class OrganismBean {
         return organism;
     }
     
+    /**
+     * 
+     * @param organismId
+     * @return
+     */
+    public Organism getOrganism(Integer organismId) {
+    	return em.find(Organism.class, organismId);
+    }
+    
     public List<Organism> sortOrganismsByLocalName(List<Organism> organisms, final String locale)
     {
         Collections.sort(organisms, new Comparator<Organism>() {
diff --git a/src/main/java/no/nibio/vips/logic/controller/session/PointOfInterestBean.java b/src/main/java/no/nibio/vips/logic/controller/session/PointOfInterestBean.java
index 0e029a33..6f624f3d 100755
--- a/src/main/java/no/nibio/vips/logic/controller/session/PointOfInterestBean.java
+++ b/src/main/java/no/nibio/vips/logic/controller/session/PointOfInterestBean.java
@@ -421,7 +421,7 @@ public class PointOfInterestBean {
             // double catching of privately owned weather station
             retVal.addAll(this.getWeatherstationsForOrganization(user.getOrganizationId(), Boolean.TRUE)
                     .stream()
-                    .filter(weatherStation -> ! weatherStation.getUserId().getUserId().equals(user.getUserId()))
+                    .filter(weatherStation -> ! weatherStation.getUser().getUserId().equals(user.getUserId()))
                     .collect(Collectors.toList())
             );
             
diff --git a/src/main/java/no/nibio/vips/logic/controller/session/UserBean.java b/src/main/java/no/nibio/vips/logic/controller/session/UserBean.java
index cb6f26ed..cf84f1c2 100755
--- a/src/main/java/no/nibio/vips/logic/controller/session/UserBean.java
+++ b/src/main/java/no/nibio/vips/logic/controller/session/UserBean.java
@@ -49,8 +49,11 @@ import javax.persistence.NoResultException;
 import javax.persistence.NonUniqueResultException;
 import javax.persistence.PersistenceContext;
 import javax.persistence.Query;
+import javax.servlet.http.HttpServletRequest;
 import javax.validation.ConstraintViolation;
 import javax.validation.ConstraintViolationException;
+import javax.ws.rs.core.HttpHeaders;
+
 import no.nibio.vips.logic.authenticate.PasswordValidationException;
 import no.nibio.vips.logic.entity.Country;
 import no.nibio.vips.logic.entity.ForecastConfiguration;
@@ -278,7 +281,7 @@ public class UserBean {
     public void transferUserResources(VipsLogicUser fromUser, VipsLogicUser toUser) {
         UserResources userResources = this.getUserResources(fromUser);
         (userResources.getPois() != null ? userResources.getPois() : Collections.<PointOfInterest>emptyList()).forEach((ws) -> {
-            ws.setUserId(toUser);
+            ws.setUser(toUser);
         });
         (userResources.getMessageLocales() != null ? userResources.getMessageLocales() : Collections.<MessageLocale>emptyList()).forEach((ml) -> {
             ml.setCreatedBy(toUser);
@@ -973,4 +976,16 @@ public class UserBean {
                 .setParameter("vipsLogicRoleIds", Arrays.asList(vipsLogicRoleIds))
                 .getResultList();
     }
+    
+    public VipsLogicUser getUserFromUUID(HttpServletRequest request)
+    {
+    	String uuidStr = request.getHeader(HttpHeaders.AUTHORIZATION);
+    	if(uuidStr == null)
+    	{
+    		return null;
+    	}
+		UUID uuid = UUID.fromString(uuidStr);
+		VipsLogicUser user = SessionControllerGetter.getUserBean().findVipsLogicUser(uuid);
+		return user;
+    }
 }
diff --git a/src/main/java/no/nibio/vips/logic/entity/AppObservation.java b/src/main/java/no/nibio/vips/logic/entity/AppObservation.java
new file mode 100644
index 00000000..bfb76f2b
--- /dev/null
+++ b/src/main/java/no/nibio/vips/logic/entity/AppObservation.java
@@ -0,0 +1,72 @@
+package no.nibio.vips.logic.entity;
+
+import java.util.Date;
+
+public class AppObservation implements no.nibio.vips.observation.Observation{
+    private Date timeOfObservation;
+    private String geoinfo, observationData, name;
+
+
+    
+    /**
+     * @return the timeOfObservation
+     */
+    public Date getTimeOfObservation() {
+        return timeOfObservation;
+    }
+
+    /**
+     * @param timeOfObservation the timeOfObservation to set
+     */
+    public void setTimeOfObservation(Date timeOfObservation) {
+        this.timeOfObservation = timeOfObservation;
+    }
+
+    /**
+     * @return the geoinfo
+     */
+    public String getGeoinfo() {
+        return geoinfo;
+    }
+
+    /**
+     * @param geoinfo the geoinfo to set
+     */
+    public void setGeoinfo(String geoinfo) {
+        this.geoinfo = geoinfo;
+    }
+
+    /**
+     * @return the observationData
+     */
+    public String getObservationData() {
+        return observationData;
+    }
+
+    /**
+     * @param observationData the observationData to set
+     */
+    public void setObservationData(String observationData) {
+        this.observationData = observationData;
+    }
+
+    /**
+     * @return the name
+     */
+    public String getName() {
+        return name;
+    }
+
+    /**
+     * @param name the name to set
+     */
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    @Override
+    public int compareTo(Object t) {
+        throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates.
+    }
+
+}
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 d10dc810..d34522ac 100755
--- a/src/main/java/no/nibio/vips/logic/entity/Observation.java
+++ b/src/main/java/no/nibio/vips/logic/entity/Observation.java
@@ -101,6 +101,7 @@ public class Observation implements Serializable, no.nibio.vips.observation.Obse
     private Integer statusTypeId;
     private Integer statusChangedByUserId;
     private Date statusChangedTime;
+    private Date lastEditedTime;
     private String statusRemarks;
     private String observationData;
     private Boolean isQuantified;
@@ -654,5 +655,15 @@ public class Observation implements Serializable, no.nibio.vips.observation.Obse
                 observationDataSchema
         );
     }
+
+    @Temporal(TemporalType.TIMESTAMP)
+    @Column(name = "last_edited_time")
+	public Date getLastEditedTime() {
+		return lastEditedTime; 
+	}
+
+	public void setLastEditedTime(Date lastEditedTime) {
+		this.lastEditedTime = lastEditedTime;
+	}
     
 }
diff --git a/src/main/java/no/nibio/vips/logic/entity/ObservationSyncInfo.java b/src/main/java/no/nibio/vips/logic/entity/ObservationSyncInfo.java
new file mode 100644
index 00000000..5702cfe1
--- /dev/null
+++ b/src/main/java/no/nibio/vips/logic/entity/ObservationSyncInfo.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2021 NIBIO <http://www.nibio.no/>. 
+ * 
+ * This file is part of VIPSLogic.
+ * VIPSLogic is free software: you can redistribute it and/or modify
+ * it under the terms of the NIBIO Open Source License as published by 
+ * NIBIO, either version 1 of the License, or (at your option) any
+ * later version.
+ * 
+ * VIPSLogic 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
+ * NIBIO Open Source License for more details.
+ * 
+ * You should have received a copy of the NIBIO Open Source License
+ * along with VIPSLogic.  If not, see <http://www.nibio.no/licenses/>.
+ * 
+ */
+
+package no.nibio.vips.logic.entity;
+
+import java.util.Date;
+
+/**
+ * A minimized object for serialization when only sync related
+ * information is needed
+ * 
+ * @author Tor-Einar Skog <tor-einar.skog@nibio.no>
+ *
+ */
+public class ObservationSyncInfo {
+	private Integer observationId;
+	private Date lastEditedTime;
+	
+	public ObservationSyncInfo(Observation obs)
+	{
+		this.observationId = obs.getObservationId();
+		this.lastEditedTime = obs.getLastEditedTime();
+	}
+	
+	public Integer getObservationId() {
+		return observationId;
+	}
+	public void setObservationId(Integer observationId) {
+		this.observationId = observationId;
+	}
+	public Date getLastEditedTime() {
+		return lastEditedTime;
+	}
+	public void setLastEditedTime(Date lastEditedTime) {
+		this.lastEditedTime = lastEditedTime;
+	}
+}
diff --git a/src/main/java/no/nibio/vips/logic/entity/Organism.java b/src/main/java/no/nibio/vips/logic/entity/Organism.java
index 3e2ba63f..612cfaec 100755
--- a/src/main/java/no/nibio/vips/logic/entity/Organism.java
+++ b/src/main/java/no/nibio/vips/logic/entity/Organism.java
@@ -98,6 +98,9 @@ public class Organism implements Serializable {
     @Transient
     private Map<String,Object> extraProperties;
     
+    @Transient
+    private String observationDataSchema;
+    
     /*@OneToMany(cascade = CascadeType.ALL, mappedBy = "organism")
     private Set<OrganismLocale> organismLocaleSet;
     @OneToMany(cascade = CascadeType.ALL, mappedBy = "organism")
@@ -346,4 +349,12 @@ public class Organism implements Serializable {
     public void setExtraProperties(Map<String,Object> extraProperties) {
         this.extraProperties = extraProperties;
     }
+
+	public String getObservationDataSchema() {
+		return observationDataSchema;
+	}
+
+	public void setObservationDataSchema(String observationDataSchema) {
+		this.observationDataSchema = observationDataSchema;
+	}
 }
diff --git a/src/main/java/no/nibio/vips/logic/entity/PointOfInterest.java b/src/main/java/no/nibio/vips/logic/entity/PointOfInterest.java
index f8325ed7..db7fc933 100755
--- a/src/main/java/no/nibio/vips/logic/entity/PointOfInterest.java
+++ b/src/main/java/no/nibio/vips/logic/entity/PointOfInterest.java
@@ -19,6 +19,7 @@
 package no.nibio.vips.logic.entity;
 
 import java.io.Serializable;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
 import javax.persistence.Basic;
@@ -62,15 +63,11 @@ import no.nibio.vips.gis.GISUtil;
     @NamedQuery(name = "PointOfInterest.findAll", query = "SELECT p FROM PointOfInterest p ORDER BY p.name ASC"),
     @NamedQuery(name = "PointOfInterest.findByPointOfInterestId", query = "SELECT p FROM PointOfInterest p WHERE p.pointOfInterestId = :pointOfInterestId"),
     @NamedQuery(name = "PointOfInterest.findByPointOfInterestIds", query = "SELECT p FROM PointOfInterest p WHERE p.pointOfInterestId IN :pointOfInterestIds"),
-    //@NamedQuery(name = "PointOfInterest.findByPointOfInterestTypeId", query = "SELECT p FROM PointOfInterest p WHERE p.pointOfInterestType = :pointOfInterestType"),
-    @NamedQuery(name = "PointOfInterest.findByOrganizationId", query = "SELECT p FROM PointOfInterest p WHERE p.userId IN(SELECT u.userId FROM VipsLogicUser u WHERE u.organizationId=:organizationId OR u.organizationId IN (SELECT o.organizationId FROM Organization o WHERE o.parentOrganizationId = :organizationId))  ORDER BY p.name ASC"),
-    @NamedQuery(name = "PointOfInterest.findForecastLocationsByOrganizationId", query = "SELECT p FROM PointOfInterest p WHERE p.isForecastLocation IS TRUE AND p.userId IN(SELECT u.userId FROM VipsLogicUser u WHERE u.organizationId=:organizationId OR u.organizationId IN (SELECT o.organizationId FROM Organization o WHERE o.parentOrganizationId = :organizationId))  ORDER BY p.name ASC"),
+    @NamedQuery(name = "PointOfInterest.findByOrganizationId", query = "SELECT p FROM PointOfInterest p WHERE p.user IN(SELECT u.userId FROM VipsLogicUser u WHERE u.organizationId=:organizationId OR u.organizationId IN (SELECT o.organizationId FROM Organization o WHERE o.parentOrganizationId = :organizationId))  ORDER BY p.name ASC"),
+    @NamedQuery(name = "PointOfInterest.findForecastLocationsByOrganizationId", query = "SELECT p FROM PointOfInterest p WHERE p.isForecastLocation IS TRUE AND p.user IN(SELECT u.userId FROM VipsLogicUser u WHERE u.organizationId=:organizationId OR u.organizationId IN (SELECT o.organizationId FROM Organization o WHERE o.parentOrganizationId = :organizationId))  ORDER BY p.name ASC"),
     @NamedQuery(name = "PointOfInterest.findByName", query = "SELECT p FROM PointOfInterest p WHERE p.name = :name"),
     @NamedQuery(name = "PointOfInterest.findByNameCaseInsensitive", query = "SELECT p FROM PointOfInterest p WHERE lower(p.name) = lower(:name)"),
-    //@NamedQuery(name = "PointOfInterest.findByLongitude", query = "SELECT p FROM PointOfInterest p WHERE p.longitude = :longitude"),
-    //@NamedQuery(name = "PointOfInterest.findByLatitude", query = "SELECT p FROM PointOfInterest p WHERE p.latitude = :latitude"),
-    //@NamedQuery(name = "PointOfInterest.findByAltitude", query = "SELECT p FROM PointOfInterest p WHERE p.altitude = :altitude"),
-    @NamedQuery(name = "PointOfInterest.findByUserId", query = "SELECT p FROM PointOfInterest p WHERE p.userId = :userId ORDER BY p.name ASC")
+    @NamedQuery(name = "PointOfInterest.findByUserId", query = "SELECT p FROM PointOfInterest p WHERE p.user = :userId ORDER BY p.name ASC")
 })
 public class PointOfInterest implements Serializable, Comparable {
     private Set<PointOfInterestExternalResource> pointOfInterestExternalResourceSet;
@@ -92,6 +89,7 @@ public class PointOfInterest implements Serializable, Comparable {
     private WeatherForecastProvider weatherForecastProviderId;
     private Boolean isForecastLocation;
     private Integer pointOfInterestTypeId;
+    private Date lastEditedTime;
    
     
     // For attaching ad-hoc properties
@@ -107,6 +105,26 @@ public class PointOfInterest implements Serializable, Comparable {
     public PointOfInterest(Integer pointOfInterestId) {
         this.pointOfInterestId = pointOfInterestId;
     }
+    
+    public static PointOfInterest getInstance(Integer typeId)
+    {
+    	PointOfInterest instance;
+    	
+    	switch(typeId)
+    	{
+    		case 1: instance = new PointOfInterestWeatherStation();
+    				break;
+    		case 2: instance = new PointOfInterestTypeFarm();
+    				break;
+    		case 3: instance = new PointOfInterestTypeField();
+    				break;
+    		case 4: instance = new PointOfInterestTypeRegion();
+    				break;
+    		default: instance = null;
+    				break;
+    	}
+    	return instance;
+    }
 
     @Id
     @GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -263,16 +281,26 @@ public class PointOfInterest implements Serializable, Comparable {
     @JoinColumn(name = "user_id", referencedColumnName = "user_id")
     @ManyToOne
     @JsonIgnore
-    public VipsLogicUser getUserId() {
+    public VipsLogicUser getUser() {
         return userId;
     }
 
     /**
      * @param userId the userId to set
      */
-    public void setUserId(VipsLogicUser userId) {
+    public void setUser(VipsLogicUser userId) {
         this.userId = userId;
     }
+    
+    /**
+     * For serialization
+     * @return
+     */
+    @Transient
+    public Integer getUserId()
+    {
+    	return this.getUser().getUserId();
+    }
 
     /**
      * @return the weatherForecastProviderId
@@ -356,4 +384,13 @@ public class PointOfInterest implements Serializable, Comparable {
     public void setIsForecastLocation(Boolean isForecastLocation) {
         this.isForecastLocation = isForecastLocation;
     }
+
+    @Column(name = "last_edited_time")
+	public Date getLastEditedTime() {
+		return lastEditedTime;
+	}
+
+	public void setLastEditedTime(Date lastEditedTime) {
+		this.lastEditedTime = lastEditedTime;
+	}
 }
diff --git a/src/main/java/no/nibio/vips/logic/entity/PointOfInterestWeatherStation.java b/src/main/java/no/nibio/vips/logic/entity/PointOfInterestWeatherStation.java
index 81827edc..85804f24 100755
--- a/src/main/java/no/nibio/vips/logic/entity/PointOfInterestWeatherStation.java
+++ b/src/main/java/no/nibio/vips/logic/entity/PointOfInterestWeatherStation.java
@@ -47,9 +47,9 @@ import javax.persistence.Transient;
     @NamedQuery(name = "PointOfInterestWeatherStation.findAll", query = "SELECT p FROM PointOfInterestWeatherStation p"),
     @NamedQuery(name = "PointOfInterestWeatherStation.findAllByActivity", query = "SELECT p FROM PointOfInterestWeatherStation p WHERE p.active = :active"),
     @NamedQuery(name = "PointOfInterestWeatherStation.findByPointOfInterestId", query = "SELECT p FROM PointOfInterestWeatherStation p WHERE p.pointOfInterestId = :pointOfInterestId"),
-    @NamedQuery(name = "PointOfInterestWeatherStation.findByOrganizationId", query = "SELECT p FROM PointOfInterestWeatherStation p WHERE p.userId IN(SELECT u.userId FROM VipsLogicUser u WHERE u.organizationId=:organizationId)"),
-    @NamedQuery(name = "PointOfInterestWeatherStation.findByActivityAndOrganizationId", query = "SELECT p FROM PointOfInterestWeatherStation p WHERE p.active = :active AND p.userId IN(SELECT u.userId FROM VipsLogicUser u WHERE u.organizationId=:organizationId)"),
-    @NamedQuery(name = "PointOfInterestWeatherStation.findByUserId", query = "SELECT p FROM PointOfInterestWeatherStation p WHERE p.userId = :userId"),
+    @NamedQuery(name = "PointOfInterestWeatherStation.findByOrganizationId", query = "SELECT p FROM PointOfInterestWeatherStation p WHERE p.user IN(SELECT u.userId FROM VipsLogicUser u WHERE u.organizationId=:organizationId)"),
+    @NamedQuery(name = "PointOfInterestWeatherStation.findByActivityAndOrganizationId", query = "SELECT p FROM PointOfInterestWeatherStation p WHERE p.active = :active AND p.user IN(SELECT u.userId FROM VipsLogicUser u WHERE u.organizationId=:organizationId)"),
+    @NamedQuery(name = "PointOfInterestWeatherStation.findByUserId", query = "SELECT p FROM PointOfInterestWeatherStation p WHERE p.user = :userId"),
     @NamedQuery(name = "PointOfInterestWeatherStation.findByNames", query = "SELECT p FROM PointOfInterestWeatherStation p WHERE p.name IN :names")
 })
 public class PointOfInterestWeatherStation extends PointOfInterest implements Serializable {
diff --git a/src/main/java/no/nibio/vips/logic/service/AuthenticationService.java b/src/main/java/no/nibio/vips/logic/service/AuthenticationService.java
index abb63297..114ffc24 100644
--- a/src/main/java/no/nibio/vips/logic/service/AuthenticationService.java
+++ b/src/main/java/no/nibio/vips/logic/service/AuthenticationService.java
@@ -79,7 +79,7 @@ public class AuthenticationService {
 		// Get username and password from Json
 		String username = credentials.get("username").asText();
 		String password = credentials.get("password").asText();
-		Map<String,String> creds = new HashMap();
+		Map<String,String> creds = new HashMap<>();
         creds.put("username", username);
         creds.put("password", password);
 		// Authenticate 
@@ -119,7 +119,7 @@ public class AuthenticationService {
 		{
 			// Also, renew the uuid by default length
 			userBean.renewUserUuid(uuid);
-			return Response.ok().entity(user).build();
+			return Response.ok().entity(user).build();			
 		}
 		else
 		{
diff --git a/src/main/java/no/nibio/vips/logic/service/LogicService.java b/src/main/java/no/nibio/vips/logic/service/LogicService.java
index 655cf843..703e2d5f 100755
--- a/src/main/java/no/nibio/vips/logic/service/LogicService.java
+++ b/src/main/java/no/nibio/vips/logic/service/LogicService.java
@@ -71,6 +71,7 @@ import no.nibio.vips.logic.entity.PointOfInterestType;
 import no.nibio.vips.logic.entity.PointOfInterestWeatherStation;
 import no.nibio.vips.logic.entity.VipsLogicUser;
 import no.nibio.vips.logic.util.SystemTime;
+import no.nibio.vips.observationdata.ObservationDataBean;
 import no.nibio.vips.util.CSVPrintUtil;
 import no.nibio.vips.util.ServletUtil;
 import no.nibio.vips.util.SolarRadiationUtil;
@@ -101,6 +102,8 @@ public class LogicService {
     PointOfInterestBean pointOfInterestBean;
     @EJB
     MessageBean messageBean;
+    @EJB
+    ObservationDataBean observationDataBean;
     
     /**
      * Get all results for one pest prediction
@@ -679,6 +682,7 @@ public class LogicService {
         return Response.ok().entity(retVal).build();
     }
     
+
     
     /**
      * Get a list of locations (pois) for a given organization
@@ -809,6 +813,7 @@ public class LogicService {
     /**
      * Get a list of all pests, OR if cropOrganismId is specified,
      * get a list of all pests that are connected with this crop
+     * @param organization Id optional if set, observation data schemas are added
      * @return 
      */
     @GET
@@ -816,7 +821,8 @@ public class LogicService {
     @Produces("application/json;charset=UTF-8")
     @Facet("restricted")
     public Response getPestOrganismList(
-    		@QueryParam("cropOrganismId") Integer cropOrganismId
+    		@QueryParam("cropOrganismId") Integer cropOrganismId,
+    		@QueryParam("organizationId") Integer organizationId
     		)
     {
     	List<Organism> organismList;
@@ -828,6 +834,10 @@ public class LogicService {
     	{
     		organismList = organismBean.getCropPests(cropOrganismId);
     	}
+    	if(organizationId != null)
+    	{
+    		organismList = observationDataBean.decoratePestsWithOrganismDataSchema(organismList, organizationId);
+    	}
         return Response.ok().entity(organismList).build();
     }
     
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 cf695a52..aea53b42 100755
--- a/src/main/java/no/nibio/vips/logic/service/ObservationService.java
+++ b/src/main/java/no/nibio/vips/logic/service/ObservationService.java
@@ -19,17 +19,27 @@
 
 package no.nibio.vips.logic.service;
 
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import com.ibm.icu.util.ULocale;
+
+import java.io.File;
 import java.io.IOException;
 import java.net.URI;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.Files;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.UUID;
 import java.util.stream.Collectors;
 import javax.ejb.EJB;
@@ -57,6 +67,10 @@ import no.nibio.vips.logic.controller.session.UserBean;
 
 import no.nibio.vips.logic.entity.Gis;
 import no.nibio.vips.logic.entity.Observation;
+import no.nibio.vips.logic.entity.ObservationIllustrationPK;
+import no.nibio.vips.logic.entity.ObservationStatusType;
+import no.nibio.vips.logic.entity.ObservationSyncInfo;
+import no.nibio.vips.logic.entity.Organism;
 import no.nibio.vips.logic.entity.PolygonService;
 import no.nibio.vips.logic.entity.VipsLogicRole;
 import no.nibio.vips.logic.entity.VipsLogicUser;
@@ -68,6 +82,8 @@ import no.nibio.vips.logic.util.Globals;
 import org.jboss.resteasy.annotations.GZIP;
 import org.wololo.geojson.Feature;
 
+import org.apache.commons.codec.binary.Base64;
+
 /**
  * @copyright 2016-2021 <a href="http://www.nibio.no/">NIBIO</a>
  * @author Tor-Einar Skog <tor-einar.skog@nibio.no>
@@ -314,21 +330,63 @@ public class ObservationService {
     @GET
     @Path("list/user")
     @Produces("application/json;charset=UTF-8")
-    public Response getObservationsForUser()
+    public Response getObservationsForUser(
+    		@QueryParam("observationIds") String observationIds
+    		)
     {
-    	String uuidStr = httpServletRequest.getHeader(HttpHeaders.AUTHORIZATION);
-		UUID uuid = UUID.fromString(uuidStr);
-		VipsLogicUser user = userBean.findVipsLogicUser(uuid);
+    	try
+	    	{
+			VipsLogicUser user = userBean.getUserFromUUID(httpServletRequest);
+			if(user != null)
+			{
+				List<Observation> allObs = observationBean.getObservationsForUser(user);
+				if(observationIds != null)
+				{
+					Set<Integer> observationIdSet = Arrays.asList(observationIds.split(",")).stream()
+							.map(s->Integer.valueOf(s))
+							.collect(Collectors.toSet());
+					return Response.ok().entity(
+							allObs.stream()
+							.filter(obs->observationIdSet.contains(obs.getObservationId()))
+							.collect(Collectors.toList())
+							)
+							.build();
+				}
+				return Response.ok().entity(allObs).build();
+			}
+			else
+			{
+				return Response.status(Status.UNAUTHORIZED).build();
+			}
+    	}
+    	catch(Exception e)
+    	{
+    		return Response.serverError().entity(e.getMessage()).build();
+    	}
+    }
+    
+    /**
+     * Get observations for a user
+     * Requires a valid UUID to be provided in the Authorization header
+     * @return
+     */
+    @GET
+    @Path("list/minimized/user")
+    @Produces("application/json;charset=UTF-8")
+    public Response getMinimizedObservationsForUser()
+    {
+		VipsLogicUser user = userBean.getUserFromUUID(httpServletRequest);
 		if(user != null)
 		{
-			return Response.ok().entity(observationBean.getObservationsForUser(user)).build();
+			return Response.ok().entity(observationBean.getObservationsForUser(user).stream()
+					.map(obs->new ObservationSyncInfo(obs)).collect(Collectors.toList())).build();
 		}
 		else
 		{
-			return Response.status(Status.NOT_FOUND).build();
+			return Response.status(Status.UNAUTHORIZED).build();
 		}
     }
-    
+   
     /**
      * Publicly available observations per organization
      * @param organizationId
@@ -425,6 +483,16 @@ public class ObservationService {
         return Response.ok().entity(o).build();
     }
     
+    @GET
+    @Path("polygonservices/{organizationId}")
+    @Produces("application/json;charset=UTF-8")
+    public Response getPolygonServicesForOrganization(
+    		@PathParam("organizationId") Integer organizationId
+    		)
+    {
+    	return Response.ok().entity(observationBean.getPolygonServicesForOrganization(organizationId)).build();
+    }
+    
     /**
      * Deletes a gis entity and its corresponding observation
      */
@@ -641,4 +709,116 @@ public class ObservationService {
         return observations;
     }
     
+    @POST
+    @Path("syncobservationfromapp")
+    @Consumes("application/json;charset=UTF-8")
+    @Produces("application/json;charset=UTF-8")
+    public Response syncObservationFromApp(
+    		String observationJson
+    		)
+    {
+    	try
+    	{
+	    	VipsLogicUser user = userBean.getUserFromUUID(httpServletRequest);
+	    	if(user == null)
+	    	{
+	    		return Response.status(Status.UNAUTHORIZED).build();
+	    	}
+	    	ObjectMapper oM = new ObjectMapper();
+	    	SimpleDateFormat df = new SimpleDateFormat(Globals.defaultTimestampFormat);
+	    	try {
+	    		Map<Object,Object> mapFromApp = oM.readValue(observationJson, new TypeReference<HashMap<Object,Object>>(){});
+				// Check if it is marked as deleted or not
+				if(mapFromApp.get("deleted") != null && ((Boolean)mapFromApp.get("deleted").equals(true)))
+				{
+					if(observationBean.getObservation((Integer)mapFromApp.get("observationId")) != null)
+					{
+						observationBean.deleteObservation((Integer)mapFromApp.get("observationId"));
+						return Response.ok().build();
+					}
+					else
+					{
+						return Response.status(Status.NOT_FOUND).build();
+					}
+				}
+				else
+				{
+					Observation mergeObs = ((Integer)mapFromApp.get("observationId")) > 0 ? observationBean.getObservation((Integer)mapFromApp.get("observationId")): new Observation();
+					// Trying to sync a non-existing observation
+					if(mergeObs == null)
+					{
+						return Response.status(Status.NOT_FOUND).build();
+					}
+					// Pest organism
+					mergeObs.setOrganism(organismBean.getOrganism((Integer)mapFromApp.get("organismId")));
+					// Crop organism
+					mergeObs.setCropOrganism(organismBean.getOrganism((Integer)mapFromApp.get("cropOrganismId")));
+					// Other properties
+					mergeObs.setTimeOfObservation(oM.convertValue(mapFromApp.get("timeOfObservation"), new TypeReference<Date>(){}));
+					mergeObs.setUserId(mapFromApp.get("userId") != null ? Integer.valueOf((Integer)mapFromApp.get("userId")): user.getUserId());
+					mergeObs.setGeoinfo((String)mapFromApp.get("geoinfo"));
+					mergeObs.setLocationPointOfInterestId(mapFromApp.get("locationPointOfInterestId") != null ? (Integer) mapFromApp.get("locationPointOfInterestId") : null);
+					mergeObs.setObservationHeading(mapFromApp.get("observationHeading") != null ? (String) mapFromApp.get("observationHeading") : null);
+					mergeObs.setObservationText(mapFromApp.get("observationText") != null ? (String) mapFromApp.get("observationText") : null);
+					mergeObs.setBroadcastMessage(mapFromApp.get("broadcastMessage") != null ? (Boolean) mapFromApp.get("broadcastMessage") : false);
+					mergeObs.setStatusTypeId(Integer.valueOf((Integer)mapFromApp.get("statusTypeId")));
+					// If the user has the role of observation approver, change to approved if set to pending
+					if(mergeObs.getStatusTypeId().equals(ObservationStatusType.STATUS_PENDING) && user.isObservationAuthority())
+					{
+						mergeObs.setStatusTypeId(ObservationStatusType.STATUS_APPROVED);
+					}
+					mergeObs.setStatusChangedByUserId(mapFromApp.get("statusChangedByUserId") != null ? (Integer) mapFromApp.get("statusChangedByUserId") : null);
+					mergeObs.setStatusChangedTime(mapFromApp.get("timeOfObservation") != null ? oM.convertValue(mapFromApp.get("timeOfObservation"), new TypeReference<Date>(){}) : null);
+					mergeObs.setStatusRemarks(mapFromApp.get("statusRemarks") != null ? (String) mapFromApp.get("statusRemarks") : null);
+					mergeObs.setIsQuantified(mapFromApp.get("isQuantified") != null ? (Boolean) mapFromApp.get("isQuantified") : false);
+					mergeObs.setLocationIsPrivate(mapFromApp.get("locationIsPrivate") != null ? (Boolean) mapFromApp.get("locationIsPrivate") : false);
+					mergeObs.setPolygonService(mapFromApp.get("polygonService") != null && ! ((String)mapFromApp.get("polygonService")).isEmpty() ? oM.convertValue(mapFromApp.get("polygonService"), new TypeReference<PolygonService>(){}) : null);
+					mergeObs.setObservationDataSchema(observationBean.getObservationDataSchema(user.getOrganization_id(), mergeObs.getOrganismId()));
+					mergeObs.setObservationData(mapFromApp.get("observationData") != null ? mapFromApp.get("observationData").toString() : null);
+					mergeObs.setLastEditedBy(user.getUserId());
+					mergeObs.setLastEditedTime(new Date());
+					
+					// Input check before storing
+					// Location must be set
+					if((mergeObs.getGeoinfo() == null || mergeObs.getGeoinfo().trim().isEmpty()) && mergeObs.getLocationPointOfInterestId() == null)
+					{
+						return Response.status(Status.BAD_REQUEST).entity("The observation is missing location data.").build();
+					}
+					
+					// We need to get an observation Id before storing the illustrations!
+					mergeObs = observationBean.storeObservation(mergeObs);
+					
+					// ObservationIllustrationSet
+					// Including data that may need to be stored
+					if(mapFromApp.get("observationIllustrationSet") != null)
+					{
+						List<Map<Object,Object>> illusMaps = (List<Map<Object,Object>>) mapFromApp.get("observationIllustrationSet");
+						for(Map<Object,Object> illusMap:illusMaps)
+						{
+							ObservationIllustrationPK pk = oM.convertValue(illusMap.get("observationIllustrationPK"), new TypeReference<ObservationIllustrationPK>(){});
+							
+							if(illusMap.get("deleted") != null && ((Boolean) illusMap.get("deleted")) == true)
+							{
+								mergeObs = observationBean.deleteObservationIllustration(mergeObs, new String[] {pk.getFileName()});
+							}
+							else if(illusMap.get("uploaded") != null && ((Boolean) illusMap.get("uploaded")) == false && illusMap.get("imageTextData") != null)
+							{
+								mergeObs = observationBean.storeObservationIllustration(mergeObs, pk.getFileName(), (String)illusMap.get("imageTextData"));
+							}
+						}
+					}
+						
+					
+					return Response.ok().entity(mergeObs).build();
+				}
+			} catch (IOException e) {
+				return Response.serverError().entity(e).build();
+			}
+    	}
+    	catch(Exception e)
+    	{
+    		return Response.serverError().entity(e).build();
+    	}
+    	
+    }
 }
diff --git a/src/main/java/no/nibio/vips/logic/service/POIService.java b/src/main/java/no/nibio/vips/logic/service/POIService.java
new file mode 100644
index 00000000..7333c185
--- /dev/null
+++ b/src/main/java/no/nibio/vips/logic/service/POIService.java
@@ -0,0 +1,223 @@
+/*
+ * Copyright (c) 2021 NIBIO <http://www.nibio.no/>. 
+ * 
+ * This file is part of VIPSLogic.
+ * VIPSLogic is free software: you can redistribute it and/or modify
+ * it under the terms of the NIBIO Open Source License as published by 
+ * NIBIO, either version 1 of the License, or (at your option) any
+ * later version.
+ * 
+ * VIPSLogic 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
+ * NIBIO Open Source License for more details.
+ * 
+ * You should have received a copy of the NIBIO Open Source License
+ * along with VIPSLogic.  If not, see <http://www.nibio.no/licenses/>.
+ * 
+ */
+package no.nibio.vips.logic.service;
+
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+
+import org.jboss.resteasy.spi.HttpRequest;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.Point;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.webcohesion.enunciate.metadata.Facet;
+
+import no.nibio.vips.gis.GISUtil;
+import no.nibio.vips.logic.entity.Country;
+import no.nibio.vips.logic.entity.Observation;
+import no.nibio.vips.logic.entity.ObservationIllustrationPK;
+import no.nibio.vips.logic.entity.Organization;
+import no.nibio.vips.logic.entity.PointOfInterest;
+import no.nibio.vips.logic.entity.PointOfInterestWeatherStation;
+import no.nibio.vips.logic.entity.PolygonService;
+import no.nibio.vips.logic.entity.VipsLogicUser;
+import no.nibio.vips.logic.util.Globals;
+import no.nibio.vips.logic.controller.session.SessionControllerGetter;
+
+/**
+ * @copyright 2021 <a href="http://www.nibio.no/">NIBIO</a>
+ * @author Tor-Einar Skog <tor-einar.skog@nibio.no>
+ */
+@Path("rest/poi")
+public class POIService {
+	
+	@Context
+    private HttpRequest httpRequest;
+    @Context
+    private HttpServletRequest httpServletRequest;
+    
+	
+    /**
+     * Get a list of locations (pois) for a given organization
+     * @param organizationId
+     * @return 
+     */
+    @GET
+    @Path("organization/{organizationId}")
+    @Produces("application/json;charset=UTF-8")
+    public Response getPoisForOrganization(@PathParam("organizationId") Integer organizationId)
+    {
+        Organization organization = SessionControllerGetter.getUserBean().getOrganization(organizationId);
+        List<PointOfInterestWeatherStation> retVal = SessionControllerGetter.getPointOfInterestBean().getWeatherstationsForOrganization(organization, Boolean.TRUE);
+        return Response.ok().entity(retVal).build();
+    }
+    
+    /**
+     * 
+     * @param pointOfInterestId
+     * @return a particular POI (Point of interest)
+     */
+    @GET
+    @Path("{pointOfInterestId}")
+    @Produces("application/json;charset=UTF-8")
+    public Response getPoi(@PathParam("pointOfInterestId") Integer pointOfInterestId)
+    {
+        PointOfInterest retVal = SessionControllerGetter.getPointOfInterestBean().getPointOfInterest(pointOfInterestId);
+        return Response.ok().entity(retVal).build();
+    }
+    
+    /**
+     * Find a POI (Point of interest) by name
+     * @param poiName
+     * @return 
+     */
+    @GET
+    @Path("name/{poiName}")
+    @Produces("application/json;charset=UTF-8")
+    public Response getPoiByName(@PathParam("poiName") String poiName)
+    {
+        PointOfInterest retVal = SessionControllerGetter.getPointOfInterestBean().getPointOfInterest(poiName);
+        return retVal != null ? Response.ok().entity(retVal).build() : Response.noContent().build();
+    }
+    
+    /**
+     * If used outside of VIPSLogic: Requires a valid UUID to be provided in the Authorization header
+     * @return 
+     */
+    @GET
+    @Path("user")
+    @Produces("application/json;charset=UTF-8")
+    @Facet("restricted")
+    public Response getPoisForCurrentUser()
+    {
+        VipsLogicUser user = (VipsLogicUser) httpServletRequest.getSession().getAttribute("user");
+        // Could be the VIPS obs app or some other client using UUID
+        if(user == null)
+        {
+        	String uuidStr = httpServletRequest.getHeader(HttpHeaders.AUTHORIZATION);
+    		UUID uuid = UUID.fromString(uuidStr);
+    		user = SessionControllerGetter.getUserBean().findVipsLogicUser(uuid);
+    		if(user == null)
+    		{
+    			return Response.status(Status.UNAUTHORIZED).build();
+    		}
+        }
+        List<PointOfInterest> retVal = SessionControllerGetter.getPointOfInterestBean().getRelevantPointOfInterestsForUser(user);
+        return Response.ok().entity(retVal).build();
+    }
+    
+    @POST
+    @Path("syncpoifromapp")
+    @Consumes("application/json;charset=UTF-8")
+    @Produces("application/json;charset=UTF-8")
+    public Response syncPOIFromApp(
+    		String poiJson
+    		)
+    {
+    	VipsLogicUser user = SessionControllerGetter.getUserBean().getUserFromUUID(httpServletRequest);
+    	if(user == null)
+    	{
+    		return Response.status(Status.UNAUTHORIZED).build();
+    	}
+    	ObjectMapper oM = new ObjectMapper();
+    	SimpleDateFormat df = new SimpleDateFormat(Globals.defaultTimestampFormat);
+    	try {
+    		Map<Object,Object> mapFromApp = oM.readValue(poiJson, new TypeReference<HashMap<Object,Object>>(){});
+			// Check if it is marked as deleted or not
+			if(mapFromApp.get("deleted") != null && ((Boolean)mapFromApp.get("deleted").equals(true)))
+			{
+				if(SessionControllerGetter.getPointOfInterestBean().getPointOfInterest((Integer)mapFromApp.get("pointOfInterestId")) != null)
+				{
+					SessionControllerGetter.getPointOfInterestBean().deletePoi((Integer)mapFromApp.get("pointOfInterestId"));
+					return Response.ok().build();
+				}
+				else
+				{
+					return Response.status(Status.NOT_FOUND).build();
+				}
+			}
+			else
+			{
+				PointOfInterest mergePoi = ((Integer)mapFromApp.get("pointOfInterestId")) > 0 ? SessionControllerGetter.getPointOfInterestBean().getPointOfInterest((Integer)mapFromApp.get("pointOfInterestId")): PointOfInterest.getInstance((Integer)mapFromApp.get("pointOfInterestTypeId"));
+				// Trying to sync a non-existing observation
+				if(mergePoi == null)
+				{
+					return Response.status(Status.NOT_FOUND).build();
+				}
+				
+				mergePoi.setName((String)mapFromApp.get("name"));
+				mergePoi.setTimeZone(mapFromApp.get("timeZone") != null ? 
+						(String) mapFromApp.get("timeZone") 
+						: user.getOrganizationId().getDefaultTimeZone()
+						);
+				mergePoi.setLongitude((Double) mapFromApp.get("longitude"));
+				mergePoi.setLatitude((Double) mapFromApp.get("latitude"));
+				try {
+					Double altitude = mapFromApp.get("altitude") instanceof Integer ? 
+							((Integer) mapFromApp.get("altitude")).doubleValue()
+							:(Double) mapFromApp.get("altitude");
+					mergePoi.setAltitude(altitude);
+				}
+				catch(NullPointerException | ClassCastException ex)
+				{
+					mergePoi.setAltitude(0.0);
+				}
+				mergePoi.setCountryCode(
+						mapFromApp.get("countryCode") != null ? 
+						new Country((String)(((Map<Object,Object>)mapFromApp.get("countryCode")).get("countryCode")))
+						: user.getOrganizationId().getCountryCode()
+						);
+				mergePoi.setUser(user);
+				mergePoi.setLastEditedTime(new Date());
+				GISUtil gisUtil = new GISUtil();
+				Coordinate coordinate = new Coordinate(mergePoi.getLongitude(), mergePoi.getLatitude(), mergePoi.getAltitude());
+				Point p3d = gisUtil.createPointWGS84(coordinate);
+				mergePoi.setGisGeom(p3d);
+				
+				mergePoi = SessionControllerGetter.getPointOfInterestBean().getPointOfInterest(SessionControllerGetter.getPointOfInterestBean().storePoi(mergePoi).getPointOfInterestId());
+				
+				
+				return Response.ok().entity(mergePoi).build();
+			}
+		} catch (IOException e) {
+			return Response.serverError().entity(e).build();
+		}
+    	
+    }
+
+}
diff --git a/src/main/java/no/nibio/vips/observationdata/ObservationDataBean.java b/src/main/java/no/nibio/vips/observationdata/ObservationDataBean.java
new file mode 100644
index 00000000..1bbfff03
--- /dev/null
+++ b/src/main/java/no/nibio/vips/observationdata/ObservationDataBean.java
@@ -0,0 +1,114 @@
+package no.nibio.vips.observationdata;
+
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.ResourceBundle;
+import java.util.Map.Entry;
+
+import javax.ejb.Stateless;
+import javax.persistence.EntityManager;
+import javax.persistence.NoResultException;
+import javax.persistence.PersistenceContext;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import no.nibio.vips.logic.entity.Organism;
+
+@Stateless
+public class ObservationDataBean {
+	@PersistenceContext(unitName="VIPSLogic-PU")
+    EntityManager em;
+	
+	public JsonNode getSchema(Integer organizationId, Integer organismId, ResourceBundle bundle){
+    	ObservationDataSchema ods = null;
+    	ObjectMapper m = new ObjectMapper();
+        try
+        {
+        	//System.out.println("organizationId = " + organizationId + ", organismId = " + organismId);
+            ods = em.createNamedQuery("ObservationDataSchema.findByPK", ObservationDataSchema.class)
+                .setParameter("organizationId", organizationId)
+                .setParameter("organismId", organismId)
+                .getSingleResult();
+            
+            
+            
+            // We iterate the schema, replacing default field labels with
+            // translated ones
+            // First: Convert to Jackson JsonNode tree
+            JsonNode rootNode = m.readTree(ods.getDataSchema());
+            if(bundle != null)
+            {
+	            Iterator<Entry<String, JsonNode>> nodeIterator = rootNode.fields();
+	            
+	            String fieldKeyPrefix = "observationDataField_";
+	            // Loop through each field
+	            while (nodeIterator.hasNext()) {
+	                Map.Entry<String, JsonNode> schemaPropertyField = (Map.Entry<String, JsonNode>) nodeIterator.next();
+	                // Get the property field key (e.g. "counting2")
+	                String fieldKey = schemaPropertyField.getKey();
+	                // Find a translation. 
+	                if(bundle.containsKey(fieldKeyPrefix + fieldKey))
+	                {
+	                    // If found, replace with translation
+	                    // Get the property field (e.g. {"title":"Counting 2"} )
+	                    JsonNode schemaProperty = schemaPropertyField.getValue();
+	                    ((ObjectNode)schemaProperty).put("title", bundle.getString(fieldKeyPrefix + fieldKey));
+	                    ((ObjectNode)rootNode).replace(fieldKey, schemaProperty);
+	                }
+	            }
+            }
+            return rootNode;
+            
+        }catch(IOException | NoResultException ex){}
+        
+        // If not found, return standard nominator/denominator (unit) form 
+        try {
+			return m.readTree(getStandardSchema());
+		} catch (IOException e) {
+			return m.createObjectNode();
+		}
+    }
+    
+	
+	public String getStandardSchema(){
+        return "{\n"
+        		+ "  \"$schema\": \"http://json-schema.org/draft-04/schema#\",\n"
+        		+ "  \"type\": \"object\",\n"
+        		+ "  \"title\": \" \",\n"
+        		+ "  \"properties\": {"
+                + "\"number\":{\"title\":\"Number\", \"type\":\"string\"},"
+                + "\"unit\":{\"title\":\"Unit\", \"type\":\"string\"}"
+                + "}}";
+    }
+    
+    public String getStandardModel(){
+        return "{"
+                + "\"number\":0,"
+                + "\"unit\":\"Number\""
+                + "}";
+    }
+    
+    public List<Organism> decoratePestsWithOrganismDataSchema(List<Organism> pests, Integer organizationId )
+    {
+    	try
+    	{
+	    	
+	    	ObjectMapper om = new ObjectMapper();
+	    	for(Organism pest:pests)
+	    	{
+	    		pest.setObservationDataSchema(om.writeValueAsString(this.getSchema(organizationId, pest.getOrganismId(), null)));
+	    	}
+	    	return pests;
+    	}
+    	catch(JsonProcessingException ex)
+    	{
+    		ex.printStackTrace();
+    		return pests;
+    	}
+    }
+}
diff --git a/src/main/java/no/nibio/vips/observationdata/ObservationDataService.java b/src/main/java/no/nibio/vips/observationdata/ObservationDataService.java
index d02ba09d..d4475d13 100755
--- a/src/main/java/no/nibio/vips/observationdata/ObservationDataService.java
+++ b/src/main/java/no/nibio/vips/observationdata/ObservationDataService.java
@@ -50,6 +50,8 @@ public class ObservationDataService {
     UserBean userBean;
     @EJB
     ObservationBean observationBean;
+    @EJB
+    ObservationDataBean observationDataBean;
     
     @Context
     private HttpServletRequest httpServletRequest;
@@ -80,9 +82,9 @@ public class ObservationDataService {
         {
             return Response.serverError().entity(ex).build();
         }
-
     }
     
+    
     @GET
     @Path("model/{organizationId}/{organismId}")
     @Produces("application/json;charset=UTF-8")
@@ -98,20 +100,8 @@ public class ObservationDataService {
                 .getSingleResult();
         }catch(NoResultException nre){}
         // If not found, return standard nominator/denominator (unit) form 
-        return Response.ok().entity(ods != null ? ods.getDataModel() : this.getStandardModel()).build();
+        return Response.ok().entity(ods != null ? ods.getDataModel() : observationDataBean.getStandardModel()).build();
     }
     
-    private String getStandardSchema(){
-        return "{"
-                + "\"number\":{\"title\":\"Number\"},"
-                + "\"unit\":{\"title\":\"Unit\"}"
-                + "}";
-    }
     
-    private String getStandardModel(){
-        return "{"
-                + "\"number\":0,"
-                + "\"unit\":\"Number\""
-                + "}";
-    }
 }
diff --git a/src/main/java/no/nibio/vips/util/weather/WeatherDataSourceUtil.java b/src/main/java/no/nibio/vips/util/weather/WeatherDataSourceUtil.java
index ad85e6ae..f0746db3 100755
--- a/src/main/java/no/nibio/vips/util/weather/WeatherDataSourceUtil.java
+++ b/src/main/java/no/nibio/vips/util/weather/WeatherDataSourceUtil.java
@@ -50,7 +50,7 @@ import org.apache.commons.io.IOUtils;
  */
 public class WeatherDataSourceUtil {
     
-    private final boolean DEBUG = false;
+    private final boolean DEBUG = true;
 
     /**
      * Fetches measured data from the stations weather data source, and optionally
diff --git a/src/main/resources/db/migration/V10__POI_add_last_edited_date.sql b/src/main/resources/db/migration/V10__POI_add_last_edited_date.sql
new file mode 100644
index 00000000..30408a32
--- /dev/null
+++ b/src/main/resources/db/migration/V10__POI_add_last_edited_date.sql
@@ -0,0 +1,7 @@
+ALTER TABLE public.point_of_interest
+ADD COLUMN last_edited_time TIMESTAMP WITH TIME ZONE DEFAULT now();
+
+-- One time update
+UPDATE public.point_of_interest
+SET last_edited_time = '2021-01-01';
+
diff --git a/src/main/resources/db/migration/V9__Observation_add_last_edited_date.sql b/src/main/resources/db/migration/V9__Observation_add_last_edited_date.sql
new file mode 100644
index 00000000..243e8bcf
--- /dev/null
+++ b/src/main/resources/db/migration/V9__Observation_add_last_edited_date.sql
@@ -0,0 +1,6 @@
+ALTER TABLE public.observation
+ADD COLUMN last_edited_time TIMESTAMP WITH TIME ZONE DEFAULT now();
+
+-- One-time update of existing records
+UPDATE public.observation
+SET last_edited_time = status_changed_time;
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 d68d35f5..8ecc2a34 100755
--- a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties
+++ b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts.properties
@@ -1,12 +1,21 @@
 #Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/)
 #
-
-\#\ Copyright\ (c)\ 2014\ NIBIO\ <http = //www.nibio.no/>. 
-
-\#\ VIPSLogic\ is\ free\ software = you can redistribute it and/or modify
-
-\#\ along\ with\ VIPSLogic.\ \ If\ not,\ see\ <http = //www.nibio.no/licenses/>.
-
+ # Copyright (c) 2014 NIBIO <http://www.nibio.no/>. 
+ # 
+ # This file is part of VIPSLogic.
+ # VIPSLogic is free software: you can redistribute it and/or modify
+ # it under the terms of the NIBIO Open Source License as published by 
+ # NIBIO, either version 1 of the License, or (at your option) any
+ # later version.
+ # 
+ # VIPSLogic 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
+ # NIBIO Open Source License for more details.
+ # 
+ # You should have received a copy of the NIBIO Open Source License
+ # along with VIPSLogic.  If not, see <http://www.nibio.no/licenses/>.
+ # 
 ALTERNARIA = Alternaria Model
 
 APPLESCABM = Apple scab model
@@ -1028,3 +1037,4 @@ dd_lower=Day degree lower cutoffs
 dd_upper=Day degree upper cutoffs
 observedPhase=Observed phase at biofix date
 YSTEMBTEMP=Yellow Stemborer Temperature Model
+addIllustration=Add illustration
diff --git a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_bs.properties b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_bs.properties
index 0c111166..1afc139b 100755
--- a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_bs.properties
+++ b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_bs.properties
@@ -1,12 +1,20 @@
-#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/)
 #
-
-\#\ Copyright\ (c)\ 2014\ NIBIO\ <http = //www.nibio.no/>. 
-
-\#\ VIPSLogic\ is\ free\ software = you can redistribute it and/or modify
-
-\#\ along\ with\ VIPSLogic.\ \ If\ not,\ see\ <http = //www.nibio.no/licenses/>.
-
+ # Copyright (c) 2014 NIBIO <http://www.nibio.no/>. 
+ # 
+ # This file is part of VIPSLogic.
+ # VIPSLogic is free software: you can redistribute it and/or modify
+ # it under the terms of the NIBIO Open Source License as published by 
+ # NIBIO, either version 1 of the License, or (at your option) any
+ # later version.
+ # 
+ # VIPSLogic 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
+ # NIBIO Open Source License for more details.
+ # 
+ # You should have received a copy of the NIBIO Open Source License
+ # along with VIPSLogic.  If not, see <http://www.nibio.no/licenses/>.
+ # 
 ALTERNARIA = Alternaria Model
 
 APPLESCABM = Apple scab model
@@ -1022,3 +1030,4 @@ dd_lower=Day degree lower cutoffs
 dd_upper=Day degree upper cutoffs
 observedPhase=Observed phase at biofix date
 YSTEMBTEMP=Yellow Stemborer Temperature Model
+addIllustration=Add illustration
diff --git a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_hr.properties b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_hr.properties
index 27f573bf..1bb1784d 100755
--- a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_hr.properties
+++ b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_hr.properties
@@ -1,11 +1,20 @@
-#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/)
 #
-
-\#\ Copyright\ (c)\ 2014\ NIBIO\ <http = //www.nibio.no/>. 
-
-\#\ VIPSLogic\ is\ free\ software = you can redistribute it and/or modify
-
-\#\ along\ with\ VIPSLogic.\ \ If\ not,\ see\ <http = //www.nibio.no/licenses/>.
+ # Copyright (c) 2014 NIBIO <http://www.nibio.no/>. 
+ # 
+ # This file is part of VIPSLogic.
+ # VIPSLogic is free software: you can redistribute it and/or modify
+ # it under the terms of the NIBIO Open Source License as published by 
+ # NIBIO, either version 1 of the License, or (at your option) any
+ # later version.
+ # 
+ # VIPSLogic 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
+ # NIBIO Open Source License for more details.
+ # 
+ # You should have received a copy of the NIBIO Open Source License
+ # along with VIPSLogic.  If not, see <http://www.nibio.no/licenses/>.
+ # 
 
 ALTERNARIA = Alternaria Model
 
@@ -1020,3 +1029,4 @@ dd_lower=Day degree lower cutoffs
 dd_upper=Day degree upper cutoffs
 observedPhase=Observed phase at biofix date
 YSTEMBTEMP=Yellow Stemborer Temperature Model
+addIllustration=Add illustration
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 4544e4c7..20a2a531 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
@@ -1,11 +1,21 @@
-#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/)
 #
+ # Copyright (c) 2014 NIBIO <http://www.nibio.no/>. 
+ # 
+ # This file is part of VIPSLogic.
+ # VIPSLogic is free software: you can redistribute it and/or modify
+ # it under the terms of the NIBIO Open Source License as published by 
+ # NIBIO, either version 1 of the License, or (at your option) any
+ # later version.
+ # 
+ # VIPSLogic 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
+ # NIBIO Open Source License for more details.
+ # 
+ # You should have received a copy of the NIBIO Open Source License
+ # along with VIPSLogic.  If not, see <http://www.nibio.no/licenses/>.
+ # 
 
-\#\ Copyright\ (c)\ 2014\ NIBIO\ <http = //www.nibio.no/>. 
-
-\#\ VIPSLogic\ is\ free\ software = you can redistribute it and/or modify
-
-\#\ along\ with\ VIPSLogic.\ \ If\ not,\ see\ <http = //www.nibio.no/licenses/>.
 
 ALTERNARIA = Alternariamodell
 
@@ -1028,3 +1038,4 @@ dd_lower=Minimumstemperaturer d\u00f8gngradberegning
 dd_upper=Maksimumstemperaturer d\u00f8gngradberegning
 observedPhase=Observert utviklingsstadium ved biofix-dato
 YSTEMBTEMP=Yellow Stemborer temperaturmodell
+addIllustration=Legg til illustrasjon
diff --git a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_sr.properties b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_sr.properties
index 591da1f9..fee3ba47 100755
--- a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_sr.properties
+++ b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_sr.properties
@@ -1,11 +1,20 @@
-#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/)
 #
-
-\#\ Copyright\ (c)\ 2014\ NIBIO\ <http = //www.nibio.no/>. 
-
-\#\ VIPSLogic\ is\ free\ software = you can redistribute it and/or modify
-
-\#\ along\ with\ VIPSLogic.\ \ If\ not,\ see\ <http = //www.nibio.no/licenses/>.
+ # Copyright (c) 2014 NIBIO <http://www.nibio.no/>. 
+ # 
+ # This file is part of VIPSLogic.
+ # VIPSLogic is free software: you can redistribute it and/or modify
+ # it under the terms of the NIBIO Open Source License as published by 
+ # NIBIO, either version 1 of the License, or (at your option) any
+ # later version.
+ # 
+ # VIPSLogic 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
+ # NIBIO Open Source License for more details.
+ # 
+ # You should have received a copy of the NIBIO Open Source License
+ # along with VIPSLogic.  If not, see <http://www.nibio.no/licenses/>.
+ # 
 
 ALTERNARIA = Alternaria Model
 
@@ -1022,3 +1031,4 @@ dd_lower=Day degree lower cutoffs
 dd_upper=Day degree upper cutoffs
 observedPhase=Observed phase at biofix date
 YSTEMBTEMP=Yellow Stemborer Temperature Model
+addIllustration=Add illustration
diff --git a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_zh_CN.properties b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_zh_CN.properties
index 599a97ca..778dbb65 100755
--- a/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_zh_CN.properties
+++ b/src/main/resources/no/nibio/vips/logic/i18n/vipslogictexts_zh_CN.properties
@@ -1,12 +1,20 @@
-#Generated by ResourceBundle Editor (http://essiembre.github.io/eclipse-rbe/)
 #
-
-\#\ Copyright\ (c)\ 2014\ NIBIO\ <http = //www.nibio.no/>. 
-
-\#\ VIPSLogic\ is\ free\ software = you can redistribute it and/or modify
-
-\#\ along\ with\ VIPSLogic.\ \ If\ not,\ see\ <http = //www.nibio.no/licenses/>.
-
+ # Copyright (c) 2014 NIBIO <http://www.nibio.no/>. 
+ # 
+ # This file is part of VIPSLogic.
+ # VIPSLogic is free software: you can redistribute it and/or modify
+ # it under the terms of the NIBIO Open Source License as published by 
+ # NIBIO, either version 1 of the License, or (at your option) any
+ # later version.
+ # 
+ # VIPSLogic 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
+ # NIBIO Open Source License for more details.
+ # 
+ # You should have received a copy of the NIBIO Open Source License
+ # along with VIPSLogic.  If not, see <http://www.nibio.no/licenses/>.
+ # 
 ALTERNARIA = Alternaria Model
 
 APPLESCABM = \u82f9\u679c\u9ed1\u661f\u75c5\u6a21\u578b
@@ -1016,3 +1024,4 @@ dd_lower=Day degree lower cutoffs
 dd_upper=Day degree upper cutoffs
 observedPhase=Observed phase at biofix date
 YSTEMBTEMP=Yellow Stemborer Temperature Model
+addIllustration=Add illustration
diff --git a/src/main/webapp/templates/observationForm.ftl b/src/main/webapp/templates/observationForm.ftl
index ba748445..e195bd66 100755
--- a/src/main/webapp/templates/observationForm.ftl
+++ b/src/main/webapp/templates/observationForm.ftl
@@ -689,19 +689,20 @@
                               <textarea class="form-control" name="observationText" placeholder=""  <#if editAccess!="W">readonly="readonly"</#if>>${observation.observationText!""}</textarea>
                               <span class="help-block" id="${formId}_observationText_validation"></span>
                             </div>
-                            <#if observation.observationIllustrationSet?has_content && observation.observationIllustrationSet?size == 1>
-                                  <#assign illustration = observation.observationIllustrationSet?first>
+                            <#if observation.observationIllustrationSet?has_content && observation.observationIllustrationSet?size gt 0>
+                                  <#list observation.observationIllustrationSet as illustration>
                                   <img src="/static/images/observations/${observation.organismId}/${illustration.observationIllustrationPK.fileName}" alt="TODO: Add describing text" class="img-responsive"/>
                                   <div class="checkbox">
                                     <label>
-                                      <input type="checkbox" name="deleteIllustration" value="true"/>
+                                      <input type="checkbox" name="deleteIllustration" value="${illustration.observationIllustrationPK.fileName}"/>
                                       ${i18nBundle.deleteIllustration}
                                     </label>
                                   </div>
+                                  </#list>
                           </#if>
                             <div class="form-group">
                                   <div class="input-group">
-                                          <label for="illustration"><#if observation.observationIllustrationSet?has_content && observation.observationIllustrationSet?size == 1>${i18nBundle.replaceIllustration}<#else>${i18nBundle.newIllustration}</#if></label><br/>
+                                          <label for="illustration"><#if observation.observationIllustrationSet?has_content && observation.observationIllustrationSet?size gt 0>${i18nBundle.addIllustration}<#else>${i18nBundle.newIllustration}</#if></label><br/>
                                           <span class="btn btn-default btn-file">${i18nBundle.browse}<input type="file" name="illustration"></span>
                                           <input type="text" class="form-control" readonly>
                                   </div>
diff --git a/src/main/webapp/templates/poiForm.ftl b/src/main/webapp/templates/poiForm.ftl
index ca9d861c..74555332 100755
--- a/src/main/webapp/templates/poiForm.ftl
+++ b/src/main/webapp/templates/poiForm.ftl
@@ -172,7 +172,7 @@
 			    <select class="form-control" name="userId" onblur="validateField(this);">
 			    	<option value="-1">${i18nBundle.pleaseSelect} ${i18nBundle.vipsLogicUserId?lower_case}</option>
 				<#list users as user>
-					<option value="${user.userId}"<#if poi.userId?has_content && user.userId == poi.userId.userId
+					<option value="${user.userId}"<#if poi.user?has_content && user.userId == poi.user.userId
 								> selected="selected"</#if>>${user.lastName}, ${user.firstName} (${user.organizationId.organizationName})</option>
 				</#list>
 			     </select>
diff --git a/src/main/webapp/templates/poiList.ftl b/src/main/webapp/templates/poiList.ftl
index 6e8e0b3a..12bd313f 100755
--- a/src/main/webapp/templates/poiList.ftl
+++ b/src/main/webapp/templates/poiList.ftl
@@ -88,7 +88,7 @@
 						${i18nBundle["pointOfInterestType_" + poi.pointOfInterestTypeId]}
 					</td>
 					<td>
-                                            <#if user.isSuperUser() || user.isOrganizationAdmin() || poi.userId.userId == user.userId>
+                                            <#if user.isSuperUser() || user.isOrganizationAdmin() || poi.user.userId == user.userId>
 						<a href="/poi?action=editPoiForm&pointOfInterestId=${poi.pointOfInterestId}" class="btn btn-default" role="button">${i18nBundle.edit}</a>
                                             </#if>
 					</td>
-- 
GitLab