Avoid timezone confusion

Message ID 20180112132437.8805-1-vkabatov@redhat.com
State New
Headers show
Series
  • Avoid timezone confusion
Related show

Commit Message

Veronika Kabatova Jan. 12, 2018, 1:24 p.m.
From: Veronika Kabatova <vkabatov@redhat.com>

Patchwork saves patches, comments etc with UTC timezone and reports
this time when opening the patch details. However, internally generated
processes such as events are reported with the instance's local time.
There's nothing wrong with that and making PW timezone-aware would add
useless complexity, but in a world-wide collaboration a lot of confusion
may arise as the timezone is not reported at all. Instance's local time
might be very different from the local time of CI integrating with PW,
which is different from the local time of person dealing with it etc.

For submission views, just add 'UTC' strings. For API responses, change
the serializers to still use ISO 8601 format but with timezone
information suffix [Z+-HHMM] - UTC for submissions and PW's local
timezone for internal events.

Signed-off-by: Veronika Kabatova <vkabatov@redhat.com>
---
 docs/api/rest.rst                                         | 12 ++++++++++--
 docs/deployment/installation.rst                          |  3 ++-
 patchwork/api/check.py                                    |  2 ++
 patchwork/api/cover.py                                    |  5 +++++
 patchwork/api/embedded.py                                 | 15 +++++++++++++++
 patchwork/api/event.py                                    |  2 ++
 patchwork/api/patch.py                                    |  5 +++++
 patchwork/api/series.py                                   |  6 ++++++
 patchwork/templates/patchwork/submission.html             |  4 ++--
 patchwork/views/xmlrpc.py                                 |  7 ++++---
 releasenotes/notes/report-timezones-47e2095efcb37a84.yaml |  4 ++++
 requirements-prod.txt                                     |  1 +
 requirements-test.txt                                     |  1 +
 13 files changed, 59 insertions(+), 8 deletions(-)
 create mode 100644 releasenotes/notes/report-timezones-47e2095efcb37a84.yaml

Comments

Daniel Axtens Jan. 15, 2018, 12:52 a.m. | #1
vkabatov@redhat.com writes:

> From: Veronika Kabatova <vkabatov@redhat.com>
>
> Patchwork saves patches, comments etc with UTC timezone and reports
> this time when opening the patch details. However, internally generated
> processes such as events are reported with the instance's local time.
> There's nothing wrong with that and making PW timezone-aware would add
> useless complexity, but in a world-wide collaboration a lot of confusion
> may arise as the timezone is not reported at all. Instance's local time
> might be very different from the local time of CI integrating with PW,
> which is different from the local time of person dealing with it etc.
>
> For submission views, just add 'UTC' strings. For API responses, change
> the serializers to still use ISO 8601 format but with timezone
> information suffix [Z+-HHMM] - UTC for submissions and PW's local
> timezone for internal events.

Thanks for your patch! It looks very comprehensive - thanks especially
for including documentation!!

Would you prefer if we made internal events UTC? I think that might be
preferable, but I haven't thought about it in great detail just yet.

>  
>  Responses are returned as JSON. Blank fields are returned as ``null``, rather
> -than being omitted. Timestamps use the ISO 8601 format::
> +than being omitted. Returned timestamps use the ISO 8601 format including the
> +timezone information::
>  
> -    YYYY-MM-DDTHH:MM:SSZ
> +    YYYY-MM-DDTHH:MM:SS[Z+-HH:MM]
> +
> +.. note::
> +
> +    Timezone suffix is only provided in responses to avoid users' confusion and
> +    to allow proper coordination with CI tooling. Underlying data are
> +    timezone-naive and can't be filtered based on timezones, therefore requests
> +    made to the API should not use the timezone suffix.
>

In particular, I wonder if using UTC everywhere would make things like
this simpler?

As I said I haven't put an enormous deal of thought into it, and I
haven't done a lot of work with TZ in python before so I don't know if
it would be crazy to implement. I just wanted your thoughts on it.

Regards,
Daniel

>  Requests should use either query parameters or form-data, depending on the
>  method. Further information is provided `below <rest_parameters>`__.
> diff --git a/docs/deployment/installation.rst b/docs/deployment/installation.rst
> index a570dd8..38e12d1 100644
> --- a/docs/deployment/installation.rst
> +++ b/docs/deployment/installation.rst
> @@ -173,7 +173,8 @@ this also:
>  .. code-block:: shell
>  
>     $ sudo apt-get install -y python3-django python3-psycopg2 \
> -       python3-djangorestframework python3-django-filters
> +       python3-djangorestframework python3-django-filters \
> +       python3-tz
>  
>  .. tip::
>  
> diff --git a/patchwork/api/check.py b/patchwork/api/check.py
> index b37d6e0..4854a82 100644
> --- a/patchwork/api/check.py
> +++ b/patchwork/api/check.py
> @@ -17,6 +17,7 @@
>  # along with Patchwork; if not, write to the Free Software
>  # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
>  
> +from django.utils import timezone
>  from rest_framework.exceptions import PermissionDenied
>  from rest_framework.generics import ListCreateAPIView
>  from rest_framework.generics import RetrieveAPIView
> @@ -63,6 +64,7 @@ class CheckSerializer(HyperlinkedModelSerializer):
>          return super(CheckSerializer, self).run_validation(data)
>  
>      def to_representation(self, instance):
> +        instance.date = timezone.make_aware(instance.date)
>          data = super(CheckSerializer, self).to_representation(instance)
>          data['state'] = instance.get_state_display()
>          return data
> diff --git a/patchwork/api/cover.py b/patchwork/api/cover.py
> index 1064504..2de4ea2 100644
> --- a/patchwork/api/cover.py
> +++ b/patchwork/api/cover.py
> @@ -19,6 +19,7 @@
>  
>  import email.parser
>  
> +from pytz import UTC
>  from rest_framework.generics import ListAPIView
>  from rest_framework.generics import RetrieveAPIView
>  from rest_framework.serializers import HyperlinkedModelSerializer
> @@ -37,6 +38,10 @@ class CoverLetterListSerializer(HyperlinkedModelSerializer):
>      submitter = PersonSerializer(read_only=True)
>      mbox = SerializerMethodField()
>      series = SeriesSerializer(many=True, read_only=True)
> +    date = SerializerMethodField()
> +
> +    def get_date(self, instance):
> +        return instance.date.replace(tzinfo=UTC)
>  
>      def get_mbox(self, instance):
>          request = self.context.get('request')
> diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py
> index 7b5090a..bc915fe 100644
> --- a/patchwork/api/embedded.py
> +++ b/patchwork/api/embedded.py
> @@ -23,6 +23,8 @@ A collection of serializers. None of the serializers here should reference
>  nested fields.
>  """
>  
> +from django.utils import timezone
> +from pytz import UTC
>  from rest_framework.serializers import CharField
>  from rest_framework.serializers import HyperlinkedModelSerializer
>  from rest_framework.serializers import SerializerMethodField
> @@ -61,6 +63,7 @@ class CheckSerializer(HyperlinkedModelSerializer):
>      url = CheckHyperlinkedIdentityField('api-check-detail')
>  
>      def to_representation(self, instance):
> +        instance.date = timezone.make_aware(instance.date)
>          data = super(CheckSerializer, self).to_representation(instance)
>          data['state'] = instance.get_state_display()
>          return data
> @@ -77,6 +80,10 @@ class CheckSerializer(HyperlinkedModelSerializer):
>  
>  class CoverLetterSerializer(MboxMixin, HyperlinkedModelSerializer):
>  
> +    def to_representation(self, instance):
> +        instance.date = instance.date.replace(tzinfo=UTC)
> +        return super(CoverLetterSerializer, self).to_representation(instance)
> +
>      class Meta:
>          model = models.CoverLetter
>          fields = ('id', 'url', 'msgid', 'date', 'name', 'mbox')
> @@ -88,6 +95,10 @@ class CoverLetterSerializer(MboxMixin, HyperlinkedModelSerializer):
>  
>  class PatchSerializer(MboxMixin, HyperlinkedModelSerializer):
>  
> +    def to_representation(self, instance):
> +        instance.date = instance.date.replace(tzinfo=UTC)
> +        return super(PatchSerializer, self).to_representation(instance)
> +
>      class Meta:
>          model = models.Patch
>          fields = ('id', 'url', 'msgid', 'date', 'name', 'mbox')
> @@ -126,6 +137,10 @@ class ProjectSerializer(HyperlinkedModelSerializer):
>  
>  class SeriesSerializer(MboxMixin, HyperlinkedModelSerializer):
>  
> +    def to_representation(self, instance):
> +        instance.date = instance.date.replace(tzinfo=UTC)
> +        return super(SeriesSerializer, self).to_representation(instance)
> +
>      class Meta:
>          model = models.Series
>          fields = ('id', 'url', 'date', 'name', 'version', 'mbox')
> diff --git a/patchwork/api/event.py b/patchwork/api/event.py
> index 0d97af2..22c747e 100644
> --- a/patchwork/api/event.py
> +++ b/patchwork/api/event.py
> @@ -19,6 +19,7 @@
>  
>  from collections import OrderedDict
>  
> +from django.utils import timezone
>  from rest_framework.generics import ListAPIView
>  from rest_framework.serializers import ModelSerializer
>  from rest_framework.serializers import SerializerMethodField
> @@ -61,6 +62,7 @@ class EventSerializer(ModelSerializer):
>      }
>  
>      def to_representation(self, instance):
> +        instance.date = timezone.make_aware(instance.date)
>          data = super(EventSerializer, self).to_representation(instance)
>          payload = OrderedDict()
>          kept_fields = self._category_map[instance.category] + [
> diff --git a/patchwork/api/patch.py b/patchwork/api/patch.py
> index 115feff..57aa97d 100644
> --- a/patchwork/api/patch.py
> +++ b/patchwork/api/patch.py
> @@ -20,6 +20,7 @@
>  import email.parser
>  
>  from django.utils.translation import ugettext_lazy as _
> +from pytz import UTC
>  from rest_framework.generics import ListAPIView
>  from rest_framework.generics import RetrieveUpdateAPIView
>  from rest_framework.relations import RelatedField
> @@ -86,6 +87,10 @@ class PatchListSerializer(HyperlinkedModelSerializer):
>      check = SerializerMethodField()
>      checks = SerializerMethodField()
>      tags = SerializerMethodField()
> +    date = SerializerMethodField()
> +
> +    def get_date(self, instance):
> +        return instance.date.replace(tzinfo=UTC)
>  
>      def get_mbox(self, instance):
>          request = self.context.get('request')
> diff --git a/patchwork/api/series.py b/patchwork/api/series.py
> index b5f4450..562bf45 100644
> --- a/patchwork/api/series.py
> +++ b/patchwork/api/series.py
> @@ -17,6 +17,8 @@
>  # along with Patchwork; if not, write to the Free Software
>  # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
>  
> +
> +from pytz import UTC
>  from rest_framework.generics import ListAPIView
>  from rest_framework.generics import RetrieveAPIView
>  from rest_framework.serializers import HyperlinkedModelSerializer
> @@ -38,6 +40,10 @@ class SeriesSerializer(HyperlinkedModelSerializer):
>      mbox = SerializerMethodField()
>      cover_letter = CoverLetterSerializer(read_only=True)
>      patches = PatchSerializer(read_only=True, many=True)
> +    date = SerializerMethodField()
> +
> +    def get_date(self, instance):
> +        return instance.date.replace(tzinfo=UTC)
>  
>      def get_mbox(self, instance):
>          request = self.context.get('request')
> diff --git a/patchwork/templates/patchwork/submission.html b/patchwork/templates/patchwork/submission.html
> index 6ed20c3..e817713 100644
> --- a/patchwork/templates/patchwork/submission.html
> +++ b/patchwork/templates/patchwork/submission.html
> @@ -255,7 +255,7 @@ function toggle_div(link_id, headers_id)
>  <div class="comment">
>  <div class="meta">
>   <span>{{ submission.submitter|personify:project }}</span>
> - <span class="pull-right">{{ submission.date }}</span>
> + <span class="pull-right">{{ submission.date }} UTC</span>
>  </div>
>  <pre class="content">
>  {{ submission|commentsyntax }}
> @@ -271,7 +271,7 @@ function toggle_div(link_id, headers_id)
>  <div class="comment">
>  <div class="meta">
>   <span>{{ item.submitter|personify:project }}</span>
> - <span class="pull-right">{{ item.date }} | <a href="{% url 'comment-redirect' comment_id=item.id %}"
> + <span class="pull-right">{{ item.date }} UTC | <a href="{% url 'comment-redirect' comment_id=item.id %}"
>     >#{{ forloop.counter }}</a></span>
>  </div>
>  <pre class="content">
> diff --git a/patchwork/views/xmlrpc.py b/patchwork/views/xmlrpc.py
> index 93f6035..d59779d 100644
> --- a/patchwork/views/xmlrpc.py
> +++ b/patchwork/views/xmlrpc.py
> @@ -38,6 +38,7 @@ from django.views.decorators.csrf import csrf_exempt
>  from django.utils import six
>  from django.utils.six.moves import xmlrpc_client
>  from django.utils.six.moves.xmlrpc_server import SimpleXMLRPCDispatcher
> +from pytz import UTC
>  
>  from patchwork.compat import reverse
>  from patchwork.models import Check
> @@ -248,7 +249,7 @@ def patch_to_dict(obj):
>  
>      {
>          'id': 1
> -        'date': '2000-12-31 00:11:22',
> +        'date': '2000-12-31 00:11:22+00:00',
>          'filename': 'Fix-all-the-bugs.patch',
>          'msgid': '<BLU438-SMTP36690BBDD2CE71A7138B082511A@phx.gbl>',
>          'name': "Fix all the bugs",
> @@ -273,7 +274,7 @@ def patch_to_dict(obj):
>      """
>      return {
>          'id': obj.id,
> -        'date': six.text_type(obj.date).encode('utf-8'),
> +        'date': six.text_type(obj.date.replace(tzinfo=UTC)).encode('utf-8'),
>          'filename': obj.filename,
>          'msgid': obj.msgid,
>          'name': obj.name,
> @@ -319,7 +320,7 @@ def check_to_dict(obj):
>      object which is OK to send to the client."""
>      return {
>          'id': obj.id,
> -        'date': six.text_type(obj.date).encode('utf-8'),
> +        'date': six.text_type(obj.date.replace(tzinfo=UTC)).encode('utf-8'),
>          'patch': six.text_type(obj.patch).encode('utf-8'),
>          'patch_id': obj.patch_id,
>          'user': six.text_type(obj.user).encode('utf-8'),
> diff --git a/releasenotes/notes/report-timezones-47e2095efcb37a84.yaml b/releasenotes/notes/report-timezones-47e2095efcb37a84.yaml
> new file mode 100644
> index 0000000..ad1dae4
> --- /dev/null
> +++ b/releasenotes/notes/report-timezones-47e2095efcb37a84.yaml
> @@ -0,0 +1,4 @@
> +---
> +other:
> +  - |
> +    Report timezones in web UI and APIs.
> diff --git a/requirements-prod.txt b/requirements-prod.txt
> index dd2040d..1e5207e 100644
> --- a/requirements-prod.txt
> +++ b/requirements-prod.txt
> @@ -3,3 +3,4 @@ djangorestframework>=3.4,<3.7
>  django-filter>=1.0,<1.1
>  psycopg2>=2.7,<2.8
>  sqlparse==0.2.3
> +pytz>=2016.10
> diff --git a/requirements-test.txt b/requirements-test.txt
> index 141cf66..632eb4c 100644
> --- a/requirements-test.txt
> +++ b/requirements-test.txt
> @@ -3,3 +3,4 @@ psycopg2>=2.7,<2.8
>  django-debug-toolbar==1.8
>  python-dateutil>2.0,<3.0
>  selenium>=3.0,<4.0
> +pytz>=2016.10
> -- 
> 2.13.6
>
> _______________________________________________
> Patchwork mailing list
> Patchwork@lists.ozlabs.org
> https://lists.ozlabs.org/listinfo/patchwork
Veronika Kabatova Jan. 15, 2018, 2:26 p.m. | #2
Hi!

----- Original Message -----
> From: "Daniel Axtens" <dja@axtens.net>
> To: vkabatov@redhat.com, patchwork@lists.ozlabs.org
> Sent: Monday, January 15, 2018 1:52:33 AM
> Subject: Re: [PATCH] Avoid timezone confusion
> 
> vkabatov@redhat.com writes:
> 
> > From: Veronika Kabatova <vkabatov@redhat.com>
> >
> > Patchwork saves patches, comments etc with UTC timezone and reports
> > this time when opening the patch details. However, internally generated
> > processes such as events are reported with the instance's local time.
> > There's nothing wrong with that and making PW timezone-aware would add
> > useless complexity, but in a world-wide collaboration a lot of confusion
> > may arise as the timezone is not reported at all. Instance's local time
> > might be very different from the local time of CI integrating with PW,
> > which is different from the local time of person dealing with it etc.
> >
> > For submission views, just add 'UTC' strings. For API responses, change
> > the serializers to still use ISO 8601 format but with timezone
> > information suffix [Z+-HHMM] - UTC for submissions and PW's local
> > timezone for internal events.
> 
> Thanks for your patch! It looks very comprehensive - thanks especially
> for including documentation!!
> 
> Would you prefer if we made internal events UTC? I think that might be
> preferable, but I haven't thought about it in great detail just yet.
> 

That would work too. Then we could only document "hey, all times are
in UTC" and things would be definitely more consistent and easier to
work with.

> >  
> >  Responses are returned as JSON. Blank fields are returned as ``null``,
> >  rather
> > -than being omitted. Timestamps use the ISO 8601 format::
> > +than being omitted. Returned timestamps use the ISO 8601 format including
> > the
> > +timezone information::
> >  
> > -    YYYY-MM-DDTHH:MM:SSZ
> > +    YYYY-MM-DDTHH:MM:SS[Z+-HH:MM]
> > +
> > +.. note::
> > +
> > +    Timezone suffix is only provided in responses to avoid users'
> > confusion and
> > +    to allow proper coordination with CI tooling. Underlying data are
> > +    timezone-naive and can't be filtered based on timezones, therefore
> > requests
> > +    made to the API should not use the timezone suffix.
> >
> 
> In particular, I wonder if using UTC everywhere would make things like
> this simpler?
> 
> As I said I haven't put an enormous deal of thought into it, and I
> haven't done a lot of work with TZ in python before so I don't know if
> it would be crazy to implement. I just wanted your thoughts on it.
> 

Hm, let me think about it... We can set the default TIME_ZONE setting to
UTC and either

a) document that if people override it they will get inconsistent times
   and it's their problem to deal with, or

b) accept if people want to override it and use that timezone when parsing
   email submissions instead of the UTC, but then we would still need to
   report used timezone somehow because noone except instance admin would
   know what the timezone actually is (which is exactly what we want to
   avoid)


option a) is IMHO cleaner and really easy to implement too. I can send an
updated patch if you agree with it.

Thanks,
Veronika

> Regards,
> Daniel
> 
> >  Requests should use either query parameters or form-data, depending on the
> >  method. Further information is provided `below <rest_parameters>`__.
> > diff --git a/docs/deployment/installation.rst
> > b/docs/deployment/installation.rst
> > index a570dd8..38e12d1 100644
> > --- a/docs/deployment/installation.rst
> > +++ b/docs/deployment/installation.rst
> > @@ -173,7 +173,8 @@ this also:
> >  .. code-block:: shell
> >  
> >     $ sudo apt-get install -y python3-django python3-psycopg2 \
> > -       python3-djangorestframework python3-django-filters
> > +       python3-djangorestframework python3-django-filters \
> > +       python3-tz
> >  
> >  .. tip::
> >  
> > diff --git a/patchwork/api/check.py b/patchwork/api/check.py
> > index b37d6e0..4854a82 100644
> > --- a/patchwork/api/check.py
> > +++ b/patchwork/api/check.py
> > @@ -17,6 +17,7 @@
> >  # along with Patchwork; if not, write to the Free Software
> >  # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307
> >  USA
> >  
> > +from django.utils import timezone
> >  from rest_framework.exceptions import PermissionDenied
> >  from rest_framework.generics import ListCreateAPIView
> >  from rest_framework.generics import RetrieveAPIView
> > @@ -63,6 +64,7 @@ class CheckSerializer(HyperlinkedModelSerializer):
> >          return super(CheckSerializer, self).run_validation(data)
> >  
> >      def to_representation(self, instance):
> > +        instance.date = timezone.make_aware(instance.date)
> >          data = super(CheckSerializer, self).to_representation(instance)
> >          data['state'] = instance.get_state_display()
> >          return data
> > diff --git a/patchwork/api/cover.py b/patchwork/api/cover.py
> > index 1064504..2de4ea2 100644
> > --- a/patchwork/api/cover.py
> > +++ b/patchwork/api/cover.py
> > @@ -19,6 +19,7 @@
> >  
> >  import email.parser
> >  
> > +from pytz import UTC
> >  from rest_framework.generics import ListAPIView
> >  from rest_framework.generics import RetrieveAPIView
> >  from rest_framework.serializers import HyperlinkedModelSerializer
> > @@ -37,6 +38,10 @@ class
> > CoverLetterListSerializer(HyperlinkedModelSerializer):
> >      submitter = PersonSerializer(read_only=True)
> >      mbox = SerializerMethodField()
> >      series = SeriesSerializer(many=True, read_only=True)
> > +    date = SerializerMethodField()
> > +
> > +    def get_date(self, instance):
> > +        return instance.date.replace(tzinfo=UTC)
> >  
> >      def get_mbox(self, instance):
> >          request = self.context.get('request')
> > diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py
> > index 7b5090a..bc915fe 100644
> > --- a/patchwork/api/embedded.py
> > +++ b/patchwork/api/embedded.py
> > @@ -23,6 +23,8 @@ A collection of serializers. None of the serializers here
> > should reference
> >  nested fields.
> >  """
> >  
> > +from django.utils import timezone
> > +from pytz import UTC
> >  from rest_framework.serializers import CharField
> >  from rest_framework.serializers import HyperlinkedModelSerializer
> >  from rest_framework.serializers import SerializerMethodField
> > @@ -61,6 +63,7 @@ class CheckSerializer(HyperlinkedModelSerializer):
> >      url = CheckHyperlinkedIdentityField('api-check-detail')
> >  
> >      def to_representation(self, instance):
> > +        instance.date = timezone.make_aware(instance.date)
> >          data = super(CheckSerializer, self).to_representation(instance)
> >          data['state'] = instance.get_state_display()
> >          return data
> > @@ -77,6 +80,10 @@ class CheckSerializer(HyperlinkedModelSerializer):
> >  
> >  class CoverLetterSerializer(MboxMixin, HyperlinkedModelSerializer):
> >  
> > +    def to_representation(self, instance):
> > +        instance.date = instance.date.replace(tzinfo=UTC)
> > +        return super(CoverLetterSerializer,
> > self).to_representation(instance)
> > +
> >      class Meta:
> >          model = models.CoverLetter
> >          fields = ('id', 'url', 'msgid', 'date', 'name', 'mbox')
> > @@ -88,6 +95,10 @@ class CoverLetterSerializer(MboxMixin,
> > HyperlinkedModelSerializer):
> >  
> >  class PatchSerializer(MboxMixin, HyperlinkedModelSerializer):
> >  
> > +    def to_representation(self, instance):
> > +        instance.date = instance.date.replace(tzinfo=UTC)
> > +        return super(PatchSerializer, self).to_representation(instance)
> > +
> >      class Meta:
> >          model = models.Patch
> >          fields = ('id', 'url', 'msgid', 'date', 'name', 'mbox')
> > @@ -126,6 +137,10 @@ class ProjectSerializer(HyperlinkedModelSerializer):
> >  
> >  class SeriesSerializer(MboxMixin, HyperlinkedModelSerializer):
> >  
> > +    def to_representation(self, instance):
> > +        instance.date = instance.date.replace(tzinfo=UTC)
> > +        return super(SeriesSerializer, self).to_representation(instance)
> > +
> >      class Meta:
> >          model = models.Series
> >          fields = ('id', 'url', 'date', 'name', 'version', 'mbox')
> > diff --git a/patchwork/api/event.py b/patchwork/api/event.py
> > index 0d97af2..22c747e 100644
> > --- a/patchwork/api/event.py
> > +++ b/patchwork/api/event.py
> > @@ -19,6 +19,7 @@
> >  
> >  from collections import OrderedDict
> >  
> > +from django.utils import timezone
> >  from rest_framework.generics import ListAPIView
> >  from rest_framework.serializers import ModelSerializer
> >  from rest_framework.serializers import SerializerMethodField
> > @@ -61,6 +62,7 @@ class EventSerializer(ModelSerializer):
> >      }
> >  
> >      def to_representation(self, instance):
> > +        instance.date = timezone.make_aware(instance.date)
> >          data = super(EventSerializer, self).to_representation(instance)
> >          payload = OrderedDict()
> >          kept_fields = self._category_map[instance.category] + [
> > diff --git a/patchwork/api/patch.py b/patchwork/api/patch.py
> > index 115feff..57aa97d 100644
> > --- a/patchwork/api/patch.py
> > +++ b/patchwork/api/patch.py
> > @@ -20,6 +20,7 @@
> >  import email.parser
> >  
> >  from django.utils.translation import ugettext_lazy as _
> > +from pytz import UTC
> >  from rest_framework.generics import ListAPIView
> >  from rest_framework.generics import RetrieveUpdateAPIView
> >  from rest_framework.relations import RelatedField
> > @@ -86,6 +87,10 @@ class PatchListSerializer(HyperlinkedModelSerializer):
> >      check = SerializerMethodField()
> >      checks = SerializerMethodField()
> >      tags = SerializerMethodField()
> > +    date = SerializerMethodField()
> > +
> > +    def get_date(self, instance):
> > +        return instance.date.replace(tzinfo=UTC)
> >  
> >      def get_mbox(self, instance):
> >          request = self.context.get('request')
> > diff --git a/patchwork/api/series.py b/patchwork/api/series.py
> > index b5f4450..562bf45 100644
> > --- a/patchwork/api/series.py
> > +++ b/patchwork/api/series.py
> > @@ -17,6 +17,8 @@
> >  # along with Patchwork; if not, write to the Free Software
> >  # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307
> >  USA
> >  
> > +
> > +from pytz import UTC
> >  from rest_framework.generics import ListAPIView
> >  from rest_framework.generics import RetrieveAPIView
> >  from rest_framework.serializers import HyperlinkedModelSerializer
> > @@ -38,6 +40,10 @@ class SeriesSerializer(HyperlinkedModelSerializer):
> >      mbox = SerializerMethodField()
> >      cover_letter = CoverLetterSerializer(read_only=True)
> >      patches = PatchSerializer(read_only=True, many=True)
> > +    date = SerializerMethodField()
> > +
> > +    def get_date(self, instance):
> > +        return instance.date.replace(tzinfo=UTC)
> >  
> >      def get_mbox(self, instance):
> >          request = self.context.get('request')
> > diff --git a/patchwork/templates/patchwork/submission.html
> > b/patchwork/templates/patchwork/submission.html
> > index 6ed20c3..e817713 100644
> > --- a/patchwork/templates/patchwork/submission.html
> > +++ b/patchwork/templates/patchwork/submission.html
> > @@ -255,7 +255,7 @@ function toggle_div(link_id, headers_id)
> >  <div class="comment">
> >  <div class="meta">
> >   <span>{{ submission.submitter|personify:project }}</span>
> > - <span class="pull-right">{{ submission.date }}</span>
> > + <span class="pull-right">{{ submission.date }} UTC</span>
> >  </div>
> >  <pre class="content">
> >  {{ submission|commentsyntax }}
> > @@ -271,7 +271,7 @@ function toggle_div(link_id, headers_id)
> >  <div class="comment">
> >  <div class="meta">
> >   <span>{{ item.submitter|personify:project }}</span>
> > - <span class="pull-right">{{ item.date }} | <a href="{% url
> > 'comment-redirect' comment_id=item.id %}"
> > + <span class="pull-right">{{ item.date }} UTC | <a href="{% url
> > 'comment-redirect' comment_id=item.id %}"
> >     >#{{ forloop.counter }}</a></span>
> >  </div>
> >  <pre class="content">
> > diff --git a/patchwork/views/xmlrpc.py b/patchwork/views/xmlrpc.py
> > index 93f6035..d59779d 100644
> > --- a/patchwork/views/xmlrpc.py
> > +++ b/patchwork/views/xmlrpc.py
> > @@ -38,6 +38,7 @@ from django.views.decorators.csrf import csrf_exempt
> >  from django.utils import six
> >  from django.utils.six.moves import xmlrpc_client
> >  from django.utils.six.moves.xmlrpc_server import SimpleXMLRPCDispatcher
> > +from pytz import UTC
> >  
> >  from patchwork.compat import reverse
> >  from patchwork.models import Check
> > @@ -248,7 +249,7 @@ def patch_to_dict(obj):
> >  
> >      {
> >          'id': 1
> > -        'date': '2000-12-31 00:11:22',
> > +        'date': '2000-12-31 00:11:22+00:00',
> >          'filename': 'Fix-all-the-bugs.patch',
> >          'msgid': '<BLU438-SMTP36690BBDD2CE71A7138B082511A@phx.gbl>',
> >          'name': "Fix all the bugs",
> > @@ -273,7 +274,7 @@ def patch_to_dict(obj):
> >      """
> >      return {
> >          'id': obj.id,
> > -        'date': six.text_type(obj.date).encode('utf-8'),
> > +        'date':
> > six.text_type(obj.date.replace(tzinfo=UTC)).encode('utf-8'),
> >          'filename': obj.filename,
> >          'msgid': obj.msgid,
> >          'name': obj.name,
> > @@ -319,7 +320,7 @@ def check_to_dict(obj):
> >      object which is OK to send to the client."""
> >      return {
> >          'id': obj.id,
> > -        'date': six.text_type(obj.date).encode('utf-8'),
> > +        'date':
> > six.text_type(obj.date.replace(tzinfo=UTC)).encode('utf-8'),
> >          'patch': six.text_type(obj.patch).encode('utf-8'),
> >          'patch_id': obj.patch_id,
> >          'user': six.text_type(obj.user).encode('utf-8'),
> > diff --git a/releasenotes/notes/report-timezones-47e2095efcb37a84.yaml
> > b/releasenotes/notes/report-timezones-47e2095efcb37a84.yaml
> > new file mode 100644
> > index 0000000..ad1dae4
> > --- /dev/null
> > +++ b/releasenotes/notes/report-timezones-47e2095efcb37a84.yaml
> > @@ -0,0 +1,4 @@
> > +---
> > +other:
> > +  - |
> > +    Report timezones in web UI and APIs.
> > diff --git a/requirements-prod.txt b/requirements-prod.txt
> > index dd2040d..1e5207e 100644
> > --- a/requirements-prod.txt
> > +++ b/requirements-prod.txt
> > @@ -3,3 +3,4 @@ djangorestframework>=3.4,<3.7
> >  django-filter>=1.0,<1.1
> >  psycopg2>=2.7,<2.8
> >  sqlparse==0.2.3
> > +pytz>=2016.10
> > diff --git a/requirements-test.txt b/requirements-test.txt
> > index 141cf66..632eb4c 100644
> > --- a/requirements-test.txt
> > +++ b/requirements-test.txt
> > @@ -3,3 +3,4 @@ psycopg2>=2.7,<2.8
> >  django-debug-toolbar==1.8
> >  python-dateutil>2.0,<3.0
> >  selenium>=3.0,<4.0
> > +pytz>=2016.10
> > --
> > 2.13.6
> >
> > _______________________________________________
> > Patchwork mailing list
> > Patchwork@lists.ozlabs.org
> > https://lists.ozlabs.org/listinfo/patchwork
>

Patch

diff --git a/docs/api/rest.rst b/docs/api/rest.rst
index 3d7292e..71a09cf 100644
--- a/docs/api/rest.rst
+++ b/docs/api/rest.rst
@@ -107,9 +107,17 @@  Schema
 ------
 
 Responses are returned as JSON. Blank fields are returned as ``null``, rather
-than being omitted. Timestamps use the ISO 8601 format::
+than being omitted. Returned timestamps use the ISO 8601 format including the
+timezone information::
 
-    YYYY-MM-DDTHH:MM:SSZ
+    YYYY-MM-DDTHH:MM:SS[Z+-HH:MM]
+
+.. note::
+
+    Timezone suffix is only provided in responses to avoid users' confusion and
+    to allow proper coordination with CI tooling. Underlying data are
+    timezone-naive and can't be filtered based on timezones, therefore requests
+    made to the API should not use the timezone suffix.
 
 Requests should use either query parameters or form-data, depending on the
 method. Further information is provided `below <rest_parameters>`__.
diff --git a/docs/deployment/installation.rst b/docs/deployment/installation.rst
index a570dd8..38e12d1 100644
--- a/docs/deployment/installation.rst
+++ b/docs/deployment/installation.rst
@@ -173,7 +173,8 @@  this also:
 .. code-block:: shell
 
    $ sudo apt-get install -y python3-django python3-psycopg2 \
-       python3-djangorestframework python3-django-filters
+       python3-djangorestframework python3-django-filters \
+       python3-tz
 
 .. tip::
 
diff --git a/patchwork/api/check.py b/patchwork/api/check.py
index b37d6e0..4854a82 100644
--- a/patchwork/api/check.py
+++ b/patchwork/api/check.py
@@ -17,6 +17,7 @@ 
 # along with Patchwork; if not, write to the Free Software
 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 
+from django.utils import timezone
 from rest_framework.exceptions import PermissionDenied
 from rest_framework.generics import ListCreateAPIView
 from rest_framework.generics import RetrieveAPIView
@@ -63,6 +64,7 @@  class CheckSerializer(HyperlinkedModelSerializer):
         return super(CheckSerializer, self).run_validation(data)
 
     def to_representation(self, instance):
+        instance.date = timezone.make_aware(instance.date)
         data = super(CheckSerializer, self).to_representation(instance)
         data['state'] = instance.get_state_display()
         return data
diff --git a/patchwork/api/cover.py b/patchwork/api/cover.py
index 1064504..2de4ea2 100644
--- a/patchwork/api/cover.py
+++ b/patchwork/api/cover.py
@@ -19,6 +19,7 @@ 
 
 import email.parser
 
+from pytz import UTC
 from rest_framework.generics import ListAPIView
 from rest_framework.generics import RetrieveAPIView
 from rest_framework.serializers import HyperlinkedModelSerializer
@@ -37,6 +38,10 @@  class CoverLetterListSerializer(HyperlinkedModelSerializer):
     submitter = PersonSerializer(read_only=True)
     mbox = SerializerMethodField()
     series = SeriesSerializer(many=True, read_only=True)
+    date = SerializerMethodField()
+
+    def get_date(self, instance):
+        return instance.date.replace(tzinfo=UTC)
 
     def get_mbox(self, instance):
         request = self.context.get('request')
diff --git a/patchwork/api/embedded.py b/patchwork/api/embedded.py
index 7b5090a..bc915fe 100644
--- a/patchwork/api/embedded.py
+++ b/patchwork/api/embedded.py
@@ -23,6 +23,8 @@  A collection of serializers. None of the serializers here should reference
 nested fields.
 """
 
+from django.utils import timezone
+from pytz import UTC
 from rest_framework.serializers import CharField
 from rest_framework.serializers import HyperlinkedModelSerializer
 from rest_framework.serializers import SerializerMethodField
@@ -61,6 +63,7 @@  class CheckSerializer(HyperlinkedModelSerializer):
     url = CheckHyperlinkedIdentityField('api-check-detail')
 
     def to_representation(self, instance):
+        instance.date = timezone.make_aware(instance.date)
         data = super(CheckSerializer, self).to_representation(instance)
         data['state'] = instance.get_state_display()
         return data
@@ -77,6 +80,10 @@  class CheckSerializer(HyperlinkedModelSerializer):
 
 class CoverLetterSerializer(MboxMixin, HyperlinkedModelSerializer):
 
+    def to_representation(self, instance):
+        instance.date = instance.date.replace(tzinfo=UTC)
+        return super(CoverLetterSerializer, self).to_representation(instance)
+
     class Meta:
         model = models.CoverLetter
         fields = ('id', 'url', 'msgid', 'date', 'name', 'mbox')
@@ -88,6 +95,10 @@  class CoverLetterSerializer(MboxMixin, HyperlinkedModelSerializer):
 
 class PatchSerializer(MboxMixin, HyperlinkedModelSerializer):
 
+    def to_representation(self, instance):
+        instance.date = instance.date.replace(tzinfo=UTC)
+        return super(PatchSerializer, self).to_representation(instance)
+
     class Meta:
         model = models.Patch
         fields = ('id', 'url', 'msgid', 'date', 'name', 'mbox')
@@ -126,6 +137,10 @@  class ProjectSerializer(HyperlinkedModelSerializer):
 
 class SeriesSerializer(MboxMixin, HyperlinkedModelSerializer):
 
+    def to_representation(self, instance):
+        instance.date = instance.date.replace(tzinfo=UTC)
+        return super(SeriesSerializer, self).to_representation(instance)
+
     class Meta:
         model = models.Series
         fields = ('id', 'url', 'date', 'name', 'version', 'mbox')
diff --git a/patchwork/api/event.py b/patchwork/api/event.py
index 0d97af2..22c747e 100644
--- a/patchwork/api/event.py
+++ b/patchwork/api/event.py
@@ -19,6 +19,7 @@ 
 
 from collections import OrderedDict
 
+from django.utils import timezone
 from rest_framework.generics import ListAPIView
 from rest_framework.serializers import ModelSerializer
 from rest_framework.serializers import SerializerMethodField
@@ -61,6 +62,7 @@  class EventSerializer(ModelSerializer):
     }
 
     def to_representation(self, instance):
+        instance.date = timezone.make_aware(instance.date)
         data = super(EventSerializer, self).to_representation(instance)
         payload = OrderedDict()
         kept_fields = self._category_map[instance.category] + [
diff --git a/patchwork/api/patch.py b/patchwork/api/patch.py
index 115feff..57aa97d 100644
--- a/patchwork/api/patch.py
+++ b/patchwork/api/patch.py
@@ -20,6 +20,7 @@ 
 import email.parser
 
 from django.utils.translation import ugettext_lazy as _
+from pytz import UTC
 from rest_framework.generics import ListAPIView
 from rest_framework.generics import RetrieveUpdateAPIView
 from rest_framework.relations import RelatedField
@@ -86,6 +87,10 @@  class PatchListSerializer(HyperlinkedModelSerializer):
     check = SerializerMethodField()
     checks = SerializerMethodField()
     tags = SerializerMethodField()
+    date = SerializerMethodField()
+
+    def get_date(self, instance):
+        return instance.date.replace(tzinfo=UTC)
 
     def get_mbox(self, instance):
         request = self.context.get('request')
diff --git a/patchwork/api/series.py b/patchwork/api/series.py
index b5f4450..562bf45 100644
--- a/patchwork/api/series.py
+++ b/patchwork/api/series.py
@@ -17,6 +17,8 @@ 
 # along with Patchwork; if not, write to the Free Software
 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 
+
+from pytz import UTC
 from rest_framework.generics import ListAPIView
 from rest_framework.generics import RetrieveAPIView
 from rest_framework.serializers import HyperlinkedModelSerializer
@@ -38,6 +40,10 @@  class SeriesSerializer(HyperlinkedModelSerializer):
     mbox = SerializerMethodField()
     cover_letter = CoverLetterSerializer(read_only=True)
     patches = PatchSerializer(read_only=True, many=True)
+    date = SerializerMethodField()
+
+    def get_date(self, instance):
+        return instance.date.replace(tzinfo=UTC)
 
     def get_mbox(self, instance):
         request = self.context.get('request')
diff --git a/patchwork/templates/patchwork/submission.html b/patchwork/templates/patchwork/submission.html
index 6ed20c3..e817713 100644
--- a/patchwork/templates/patchwork/submission.html
+++ b/patchwork/templates/patchwork/submission.html
@@ -255,7 +255,7 @@  function toggle_div(link_id, headers_id)
 <div class="comment">
 <div class="meta">
  <span>{{ submission.submitter|personify:project }}</span>
- <span class="pull-right">{{ submission.date }}</span>
+ <span class="pull-right">{{ submission.date }} UTC</span>
 </div>
 <pre class="content">
 {{ submission|commentsyntax }}
@@ -271,7 +271,7 @@  function toggle_div(link_id, headers_id)
 <div class="comment">
 <div class="meta">
  <span>{{ item.submitter|personify:project }}</span>
- <span class="pull-right">{{ item.date }} | <a href="{% url 'comment-redirect' comment_id=item.id %}"
+ <span class="pull-right">{{ item.date }} UTC | <a href="{% url 'comment-redirect' comment_id=item.id %}"
    >#{{ forloop.counter }}</a></span>
 </div>
 <pre class="content">
diff --git a/patchwork/views/xmlrpc.py b/patchwork/views/xmlrpc.py
index 93f6035..d59779d 100644
--- a/patchwork/views/xmlrpc.py
+++ b/patchwork/views/xmlrpc.py
@@ -38,6 +38,7 @@  from django.views.decorators.csrf import csrf_exempt
 from django.utils import six
 from django.utils.six.moves import xmlrpc_client
 from django.utils.six.moves.xmlrpc_server import SimpleXMLRPCDispatcher
+from pytz import UTC
 
 from patchwork.compat import reverse
 from patchwork.models import Check
@@ -248,7 +249,7 @@  def patch_to_dict(obj):
 
     {
         'id': 1
-        'date': '2000-12-31 00:11:22',
+        'date': '2000-12-31 00:11:22+00:00',
         'filename': 'Fix-all-the-bugs.patch',
         'msgid': '<BLU438-SMTP36690BBDD2CE71A7138B082511A@phx.gbl>',
         'name': "Fix all the bugs",
@@ -273,7 +274,7 @@  def patch_to_dict(obj):
     """
     return {
         'id': obj.id,
-        'date': six.text_type(obj.date).encode('utf-8'),
+        'date': six.text_type(obj.date.replace(tzinfo=UTC)).encode('utf-8'),
         'filename': obj.filename,
         'msgid': obj.msgid,
         'name': obj.name,
@@ -319,7 +320,7 @@  def check_to_dict(obj):
     object which is OK to send to the client."""
     return {
         'id': obj.id,
-        'date': six.text_type(obj.date).encode('utf-8'),
+        'date': six.text_type(obj.date.replace(tzinfo=UTC)).encode('utf-8'),
         'patch': six.text_type(obj.patch).encode('utf-8'),
         'patch_id': obj.patch_id,
         'user': six.text_type(obj.user).encode('utf-8'),
diff --git a/releasenotes/notes/report-timezones-47e2095efcb37a84.yaml b/releasenotes/notes/report-timezones-47e2095efcb37a84.yaml
new file mode 100644
index 0000000..ad1dae4
--- /dev/null
+++ b/releasenotes/notes/report-timezones-47e2095efcb37a84.yaml
@@ -0,0 +1,4 @@ 
+---
+other:
+  - |
+    Report timezones in web UI and APIs.
diff --git a/requirements-prod.txt b/requirements-prod.txt
index dd2040d..1e5207e 100644
--- a/requirements-prod.txt
+++ b/requirements-prod.txt
@@ -3,3 +3,4 @@  djangorestframework>=3.4,<3.7
 django-filter>=1.0,<1.1
 psycopg2>=2.7,<2.8
 sqlparse==0.2.3
+pytz>=2016.10
diff --git a/requirements-test.txt b/requirements-test.txt
index 141cf66..632eb4c 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -3,3 +3,4 @@  psycopg2>=2.7,<2.8
 django-debug-toolbar==1.8
 python-dateutil>2.0,<3.0
 selenium>=3.0,<4.0
+pytz>=2016.10