Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions lms/djangoapps/course_api/blocks/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ def test_query_counts_cached(self, store_type, with_storage_backing):
self._get_blocks(
course,
expected_mongo_queries=0,
expected_sql_queries=13 if with_storage_backing else 12,
expected_sql_queries=14 if with_storage_backing else 13,
)

@ddt.data(
Expand All @@ -260,9 +260,9 @@ def test_query_counts_uncached(self, store_type_tuple, with_storage_backing):
clear_course_from_cache(course.id)

if with_storage_backing:
num_sql_queries = 23
num_sql_queries = 24
else:
num_sql_queries = 13
num_sql_queries = 14

self._get_blocks(
course,
Expand Down
8 changes: 4 additions & 4 deletions lms/djangoapps/courseware/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1492,8 +1492,8 @@ def test_view_certificate_link_hidden(self):
self.assertContains(resp, u"Download Your Certificate")

@ddt.data(
(True, 53),
(False, 52)
(True, 57),
(False, 56)
)
@ddt.unpack
def test_progress_queries_paced_courses(self, self_paced, query_count):
Expand All @@ -1506,8 +1506,8 @@ def test_progress_queries_paced_courses(self, self_paced, query_count):

@patch.dict(settings.FEATURES, {'ASSUME_ZERO_GRADE_IF_ABSENT_FOR_ALL_TESTS': False})
@ddt.data(
(False, 61, 40),
(True, 52, 35)
(False, 65, 44),
(True, 56, 39)
)
@ddt.unpack
def test_progress_queries(self, enable_waffle, initial, subsequent):
Expand Down
20 changes: 10 additions & 10 deletions lms/djangoapps/grades/tests/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,10 @@ def test_block_structure_created_only_once(self):
self.assertEqual(mock_block_structure_create.call_count, 1)

@ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 37, True),
(ModuleStoreEnum.Type.mongo, 1, 37, False),
(ModuleStoreEnum.Type.split, 3, 37, True),
(ModuleStoreEnum.Type.split, 3, 37, False),
(ModuleStoreEnum.Type.mongo, 1, 38, True),
(ModuleStoreEnum.Type.mongo, 1, 38, False),
(ModuleStoreEnum.Type.split, 3, 38, True),
(ModuleStoreEnum.Type.split, 3, 38, False),
)
@ddt.unpack
def test_query_counts(self, default_store, num_mongo_calls, num_sql_calls, create_multiple_subsections):
Expand All @@ -179,8 +179,8 @@ def test_query_counts(self, default_store, num_mongo_calls, num_sql_calls, creat
self._apply_recalculate_subsection_grade()

@ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 37),
(ModuleStoreEnum.Type.split, 3, 37),
(ModuleStoreEnum.Type.mongo, 1, 38),
(ModuleStoreEnum.Type.split, 3, 38),
)
@ddt.unpack
def test_query_counts_dont_change_with_more_content(self, default_store, num_mongo_calls, num_sql_calls):
Expand Down Expand Up @@ -225,8 +225,8 @@ def test_other_inaccessible_subsection(self, mock_subsection_signal):
)

@ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 20),
(ModuleStoreEnum.Type.split, 3, 20),
(ModuleStoreEnum.Type.mongo, 1, 21),
(ModuleStoreEnum.Type.split, 3, 21),
)
@ddt.unpack
def test_persistent_grades_not_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries):
Expand All @@ -240,8 +240,8 @@ def test_persistent_grades_not_enabled_on_course(self, default_store, num_mongo_
self.assertEqual(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0)

@ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 38),
(ModuleStoreEnum.Type.split, 3, 38),
(ModuleStoreEnum.Type.mongo, 1, 39),
(ModuleStoreEnum.Type.split, 3, 39),
)
@ddt.unpack
def test_persistent_grades_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries):
Expand Down
9 changes: 9 additions & 0 deletions lms/djangoapps/instructor/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,13 @@
from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted
from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA
from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles
from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.core.lib.teams_config import TeamsConfig
from openedx.core.lib.xblock_utils import grade_histogram
from openedx.features.course_experience import RELATIVE_DATES_FLAG
from shoppingcart.models import (
Coupon,
CouponRedemption,
Expand Down Expand Up @@ -4478,6 +4481,8 @@ def setUp(self):

self.user1 = user1
self.user2 = user2
ScheduleFactory.create(enrollment__user=self.user1, enrollment__course_id=self.course.id)
ScheduleFactory.create(enrollment__user=self.user2, enrollment__course_id=self.course.id)
self.instructor = InstructorFactory(course_key=self.course.id)
self.client.login(username=self.instructor.username, password='test')
extract_dates(None, self.course.id)
Expand Down Expand Up @@ -4519,6 +4524,7 @@ def test_change_nonexistent_due_date(self):
get_extended_due(self.course, self.week3, self.user1)
)

@override_waffle_flag(RELATIVE_DATES_FLAG, True)
def test_reset_date(self):
self.test_change_due_date()
url = reverse('reset_due_date', kwargs={'course_id': text_type(self.course.id)})
Expand Down Expand Up @@ -4637,10 +4643,13 @@ def setUp(self):

self.user1 = user1
self.user2 = user2
ScheduleFactory.create(enrollment__user=self.user1, enrollment__course_id=self.course.id)
ScheduleFactory.create(enrollment__user=self.user2, enrollment__course_id=self.course.id)
self.instructor = InstructorFactory(course_key=self.course.id)
self.client.login(username=self.instructor.username, password='test')
extract_dates(None, self.course.id)

@override_waffle_flag(RELATIVE_DATES_FLAG, True)
def test_reset_extension_to_deleted_date(self):
"""
Test that we can delete a due date extension after deleting the normal
Expand Down
7 changes: 5 additions & 2 deletions lms/djangoapps/instructor/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
from opaque_keys.edx.keys import CourseKey
from pytz import UTC

from edx_when import api
from edx_when.field_data import DateLookupFieldData
from openedx.core.djangoapps.course_date_signals import handlers
from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory
from student.tests.factories import UserFactory
from xmodule.fields import Date
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase
Expand Down Expand Up @@ -228,6 +228,8 @@ def setUp(self):
self.week3 = week3
self.user = user

ScheduleFactory.create(enrollment__user=self.user, enrollment__course_id=self.course.id)

inject_field_data((course, week1, week2, week3, homework, assignment), course, user)

def _clear_field_data_cache(self):
Expand All @@ -241,7 +243,6 @@ def _clear_field_data_cache(self):
block._field_data._load_dates(self.course.id, self.user, use_cached=False) # pylint: disable=protected-access
block.fields['due']._del_cached_value(block) # pylint: disable=protected-access

@api.override_enabled()
def test_set_due_date_extension(self):
extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=UTC)
tools.set_due_date_extension(self.course, self.week1, self.user, extended)
Expand Down Expand Up @@ -297,6 +298,8 @@ def setUp(self):
self.week2 = week2
self.user1 = user1
self.user2 = user2
ScheduleFactory.create(enrollment__user=self.user1, enrollment__course_id=self.course.id)
ScheduleFactory.create(enrollment__user=self.user2, enrollment__course_id=self.course.id)
handlers.extract_dates(None, course.id)

def test_dump_module_extensions(self):
Expand Down
45 changes: 43 additions & 2 deletions openedx/core/djangoapps/config_model_utils/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

from config_models.admin import ConfigurationModelAdmin
from django import forms
from django.utils.translation import ugettext_lazy as _
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangolib.markup import HTML, Text


class CourseOverviewField(forms.ModelChoiceField):
Expand All @@ -28,9 +30,48 @@ class StackedConfigModelAdmin(ConfigurationModelAdmin):
"""
form = StackedConfigModelAdminForm

def get_fields(self, request, obj=None):
raw_id_fields = ('course',)

def get_fieldsets(self, request, obj=None):
return (
('Context', {
'fields': self.key_fields,
'description': Text(_(
'These define the context to enable this configuration 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.'
)).format(br=HTML('<br>')),
}),
('Configuration', {
'fields': self.stackable_fields,
'description': _(
'If any of these values are 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.'
),
})
)

@property
def key_fields(self):
return list(self.model.KEY_FIELDS)

@property
def stackable_fields(self):
return list(self.model.STACKABLE_FIELDS)

@property
def config_fields(self):
fields = super(StackedConfigModelAdmin, self).get_fields(request, obj)
return list(self.model.KEY_FIELDS) + [field for field in fields if field not in self.model.KEY_FIELDS]
return [field for field in fields if field not in self.key_fields]

def get_fields(self, request, obj=None):
return self.key_fields + self.config_fields

def get_displayable_field_names(self):
"""
Expand Down
14 changes: 14 additions & 0 deletions openedx/core/djangoapps/course_date_signals/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
"""
Django Admin pages for SelfPacedRelativeDatesConfig.
"""


from django.contrib import admin

from openedx.core.djangoapps.config_model_utils.admin import StackedConfigModelAdmin

from .models import SelfPacedRelativeDatesConfig


admin.site.register(SelfPacedRelativeDatesConfig, StackedConfigModelAdmin)
17 changes: 17 additions & 0 deletions openedx/core/djangoapps/course_date_signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
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__)
Expand Down Expand Up @@ -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)}))
Comment thread
cpennington marked this conversation as resolved.
Outdated
items.extend(next_item.get_children())
else:
date_items = []
items = modulestore().get_items(course.id)
Expand Down
Original file line number Diff line number Diff line change
@@ -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'),
),
]
Empty file.
16 changes: 16 additions & 0 deletions openedx/core/djangoapps/course_date_signals/models.py
Original file line number Diff line number Diff line change
@@ -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):
Comment thread
cpennington marked this conversation as resolved.
Outdated
"""
Configuration to manage the SelfPacedRelativeDates settings.

.. no_pii:
"""
39 changes: 39 additions & 0 deletions openedx/core/djangoapps/course_date_signals/utils.py
Original file line number Diff line number Diff line change
@@ -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
Loading