Repository: akvo/akvo-rsr

Issue:
Bug: IATI export has invalid modification date
### What were you doing?

- Modify a project
- Export the IATI XML
- Upload to https://iativalidator.iatistandard.org/

Got validation error due to modification date being in the future

![image](https://user-images.githubusercontent.com/91939214/166419042-ed515307-afe5-4c0b-bd33-9337487a28d0.png)

> 11.1.1 [(see guidance)](https://iatistandard.org/en/iati-standard/203/activity-standard/iati-activities/iati-activity/)The last updated datetime of the activity must not be in the future.

### What should've happened?

The IATI validator should 

### My environment

_No response_

### Additional context

After investigation, the `last-updated-datetime` comes from https://github.com/akvo/akvo-rsr/blob/master/akvo/iati/exports/iati_export.py#L95 which is a manual format of the `datetime` instance. The datetime stored in the DB is not aware of timezones and thus uses the time on the server.

The [`last-updated-datetime` attribute](https://iatistandard.org/en/iati-standard/203/activity-standard/iati-activities/iati-activity/) is an [`xsd:dateTime`](https://www.w3schools.blog/xsd-date-and-time-data-types) which can have a timezone component. However, because the datetime instance is timezone naive and we use a manually formatted string, we export the wrong time.


Discussion:

Resolving patch:
diff --git a/akvo/iati/exports/iati_export.py b/akvo/iati/exports/iati_export.py
--- a/akvo/iati/exports/iati_export.py
+++ b/akvo/iati/exports/iati_export.py
@@ -14,6 +14,8 @@
 
 from django.core.files.storage import default_storage, FileSystemStorage
 
+from .utils import make_datetime_aware
+
 ELEMENTS = [
     'iati_identifier',
     'reporting_org',
@@ -91,9 +93,9 @@ def add_project(self, project):
         """
         project_element = etree.SubElement(self.iati_activities, "iati-activity")
 
-        if project.last_modified_at:
-            project_element.attrib['last-updated-datetime'] = project.last_modified_at.\
-                strftime("%Y-%m-%dT%H:%M:%SZ")
+        if last_modified_at := project.last_modified_at:
+            last_modified_dt = make_datetime_aware(last_modified_at)
+            project_element.attrib['last-updated-datetime'] = last_modified_dt.isoformat("T", "seconds")
 
         if project.language:
             project_element.attrib['{http://www.w3.org/XML/1998/namespace}lang'] = project.language
@@ -132,7 +134,7 @@ def __init__(self, projects, version='2.03', iati_export=None, excluded_elements
                                              nsmap={'akvo': 'http://akvo.org/iati-activities'})
         self.iati_activities.attrib['version'] = self.version
         self.iati_activities.attrib['generated-datetime'] = datetime.utcnow().\
-            strftime("%Y-%m-%dT%H:%M:%SZ")
+            isoformat("T", "seconds")
 
         for project in projects:
             # Add IATI activity export to indicate that export has started
diff --git a/akvo/iati/exports/iati_org_export.py b/akvo/iati/exports/iati_org_export.py
--- a/akvo/iati/exports/iati_org_export.py
+++ b/akvo/iati/exports/iati_org_export.py
@@ -10,6 +10,8 @@
 from datetime import datetime
 from lxml import etree
 
+from .utils import make_datetime_aware
+
 ORG_ELEMENTS = [
     'organisation_identifier',
     'name',
@@ -45,9 +47,9 @@ def add_organisation(self, organisation):
         """
         organisation_element = etree.SubElement(self.iati_organisations, "iati-organisation")
 
-        if organisation.last_modified_at:
-            organisation_element.attrib['last-updated-datetime'] = organisation.last_modified_at.\
-                strftime("%Y-%m-%dT%H:%M:%SZ")
+        if last_modified_at := organisation.last_modified_at:
+            last_modified_dt = make_datetime_aware(last_modified_at)
+            organisation_element.attrib['last-updated-datetime'] = last_modified_dt.isoformat("T", "seconds")
 
         if organisation.language:
             organisation_element.attrib['{http://www.w3.org/XML/1998/namespace}lang'] = \
@@ -78,7 +80,7 @@ def __init__(self, request, organisations, version='2.03', excluded_elements=Non
         self.iati_organisations = etree.Element("iati-organisations")
         self.iati_organisations.attrib['version'] = self.version
         self.iati_organisations.attrib['generated-datetime'] = datetime.utcnow().\
-            strftime("%Y-%m-%dT%H:%M:%SZ")
+            isoformat("T", "seconds")
 
         for organisation in organisations:
             self.add_organisation(organisation)
diff --git a/akvo/iati/exports/utils.py b/akvo/iati/exports/utils.py
new file mode 100644
--- /dev/null
+++ b/akvo/iati/exports/utils.py
@@ -0,0 +1,10 @@
+from datetime import datetime
+
+from django.utils import timezone
+
+
+def make_datetime_aware(dt: datetime):
+    last_modified_dt = dt
+    if timezone.is_naive(dt):
+        last_modified_dt = timezone.make_aware(dt, timezone.get_current_timezone())
+    return last_modified_dt


Test patch:
diff --git a/akvo/rsr/tests/iati_export/__init__.py b/akvo/rsr/tests/iati_export/__init__.py
--- a/akvo/rsr/tests/iati_export/__init__.py
+++ b/akvo/rsr/tests/iati_export/__init__.py
@@ -0,0 +1,43 @@
+from datetime import datetime
+from unittest.util import safe_repr
+
+from lxml import etree
+from xmlunittest import XmlTestMixin
+
+
+class AkvoXmlMixin(XmlTestMixin):
+    def assertXmlAttributeIsXsdDateTime(self, node: etree.Element, attribute: str, msg: str = None) -> datetime:
+        """
+        Checks if the given node with the provided attribute has a datetime str value in xsd:dateTime
+
+        https://www.w3schools.blog/xsd-date-and-time-data-types
+
+        :return: The datetime value of the attribute
+        """
+        self.assertXmlHasAttribute(node, attribute)
+        attr_val = node.attrib[attribute]
+        try:
+            return datetime.fromisoformat(attr_val)
+        except ValueError:
+            standard_msg = "%s is not a valid ISO formatted datetime" % safe_repr(attr_val)
+            self.fail(self._formatMessage(msg, standard_msg))
+
+    def assertDatetimeEqual(self, left_dt: datetime, right_dt: datetime, microseconds=False):
+        """
+        Ensures that two datetimes are equal by converting them to timestamps
+
+        In this manner, timezones don't matter
+        :param left_dt:
+        :param right_dt:
+        :param microseconds: Include microseconds in the comparison
+        :return:
+        """
+        self.assertIsNotNone(left_dt)
+        self.assertIsNotNone(right_dt)
+
+        left_ts = left_dt.timestamp()
+        right_ts = right_dt.timestamp()
+        if microseconds:
+            self.assertAlmostEqual(left_ts, right_ts)
+        else:
+            self.assertEqual(int(left_ts), int(right_ts))
diff --git a/akvo/rsr/tests/iati_export/test_iati_export.py b/akvo/rsr/tests/iati_export/test_iati_export.py
--- a/akvo/rsr/tests/iati_export/test_iati_export.py
+++ b/akvo/rsr/tests/iati_export/test_iati_export.py
@@ -11,7 +11,6 @@
 import os
 import shutil
 from lxml import etree
-from xmlunittest import XmlTestMixin
 
 from django.core.files.uploadedfile import SimpleUploadedFile
 
@@ -31,9 +30,10 @@
                              IndicatorDimensionValue, Disaggregation)
 from akvo.rsr.models.result.utils import QUALITATIVE
 from akvo.rsr.tests.base import BaseTestCase
+from akvo.rsr.tests.iati_export import AkvoXmlMixin
 
 
-class IatiExportTestCase(BaseTestCase, XmlTestMixin):
+class IatiExportTestCase(BaseTestCase, AkvoXmlMixin):
     """Tests the IATI export, and validates the XML file which is outputted."""
 
     def setUp(self):
@@ -560,6 +560,13 @@ def test_complete_project_export(self):
                                            './iati-activity/reporting-org',
                                            './iati-activity/title'))
 
+        # Ensure iati-activity date is according to iso format
+        dt = self.assertXmlAttributeIsXsdDateTime(
+            root_test.xpath("./iati-activity")[0],
+            "last-updated-datetime"
+        )
+        self.assertDatetimeEqual(self.project.last_modified_at, dt)
+
         # Test related activities are listed only once
         related_activity_id = self.related_project.iati_activity_id
         attributes = {'ref': related_activity_id, 'type': RelatedProject.PROJECT_RELATION_PARENT}
diff --git a/akvo/rsr/tests/iati_export/test_iati_org_export.py b/akvo/rsr/tests/iati_export/test_iati_org_export.py
--- a/akvo/rsr/tests/iati_export/test_iati_org_export.py
+++ b/akvo/rsr/tests/iati_export/test_iati_org_export.py
@@ -24,10 +24,11 @@
 import os
 import shutil
 from lxml import etree
-from xmlunittest import XmlTestMixin
 
+from akvo.rsr.tests.iati_export import AkvoXmlMixin
 
-class IatiOrgExportTestCase(TestCase, XmlTestMixin):
+
+class IatiOrgExportTestCase(TestCase, AkvoXmlMixin):
     """Tests the IATI export, and validates the XML file which is outputted."""
 
     def setUp(self):
@@ -212,6 +213,12 @@ def test_complete_org_export(self):
         root_test = self.assertXmlDocument(iati_org_xml)
         self.assertXmlNode(root_test, tag="iati-organisations")
 
+        dt = self.assertXmlAttributeIsXsdDateTime(
+            root_test.xpath("./iati-organisation")[0],
+            "last-updated-datetime"
+        )
+        self.assertDatetimeEqual(organisation.last_modified_at, dt)
+
     def test_empty_org_export(self):
         """
         Test the export of an empty organisation (as far as possible).