On publish, add evenly spaced dates to self-paced courses

This commit is contained in:
Calen Pennington
2020-02-03 16:27:09 -05:00
parent 67c79e27b4
commit 98328ea426
18 changed files with 220 additions and 61 deletions

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""
Django Admin pages for SelfPacedRelativeDatesConfig.
"""
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from openedx.core.djangoapps.config_model_utils.admin import StackedConfigModelAdmin
from .models import SelfPacedRelativeDatesConfig
class SelfPacedRelativeDatesConfigAdmin(StackedConfigModelAdmin):
"""
Admin for course duration limit
"""
fieldsets = (
('Context', {
'fields': SelfPacedRelativeDatesConfig.KEY_FIELDS,
'description': _(
'These define the context to enable course duration limits on. '
'If no values are set, then the configuration applies globally. '
'If a single value is set, then the configuration applies to all courses '
'within that context. At most one value can be set at a time.<br>'
'If multiple contexts apply to a course (for example, if configuration '
'is specified for the course specifically, and for the org that the course '
'is in, then the more specific context overrides the more general context.'
),
}),
('Configuration', {
'fields': ('enabled',),
'description': _(
'If any of these values is left empty or "Unknown", then their value '
'at runtime will be retrieved from the next most specific context that applies. '
'For example, if "Enabled" is left as "Unknown" in the course context, then that '
'course will be Enabled only if the org that it is in is Enabled.'
),
})
)
raw_id_fields = ('course',)
admin.site.register(SelfPacedRelativeDatesConfig, SelfPacedRelativeDatesConfigAdmin)

View File

@@ -7,6 +7,8 @@ from django.dispatch import receiver
from six import text_type
from xblock.fields import Scope
from xmodule.modulestore.django import SignalHandler, modulestore
from .models import SelfPacedRelativeDatesConfig
from .utils import get_expected_duration
from edx_when.api import FIELDS_TO_EXTRACT, set_dates_for_course
log = logging.getLogger(__name__)
@@ -44,6 +46,21 @@ def extract_dates_from_course(course):
# self-paced courses may accidentally have a course due date
metadata.pop('due', None)
date_items = [(course.location, metadata)]
if SelfPacedRelativeDatesConfig.current(course_key=course.id).enabled:
duration = get_expected_duration(course)
sections = course.get_children()
time_per_week = duration / len(sections)
# Apply the same relative due date to all content inside a section,
# unless that item already has a relative date set
for idx, section in enumerate(sections):
items = [section]
while items:
next_item = items.pop()
# TODO: Once studio can manually set relative dates,
# we would need to manually check for them here
date_items.append((next_item.location, {'due': time_per_week * (idx + 1)}))
items.extend(next_item.get_children())
else:
date_items = []
items = modulestore().get_items(course.id)

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.27 on 2020-02-03 21:22
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import openedx.core.djangoapps.config_model_utils.models
class Migration(migrations.Migration):
initial = True
dependencies = [
('course_overviews', '0019_improve_courseoverviewtab'),
('sites', '0002_alter_domain_unique'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SelfPacedRelativeDatesConfig',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.NullBooleanField(default=None, verbose_name='Enabled')),
('org', models.CharField(blank=True, db_index=True, help_text='Configure values for all course runs associated with this Organization. This is the organization string (i.e. edX, MITx).', max_length=255, null=True)),
('org_course', models.CharField(blank=True, db_index=True, help_text="Configure values for all course runs associated with this course. This is should be formatted as 'org+course' (i.e. MITx+6.002x, HarvardX+CS50).", max_length=255, null=True, validators=[openedx.core.djangoapps.config_model_utils.models.validate_course_in_org], verbose_name='Course in Org')),
('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')),
('course', models.ForeignKey(blank=True, help_text='Configure values for this course run. This should be formatted as the CourseKey (i.e. course-v1://MITx+6.002x+2019_Q1)', null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='course_overviews.CourseOverview', verbose_name='Course Run')),
('site', models.ForeignKey(blank=True, help_text='Configure values for all course runs associated with this site.', null=True, on_delete=django.db.models.deletion.CASCADE, to='sites.Site')),
],
options={
'abstract': False,
},
),
migrations.AddIndex(
model_name='selfpacedrelativedatesconfig',
index=models.Index(fields=['site', 'org', 'course'], name='course_date_site_id_a44836_idx'),
),
migrations.AddIndex(
model_name='selfpacedrelativedatesconfig',
index=models.Index(fields=['site', 'org', 'org_course', 'course'], name='course_date_site_id_c0164a_idx'),
),
]

View File

@@ -0,0 +1,16 @@
"""
Models for configuration course_date_signals
SelfPacedRelativeDatesConfig:
manage which orgs/courses/course runs have self-paced relative dates enabled
"""
from openedx.core.djangoapps.config_model_utils.models import StackedConfigurationModel
class SelfPacedRelativeDatesConfig(StackedConfigurationModel):
"""
Configuration to manage the SelfPacedRelativeDates settings.
.. no_pii:
"""

View File

@@ -0,0 +1,39 @@
"""
Utility functions around course dates.
get_expected_duration: return the expected duration of a course (absent any user information)
"""
from datetime import timedelta
from course_modes.models import CourseMode
from openedx.core.djangoapps.catalog.utils import get_course_run_details
MIN_DURATION = timedelta(weeks=4)
MAX_DURATION = timedelta(weeks=18)
def get_expected_duration(course):
"""
Return a `datetime.timedelta` defining the expected length of the supplied course.
"""
access_duration = MIN_DURATION
verified_mode = CourseMode.verified_mode_for_course(course=course, include_expired=True)
if not verified_mode:
return None
# The user course expiration date is the content availability date
# plus the weeks_to_complete field from course-discovery.
discovery_course_details = get_course_run_details(course.id, ['weeks_to_complete'])
expected_weeks = discovery_course_details.get('weeks_to_complete')
if expected_weeks:
access_duration = timedelta(weeks=expected_weeks)
# Course access duration is bounded by the min and max duration.
access_duration = max(MIN_DURATION, min(MAX_DURATION, access_duration))
return access_duration

View File

@@ -21,13 +21,12 @@ from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link
from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_specific_student
from openedx.core.djangoapps.catalog.utils import get_course_run_details
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.course_date_signals.utils import get_expected_duration
from openedx.core.djangolib.markup import HTML
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from student.models import CourseEnrollment
from util.date_utils import strftime_localized
MIN_DURATION = timedelta(weeks=4)
MAX_DURATION = timedelta(weeks=18)
EXPIRATION_DATE_FORMAT_STR = u'%b %-d, %Y'
@@ -64,28 +63,11 @@ def get_user_course_duration(user, course):
- If course fields are missing, default course access duration to MIN_DURATION.
"""
access_duration = MIN_DURATION
verified_mode = CourseMode.verified_mode_for_course(course=course, include_expired=True)
if not verified_mode:
return None
enrollment = CourseEnrollment.get_enrollment(user, course.id)
if enrollment is None or enrollment.mode != CourseMode.AUDIT:
return None
# The user course expiration date is the content availability date
# plus the weeks_to_complete field from course-discovery.
discovery_course_details = get_course_run_details(course.id, ['weeks_to_complete'])
expected_weeks = discovery_course_details.get('weeks_to_complete')
if expected_weeks:
access_duration = timedelta(weeks=expected_weeks)
# Course access duration is bounded by the min and max duration.
access_duration = max(MIN_DURATION, min(MAX_DURATION, access_duration))
return access_duration
return get_expected_duration(course)
def get_user_course_expiration_date(user, course):

View File

@@ -25,6 +25,7 @@ from lms.djangoapps.courseware.tests.factories import (
)
from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.course_date_signals.utils import MAX_DURATION, MIN_DURATION
from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA,
@@ -33,7 +34,7 @@ from openedx.core.djangoapps.django_comment_common.models import (
)
from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory
from openedx.features.content_type_gating.helpers import CONTENT_GATING_PARTITION_ID, CONTENT_TYPE_GATE_GROUP_IDS
from openedx.features.course_duration_limits.access import MAX_DURATION, MIN_DURATION, get_user_course_expiration_date
from openedx.features.course_duration_limits.access import get_user_course_expiration_date
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
from openedx.features.course_experience.tests.views.helpers import add_course_mode
from student.models import CourseEnrollment, FBEEnrollmentExclusion
@@ -68,7 +69,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase):
result = get_user_course_expiration_date(self.user, CourseOverview.get_from_id(self.course.id))
self.assertEqual(result, None)
@mock.patch("openedx.features.course_duration_limits.access.get_course_run_details")
@mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details")
@ddt.data(
[int(MIN_DURATION.days / 7) - 1, MIN_DURATION, False],
[7, timedelta(weeks=7), False],
@@ -102,7 +103,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase):
)
self.assertEqual(result, enrollment.created + access_duration)
@mock.patch("openedx.features.course_duration_limits.access.get_course_run_details")
@mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details")
def test_content_availability_date(self, mock_get_course_run_details):
"""
Content availability date is course start date or enrollment date, whichever is later.
@@ -146,7 +147,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase):
content_availability_date = start_date.replace(microsecond=0)
self.assertEqual(result, content_availability_date + access_duration)
@mock.patch("openedx.features.course_duration_limits.access.get_course_run_details")
@mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details")
def test_expired_upgrade_deadline(self, mock_get_course_run_details):
"""
The expiration date still exists if the upgrade deadline has passed
@@ -165,7 +166,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase):
content_availability_date = enrollment.created
self.assertEqual(result, content_availability_date + access_duration)
@mock.patch("openedx.features.course_duration_limits.access.get_course_run_details")
@mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details")
@ddt.data(
({'user_partition_id': CONTENT_GATING_PARTITION_ID,
'group_id': CONTENT_TYPE_GATE_GROUP_IDS['limited_access']}, True),
@@ -245,7 +246,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase):
self.assertEqual(response.status_code, 200)
return response
@mock.patch("openedx.features.course_duration_limits.access.get_course_run_details")
@mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details")
def test_masquerade_in_holdback(self, mock_get_course_run_details):
mock_get_course_run_details.return_value = {'weeks_to_complete': 12}
audit_student = UserFactory(username='audit')
@@ -279,7 +280,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase):
banner_text = 'You lose all access to this course, including your progress,'
self.assertNotContains(response, banner_text)
@mock.patch("openedx.features.course_duration_limits.access.get_course_run_details")
@mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details")
def test_masquerade_expired(self, mock_get_course_run_details):
mock_get_course_run_details.return_value = {'weeks_to_complete': 1}
@@ -315,7 +316,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase):
banner_text = 'This learner does not have access to this course. Their access expired on'
self.assertContains(response, banner_text)
@mock.patch("openedx.features.course_duration_limits.access.get_course_run_details")
@mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details")
@ddt.data(
InstructorFactory,
StaffFactory,
@@ -366,7 +367,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase):
banner_text = 'This learner does not have access to this course. Their access expired on'
self.assertNotContains(response, banner_text)
@mock.patch("openedx.features.course_duration_limits.access.get_course_run_details")
@mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details")
@ddt.data(
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_GROUP_MODERATOR,