Repository: pretix/pretix

Issue:
Event time with timezone
It's actually a follow-up for #413.

>The rule of thumb in pretix (and Django) is: The database contains always UTC, the user always is shown and enters local time.

I asked this question because I found something interesting when creating event.
The scenarios are as follows:
1) Create new event using timezone `Asia/Tokyo`
![create-tokyo](https://cloud.githubusercontent.com/assets/25281597/23635820/37274f74-0315-11e7-86dd-806d867e7323.png)
After save:
![saved-tokyo](https://cloud.githubusercontent.com/assets/25281597/23635825/412aecc4-0315-11e7-90a7-51f940b411b0.png)
Both start and end time were changed with respect to the timezone I set. 
From **11:19:59 (GMT+9)** to **20:19:59 (GMT+9)**.
I guess we consider the start and end time as UTC time by default regardless of the timezone we set.

2) Change time only
![timezone-unchanged](https://cloud.githubusercontent.com/assets/25281597/23635947/feb85506-0315-11e7-9a84-cd9fc51c33e3.png)
As expected, it works well.

3) Change timezone of an existing event
![change timezone](https://cloud.githubusercontent.com/assets/25281597/23635955/0df7829e-0316-11e7-9ccb-60d0fb54efb2.png)
This time I changed the timezone from `Asia/Tokyo`(GMT+9) to `Asia/Singapore`(GMT+8).
![saved timezone](https://cloud.githubusercontent.com/assets/25281597/23635976/2da3fff0-0316-11e7-8f6a-2ddcce9f21c3.png)
Both times were changed from **08:50:56** to **07:50:56**.

I am quite new to pretix and not sure whether this kind of behaviour is expected.
From my opinion, I think user will only set event time using local time, which means it's better to include timezone value before we save data to database.




Discussion:
No, this is certainly not wanted and a bug. Thank you for finding and reporting this. The user should only ever see local time in the timezone the user wants.
I think I can help fix this bug but probably need some directions.
I went through the code related to the above behaviour:
```
url(r'^events/add$', main.EventWizard.as_view(), name='events.add'), # add event

url(r'^event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/', include([
     url(r'^settings/$', event.EventUpdate.as_view(), name='event.settings'), # update event
```

Apparently, there are 2 ways to fix the issue.
1) Modify `EventWizard` (probably `done` method) and `EventUpdate` (`post` method) for handling timezone switching
2) Update the `def save(self, *args, **kwargs):` in Event` module. I'll modify the datetime value with respect to the timezone in settings. I'm not sure whether it's a good practice to put some magic inside the model. 

Could you give me some hints?

Regarding the modification of timezone, I think the code below works.
Since we want to keep datetime value but `tzinfo`, I think I need to change it back to naive timezone, followed by `localize` (new timezone).
```
self.date_from = timezone(self.settings.timezone)
                             .localize(self.date_from.replace(tzinfo=None))
```

Thanks!
Hi! 

> Apparently, there are 2 ways to fix the issue.

From the top of my head, I would go neither for option 1 nor for option 2. ``EventWizard`` and ``EventUpdate`` are views, which are rather to high-level for this kind of thing and ``Event`` is a model which is rather to low-level for this. In my opinion, the golden middle road would be the **forms** involved, which are ``EventWizardBasicsForm`` and ``EventUpdateForm``. We could do it in the ``save()`` methods there but I think an even better way might be the ``clean()`` method in Django's validation cycle.

> Regarding the modification of timezone, I think the code below works.

It looks correct on first sight. A few tests will tell us later, if it was ;)

Cheers
Raphael

Resolving patch:
diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py
--- a/src/pretix/control/forms/event.py
+++ b/src/pretix/control/forms/event.py
@@ -5,7 +5,7 @@
 from django.utils.timezone import get_current_timezone_name
 from django.utils.translation import ugettext_lazy as _
 from i18nfield.forms import I18nFormField, I18nTextarea
-from pytz import common_timezones
+from pytz import common_timezones, timezone
 
 from pretix.base.forms import I18nModelForm, SettingsForm
 from pretix.base.models import Event, Organizer
@@ -80,8 +80,23 @@ def clean(self):
             raise ValidationError({
                 'locale': _('Your default locale must also be enabled for your event (see box above).')
             })
+        if data.get('timezone') not in common_timezones:
+            raise ValidationError({
+                'timezone': _('Your default locale must be specified.')
+            })
+
+        # change timezone
+        zone = timezone(data.get('timezone'))
+        data['date_from'] = self.reset_timezone(zone, data.get('date_from'))
+        data['date_to'] = self.reset_timezone(zone, data.get('date_to'))
+        data['presale_start'] = self.reset_timezone(zone, data.get('presale_start'))
+        data['presale_end'] = self.reset_timezone(zone, data.get('presale_end'))
         return data
 
+    @staticmethod
+    def reset_timezone(tz, dt):
+        return tz.localize(dt.replace(tzinfo=None)) if dt is not None else None
+
     def clean_slug(self):
         slug = self.cleaned_data['slug']
         if Event.objects.filter(slug=slug, organizer=self.organizer).exists():
diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py
--- a/src/pretix/control/views/event.py
+++ b/src/pretix/control/views/event.py
@@ -14,6 +14,7 @@
 from django.views.generic import FormView, ListView
 from django.views.generic.base import TemplateView, View
 from django.views.generic.detail import SingleObjectMixin
+from pytz import timezone
 
 from pretix.base.forms import I18nModelForm
 from pretix.base.models import (
@@ -88,10 +89,21 @@ def get_success_url(self) -> str:
     def post(self, request, *args, **kwargs):
         form = self.get_form()
         if form.is_valid() and self.sform.is_valid():
+            # reset timezone
+            zone = timezone(self.sform.cleaned_data['timezone'])
+            event = form.instance
+            event.date_from = self.reset_timezone(zone, event.date_from)
+            event.date_to = self.reset_timezone(zone, event.date_to)
+            event.presale_start = self.reset_timezone(zone, event.presale_start)
+            event.presale_end = self.reset_timezone(zone, event.presale_end)
             return self.form_valid(form)
         else:
             return self.form_invalid(form)
 
+    @staticmethod
+    def reset_timezone(tz, dt):
+        return tz.localize(dt.replace(tzinfo=None)) if dt is not None else None
+
 
 class EventPlugins(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin):
     model = Event


Test patch:
diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py
--- a/src/tests/control/test_events.py
+++ b/src/tests/control/test_events.py
@@ -1,7 +1,9 @@
 import datetime
 from decimal import Decimal
 
+import pytz
 from i18nfield.strings import LazyI18nString
+from pytz import timezone
 from tests.base import SoupTest, extract_form_fields
 
 from pretix.base.models import (
@@ -45,7 +47,6 @@ def test_settings(self):
         doc = self.get_doc('/control/event/%s/%s/settings/' % (self.orga1.slug, self.event1.slug))
         doc.select("[name=date_to]")[0]['value'] = "2013-12-30 17:00:00"
         doc.select("[name=settings-max_items_per_order]")[0]['value'] = "12"
-        print(extract_form_fields(doc.select('.container-fluid form')[0]))
 
         doc = self.post_doc('/control/event/%s/%s/settings/' % (self.orga1.slug, self.event1.slug),
                             extract_form_fields(doc.select('.container-fluid form')[0]))
@@ -53,6 +54,27 @@ def test_settings(self):
         assert doc.select("[name=date_to]")[0]['value'] == "2013-12-30 17:00:00"
         assert doc.select("[name=settings-max_items_per_order]")[0]['value'] == "12"
 
+    def test_settings_timezone(self):
+        doc = self.get_doc('/control/event/%s/%s/settings/' % (self.orga1.slug, self.event1.slug))
+        doc.select("[name=date_to]")[0]['value'] = "2013-12-30 17:00:00"
+        doc.select("[name=settings-max_items_per_order]")[0]['value'] = "12"
+        doc.select("[name=settings-timezone]")[0]['value'] = "Asia/Tokyo"
+        doc.find('option', {"value": "Asia/Tokyo"})['selected'] = 'selected'
+        doc.find('option', {"value": "UTC"}).attrs.pop('selected')
+
+        doc = self.post_doc('/control/event/%s/%s/settings/' % (self.orga1.slug, self.event1.slug),
+                            extract_form_fields(doc.select('.container-fluid form')[0]))
+        assert len(doc.select(".alert-success")) > 0
+        # date_to should not be changed even though the timezone is changed
+        assert doc.select("[name=date_to]")[0]['value'] == "2013-12-30 17:00:00"
+        assert doc.find('option', {"value": "Asia/Tokyo"})['selected'] == "selected"
+        assert doc.select("[name=settings-max_items_per_order]")[0]['value'] == "12"
+
+        self.event1.refresh_from_db()
+        # Asia/Tokyo -> GMT+9
+        assert self.event1.date_to.strftime('%Y-%m-%d %H:%M:%S') == "2013-12-30 08:00:00"
+        assert self.event1.settings.timezone == 'Asia/Tokyo'
+
     def test_plugins(self):
         doc = self.get_doc('/control/event/%s/%s/settings/plugins' % (self.orga1.slug, self.event1.slug))
         self.assertIn("PayPal", doc.select(".form-plugins")[0].text)
@@ -295,4 +317,72 @@ def test_create_event_success(self):
         assert ev.currency == 'EUR'
         assert ev.settings.timezone == 'Europe/Berlin'
         assert ev.organizer == self.orga1
+        assert ev.location == LazyI18nString({'de': 'Hamburg', 'en': 'Hamburg'})
         assert EventPermission.objects.filter(event=ev, user=self.user).exists()
+
+        berlin_tz = timezone('Europe/Berlin')
+        assert ev.date_from == berlin_tz.localize(datetime.datetime(2016, 12, 27, 10, 0, 0)).astimezone(pytz.utc)
+        assert ev.date_to == berlin_tz.localize(datetime.datetime(2016, 12, 30, 19, 0, 0)).astimezone(pytz.utc)
+        assert ev.presale_start == berlin_tz.localize(datetime.datetime(2016, 11, 1, 10, 0, 0)).astimezone(pytz.utc)
+        assert ev.presale_end == berlin_tz.localize(datetime.datetime(2016, 11, 30, 18, 0, 0)).astimezone(pytz.utc)
+
+    def test_create_event_only_date_from(self):
+        # date_to, presale_start & presale_end are optional fields
+        self.post_doc('/control/events/add', {
+            'event_wizard-current_step': 'foundation',
+            'foundation-organizer': self.orga1.pk,
+            'foundation-locales': 'en'
+        })
+        self.post_doc('/control/events/add', {
+            'event_wizard-current_step': 'basics',
+            'basics-name_0': '33C3',
+            'basics-slug': '33c3',
+            'basics-date_from': '2016-12-27 10:00:00',
+            'basics-date_to': '',
+            'basics-location_0': 'Hamburg',
+            'basics-currency': 'EUR',
+            'basics-locale': 'en',
+            'basics-timezone': 'UTC',
+            'basics-presale_start': '',
+            'basics-presale_end': '',
+        })
+        self.post_doc('/control/events/add', {
+            'event_wizard-current_step': 'copy',
+            'copy-copy_from_event': ''
+        })
+
+        ev = Event.objects.get(slug='33c3')
+        assert ev.name == LazyI18nString({'en': '33C3'})
+        assert ev.settings.locales == ['en']
+        assert ev.settings.locale == 'en'
+        assert ev.currency == 'EUR'
+        assert ev.settings.timezone == 'UTC'
+        assert ev.organizer == self.orga1
+        assert ev.location == LazyI18nString({'en': 'Hamburg'})
+        assert EventPermission.objects.filter(event=ev, user=self.user).exists()
+        assert ev.date_from == datetime.datetime(2016, 12, 27, 10, 0, 0, tzinfo=pytz.utc)
+        assert ev.date_to is None
+        assert ev.presale_start is None
+        assert ev.presale_end is None
+
+    def test_create_event_missing_date_from(self):
+        # date_from is mandatory
+        self.post_doc('/control/events/add', {
+            'event_wizard-current_step': 'foundation',
+            'foundation-organizer': self.orga1.pk,
+            'foundation-locales': 'en'
+        })
+        doc = self.post_doc('/control/events/add', {
+            'event_wizard-current_step': 'basics',
+            'basics-name_0': '33C3',
+            'basics-slug': '33c3',
+            'basics-date_from': '',
+            'basics-date_to': '2016-12-30 19:00:00',
+            'basics-location_0': 'Hamburg',
+            'basics-currency': 'EUR',
+            'basics-locale': 'en',
+            'basics-timezone': 'Europe/Berlin',
+            'basics-presale_start': '2016-11-20 11:00:00',
+            'basics-presale_end': '2016-11-24 18:00:00',
+        })
+        assert doc.select(".alert-danger")