From a02ba98f17d4bcaf6305e83bb1e48d018d328bfe Mon Sep 17 00:00:00 2001 From: ttak-apphelix Date: Mon, 25 Aug 2025 09:25:36 +0000 Subject: [PATCH 01/14] chore!: replace pytz with zoneinfo --- .../djangoapps/bookmarks/tests/test_models.py | 4 +- openedx/core/djangoapps/catalog/utils.py | 4 +- .../core/djangoapps/ccxcon/tests/test_api.py | 6 +-- .../content/course_overviews/models.py | 8 ++-- .../tests/test_course_overviews.py | 4 +- .../course_overviews/tests/test_signals.py | 4 +- .../management/commands/notify_credentials.py | 4 +- .../core/djangoapps/credit/api/provider.py | 4 +- openedx/core/djangoapps/credit/models.py | 8 ++-- openedx/core/djangoapps/credit/serializers.py | 4 +- .../core/djangoapps/credit/tests/factories.py | 4 +- .../core/djangoapps/credit/tests/test_api.py | 6 +-- .../djangoapps/credit/tests/test_signals.py | 6 +-- .../djangoapps/credit/tests/test_views.py | 8 ++-- openedx/core/djangoapps/credit/views.py | 4 +- .../djangoapps/enrollments/tests/test_data.py | 4 +- .../enrollments/tests/test_views.py | 20 ++++----- .../models/tests/test_course_details.py | 10 ++--- .../notifications/email/tests/test_utils.py | 4 +- .../djangoapps/notifications/email/utils.py | 6 +-- .../notifications/grouping_notifications.py | 6 +-- .../core/djangoapps/notifications/tasks.py | 4 +- .../tests/test_notification_grouping.py | 8 ++-- .../notifications/tests/test_views.py | 8 ++-- .../core/djangoapps/notifications/views.py | 6 +-- .../dot_overrides/validators.py | 6 +-- .../core/djangoapps/oauth_dispatch/models.py | 4 +- .../oauth_dispatch/tests/factories.py | 4 +- .../djangoapps/password_policy/compliance.py | 4 +- .../password_policy/tests/test_compliance.py | 6 +-- .../profile_images/tests/test_views.py | 6 +-- .../core/djangoapps/profile_images/views.py | 4 +- .../djangoapps/programs/tests/test_tasks.py | 10 ++--- .../djangoapps/programs/tests/test_utils.py | 24 +++++------ openedx/core/djangoapps/programs/utils.py | 15 +++---- .../schedules/management/commands/__init__.py | 4 +- .../send_course_next_section_update.py | 4 +- .../setup_models_to_send_test_emails.py | 12 +++--- .../commands/tests/send_email_base.py | 12 +++--- .../tests/test_send_email_base_command.py | 4 +- .../djangoapps/schedules/tests/factories.py | 6 +-- .../schedules/tests/test_resolvers.py | 4 +- .../schedules/tests/test_signals.py | 4 +- .../djangoapps/schedules/tests/test_utils.py | 4 +- openedx/core/djangoapps/schedules/utils.py | 4 +- .../core/djangoapps/user_api/accounts/api.py | 4 +- .../accounts/tests/retirement_helpers.py | 4 +- .../user_api/accounts/tests/test_api.py | 4 +- .../accounts/tests/test_image_helpers.py | 4 +- .../accounts/tests/test_retirement_views.py | 10 ++--- .../user_api/accounts/tests/test_views.py | 12 +++--- .../djangoapps/user_api/accounts/views.py | 21 ++++++---- .../commands/create_user_gdpr_testing.py | 4 +- .../user_api/preferences/tests/test_api.py | 28 +++++++------ .../core/djangoapps/user_authn/tests/utils.py | 4 +- .../djangoapps/user_authn/views/register.py | 10 ++--- .../user_authn/views/tests/test_password.py | 4 +- .../user_authn/views/tests/test_register.py | 4 +- .../views/tests/test_reset_password.py | 4 +- openedx/core/djangoapps/util/testing.py | 4 +- .../tests/test_partition_scheme.py | 8 ++-- .../djangoapps/xblock/tests/test_utils.py | 2 +- openedx/core/lib/__init__.py | 13 ++++++ .../core/lib/tests/test_time_zone_utils.py | 7 ++-- openedx/core/lib/time_zone_utils.py | 32 +++++++++++++-- openedx/core/lib/xblock_utils/__init__.py | 4 +- openedx/features/calendar_sync/ics.py | 4 +- .../features/calendar_sync/tests/test_ics.py | 6 +-- .../content_type_gating/partitions.py | 4 +- .../content_type_gating/tests/test_models.py | 8 ++-- .../tests/test_access.py | 12 ++++-- .../tests/test_models.py | 41 +++++++++++-------- .../tests/views/test_course_updates.py | 6 ++- openedx/features/discounts/applicability.py | 4 +- .../discounts/tests/test_applicability.py | 6 +-- openedx/features/discounts/utils.py | 4 +- .../completion_integration/test_handlers.py | 8 ++-- .../xblock_integration/xblock_testcase.py | 4 +- 78 files changed, 322 insertions(+), 267 deletions(-) diff --git a/openedx/core/djangoapps/bookmarks/tests/test_models.py b/openedx/core/djangoapps/bookmarks/tests/test_models.py index 2c6877218acc..b0167036844c 100644 --- a/openedx/core/djangoapps/bookmarks/tests/test_models.py +++ b/openedx/core/djangoapps/bookmarks/tests/test_models.py @@ -8,7 +8,7 @@ from unittest import mock import ddt -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from freezegun import freeze_time from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator @@ -352,7 +352,7 @@ def test_path(self, seconds_delta, paths, get_path_call_count, mock_get_path): bookmark, __ = Bookmark.create(bookmark_data) assert bookmark.xblock_cache is not None - modification_datetime = datetime.datetime.now(pytz.utc) + datetime.timedelta(seconds=seconds_delta) + modification_datetime = datetime.datetime.now(get_utc_timezone()) + datetime.timedelta(seconds=seconds_delta) with freeze_time(modification_datetime): bookmark.xblock_cache.paths = paths bookmark.xblock_cache.save() diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py index 3a241a5c5137..8a81f2a5dfe2 100644 --- a/openedx/core/djangoapps/catalog/utils.py +++ b/openedx/core/djangoapps/catalog/utils.py @@ -13,7 +13,7 @@ from edx_rest_api_client.auth import SuppliedJwtAuth from edx_rest_api_client.client import USER_AGENT from opaque_keys.edx.keys import CourseKey -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from common.djangoapps.entitlements.utils import is_course_run_entitlement_fulfillable from common.djangoapps.student.models import CourseEnrollment @@ -593,7 +593,7 @@ def get_fulfillable_course_runs_for_entitlement(entitlement, course_runs): enrollable_sessions = [] # Only retrieve list of published course runs that can still be enrolled and upgraded - search_time = datetime.datetime.now(UTC) + search_time = datetime.datetime.now(get_utc_timezone()) for course_run in course_runs: course_id = CourseKey.from_string(course_run.get("key")) (user_enrollment_mode, is_active) = CourseEnrollment.enrollment_mode_for_user( diff --git a/openedx/core/djangoapps/ccxcon/tests/test_api.py b/openedx/core/djangoapps/ccxcon/tests/test_api.py index 762dba70e8e9..ebb933f2ff02 100644 --- a/openedx/core/djangoapps/ccxcon/tests/test_api.py +++ b/openedx/core/djangoapps/ccxcon/tests/test_api.py @@ -6,7 +6,7 @@ from urllib import parse import pytest -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from opaque_keys.edx.keys import CourseKey from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase @@ -44,10 +44,10 @@ def setUpClass(cls): # Create a course outline start = datetime.datetime( - 2010, 5, 12, 2, 42, tzinfo=pytz.UTC + 2010, 5, 12, 2, 42, tzinfo=get_utc_timezone() ) due = datetime.datetime( - 2010, 7, 7, 0, 0, tzinfo=pytz.UTC + 2010, 7, 7, 0, 0, tzinfo=get_utc_timezone() ) cls.chapters = [ diff --git a/openedx/core/djangoapps/content/course_overviews/models.py b/openedx/core/djangoapps/content/course_overviews/models.py index 10a56f0868fb..f3d32a800d1d 100644 --- a/openedx/core/djangoapps/content/course_overviews/models.py +++ b/openedx/core/djangoapps/content/course_overviews/models.py @@ -8,7 +8,7 @@ from datetime import datetime from urllib.parse import urlparse, urlunparse -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from ccx_keys.locator import CCXLocator from config_models.models import ConfigurationModel from django.conf import settings @@ -695,7 +695,7 @@ def get_all_courses(cls, orgs=None, filter_=None, active_only=False, course_keys course_overviews = course_overviews.filter(**filter_) if active_only: course_overviews = course_overviews.filter( - Q(end__isnull=True) | Q(end__gte=datetime.now().replace(tzinfo=pytz.UTC)) + Q(end__isnull=True) | Q(end__gte=datetime.now().replace(tzinfo=get_utc_timezone())) ) return course_overviews @@ -727,11 +727,11 @@ def get_courses_by_status(cls, active_only, archived_only, course_overviews): """ if active_only: return course_overviews.filter( - Q(end__isnull=True) | Q(end__gte=datetime.now().replace(tzinfo=pytz.UTC)) + Q(end__isnull=True) | Q(end__gte=datetime.now().replace(tzinfo=get_utc_timezone())) ) if archived_only: return course_overviews.filter( - end__lt=datetime.now().replace(tzinfo=pytz.UTC) + end__lt=datetime.now().replace(tzinfo=get_utc_timezone()) ) return course_overviews diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py b/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py index 8e95c44825c5..cd29ac33bbb4 100644 --- a/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_course_overviews.py @@ -11,7 +11,7 @@ import itertools # lint-amnesty, pylint: disable=wrong-import-order import math # lint-amnesty, pylint: disable=wrong-import-order import ddt -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from django.db.utils import IntegrityError from django.test.utils import override_settings @@ -93,7 +93,7 @@ def get_seconds_since_epoch(date_time): """ if date_time is None: return None - epoch = datetime.datetime.utcfromtimestamp(0).replace(tzinfo=pytz.utc) + epoch = datetime.datetime.fromtimestamp(0, tz=get_utc_timezone()) return math.floor((date_time - epoch).total_seconds()) # Load the CourseOverview from the cache twice. The first load will be a cache miss (because the cache diff --git a/openedx/core/djangoapps/content/course_overviews/tests/test_signals.py b/openedx/core/djangoapps/content/course_overviews/tests/test_signals.py index 49adf5540003..a49ed655703d 100644 --- a/openedx/core/djangoapps/content/course_overviews/tests/test_signals.py +++ b/openedx/core/djangoapps/content/course_overviews/tests/test_signals.py @@ -9,7 +9,7 @@ import pytest import ddt -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from xmodule.data import CertificatesDisplayBehaviors from xmodule.modulestore import ModuleStoreEnum @@ -29,7 +29,7 @@ class CourseOverviewSignalsTestCase(ModuleStoreTestCase): """ MODULESTORE = TEST_DATA_ONLY_SPLIT_MODULESTORE_DRAFT_PREFERRED ENABLED_SIGNALS = ['course_deleted', 'course_published'] - TODAY = datetime.datetime.utcnow().replace(tzinfo=UTC) + TODAY = datetime.datetime.utcnow().replace(tzinfo=get_utc_timezone()) NEXT_WEEK = TODAY + datetime.timedelta(days=7) def assert_changed_signal_sent(self, changes, mock_signal): diff --git a/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py b/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py index 7d28ca5197c1..80879d3fa1be 100644 --- a/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py +++ b/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py @@ -17,7 +17,7 @@ from django.core.management.base import BaseCommand, CommandError from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from openedx.core.djangoapps.credentials.models import NotifyCredentialsConfig from openedx.core.djangoapps.credentials.tasks.v1.tasks import handle_notify_credentials @@ -32,7 +32,7 @@ def parsetime(timestr): dt = dateutil.parser.parse(timestr) if dt.tzinfo is None: - dt = dt.replace(tzinfo=UTC) + dt = dt.replace(tzinfo=get_utc_timezone()) return dt diff --git a/openedx/core/djangoapps/credit/api/provider.py b/openedx/core/djangoapps/credit/api/provider.py index 9875a7d0f515..bcbd767ae1ae 100644 --- a/openedx/core/djangoapps/credit/api/provider.py +++ b/openedx/core/djangoapps/credit/api/provider.py @@ -7,7 +7,7 @@ import logging import uuid -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.db import transaction from edx_proctoring.api import get_last_exam_completion_date @@ -296,7 +296,7 @@ def create_credit_request(course_key, provider_id, username): parameters = { "request_uuid": credit_request.uuid, - "timestamp": to_timestamp(datetime.datetime.now(pytz.UTC)), + "timestamp": to_timestamp(datetime.datetime.now(get_utc_timezone())), "course_org": course_key.org, "course_num": course_key.course, "course_run": course_key.run, diff --git a/openedx/core/djangoapps/credit/models.py b/openedx/core/djangoapps/credit/models.py index 9c14a15104b9..b7635ff7c57c 100644 --- a/openedx/core/djangoapps/credit/models.py +++ b/openedx/core/djangoapps/credit/models.py @@ -10,7 +10,7 @@ import logging from collections import defaultdict -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from config_models.models import ConfigurationModel from django.conf import settings from django.core.cache import cache @@ -536,7 +536,7 @@ def default_deadline_for_credit_eligibility(): """ The default deadline to use when creating a new CreditEligibility model. """ - return datetime.datetime.now(pytz.UTC) + datetime.timedelta( + return datetime.datetime.now(get_utc_timezone()) + datetime.timedelta( days=getattr(settings, "CREDIT_ELIGIBILITY_EXPIRATION_DAYS", 365) ) @@ -617,7 +617,7 @@ def get_user_eligibilities(cls, username): return cls.objects.filter( username=username, course__enabled=True, - deadline__gt=datetime.datetime.now(pytz.UTC) + deadline__gt=datetime.datetime.now(get_utc_timezone()) ).select_related('course') @classmethod @@ -636,7 +636,7 @@ def is_user_eligible_for_credit(cls, course_key, username): course__course_key=course_key, course__enabled=True, username=username, - deadline__gt=datetime.datetime.now(pytz.UTC), + deadline__gt=datetime.datetime.now(get_utc_timezone()), ).exists() def __str__(self): diff --git a/openedx/core/djangoapps/credit/serializers.py b/openedx/core/djangoapps/credit/serializers.py index 85e8fed44e57..f7b4dbdb13b0 100644 --- a/openedx/core/djangoapps/credit/serializers.py +++ b/openedx/core/djangoapps/credit/serializers.py @@ -4,7 +4,7 @@ import datetime import logging -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from rest_framework import serializers from rest_framework.exceptions import PermissionDenied @@ -78,7 +78,7 @@ def validate_timestamp(self, value): log.warning(msg) raise serializers.ValidationError(msg) - elapsed = (datetime.datetime.now(pytz.UTC) - date_time).total_seconds() + elapsed = (datetime.datetime.now(get_utc_timezone()) - date_time).total_seconds() if elapsed > settings.CREDIT_PROVIDER_TIMESTAMP_EXPIRATION: msg = f'[{value}] is too far in the past (over [{elapsed}] seconds).' log.warning(msg) diff --git a/openedx/core/djangoapps/credit/tests/factories.py b/openedx/core/djangoapps/credit/tests/factories.py index cd777bdfe93b..4ee2e94be889 100644 --- a/openedx/core/djangoapps/credit/tests/factories.py +++ b/openedx/core/djangoapps/credit/tests/factories.py @@ -7,7 +7,7 @@ import factory from factory.fuzzy import FuzzyText -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from openedx.core.djangoapps.credit.models import ( @@ -80,7 +80,7 @@ def post(obj, create, extracted, **kwargs): obj.parameters = json.dumps({ "request_uuid": obj.uuid, - "timestamp": to_timestamp(datetime.datetime.now(pytz.UTC)), + "timestamp": to_timestamp(datetime.datetime.now(get_utc_timezone())), "course_org": course_key.org, "course_num": course_key.course, "course_run": course_key.run, diff --git a/openedx/core/djangoapps/credit/tests/test_api.py b/openedx/core/djangoapps/credit/tests/test_api.py index 7dc644dc097a..508d826c2423 100644 --- a/openedx/core/djangoapps/credit/tests/test_api.py +++ b/openedx/core/djangoapps/credit/tests/test_api.py @@ -9,7 +9,7 @@ import pytest import ddt import httpretty -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core import mail from django.db import connection @@ -400,7 +400,7 @@ def test_eligibility_expired(self): CreditEligibility.objects.create( course=credit_course, username="staff", - deadline=datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=1) + deadline=datetime.datetime.now(get_utc_timezone()) - datetime.timedelta(days=1) ) # The user should NOT be eligible for credit @@ -960,7 +960,7 @@ def test_credit_request(self): # Validate the timestamp assert 'timestamp' in parameters parsed_date = from_timestamp(parameters['timestamp']) - assert parsed_date < datetime.datetime.now(pytz.UTC) + assert parsed_date < datetime.datetime.now(get_utc_timezone()) # Validate course information assert parameters['course_org'] == self.course_key.org diff --git a/openedx/core/djangoapps/credit/tests/test_signals.py b/openedx/core/djangoapps/credit/tests/test_signals.py index c3331c0ecfc6..692beefab918 100644 --- a/openedx/core/djangoapps/credit/tests/test_signals.py +++ b/openedx/core/djangoapps/credit/tests/test_signals.py @@ -7,7 +7,7 @@ from uuid import uuid4 import ddt -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.test.client import RequestFactory from opaque_keys.edx.keys import UsageKey from openedx_events.data import EventsMetadata @@ -47,8 +47,8 @@ class TestMinGradedRequirementStatus(ModuleStoreTestCase): satisfied. But if student grade is less than and deadline is passed then user will be marked as failed. """ - VALID_DUE_DATE = datetime.now(pytz.UTC) + timedelta(days=20) - EXPIRED_DUE_DATE = datetime.now(pytz.UTC) - timedelta(days=20) + VALID_DUE_DATE = datetime.now(get_utc_timezone()) + timedelta(days=20) + EXPIRED_DUE_DATE = datetime.now(get_utc_timezone()) - timedelta(days=20) DATES = { 'valid': VALID_DUE_DATE, diff --git a/openedx/core/djangoapps/credit/tests/test_views.py b/openedx/core/djangoapps/credit/tests/test_views.py index deb3c8726aeb..e880a6311de4 100644 --- a/openedx/core/djangoapps/credit/tests/test_views.py +++ b/openedx/core/djangoapps/credit/tests/test_views.py @@ -7,7 +7,7 @@ import json import ddt -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from django.test import Client, TestCase from django.test.utils import override_settings @@ -523,7 +523,7 @@ def _credit_provider_callback(self, request_uuid, status, **kwargs): """ provider_id = kwargs.get('provider_id', self.provider.provider_id) secret_key = kwargs.get('secret_key', '931433d583c84ca7ba41784bad3232e6') - timestamp = kwargs.get('timestamp', to_timestamp(datetime.datetime.now(pytz.UTC))) + timestamp = kwargs.get('timestamp', to_timestamp(datetime.datetime.now(get_utc_timezone()))) keys = kwargs.get('keys', {self.provider.provider_id: secret_key}) url = reverse('credit:provider_callback', args=[provider_id]) @@ -577,7 +577,7 @@ def test_post_with_invalid_timestamp(self, timedelta): if timedelta == 'invalid': timestamp = timedelta else: - timestamp = to_timestamp(datetime.datetime.now(pytz.UTC) + timedelta) + timestamp = to_timestamp(datetime.datetime.now(get_utc_timezone()) + timedelta) request_uuid = self._create_credit_request_and_get_uuid() response = self._credit_provider_callback(request_uuid, 'approved', timestamp=timestamp) assert response.status_code == 400 @@ -585,7 +585,7 @@ def test_post_with_invalid_timestamp(self, timedelta): def test_post_with_string_timestamp(self): """ Verify the endpoint supports timestamps transmitted as strings instead of integers. """ request_uuid = self._create_credit_request_and_get_uuid() - timestamp = str(to_timestamp(datetime.datetime.now(pytz.UTC))) + timestamp = str(to_timestamp(datetime.datetime.now(get_utc_timezone()))) response = self._credit_provider_callback(request_uuid, 'approved', timestamp=timestamp) assert response.status_code == 200 diff --git a/openedx/core/djangoapps/credit/views.py b/openedx/core/djangoapps/credit/views.py index 2a06f85a321a..4d4ad01ee79a 100644 --- a/openedx/core/djangoapps/credit/views.py +++ b/openedx/core/djangoapps/credit/views.py @@ -6,7 +6,7 @@ import datetime import logging -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -166,7 +166,7 @@ def filter_queryset(self, queryset): return queryset.filter( username=username, course__course_key=course_key, - deadline__gt=datetime.datetime.now(pytz.UTC) + deadline__gt=datetime.datetime.now(get_utc_timezone()) ) diff --git a/openedx/core/djangoapps/enrollments/tests/test_data.py b/openedx/core/djangoapps/enrollments/tests/test_data.py index 93b299d75a3c..2e2b3dd6f3f7 100644 --- a/openedx/core/djangoapps/enrollments/tests/test_data.py +++ b/openedx/core/djangoapps/enrollments/tests/test_data.py @@ -8,7 +8,7 @@ import ddt import pytest -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory @@ -369,7 +369,7 @@ def test_get_course_without_expired_mode_included(self): def _update_verified_mode_as_expired(self, course_id): """Dry method to change verified mode expiration.""" mode = CourseMode.objects.get(course_id=course_id, mode_slug=CourseMode.VERIFIED) - mode.expiration_datetime = datetime.datetime(year=1970, month=1, day=1, tzinfo=UTC) + mode.expiration_datetime = datetime.datetime(year=1970, month=1, day=1, tzinfo=get_utc_timezone()) mode.save() def assert_enrollment_modes(self, expected_modes, include_expired): diff --git a/openedx/core/djangoapps/enrollments/tests/test_views.py b/openedx/core/djangoapps/enrollments/tests/test_views.py index a6b34cbfc60b..b89a7a2b3c16 100644 --- a/openedx/core/djangoapps/enrollments/tests/test_views.py +++ b/openedx/core/djangoapps/enrollments/tests/test_views.py @@ -12,7 +12,7 @@ import ddt import httpretty import pytest -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured @@ -356,8 +356,8 @@ def test_enroll_without_user(self): @ddt.unpack def test_force_enrollment(self, course_modes, enrollment_mode, force_enrollment): # Create the course modes (if any) required for this test case - start_date = datetime.datetime(2021, 12, 1, 5, 0, 0, tzinfo=pytz.UTC) - end_date = datetime.datetime(2022, 12, 1, 5, 0, 0, tzinfo=pytz.UTC) + start_date = datetime.datetime(2021, 12, 1, 5, 0, 0, tzinfo=get_utc_timezone()) + end_date = datetime.datetime(2022, 12, 1, 5, 0, 0, tzinfo=get_utc_timezone()) self.course = CourseFactory.create( emit_signals=True, start=start_date, @@ -658,11 +658,11 @@ def test_get_course_details_with_credit_course(self): # enforced at the data layer, so we need to handle the case # in which no dates are specified. (None, None, None, None), - (datetime.datetime(2015, 1, 2, 3, 4, 5, tzinfo=pytz.UTC), None, "2015-01-02T03:04:05Z", None), - (None, datetime.datetime(2015, 1, 2, 3, 4, 5, tzinfo=pytz.UTC), None, "2015-01-02T03:04:05Z"), + (datetime.datetime(2015, 1, 2, 3, 4, 5, tzinfo=get_utc_timezone()), None, "2015-01-02T03:04:05Z", None), + (None, datetime.datetime(2015, 1, 2, 3, 4, 5, tzinfo=get_utc_timezone()), None, "2015-01-02T03:04:05Z"), ( - datetime.datetime(2014, 6, 7, 8, 9, 10, tzinfo=pytz.UTC), - datetime.datetime(2015, 1, 2, 3, 4, 5, tzinfo=pytz.UTC), + datetime.datetime(2014, 6, 7, 8, 9, 10, tzinfo=get_utc_timezone()), + datetime.datetime(2015, 1, 2, 3, 4, 5, tzinfo=get_utc_timezone()), "2014-06-07T08:09:10Z", "2015-01-02T03:04:05Z", ), @@ -1078,7 +1078,7 @@ def test_deactivate_enrollment_expired_mode(self): # Change verified mode expiration. mode = CourseMode.objects.get(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) - mode.expiration_datetime = datetime.datetime(year=1970, month=1, day=1, tzinfo=pytz.utc) + mode.expiration_datetime = datetime.datetime(year=1970, month=1, day=1, tzinfo=get_utc_timezone()) mode.save() # Deactivate enrollment. @@ -1198,7 +1198,7 @@ def test_update_enrollment_with_expired_mode(self, using_api_key, updated_mode): # Change verified mode expiration. mode = CourseMode.objects.get(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) - mode.expiration_datetime = datetime.datetime(year=1970, month=1, day=1, tzinfo=pytz.utc) + mode.expiration_datetime = datetime.datetime(year=1970, month=1, day=1, tzinfo=get_utc_timezone()) mode.save() self.assert_enrollment_status( as_server=using_api_key, @@ -1784,7 +1784,7 @@ class CourseEnrollmentsApiListTest(APITestCase, ModuleStoreTestCase): """ Test the course enrollments list API. """ - CREATED_DATA = datetime.datetime(2018, 1, 1, 0, 0, 1, tzinfo=pytz.UTC) + CREATED_DATA = datetime.datetime(2018, 1, 1, 0, 0, 1, tzinfo=get_utc_timezone()) def setUp(self): super().setUp() diff --git a/openedx/core/djangoapps/models/tests/test_course_details.py b/openedx/core/djangoapps/models/tests/test_course_details.py index 41e739ecb4c8..df0e4ebd0dbf 100644 --- a/openedx/core/djangoapps/models/tests/test_course_details.py +++ b/openedx/core/djangoapps/models/tests/test_course_details.py @@ -7,7 +7,7 @@ from django.test import override_settings import pytest import ddt -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from xmodule.modulestore import ModuleStoreEnum @@ -86,13 +86,13 @@ def test_update_and_fetch(self): jsondetails.self_paced = True assert CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).self_paced ==\ jsondetails.self_paced - jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC) + jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=get_utc_timezone()) assert CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).start_date ==\ jsondetails.start_date - jsondetails.end_date = datetime.datetime(2011, 10, 1, 0, tzinfo=UTC) + jsondetails.end_date = datetime.datetime(2011, 10, 1, 0, tzinfo=get_utc_timezone()) assert CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).end_date ==\ jsondetails.end_date - jsondetails.certificate_available_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC) + jsondetails.certificate_available_date = datetime.datetime(2010, 10, 1, 0, tzinfo=get_utc_timezone()) assert CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user)\ .certificate_available_date == jsondetails.certificate_available_date jsondetails.course_image_name = "an_image.jpg" @@ -126,7 +126,7 @@ def test_update_and_fetch(self): jsondetails.instructor_info def test_toggle_pacing_during_course_run(self): - self.course.start = datetime.datetime.now(UTC) + self.course.start = datetime.datetime.now(get_utc_timezone()) self.store.update_item(self.course, self.user.id) details = CourseDetails.fetch(self.course.id) diff --git a/openedx/core/djangoapps/notifications/email/tests/test_utils.py b/openedx/core/djangoapps/notifications/email/tests/test_utils.py index 75c70ae194d9..f88cb0af61cd 100644 --- a/openedx/core/djangoapps/notifications/email/tests/test_utils.py +++ b/openedx/core/djangoapps/notifications/email/tests/test_utils.py @@ -6,7 +6,7 @@ import pytest from django.http.response import Http404 -from pytz import utc +from openedx.core.lib.time_zone_utils import get_utc_timezone from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import from common.djangoapps.student.tests.factories import UserFactory @@ -87,7 +87,7 @@ def test_get_time_ago(self): """ Tests time_ago string """ - current_datetime = utc.localize(datetime.datetime.now()) + current_datetime = datetime.datetime.now(get_utc_timezone()) assert "Today" == get_time_ago(current_datetime) assert "1d" == get_time_ago(current_datetime - datetime.timedelta(days=1)) assert "1w" == get_time_ago(current_datetime - datetime.timedelta(days=7)) diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py index 3ce2590d1859..b6c3f76bb5fc 100644 --- a/openedx/core/djangoapps/notifications/email/utils.py +++ b/openedx/core/djangoapps/notifications/email/utils.py @@ -8,7 +8,7 @@ from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ -from pytz import utc +from openedx.core.lib.time_zone_utils import get_utc_timezone from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import from lms.djangoapps.branding.api import get_logo_url_for_email @@ -189,7 +189,7 @@ def get_start_end_date(cadence_type): start_date = end_date - datetime.timedelta(days=1, minutes=15) if cadence_type == EmailCadence.WEEKLY: start_date = start_date - datetime.timedelta(days=6) - return utc.localize(start_date), utc.localize(end_date) + return start_date.replace(tzinfo=get_utc_timezone()), end_date.replace(tzinfo=get_utc_timezone()) def get_course_info(course_key): @@ -205,7 +205,7 @@ def get_time_ago(datetime_obj): """ Returns time_ago for datetime instance """ - current_date = utc.localize(datetime.datetime.today()) + current_date = datetime.datetime.now(get_utc_timezone()) days_diff = (current_date - datetime_obj).days if days_diff == 0: return _("Today") diff --git a/openedx/core/djangoapps/notifications/grouping_notifications.py b/openedx/core/djangoapps/notifications/grouping_notifications.py index c855ca3d234e..dd9584755641 100644 --- a/openedx/core/djangoapps/notifications/grouping_notifications.py +++ b/openedx/core/djangoapps/notifications/grouping_notifications.py @@ -1,12 +1,10 @@ """ Notification grouping utilities for notifications """ -import datetime from abc import ABC, abstractmethod +from datetime import datetime, timezone from typing import Dict, Type, Union -from pytz import utc - from openedx.core.djangoapps.notifications.base_notification import COURSE_NOTIFICATION_TYPES from openedx.core.djangoapps.notifications.models import Notification @@ -120,7 +118,7 @@ def group_user_notifications(new_notification: Notification, old_notification: N old_notification.content_url = new_notification.content_url old_notification.last_read = None old_notification.last_seen = None - old_notification.created = utc.localize(datetime.datetime.now()) + old_notification.created = datetime.now(timezone.utc) old_notification.save() diff --git a/openedx/core/djangoapps/notifications/tasks.py b/openedx/core/djangoapps/notifications/tasks.py index fb9f95990d3a..18d67b4b41c6 100644 --- a/openedx/core/djangoapps/notifications/tasks.py +++ b/openedx/core/djangoapps/notifications/tasks.py @@ -10,7 +10,7 @@ from django.core.exceptions import ValidationError from edx_django_utils.monitoring import set_code_owner_attribute from opaque_keys.edx.keys import CourseKey -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from openedx.core.djangoapps.notifications.audience_filters import NotificationFilter from openedx.core.djangoapps.notifications.base_notification import ( @@ -75,7 +75,7 @@ def delete_expired_notifications(): This task deletes all expired notifications """ batch_size = settings.EXPIRED_NOTIFICATIONS_DELETE_BATCH_SIZE - expiry_date = datetime.now(UTC) - timedelta(days=settings.NOTIFICATIONS_EXPIRY) + expiry_date = datetime.now(get_utc_timezone()) - timedelta(days=settings.NOTIFICATIONS_EXPIRY) start_time = datetime.now() total_deleted = 0 delete_count = None diff --git a/openedx/core/djangoapps/notifications/tests/test_notification_grouping.py b/openedx/core/djangoapps/notifications/tests/test_notification_grouping.py index debd72d9011f..d12696eaa65a 100644 --- a/openedx/core/djangoapps/notifications/tests/test_notification_grouping.py +++ b/openedx/core/djangoapps/notifications/tests/test_notification_grouping.py @@ -6,7 +6,7 @@ import unittest from unittest.mock import MagicMock, patch from datetime import datetime -from pytz import utc +from openedx.core.lib.time_zone_utils import get_utc_timezone from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.notifications.grouping_notifications import ( @@ -128,7 +128,7 @@ def test_group_user_notifications_no_grouper(self): self.assertFalse(old_notification.save.called) - @ddt.data(datetime(2023, 1, 1, tzinfo=utc), None) + @ddt.data(datetime(2023, 1, 1, tzinfo=get_utc_timezone()), None) def test_not_grouped_when_notification_is_seen(self, last_seen): """ Notification is not grouped if the notification is marked as seen @@ -172,11 +172,11 @@ def test_get_user_existing_notifications(self, mock_filter): # Mock the notification objects returned by the filter mock_notification1 = MagicMock(spec=Notification) mock_notification1.user_id = 1 - mock_notification1.created = datetime(2023, 9, 1, tzinfo=utc) + mock_notification1.created = datetime(2023, 9, 1, tzinfo=get_utc_timezone()) mock_notification2 = MagicMock(spec=Notification) mock_notification2.user_id = 1 - mock_notification2.created = datetime(2023, 9, 2, tzinfo=utc) + mock_notification2.created = datetime(2023, 9, 2, tzinfo=get_utc_timezone()) mock_filter.return_value = [mock_notification1, mock_notification2] diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index de02141745e8..dc9666800672 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -10,7 +10,7 @@ from django.test.utils import override_settings from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from rest_framework import status from rest_framework.test import APIClient, APITestCase @@ -33,7 +33,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from ..base_notification import COURSE_NOTIFICATION_APPS, NotificationTypeManager, COURSE_NOTIFICATION_TYPES +from ..base_notification import COURSE_NOTIFICATION_APPS, NotificationTypeManager from ..utils import get_notification_types_with_visibility_settings, exclude_inaccessible_preferences User = get_user_model() @@ -188,7 +188,7 @@ def test_list_notifications_with_expiry_date(self): """ Test that the view can filter notifications by expiry date. """ - today = datetime.now(UTC) + today = datetime.now(get_utc_timezone()) # Create two notifications for the user, one with current date and other with expiry date. Notification.objects.create( @@ -443,7 +443,7 @@ def test_mark_notification_read_with_invalid_notification_id(self): response = self.client.patch(self.url, data) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(response.data["detail"].code, 'not_found') + self.assertEqual(response.data["detail"], 'Not found.') def test_mark_notification_read_with_app_name_and_notification_id(self): # Create a PATCH request to mark notification as read for existing app e.g 'discussion' and notification_id: 2 diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py index d3a9dd1f48e2..9a8ec340cb8c 100644 --- a/openedx/core/djangoapps/notifications/views.py +++ b/openedx/core/djangoapps/notifications/views.py @@ -7,7 +7,7 @@ from django.db.models import Count from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from rest_framework import generics, status from rest_framework.decorators import api_view from rest_framework.generics import UpdateAPIView @@ -78,7 +78,7 @@ def get_queryset(self): """ Override the get_queryset method to filter the queryset by app name, request.user and created """ - expiry_date = datetime.now(UTC) - timedelta(days=settings.NOTIFICATIONS_EXPIRY) + expiry_date = datetime.now(get_utc_timezone()) - timedelta(days=settings.NOTIFICATIONS_EXPIRY) app_name = self.request.query_params.get('app_name') if self.request.query_params.get('tray_opened'): @@ -210,7 +210,7 @@ def patch(self, request, *args, **kwargs): - 404: Not Found status code if the notification was not found. """ notification_id = request.data.get('notification_id', None) - read_at = datetime.now(UTC) + read_at = datetime.now(get_utc_timezone()) if notification_id: notification = get_object_or_404(Notification, pk=notification_id, user=request.user) diff --git a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py index f8cf0538140d..fe48c1d8a701 100644 --- a/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py +++ b/openedx/core/djangoapps/oauth_dispatch/dot_overrides/validators.py @@ -11,7 +11,7 @@ from oauth2_provider.models import AccessToken from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.scopes import get_scopes_backend -from pytz import utc +from openedx.core.lib.time_zone_utils import get_utc_timezone from ..models import RestrictedApplication # pylint: disable=W0223 @@ -23,7 +23,7 @@ def on_access_token_presave(sender, instance, *args, **kwargs): # pylint: disab Mark AccessTokens as expired for 'restricted applications' if required. """ if RestrictedApplication.should_expire_access_token(instance.application): - instance.expires = datetime(1970, 1, 1, tzinfo=utc) + instance.expires = datetime(1970, 1, 1, tzinfo=get_utc_timezone()) class EdxOAuth2Validator(OAuth2Validator): @@ -152,4 +152,4 @@ def _get_utc_now(): """ Return current time in UTC. """ - return datetime.utcnow().replace(tzinfo=utc) + return datetime.utcnow().replace(tzinfo=get_utc_timezone()) diff --git a/openedx/core/djangoapps/oauth_dispatch/models.py b/openedx/core/djangoapps/oauth_dispatch/models.py index 2e635167e4c7..35e232282921 100644 --- a/openedx/core/djangoapps/oauth_dispatch/models.py +++ b/openedx/core/djangoapps/oauth_dispatch/models.py @@ -11,7 +11,7 @@ from django_mysql.models import ListCharField from oauth2_provider.settings import oauth2_settings from organizations.models import Organization -from pytz import utc +from openedx.core.lib.time_zone_utils import get_utc_timezone from openedx.core.djangolib.markup import HTML from openedx.core.lib.request_utils import get_request_or_stub @@ -53,7 +53,7 @@ def verify_access_token_as_expired(cls, access_token): For access_tokens for RestrictedApplications, make sure that the expiry date is set at the beginning of the epoch which is Jan. 1, 1970 """ - return access_token.expires == datetime(1970, 1, 1, tzinfo=utc) + return access_token.expires == datetime(1970, 1, 1, tzinfo=get_utc_timezone()) class ApplicationAccess(models.Model): diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/factories.py b/openedx/core/djangoapps/oauth_dispatch/tests/factories.py index 473bcd4ced9d..10cf1268385e 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/factories.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/factories.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta import factory -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from factory.django import DjangoModelFactory from factory.fuzzy import FuzzyText from oauth2_provider.models import AccessToken, Application, RefreshToken @@ -39,7 +39,7 @@ class Meta: django_get_or_create = ('user', 'application') token = FuzzyText(length=32) - expires = datetime.now(pytz.UTC) + timedelta(days=1) + expires = datetime.now(get_utc_timezone()) + timedelta(days=1) class RefreshTokenFactory(DjangoModelFactory): diff --git a/openedx/core/djangoapps/password_policy/compliance.py b/openedx/core/djangoapps/password_policy/compliance.py index fdd103d2437d..e9a31695facd 100644 --- a/openedx/core/djangoapps/password_policy/compliance.py +++ b/openedx/core/djangoapps/password_policy/compliance.py @@ -4,7 +4,7 @@ from datetime import datetime -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from django.utils.translation import gettext as _ @@ -69,7 +69,7 @@ def enforce_compliance_on_login(user, password): if deadline is None: return - now = datetime.now(pytz.UTC) + now = datetime.now(get_utc_timezone()) if now >= deadline: # lint-amnesty, pylint: disable=no-else-raise raise NonCompliantPasswordException( HTML(_( diff --git a/openedx/core/djangoapps/password_policy/tests/test_compliance.py b/openedx/core/djangoapps/password_policy/tests/test_compliance.py index cb803bed99a9..dd226e67c17a 100644 --- a/openedx/core/djangoapps/password_policy/tests/test_compliance.py +++ b/openedx/core/djangoapps/password_policy/tests/test_compliance.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from dateutil.parser import parse as parse_date from django.test import TestCase, override_settings @@ -75,7 +75,7 @@ def test_enforce_compliance_on_login(self): mock_check_user_compliance.return_value = False with patch('openedx.core.djangoapps.password_policy.compliance._get_compliance_deadline_for_user') as \ mock_get_compliance_deadline_for_user: - mock_get_compliance_deadline_for_user.return_value = datetime.now(pytz.UTC) - timedelta(1) + mock_get_compliance_deadline_for_user.return_value = datetime.now(get_utc_timezone()) - timedelta(1) pytest.raises(NonCompliantPasswordException, enforce_compliance_on_login, user, password) # Test deadline is in the future @@ -84,7 +84,7 @@ def test_enforce_compliance_on_login(self): mock_check_user_compliance.return_value = False with patch('openedx.core.djangoapps.password_policy.compliance._get_compliance_deadline_for_user') as \ mock_get_compliance_deadline_for_user: - mock_get_compliance_deadline_for_user.return_value = datetime.now(pytz.UTC) + timedelta(1) + mock_get_compliance_deadline_for_user.return_value = datetime.now(get_utc_timezone()) + timedelta(1) assert pytest.raises(NonCompliantPasswordWarning, enforce_compliance_on_login, user, password) def test_check_user_compliance(self): diff --git a/openedx/core/djangoapps/profile_images/tests/test_views.py b/openedx/core/djangoapps/profile_images/tests/test_views.py index 0a276377589b..66163e02b067 100644 --- a/openedx/core/djangoapps/profile_images/tests/test_views.py +++ b/openedx/core/djangoapps/profile_images/tests/test_views.py @@ -7,7 +7,7 @@ import pytest import datetime # lint-amnesty, pylint: disable=wrong-import-order -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.urls import reverse from django.http import HttpResponse @@ -30,8 +30,8 @@ from .helpers import make_image_file TEST_PASSWORD = "test" -TEST_UPLOAD_DT = datetime.datetime(2002, 1, 9, 15, 43, 1, tzinfo=UTC) -TEST_UPLOAD_DT2 = datetime.datetime(2003, 1, 9, 15, 43, 1, tzinfo=UTC) +TEST_UPLOAD_DT = datetime.datetime(2002, 1, 9, 15, 43, 1, tzinfo=get_utc_timezone()) +TEST_UPLOAD_DT2 = datetime.datetime(2003, 1, 9, 15, 43, 1, tzinfo=get_utc_timezone()) class ProfileImageEndpointMixin(UserSettingsEventTestMixin): diff --git a/openedx/core/djangoapps/profile_images/views.py b/openedx/core/djangoapps/profile_images/views.py index b88b3ad32bdb..8cd78c87162f 100644 --- a/openedx/core/djangoapps/profile_images/views.py +++ b/openedx/core/djangoapps/profile_images/views.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext as _ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from rest_framework import permissions, status from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response @@ -38,7 +38,7 @@ def _make_upload_dt(): Generate a server-side timestamp for the upload. This is in a separate function so its behavior can be overridden in tests. """ - return datetime.datetime.utcnow().replace(tzinfo=UTC) + return datetime.datetime.utcnow().replace(tzinfo=get_utc_timezone()) class ProfileImageView(DeveloperErrorViewMixin, APIView): diff --git a/openedx/core/djangoapps/programs/tests/test_tasks.py b/openedx/core/djangoapps/programs/tests/test_tasks.py index e2b1c554c840..fd5a756022af 100644 --- a/openedx/core/djangoapps/programs/tests/test_tasks.py +++ b/openedx/core/djangoapps/programs/tests/test_tasks.py @@ -10,7 +10,7 @@ import ddt import httpretty import pytest -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone import requests from celery.exceptions import MaxRetriesExceededError from django.conf import settings @@ -520,7 +520,7 @@ class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase): def setUp(self): super().setUp() - self.available_date = datetime.now(pytz.UTC) + timedelta(days=1) + self.available_date = datetime.now(get_utc_timezone()) + timedelta(days=1) self.course = CourseOverviewFactory.create( self_paced=True, # Any option to allow the certificate to be viewable for the course certificate_available_date=self.available_date, @@ -1023,7 +1023,7 @@ class UpdateCertificateAvailableDateOnCourseUpdateTestCase(CredentialsApiConfigM def setUp(self): super().setUp() - self.end_date = datetime.now(pytz.UTC) + timedelta(days=90) + self.end_date = datetime.now(get_utc_timezone()) + timedelta(days=90) self.credentials_api_config = self.create_credentials_config(enabled=False) def tearDown(self): @@ -1135,7 +1135,7 @@ def test_update_certificate_available_date_instructor_paced_cdb_end_with_date(se explicitly set as part of the course overview. """ self._update_credentials_api_config(True) - certificate_available_date = datetime.now(pytz.UTC) + timedelta(days=120) + certificate_available_date = datetime.now(get_utc_timezone()) + timedelta(days=120) course_overview = self._create_course_overview( False, @@ -1168,7 +1168,7 @@ def test_update_certificate_available_date_self_paced(self, mock_update): invalid data is set in a course overview, we don't pass it to Credentials. """ self._update_credentials_api_config(True) - certificate_available_date = datetime.now(pytz.UTC) + timedelta(days=120) + certificate_available_date = datetime.now(get_utc_timezone()) + timedelta(days=120) course_overview = self._create_course_overview( True, diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index 264f1a6aeebd..56471eabb75a 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -15,7 +15,7 @@ from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_switch from opaque_keys.edx.keys import CourseKey # lint-amnesty, pylint: disable=wrong-import-order -from pytz import utc +from openedx.core.lib.time_zone_utils import get_utc_timezone from testfixtures import LogCapture from common.djangoapps.course_modes.models import CourseMode @@ -209,7 +209,7 @@ def test_single_program_multiple_entitlements(self, mock_get_programs): CourseEntitlementFactory.create( user=self.user, course_uuid=course_uuid, - expired_at=datetime.datetime.now(utc), + expired_at=datetime.datetime.now(get_utc_timezone()), mode=CourseMode.VERIFIED, enrollment_course_run=enrollment @@ -308,7 +308,7 @@ def test_in_progress_course_upgrade_deadline_check(self, offset, mock_get_progra the right type for which the upgrade deadline has not passed. """ course_run_key = generate_course_run_key() - now = datetime.datetime.now(utc) + now = datetime.datetime.now(get_utc_timezone()) upgrade_deadline = None if not offset else str(now + datetime.timedelta(days=offset)) required_seat = SeatFactory(type=CourseMode.VERIFIED, upgrade_deadline=upgrade_deadline) enrolled_seat = SeatFactory(type=CourseMode.AUDIT) @@ -488,7 +488,7 @@ def test_shared_entitlement_engagement(self, mock_get_programs): def test_simulate_progress(self, mock_get_programs): # lint-amnesty, pylint: disable=too-many-statements """Simulate the entirety of a user's progress through a program.""" - today = datetime.datetime.now(utc) + today = datetime.datetime.now(get_utc_timezone()) two_days_ago = today - datetime.timedelta(days=2) three_days_ago = today - datetime.timedelta(days=3) yesterday = today - datetime.timedelta(days=1) @@ -862,8 +862,8 @@ def _create_course(self, course_price, course_run_count=1, make_entitlement=Fals course_runs = [] for x in range(course_run_count): course = ModuleStoreCourseFactory.create(run='Run_' + str(x)) - course.start = datetime.datetime.now(utc) - datetime.timedelta(days=1) - course.end = datetime.datetime.now(utc) + datetime.timedelta(days=1) + course.start = datetime.datetime.now(get_utc_timezone()) - datetime.timedelta(days=1) + course.end = datetime.datetime.now(get_utc_timezone()) + datetime.timedelta(days=1) course.instructor_info = self.instructors course = self.update_course(course, self.user.id) @@ -899,8 +899,8 @@ def setUp(self): super().setUp() self.course = ModuleStoreCourseFactory() - self.course.start = datetime.datetime.now(utc) - datetime.timedelta(days=1) - self.course.end = datetime.datetime.now(utc) + datetime.timedelta(days=1) + self.course.start = datetime.datetime.now(get_utc_timezone()) - datetime.timedelta(days=1) + self.course.end = datetime.datetime.now(get_utc_timezone()) + datetime.timedelta(days=1) self.course = self.update_course(self.course, self.user.id) self.course_run = CourseRunFactory(key=str(self.course.id)) @@ -941,7 +941,7 @@ def test_is_enrollment_open(self, days_offset): Verify that changes to the course run end date do not affect our assessment of the course run being open for enrollment. """ - self.course.end = datetime.datetime.now(utc) + datetime.timedelta(days=days_offset) + self.course.end = datetime.datetime.now(get_utc_timezone()) + datetime.timedelta(days=days_offset) self.course = self.update_course(self.course, self.user.id) data = ProgramDataExtender(self.program, self.user).extend() @@ -1022,8 +1022,8 @@ def test_course_run_enrollment_status(self, start_offset, end_offset, is_enrollm """ Verify that course run enrollment status is reflected correctly. """ - self.course.enrollment_start = datetime.datetime.now(utc) - datetime.timedelta(days=start_offset) - self.course.enrollment_end = datetime.datetime.now(utc) - datetime.timedelta(days=end_offset) + self.course.enrollment_start = datetime.datetime.now(get_utc_timezone()) - datetime.timedelta(days=start_offset) + self.course.enrollment_end = datetime.datetime.now(get_utc_timezone()) - datetime.timedelta(days=end_offset) self.course = self.update_course(self.course, self.user.id) @@ -1040,7 +1040,7 @@ def test_no_enrollment_start_date(self): Verify that a closed course run with no explicit enrollment start date doesn't cause an error. Regression test for ECOM-4973. """ - self.course.enrollment_end = datetime.datetime.now(utc) - datetime.timedelta(days=1) + self.course.enrollment_end = datetime.datetime.now(get_utc_timezone()) - datetime.timedelta(days=1) self.course = self.update_course(self.course, self.user.id) data = ProgramDataExtender(self.program, self.user).extend() diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 76263c4b405c..e7c072e813be 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -14,7 +14,7 @@ from django.urls import reverse from django.utils.functional import cached_property from opaque_keys.edx.keys import CourseKey -from pytz import utc +from openedx.core.lib.time_zone_utils import get_utc_timezone from requests.exceptions import RequestException from common.djangoapps.course_modes.api import get_paid_modes_for_course @@ -43,7 +43,7 @@ from xmodule.modulestore.django import modulestore # The datetime module's strftime() methods require a year >= 1900. -DEFAULT_ENROLLMENT_START_DATE = datetime.datetime(1900, 1, 1, tzinfo=utc) +DEFAULT_ENROLLMENT_START_DATE = datetime.datetime(1900, 1, 1, tzinfo=get_utc_timezone()) log = logging.getLogger(__name__) @@ -286,7 +286,7 @@ def progress(self, programs: list[dict | None] | None = None, count_only: bool = list of dict, each containing information about a user's progress towards completing a program. """ - now = datetime.datetime.now(utc) + now = datetime.datetime.now(get_utc_timezone()) progress = [] programs = programs or self.engaged_programs @@ -598,15 +598,16 @@ def _attach_course_run_enrollment_open_date(self, run_mode): run_mode["enrollment_open_date"] = strftime_localized(self.enrollment_start, "SHORT_DATE") def _attach_course_run_is_course_ended(self, run_mode): - end_date = self.course_overview.end or datetime.datetime.max.replace(tzinfo=utc) - run_mode["is_course_ended"] = end_date < datetime.datetime.now(utc) + end_date = self.course_overview.end or datetime.datetime.max.replace(tzinfo=get_utc_timezone()) + run_mode["is_course_ended"] = end_date < datetime.datetime.now(get_utc_timezone()) def _attach_course_run_is_enrolled(self, run_mode): run_mode["is_enrolled"] = CourseEnrollment.is_enrolled(self.user, self.course_run_key) def _attach_course_run_is_enrollment_open(self, run_mode): - enrollment_end = self.course_overview.enrollment_end or datetime.datetime.max.replace(tzinfo=utc) - run_mode["is_enrollment_open"] = self.enrollment_start <= datetime.datetime.now(utc) < enrollment_end + enrollment_end = self.course_overview.enrollment_end or datetime.datetime.max.replace(tzinfo=get_utc_timezone()) + run_mode["is_enrollment_open"] = self.enrollment_start <= datetime.datetime.now( + get_utc_timezone()) < enrollment_end def _attach_course_run_advertised_start(self, run_mode): """ diff --git a/openedx/core/djangoapps/schedules/management/commands/__init__.py b/openedx/core/djangoapps/schedules/management/commands/__init__.py index bd0082f5331e..e6b75aaaec1e 100644 --- a/openedx/core/djangoapps/schedules/management/commands/__init__.py +++ b/openedx/core/djangoapps/schedules/management/commands/__init__.py @@ -5,7 +5,7 @@ import datetime -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.contrib.sites.models import Site from django.core.management.base import BaseCommand @@ -46,7 +46,7 @@ def handle(self, *args, **options): current_date = datetime.datetime( *[int(x) for x in options['date'].split('-')], - tzinfo=pytz.UTC + tzinfo=get_utc_timezone() ) self.log_debug('Current date = %s', current_date.isoformat()) diff --git a/openedx/core/djangoapps/schedules/management/commands/send_course_next_section_update.py b/openedx/core/djangoapps/schedules/management/commands/send_course_next_section_update.py index 53e2100649a1..f227aef29f8f 100644 --- a/openedx/core/djangoapps/schedules/management/commands/send_course_next_section_update.py +++ b/openedx/core/djangoapps/schedules/management/commands/send_course_next_section_update.py @@ -3,7 +3,7 @@ """ import datetime -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from textwrap import dedent # lint-amnesty, pylint: disable=wrong-import-order from django.contrib.sites.models import Site @@ -23,7 +23,7 @@ class Command(SendEmailBaseCommand): def handle(self, *args, ** options): current_date = datetime.datetime( *[int(x) for x in options['date'].split('-')], - tzinfo=pytz.UTC + tzinfo=get_utc_timezone() ) site = Site.objects.get(domain__iexact=options['site_domain_name']) diff --git a/openedx/core/djangoapps/schedules/management/commands/setup_models_to_send_test_emails.py b/openedx/core/djangoapps/schedules/management/commands/setup_models_to_send_test_emails.py index 976f1aa16fd9..5cb2c944fc74 100644 --- a/openedx/core/djangoapps/schedules/management/commands/setup_models_to_send_test_emails.py +++ b/openedx/core/djangoapps/schedules/management/commands/setup_models_to_send_test_emails.py @@ -7,7 +7,7 @@ from textwrap import dedent import factory -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.contrib.sites.models import Site from django.core.management.base import BaseCommand @@ -26,29 +26,29 @@ class ThreeDayNudgeSchedule(ScheduleFactory): """ A ScheduleFactory that creates a Schedule set up for a 3-day nudge email. """ - start_date = factory.Faker('date_time_between', start_date='-3d', end_date='-3d', tzinfo=pytz.UTC) + start_date = factory.Faker('date_time_between', start_date='-3d', end_date='-3d', tzinfo=get_utc_timezone()) class TenDayNudgeSchedule(ScheduleFactory): """ A ScheduleFactory that creates a Schedule set up for a 10-day nudge email. """ - start_date = factory.Faker('date_time_between', start_date='-10d', end_date='-10d', tzinfo=pytz.UTC) + start_date = factory.Faker('date_time_between', start_date='-10d', end_date='-10d', tzinfo=get_utc_timezone()) class UpgradeReminderSchedule(ScheduleFactory): """ A ScheduleFactory that creates a Schedule set up for a 2-days-remaining upgrade reminder. """ - start_date = factory.Faker('past_datetime', tzinfo=pytz.UTC) - upgrade_deadline = factory.Faker('date_time_between', start_date='+2d', end_date='+2d', tzinfo=pytz.UTC) + start_date = factory.Faker('past_datetime', tzinfo=get_utc_timezone()) + upgrade_deadline = factory.Faker('date_time_between', start_date='+2d', end_date='+2d', tzinfo=get_utc_timezone()) class ContentHighlightSchedule(ScheduleFactory): """ A ScheduleFactory that creates a Schedule set up for a course highlights email. """ - start_date = factory.Faker('date_time_between', start_date='-7d', end_date='-7d', tzinfo=pytz.UTC) + start_date = factory.Faker('date_time_between', start_date='-7d', end_date='-7d', tzinfo=get_utc_timezone()) experience = factory.RelatedFactory(ScheduleExperienceFactory, 'schedule', experience_type=ScheduleExperience.EXPERIENCES.course_updates) # lint-amnesty, pylint: disable=line-too-long diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py index 774f1f418124..81e3a1d59c04 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/send_email_base.py @@ -11,7 +11,7 @@ import attr import ddt -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.db.models import Max @@ -119,7 +119,7 @@ def _next_user_id(self): return max_user_id + num_bins - (max_user_id % num_bins) def _get_dates(self, offset=None): # lint-amnesty, pylint: disable=missing-function-docstring - current_day = _get_datetime_beginning_of_day(datetime.datetime.now(pytz.UTC)) + current_day = _get_datetime_beginning_of_day(datetime.datetime.now(get_utc_timezone())) offset = offset or self.expected_offsets[0] target_day = current_day + datetime.timedelta(days=offset) if self.resolver.schedule_date_field == 'upgrade_deadline': @@ -148,7 +148,7 @@ def _schedule_factory(self, offset=None, **factory_kwargs): # lint-amnesty, pyl CourseModeFactory( course_id=course_id, mode_slug=CourseMode.VERIFIED, - expiration_datetime=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=30), + expiration_datetime=datetime.datetime.now(get_utc_timezone()) + datetime.timedelta(days=30), ) self._courses_with_verified_modes.add(course_id) return schedule @@ -158,7 +158,7 @@ def _update_schedule_config(self, schedule_config_kwargs): Updates the schedule config model by making sure the new entry has a later timestamp. """ - later_time = datetime.datetime.now(pytz.UTC) + datetime.timedelta(minutes=1) + later_time = datetime.datetime.now(get_utc_timezone()) + datetime.timedelta(minutes=1) with freeze_time(later_time): ScheduleConfigFactory.create(**schedule_config_kwargs) @@ -167,7 +167,7 @@ def test_command_task_binding(self): def test_handle(self): with patch.object(self.command, 'async_send_task') as mock_send: - test_day = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC) + test_day = datetime.datetime(2017, 8, 1, tzinfo=get_utc_timezone()) self.command().handle(date='2017-08-01', site_domain_name=self.site_config.site.domain) for offset in self.expected_offsets: @@ -287,7 +287,7 @@ def test_enqueue_config(self, is_enabled): } self._update_schedule_config(schedule_config_kwargs) - current_datetime = datetime.datetime(2017, 8, 1, tzinfo=pytz.UTC) + current_datetime = datetime.datetime(2017, 8, 1, tzinfo=get_utc_timezone()) with patch.object(self.task, 'apply_async') as mock_apply_async: self.task.enqueue(self.site_config.site, current_datetime, 3) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_email_base_command.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_email_base_command.py index 47ba67cc0999..5ca072c308d2 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_email_base_command.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_email_base_command.py @@ -8,7 +8,7 @@ from unittest.mock import DEFAULT, Mock, patch import ddt -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from openedx.core.djangoapps.schedules.management.commands import SendEmailBaseCommand @@ -32,7 +32,7 @@ def test_handle(self): self.command.handle(site_domain_name=self.site.domain, date='2017-09-29') send_emails.assert_called_once_with( self.site, - datetime.datetime(2017, 9, 29, tzinfo=pytz.UTC), + datetime.datetime(2017, 9, 29, tzinfo=get_utc_timezone()), None ) diff --git a/openedx/core/djangoapps/schedules/tests/factories.py b/openedx/core/djangoapps/schedules/tests/factories.py index 882b62fb8b78..215f94a405c2 100644 --- a/openedx/core/djangoapps/schedules/tests/factories.py +++ b/openedx/core/djangoapps/schedules/tests/factories.py @@ -4,7 +4,7 @@ import factory -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from openedx.core.djangoapps.schedules import models from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory @@ -22,8 +22,8 @@ class ScheduleFactory(factory.django.DjangoModelFactory): # lint-amnesty, pylin class Meta: model = models.Schedule - start_date = factory.Faker('future_datetime', tzinfo=pytz.UTC) - upgrade_deadline = factory.Faker('future_datetime', tzinfo=pytz.UTC) + start_date = factory.Faker('future_datetime', tzinfo=get_utc_timezone()) + upgrade_deadline = factory.Faker('future_datetime', tzinfo=get_utc_timezone()) enrollment = factory.SubFactory(CourseEnrollmentFactory) experience = factory.RelatedFactory(ScheduleExperienceFactory, 'schedule') diff --git a/openedx/core/djangoapps/schedules/tests/test_resolvers.py b/openedx/core/djangoapps/schedules/tests/test_resolvers.py index 2c37608e5cff..0cde8ef2bb03 100644 --- a/openedx/core/djangoapps/schedules/tests/test_resolvers.py +++ b/openedx/core/djangoapps/schedules/tests/test_resolvers.py @@ -8,7 +8,7 @@ import crum import ddt -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.test import TestCase from django.test.client import RequestFactory from django.test.utils import override_settings @@ -123,7 +123,7 @@ def test_external_course_updates(self, bucket): # experiment. Note that the experiment waffle is currently inactive, but they should still be excluded because # they were bucketed at enrollment time. bin_num = BinnedSchedulesBaseResolver.bin_num_for_user_id(user.id) - resolver = BinnedSchedulesBaseResolver(None, self.site, datetime.datetime.now(pytz.UTC), 0, bin_num) + resolver = BinnedSchedulesBaseResolver(None, self.site, datetime.datetime.now(get_utc_timezone()), 0, bin_num) resolver.schedule_date_field = 'created' schedules = resolver.get_schedules_with_target_date_by_bin_and_orgs() diff --git a/openedx/core/djangoapps/schedules/tests/test_signals.py b/openedx/core/djangoapps/schedules/tests/test_signals.py index 023fbbdbafcc..63e173ae3761 100644 --- a/openedx/core/djangoapps/schedules/tests/test_signals.py +++ b/openedx/core/djangoapps/schedules/tests/test_signals.py @@ -8,7 +8,7 @@ import ddt import pytest -from pytz import utc +from openedx.core.lib.time_zone_utils import get_utc_timezone from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory @@ -188,7 +188,7 @@ def _create_course_run(self_paced=True, start_day_offset=-1): Both audit and verified `CourseMode` objects will be created for the course run. """ - now = datetime.datetime.now(utc) + now = datetime.datetime.now(get_utc_timezone()) start = now + datetime.timedelta(days=start_day_offset) course = CourseFactory.create(start=start, self_paced=self_paced) diff --git a/openedx/core/djangoapps/schedules/tests/test_utils.py b/openedx/core/djangoapps/schedules/tests/test_utils.py index f1d0cd9fcba0..ea7d208ba3b9 100644 --- a/openedx/core/djangoapps/schedules/tests/test_utils.py +++ b/openedx/core/djangoapps/schedules/tests/test_utils.py @@ -5,7 +5,7 @@ import datetime import ddt -from pytz import utc +from openedx.core.lib.time_zone_utils import get_utc_timezone from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -26,7 +26,7 @@ def create_schedule(self, enrollment_offset=0, course_start_offset=-100): # pylint: disable=attribute-defined-outside-init self.config = ScheduleConfigFactory() - start = datetime.datetime.now(utc) + datetime.timedelta(days=course_start_offset) + start = datetime.datetime.now(get_utc_timezone()) + datetime.timedelta(days=course_start_offset) self.course = CourseFactory.create(start=start, self_paced=True) self.enrollment = CourseEnrollmentFactory( diff --git a/openedx/core/djangoapps/schedules/utils.py b/openedx/core/djangoapps/schedules/utils.py index c2565a1c87a3..1b84546831d8 100644 --- a/openedx/core/djangoapps/schedules/utils.py +++ b/openedx/core/djangoapps/schedules/utils.py @@ -3,7 +3,7 @@ import datetime import logging -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.db import transaction from openedx.core.djangoapps.schedules.models import Schedule @@ -59,7 +59,7 @@ def reset_self_paced_schedule(user, course_key, use_enrollment_date=False): if use_enrollment_date: new_start_date = schedule.enrollment.created else: - new_start_date = datetime.datetime.now(pytz.utc) + new_start_date = datetime.datetime.now(get_utc_timezone()) # Make sure we don't start the clock on the learner's schedule before the course even starts new_start_date = max(new_start_date, schedule.enrollment.course.start) diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 6970ea6f852f..253e9582e24a 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -12,7 +12,7 @@ from django.utils.translation import gettext as _ from django.utils.translation import override as override_language from eventtracking import tracker -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from common.djangoapps.student import views as student_views from common.djangoapps.student.models import ( @@ -375,7 +375,7 @@ def _store_old_name_if_needed(old_name, user_profile, requesting_user): meta['old_names'].append([ old_name, f"Name change requested through account API by {requesting_user.username}", - datetime.datetime.now(UTC).isoformat() + datetime.datetime.now(get_utc_timezone()).isoformat() ]) user_profile.set_meta(meta) user_profile.save() diff --git a/openedx/core/djangoapps/user_api/accounts/tests/retirement_helpers.py b/openedx/core/djangoapps/user_api/accounts/tests/retirement_helpers.py index a3c40002f61c..021405c92b5b 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/retirement_helpers.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/retirement_helpers.py @@ -6,7 +6,7 @@ import datetime import pytest -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.test import TestCase from social_django.models import UserSocialAuth @@ -67,7 +67,7 @@ def create_retirement_status(user, state=None, create_datetime=None): Assumes that retirement states have been setup before calling. """ if create_datetime is None: - create_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=8) + create_datetime = datetime.datetime.now(get_utc_timezone()) - datetime.timedelta(days=8) retirement = UserRetirementStatus.create_retirement(user) if state: diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index f9071c06a5c2..85f91eb0a565 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -17,7 +17,7 @@ from django.test import TestCase from django.test.client import RequestFactory from django.urls import reverse -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from social_django.models import UserSocialAuth from common.djangoapps.student.models import ( @@ -381,7 +381,7 @@ def test_validate_name_change_same_name(self): meta['old_names'] = [] for num in range(3): meta['old_names'].append( - [f'old_name_{num}', 'test', datetime.datetime.now(UTC).isoformat()] + [f'old_name_{num}', 'test', datetime.datetime.now(get_utc_timezone()).isoformat()] ) user_profile.set_meta(meta) user_profile.save() diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py b/openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py index 3608073a5217..73d410dafa63 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_image_helpers.py @@ -8,7 +8,7 @@ from unittest.mock import patch from django.test import TestCase -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from openedx.core.djangolib.testing.utils import skip_unless_lms from common.djangoapps.student.tests.factories import UserFactory @@ -16,7 +16,7 @@ from ..image_helpers import get_profile_image_urls_for_user TEST_SIZES = {'full': 50, 'small': 10} -TEST_PROFILE_IMAGE_UPLOAD_DT = datetime.datetime(2002, 1, 9, 15, 43, 1, tzinfo=UTC) +TEST_PROFILE_IMAGE_UPLOAD_DT = datetime.datetime(2002, 1, 9, 15, 43, 1, tzinfo=get_utc_timezone()) @patch.dict('django.conf.settings.PROFILE_IMAGE_SIZES_MAP', TEST_SIZES, clear=True) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py index 9d4efb2fa77c..674e93836e44 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py @@ -7,7 +7,7 @@ from unittest import mock import ddt -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from consent.models import DataSharingConsent from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.sites.models import Site @@ -516,7 +516,7 @@ def setUp(self): self.headers = build_jwt_headers(self.test_superuser) self.url = reverse('accounts_retirement_partner_report') self.maxDiff = None - self.test_created_datetime = datetime.datetime(2018, 1, 1, tzinfo=pytz.UTC) + self.test_created_datetime = datetime.datetime(2018, 1, 1, tzinfo=get_utc_timezone()) ExternalIdType.objects.get_or_create(name=ExternalIdType.CALIPER) def get_user_dict(self, user, enrollments): @@ -769,7 +769,7 @@ def test_date_filter(self): # retirements = [2018-04-10..., 2018-04-09..., 2018-04-08...] pending_state = RetirementState.objects.get(state_name='PENDING') for days_back in range(1, days_back_to_test, -1): - create_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=days_back) + create_datetime = datetime.datetime.now(get_utc_timezone()) - datetime.timedelta(days=days_back) retirements.append(create_retirement_status( UserFactory(), state=pending_state, @@ -927,12 +927,12 @@ def test_date_filter(self): # Create retirements for the last 10 days for days_back in range(0, 10): # lint-amnesty, pylint: disable=simplifiable-range - create_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=days_back) + create_datetime = datetime.datetime.now(get_utc_timezone()) - datetime.timedelta(days=days_back) ret = create_retirement_status(UserFactory(), state=complete_state, create_datetime=create_datetime) retirements.append(self._retirement_to_dict(ret)) # Go back in time adding days to the query, assert the correct retirements are present - end_date = datetime.datetime.now(pytz.UTC) + end_date = datetime.datetime.now(get_utc_timezone()) for days_back in range(1, 11): retirement_dicts = retirements[:days_back] start_date = end_date - datetime.timedelta(days=days_back - 1) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 466e1e278abd..441085ec1140 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -10,7 +10,7 @@ from urllib.parse import quote import ddt -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from django.core.files.storage import FileSystemStorage from django.test.testcases import TransactionTestCase @@ -44,7 +44,7 @@ from .. import ALL_USERS_VISIBILITY, CUSTOM_VISIBILITY, PRIVATE_VISIBILITY -TEST_PROFILE_IMAGE_UPLOADED_AT = datetime.datetime(2002, 1, 9, 15, 43, 1, tzinfo=pytz.UTC) +TEST_PROFILE_IMAGE_UPLOADED_AT = datetime.datetime(2002, 1, 9, 15, 43, 1, tzinfo=get_utc_timezone()) # this is used in one test to check the behavior of profile image url # generation with a relative url in the config. @@ -304,7 +304,7 @@ def test_cancel_retirement_not_pending(self): current_state=retirement_state, last_state=retirement_state, original_email=self.user.email, - created=datetime.datetime.now(pytz.UTC) + created=datetime.datetime.now(get_utc_timezone()) ) url = reverse("cancel_account_retirement") response = client.post(url, data={'retirement_id': user_retirement_status.id}) @@ -329,7 +329,7 @@ def test_cancel_retirement_successful(self): current_state=retirement_state, last_state=retirement_state, original_email=self.user.email, - created=datetime.datetime.now(pytz.UTC) + created=datetime.datetime.now(get_utc_timezone()) ) user_retirement_status.user.set_unusable_password() assert UserRetirementStatus.objects.count() == 1 @@ -585,8 +585,8 @@ def test_get_account_by_user_id_non_integer(self, non_integer_id): @mock.patch('openedx.core.djangoapps.user_api.accounts.views.is_email_retired') @ddt.data( - (datetime.datetime.now(pytz.UTC), True), - (datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=15), False) + (datetime.datetime.now(get_utc_timezone()), True), + (datetime.datetime.now(get_utc_timezone()) - datetime.timedelta(days=15), False) ) @ddt.unpack def test_search_emails_retired_before_cooloff_period(self, created_date, can_cancel, mock_is_email_retired): diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 55b90aa67f78..ca1467355fde 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -9,7 +9,7 @@ import logging from functools import wraps -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from consent.models import DataSharingConsent from django.apps import apps from django.conf import settings @@ -197,11 +197,12 @@ def list(self, request): if is_email_retired(user_email): can_cancel_retirement = True retirement_id = None - earliest_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=settings.COOL_OFF_DAYS) + earliest_datetime = datetime.datetime.now( + get_utc_timezone()) - datetime.timedelta(days=settings.COOL_OFF_DAYS) try: retirement_status = UserRetirementStatus.objects.get( created__gt=earliest_datetime, - created__lt=datetime.datetime.now(pytz.UTC), + created__lt=datetime.datetime.now(get_utc_timezone()), original_email=user_email, ) retirement_id = retirement_status.id @@ -879,7 +880,7 @@ def retirement_queue(self, request): status=status.HTTP_400_BAD_REQUEST, ) - earliest_datetime = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=cool_off_days) + earliest_datetime = datetime.datetime.now(get_utc_timezone()) - datetime.timedelta(days=cool_off_days) retirements = ( UserRetirementStatus.objects.select_related("user", "current_state", "last_state") @@ -909,11 +910,15 @@ def retirements_by_status_and_date(self, request): so to get one day you would set both dates to that day. """ try: - start_date = datetime.datetime.strptime(request.GET["start_date"], "%Y-%m-%d").replace(tzinfo=pytz.UTC) - end_date = datetime.datetime.strptime(request.GET["end_date"], "%Y-%m-%d").replace(tzinfo=pytz.UTC) - now = datetime.datetime.now(pytz.UTC) + start_date = datetime.datetime.strptime( + request.GET["start_date"], "%Y-%m-%d").replace(tzinfo=get_utc_timezone()) + end_date = datetime.datetime.strptime( + request.GET["end_date"], "%Y-%m-%d").replace(tzinfo=get_utc_timezone()) + now = datetime.datetime.now(get_utc_timezone()) if start_date > now or end_date > now or start_date > end_date: - raise RetirementStateError("Dates must be today or earlier, and start must be earlier than end.") + raise RetirementStateError( + "Dates must be today or earlier, and start must be earlier than end." + ) # Add a day to make sure we get all the way to 23:59:59.999, this is compared "lt" in the query # not "lte". diff --git a/openedx/core/djangoapps/user_api/management/commands/create_user_gdpr_testing.py b/openedx/core/djangoapps/user_api/management/commands/create_user_gdpr_testing.py index 2008ce8652d5..e3ce3c0c05b3 100644 --- a/openedx/core/djangoapps/user_api/management/commands/create_user_gdpr_testing.py +++ b/openedx/core/djangoapps/user_api/management/commands/create_user_gdpr_testing.py @@ -20,7 +20,7 @@ ) from integrated_channels.sap_success_factors.models import SapSuccessFactorsLearnerDataTransmissionAudit from opaque_keys.edx.keys import CourseKey -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from common.djangoapps.entitlements.models import CourseEntitlement, CourseEntitlementSupportDetail from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification @@ -82,7 +82,7 @@ def handle(self, *args, **options): user.save() # UserProfile - profile_image_uploaded_date = datetime(2018, 5, 3, tzinfo=UTC) + profile_image_uploaded_date = datetime(2018, 5, 3, tzinfo=get_utc_timezone()) user_profile, __ = UserProfile.objects.get_or_create( user=user ) 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 34295801801b..fa6b4c5803fa 100644 --- a/openedx/core/djangoapps/user_api/preferences/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/preferences/tests/test_api.py @@ -11,7 +11,8 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.test.utils import override_settings from django.urls import reverse -from pytz import common_timezones, utc +from pytz import common_timezones +from openedx.core.lib.time_zone_utils import get_utc_timezone from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.core.lib.time_zone_utils import get_display_time_zone @@ -372,7 +373,7 @@ def test_change_email_optin(self, age, option, second_option, expected_result): # 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(get_utc_timezone()).year - age profile.year_of_birth = year_of_birth profile.save() @@ -403,23 +404,26 @@ class CountryTimeZoneTest(CacheIsolationTestCase): """ @ddt.data(('ES', ['Africa/Ceuta', 'Atlantic/Canary', 'Europe/Madrid']), - (None, common_timezones[:10]), - ('AA', common_timezones[:10])) + (None, common_timezones), + ('AA', common_timezones)) @ddt.unpack def test_get_country_time_zones(self, country_code, expected_time_zones): """ Verify that list of common country time zones dictionaries is returned An unrecognized country code (e.g. AA) will return the list of common timezones """ - expected_dict = [ - { - 'time_zone': time_zone, - 'description': get_display_time_zone(time_zone) - } - for time_zone in expected_time_zones - ] + expected_dict = sorted( + [ + { + 'time_zone': time_zone_name, + 'description': get_display_time_zone(time_zone_name), + } + for time_zone_name in expected_time_zones + ], + key=lambda tz_dict: tz_dict['description'] + ) country_time_zones_dicts = get_country_time_zones(country_code)[:10] - assert country_time_zones_dicts == expected_dict + assert country_time_zones_dicts == expected_dict[:10] def get_expected_validation_developer_message(preference_key, preference_value): diff --git a/openedx/core/djangoapps/user_authn/tests/utils.py b/openedx/core/djangoapps/user_authn/tests/utils.py index 09ca85145f35..608c62deb724 100644 --- a/openedx/core/djangoapps/user_authn/tests/utils.py +++ b/openedx/core/djangoapps/user_authn/tests/utils.py @@ -6,7 +6,7 @@ from unittest.mock import patch import ddt -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from oauth2_provider import models as dot_models from rest_framework import status @@ -42,7 +42,7 @@ def utcnow(): """ Helper function to return the current UTC time localized to the UTC timezone. """ - return datetime.now(pytz.UTC) + return datetime.now(get_utc_timezone()) @ddt.ddt diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index aff210e7b26d..08e8b956a289 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -26,7 +26,7 @@ from openedx_events.learning.data import UserData, UserPersonalData from openedx_events.learning.signals import STUDENT_REGISTRATION_COMPLETED from openedx_filters.learning.filters import StudentRegistrationRequested -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from django_ratelimit.decorators import ratelimit from requests import HTTPError from rest_framework.response import Response @@ -371,7 +371,7 @@ def _track_user_registration(user, profile, params, third_party_provider, regist 'name': profile.name, # Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey. 'age': profile.age or -1, - 'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year, + 'yearOfBirth': profile.year_of_birth or datetime.datetime.now(get_utc_timezone()).year, 'education': profile.level_of_education_display, 'address': profile.mailing_address, 'gender': profile.gender_display, @@ -530,7 +530,8 @@ def _record_utm_registration_attribution(request, user): # We divide by 1000 here because the javascript timestamp generated is in milliseconds not seconds. # PYTHON: time.time() => 1475590280.823698 # JS: new Date().getTime() => 1475590280823 - created_at_datetime = datetime.datetime.fromtimestamp(int(created_at_unixtime) / float(1000), tz=UTC) + created_at_datetime = datetime.datetime.fromtimestamp( + int(created_at_unixtime) / float(1000), tz=get_utc_timezone()) UserAttribute.set_user_attribute( user, REGISTRATION_UTM_CREATED_AT, @@ -600,8 +601,7 @@ def post(self, request): errors = { "error_message": [{"user_message": str(exc)}], } - error_code = getattr(exc, "error_code", None) - return self._create_response(request, errors, status_code=exc.status_code, error_code=error_code) + return self._create_response(request, errors, status_code=exc.status_code) response = self._handle_duplicate_email_username(request, data) if response: diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_password.py index a403298a6e78..7de6466a11e6 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_password.py @@ -19,7 +19,7 @@ from freezegun import freeze_time from oauth2_provider.models import AccessToken as dot_access_token from oauth2_provider.models import RefreshToken as dot_refresh_token -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from testfixtures import LogCapture from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories @@ -319,7 +319,7 @@ def test_password_change_rate_limited(self): # now reset the time to 1 min from now in future and change the email and # verify that it will allow another request from same IP - reset_time = datetime.now(UTC) + timedelta(seconds=61) + reset_time = datetime.now(get_utc_timezone()) + timedelta(seconds=61) with freeze_time(reset_time): response = self._change_password(email=self.OLD_EMAIL) assert response.status_code == 200 diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 54d42efa55c0..1ace37081926 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -16,7 +16,7 @@ from django.test.utils import override_settings from django.urls import reverse from openedx_events.tests.utils import OpenEdxEventsTestMixin -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from social_django.models import Partial, UserSocialAuth from testfixtures import LogCapture @@ -949,7 +949,7 @@ def test_register_form_gender_translations(self, fake_gettext): ) def test_register_form_year_of_birth(self): - this_year = datetime.now(UTC).year + this_year = datetime.now(get_utc_timezone()).year year_options = ( [ { diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py index b89b458ed1ae..19f16a7477c3 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_reset_password.py @@ -24,7 +24,7 @@ from django.utils.http import int_to_base36 from freezegun import freeze_time from oauth2_provider import models as dot_models -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -267,7 +267,7 @@ def test_ratelimited_from_different_ips_with_same_email(self): self.request_password_reset(200) # now reset the time to 1 min from now in future and change the email and # verify that it will allow another request from same IP - reset_time = datetime.now(UTC) + timedelta(seconds=61) + reset_time = datetime.now(get_utc_timezone()) + timedelta(seconds=61) with freeze_time(reset_time): for status in [200, 403]: self.request_password_reset(status) diff --git a/openedx/core/djangoapps/util/testing.py b/openedx/core/djangoapps/util/testing.py index 040a2b5af180..75e82f290427 100644 --- a/openedx/core/djangoapps/util/testing.py +++ b/openedx/core/djangoapps/util/testing.py @@ -3,7 +3,7 @@ from datetime import datetime -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory @@ -32,7 +32,7 @@ def setUp(self): # This test needs to use a course that has already started -- # discussion topics only show up if the course has already started, # and the default start date for courses is Jan 1, 2030. - start=datetime(2012, 2, 3, tzinfo=UTC), + start=datetime(2012, 2, 3, tzinfo=get_utc_timezone()), user_partitions=[ UserPartition( 0, diff --git a/openedx/core/djangoapps/verified_track_content/tests/test_partition_scheme.py b/openedx/core/djangoapps/verified_track_content/tests/test_partition_scheme.py index a2667bfa1c0b..6d538a03e3ac 100644 --- a/openedx/core/djangoapps/verified_track_content/tests/test_partition_scheme.py +++ b/openedx/core/djangoapps/verified_track_content/tests/test_partition_scheme.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone import pytest from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment @@ -38,7 +38,7 @@ def test_multiple_groups(self): # Note that the verified mode is expired-- this is intentional. create_mode( self.course, CourseMode.VERIFIED, "Verified Enrollment Track", min_price=1, - expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=-1) + expiration_datetime=datetime.now(get_utc_timezone()) + timedelta(days=-1) ) # Note that the credit mode is not selectable-- this is intentional so we # can test that it is filtered out. @@ -128,7 +128,7 @@ def test_enrolled_in_verified(self): def test_enrolled_in_expired(self): create_mode( self.course, CourseMode.VERIFIED, "Verified Enrollment Track", - min_price=1, expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=-1) + min_price=1, expiration_datetime=datetime.now(get_utc_timezone()) + timedelta(days=-1) ) CourseEnrollment.enroll(self.student, self.course.id, mode=CourseMode.VERIFIED) assert 'Verified Enrollment Track' == self._get_user_group().name @@ -153,7 +153,7 @@ def test_credit_after_upgrade_deadline(self): # the upgrade deadline has passed (see EDUCATOR-1511 for why this matters). create_mode( self.course, CourseMode.VERIFIED, "Verified Enrollment Track", min_price=1, - expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=-1) + expiration_datetime=datetime.now(get_utc_timezone()) + timedelta(days=-1) ) assert 'Verified Enrollment Track' == self._get_user_group().name diff --git a/openedx/core/djangoapps/xblock/tests/test_utils.py b/openedx/core/djangoapps/xblock/tests/test_utils.py index 229406e1bca1..0c5bbd026af9 100644 --- a/openedx/core/djangoapps/xblock/tests/test_utils.py +++ b/openedx/core/djangoapps/xblock/tests/test_utils.py @@ -71,7 +71,7 @@ }, True, ), - # Setting reference_time to 20 seconds before end of a 2 day time period(UTC) + # Setting reference_time to 20 seconds before end of a 2 day time periodget_utc_timezone() # Demonstrating minimum possible validity period is just above 2 days # This fails because validation time is just above the cutoff point ( diff --git a/openedx/core/lib/__init__.py b/openedx/core/lib/__init__.py index 61565e758131..64492942a8d0 100644 --- a/openedx/core/lib/__init__.py +++ b/openedx/core/lib/__init__.py @@ -9,6 +9,19 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from edx_toggles.toggles import WaffleSwitch + + +# .. toggle_name: open_edx_util.enable_zoneinfo_tz +# .. toggle_implementation: WaffleSwitch +# .. toggle_default: False +# .. toggle_description: Replaces pytz.UTC with get_utc_timezone(), when active. +# .. toggle_use_cases: opt_in +# .. toggle_creation_date: 2025-06-03 +# .. toggle_tickets: N/A +ENABLE_ZONEINFO_TZ = WaffleSwitch( + 'open_edx_util.enable_zoneinfo_tz', __name__ +) _LMS_URLCONF = 'lms.urls' _CMS_URLCONF = 'cms.urls' diff --git a/openedx/core/lib/tests/test_time_zone_utils.py b/openedx/core/lib/tests/test_time_zone_utils.py index c876f68be2b0..0f6e6f083754 100644 --- a/openedx/core/lib/tests/test_time_zone_utils.py +++ b/openedx/core/lib/tests/test_time_zone_utils.py @@ -3,8 +3,7 @@ from django.test import TestCase from freezegun import freeze_time -from pytz import timezone - +from zoneinfo import ZoneInfo from openedx.core.lib.time_zone_utils import get_display_time_zone, get_time_zone_abbr, get_time_zone_offset from common.djangoapps.student.tests.factories import UserFactory @@ -27,7 +26,7 @@ def _display_time_zone_helper(self, time_zone_string): 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 = ZoneInfo(time_zone_string) tz_abbr = get_time_zone_abbr(time_zone) tz_offset = get_time_zone_offset(time_zone) @@ -75,6 +74,6 @@ def test_display_time_zone_ambiguous_after(self): 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 """ - with freeze_time("2015-11-01 09:00:00"): + with freeze_time("2024-11-04 09:00:00"): 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 eabd471d1812..79ea8afa2ffd 100644 --- a/openedx/core/lib/time_zone_utils.py +++ b/openedx/core/lib/time_zone_utils.py @@ -4,7 +4,31 @@ from datetime import datetime -from pytz import common_timezones, timezone, utc +from pytz import common_timezones, UTC + +from zoneinfo import ZoneInfo + +from . import ENABLE_ZONEINFO_TZ + + +def get_utc_timezone(): + """ + Returns a UTC timezone object. + + Uses ZoneInfo if the ENABLE_ZONEINFO_TZ toggle is enabled, otherwise falls back to pytz UTC. + If there's an issue checking the toggle (e.g., during app startup), defaults to pytz UTC. + + Returns: + A timezone object representing UTC (either ZoneInfo or pytz UTC) + """ + try: + if ENABLE_ZONEINFO_TZ.is_enabled(): + return ZoneInfo('UTC') + else: + return UTC + except Exception: # pylint: disable=broad-except + # Fallback to UTC if toggle check fails (e.g., during app startup) + return UTC def _format_time_zone_string(time_zone, date_time, format_string): @@ -23,7 +47,7 @@ def get_time_zone_abbr(time_zone, date_time=None): """ Returns the time zone abbreviation (e.g. EST) of the time zone for given datetime """ - date_time = datetime.now(utc) if date_time is None else date_time + date_time = datetime.now(get_utc_timezone()) if date_time is None else date_time return _format_time_zone_string(time_zone, date_time, '%Z') @@ -31,7 +55,7 @@ def get_time_zone_offset(time_zone, date_time=None): """ Returns the time zone offset (e.g. -0800) of the time zone for given datetime """ - date_time = datetime.now(utc) if date_time is None else date_time + date_time = datetime.now(get_utc_timezone()) if date_time is None else date_time return _format_time_zone_string(time_zone, date_time, '%z') @@ -41,7 +65,7 @@ def get_display_time_zone(time_zone_name): :param time_zone_name (str): Name of Pytz time zone """ - time_zone = timezone(time_zone_name) + time_zone = ZoneInfo(time_zone_name) tz_abbr = get_time_zone_abbr(time_zone) tz_offset = get_time_zone_offset(time_zone) diff --git a/openedx/core/lib/xblock_utils/__init__.py b/openedx/core/lib/xblock_utils/__init__.py index a8b76541b6e5..72caffc65cb0 100644 --- a/openedx/core/lib/xblock_utils/__init__.py +++ b/openedx/core/lib/xblock_utils/__init__.py @@ -19,7 +19,7 @@ from edx_django_utils.plugins import pluggable_override from lxml import etree, html from opaque_keys.edx.asides import AsideUsageKeyV1, AsideUsageKeyV2 -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.exceptions import InvalidScopeError @@ -310,7 +310,7 @@ def add_staff_markup(user, disable_staff_debug_info, block, view, frag, context) # Useful to indicate to staff if problem has been released or not. # TODO (ichuang): use _has_access_block.can_load in lms.courseware.access, # instead of now>mstart comparison here. - now = datetime.datetime.now(UTC) + now = datetime.datetime.now(get_utc_timezone()) is_released = "unknown" mstart = block.start diff --git a/openedx/features/calendar_sync/ics.py b/openedx/features/calendar_sync/ics.py index fc465443e714..7eb5ce181ae8 100644 --- a/openedx/features/calendar_sync/ics.py +++ b/openedx/features/calendar_sync/ics.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from django.utils.translation import gettext as _ from icalendar import Calendar, Event, vCalAddress, vText @@ -59,7 +59,7 @@ def generate_ics_files_for_user_course(course, user, user_calendar_sync_config_i assignments = get_course_assignments(course.id, user) platform_name = get_value('platform_name', settings.PLATFORM_NAME) platform_email = get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) - now = datetime.now(pytz.utc) + now = datetime.now(get_utc_timezone()) site_config = SiteConfiguration.get_configuration_for_org(course.org) ics_files = {} diff --git a/openedx/features/calendar_sync/tests/test_ics.py b/openedx/features/calendar_sync/tests/test_ics.py index 02301079285d..a9e2bcc0299c 100644 --- a/openedx/features/calendar_sync/tests/test_ics.py +++ b/openedx/features/calendar_sync/tests/test_ics.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from unittest.mock import patch -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.test import RequestFactory, TestCase from freezegun import freeze_time @@ -21,7 +21,7 @@ class TestIcsGeneration(TestCase): def setUp(self): super().setUp() - freezer = freeze_time(datetime(2013, 10, 3, 8, 24, 55, tzinfo=pytz.utc)) + freezer = freeze_time(datetime(2013, 10, 3, 8, 24, 55, tzinfo=get_utc_timezone())) self.addCleanup(freezer.stop) freezer.start() @@ -103,7 +103,7 @@ def assert_ics(self, *assignments): def test_generate_ics_for_user_course(self): """ Tests that a simple sample set of course assignments is generated correctly """ - now = datetime.now(pytz.utc) + now = datetime.now(get_utc_timezone()) day1 = now + timedelta(1) day2 = now + timedelta(1) diff --git a/openedx/features/content_type_gating/partitions.py b/openedx/features/content_type_gating/partitions.py index 61851ec43bbd..e6dd304fe808 100644 --- a/openedx/features/content_type_gating/partitions.py +++ b/openedx/features/content_type_gating/partitions.py @@ -10,7 +10,7 @@ import logging import crum -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.template.loader import render_to_string from django.utils.translation import gettext_lazy as _ from web_fragments.fragment import Fragment @@ -88,7 +88,7 @@ def access_denied_fragment(self, block, user, user_group, allowed_groups): return None expiration_datetime = verified_mode.expiration_datetime - if expiration_datetime and expiration_datetime < datetime.datetime.now(pytz.UTC): + if expiration_datetime and expiration_datetime < datetime.datetime.now(get_utc_timezone()): ecommerce_checkout_link = None else: ecommerce_checkout_link = self._get_checkout_link(user, verified_mode.sku, str(course_key)) diff --git a/openedx/features/content_type_gating/tests/test_models.py b/openedx/features/content_type_gating/tests/test_models.py index 673ce805a750..9d6a1a27fd28 100644 --- a/openedx/features/content_type_gating/tests/test_models.py +++ b/openedx/features/content_type_gating/tests/test_models.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta # lint-amnesty, pylint: disable=wrong-import-order import ddt -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.utils import timezone from edx_django_utils.cache import RequestCache from unittest.mock import Mock # lint-amnesty, pylint: disable=wrong-import-order @@ -217,17 +217,17 @@ def test_all_current_course_configs(self): # Point-test some of the final configurations assert all_configs[CourseLocator('7-True', 'test_course', 'run-None')] == { 'enabled': (True, Provenance.org), - 'enabled_as_of': (datetime(2018, 1, 1, 0, tzinfo=pytz.UTC), Provenance.run), + 'enabled_as_of': (datetime(2018, 1, 1, 0, tzinfo=get_utc_timezone()), Provenance.run), 'studio_override_enabled': (None, Provenance.default) } assert all_configs[CourseLocator('7-True', 'test_course', 'run-False')] == { 'enabled': (False, Provenance.run), - 'enabled_as_of': (datetime(2018, 1, 1, 0, tzinfo=pytz.UTC), Provenance.run), + 'enabled_as_of': (datetime(2018, 1, 1, 0, tzinfo=get_utc_timezone()), Provenance.run), 'studio_override_enabled': (None, Provenance.default) } assert all_configs[CourseLocator('7-None', 'test_course', 'run-None')] == { 'enabled': (True, Provenance.site), - 'enabled_as_of': (datetime(2018, 1, 1, 0, tzinfo=pytz.UTC), Provenance.run), + 'enabled_as_of': (datetime(2018, 1, 1, 0, tzinfo=get_utc_timezone()), Provenance.run), 'studio_override_enabled': (None, Provenance.default) } diff --git a/openedx/features/course_duration_limits/tests/test_access.py b/openedx/features/course_duration_limits/tests/test_access.py index 558afec22ab3..10f48fd14ed8 100644 --- a/openedx/features/course_duration_limits/tests/test_access.py +++ b/openedx/features/course_duration_limits/tests/test_access.py @@ -8,7 +8,7 @@ from crum import set_current_request from django.test import RequestFactory from django.utils import timezone -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from common.djangoapps.course_modes.models import CourseMode @@ -34,9 +34,13 @@ class TestAccess(ModuleStoreTestCase): def setUp(self): super().setUp() # lint-amnesty, pylint: disable=super-with-arguments - CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=UTC)) + CourseDurationLimitConfig.objects.create( + enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone()) + ) DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True) - self.course = CourseOverviewFactory.create(start=datetime(2018, 1, 1, tzinfo=UTC), self_paced=True) + self.course = CourseOverviewFactory.create( + start=datetime(2018, 1, 1, tzinfo=get_utc_timezone()), self_paced=True + ) def assertDateInMessage(self, date, message): # lint-amnesty, pylint: disable=missing-function-docstring # First, check that the formatted version is in there @@ -148,7 +152,7 @@ def test_schedule_start_date_in_past(self): course_id=enrollment.course.id, mode_slug=CourseMode.AUDIT, ) - Schedule.objects.update(start_date=datetime(2017, 1, 1, tzinfo=UTC)) + Schedule.objects.update(start_date=datetime(2017, 1, 1, tzinfo=get_utc_timezone())) content_availability_date = max(enrollment.created, enrollment.course.start) access_duration = get_user_course_duration(enrollment.user, enrollment.course) diff --git a/openedx/features/course_duration_limits/tests/test_models.py b/openedx/features/course_duration_limits/tests/test_models.py index 0473faefd330..fd46c082673e 100644 --- a/openedx/features/course_duration_limits/tests/test_models.py +++ b/openedx/features/course_duration_limits/tests/test_models.py @@ -8,7 +8,7 @@ import ddt import pytest -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.utils import timezone from edx_django_utils.cache import RequestCache from opaque_keys.edx.locator import CourseLocator @@ -178,13 +178,14 @@ def test_config_overrides(self, global_setting, site_setting, org_setting, cours def test_all_current_course_configs(self): # Set up test objects for global_setting in (True, False, None): - CourseDurationLimitConfig.objects.create(enabled=global_setting, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC)) # lint-amnesty, pylint: disable=line-too-long + CourseDurationLimitConfig.objects.create(enabled=global_setting, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone())) # lint-amnesty, pylint: disable=line-too-long for site_setting in (True, False, None): test_site_cfg = SiteConfigurationFactory.create( site_values={'course_org_filter': []} ) CourseDurationLimitConfig.objects.create( - site=test_site_cfg.site, enabled=site_setting, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC) + site=test_site_cfg.site, enabled=site_setting, enabled_as_of=datetime( + 2018, 1, 1, tzinfo=get_utc_timezone()) ) for org_setting in (True, False, None): @@ -193,7 +194,7 @@ def test_all_current_course_configs(self): test_site_cfg.save() CourseDurationLimitConfig.objects.create( - org=test_org, enabled=org_setting, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC) + org=test_org, enabled=org_setting, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone()) ) for course_setting in (True, False, None): @@ -202,7 +203,7 @@ def test_all_current_course_configs(self): id=CourseLocator(test_org, 'test_course', f'run-{course_setting}') ) CourseDurationLimitConfig.objects.create( - course=test_course, enabled=course_setting, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC) # lint-amnesty, pylint: disable=line-too-long + course=test_course, enabled=course_setting, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone()) # lint-amnesty, pylint: disable=line-too-long ) with self.assertNumQueries(4): @@ -216,22 +217,23 @@ def test_all_current_course_configs(self): # Point-test some of the final configurations assert all_configs[CourseLocator('7-True', 'test_course', 'run-None')] == { 'enabled': (True, Provenance.org), - 'enabled_as_of': (datetime(2018, 1, 1, 0, tzinfo=pytz.UTC), + 'enabled_as_of': (datetime(2018, 1, 1, 0, tzinfo=get_utc_timezone()), Provenance.run) } assert all_configs[CourseLocator('7-True', 'test_course', 'run-False')] == { 'enabled': (False, Provenance.run), - 'enabled_as_of': (datetime(2018, 1, 1, 0, tzinfo=pytz.UTC), + 'enabled_as_of': (datetime(2018, 1, 1, 0, tzinfo=get_utc_timezone()), Provenance.run) } assert all_configs[CourseLocator('7-None', 'test_course', 'run-None')] == { 'enabled': (True, Provenance.site), - 'enabled_as_of': (datetime(2018, 1, 1, 0, tzinfo=pytz.UTC), + 'enabled_as_of': (datetime(2018, 1, 1, 0, tzinfo=get_utc_timezone()), Provenance.run) } def test_caching_global(self): - global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC)) + global_config = CourseDurationLimitConfig( + enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone())) global_config.save() RequestCache.clear_all_namespaces() @@ -257,7 +259,7 @@ def test_caching_global(self): def test_caching_site(self): site_cfg = SiteConfigurationFactory() - site_config = CourseDurationLimitConfig(site=site_cfg.site, enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC)) # lint-amnesty, pylint: disable=line-too-long + site_config = CourseDurationLimitConfig(site=site_cfg.site, enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone())) # lint-amnesty, pylint: disable=line-too-long site_config.save() RequestCache.clear_all_namespaces() @@ -281,7 +283,8 @@ def test_caching_site(self): with self.assertNumQueries(1): assert not CourseDurationLimitConfig.current(site=site_cfg.site).enabled - global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC)) + global_config = CourseDurationLimitConfig( + enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone())) global_config.save() RequestCache.clear_all_namespaces() @@ -295,7 +298,7 @@ def test_caching_org(self): site_cfg = SiteConfigurationFactory.create( site_values={'course_org_filter': course.org} ) - org_config = CourseDurationLimitConfig(org=course.org, enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC)) # lint-amnesty, pylint: disable=line-too-long + org_config = CourseDurationLimitConfig(org=course.org, enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone())) # lint-amnesty, pylint: disable=line-too-long org_config.save() RequestCache.clear_all_namespaces() @@ -319,7 +322,8 @@ def test_caching_org(self): with self.assertNumQueries(2): assert not CourseDurationLimitConfig.current(org=course.org).enabled - global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC)) + global_config = CourseDurationLimitConfig( + enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone())) global_config.save() RequestCache.clear_all_namespaces() @@ -328,7 +332,7 @@ def test_caching_org(self): with self.assertNumQueries(0): assert not CourseDurationLimitConfig.current(org=course.org).enabled - site_config = CourseDurationLimitConfig(site=site_cfg.site, enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC)) # lint-amnesty, pylint: disable=line-too-long + site_config = CourseDurationLimitConfig(site=site_cfg.site, enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone())) # lint-amnesty, pylint: disable=line-too-long site_config.save() RequestCache.clear_all_namespaces() @@ -342,7 +346,7 @@ def test_caching_course(self): site_cfg = SiteConfigurationFactory.create( site_values={'course_org_filter': course.org} ) - course_config = CourseDurationLimitConfig(course=course, enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC)) # lint-amnesty, pylint: disable=line-too-long + course_config = CourseDurationLimitConfig(course=course, enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone())) # lint-amnesty, pylint: disable=line-too-long course_config.save() RequestCache.clear_all_namespaces() @@ -366,7 +370,8 @@ def test_caching_course(self): with self.assertNumQueries(2): assert not CourseDurationLimitConfig.current(course_key=course.id).enabled - global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC)) + global_config = CourseDurationLimitConfig( + enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone())) global_config.save() RequestCache.clear_all_namespaces() @@ -375,7 +380,7 @@ def test_caching_course(self): with self.assertNumQueries(0): assert not CourseDurationLimitConfig.current(course_key=course.id).enabled - site_config = CourseDurationLimitConfig(site=site_cfg.site, enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC)) # lint-amnesty, pylint: disable=line-too-long + site_config = CourseDurationLimitConfig(site=site_cfg.site, enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone())) # lint-amnesty, pylint: disable=line-too-long site_config.save() RequestCache.clear_all_namespaces() @@ -384,7 +389,7 @@ def test_caching_course(self): with self.assertNumQueries(0): assert not CourseDurationLimitConfig.current(course_key=course.id).enabled - org_config = CourseDurationLimitConfig(org=course.org, enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=pytz.UTC)) # lint-amnesty, pylint: disable=line-too-long + org_config = CourseDurationLimitConfig(org=course.org, enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone())) # lint-amnesty, pylint: disable=line-too-long org_config.save() RequestCache.clear_all_namespaces() diff --git a/openedx/features/course_experience/tests/views/test_course_updates.py b/openedx/features/course_experience/tests/views/test_course_updates.py index 379be52ed40f..ad365df2046a 100644 --- a/openedx/features/course_experience/tests/views/test_course_updates.py +++ b/openedx/features/course_experience/tests/views/test_course_updates.py @@ -5,7 +5,7 @@ from datetime import datetime from django.urls import reverse -from pytz import UTC +from openedx.core.lib.time_zone_utils import get_utc_timezone from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES from openedx.features.content_type_gating.models import ContentTypeGatingConfig @@ -41,7 +41,9 @@ def test_view(self): self.assertContains(response, 'Second Message') def test_queries(self): - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=UTC)) + ContentTypeGatingConfig.objects.create( + enabled=True, enabled_as_of=datetime(2018, 1, 1, tzinfo=get_utc_timezone()) + ) self.create_course_update('First Message') # Pre-fetch the view to populate any caches diff --git a/openedx/features/discounts/applicability.py b/openedx/features/discounts/applicability.py index 97d6f74403bd..c3f52237bd78 100644 --- a/openedx/features/discounts/applicability.py +++ b/openedx/features/discounts/applicability.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from crum import get_current_request, impersonate from django.conf import settings from django.utils import timezone @@ -197,7 +197,7 @@ def _is_in_holdback_and_bucket(user): Return whether the specified user is in the first-purchase-discount holdback group. This will also stable bucket the user. """ - if datetime(2020, 8, 1, tzinfo=pytz.UTC) <= datetime.now(tz=pytz.UTC): + if datetime(2020, 8, 1, tzinfo=get_utc_timezone()) <= datetime.now(tz=get_utc_timezone()): return False # Holdback is 10% diff --git a/openedx/features/discounts/tests/test_applicability.py b/openedx/features/discounts/tests/test_applicability.py index 60dbe7a67edf..4a83275120e4 100644 --- a/openedx/features/discounts/tests/test_applicability.py +++ b/openedx/features/discounts/tests/test_applicability.py @@ -6,7 +6,7 @@ import ddt import pytest -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.contrib.sites.models import Site from django.utils.timezone import now from edx_toggles.toggles.testutils import override_waffle_flag @@ -39,7 +39,7 @@ def setUp(self): self.user = UserFactory.create() self.course = CourseFactory.create(run='test', display_name='test') CourseModeFactory.create(course_id=self.course.id, mode_slug='verified') - now_time = datetime.now(tz=pytz.UTC).strftime("%Y-%m-%d %H:%M:%S%z") + now_time = datetime.now(tz=get_utc_timezone()).strftime("%Y-%m-%d %H:%M:%S%z") ExperimentData.objects.create( user=self.user, experiment_id=REV1008_EXPERIMENT_ID, key=str(self.course.id), value=now_time ) @@ -175,6 +175,6 @@ def test_holdback_expiry(self): with patch('openedx.features.discounts.applicability.stable_bucketing_hash_group', return_value=0): with patch( 'openedx.features.discounts.applicability.datetime', - Mock(now=Mock(return_value=datetime(2020, 8, 1, 0, 1, tzinfo=pytz.UTC)), wraps=datetime), + Mock(now=Mock(return_value=datetime(2020, 8, 1, 0, 1, tzinfo=get_utc_timezone())), wraps=datetime), ): assert not _is_in_holdback_and_bucket(self.user) diff --git a/openedx/features/discounts/utils.py b/openedx/features/discounts/utils.py index 92490f19bcb3..6335cc9f81bd 100644 --- a/openedx/features/discounts/utils.py +++ b/openedx/features/discounts/utils.py @@ -4,7 +4,7 @@ from datetime import datetime -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from django.conf import settings from django.utils.translation import get_language from django.utils.translation import gettext as _ @@ -89,7 +89,7 @@ def generate_offer_data(user, course): ExperimentData.objects.get_or_create( user=user, experiment_id=REV1008_EXPERIMENT_ID, key=str(course), defaults={ - 'value': datetime.now(tz=pytz.UTC).strftime('%Y-%m-%d %H:%M:%S%z'), + 'value': datetime.now(tz=get_utc_timezone()).strftime('%Y-%m-%d %H:%M:%S%z'), }, ) diff --git a/openedx/tests/completion_integration/test_handlers.py b/openedx/tests/completion_integration/test_handlers.py index 677a7514628e..3d7a39ddb6c0 100644 --- a/openedx/tests/completion_integration/test_handlers.py +++ b/openedx/tests/completion_integration/test_handlers.py @@ -11,7 +11,7 @@ from completion.models import BlockCompletion from completion.test_utils import CompletionSetUpMixin from django.test import TestCase -from pytz import utc +from openedx.core.lib.time_zone_utils import get_utc_timezone from xblock.completable import XBlockCompletionMode from xblock.core import XBlock @@ -66,7 +66,7 @@ def call_scorable_block_completion_handler(self, block_key, score_deleted=None): usage_id=str(block_key), weighted_earned=0.0, weighted_possible=3.0, - modified=datetime.utcnow().replace(tzinfo=utc), + modified=datetime.utcnow().replace(tzinfo=get_utc_timezone()), score_db_table='submissions', **params ) @@ -127,7 +127,7 @@ def test_signal_calls_handler(self): usage_id=str(self.block_key), weighted_earned=0.0, weighted_possible=3.0, - modified=datetime.utcnow().replace(tzinfo=utc), + modified=datetime.utcnow().replace(tzinfo=get_utc_timezone()), score_db_table='submissions', ) mock_handler.assert_called() @@ -153,7 +153,7 @@ def test_disabled_handler_does_not_submit_completion(self): usage_id=str(self.block_key), weighted_earned=0.0, weighted_possible=3.0, - modified=datetime.utcnow().replace(tzinfo=utc), + modified=datetime.utcnow().replace(tzinfo=get_utc_timezone()), score_db_table='submissions', ) with pytest.raises(BlockCompletion.DoesNotExist): diff --git a/openedx/tests/xblock_integration/xblock_testcase.py b/openedx/tests/xblock_integration/xblock_testcase.py index 6f598a342cb1..95e260f99a86 100644 --- a/openedx/tests/xblock_integration/xblock_testcase.py +++ b/openedx/tests/xblock_integration/xblock_testcase.py @@ -44,7 +44,7 @@ import html from unittest import mock -import pytz +from openedx.core.lib.time_zone_utils import get_utc_timezone from bs4 import BeautifulSoup from django.conf import settings from django.urls import reverse @@ -199,7 +199,7 @@ def capture_score(user_id, usage_key, score, max_score): 'score': score, 'max_score': max_score}) # Shim a return time, defaults to 1 hour before now - return datetime.now().replace(tzinfo=pytz.UTC) - timedelta(hours=1) + return datetime.now().replace(tzinfo=get_utc_timezone()) - timedelta(hours=1) self.scores = [] patcher = mock.patch("lms.djangoapps.grades.signals.handlers.set_score", capture_score) From e63caf651c04538c85bd7680d34f83b5a044330c Mon Sep 17 00:00:00 2001 From: ttak-apphelix Date: Mon, 25 Aug 2025 10:36:54 +0000 Subject: [PATCH 02/14] fix: update query count in credit requirement API --- openedx/core/djangoapps/credit/tests/test_api.py | 2 +- openedx/core/djangoapps/notifications/tests/test_views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/credit/tests/test_api.py b/openedx/core/djangoapps/credit/tests/test_api.py index 508d826c2423..05839aed9eb4 100644 --- a/openedx/core/djangoapps/credit/tests/test_api.py +++ b/openedx/core/djangoapps/credit/tests/test_api.py @@ -651,7 +651,7 @@ def test_satisfy_all_requirements(self): api.set_credit_requirements(self.course_key, requirements) # Satisfy one of the requirements, but not the other - with self.assertNumQueries(11): + with self.assertNumQueries(12): api.set_credit_requirement_status( user, self.course_key, diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index dc9666800672..696f2a356a0c 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -33,7 +33,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from ..base_notification import COURSE_NOTIFICATION_APPS, NotificationTypeManager +from ..base_notification import COURSE_NOTIFICATION_APPS, NotificationTypeManager, COURSE_NOTIFICATION_TYPES from ..utils import get_notification_types_with_visibility_settings, exclude_inaccessible_preferences User = get_user_model() From 8f00d36074c2c148235ab5f285f0eeee6d288f61 Mon Sep 17 00:00:00 2001 From: ttak-apphelix Date: Mon, 25 Aug 2025 11:47:01 +0000 Subject: [PATCH 03/14] fix: update audience query count in email digest tests --- openedx/core/djangoapps/notifications/email/tests/test_tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/notifications/email/tests/test_tasks.py b/openedx/core/djangoapps/notifications/email/tests/test_tasks.py index 3cc96e002e9b..9b8756bad10d 100644 --- a/openedx/core/djangoapps/notifications/email/tests/test_tasks.py +++ b/openedx/core/djangoapps/notifications/email/tests/test_tasks.py @@ -283,7 +283,7 @@ def test_email_is_sent_to_user_when_task_is_called(self, mock_func): assert mock_func.call_count == 1 def test_audience_query_count(self): - with self.assertNumQueries(1): + with self.assertNumQueries(2): audience = get_audience_for_cadence_email(EmailCadence.DAILY) list(audience) # evaluating queryset From 4497b44d765c32a9e62f4474083d8b0659aecaa9 Mon Sep 17 00:00:00 2001 From: ttak-apphelix Date: Mon, 25 Aug 2025 12:25:49 +0000 Subject: [PATCH 04/14] fix: update error response detail format --- openedx/core/djangoapps/notifications/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index 696f2a356a0c..3661176fa909 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -443,7 +443,7 @@ def test_mark_notification_read_with_invalid_notification_id(self): response = self.client.patch(self.url, data) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(response.data["detail"], 'Not found.') + self.assertEqual(response.data["detail"].code, 'not_found') def test_mark_notification_read_with_app_name_and_notification_id(self): # Create a PATCH request to mark notification as read for existing app e.g 'discussion' and notification_id: 2 From 1428d81aa2492e6ff7128e0d33b9588a1d01b5be Mon Sep 17 00:00:00 2001 From: Tarun Tak Date: Tue, 23 Sep 2025 10:25:39 +0000 Subject: [PATCH 05/14] fix: resolve merge conflict in saml.py --- common/djangoapps/third_party_auth/management/commands/saml.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/common/djangoapps/third_party_auth/management/commands/saml.py b/common/djangoapps/third_party_auth/management/commands/saml.py index eeeb2b90f220..afe369c2ade0 100644 --- a/common/djangoapps/third_party_auth/management/commands/saml.py +++ b/common/djangoapps/third_party_auth/management/commands/saml.py @@ -9,10 +9,7 @@ from edx_django_utils.monitoring import set_custom_attribute from common.djangoapps.third_party_auth.tasks import fetch_saml_metadata -<<<<<<<<< Temporary merge branch 1 -========= from common.djangoapps.third_party_auth.models import SAMLProviderConfig, SAMLConfiguration ->>>>>>>>> Temporary merge branch 2 class Command(BaseCommand): From f1581962a6fd6f9c05d1286faf3ce72b4795bb60 Mon Sep 17 00:00:00 2001 From: Tarun Tak Date: Wed, 24 Sep 2025 08:54:56 +0000 Subject: [PATCH 06/14] fix: replace pytz with get_utc_timezone --- .../management/commands/tests/test_send_email_base_command.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_email_base_command.py b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_email_base_command.py index 0e675b19897b..ade07e695360 100644 --- a/openedx/core/djangoapps/schedules/management/commands/tests/test_send_email_base_command.py +++ b/openedx/core/djangoapps/schedules/management/commands/tests/test_send_email_base_command.py @@ -33,7 +33,7 @@ def test_handle(self): self.command.handle(site_domain_name=self.site.domain, date='2017-09-29') send_emails.assert_called_once_with( self.site, - datetime.datetime(2017, 9, 29, tzinfo=pytz.UTC), + datetime.datetime(2017, 9, 29, tzinfo=get_utc_timezone()), None, None ) @@ -45,7 +45,7 @@ def test_handle_all_sites(self): for expected_site in expected_sites: send_emails.assert_any_call( expected_site, - datetime.datetime(2017, 9, 29, tzinfo=pytz.UTC), + datetime.datetime(2017, 9, 29, tzinfo=get_utc_timezone()), None, None ) From 75335ab9c94a496bb908648780ee98797d0c958c Mon Sep 17 00:00:00 2001 From: Tarun Tak Date: Wed, 24 Sep 2025 10:42:54 +0000 Subject: [PATCH 07/14] feat: add timezone utility functions with toggle support for ZoneInfo and pytz --- .../core/lib/tests/test_time_zone_utils.py | 96 ++++++++++++++++++- openedx/core/lib/time_zone_utils.py | 40 ++++++-- 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/openedx/core/lib/tests/test_time_zone_utils.py b/openedx/core/lib/tests/test_time_zone_utils.py index 0f6e6f083754..427643f20ddf 100644 --- a/openedx/core/lib/tests/test_time_zone_utils.py +++ b/openedx/core/lib/tests/test_time_zone_utils.py @@ -1,13 +1,22 @@ """Tests covering time zone utilities.""" +import ddt +from unittest.mock import patch from django.test import TestCase from freezegun import freeze_time from zoneinfo import ZoneInfo -from openedx.core.lib.time_zone_utils import get_display_time_zone, get_time_zone_abbr, get_time_zone_offset +from openedx.core.lib.time_zone_utils import ( + get_display_time_zone, + get_time_zone_abbr, + get_time_zone_offset, + get_utc_timezone, + get_common_timezones +) from common.djangoapps.student.tests.factories import UserFactory +@ddt.ddt class TestTimeZoneUtils(TestCase): """ Tests the time zone utilities @@ -42,6 +51,91 @@ def _assert_time_zone_info_equal(self, display_tz_info, expected_name, expected_ assert display_tz_info['abbr'] == expected_abbr assert display_tz_info['offset'] == expected_offset + # New tests for newly added functions + @ddt.data( + (True, ZoneInfo), # ZoneInfo enabled + (False, 'pytz.UTC'), # ZoneInfo disabled + ) + @ddt.unpack + @patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') + def test_get_utc_timezone(self, toggle_enabled, expected_type, mock_toggle): + """Test get_utc_timezone returns correct timezone object based on toggle""" + mock_toggle.is_enabled.return_value = toggle_enabled + + utc_tz = get_utc_timezone() + + if toggle_enabled: + self.assertIsInstance(utc_tz, ZoneInfo) + self.assertEqual(str(utc_tz), 'UTC') + else: + from pytz import UTC + self.assertEqual(utc_tz, UTC) + + @patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') + def test_get_utc_timezone_fallback_on_exception(self, mock_toggle): + """Test get_utc_timezone falls back to pytz UTC when toggle check fails""" + mock_toggle.is_enabled.side_effect = Exception("Toggle check failed") + + utc_tz = get_utc_timezone() + + # Should fallback to pytz UTC + from pytz import UTC + self.assertEqual(utc_tz, UTC) + + @ddt.data( + (True, 'zoneinfo'), # ZoneInfo enabled + (False, 'pytz'), # ZoneInfo disabled + ) + @ddt.unpack + @patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') + def test_get_common_timezones(self, toggle_enabled, expected_source, mock_toggle): + """Test get_common_timezones returns correct timezone list based on toggle""" + mock_toggle.is_enabled.return_value = toggle_enabled + + timezones = get_common_timezones() + + if toggle_enabled: + from zoneinfo import available_timezones + self.assertEqual(timezones, available_timezones()) + else: + from pytz import common_timezones + self.assertEqual(timezones, common_timezones) + + @patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') + def test_get_common_timezones_fallback_on_exception(self, mock_toggle): + """Test get_common_timezones falls back to pytz when toggle check fails""" + mock_toggle.is_enabled.side_effect = Exception("Toggle check failed") + + timezones = get_common_timezones() + + from pytz import common_timezones + self.assertEqual(timezones, common_timezones) + + @ddt.data( + (True, 'zoneinfo'), # ZoneInfo enabled + (False, 'pytz'), # ZoneInfo disabled + ) + @ddt.unpack + @patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') + def test_get_display_time_zone_with_toggle(self, toggle_enabled, expected_source, mock_toggle): + """Test get_display_time_zone works correctly with both implementations""" + mock_toggle.is_enabled.return_value = toggle_enabled + + with freeze_time("2015-02-09"): + result = get_display_time_zone('America/Los_Angeles') + expected = 'America/Los Angeles (PST, UTC-0800)' + self.assertEqual(result, expected) + + @patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') + def test_get_display_time_zone_fallback_on_exception(self, mock_toggle): + """Test get_display_time_zone falls back to pytz when toggle check fails""" + mock_toggle.is_enabled.side_effect = Exception("Toggle check failed") + + with freeze_time("2015-02-09"): + result = get_display_time_zone('America/Los_Angeles') + expected = 'America/Los Angeles (PST, UTC-0800)' + self.assertEqual(result, expected) + def test_display_time_zone_without_dst(self): """ Test to ensure get_display_time_zone() returns full display string when no kwargs specified diff --git a/openedx/core/lib/time_zone_utils.py b/openedx/core/lib/time_zone_utils.py index 79ea8afa2ffd..540cb84196a5 100644 --- a/openedx/core/lib/time_zone_utils.py +++ b/openedx/core/lib/time_zone_utils.py @@ -4,9 +4,9 @@ from datetime import datetime -from pytz import common_timezones, UTC +from pytz import common_timezones, UTC, timezone -from zoneinfo import ZoneInfo +from zoneinfo import ZoneInfo, available_timezones from . import ENABLE_ZONEINFO_TZ @@ -35,7 +35,7 @@ def _format_time_zone_string(time_zone, date_time, format_string): """ Returns a string, specified by format string, of the current date/time of the time zone. - :param time_zone: Pytz time zone object + :param time_zone: Timezone object (either pytz or ZoneInfo) :param date_time: datetime object of date to convert :param format_string: A list of format codes can be found at: https://docs.python.org/2/library/datetime.html#strftime-and-strptime-behavior @@ -63,16 +63,44 @@ def get_display_time_zone(time_zone_name): """ Returns a formatted display time zone (e.g. 'Asia/Tokyo (JST, UTC+0900)') - :param time_zone_name (str): Name of Pytz time zone + :param time_zone_name (str): Name of time zone """ - time_zone = ZoneInfo(time_zone_name) + try: + if ENABLE_ZONEINFO_TZ.is_enabled(): + time_zone = ZoneInfo(time_zone_name) + else: + time_zone = timezone(time_zone_name) + except Exception: # pylint: disable=broad-except + # Fallback to pytz if toggle check fails (e.g., during app startup) + time_zone = timezone(time_zone_name) + tz_abbr = get_time_zone_abbr(time_zone) tz_offset = get_time_zone_offset(time_zone) return f"{time_zone} ({tz_abbr}, UTC{tz_offset})".replace("_", " ") +def get_common_timezones(): + """ + Returns a list of common timezone names. + + Uses ZoneInfo if the ENABLE_ZONEINFO_TZ toggle is enabled, otherwise falls back to pytz common_timezones. + If there's an issue checking the toggle (e.g., during app startup), defaults to pytz common_timezones. + + Returns: + A list/set of timezone name strings + """ + try: + if ENABLE_ZONEINFO_TZ.is_enabled(): + return available_timezones() + else: + return common_timezones + except Exception: # pylint: disable=broad-except + # Fallback to pytz if toggle check fails (e.g., during app startup) + return common_timezones + + TIME_ZONE_CHOICES = sorted( - [(tz, get_display_time_zone(tz)) for tz in common_timezones], + [(tz, get_display_time_zone(tz)) for tz in get_common_timezones()], key=lambda tz_tuple: tz_tuple[1] ) From 08e79b6a08ac56f9fca7014d09e1501fd02dfce9 Mon Sep 17 00:00:00 2001 From: Tarun Tak Date: Wed, 24 Sep 2025 10:59:00 +0000 Subject: [PATCH 08/14] fix: format code for consistency in test_time_zone_utils --- .../core/lib/tests/test_time_zone_utils.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/openedx/core/lib/tests/test_time_zone_utils.py b/openedx/core/lib/tests/test_time_zone_utils.py index 427643f20ddf..86bbdbdde1ac 100644 --- a/openedx/core/lib/tests/test_time_zone_utils.py +++ b/openedx/core/lib/tests/test_time_zone_utils.py @@ -7,9 +7,9 @@ from freezegun import freeze_time from zoneinfo import ZoneInfo from openedx.core.lib.time_zone_utils import ( - get_display_time_zone, - get_time_zone_abbr, - get_time_zone_offset, + get_display_time_zone, + get_time_zone_abbr, + get_time_zone_offset, get_utc_timezone, get_common_timezones ) @@ -61,9 +61,9 @@ def _assert_time_zone_info_equal(self, display_tz_info, expected_name, expected_ def test_get_utc_timezone(self, toggle_enabled, expected_type, mock_toggle): """Test get_utc_timezone returns correct timezone object based on toggle""" mock_toggle.is_enabled.return_value = toggle_enabled - + utc_tz = get_utc_timezone() - + if toggle_enabled: self.assertIsInstance(utc_tz, ZoneInfo) self.assertEqual(str(utc_tz), 'UTC') @@ -75,9 +75,9 @@ def test_get_utc_timezone(self, toggle_enabled, expected_type, mock_toggle): def test_get_utc_timezone_fallback_on_exception(self, mock_toggle): """Test get_utc_timezone falls back to pytz UTC when toggle check fails""" mock_toggle.is_enabled.side_effect = Exception("Toggle check failed") - + utc_tz = get_utc_timezone() - + # Should fallback to pytz UTC from pytz import UTC self.assertEqual(utc_tz, UTC) @@ -91,9 +91,9 @@ def test_get_utc_timezone_fallback_on_exception(self, mock_toggle): def test_get_common_timezones(self, toggle_enabled, expected_source, mock_toggle): """Test get_common_timezones returns correct timezone list based on toggle""" mock_toggle.is_enabled.return_value = toggle_enabled - + timezones = get_common_timezones() - + if toggle_enabled: from zoneinfo import available_timezones self.assertEqual(timezones, available_timezones()) @@ -105,9 +105,9 @@ def test_get_common_timezones(self, toggle_enabled, expected_source, mock_toggle def test_get_common_timezones_fallback_on_exception(self, mock_toggle): """Test get_common_timezones falls back to pytz when toggle check fails""" mock_toggle.is_enabled.side_effect = Exception("Toggle check failed") - + timezones = get_common_timezones() - + from pytz import common_timezones self.assertEqual(timezones, common_timezones) @@ -120,7 +120,7 @@ def test_get_common_timezones_fallback_on_exception(self, mock_toggle): def test_get_display_time_zone_with_toggle(self, toggle_enabled, expected_source, mock_toggle): """Test get_display_time_zone works correctly with both implementations""" mock_toggle.is_enabled.return_value = toggle_enabled - + with freeze_time("2015-02-09"): result = get_display_time_zone('America/Los_Angeles') expected = 'America/Los Angeles (PST, UTC-0800)' @@ -130,7 +130,7 @@ def test_get_display_time_zone_with_toggle(self, toggle_enabled, expected_source def test_get_display_time_zone_fallback_on_exception(self, mock_toggle): """Test get_display_time_zone falls back to pytz when toggle check fails""" mock_toggle.is_enabled.side_effect = Exception("Toggle check failed") - + with freeze_time("2015-02-09"): result = get_display_time_zone('America/Los_Angeles') expected = 'America/Los Angeles (PST, UTC-0800)' From f81d754604b359b30b8c9ecf0353abdb29a38bb5 Mon Sep 17 00:00:00 2001 From: Tarun Tak Date: Wed, 24 Sep 2025 11:06:10 +0000 Subject: [PATCH 09/14] fix: format code --- openedx/core/lib/time_zone_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openedx/core/lib/time_zone_utils.py b/openedx/core/lib/time_zone_utils.py index 540cb84196a5..03cfe489b639 100644 --- a/openedx/core/lib/time_zone_utils.py +++ b/openedx/core/lib/time_zone_utils.py @@ -73,7 +73,7 @@ def get_display_time_zone(time_zone_name): except Exception: # pylint: disable=broad-except # Fallback to pytz if toggle check fails (e.g., during app startup) time_zone = timezone(time_zone_name) - + tz_abbr = get_time_zone_abbr(time_zone) tz_offset = get_time_zone_offset(time_zone) @@ -83,10 +83,10 @@ def get_display_time_zone(time_zone_name): def get_common_timezones(): """ Returns a list of common timezone names. - + Uses ZoneInfo if the ENABLE_ZONEINFO_TZ toggle is enabled, otherwise falls back to pytz common_timezones. If there's an issue checking the toggle (e.g., during app startup), defaults to pytz common_timezones. - + Returns: A list/set of timezone name strings """ From f70029a60c520bd728d11e928098931ac6317984 Mon Sep 17 00:00:00 2001 From: Tarun Tak Date: Thu, 25 Sep 2025 10:49:45 +0000 Subject: [PATCH 10/14] fix: update imports and use get_common_timezones in tests --- .../core/djangoapps/user_api/preferences/tests/test_api.py | 7 +++---- openedx/core/djangoapps/xblock/tests/test_utils.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) 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 fa6b4c5803fa..41da4cd54a77 100644 --- a/openedx/core/djangoapps/user_api/preferences/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/preferences/tests/test_api.py @@ -11,8 +11,7 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.test.utils import override_settings from django.urls import reverse -from pytz import common_timezones -from openedx.core.lib.time_zone_utils import get_utc_timezone +from openedx.core.lib.time_zone_utils import get_utc_timezone, get_common_timezones from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.core.lib.time_zone_utils import get_display_time_zone @@ -404,8 +403,8 @@ class CountryTimeZoneTest(CacheIsolationTestCase): """ @ddt.data(('ES', ['Africa/Ceuta', 'Atlantic/Canary', 'Europe/Madrid']), - (None, common_timezones), - ('AA', common_timezones)) + (None, get_common_timezones()), + ('AA', get_common_timezones())) @ddt.unpack def test_get_country_time_zones(self, country_code, expected_time_zones): """ diff --git a/openedx/core/djangoapps/xblock/tests/test_utils.py b/openedx/core/djangoapps/xblock/tests/test_utils.py index 0c5bbd026af9..229406e1bca1 100644 --- a/openedx/core/djangoapps/xblock/tests/test_utils.py +++ b/openedx/core/djangoapps/xblock/tests/test_utils.py @@ -71,7 +71,7 @@ }, True, ), - # Setting reference_time to 20 seconds before end of a 2 day time periodget_utc_timezone() + # Setting reference_time to 20 seconds before end of a 2 day time period(UTC) # Demonstrating minimum possible validity period is just above 2 days # This fails because validation time is just above the cutoff point ( From 4b0c1bac2c3c75123c8d670709ffc99b3854cb2c Mon Sep 17 00:00:00 2001 From: Tarun Tak Date: Thu, 9 Oct 2025 10:04:31 +0000 Subject: [PATCH 11/14] fix: updated timezone utilities --- .../user_api/preferences/tests/test_api.py | 8 +- openedx/core/lib/__init__.py | 14 +- .../core/lib/tests/test_time_zone_utils.py | 162 ++++++++++-------- openedx/core/lib/time_zone_utils.py | 38 ++-- 4 files changed, 114 insertions(+), 108 deletions(-) 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 41da4cd54a77..c34698952012 100644 --- a/openedx/core/djangoapps/user_api/preferences/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/preferences/tests/test_api.py @@ -11,7 +11,7 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.test.utils import override_settings from django.urls import reverse -from openedx.core.lib.time_zone_utils import get_utc_timezone, get_common_timezones +from openedx.core.lib.time_zone_utils import get_utc_timezone, get_available_timezones from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.core.lib.time_zone_utils import get_display_time_zone @@ -403,13 +403,13 @@ class CountryTimeZoneTest(CacheIsolationTestCase): """ @ddt.data(('ES', ['Africa/Ceuta', 'Atlantic/Canary', 'Europe/Madrid']), - (None, get_common_timezones()), - ('AA', get_common_timezones())) + (None, get_available_timezones()), + ('AA', get_available_timezones())) @ddt.unpack def test_get_country_time_zones(self, country_code, expected_time_zones): """ Verify that list of common country time zones dictionaries is returned - An unrecognized country code (e.g. AA) will return the list of common timezones + An unrecognized country code (e.g. AA) will return the list of available timezones """ expected_dict = sorted( [ diff --git a/openedx/core/lib/__init__.py b/openedx/core/lib/__init__.py index 64492942a8d0..44351750be5b 100644 --- a/openedx/core/lib/__init__.py +++ b/openedx/core/lib/__init__.py @@ -9,18 +9,20 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from edx_toggles.toggles import WaffleSwitch +from edx_toggles.toggles import SettingToggle -# .. toggle_name: open_edx_util.enable_zoneinfo_tz -# .. toggle_implementation: WaffleSwitch +# .. toggle_name: ENABLE_ZONEINFO_TZ +# .. toggle_implementation: SettingToggle # .. toggle_default: False # .. toggle_description: Replaces pytz.UTC with get_utc_timezone(), when active. -# .. toggle_use_cases: opt_in +# .. toggle_use_cases: temporary # .. toggle_creation_date: 2025-06-03 # .. toggle_tickets: N/A -ENABLE_ZONEINFO_TZ = WaffleSwitch( - 'open_edx_util.enable_zoneinfo_tz', __name__ +ENABLE_ZONEINFO_TZ = SettingToggle( + "ENABLE_ZONEINFO_TZ", + default=False, + module_name=__name__ ) _LMS_URLCONF = 'lms.urls' diff --git a/openedx/core/lib/tests/test_time_zone_utils.py b/openedx/core/lib/tests/test_time_zone_utils.py index 86bbdbdde1ac..5e29f58954be 100644 --- a/openedx/core/lib/tests/test_time_zone_utils.py +++ b/openedx/core/lib/tests/test_time_zone_utils.py @@ -1,17 +1,20 @@ """Tests covering time zone utilities.""" +from datetime import datetime + import ddt +from pytz import timezone as pytz_timezone, UTC as pytz_UTC, common_timezones from unittest.mock import patch from django.test import TestCase from freezegun import freeze_time -from zoneinfo import ZoneInfo +from zoneinfo import ZoneInfo, available_timezones as zoneinfo_available_timezones from openedx.core.lib.time_zone_utils import ( get_display_time_zone, get_time_zone_abbr, get_time_zone_offset, get_utc_timezone, - get_common_timezones + get_available_timezones ) from common.djangoapps.student.tests.factories import UserFactory @@ -30,28 +33,24 @@ def setUp(self): self.user = UserFactory.build() self.user.save() - def _display_time_zone_helper(self, time_zone_string): + def _display_time_zone_helper(self, time_zone_string, date_time=None): """ - Helper function to return all info from get_display_time_zone() + Helper function to return all info from individual functions with explicit datetime """ - tz_str = get_display_time_zone(time_zone_string) time_zone = ZoneInfo(time_zone_string) - tz_abbr = get_time_zone_abbr(time_zone) - tz_offset = get_time_zone_offset(time_zone) + tz_abbr = get_time_zone_abbr(time_zone, date_time) + tz_offset = get_time_zone_offset(time_zone, date_time) - return {'str': tz_str, 'abbr': tz_abbr, 'offset': tz_offset} + return {'abbr': tz_abbr, 'offset': tz_offset} - def _assert_time_zone_info_equal(self, display_tz_info, expected_name, expected_abbr, expected_offset): + def _assert_time_zone_info_equal(self, display_tz_info, expected_abbr, expected_offset): """ - Asserts that all display_tz_info is equal to the expected inputs + Asserts that display_tz_info is equal to the expected inputs """ - assert display_tz_info['str'] == '{name} ({abbr}, UTC{offset})'.format( - name=expected_name, abbr=expected_abbr, offset=expected_offset - ) assert display_tz_info['abbr'] == expected_abbr assert display_tz_info['offset'] == expected_offset - # New tests for newly added functions + # Tests for toggle-based timezone selection @ddt.data( (True, ZoneInfo), # ZoneInfo enabled (False, 'pytz.UTC'), # ZoneInfo disabled @@ -68,19 +67,7 @@ def test_get_utc_timezone(self, toggle_enabled, expected_type, mock_toggle): self.assertIsInstance(utc_tz, ZoneInfo) self.assertEqual(str(utc_tz), 'UTC') else: - from pytz import UTC - self.assertEqual(utc_tz, UTC) - - @patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') - def test_get_utc_timezone_fallback_on_exception(self, mock_toggle): - """Test get_utc_timezone falls back to pytz UTC when toggle check fails""" - mock_toggle.is_enabled.side_effect = Exception("Toggle check failed") - - utc_tz = get_utc_timezone() - - # Should fallback to pytz UTC - from pytz import UTC - self.assertEqual(utc_tz, UTC) + self.assertEqual(utc_tz, pytz_UTC) @ddt.data( (True, 'zoneinfo'), # ZoneInfo enabled @@ -88,29 +75,41 @@ def test_get_utc_timezone_fallback_on_exception(self, mock_toggle): ) @ddt.unpack @patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') - def test_get_common_timezones(self, toggle_enabled, expected_source, mock_toggle): - """Test get_common_timezones returns correct timezone list based on toggle""" + def test_get_available_timezones(self, toggle_enabled, expected_source, mock_toggle): + """ + Test get_available_timezones returns correct timezone list based on toggle. + + Note: When using zoneinfo, this returns available_timezones() which is a superset + of pytz.common_timezones (599 vs 433 timezones). This is an intentional change + to provide access to all available timezones, not just the "common" ones. + We verify that all legacy common_timezones are still available to ensure + backward compatibility. + """ mock_toggle.is_enabled.return_value = toggle_enabled - timezones = get_common_timezones() - + timezones = get_available_timezones() + if toggle_enabled: - from zoneinfo import available_timezones - self.assertEqual(timezones, available_timezones()) + self.assertEqual(timezones, zoneinfo_available_timezones()) + + timezone_strings = set(timezones) + common_timezone_strings = set(common_timezones) + + missing_timezones = common_timezone_strings - timezone_strings + self.assertEqual( + missing_timezones, + set(), + f"These common timezones are missing from zoneinfo: {missing_timezones}" + ) + + self.assertGreater( + len(timezone_strings), + len(common_timezone_strings), + "zoneinfo should provide more timezones than pytz.common_timezones" + ) else: - from pytz import common_timezones self.assertEqual(timezones, common_timezones) - @patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') - def test_get_common_timezones_fallback_on_exception(self, mock_toggle): - """Test get_common_timezones falls back to pytz when toggle check fails""" - mock_toggle.is_enabled.side_effect = Exception("Toggle check failed") - - timezones = get_common_timezones() - - from pytz import common_timezones - self.assertEqual(timezones, common_timezones) - @ddt.data( (True, 'zoneinfo'), # ZoneInfo enabled (False, 'pytz'), # ZoneInfo disabled @@ -127,47 +126,66 @@ def test_get_display_time_zone_with_toggle(self, toggle_enabled, expected_source self.assertEqual(result, expected) @patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') - def test_get_display_time_zone_fallback_on_exception(self, mock_toggle): - """Test get_display_time_zone falls back to pytz when toggle check fails""" - mock_toggle.is_enabled.side_effect = Exception("Toggle check failed") - - with freeze_time("2015-02-09"): - result = get_display_time_zone('America/Los_Angeles') - expected = 'America/Los Angeles (PST, UTC-0800)' - self.assertEqual(result, expected) + def test_mixed_timezone_types_work(self, mock_toggle): + """Test that mixing pytz and ZoneInfo timezone types works correctly""" + # Test with ZoneInfo datetime and pytz timezone + mock_toggle.is_enabled.return_value = True + zoneinfo_dt = datetime.now(get_utc_timezone()) + pytz_tz = pytz_timezone('America/New_York') + result1 = get_time_zone_abbr(pytz_tz, zoneinfo_dt) + self.assertIsNotNone(result1) # Should not raise an exception + + # Test with pytz datetime and ZoneInfo timezone + mock_toggle.is_enabled.return_value = False + pytz_dt = datetime.now(get_utc_timezone()) + zoneinfo_tz = ZoneInfo('America/New_York') + result2 = get_time_zone_abbr(zoneinfo_tz, pytz_dt) + self.assertIsNotNone(result2) # Should not raise an exception def test_display_time_zone_without_dst(self): """ - Test to ensure get_display_time_zone() returns full display string when no kwargs specified - and returns just abbreviation or offset when specified + Test to ensure get_time_zone_abbr() and get_time_zone_offset() return correct values + when not in daylight savings time """ - with freeze_time("2015-02-09"): - tz_info = self._display_time_zone_helper('America/Los_Angeles') - self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PST', '-0800') + with patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') as mock_toggle: + mock_toggle.is_enabled.return_value = True + test_dt = datetime(2015, 2, 9, 12, 0, 0, tzinfo=ZoneInfo('UTC')) + tz_info = self._display_time_zone_helper('America/Los_Angeles', test_dt) + self._assert_time_zone_info_equal(tz_info, 'PST', '-0800') def test_display_time_zone_with_dst(self): """ - Test to ensure get_display_time_zone() returns modified abbreviations and - offsets during daylight savings time. + Test to ensure get_time_zone_abbr() and get_time_zone_offset() return modified + abbreviations and offsets during daylight savings time. """ - with freeze_time("2015-04-02"): - tz_info = self._display_time_zone_helper('America/Los_Angeles') - self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PDT', '-0700') + with patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') as mock_toggle: + mock_toggle.is_enabled.return_value = True + test_dt = datetime(2015, 4, 2, 12, 0, 0, tzinfo=ZoneInfo('UTC')) + tz_info = self._display_time_zone_helper('America/Los_Angeles', test_dt) + self._assert_time_zone_info_equal(tz_info, 'PDT', '-0700') def test_display_time_zone_ambiguous_before(self): """ - 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 + Test to ensure get_time_zone_abbr() and get_time_zone_offset() return correct + abbreviations and offsets during ambiguous time periods (e.g. when DST is about + to start/end) before the change """ - with freeze_time("2015-11-01 08:59:00"): - tz_info = self._display_time_zone_helper('America/Los_Angeles') - self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PDT', '-0700') + with patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') as mock_toggle: + mock_toggle.is_enabled.return_value = True + # UTC 08:59 = 01:59 PDT (before DST ends at 02:00) + test_dt = datetime(2015, 11, 1, 8, 59, 0, tzinfo=ZoneInfo('UTC')) + tz_info = self._display_time_zone_helper('America/Los_Angeles', test_dt) + self._assert_time_zone_info_equal(tz_info, 'PDT', '-0700') def test_display_time_zone_ambiguous_after(self): """ - 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 + Test to ensure get_time_zone_abbr() and get_time_zone_offset() return correct + abbreviations and offsets during ambiguous time periods (e.g. when DST is about + to start/end) after the change """ - with freeze_time("2024-11-04 09:00:00"): - tz_info = self._display_time_zone_helper('America/Los_Angeles') - self._assert_time_zone_info_equal(tz_info, 'America/Los Angeles', 'PST', '-0800') + with patch('openedx.core.lib.time_zone_utils.ENABLE_ZONEINFO_TZ') as mock_toggle: + mock_toggle.is_enabled.return_value = True + # UTC 09:00 = 01:00 PST (after DST ends, clocks fall back) + test_dt = datetime(2015, 11, 1, 9, 0, 0, tzinfo=ZoneInfo('UTC')) + tz_info = self._display_time_zone_helper('America/Los_Angeles', test_dt) + self._assert_time_zone_info_equal(tz_info, 'PST', '-0800') diff --git a/openedx/core/lib/time_zone_utils.py b/openedx/core/lib/time_zone_utils.py index 03cfe489b639..b3cd041c943c 100644 --- a/openedx/core/lib/time_zone_utils.py +++ b/openedx/core/lib/time_zone_utils.py @@ -16,18 +16,13 @@ def get_utc_timezone(): Returns a UTC timezone object. Uses ZoneInfo if the ENABLE_ZONEINFO_TZ toggle is enabled, otherwise falls back to pytz UTC. - If there's an issue checking the toggle (e.g., during app startup), defaults to pytz UTC. Returns: A timezone object representing UTC (either ZoneInfo or pytz UTC) """ - try: - if ENABLE_ZONEINFO_TZ.is_enabled(): - return ZoneInfo('UTC') - else: - return UTC - except Exception: # pylint: disable=broad-except - # Fallback to UTC if toggle check fails (e.g., during app startup) + if ENABLE_ZONEINFO_TZ.is_enabled(): + return ZoneInfo('UTC') + else: return UTC @@ -65,13 +60,9 @@ def get_display_time_zone(time_zone_name): :param time_zone_name (str): Name of time zone """ - try: - if ENABLE_ZONEINFO_TZ.is_enabled(): - time_zone = ZoneInfo(time_zone_name) - else: - time_zone = timezone(time_zone_name) - except Exception: # pylint: disable=broad-except - # Fallback to pytz if toggle check fails (e.g., during app startup) + if ENABLE_ZONEINFO_TZ.is_enabled(): + time_zone = ZoneInfo(time_zone_name) + else: time_zone = timezone(time_zone_name) tz_abbr = get_time_zone_abbr(time_zone) @@ -80,27 +71,22 @@ def get_display_time_zone(time_zone_name): return f"{time_zone} ({tz_abbr}, UTC{tz_offset})".replace("_", " ") -def get_common_timezones(): +def get_available_timezones(): """ - Returns a list of common timezone names. + Returns a list of available timezone names. Uses ZoneInfo if the ENABLE_ZONEINFO_TZ toggle is enabled, otherwise falls back to pytz common_timezones. - If there's an issue checking the toggle (e.g., during app startup), defaults to pytz common_timezones. Returns: A list/set of timezone name strings """ - try: - if ENABLE_ZONEINFO_TZ.is_enabled(): - return available_timezones() - else: - return common_timezones - except Exception: # pylint: disable=broad-except - # Fallback to pytz if toggle check fails (e.g., during app startup) + if ENABLE_ZONEINFO_TZ.is_enabled(): + return available_timezones() + else: return common_timezones TIME_ZONE_CHOICES = sorted( - [(tz, get_display_time_zone(tz)) for tz in get_common_timezones()], + [(tz, get_display_time_zone(tz)) for tz in get_available_timezones()], key=lambda tz_tuple: tz_tuple[1] ) From bcbfba46777f8bc634b1750c6c81fd7687b2bc95 Mon Sep 17 00:00:00 2001 From: Tarun Tak Date: Thu, 9 Oct 2025 11:11:36 +0000 Subject: [PATCH 12/14] fix: clean up whitespace in time zone utility tests --- openedx/core/lib/tests/test_time_zone_utils.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openedx/core/lib/tests/test_time_zone_utils.py b/openedx/core/lib/tests/test_time_zone_utils.py index 5e29f58954be..d01335d4dbcc 100644 --- a/openedx/core/lib/tests/test_time_zone_utils.py +++ b/openedx/core/lib/tests/test_time_zone_utils.py @@ -88,20 +88,19 @@ def test_get_available_timezones(self, toggle_enabled, expected_source, mock_tog mock_toggle.is_enabled.return_value = toggle_enabled timezones = get_available_timezones() - if toggle_enabled: self.assertEqual(timezones, zoneinfo_available_timezones()) - + timezone_strings = set(timezones) common_timezone_strings = set(common_timezones) - + missing_timezones = common_timezone_strings - timezone_strings self.assertEqual( missing_timezones, set(), f"These common timezones are missing from zoneinfo: {missing_timezones}" ) - + self.assertGreater( len(timezone_strings), len(common_timezone_strings), @@ -134,8 +133,8 @@ def test_mixed_timezone_types_work(self, mock_toggle): pytz_tz = pytz_timezone('America/New_York') result1 = get_time_zone_abbr(pytz_tz, zoneinfo_dt) self.assertIsNotNone(result1) # Should not raise an exception - - # Test with pytz datetime and ZoneInfo timezone + + # Test with pytz datetime and ZoneInfo timezone mock_toggle.is_enabled.return_value = False pytz_dt = datetime.now(get_utc_timezone()) zoneinfo_tz = ZoneInfo('America/New_York') From a6a565d8f07da9d76b6af4dba6f85031416bdc86 Mon Sep 17 00:00:00 2001 From: Tarun Tak Date: Thu, 9 Oct 2025 11:17:00 +0000 Subject: [PATCH 13/14] fix: clean up whitespace in time zone utility tests --- openedx/core/lib/tests/test_time_zone_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx/core/lib/tests/test_time_zone_utils.py b/openedx/core/lib/tests/test_time_zone_utils.py index d01335d4dbcc..5dc48eda0a78 100644 --- a/openedx/core/lib/tests/test_time_zone_utils.py +++ b/openedx/core/lib/tests/test_time_zone_utils.py @@ -78,7 +78,7 @@ def test_get_utc_timezone(self, toggle_enabled, expected_type, mock_toggle): def test_get_available_timezones(self, toggle_enabled, expected_source, mock_toggle): """ Test get_available_timezones returns correct timezone list based on toggle. - + Note: When using zoneinfo, this returns available_timezones() which is a superset of pytz.common_timezones (599 vs 433 timezones). This is an intentional change to provide access to all available timezones, not just the "common" ones. From ce5675cd069bc5d425bd810fd6b2dc4f8dab9ca8 Mon Sep 17 00:00:00 2001 From: Tarun Tak Date: Thu, 9 Oct 2025 11:33:16 +0000 Subject: [PATCH 14/14] fix: clean up whitespace in time zone utility tests --- openedx/core/lib/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openedx/core/lib/__init__.py b/openedx/core/lib/__init__.py index 44351750be5b..6c277e3be0be 100644 --- a/openedx/core/lib/__init__.py +++ b/openedx/core/lib/__init__.py @@ -18,6 +18,7 @@ # .. toggle_description: Replaces pytz.UTC with get_utc_timezone(), when active. # .. toggle_use_cases: temporary # .. toggle_creation_date: 2025-06-03 +# .. toggle_target_removal_date: 2025-11-30 # .. toggle_tickets: N/A ENABLE_ZONEINFO_TZ = SettingToggle( "ENABLE_ZONEINFO_TZ",