From 127095b94acb7b315f0ef4a142e5afcea08013ed Mon Sep 17 00:00:00 2001 From: Kevin Kim Date: Mon, 18 Jul 2016 19:08:55 +0000 Subject: [PATCH] Add endpoint for commonly used country time zones --- openedx/core/djangoapps/user_api/errors.py | 5 ++ .../core/djangoapps/user_api/legacy_urls.py | 4 ++ .../djangoapps/user_api/preferences/api.py | 24 +++++++-- .../user_api/preferences/tests/test_api.py | 41 ++++++++++++++-- .../core/djangoapps/user_api/serializers.py | 22 +++++++++ .../djangoapps/user_api/tests/test_views.py | 40 ++++++++++++++- openedx/core/djangoapps/user_api/views.py | 38 +++++++++++++- .../core/lib/tests/test_time_zone_utils.py | 49 ++++++++++--------- openedx/core/lib/time_zone_utils.py | 9 ++-- 9 files changed, 194 insertions(+), 38 deletions(-) diff --git a/openedx/core/djangoapps/user_api/errors.py b/openedx/core/djangoapps/user_api/errors.py index ea104f01e6..9435529759 100644 --- a/openedx/core/djangoapps/user_api/errors.py +++ b/openedx/core/djangoapps/user_api/errors.py @@ -93,3 +93,8 @@ class PreferenceUpdateError(PreferenceRequestError): def __init__(self, developer_message, user_message=None): self.developer_message = developer_message self.user_message = user_message + + +class CountryCodeError(ValueError): + """There was a problem with the country code""" + pass diff --git a/openedx/core/djangoapps/user_api/legacy_urls.py b/openedx/core/djangoapps/user_api/legacy_urls.py index 3bfb1af025..60fd730858 100644 --- a/openedx/core/djangoapps/user_api/legacy_urls.py +++ b/openedx/core/djangoapps/user_api/legacy_urls.py @@ -29,6 +29,10 @@ urlpatterns = patterns( user_api_views.UpdateEmailOptInPreference.as_view(), 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'): diff --git a/openedx/core/djangoapps/user_api/preferences/api.py b/openedx/core/djangoapps/user_api/preferences/api.py index a3184ddf8b..b3d703ec12 100644 --- a/openedx/core/djangoapps/user_api/preferences/api.py +++ b/openedx/core/djangoapps/user_api/preferences/api.py @@ -7,22 +7,22 @@ from eventtracking import tracker from django.conf import settings from django.core.exceptions import ObjectDoesNotExist +from django_countries import countries from django.db import IntegrityError from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_noop +from pytz import common_timezones, common_timezones_set, country_timezones from student.models import User, UserProfile from request_cache import get_request_or_stub from ..errors import ( UserAPIInternalError, UserAPIRequestError, UserNotFound, UserNotAuthorized, - PreferenceValidationError, PreferenceUpdateError + PreferenceValidationError, PreferenceUpdateError, CountryCodeError ) from ..helpers import intercept_errors from ..models import UserOrgTag, UserPreference from ..serializers import UserSerializer, RawUserPreferenceSerializer -from pytz import common_timezones_set - log = logging.getLogger(__name__) @@ -417,3 +417,21 @@ def _create_preference_update_error(preference_key, preference_value, error): 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 diff --git a/openedx/core/djangoapps/user_api/preferences/tests/test_api.py b/openedx/core/djangoapps/user_api/preferences/tests/test_api.py index 682d9b0828..54417642cb 100644 --- a/openedx/core/djangoapps/user_api/preferences/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/preferences/tests/test_api.py @@ -7,7 +7,7 @@ import ddt import unittest from mock import patch from nose.plugins.attrib import attr -from pytz import UTC +from pytz import common_timezones, utc from django.conf import settings from django.contrib.auth.models import User @@ -21,11 +21,22 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory 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 ...preferences.api import ( - get_user_preference, get_user_preferences, set_user_preference, update_user_preferences, delete_user_preference, - update_email_opt_in + get_user_preference, + 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): # Set year of birth user = User.objects.get(username=self.USERNAME) 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.save() @@ -431,6 +442,26 @@ class UpdateEmailOptInTests(ModuleStoreTestCase): 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): """ Returns the expected dict of validation messages for the specified key. diff --git a/openedx/core/djangoapps/user_api/serializers.py b/openedx/core/djangoapps/user_api/serializers.py index b7a71f0eb5..3453c2dcb5 100644 --- a/openedx/core/djangoapps/user_api/serializers.py +++ b/openedx/core/djangoapps/user_api/serializers.py @@ -3,6 +3,8 @@ Django REST Framework serializers for the User API application """ from django.contrib.auth.models import User from rest_framework import serializers + +from openedx.core.lib.time_zone_utils import get_display_time_zone from student.models import UserProfile from .models import UserPreference @@ -81,3 +83,23 @@ class ReadOnlyFieldsSerializerMixin(object): """ all_fields = getattr(cls.Meta, 'fields', tuple()) 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) diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index d4208d5c06..26b4a4d1e3 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -15,11 +15,12 @@ from django.test.client import RequestFactory from django.test.testcases import TransactionTestCase from django.test.utils import override_settings 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 django_comment_common import models 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 student.tests.factories import UserFactory from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin @@ -1963,3 +1964,40 @@ class UpdateEmailOptInTestCase(UserAPITestCase, SharedModuleStoreTestCase): self.assertHttpBadRequest(response) with self.assertRaises(UserOrgTag.DoesNotExist): 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) diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 506ee3d9b4..1b2db66d7f 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -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.lib.api.authentication import SessionAuthenticationAllowInactiveUser 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 .models import UserPreference, UserProfile from .accounts import ( @@ -39,7 +39,7 @@ from .accounts import ( USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH ) from .accounts.api import check_account_exists -from .serializers import UserSerializer, UserPreferenceSerializer +from .serializers import CountryTimeZoneSerializer, UserSerializer, UserPreferenceSerializer class LoginSessionView(APIView): @@ -1036,3 +1036,37 @@ class UpdateEmailOptInPreference(APIView): email_opt_in = request.data['email_opt_in'].lower() == 'true' update_email_opt_in(request.user, org, email_opt_in) 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) diff --git a/openedx/core/lib/tests/test_time_zone_utils.py b/openedx/core/lib/tests/test_time_zone_utils.py index 3133841612..f1ad4be79a 100644 --- a/openedx/core/lib/tests/test_time_zone_utils.py +++ b/openedx/core/lib/tests/test_time_zone_utils.py @@ -3,7 +3,10 @@ from freezegun import freeze_time from student.tests.factories import UserFactory from openedx.core.djangoapps.user_api.preferences.api import set_user_preference 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 unittest import TestCase @@ -36,59 +39,59 @@ class TestTimeZoneUtils(TestCase): user_tz = get_user_time_zone(self.user) 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) - tz_str = get_formatted_time_zone(time_zone) tz_abbr = get_time_zone_abbr(time_zone) tz_offset = get_time_zone_offset(time_zone) 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, - abbr=expected_abbr, - offset=expected_offset)) - self.assertEqual(formatted_tz_info['abbr'], expected_abbr) - self.assertEqual(formatted_tz_info['offset'], expected_offset) + self.assertEqual(display_tz_info['str'], '{name} ({abbr}, UTC{offset})'.format(name=expected_name, + abbr=expected_abbr, + offset=expected_offset)) + self.assertEqual(display_tz_info['abbr'], expected_abbr) + self.assertEqual(display_tz_info['offset'], expected_offset) @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 """ - 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') @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. """ - 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') @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 """ - 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') @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 """ - 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') diff --git a/openedx/core/lib/time_zone_utils.py b/openedx/core/lib/time_zone_utils.py index b9637b41f3..442d5f7d60 100644 --- a/openedx/core/lib/time_zone_utils.py +++ b/openedx/core/lib/time_zone_utils.py @@ -43,12 +43,13 @@ def get_time_zone_offset(time_zone, date_time=None): 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_offset = get_time_zone_offset(time_zone) @@ -56,6 +57,6 @@ def get_formatted_time_zone(time_zone): 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] )