SEBA-270 Apply xosbase timestamp code to User object

Change-Id: Iaa2015e691457d3038b807cc00039cb3677fb338
diff --git a/VERSION b/VERSION
index 8dbb0f2..a39c0b7 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.1.10
+2.1.11
diff --git a/containers/chameleon/Dockerfile.chameleon b/containers/chameleon/Dockerfile.chameleon
index 482f319..103e773 100644
--- a/containers/chameleon/Dockerfile.chameleon
+++ b/containers/chameleon/Dockerfile.chameleon
@@ -13,7 +13,7 @@
 # limitations under the License.
 
 # xosproject/chameleon
-FROM xosproject/xos-base:2.1.9
+FROM xosproject/xos-base:2.1.11
 
 # xos-base already has protoc and dependencies installed
 
diff --git a/containers/xos/Dockerfile.client b/containers/xos/Dockerfile.client
index 299cb90..bee6909 100644
--- a/containers/xos/Dockerfile.client
+++ b/containers/xos/Dockerfile.client
@@ -13,7 +13,7 @@
 # limitations under the License.
 
 # xosproject/xos-client
-FROM xosproject/xos-libraries:2.1.10
+FROM xosproject/xos-libraries:2.1.11
 
 # Install XOS client
 COPY xos/xos_client /tmp/xos_client
diff --git a/containers/xos/Dockerfile.libraries b/containers/xos/Dockerfile.libraries
index 1a150d9..07efd91 100644
--- a/containers/xos/Dockerfile.libraries
+++ b/containers/xos/Dockerfile.libraries
@@ -12,7 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-FROM xosproject/xos-base:2.1.10
+FROM xosproject/xos-base:2.1.11
 
 # Add libraries
 COPY lib /opt/xos/lib
diff --git a/containers/xos/Dockerfile.synchronizer-base b/containers/xos/Dockerfile.synchronizer-base
index 14913a5..aa5f6fb 100644
--- a/containers/xos/Dockerfile.synchronizer-base
+++ b/containers/xos/Dockerfile.synchronizer-base
@@ -13,7 +13,7 @@
 # limitations under the License.
 
 # xosproject/xos-synchronizer-base
-FROM xosproject/xos-client:2.1.10
+FROM xosproject/xos-client:2.1.11
 
 COPY xos/synchronizers/new_base /opt/xos/synchronizers/new_base
 COPY xos/xos/logger.py /opt/xos/xos/logger.py
diff --git a/containers/xos/Dockerfile.xos-core b/containers/xos/Dockerfile.xos-core
index 0d28235..a00c1c9 100644
--- a/containers/xos/Dockerfile.xos-core
+++ b/containers/xos/Dockerfile.xos-core
@@ -13,7 +13,7 @@
 # limitations under the License.
 
 # xosproject/xos-core
-FROM xosproject/xos-libraries:2.1.10
+FROM xosproject/xos-libraries:2.1.11
 
 # Install XOS
 ADD xos /opt/xos
diff --git a/xos/core/models/user.py b/xos/core/models/user.py
index 63510c7..7447b49 100644
--- a/xos/core/models/user.py
+++ b/xos/core/models/user.py
@@ -28,6 +28,7 @@
 from django.core.exceptions import PermissionDenied
 from django.core.mail import EmailMultiAlternatives
 from django.db import models
+from django.utils.timezone import now
 from django.db import transaction
 from django.db import router
 from django.db.models import F, Q
@@ -144,10 +145,13 @@
     login_page = StrippedCharField(
         help_text="send this user to a specific page on login", max_length=200, null=True, blank=True)
 
-    created = models.DateTimeField(auto_now_add=True)
-    updated = models.DateTimeField(auto_now=True)
-    enacted = models.DateTimeField(null=True, blank = True, default=None)
-    policed = models.DateTimeField(null=True, blank = True, default=None)
+    created = models.DateTimeField(help_text="Time this model was created", auto_now_add=True, null=False, blank=False)
+    updated = models.DateTimeField(help_text="Time this model was changed by a non-synchronizer", default=now, null=False,
+                            blank=False)
+    enacted = models.DateTimeField(default=None, help_text="When synced, set to the timestamp of the data that was synced",
+                            null=True, blank=True)
+    policed = models.DateTimeField(default=None, help_text="When policed, set to the timestamp of the data that was policed",
+                            null=True, blank=True)
     backend_status = StrippedCharField(max_length=1024,
                                        default="Provisioning in progress")
     backend_code = models.IntegerField( default = 0, null = False )
@@ -259,6 +263,20 @@
                             model.policed=None
                             model.save(update_fields=['enacted','deleted','policed'], silent=silent)
 
+    def has_important_changes(self):
+        """ Determine whether the model has changes that should be reflected in one of the changed_by_* timestamps.
+            Ignores various feedback and bookkeeping state set by synchronizers.
+        """
+        for field_name in self.changed_fields:
+            if field_name in ["policed", "updated", "enacted", "changed_by_step", "changed_by_policy"]:
+                continue
+            if field_name.startswith("backend_"):
+                continue
+            if field_name.startswith("policy_"):
+                continue
+            return True
+        return False
+
     def save(self, *args, **kwargs):
         if not self.leaf_model_name:
             self.leaf_model_name = "User"
@@ -276,33 +294,53 @@
 
         caller_kind = "unknown"
 
-        if ('synchronizer' in threading.current_thread().name):
-            caller_kind = "synchronizer"
-
         if "caller_kind" in kwargs:
             caller_kind = kwargs.pop("caller_kind")
 
+        update_fields = None
+        if "update_fields" in kwargs:
+            # NOTE(smbaker): modifying update_fields will cause kwargs["update_fields"] to be modified. This is
+            # intended, as kwargs will be passed to save() below.
+            update_fields = kwargs["update_fields"]
+
+        # NOTE(smbaker): always_update_timestamp still has some relevance for event_steps and pull_steps that
+        # want to cause an update. For model_policies or sync_steps it should no longer be required.
         always_update_timestamp = False
         if "always_update_timestamp" in kwargs:
             always_update_timestamp = always_update_timestamp or kwargs.pop("always_update_timestamp")
 
-        # SMBAKER: if an object is trying to delete itself, or if the observer
-        # is updating an object's backend_* fields, then let it slip past the
-        # composite key check.
-        ignore_composite_key_check=False
-        if "update_fields" in kwargs:
-            ignore_composite_key_check=True
-            for field in kwargs["update_fields"]:
-                if not (field in ["backend_register", "backend_status", "deleted", "enacted", "updated"]):
-                    ignore_composite_key_check=False
+        is_sync_save = False
+        if "is_sync_save" in kwargs:
+            is_sync_save = kwargs.pop("is_sync_save")
+
+        is_policy_save = False
+        if "is_policy_save" in kwargs:
+            is_policy_save = kwargs.pop("is_policy_save")
 
         if (caller_kind!="synchronizer") or always_update_timestamp:
             self.updated = timezone.now()
+        else:
+            # We're not auto-setting timestamp, but let's check to make sure that the caller hasn't tried to set our
+            # timestamp backward...
+            if (self.updated != self._initial["updated"]) and ((not update_fields) or ("updated" in update_fields)):
+                log.info("Synchronizer tried to change `updated` timestamp on model %s from %s to %s. Ignored." % (self, self._initial["updated"], self.updated))
+                self.updated = self._initial["updated"]
+
+        if is_sync_save and self.has_important_changes():
+            self.changed_by_step = timezone.now()
+            if update_fields:
+                update_fields.append("changed_by_step")
+
+        if is_policy_save and self.has_important_changes():
+            self.changed_by_policy = timezone.now()
+            if update_fields:
+                update_fields.append("changed_by_policy")
 
         if not self.username:
             self.username = self.email
 
-        self.full_clean()
+        if not self.deleted:
+            self.full_clean()
 
         super(User, self).save(*args, **kwargs)
 
diff --git a/xos/core/models/xosbase.py b/xos/core/models/xosbase.py
index 8cd1d4f..5a327ac 100644
--- a/xos/core/models/xosbase.py
+++ b/xos/core/models/xosbase.py
@@ -114,8 +114,8 @@
                 raise Exception("Attempt to save object with deleted foreign key reference")
 
     def has_important_changes(self):
-        """ Determine whether the model has changes that should be reflected in one of the changed_by_* timestampes.
-            Ignores varous feedback and bookeeeping state set by synchronizers.
+        """ Determine whether the model has changes that should be reflected in one of the changed_by_* timestamps.
+            Ignores various feedback and bookkeeping state set by synchronizers.
         """
         for field_name in self.changed_fields:
             if field_name in ["policed", "updated", "enacted", "changed_by_step", "changed_by_policy"]:
@@ -141,7 +141,7 @@
 
         update_fields = None
         if "update_fields" in kwargs:
-            # NOTE(smbaker): modifying update_fields will cause kwargs["update_fiels"] to be modified. This is
+            # NOTE(smbaker): modifying update_fields will cause kwargs["update_fields"] to be modified. This is
             # intended, as kwargs will be passed to save() below.
             update_fields = kwargs["update_fields"]