Adds support for beta users to the course outline API

Adds days_early_for_beta to the ScheduleData class, and its loading
and usage to ScheduleOutlineProcessor.
This commit is contained in:
Bill Currie
2020-07-07 15:31:46 +09:00
committed by Kshitij Sobti
parent cb1635a60c
commit 17e69d7881
10 changed files with 214 additions and 14 deletions

View File

@@ -103,6 +103,7 @@ def get_course_outline(course_key: CourseKey) -> CourseOutlineData:
title=course_context.learning_context.title,
published_at=course_context.learning_context.published_at,
published_version=course_context.learning_context.published_version,
days_early_for_beta=course_context.days_early_for_beta,
sections=sections_data,
self_paced=course_context.self_paced,
course_visibility=CourseVisibility(course_context.course_visibility),
@@ -220,8 +221,14 @@ def _get_user_course_outline_and_processors(course_key: CourseKey,
**{
name: getattr(trimmed_course_outline, name)
for name in [
'course_key', 'title', 'published_at', 'published_version',
'sections', 'self_paced', 'course_visibility'
'course_key',
'title',
'published_at',
'published_version',
'sections',
'self_paced',
'course_visibility',
'days_early_for_beta',
]
}
)
@@ -270,6 +277,7 @@ def _update_course_context(course_outline: CourseOutlineData):
learning_context=learning_context,
defaults={
'course_visibility': course_outline.course_visibility.value,
'days_early_for_beta': course_outline.days_early_for_beta,
'self_paced': course_outline.self_paced,
}
)

View File

@@ -1,10 +1,12 @@
import logging
from collections import defaultdict, OrderedDict
from datetime import datetime
from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from edx_when.api import get_dates_for_course
from opaque_keys.edx.keys import CourseKey, UsageKey
from student.auth import user_has_role
from student.roles import CourseBetaTesterRole
from ...data import ScheduleData, ScheduleItemData, UserCourseOutlineData
from .base import OutlineProcessor
@@ -36,6 +38,7 @@ class ScheduleOutlineProcessor(OutlineProcessor):
self.keys_to_schedule_fields = defaultdict(dict)
self._course_start = None
self._course_end = None
self._is_beta_tester = False
def load_data(self):
"""Pull dates information from edx-when."""
@@ -49,6 +52,7 @@ class ScheduleOutlineProcessor(OutlineProcessor):
course_usage_key = self.course_key.make_usage_key('course', 'course')
self._course_start = self.keys_to_schedule_fields[course_usage_key].get('start')
self._course_end = self.keys_to_schedule_fields[course_usage_key].get('end')
self._is_beta_tester = user_has_role(self.user, CourseBetaTesterRole(self.course_key))
def inaccessible_sequences(self, full_course_outline):
"""
@@ -57,8 +61,13 @@ class ScheduleOutlineProcessor(OutlineProcessor):
Sequences are inaccessible, regardless of the individual Sequence start
dates.
"""
if self._is_beta_tester and full_course_outline.days_early_for_beta is not None:
start_offset = timedelta(days=full_course_outline.days_early_for_beta)
else:
start_offset = timedelta(days=0)
# If the course hasn't started at all, then everything is inaccessible.
if self._course_start is None or self.at_time < self._course_start:
if self._course_start is None or self.at_time < self._course_start - start_offset:
return set(full_course_outline.sequences)
self_paced = full_course_outline.self_paced
@@ -66,6 +75,8 @@ class ScheduleOutlineProcessor(OutlineProcessor):
inaccessible = set()
for section in full_course_outline.sections:
section_start = self.keys_to_schedule_fields[section.usage_key].get('start')
if section_start is not None:
section_start -= start_offset
if section_start and self.at_time < section_start:
# If the section hasn't started yet, all the sequences it
# contains are inaccessible, regardless of the start value for
@@ -74,6 +85,8 @@ class ScheduleOutlineProcessor(OutlineProcessor):
else:
for seq in section.sequences:
seq_start = self.keys_to_schedule_fields[seq.usage_key].get('start')
if seq_start is not None:
seq_start -= start_offset
if seq_start and self.at_time < seq_start:
inaccessible.add(seq.usage_key)
continue
@@ -110,12 +123,22 @@ class ScheduleOutlineProcessor(OutlineProcessor):
course_usage_key = self.course_key.make_usage_key('course', 'course')
course_start = self.keys_to_schedule_fields[course_usage_key].get('start')
course_end = self.keys_to_schedule_fields[course_usage_key].get('end')
days_early_for_beta = pruned_course_outline.days_early_for_beta
if days_early_for_beta is not None and self._is_beta_tester:
start_offset = timedelta(days=days_early_for_beta)
else:
start_offset = timedelta(days=0)
if course_start is not None:
course_start -= start_offset
sequences = {}
sections = {}
for section in pruned_course_outline.sections:
section_dict = self.keys_to_schedule_fields[section.usage_key]
section_start = section_dict.get('start')
if section_start is not None:
section_start -= start_offset
section_effective_start = _effective_start(course_start, section_start)
section_due = section_dict.get('due')
@@ -129,6 +152,8 @@ class ScheduleOutlineProcessor(OutlineProcessor):
for seq in section.sequences:
seq_dict = self.keys_to_schedule_fields[seq.usage_key]
seq_start = seq_dict.get('start')
if seq_start is not None:
seq_start -= start_offset
seq_due = seq_dict.get('due')
sequences[seq.usage_key] = ScheduleItemData(
usage_key=seq.usage_key,

View File

@@ -1,6 +1,7 @@
from datetime import datetime, timezone
from unittest import TestCase
import pytest
from opaque_keys.edx.keys import CourseKey
import attr
@@ -31,6 +32,7 @@ class TestCourseOutlineData(TestCase):
title="Exciting Test Course!",
published_at=datetime(2020, 5, 19, tzinfo=timezone.utc),
published_version="5ebece4b69dd593d82fe2014",
days_early_for_beta=None,
sections=generate_sections(cls.course_key, [3, 2]),
self_paced=False,
course_visibility=CourseVisibility.PRIVATE
@@ -103,6 +105,26 @@ class TestCourseOutlineData(TestCase):
new_outline = self.course_outline.remove({seq_key_to_remove})
assert new_outline == self.course_outline
def test_days_early_for_beta(self):
"""
Check that days_early_for_beta exists, can be set, and validates correctly.
"""
assert self.course_outline.days_early_for_beta is None
new_outline = attr.evolve(
self.course_outline,
days_early_for_beta=5
)
assert new_outline is not None
assert new_outline != self.course_outline
assert new_outline.days_early_for_beta == 5
with pytest.raises(ValueError) as error:
attr.evolve(self.course_outline, days_early_for_beta=-1)
assert error.match(
"Provided value -1 for days_early_for_beta is invalid. The value must be positive or zero. "
"A positive value will shift back the starting date for Beta users by that many days."
)
def generate_sections(course_key, num_sequences):
"""

View File

@@ -10,9 +10,13 @@ from edx_when.api import set_dates_for_course
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import BlockUsageLocator
from lms.djangoapps.courseware.tests.factories import BetaTesterFactory
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG
from student.auth import user_has_role
from student.models import CourseEnrollment
from student.roles import CourseBetaTesterRole
from ...data import (
CourseLearningSequenceData, CourseOutlineData, CourseSectionData, VisibilityData, CourseVisibility
@@ -42,6 +46,7 @@ class CourseOutlineTestCase(CacheIsolationTestCase):
title="Roundtrip Test Course!",
published_at=datetime(2020, 5, 20, tzinfo=timezone.utc),
published_version="5ebece4b69dd593d82fe2015",
days_early_for_beta=None,
sections=generate_sections(cls.course_key, [2, 2]),
self_paced=False,
course_visibility=CourseVisibility.PRIVATE
@@ -117,6 +122,7 @@ class UserCourseOutlineTestCase(CacheIsolationTestCase):
@classmethod
def setUpTestData(cls):
course_key = CourseKey.from_string("course-v1:OpenEdX+Outline+T1")
# Users...
cls.global_staff = User.objects.create_user(
'global_staff', email='gstaff@example.com', is_staff=True
@@ -124,15 +130,17 @@ class UserCourseOutlineTestCase(CacheIsolationTestCase):
cls.student = User.objects.create_user(
'student', email='student@example.com', is_staff=False
)
cls.beta_tester = BetaTesterFactory(course_key=course_key)
cls.anonymous_user = AnonymousUser()
# Seed with data
cls.course_key = CourseKey.from_string("course-v1:OpenEdX+Outline+T1")
cls.course_key = course_key
cls.simple_outline = CourseOutlineData(
course_key=cls.course_key,
title="User Outline Test Course!",
published_at=datetime(2020, 5, 20, tzinfo=timezone.utc),
published_version="5ebece4b69dd593d82fe2020",
days_early_for_beta=None,
sections=generate_sections(cls.course_key, [2, 1, 3]),
self_paced=False,
course_visibility=CourseVisibility.PRIVATE
@@ -140,20 +148,31 @@ class UserCourseOutlineTestCase(CacheIsolationTestCase):
replace_course_outline(cls.simple_outline)
# Enroll student in the course
cls.student.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit")
CourseEnrollment.enroll(user=cls.student, course_key=cls.course_key, mode="audit")
# Enroll beta tester in the course
CourseEnrollment.enroll(user=cls.beta_tester, course_key=cls.course_key, mode="audit")
def test_simple_outline(self):
"""This outline is the same for everyone."""
at_time = datetime(2020, 5, 21, tzinfo=timezone.utc)
beta_tester_outline = get_user_course_outline(
self.course_key, self.beta_tester, at_time
)
student_outline = get_user_course_outline(
self.course_key, self.student, at_time
)
global_staff_outline = get_user_course_outline(
self.course_key, self.global_staff, at_time
)
assert beta_tester_outline.sections == global_staff_outline.sections
assert student_outline.sections == global_staff_outline.sections
assert student_outline.at_time == at_time
beta_tester_outline_details = get_user_course_outline_details(
self.course_key, self.beta_tester, at_time
)
assert beta_tester_outline_details.outline == beta_tester_outline
student_outline_details = get_user_course_outline_details(
self.course_key, self.student, at_time
)
@@ -177,6 +196,7 @@ class ScheduleTestCase(CacheIsolationTestCase):
@classmethod
def setUpTestData(cls):
course_key = CourseKey.from_string("course-v1:OpenEdX+Outline+T1")
# Users...
cls.global_staff = User.objects.create_user(
'global_staff', email='gstaff@example.com', is_staff=True
@@ -184,9 +204,10 @@ class ScheduleTestCase(CacheIsolationTestCase):
cls.student = User.objects.create_user(
'student', email='student@example.com', is_staff=False
)
cls.beta_tester = BetaTesterFactory(course_key=course_key)
cls.anonymous_user = AnonymousUser()
cls.course_key = CourseKey.from_string("course-v1:OpenEdX+Outline+T1")
cls.course_key = course_key
# The UsageKeys we're going to set up for date tests.
cls.section_key = cls.course_key.make_usage_key('chapter', 'ch1')
@@ -256,6 +277,7 @@ class ScheduleTestCase(CacheIsolationTestCase):
title="User Outline Test Course!",
published_at=datetime(2020, 5, 20, tzinfo=timezone.utc),
published_version="5ebece4b69dd593d82fe2020",
days_early_for_beta=None,
course_visibility=CourseVisibility.PRIVATE,
sections=[
CourseSectionData(
@@ -291,17 +313,22 @@ class ScheduleTestCase(CacheIsolationTestCase):
]
)
],
self_paced=False
self_paced=False,
)
replace_course_outline(cls.outline)
# Enroll student in the course
cls.student.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit")
# Enroll beta tester in the course
cls.beta_tester.courseenrollment_set.create(course_id=cls.course_key, is_active=True, mode="audit")
assert user_has_role(cls.beta_tester, CourseBetaTesterRole(cls.course_key))
assert cls.outline.days_early_for_beta is None
def get_details(self, at_time):
staff_details = get_user_course_outline_details(self.course_key, self.global_staff, at_time)
student_details = get_user_course_outline_details(self.course_key, self.student, at_time)
return staff_details, student_details
beta_tester_details = get_user_course_outline_details(self.course_key, self.beta_tester, at_time)
return staff_details, student_details, beta_tester_details
def get_sequence_keys(self, exclude=None):
if exclude is None:
@@ -311,20 +338,43 @@ class ScheduleTestCase(CacheIsolationTestCase):
return [key for key in self.all_seq_keys if key not in exclude]
def test_before_course_starts(self):
staff_details, student_details = self.get_details(
staff_details, student_details, beta_tester_details = self.get_details(
datetime(2020, 5, 9, tzinfo=timezone.utc)
)
# Staff can always access all sequences
assert len(staff_details.outline.accessible_sequences) == 5
# Student can access nothing
assert len(student_details.outline.accessible_sequences) == 0
# Beta tester can access nothing
assert len(beta_tester_details.outline.accessible_sequences) == 0
# Everyone can see everything
assert len(staff_details.outline.sequences) == 5
assert len(student_details.outline.sequences) == 5
assert len(beta_tester_details.outline.sequences) == 5
def test_course_beta_access(self):
course_outline = attr.evolve(self.outline, days_early_for_beta=6)
assert course_outline.days_early_for_beta is not None
replace_course_outline(course_outline)
staff_details, student_details, beta_tester_details = self.get_details(
datetime(2020, 5, 9, tzinfo=timezone.utc)
)
# Staff can always access all sequences
assert len(staff_details.outline.accessible_sequences) == 5
# Student can access nothing
assert len(student_details.outline.accessible_sequences) == 0
# Beta tester can access some
assert len(beta_tester_details.outline.accessible_sequences) == 4
# Everyone can see everything
assert len(staff_details.outline.sequences) == 5
assert len(student_details.outline.sequences) == 5
assert len(beta_tester_details.outline.sequences) == 5
def test_before_section_starts(self):
staff_details, student_details = self.get_details(
staff_details, student_details, beta_tester_details = self.get_details(
datetime(2020, 5, 14, tzinfo=timezone.utc)
)
# Staff can always access all sequences
@@ -338,8 +388,33 @@ class ScheduleTestCase(CacheIsolationTestCase):
assert before_seq_sched_item_data.start == datetime(2020, 5, 14, tzinfo=timezone.utc)
assert before_seq_sched_item_data.effective_start == datetime(2020, 5, 15, tzinfo=timezone.utc)
# Beta tester can access nothing
assert len(beta_tester_details.outline.accessible_sequences) == 0
def test_section_beta_access(self):
course_outline = attr.evolve(self.outline, days_early_for_beta=1)
assert course_outline.days_early_for_beta is not None
replace_course_outline(course_outline)
staff_details, student_details, beta_tester_details = self.get_details(
datetime(2020, 5, 14, tzinfo=timezone.utc)
)
# Staff can always access all sequences
assert len(staff_details.outline.accessible_sequences) == 5
# Student can access nothing -- even though one of the sequences is set
# to start on 2020-05-14, it's not available because the section hasn't
# started yet.
assert len(student_details.outline.accessible_sequences) == 0
before_seq_sched_item_data = student_details.schedule.sequences[self.seq_before_key]
assert before_seq_sched_item_data.start == datetime(2020, 5, 14, tzinfo=timezone.utc)
assert before_seq_sched_item_data.effective_start == datetime(2020, 5, 15, tzinfo=timezone.utc)
# Beta tester can access some
assert len(beta_tester_details.outline.accessible_sequences) == 4
def test_at_section_start(self):
staff_details, student_details = self.get_details(
staff_details, student_details, beta_tester_details = self.get_details(
datetime(2020, 5, 15, tzinfo=timezone.utc)
)
# Staff can always access all sequences
@@ -352,8 +427,32 @@ class ScheduleTestCase(CacheIsolationTestCase):
for key in self.get_sequence_keys(exclude=[self.seq_after_key]):
assert key in student_details.outline.accessible_sequences
# Beta tester can access same as student
assert len(beta_tester_details.outline.accessible_sequences) == 4
def test_at_beta_section_start(self):
course_outline = attr.evolve(self.outline, days_early_for_beta=1)
assert course_outline.days_early_for_beta is not None
replace_course_outline(course_outline)
staff_details, student_details, beta_tester_details = self.get_details(
datetime(2020, 5, 15, tzinfo=timezone.utc)
)
# Staff can always access all sequences
assert len(staff_details.outline.accessible_sequences) == 5
# Student can access all sequences except the one that starts after this
# datetime (self.seq_after_key)
assert len(student_details.outline.accessible_sequences) == 4
assert self.seq_after_key not in student_details.outline.accessible_sequences
for key in self.get_sequence_keys(exclude=[self.seq_after_key]):
assert key in student_details.outline.accessible_sequences
# Beta tester can access all
assert len(beta_tester_details.outline.accessible_sequences) == 5
def test_is_due_and_before_due(self):
staff_details, student_details = self.get_details(
staff_details, student_details, beta_tester_details = self.get_details(
datetime(2020, 5, 16, tzinfo=timezone.utc)
)
# Staff can always access all sequences
@@ -367,8 +466,11 @@ class ScheduleTestCase(CacheIsolationTestCase):
seq_due_sched_item_data = student_details.schedule.sequences[self.seq_due_key]
assert seq_due_sched_item_data.due == datetime(2020, 5, 20, tzinfo=timezone.utc)
# Beta tester can access some
assert len(beta_tester_details.outline.accessible_sequences) == 5
def test_is_due_and_after_due(self):
staff_details, student_details = self.get_details(
staff_details, student_details, beta_tester_details = self.get_details(
datetime(2020, 5, 21, tzinfo=timezone.utc)
)
# Staff can always access all sequences
@@ -382,6 +484,9 @@ class ScheduleTestCase(CacheIsolationTestCase):
for key in self.get_sequence_keys(exclude=[self.seq_due_key]):
assert key in student_details.outline.accessible_sequences
# Beta tester can access same as student
assert len(beta_tester_details.outline.accessible_sequences) == 4
class SelfPacedCourseOutlineTestCase(CacheIsolationTestCase):
@classmethod
@@ -439,6 +544,7 @@ class SelfPacedCourseOutlineTestCase(CacheIsolationTestCase):
title="User Outline Test Course!",
published_at=datetime(2020, 5, 20, tzinfo=timezone.utc),
published_version="5ebece4b69dd593d82fe2020",
days_early_for_beta=None,
course_visibility=CourseVisibility.PRIVATE,
sections=[
CourseSectionData(
@@ -528,6 +634,7 @@ class VisbilityTestCase(CacheIsolationTestCase):
title="User Outline Test Course!",
published_at=datetime(2020, 5, 20, tzinfo=timezone.utc),
published_version="5ebece4b69dd593d82fe2020",
days_early_for_beta=None,
course_visibility=CourseVisibility.PRIVATE,
sections=[
CourseSectionData(
@@ -610,6 +717,7 @@ class SequentialVisibilityTestCase(CacheIsolationTestCase):
title="User Outline Test Course!",
published_at=datetime(2020, 5, 20, tzinfo=timezone.utc),
published_version="5ebece4b69dd593d82fe2020",
days_early_for_beta=None,
sections=generate_sections(cls.course_key, [2, 1, 3]),
self_paced=False,
course_visibility=CourseVisibility.PRIVATE

View File

@@ -132,6 +132,10 @@ class CourseOutlineData:
# course is modified.
published_version = attr.ib(type=str)
# The time period (in days) before the official start of the course during which
# beta testers have access to the course.
days_early_for_beta = attr.ib(type=Optional[int])
sections = attr.ib(type=List[CourseSectionData])
# Defines if course self-paced or instructor-paced.
@@ -199,6 +203,17 @@ class CourseOutlineData:
]
)
@days_early_for_beta.validator
def validate_days_early_for_beta(self, attribute, value):
"""
Ensure that days_early_for_beta isn't negative.
"""
if value is not None and value < 0:
raise ValueError(
"Provided value {} for days_early_for_beta is invalid. The value must be positive or zero. "
"A positive value will shift back the starting date for Beta users by that many days.".format(value)
)
@attr.s(frozen=True)
class ScheduleItemData:

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.15 on 2020-09-01 07:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('learning_sequences', '0004_coursecontext_self_paced'),
]
operations = [
migrations.AddField(
model_name='coursecontext',
name='days_early_for_beta',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@@ -80,6 +80,7 @@ class CourseContext(TimeStampedModel):
course_visibility = models.CharField(
max_length=32, choices=[(constant.value, constant.value) for constant in CourseVisibility]
)
days_early_for_beta = models.IntegerField(null=True, blank=True)
self_paced = models.BooleanField(default=False)

View File

@@ -71,6 +71,7 @@ def get_outline_from_modulestore(course_key):
title=course.display_name,
published_at=course.subtree_edited_on,
published_version=str(course.course_version), # .course_version is a BSON obj
days_early_for_beta=course.days_early_for_beta,
sections=sections_data,
self_paced=course.self_paced,
course_visibility=CourseVisibility(course.course_visibility),

View File

@@ -44,6 +44,7 @@ class CourseOutlineViewTest(CacheIsolationTestCase, APITestCase):
title="Views Test Course!",
published_at=datetime(2020, 5, 20, tzinfo=timezone.utc),
published_version="5ebece4b69dd593d82fe2020",
days_early_for_beta=None,
sections=generate_sections(cls.course_key, [2, 2]),
self_paced=False,
course_visibility=CourseVisibility.PUBLIC

View File

@@ -70,6 +70,7 @@ class CourseOutlineView(APIView):
"title": user_course_outline.title,
"published_at": user_course_outline.published_at,
"published_version": user_course_outline.published_version,
"days_early_for_beta": user_course_outline.days_early_for_beta,
"self_paced": user_course_outline.self_paced,
# Who and when this request was generated for (we can eventually