From 062563f487b5a504e1dd613573da93141d98fee1 Mon Sep 17 00:00:00 2001 From: Paul Craciunoiu Date: Tue, 27 Nov 2012 14:35:19 -0800 Subject: [PATCH] Add timezone support in the admin. Hacky pytz approach for now. --- admin_settings.py | 3 + fields.py | 49 ++++++ handlers.py | 3 + media/css/main.css | 4 + media/js/jstz.js | 301 +++++++++++++++++++++++++++++++++ media/js/jstz.min.js | 12 ++ templates/admin_base.html | 1 + templates/model_item_edit.html | 18 +- tests/test_aetycoon.py | 20 +-- tests/test_forms.py | 9 +- utils.py | 5 + 11 files changed, 410 insertions(+), 15 deletions(-) create mode 100644 media/js/jstz.js create mode 100644 media/js/jstz.min.js diff --git a/admin_settings.py b/admin_settings.py index 180a1b2..e4d0594 100644 --- a/admin_settings.py +++ b/admin_settings.py @@ -37,3 +37,6 @@ # Set this to your custom notification callback # Useful e.g. when errors occur in the admin handler NOTIFY_CALLBACK = None + +# Default timezone for use in admin dates +TIMEZONE = 'America/Los_Angeles' diff --git a/fields.py b/fields.py index d8bc8cf..9e0f2e6 100644 --- a/fields.py +++ b/fields.py @@ -1,3 +1,5 @@ +from datetime import datetime + from google.appengine.ext import db from . import widgets, wtforms @@ -9,8 +11,55 @@ class DateTimeField(f.DateTimeField): widget = widgets.DateTimeTextInput() def __init__(self, *args, **kwargs): + kwargs['format'] = '%Y-%m-%d %H:%M:%S %Z' # output format super(DateTimeField, self).__init__(*args, **kwargs) + self.input_format = '%Y-%m-%d %H:%M:%S' + + def process_data(self, value): + if not isinstance(value, datetime): + # Non-datetime values are left alone + super(DateTimeField, self).process_data(value) + else: + # Already localized values are not localized + from . import admin_settings, utils + try: + pytz = utils.import_pytz() + except ImportError: # Fall back to default behavior if no pytz available + return super(DateTimeField, self).process_data(value) + zone = pytz.timezone(admin_settings.TIMEZONE) + if not value.tzinfo: + value = pytz.utc.localize(value) + self.data = value.astimezone(zone) + + def _value(self): + try: + from . import utils + utils.import_pytz() + except ImportError: # Fall back to default behavior if no pytz available + return super(DateTimeField, self).value() + if self.raw_data: + return ' '.join(self.raw_data) + else: + data = self.data and self.data.strftime(self.format) or '' + if self.data.tzinfo: + return data.replace(self.data.tzinfo._tzname, self.data.tzinfo.zone) + return data + def process_formdata(self, valuelist): + if valuelist: + date_str = ' '.join(valuelist).strip() + from . import utils + pytz = utils.import_pytz() + date_without_tz, _, zone_name = date_str.rpartition(' ') + try: + zone = pytz.timezone(zone_name) + except pytz.UnknownTimeZoneError: + raise ValueError(self.gettext('Not a valid timezone value')) + try: + self.data = zone.localize(datetime.strptime(date_without_tz, self.input_format)) + except ValueError: + self.data = None + raise ValueError(self.gettext('Not a valid datetime value')) class DateField(f.DateField): '''Custom DateField that use the appengine_admin DateTextInput.''' diff --git a/handlers.py b/handlers.py index 2e0dd9b..fd1eaa2 100644 --- a/handlers.py +++ b/handlers.py @@ -34,6 +34,9 @@ def render(self, path, template_kwargs={}): 'uri_for': lambda route_name, *a, **kw: self.uri_for('appengine_admin.%s' % route_name, *a, **kw), 'get_messages': self.get_messages, 'csrf_token': self.get_csrf_token, + 'settings': { + 'TIMEZONE': admin_settings.TIMEZONE, + } }) if hasattr(self, 'models'): template_kwargs['models'] = self.models diff --git a/media/css/main.css b/media/css/main.css index 36f84ca..eb5c61a 100644 --- a/media/css/main.css +++ b/media/css/main.css @@ -59,6 +59,10 @@ body { width: 40%; } +.ui_tpicker_timezone select { + width: auto; +} + .ajax_add_key_input { width: 40%; } diff --git a/media/js/jstz.js b/media/js/jstz.js new file mode 100644 index 0000000..ae619d3 --- /dev/null +++ b/media/js/jstz.js @@ -0,0 +1,301 @@ +/*jslint undef: true */ +/*global console*/ + +/** + * Namespace to hold all the code for timezone detection. + */ +var jstz = (function () { + 'use strict'; + var HEMISPHERE_SOUTH = 's', + + /** + * Gets the offset in minutes from UTC for a certain date. + * @param {Date} date + * @returns {Number} + */ + get_date_offset = function (date) { + var offset = -date.getTimezoneOffset(); + return (offset !== null ? offset : 0); + }, + + get_january_offset = function () { + return get_date_offset(new Date(2010, 0, 1, 0, 0, 0, 0)); + }, + + get_june_offset = function () { + return get_date_offset(new Date(2010, 5, 1, 0, 0, 0, 0)); + }, + + /** + * Private method. + * Checks whether a given date is in daylight savings time. + * If the date supplied is after june, we assume that we're checking + * for southern hemisphere DST. + * @param {Date} date + * @returns {Boolean} + */ + date_is_dst = function (date) { + var base_offset = ((date.getMonth() > 5 ? get_june_offset() + : get_january_offset())), + date_offset = get_date_offset(date); + + return (base_offset - date_offset) !== 0; + }, + + /** + * This function does some basic calculations to create information about + * the user's timezone. + * + * Returns a key that can be used to do lookups in jstz.olson.timezones. + * + * @returns {String} + */ + + lookup_key = function () { + var january_offset = get_january_offset(), + june_offset = get_june_offset(), + diff = get_january_offset() - get_june_offset(); + + if (diff < 0) { + return january_offset + ",1"; + } else if (diff > 0) { + return june_offset + ",1," + HEMISPHERE_SOUTH; + } + + return january_offset + ",0"; + }, + + /** + * Uses get_timezone_info() to formulate a key to use in the olson.timezones dictionary. + * + * Returns a primitive object on the format: + * {'timezone': TimeZone, 'key' : 'the key used to find the TimeZone object'} + * + * @returns Object + */ + determine = function () { + var key = lookup_key(); + return new jstz.TimeZone(jstz.olson.timezones[key]); + }; + + return { + determine_timezone : function () { + if (typeof console !== 'undefined') { + console.log("jstz.determine_timezone() is deprecated and will be removed in an upcoming version. Please use jstz.determine() instead."); + } + return determine(); + }, + determine: determine, + date_is_dst : date_is_dst + }; +}()); + +/** + * Simple object to perform ambiguity check and to return name of time zone. + */ +jstz.TimeZone = function (tz_name) { + 'use strict'; + var timezone_name = null, + + name = function () { + return timezone_name; + }, + + /** + * Checks if a timezone has possible ambiguities. I.e timezones that are similar. + * + * For example, if the preliminary scan determines that we're in America/Denver. + * We double check here that we're really there and not in America/Mazatlan. + * + * This is done by checking known dates for when daylight savings start for different + * timezones during 2010 and 2011. + */ + ambiguity_check = function () { + var ambiguity_list = jstz.olson.ambiguity_list[timezone_name], + length = ambiguity_list.length, + i = 0, + tz = ambiguity_list[0]; + + for (; i < length; i += 1) { + tz = ambiguity_list[i]; + + if (jstz.date_is_dst(jstz.olson.dst_start_dates[tz])) { + timezone_name = tz; + return; + } + } + }, + + /** + * Checks if it is possible that the timezone is ambiguous. + */ + is_ambiguous = function () { + return typeof (jstz.olson.ambiguity_list[timezone_name]) !== 'undefined'; + }; + + + + timezone_name = tz_name; + if (is_ambiguous()) { + ambiguity_check(); + } + + return { + name: name + }; +}; + +jstz.olson = {}; + +/* + * The keys in this dictionary are comma separated as such: + * + * First the offset compared to UTC time in minutes. + * + * Then a flag which is 0 if the timezone does not take daylight savings into account and 1 if it + * does. + * + * Thirdly an optional 's' signifies that the timezone is in the southern hemisphere, + * only interesting for timezones with DST. + * + * The mapped arrays is used for constructing the jstz.TimeZone object from within + * jstz.determine_timezone(); + */ +jstz.olson.timezones = { + '-720,0' : 'Etc/GMT+12', + '-660,0' : 'Pacific/Pago_Pago', + '-600,1' : 'America/Adak', + '-600,0' : 'Pacific/Honolulu', + '-570,0' : 'Pacific/Marquesas', + '-540,0' : 'Pacific/Gambier', + '-540,1' : 'America/Anchorage', + '-480,1' : 'America/Los_Angeles', + '-480,0' : 'Pacific/Pitcairn', + '-420,0' : 'America/Phoenix', + '-420,1' : 'America/Denver', + '-360,0' : 'America/Guatemala', + '-360,1' : 'America/Chicago', + '-360,1,s' : 'Pacific/Easter', + '-300,0' : 'America/Bogota', + '-300,1' : 'America/New_York', + '-270,0' : 'America/Caracas', + '-240,1' : 'America/Halifax', + '-240,0' : 'America/Santo_Domingo', + '-240,1,s' : 'America/Asuncion', + '-210,1' : 'America/St_Johns', + '-180,1' : 'America/Godthab', + '-180,0' : 'America/Argentina/Buenos_Aires', + '-180,1,s' : 'America/Montevideo', + '-120,0' : 'America/Noronha', + '-120,1' : 'Etc/GMT+2', + '-60,1' : 'Atlantic/Azores', + '-60,0' : 'Atlantic/Cape_Verde', + '0,0' : 'Etc/UTC', + '0,1' : 'Europe/London', + '60,1' : 'Europe/Berlin', + '60,0' : 'Africa/Lagos', + '60,1,s' : 'Africa/Windhoek', + '120,1' : 'Asia/Beirut', + '120,0' : 'Africa/Johannesburg', + '180,1' : 'Europe/Moscow', + '180,0' : 'Asia/Baghdad', + '210,1' : 'Asia/Tehran', + '240,0' : 'Asia/Dubai', + '240,1' : 'Asia/Yerevan', + '270,0' : 'Asia/Kabul', + '300,1' : 'Asia/Yekaterinburg', + '300,0' : 'Asia/Karachi', + '330,0' : 'Asia/Kolkata', + '345,0' : 'Asia/Kathmandu', + '360,0' : 'Asia/Dhaka', + '360,1' : 'Asia/Omsk', + '390,0' : 'Asia/Rangoon', + '420,1' : 'Asia/Krasnoyarsk', + '420,0' : 'Asia/Jakarta', + '480,0' : 'Asia/Shanghai', + '480,1' : 'Asia/Irkutsk', + '525,0' : 'Australia/Eucla', + '525,1,s' : 'Australia/Eucla', + '540,1' : 'Asia/Yakutsk', + '540,0' : 'Asia/Tokyo', + '570,0' : 'Australia/Darwin', + '570,1,s' : 'Australia/Adelaide', + '600,0' : 'Australia/Brisbane', + '600,1' : 'Asia/Vladivostok', + '600,1,s' : 'Australia/Sydney', + '630,1,s' : 'Australia/Lord_Howe', + '660,1' : 'Asia/Kamchatka', + '660,0' : 'Pacific/Noumea', + '690,0' : 'Pacific/Norfolk', + '720,1,s' : 'Pacific/Auckland', + '720,0' : 'Pacific/Tarawa', + '765,1,s' : 'Pacific/Chatham', + '780,0' : 'Pacific/Tongatapu', + '780,1,s' : 'Pacific/Apia', + '840,0' : 'Pacific/Kiritimati' +}; + + +/** + * This object contains information on when daylight savings starts for + * different timezones. + * + * The list is short for a reason. Often we do not have to be very specific + * to single out the correct timezone. But when we do, this list comes in + * handy. + * + * Each value is a date denoting when daylight savings starts for that timezone. + */ +jstz.olson.dst_start_dates = { + 'America/Denver' : new Date(2011, 2, 13, 3, 0, 0, 0), + 'America/Mazatlan' : new Date(2011, 3, 3, 3, 0, 0, 0), + 'America/Chicago' : new Date(2011, 2, 13, 3, 0, 0, 0), + 'America/Mexico_City' : new Date(2011, 3, 3, 3, 0, 0, 0), + 'Atlantic/Stanley' : new Date(2011, 8, 4, 7, 0, 0, 0), + 'America/Asuncion' : new Date(2011, 9, 2, 3, 0, 0, 0), + 'America/Santiago' : new Date(2011, 9, 9, 3, 0, 0, 0), + 'America/Campo_Grande' : new Date(2011, 9, 16, 5, 0, 0, 0), + 'America/Montevideo' : new Date(2011, 9, 2, 3, 0, 0, 0), + 'America/Sao_Paulo' : new Date(2011, 9, 16, 5, 0, 0, 0), + 'America/Los_Angeles' : new Date(2011, 2, 13, 8, 0, 0, 0), + 'America/Santa_Isabel' : new Date(2011, 3, 5, 8, 0, 0, 0), + 'America/Havana' : new Date(2011, 2, 13, 2, 0, 0, 0), + 'America/New_York' : new Date(2011, 2, 13, 7, 0, 0, 0), + 'Asia/Gaza' : new Date(2011, 2, 26, 23, 0, 0, 0), + 'Asia/Beirut' : new Date(2011, 2, 27, 1, 0, 0, 0), + 'Europe/Minsk' : new Date(2011, 2, 27, 2, 0, 0, 0), + 'Europe/Helsinki' : new Date(2011, 2, 27, 4, 0, 0, 0), + 'Europe/Istanbul' : new Date(2011, 2, 28, 5, 0, 0, 0), + 'Asia/Damascus' : new Date(2011, 3, 1, 2, 0, 0, 0), + 'Asia/Jerusalem' : new Date(2011, 3, 1, 6, 0, 0, 0), + 'Africa/Cairo' : new Date(2010, 3, 30, 4, 0, 0, 0), + 'Asia/Yerevan' : new Date(2011, 2, 27, 4, 0, 0, 0), + 'Asia/Baku' : new Date(2011, 2, 27, 8, 0, 0, 0), + 'Pacific/Auckland' : new Date(2011, 8, 26, 7, 0, 0, 0), + 'Pacific/Fiji' : new Date(2010, 11, 29, 23, 0, 0, 0), + 'America/Halifax' : new Date(2011, 2, 13, 6, 0, 0, 0), + 'America/Goose_Bay' : new Date(2011, 2, 13, 2, 1, 0, 0), + 'America/Miquelon' : new Date(2011, 2, 13, 5, 0, 0, 0), + 'America/Godthab' : new Date(2011, 2, 27, 1, 0, 0, 0) +}; + +/** + * The keys in this object are timezones that we know may be ambiguous after + * a preliminary scan through the olson_tz object. + * + * The array of timezones to compare must be in the order that daylight savings + * starts for the regions. + */ +jstz.olson.ambiguity_list = { + 'America/Denver' : ['America/Denver', 'America/Mazatlan'], + 'America/Chicago' : ['America/Chicago', 'America/Mexico_City'], + 'America/Asuncion' : ['Atlantic/Stanley', 'America/Asuncion', 'America/Santiago', 'America/Campo_Grande'], + 'America/Montevideo' : ['America/Montevideo', 'America/Sao_Paulo'], + 'Asia/Beirut' : ['Asia/Gaza', 'Asia/Beirut', 'Europe/Minsk', 'Europe/Helsinki', 'Europe/Istanbul', 'Asia/Damascus', 'Asia/Jerusalem', 'Africa/Cairo'], + 'Asia/Yerevan' : ['Asia/Yerevan', 'Asia/Baku'], + 'Pacific/Auckland' : ['Pacific/Auckland', 'Pacific/Fiji'], + 'America/Los_Angeles' : ['America/Los_Angeles', 'America/Santa_Isabel'], + 'America/New_York' : ['America/Havana', 'America/New_York'], + 'America/Halifax' : ['America/Goose_Bay', 'America/Halifax'], + 'America/Godthab' : ['America/Miquelon', 'America/Godthab'] +}; diff --git a/media/js/jstz.min.js b/media/js/jstz.min.js new file mode 100644 index 0000000..4934e9f --- /dev/null +++ b/media/js/jstz.min.js @@ -0,0 +1,12 @@ +// from http://cdn.bitbucket.org/pellepim/jstimezonedetect/downloads/jstz.min.js +var jstz=function(){var b=function(a){a=-a.getTimezoneOffset();return null!==a?a:0},c=function(){return b(new Date(2010,0,1,0,0,0,0))},f=function(){return b(new Date(2010,5,1,0,0,0,0))},e=function(){var a=c(),d=f(),b=c()-f();return new jstz.TimeZone(jstz.olson.timezones[0>b?a+",1":0 + diff --git a/templates/model_item_edit.html b/templates/model_item_edit.html index af3c201..36a4559 100644 --- a/templates/model_item_edit.html +++ b/templates/model_item_edit.html @@ -111,9 +111,25 @@

Errors

{% block javascript %}