Skip to content

Commit

Permalink
feat: due date reminders
Browse files Browse the repository at this point in the history
Adds a command to send reminder emails for self-paced courses with relative
due dates.
  • Loading branch information
navinkarkera authored and gabor-boros committed Oct 1, 2023
1 parent 4266934 commit 8f213ac
Show file tree
Hide file tree
Showing 24 changed files with 826 additions and 143 deletions.
14 changes: 0 additions & 14 deletions cms/djangoapps/contentstore/config/waffle.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,3 @@
REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND = WaffleFlag(
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__)
2 changes: 1 addition & 1 deletion cms/templates/base.html
Original file line number Diff line number Diff line change
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
13 changes: 11 additions & 2 deletions openedx/core/djangoapps/content/course_overviews/models.py
Original file line number Diff line number Diff line change
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 @@ -702,10 +705,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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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__)
1 change: 1 addition & 0 deletions openedx/core/djangoapps/schedules/admin.py
Original file line number Diff line number Diff line change
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
68 changes: 47 additions & 21 deletions openedx/core/djangoapps/schedules/content_highlights.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
"""
Contains methods for accessing course highlights. Course highlights is a
schedule experience built on the Schedules app.
Contains methods for accessing course highlights and course due dates.
Course highlights is a schedule experience built on the Schedules app.
"""


from datetime import timedelta
import logging

from lms.djangoapps.courseware.courses import get_course
from lms.djangoapps.courseware.exceptions import CourseRunNotFound
from openedx.core.djangoapps.course_date_signals.utils import spaced_out_sections
from openedx.core.djangoapps.course_date_signals.waffle import CUSTOM_RELATIVE_DATES
from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist
from openedx.core.lib.request_utils import get_request_or_stub
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order

log = logging.getLogger(__name__)
DUE_DATE_FORMAT = "%b %d, %Y at %H:%M %Z"


def get_all_course_highlights(course_key):
Expand Down Expand Up @@ -67,8 +71,8 @@ def course_has_highlights_from_store(course_key):
course_key (CourseKey): course to lookup from the modulestore
"""
try:
course = _get_course_descriptor(course_key)
except CourseUpdateDoesNotExist:
course = get_course(course_key, depth=1)
except CourseRunNotFound:
return False
return course_has_highlights(course)

Expand All @@ -93,7 +97,16 @@ def get_week_highlights(user, course_key, week_num):
return highlights


def get_next_section_highlights(user, course_key, start_date, target_date):
def get_upcoming_subsection_due_dates(user, course_key, start_date, target_date, current_date, duration=None):
"""
Get section due dates, based upon the current date.
"""
course_descriptor = get_course(course_key, depth=2)
course_block = _get_course_block(course_descriptor, user)
return _get_upcoming_due_dates(course_block, start_date, target_date, current_date, duration)


def get_next_section_highlights(user, course_key, start_date, target_date, duration=None):
"""
Get highlights (list of unicode strings) for a week, based upon the current date.
Expand All @@ -102,12 +115,12 @@ def get_next_section_highlights(user, course_key, start_date, target_date):
"""
course_descriptor = _get_course_with_highlights(course_key)
course_block = _get_course_block(course_descriptor, user)
return _get_highlights_for_next_section(course_block, start_date, target_date)
return _get_highlights_for_next_section(course_block, start_date, target_date, duration)


def _get_course_with_highlights(course_key):
""" Gets Course descriptor if highlights are enabled for the course """
course_descriptor = _get_course_descriptor(course_key)
course_descriptor = get_course(course_key, depth=1)
if not course_descriptor.highlights_enabled_for_messaging:
raise CourseUpdateDoesNotExist(
f'{course_key} Course Update Messages are disabled.'
Expand All @@ -116,16 +129,6 @@ def _get_course_with_highlights(course_key):
return course_descriptor


def _get_course_descriptor(course_key):
""" Gets course descriptor from modulestore """
descriptor = modulestore().get_course(course_key, depth=1)
if descriptor is None:
raise CourseUpdateDoesNotExist(
f'Course {course_key} not found.'
)
return descriptor


def _get_course_block(course_descriptor, user):
""" Gets course block that takes into account user state and permissions """
# Adding courseware imports here to insulate other apps (e.g. schedules) to
Expand All @@ -146,7 +149,7 @@ def _get_course_block(course_descriptor, user):
user, request, course_descriptor, field_data_cache, course_descriptor.id, course=course_descriptor,
)
if not course_block:
raise CourseUpdateDoesNotExist(f'Course block {course_descriptor.id} not found')
raise CourseRunNotFound(course_descriptor.id)
return course_block


Expand Down Expand Up @@ -175,10 +178,10 @@ def _get_highlights_for_week(sections, week_num, course_key):
return section.highlights


def _get_highlights_for_next_section(course, start_date, target_date):
def _get_highlights_for_next_section(course, start_date, target_date, duration=None):
""" Using the target date, retrieves highlights for the next section. """
use_next_sections_highlights = False
for index, section, weeks_to_complete in spaced_out_sections(course):
for index, section, weeks_to_complete in spaced_out_sections(course, duration):
# We calculate section due date ourselves (rather than grabbing the due attribute),
# since not every section has a real due date (i.e. not all are graded), but we still
# want to know when this section should have been completed by the learner.
Expand All @@ -199,3 +202,26 @@ def _get_highlights_for_next_section(course, start_date, target_date):
)

return None, None


def _get_upcoming_due_dates(course, start_date, target_date, current_date, duration=None):
""" Retrieves section names and due dates within the provided target_date. """
date_items = []
# Apply the same relative due date to all content inside a section,
# unless that item already has a relative date set
for _, section, days_to_complete in spaced_out_sections(course, duration):
# Default to Personalized Learner Schedules (PLS) logic for self paced courses.
section_due_date = start_date + days_to_complete
section_date_items = []

for subsection in section.get_children():
# Get custom due date for subsection if it is set
relative_weeks_due = subsection.fields['relative_weeks_due'].read_from(subsection)
if CUSTOM_RELATIVE_DATES.is_enabled(course.id) and relative_weeks_due:
section_due_date = start_date + timedelta(weeks=relative_weeks_due)

# If the section_due_date is within current date and the target date range, include it in reminder list.
if current_date <= section_due_date <= target_date:
section_date_items.append((subsection.display_name, section_due_date.strftime(DUE_DATE_FORMAT)))
date_items.extend(section_date_items)
return date_items
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""
Management command to send Schedule course due date reminders
"""

import datetime
import pytz
from textwrap import dedent # lint-amnesty, pylint: disable=wrong-import-order

from django.contrib.sites.models import Site

from openedx.core.djangoapps.schedules.management.commands import SendEmailBaseCommand
from openedx.core.djangoapps.schedules.tasks import COURSE_DUE_DATE_REMINDER_LOG_PREFIX, ScheduleCourseDueDateReminders


class Command(SendEmailBaseCommand):
"""
Command to send due date reminders for subsections in Self paced courses.
Note: this feature does not support reminders for INDIVIDUAL_DUE_DATES as the applicable schedule
objects are fetched based on course relative due dates.
Usage:
./manage.py lms send_course_due_date_reminders localhost:18000 --due 7 --date 2023-06-07
Positional required args:
- site: Django site domain name, for example: localhost:18000
Keyword Required args
- due-in: Remind subsections due in given days
Optional args:
- date: The date to compute weekly messages relative to, in YYYY-MM-DD format.
- override-recipient-email: Send all emails to this address instead of the actual recipient
"""
help = dedent(__doc__).strip()
async_send_task = ScheduleCourseDueDateReminders
log_prefix = COURSE_DUE_DATE_REMINDER_LOG_PREFIX

def add_arguments(self, parser):
super().add_arguments(parser)
parser.add_argument(
'--due-in',
type=int,
help='Remind subsections due in given days',
)

def handle(self, *args, **options):
current_date = datetime.datetime(
*[int(x) for x in options['date'].split('-')],
tzinfo=pytz.UTC
)

site = Site.objects.get(domain__iexact=options['site_domain_name'])
override_recipient_email = options.get('override_recipient_email')

self.async_send_task.enqueue(site, current_date, options['due_in'], override_recipient_email)
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
class Command(SendEmailBaseCommand):
"""
Command to send Schedule course updates for Self-paced Courses
Usage:
./manage.py lms send_course_next_section_update localhost:18000 --date 2023-06-08
"""
help = dedent(__doc__).strip()
async_send_task = ScheduleCourseNextSectionUpdate
Expand Down
4 changes: 4 additions & 0 deletions openedx/core/djangoapps/schedules/message_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,9 @@ class CourseUpdate(ScheduleMessageType):
pass


class CourseDueDatesReminder(ScheduleMessageType):
pass


class InstructorLedCourseUpdate(ScheduleMessageType):
pass
Loading

0 comments on commit 8f213ac

Please sign in to comment.