Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: due date reminders #583

Draft
wants to merge 4 commits into
base: opencraft-release/palm.1
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 0 additions & 14 deletions cms/djangoapps/contentstore/config/waffle.py
Expand Up @@ -40,20 +40,6 @@
f'{WAFFLE_NAMESPACE}.library_authoring_mfe', __name__, LOG_PREFIX
)


# .. toggle_name: studio.custom_relative_dates
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag to enable custom pacing input for Personalized Learner Schedule (PLS).
# .. This flag guards an input in Studio for a self paced course, where the user can enter date offsets
# .. for a subsection.
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2021-07-12
# .. toggle_target_removal_date: 2021-12-31
# .. toggle_warning: Flag course_experience.relative_dates should also be active for relative dates functionalities to work.
# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-844
CUSTOM_RELATIVE_DATES = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.custom_relative_dates', __name__)

# .. toggle_name: studio.prevent_staff_structure_deletion
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
Expand Down
2 changes: 1 addition & 1 deletion cms/templates/base.html
Expand Up @@ -10,8 +10,8 @@
<%!
from django.utils.translation import gettext as _

from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
from lms.djangoapps.branding import api as branding_api
from openedx.core.djangoapps.course_date_signals.waffle import CUSTOM_RELATIVE_DATES
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangolib.js_utils import (
dump_js_escaped_json, js_escaped_string
Expand Down
8 changes: 4 additions & 4 deletions lms/djangoapps/learner_recommendations/tests/test_views.py
Expand Up @@ -154,6 +154,7 @@ def test_successful_response(
assert segment_mock.call_args[0][1] == "edx.bi.user.recommendations.viewed"


@ddt.ddt
class TestCrossProductRecommendationsView(APITestCase):
"""Unit tests for the Cross Product Recommendations View"""

Expand Down Expand Up @@ -218,13 +219,12 @@ def _get_recommended_courses(self, num_of_courses_with_restriction=0):
@mock.patch("django.conf.settings.CROSS_PRODUCT_RECOMMENDATIONS_KEYS", mock_cross_product_recommendation_keys)
@mock.patch("lms.djangoapps.learner_recommendations.views.get_course_data")
@mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip")
def test_successful_response(
self, country_code_from_ip_mock, get_course_data_mock,
):
@ddt.data("za", "") # Ensure that the empty string is handled correctly.
def test_successful_response(self, country_code, country_code_from_ip_mock, get_course_data_mock):
"""
Verify 2 cross product course recommendations are returned.
"""
country_code_from_ip_mock.return_value = "za"
country_code_from_ip_mock.return_value = country_code
mock_course_data = self._get_recommended_courses()
get_course_data_mock.side_effect = [mock_course_data[0], mock_course_data[1]]

Expand Down
15 changes: 15 additions & 0 deletions lms/djangoapps/user_tours/toggles.py
@@ -0,0 +1,15 @@
"""
Toggles for the User Tours Experience.
"""

from edx_toggles.toggles import WaffleFlag

# .. toggle_name: user_tours.tours_disabled
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
# .. toggle_description: This flag disables user tours in LMS.
# .. toggle_warnings: None
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2021-12-13
# .. toggle_target_removal_date: None
USER_TOURS_DISABLED = WaffleFlag('user_tours.tours_disabled', module_name=__name__, log_prefix='user_tours')
19 changes: 19 additions & 0 deletions lms/djangoapps/user_tours/v1/tests/test_views.py
Expand Up @@ -5,11 +5,13 @@
from django.db.models.signals import post_save
from django.test import TestCase, override_settings
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
from rest_framework import status

from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.user_tours.handlers import init_user_tour
from lms.djangoapps.user_tours.models import UserTour, UserDiscussionsTours
from lms.djangoapps.user_tours.toggles import USER_TOURS_DISABLED
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user

User = get_user_model()
Expand Down Expand Up @@ -47,6 +49,13 @@ def send_request(self, jwt_user, request_user, method, data=None):
elif method == 'PATCH':
return self.client.patch(url, data, content_type='application/json', **headers)

@ddt.data('GET', 'PATCH')
@override_waffle_flag(USER_TOURS_DISABLED, active=True)
def test_tours_disabled(self, method):
""" Test that the tours can be turned off with a waffle flag. """
response = self.send_request(self.staff_user, self.user, method)
assert response.status_code == status.HTTP_403_FORBIDDEN

@ddt.data('GET', 'PATCH')
def test_unauthorized_user(self, method):
""" Test all endpoints if request does not have jwt auth. """
Expand Down Expand Up @@ -188,6 +197,11 @@ def test_get_tours(self):
self.assertEqual(response.data[1]['tour_name'], 'not_responded_filter')
self.assertTrue(response.data[1]['show_tour'])

# Test that the view can be disabled by a waffle flag.
with override_waffle_flag(USER_TOURS_DISABLED, active=True):
response = self.client.get(self.url, **headers)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_get_tours_unauthenticated(self):
"""
Test that an unauthenticated user cannot access the discussion tours endpoint.
Expand Down Expand Up @@ -215,3 +229,8 @@ def test_update_tour(self):
# Check that the tour was updated in the database
updated_tour = UserDiscussionsTours.objects.get(id=self.tour.id)
self.assertEqual(updated_tour.show_tour, False)

# Test that the view can be disabled by a waffle flag.
with override_waffle_flag(USER_TOURS_DISABLED, active=True):
response = self.client.put(url, updated_data, content_type='application/json', **headers)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
18 changes: 15 additions & 3 deletions lms/djangoapps/user_tours/v1/views.py
Expand Up @@ -10,6 +10,7 @@
from rest_framework import status

from lms.djangoapps.user_tours.models import UserTour, UserDiscussionsTours
from lms.djangoapps.user_tours.toggles import USER_TOURS_DISABLED
from lms.djangoapps.user_tours.v1.serializers import UserTourSerializer, UserDiscussionsToursSerializer

from rest_framework.views import APIView
Expand Down Expand Up @@ -41,9 +42,12 @@ def get(self, request, username): # pylint: disable=arguments-differ

400 if there is a not allowed request (requesting a user you don't have access to)
401 if unauthorized request
403 if waffle flag is not enabled
403 if tours are disabled
404 if the UserTour does not exist (shouldn't happen, but safety first)
"""
if USER_TOURS_DISABLED.is_enabled():
return Response(status=status.HTTP_403_FORBIDDEN)

if request.user.username != username and not request.user.is_staff:
return Response(status=status.HTTP_400_BAD_REQUEST)

Expand All @@ -66,8 +70,11 @@ def patch(self, request, username): # pylint: disable=arguments-differ

400 if update was unsuccessful or there was nothing to update
401 if unauthorized request
403 if waffle flag is not enabled
403 if tours are disabled
"""
if USER_TOURS_DISABLED.is_enabled():
return Response(status=status.HTTP_403_FORBIDDEN)

if request.user.username != username:
return Response(status=status.HTTP_400_BAD_REQUEST)

Expand Down Expand Up @@ -125,8 +132,11 @@ def get(self, request, tour_id=None):
"user": 1
}
]
403 if the tours are disabled

"""
if USER_TOURS_DISABLED.is_enabled():
return Response(status=status.HTTP_403_FORBIDDEN)
try:
with transaction.atomic():
tours = UserDiscussionsTours.objects.filter(user=request.user)
Expand Down Expand Up @@ -158,9 +168,11 @@ def put(self, request, tour_id):
Returns:
200: The updated tour, serialized using the UserDiscussionsToursSerializer
404: If the tour does not exist
403: If the user does not have permission to update the tour
403: If the user does not have permission to update the tour or the tours are disabled
400: Validation error
"""
if USER_TOURS_DISABLED.is_enabled():
return Response(status=status.HTTP_403_FORBIDDEN)
tour = get_object_or_404(UserDiscussionsTours, pk=tour_id)
if tour.user != request.user:
return Response(status=status.HTTP_403_FORBIDDEN)
Expand Down
13 changes: 11 additions & 2 deletions openedx/core/djangoapps/content/course_overviews/models.py
Expand Up @@ -2,10 +2,12 @@
Declaration of CourseOverview model
"""

from __future__ import annotations

import json
import logging
from datetime import datetime
from typing import List
from urllib.parse import urlparse, urlunparse

import pytz
Expand All @@ -17,6 +19,7 @@
from django.db.models.signals import post_save, post_delete
from django.db.utils import IntegrityError
from django.template import defaultfilters
from opaque_keys.edx.keys import CourseKey

from django.utils.functional import cached_property
from model_utils.models import TimeStampedModel
Expand Down Expand Up @@ -696,10 +699,16 @@ def get_all_courses(cls, orgs=None, filter_=None, active_only=False, course_keys
return course_overviews

@classmethod
def get_all_course_keys(cls):
def get_all_course_keys(cls, self_paced: bool | None = None) -> List[CourseKey]:
"""
Returns all course keys from course overviews.
Returns all course keys from course overviews, optionally filter by pacing.
The filter is only used when a boolean is passed as argument and it is disabled when this value is `None`.

Args:
self_paced: Optionally filter by pacing
"""
if self_paced is not None:
return CourseOverview.objects.filter(self_paced=self_paced).values_list('id', flat=True)
return CourseOverview.objects.values_list('id', flat=True)

def is_discussion_tab_enabled(self):
Expand Down
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/course_date_signals/handlers.py
Expand Up @@ -8,7 +8,7 @@
from edx_when.api import FIELDS_TO_EXTRACT, set_dates_for_course
from xblock.fields import Scope

from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
from openedx.core.djangoapps.course_date_signals.waffle import CUSTOM_RELATIVE_DATES
from openedx.core.lib.graph_traversals import get_children, leaf_filter, traverse_pre_order
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import SignalHandler, modulestore # lint-amnesty, pylint: disable=wrong-import-order
Expand Down
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/course_date_signals/tests.py
Expand Up @@ -6,13 +6,13 @@
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory

from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
from openedx.core.djangoapps.course_date_signals.handlers import (
_gather_graded_items,
_get_custom_pacing_children,
_has_assignment_blocks,
extract_dates_from_course
)
from openedx.core.djangoapps.course_date_signals.waffle import CUSTOM_RELATIVE_DATES
from openedx.core.djangoapps.course_date_signals.models import SelfPacedRelativeDatesConfig

from . import utils
Expand Down
26 changes: 23 additions & 3 deletions openedx/core/djangoapps/course_date_signals/utils.py
Expand Up @@ -5,8 +5,10 @@
"""

from datetime import timedelta
from typing import Optional

from openedx.core.djangoapps.catalog.utils import get_course_run_details
from openedx.core.djangoapps.course_date_signals.waffle import CUSTOM_RELATIVE_DATES


MIN_DURATION = timedelta(weeks=4)
Expand All @@ -33,7 +35,24 @@ def get_expected_duration(course_id):
return access_duration


def spaced_out_sections(course):
def get_expected_duration_based_on_relative_due_dates(course) -> timedelta:
"""
Calculate duration based on custom relative due dates.
Returns the longest relative due date if set else a minimum duration of 1 week.
"""
duration_in_weeks = 1
if CUSTOM_RELATIVE_DATES.is_enabled(course.id):
for section in course.get_children():
if section.visible_to_staff_only:
continue
for subsection in section.get_children():
relative_weeks_due = subsection.fields['relative_weeks_due'].read_from(subsection)
if relative_weeks_due and relative_weeks_due > duration_in_weeks:
duration_in_weeks = relative_weeks_due
return timedelta(weeks=duration_in_weeks)


def spaced_out_sections(course, duration: Optional[timedelta] = None):
"""
Generator that returns sections of the course block with a suggested time to complete for each

Expand All @@ -42,13 +61,14 @@ def spaced_out_sections(course):
section (block): a section block of the course
relative time (timedelta): the amount of weeks to complete the section, since start of course
"""
duration = get_expected_duration(course.id)
if not duration:
duration = get_expected_duration(course.id)
sections = [
section
for section
in course.get_children()
if not section.visible_to_staff_only
]
weeks_per_section = duration / len(sections)
weeks_per_section = duration / (len(sections) or 1) # if course has zero sections
for idx, section in enumerate(sections):
yield idx, section, weeks_per_section * (idx + 1)
20 changes: 20 additions & 0 deletions openedx/core/djangoapps/course_date_signals/waffle.py
@@ -0,0 +1,20 @@
"""
This module contains various configuration settings via
waffle switches for the course_date_signals app.
"""


from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag

# .. toggle_name: studio.custom_relative_dates
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag to enable custom pacing input for Personalized Learner Schedule (PLS).
# .. This flag guards an input in Studio for a self paced course, where the user can enter date offsets
# .. for a subsection.
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2021-07-12
# .. toggle_target_removal_date: 2021-12-31
# .. toggle_warning: Flag course_experience.relative_dates should also be active for relative dates functionalities to work.
# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-844
CUSTOM_RELATIVE_DATES = CourseWaffleFlag('studio.custom_relative_dates', __name__)
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/geoinfo/api.py
Expand Up @@ -22,7 +22,7 @@ def country_code_from_ip(ip_addr: str) -> str:
try:
response = reader.country(ip_addr)
# pylint: disable=no-member
country_code = response.country.iso_code
country_code = response.country.iso_code or ""
except geoip2.errors.AddressNotFoundError:
country_code = ""
reader.close()
Expand Down
1 change: 1 addition & 0 deletions openedx/core/djangoapps/schedules/admin.py
Expand Up @@ -176,5 +176,6 @@ class ScheduleConfigAdmin(admin.ModelAdmin): # lint-amnesty, pylint: disable=mi
'enqueue_recurring_nudge', 'deliver_recurring_nudge',
'enqueue_upgrade_reminder', 'deliver_upgrade_reminder',
'enqueue_course_update', 'deliver_course_update',
'enqueue_course_due_date_reminder', 'deliver_course_due_date_reminder',
)
form = ScheduleConfigAdminForm