Commit 3a65ca02 by Kevin Kim Committed by GitHub

Merge pull request #13023 from edx/kkim/country_tz_api

Country Time Zone API
parents a35a6b73 127095b9
...@@ -93,3 +93,8 @@ class PreferenceUpdateError(PreferenceRequestError): ...@@ -93,3 +93,8 @@ class PreferenceUpdateError(PreferenceRequestError):
def __init__(self, developer_message, user_message=None): def __init__(self, developer_message, user_message=None):
self.developer_message = developer_message self.developer_message = developer_message
self.user_message = user_message self.user_message = user_message
class CountryCodeError(ValueError):
"""There was a problem with the country code"""
pass
...@@ -29,6 +29,10 @@ urlpatterns = patterns( ...@@ -29,6 +29,10 @@ urlpatterns = patterns(
user_api_views.UpdateEmailOptInPreference.as_view(), user_api_views.UpdateEmailOptInPreference.as_view(),
name="preferences_email_opt_in" name="preferences_email_opt_in"
), ),
url(
r'^v1/preferences/time_zones/$',
user_api_views.CountryTimeZoneListView.as_view(),
),
) )
if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'): if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
......
...@@ -7,22 +7,22 @@ from eventtracking import tracker ...@@ -7,22 +7,22 @@ from eventtracking import tracker
from django.conf import settings from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django_countries import countries
from django.db import IntegrityError from django.db import IntegrityError
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_noop from django.utils.translation import ugettext_noop
from pytz import common_timezones, common_timezones_set, country_timezones
from student.models import User, UserProfile from student.models import User, UserProfile
from request_cache import get_request_or_stub from request_cache import get_request_or_stub
from ..errors import ( from ..errors import (
UserAPIInternalError, UserAPIRequestError, UserNotFound, UserNotAuthorized, UserAPIInternalError, UserAPIRequestError, UserNotFound, UserNotAuthorized,
PreferenceValidationError, PreferenceUpdateError PreferenceValidationError, PreferenceUpdateError, CountryCodeError
) )
from ..helpers import intercept_errors from ..helpers import intercept_errors
from ..models import UserOrgTag, UserPreference from ..models import UserOrgTag, UserPreference
from ..serializers import UserSerializer, RawUserPreferenceSerializer from ..serializers import UserSerializer, RawUserPreferenceSerializer
from pytz import common_timezones_set
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -417,3 +417,21 @@ def _create_preference_update_error(preference_key, preference_value, error): ...@@ -417,3 +417,21 @@ def _create_preference_update_error(preference_key, preference_value, error):
key=preference_key, value=preference_value key=preference_key, value=preference_value
), ),
) )
def get_country_time_zones(country_code=None):
"""
Returns a list of time zones commonly used in given country
or list of all time zones, if country code is None.
Arguments:
country_code (str): ISO 3166-1 Alpha-2 country code
Raises:
CountryCodeError: the given country code is invalid
"""
if country_code is None:
return common_timezones
if country_code.upper() in set(countries.alt_codes):
return country_timezones(country_code)
raise CountryCodeError
...@@ -7,7 +7,7 @@ import ddt ...@@ -7,7 +7,7 @@ import ddt
import unittest import unittest
from mock import patch from mock import patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from pytz import UTC from pytz import common_timezones, utc
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
...@@ -21,11 +21,22 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase ...@@ -21,11 +21,22 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from ...accounts.api import create_account from ...accounts.api import create_account
from ...errors import UserNotFound, UserNotAuthorized, PreferenceValidationError, PreferenceUpdateError from ...errors import (
UserNotFound,
UserNotAuthorized,
PreferenceValidationError,
PreferenceUpdateError,
CountryCodeError,
)
from ...models import UserProfile, UserOrgTag from ...models import UserProfile, UserOrgTag
from ...preferences.api import ( from ...preferences.api import (
get_user_preference, get_user_preferences, set_user_preference, update_user_preferences, delete_user_preference, get_user_preference,
update_email_opt_in get_user_preferences,
set_user_preference,
update_user_preferences,
delete_user_preference,
update_email_opt_in,
get_country_time_zones,
) )
...@@ -407,7 +418,7 @@ class UpdateEmailOptInTests(ModuleStoreTestCase): ...@@ -407,7 +418,7 @@ class UpdateEmailOptInTests(ModuleStoreTestCase):
# Set year of birth # Set year of birth
user = User.objects.get(username=self.USERNAME) user = User.objects.get(username=self.USERNAME)
profile = UserProfile.objects.get(user=user) profile = UserProfile.objects.get(user=user)
year_of_birth = datetime.datetime.now(UTC).year - age year_of_birth = datetime.datetime.now(utc).year - age
profile.year_of_birth = year_of_birth profile.year_of_birth = year_of_birth
profile.save() profile.save()
...@@ -431,6 +442,26 @@ class UpdateEmailOptInTests(ModuleStoreTestCase): ...@@ -431,6 +442,26 @@ class UpdateEmailOptInTests(ModuleStoreTestCase):
return True return True
@ddt.ddt
class CountryTimeZoneTest(TestCase):
"""
Test cases to validate country code api functionality
"""
@ddt.data(('NZ', ['Pacific/Auckland', 'Pacific/Chatham']),
(None, common_timezones))
@ddt.unpack
def test_get_country_time_zones(self, country_code, expected_time_zones):
"""Verify that list of common country time zones are returned"""
country_time_zones = get_country_time_zones(country_code)
self.assertEqual(country_time_zones, expected_time_zones)
def test_country_code_errors(self):
"""Verify that country code error is raised for invalid country code"""
with self.assertRaises(CountryCodeError):
get_country_time_zones('AA')
def get_expected_validation_developer_message(preference_key, preference_value): def get_expected_validation_developer_message(preference_key, preference_value):
""" """
Returns the expected dict of validation messages for the specified key. Returns the expected dict of validation messages for the specified key.
......
...@@ -3,6 +3,8 @@ Django REST Framework serializers for the User API application ...@@ -3,6 +3,8 @@ Django REST Framework serializers for the User API application
""" """
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import serializers from rest_framework import serializers
from openedx.core.lib.time_zone_utils import get_display_time_zone
from student.models import UserProfile from student.models import UserProfile
from .models import UserPreference from .models import UserPreference
...@@ -81,3 +83,23 @@ class ReadOnlyFieldsSerializerMixin(object): ...@@ -81,3 +83,23 @@ class ReadOnlyFieldsSerializerMixin(object):
""" """
all_fields = getattr(cls.Meta, 'fields', tuple()) all_fields = getattr(cls.Meta, 'fields', tuple())
return tuple(set(all_fields) - set(cls.get_read_only_fields())) return tuple(set(all_fields) - set(cls.get_read_only_fields()))
class CountryTimeZoneSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Serializer that generates a list of common time zones for a country
"""
time_zone = serializers.SerializerMethodField()
description = serializers.SerializerMethodField()
def get_time_zone(self, time_zone_name):
"""
Returns inputted time zone name
"""
return time_zone_name
def get_description(self, time_zone_name):
"""
Returns the display version of time zone [e.g. US/Pacific (PST, UTC-0800)]
"""
return get_display_time_zone(time_zone_name)
...@@ -15,11 +15,12 @@ from django.test.client import RequestFactory ...@@ -15,11 +15,12 @@ from django.test.client import RequestFactory
from django.test.testcases import TransactionTestCase from django.test.testcases import TransactionTestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from pytz import UTC from pytz import common_timezones_set, UTC
from social.apps.django_app.default.models import UserSocialAuth from social.apps.django_app.default.models import UserSocialAuth
from django_comment_common import models from django_comment_common import models
from openedx.core.lib.api.test_utils import ApiTestCase, TEST_API_KEY from openedx.core.lib.api.test_utils import ApiTestCase, TEST_API_KEY
from openedx.core.lib.time_zone_utils import get_display_time_zone
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin
...@@ -1963,3 +1964,40 @@ class UpdateEmailOptInTestCase(UserAPITestCase, SharedModuleStoreTestCase): ...@@ -1963,3 +1964,40 @@ class UpdateEmailOptInTestCase(UserAPITestCase, SharedModuleStoreTestCase):
self.assertHttpBadRequest(response) self.assertHttpBadRequest(response)
with self.assertRaises(UserOrgTag.DoesNotExist): with self.assertRaises(UserOrgTag.DoesNotExist):
UserOrgTag.objects.get(user=self.user, org=self.course.id.org, key="email-optin") UserOrgTag.objects.get(user=self.user, org=self.course.id.org, key="email-optin")
@ddt.ddt
class CountryTimeZoneListViewTest(UserApiTestCase):
"""
Test cases covering the list viewing behavior for country time zones
"""
ALL_TIME_ZONES_URI = "/user_api/v1/preferences/time_zones/"
COUNTRY_TIME_ZONES_URI = "/user_api/v1/preferences/time_zones/?country_code=cA"
@ddt.data(ALL_TIME_ZONES_URI, COUNTRY_TIME_ZONES_URI)
def test_options(self, country_uri):
""" Verify that following options are allowed """
self.assertAllowedMethods(country_uri, ['OPTIONS', 'GET', 'HEAD'])
@ddt.data(ALL_TIME_ZONES_URI, COUNTRY_TIME_ZONES_URI)
def test_methods_not_allowed(self, country_uri):
""" Verify that put, patch, and delete are not allowed """
unallowed_methods = ['put', 'patch', 'delete']
for unallowed_method in unallowed_methods:
self.assertHttpMethodNotAllowed(self.request_with_auth(unallowed_method, country_uri))
def _assert_time_zone_is_valid(self, time_zone_info):
""" Asserts that the time zone is a valid pytz time zone """
time_zone_name = time_zone_info['time_zone']
self.assertIn(time_zone_name, common_timezones_set)
self.assertEqual(time_zone_info['description'], get_display_time_zone(time_zone_name))
@ddt.data((ALL_TIME_ZONES_URI, 432),
(COUNTRY_TIME_ZONES_URI, 27))
@ddt.unpack
def test_get_basic(self, country_uri, expected_count):
""" Verify that correct time zone info is returned """
results = self.get_json(country_uri)
self.assertEqual(len(results), expected_count)
for time_zone_info in results:
self._assert_time_zone_is_valid(time_zone_info)
...@@ -31,7 +31,7 @@ from student.cookies import set_logged_in_cookies ...@@ -31,7 +31,7 @@ from student.cookies import set_logged_in_cookies
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser from openedx.core.lib.api.authentication import SessionAuthenticationAllowInactiveUser
from util.json_request import JsonResponse from util.json_request import JsonResponse
from .preferences.api import update_email_opt_in from .preferences.api import get_country_time_zones, update_email_opt_in
from .helpers import FormDescription, shim_student_view, require_post_params from .helpers import FormDescription, shim_student_view, require_post_params
from .models import UserPreference, UserProfile from .models import UserPreference, UserProfile
from .accounts import ( from .accounts import (
...@@ -39,7 +39,7 @@ from .accounts import ( ...@@ -39,7 +39,7 @@ from .accounts import (
USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH
) )
from .accounts.api import check_account_exists from .accounts.api import check_account_exists
from .serializers import UserSerializer, UserPreferenceSerializer from .serializers import CountryTimeZoneSerializer, UserSerializer, UserPreferenceSerializer
class LoginSessionView(APIView): class LoginSessionView(APIView):
...@@ -1036,3 +1036,37 @@ class UpdateEmailOptInPreference(APIView): ...@@ -1036,3 +1036,37 @@ class UpdateEmailOptInPreference(APIView):
email_opt_in = request.data['email_opt_in'].lower() == 'true' email_opt_in = request.data['email_opt_in'].lower() == 'true'
update_email_opt_in(request.user, org, email_opt_in) update_email_opt_in(request.user, org, email_opt_in)
return HttpResponse(status=status.HTTP_200_OK) return HttpResponse(status=status.HTTP_200_OK)
class CountryTimeZoneListView(generics.ListAPIView):
"""
**Use Cases**
Retrieves a list of all time zones, by default, or common time zones for country, if given
The country is passed in as its ISO 3166-1 Alpha-2 country code as an
optional 'country_code' argument. The country code is also case-insensitive.
**Example Requests**
GET /user_api/v1/preferences/time_zones/
GET /user_api/v1/preferences/time_zones/?country_code=FR
**Example GET Response**
If the request is successful, an HTTP 200 "OK" response is returned along with a
list of time zone dictionaries for all time zones or just for time zones commonly
used in a country, if given.
Each time zone dictionary contains the following values.
* time_zone: The name of the time zone.
* description: The display version of the time zone
"""
serializer_class = CountryTimeZoneSerializer
paginator = None
def get_queryset(self):
country_code = self.request.GET.get('country_code', None)
return get_country_time_zones(country_code)
...@@ -3,7 +3,10 @@ from freezegun import freeze_time ...@@ -3,7 +3,10 @@ from freezegun import freeze_time
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from openedx.core.djangoapps.user_api.preferences.api import set_user_preference
from openedx.core.lib.time_zone_utils import ( from openedx.core.lib.time_zone_utils import (
get_formatted_time_zone, get_time_zone_abbr, get_time_zone_offset, get_user_time_zone get_display_time_zone,
get_time_zone_abbr,
get_time_zone_offset,
get_user_time_zone,
) )
from pytz import timezone, utc from pytz import timezone, utc
from unittest import TestCase from unittest import TestCase
...@@ -36,59 +39,59 @@ class TestTimeZoneUtils(TestCase): ...@@ -36,59 +39,59 @@ class TestTimeZoneUtils(TestCase):
user_tz = get_user_time_zone(self.user) user_tz = get_user_time_zone(self.user)
self.assertEqual(user_tz, timezone('Asia/Tokyo')) self.assertEqual(user_tz, timezone('Asia/Tokyo'))
def _formatted_time_zone_helper(self, time_zone_string): def _display_time_zone_helper(self, time_zone_string):
""" """
Helper function to return all info from get_formatted_time_zone() Helper function to return all info from get_display_time_zone()
""" """
tz_str = get_display_time_zone(time_zone_string)
time_zone = timezone(time_zone_string) time_zone = timezone(time_zone_string)
tz_str = get_formatted_time_zone(time_zone)
tz_abbr = get_time_zone_abbr(time_zone) tz_abbr = get_time_zone_abbr(time_zone)
tz_offset = get_time_zone_offset(time_zone) tz_offset = get_time_zone_offset(time_zone)
return {'str': tz_str, 'abbr': tz_abbr, 'offset': tz_offset} return {'str': tz_str, 'abbr': tz_abbr, 'offset': tz_offset}
def _assert_time_zone_info_equal(self, formatted_tz_info, expected_name, expected_abbr, expected_offset): def _assert_time_zone_info_equal(self, display_tz_info, expected_name, expected_abbr, expected_offset):
""" """
Asserts that all formatted_tz_info is equal to the expected inputs Asserts that all display_tz_info is equal to the expected inputs
""" """
self.assertEqual(formatted_tz_info['str'], '{name} ({abbr}, UTC{offset})'.format(name=expected_name, self.assertEqual(display_tz_info['str'], '{name} ({abbr}, UTC{offset})'.format(name=expected_name,
abbr=expected_abbr, abbr=expected_abbr,
offset=expected_offset)) offset=expected_offset))
self.assertEqual(formatted_tz_info['abbr'], expected_abbr) self.assertEqual(display_tz_info['abbr'], expected_abbr)
self.assertEqual(formatted_tz_info['offset'], expected_offset) self.assertEqual(display_tz_info['offset'], expected_offset)
@freeze_time("2015-02-09") @freeze_time("2015-02-09")
def test_formatted_time_zone_without_dst(self): def test_display_time_zone_without_dst(self):
""" """
Test to ensure get_formatted_time_zone() returns full formatted string when no kwargs specified Test to ensure get_display_time_zone() returns full display string when no kwargs specified
and returns just abbreviation or offset when specified and returns just abbreviation or offset when specified
""" """
tz_info = self._formatted_time_zone_helper('America/Los_Angeles') tz_info = self._display_time_zone_helper('America/Los_Angeles')
self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PST', '-0800') self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PST', '-0800')
@freeze_time("2015-04-02") @freeze_time("2015-04-02")
def test_formatted_time_zone_with_dst(self): def test_display_time_zone_with_dst(self):
""" """
Test to ensure get_formatted_time_zone() returns modified abbreviations and Test to ensure get_display_time_zone() returns modified abbreviations and
offsets during daylight savings time. offsets during daylight savings time.
""" """
tz_info = self._formatted_time_zone_helper('America/Los_Angeles') tz_info = self._display_time_zone_helper('America/Los_Angeles')
self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PDT', '-0700') self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PDT', '-0700')
@freeze_time("2015-11-01 08:59:00") @freeze_time("2015-11-01 08:59:00")
def test_formatted_time_zone_ambiguous_before(self): def test_display_time_zone_ambiguous_before(self):
""" """
Test to ensure get_formatted_time_zone() returns correct abbreviations and offsets Test to ensure get_display_time_zone() returns correct abbreviations and offsets
during ambiguous time periods (e.g. when DST is about to start/end) before the change during ambiguous time periods (e.g. when DST is about to start/end) before the change
""" """
tz_info = self._formatted_time_zone_helper('America/Los_Angeles') tz_info = self._display_time_zone_helper('America/Los_Angeles')
self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PDT', '-0700') self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PDT', '-0700')
@freeze_time("2015-11-01 09:00:00") @freeze_time("2015-11-01 09:00:00")
def test_formatted_time_zone_ambiguous_after(self): def test_display_time_zone_ambiguous_after(self):
""" """
Test to ensure get_formatted_time_zone() returns correct abbreviations and offsets Test to ensure get_display_time_zone() returns correct abbreviations and offsets
during ambiguous time periods (e.g. when DST is about to start/end) after the change during ambiguous time periods (e.g. when DST is about to start/end) after the change
""" """
tz_info = self._formatted_time_zone_helper('America/Los_Angeles') tz_info = self._display_time_zone_helper('America/Los_Angeles')
self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PST', '-0800') self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PST', '-0800')
...@@ -43,12 +43,13 @@ def get_time_zone_offset(time_zone, date_time=None): ...@@ -43,12 +43,13 @@ def get_time_zone_offset(time_zone, date_time=None):
return _format_time_zone_string(time_zone, date_time, '%z') return _format_time_zone_string(time_zone, date_time, '%z')
def get_formatted_time_zone(time_zone): def get_display_time_zone(time_zone_name):
""" """
Returns a formatted time zone (e.g. 'Asia/Tokyo (JST, UTC+0900)') Returns a formatted display time zone (e.g. 'Asia/Tokyo (JST, UTC+0900)')
:param time_zone: Pytz time zone object :param time_zone_name (str): Name of Pytz time zone
""" """
time_zone = timezone(time_zone_name)
tz_abbr = get_time_zone_abbr(time_zone) tz_abbr = get_time_zone_abbr(time_zone)
tz_offset = get_time_zone_offset(time_zone) tz_offset = get_time_zone_offset(time_zone)
...@@ -56,6 +57,6 @@ def get_formatted_time_zone(time_zone): ...@@ -56,6 +57,6 @@ def get_formatted_time_zone(time_zone):
TIME_ZONE_CHOICES = sorted( TIME_ZONE_CHOICES = sorted(
[(tz, get_formatted_time_zone(timezone(tz))) for tz in common_timezones], [(tz, get_display_time_zone(tz)) for tz in common_timezones],
key=lambda tz_tuple: tz_tuple[1] key=lambda tz_tuple: tz_tuple[1]
) )
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment