Commit 9519087d by Victor Shnayder

Merge pull request #1271 from MITx/feature/victor/beta-testers

Feature/victor/beta testers
parents 39655bff b188c8e7
...@@ -7,8 +7,11 @@ TIME_FORMAT = "%Y-%m-%dT%H:%M" ...@@ -7,8 +7,11 @@ TIME_FORMAT = "%Y-%m-%dT%H:%M"
def parse_time(time_str): def parse_time(time_str):
""" """
Takes a time string in TIME_FORMAT, returns Takes a time string in TIME_FORMAT
it as a time_struct. Raises ValueError if the string is not in the right format.
Returns it as a time_struct.
Raises ValueError if the string is not in the right format.
""" """
return time.strptime(time_str, TIME_FORMAT) return time.strptime(time_str, TIME_FORMAT)
......
...@@ -414,7 +414,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): ...@@ -414,7 +414,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
'xqa_key', 'xqa_key',
# TODO: This is used by the XMLModuleStore to provide for locations for # TODO: This is used by the XMLModuleStore to provide for locations for
# static files, and will need to be removed when that code is removed # static files, and will need to be removed when that code is removed
'data_dir' 'data_dir',
# How many days early to show a course element to beta testers (float)
# intended to be set per-course, but can be overridden in for specific
# elements. Can be a float.
'days_early_for_beta'
) )
# cdodge: this is a list of metadata names which are 'system' metadata # cdodge: this is a list of metadata names which are 'system' metadata
...@@ -497,13 +501,27 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): ...@@ -497,13 +501,27 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
@property @property
def start(self): def start(self):
""" """
If self.metadata contains start, return it. Else return None. If self.metadata contains a valid start time, return it as a time struct.
Else return None.
""" """
if 'start' not in self.metadata: if 'start' not in self.metadata:
return None return None
return self._try_parse_time('start') return self._try_parse_time('start')
@property @property
def days_early_for_beta(self):
"""
If self.metadata contains start, return the number, as a float. Else return None.
"""
if 'days_early_for_beta' not in self.metadata:
return None
try:
return float(self.metadata['days_early_for_beta'])
except ValueError:
return None
@property
def own_metadata(self): def own_metadata(self):
""" """
Return the metadata that is not inherited, but was defined on this module. Return the metadata that is not inherited, but was defined on this module.
...@@ -715,7 +733,8 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): ...@@ -715,7 +733,8 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
""" """
Parse an optional metadata key containing a time: if present, complain Parse an optional metadata key containing a time: if present, complain
if it doesn't parse. if it doesn't parse.
Return None if not present or invalid.
Returns a time_struct, or None if metadata key is not present or is invalid.
""" """
if key in self.metadata: if key in self.metadata:
try: try:
......
...@@ -257,6 +257,7 @@ Supported fields at the course level: ...@@ -257,6 +257,7 @@ Supported fields at the course level:
* "tabs" -- have custom tabs in the courseware. See below for details on config. * "tabs" -- have custom tabs in the courseware. See below for details on config.
* "discussion_blackouts" -- An array of time intervals during which you want to disable a student's ability to create or edit posts in the forum. Moderators, Community TAs, and Admins are unaffected. You might use this during exam periods, but please be aware that the forum is often a very good place to catch mistakes and clarify points to students. The better long term solution would be to have better flagging/moderation mechanisms, but this is the hammer we have today. Format by example: [["2012-10-29T04:00", "2012-11-03T04:00"], ["2012-12-30T04:00", "2013-01-02T04:00"]] * "discussion_blackouts" -- An array of time intervals during which you want to disable a student's ability to create or edit posts in the forum. Moderators, Community TAs, and Admins are unaffected. You might use this during exam periods, but please be aware that the forum is often a very good place to catch mistakes and clarify points to students. The better long term solution would be to have better flagging/moderation mechanisms, but this is the hammer we have today. Format by example: [["2012-10-29T04:00", "2012-11-03T04:00"], ["2012-12-30T04:00", "2013-01-02T04:00"]]
* "show_calculator" (value "Yes" if desired) * "show_calculator" (value "Yes" if desired)
* "days_early_for_beta" -- number of days (floating point ok) early that students in the beta-testers group get to see course content. Can also be specified for any other course element, and overrides values set at higher levels.
* TODO: there are others * TODO: there are others
### Grading policy file contents ### Grading policy file contents
......
...@@ -4,13 +4,13 @@ like DISABLE_START_DATES""" ...@@ -4,13 +4,13 @@ like DISABLE_START_DATES"""
import logging import logging
import time import time
from datetime import datetime, timedelta
from django.conf import settings from django.conf import settings
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.timeparse import parse_time
from xmodule.x_module import XModule, XModuleDescriptor from xmodule.x_module import XModule, XModuleDescriptor
from student.models import CourseEnrollmentAllowed from student.models import CourseEnrollmentAllowed
...@@ -73,7 +73,7 @@ def has_access(user, obj, action): ...@@ -73,7 +73,7 @@ def has_access(user, obj, action):
raise TypeError("Unknown object type in has_access(): '{0}'" raise TypeError("Unknown object type in has_access(): '{0}'"
.format(type(obj))) .format(type(obj)))
def get_access_group_name(obj,action): def get_access_group_name(obj, action):
''' '''
Returns group name for user group which has "action" access to the given object. Returns group name for user group which has "action" access to the given object.
...@@ -226,9 +226,10 @@ def _has_access_descriptor(user, descriptor, action): ...@@ -226,9 +226,10 @@ def _has_access_descriptor(user, descriptor, action):
# Check start date # Check start date
if descriptor.start is not None: if descriptor.start is not None:
now = time.gmtime() now = time.gmtime()
if now > descriptor.start: effective_start = _adjust_start_date_for_beta_testers(user, descriptor)
if now > effective_start:
# after start date, everyone can see it # after start date, everyone can see it
debug("Allow: now > start date") debug("Allow: now > effective start date")
return True return True
# otherwise, need staff access # otherwise, need staff access
return _has_staff_access_to_descriptor(user, descriptor) return _has_staff_access_to_descriptor(user, descriptor)
...@@ -328,6 +329,15 @@ def _course_staff_group_name(location): ...@@ -328,6 +329,15 @@ def _course_staff_group_name(location):
""" """
return 'staff_%s' % Location(location).course return 'staff_%s' % Location(location).course
def course_beta_test_group_name(location):
"""
Get the name of the beta tester group for a location. Right now, that's
beta_testers_COURSE.
location: something that can passed to Location.
"""
return 'beta_testers_{0}'.format(Location(location).course)
def _course_instructor_group_name(location): def _course_instructor_group_name(location):
""" """
...@@ -348,6 +358,51 @@ def _has_global_staff_access(user): ...@@ -348,6 +358,51 @@ def _has_global_staff_access(user):
return False return False
def _adjust_start_date_for_beta_testers(user, descriptor):
"""
If user is in a beta test group, adjust the start date by the appropriate number of
days.
Arguments:
user: A django user. May be anonymous.
descriptor: the XModuleDescriptor the user is trying to get access to, with a
non-None start date.
Returns:
A time, in the same format as returned by time.gmtime(). Either the same as
start, or earlier for beta testers.
NOTE: number of days to adjust should be cached to avoid looking it up thousands of
times per query.
NOTE: For now, this function assumes that the descriptor's location is in the course
the user is looking at. Once we have proper usages and definitions per the XBlock
design, this should use the course the usage is in.
NOTE: If testing manually, make sure MITX_FEATURES['DISABLE_START_DATES'] = False
in envs/dev.py!
"""
if descriptor.days_early_for_beta is None:
# bail early if no beta testing is set up
return descriptor.start
user_groups = [g.name for g in user.groups.all()]
beta_group = course_beta_test_group_name(descriptor.location)
if beta_group in user_groups:
debug("Adjust start time: user in group %s", beta_group)
# time_structs don't support subtraction, so convert to datetimes,
# subtract, convert back.
# (fun fact: datetime(*a_time_struct[:6]) is the beautiful syntax for
# converting time_structs into datetimes)
start_as_datetime = datetime(*descriptor.start[:6])
delta = timedelta(descriptor.days_early_for_beta)
effective = start_as_datetime - delta
# ...and back to time_struct
return effective.timetuple()
return descriptor.start
def _has_instructor_access_to_location(user, location): def _has_instructor_access_to_location(user, location):
return _has_access_to_location(user, location, 'instructor') return _has_access_to_location(user, location, 'instructor')
......
...@@ -17,7 +17,8 @@ import xmodule.modulestore.django ...@@ -17,7 +17,8 @@ import xmodule.modulestore.django
# Need access to internal func to put users in the right group # Need access to internal func to put users in the right group
from courseware import grades from courseware import grades
from courseware.access import _course_staff_group_name from courseware.access import (has_access, _course_staff_group_name,
course_beta_test_group_name)
from courseware.models import StudentModuleCache from courseware.models import StudentModuleCache
from student.models import Registration from student.models import Registration
...@@ -238,7 +239,7 @@ class PageLoader(ActivateLoginTestCase): ...@@ -238,7 +239,7 @@ class PageLoader(ActivateLoginTestCase):
n = 0 n = 0
num_bad = 0 num_bad = 0
all_ok = True all_ok = True
for descriptor in module_store.modules[course_id].itervalues(): for descriptor in module_store.modules[course_id].itervalues():
n += 1 n += 1
print "Checking ", descriptor.location.url() print "Checking ", descriptor.location.url()
#print descriptor.__class__, descriptor.location #print descriptor.__class__, descriptor.location
...@@ -259,11 +260,11 @@ class PageLoader(ActivateLoginTestCase): ...@@ -259,11 +260,11 @@ class PageLoader(ActivateLoginTestCase):
# check content to make sure there were no rendering failures # check content to make sure there were no rendering failures
content = resp.content content = resp.content
if content.find("this module is temporarily unavailable")>=0: if content.find("this module is temporarily unavailable")>=0:
msg = "ERROR unavailable module " msg = "ERROR unavailable module "
all_ok = False all_ok = False
num_bad += 1 num_bad += 1
elif isinstance(descriptor, ErrorDescriptor): elif isinstance(descriptor, ErrorDescriptor):
msg = "ERROR error descriptor loaded: " msg = "ERROR error descriptor loaded: "
msg = msg + descriptor.definition['data']['error_msg'] msg = msg + descriptor.definition['data']['error_msg']
all_ok = False all_ok = False
num_bad += 1 num_bad += 1
...@@ -286,7 +287,7 @@ class TestCoursesLoadTestCase(PageLoader): ...@@ -286,7 +287,7 @@ class TestCoursesLoadTestCase(PageLoader):
# xmodule.modulestore.django.modulestore().collection.drop() # xmodule.modulestore.django.modulestore().collection.drop()
# store = xmodule.modulestore.django.modulestore() # store = xmodule.modulestore.django.modulestore()
# is there a way to empty the store? # is there a way to empty the store?
def test_toy_course_loads(self): def test_toy_course_loads(self):
self.check_pages_load('toy', TEST_DATA_DIR, modulestore()) self.check_pages_load('toy', TEST_DATA_DIR, modulestore())
...@@ -453,6 +454,9 @@ class TestViewAuth(PageLoader): ...@@ -453,6 +454,9 @@ class TestViewAuth(PageLoader):
"""Check that enrollment periods work""" """Check that enrollment periods work"""
self.run_wrapped(self._do_test_enrollment_period) self.run_wrapped(self._do_test_enrollment_period)
def test_beta_period(self):
"""Check that beta-test access works"""
self.run_wrapped(self._do_test_beta_period)
def _do_test_dark_launch(self): def _do_test_dark_launch(self):
"""Actually do the test, relying on settings to be right.""" """Actually do the test, relying on settings to be right."""
...@@ -618,6 +622,38 @@ class TestViewAuth(PageLoader): ...@@ -618,6 +622,38 @@ class TestViewAuth(PageLoader):
self.unenroll(self.toy) self.unenroll(self.toy)
self.assertTrue(self.try_enroll(self.toy)) self.assertTrue(self.try_enroll(self.toy))
def _do_test_beta_period(self):
"""Actually test beta periods, relying on settings to be right."""
# trust, but verify :)
self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES'])
# Make courses start in the future
tomorrow = time.time() + 24 * 3600
nextday = tomorrow + 24 * 3600
yesterday = time.time() - 24 * 3600
# toy course's hasn't started
self.toy.metadata['start'] = stringify_time(time.gmtime(tomorrow))
self.assertFalse(self.toy.has_started())
# but should be accessible for beta testers
self.toy.metadata['days_early_for_beta'] = '2'
# student user shouldn't see it
student_user = user(self.student)
self.assertFalse(has_access(student_user, self.toy, 'load'))
# now add the student to the beta test group
group_name = course_beta_test_group_name(self.toy.location)
g = Group.objects.create(name=group_name)
g.user_set.add(student_user)
# now the student should see it
self.assertTrue(has_access(student_user, self.toy, 'load'))
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestCourseGrader(PageLoader): class TestCourseGrader(PageLoader):
"""Check that a course gets graded properly""" """Check that a course gets graded properly"""
......
...@@ -179,7 +179,7 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader): ...@@ -179,7 +179,7 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader):
self.assertTrue(response.content.find('Removed "{0}" from "{1}" forum role = "{2}"'.format(username, course.id, rolename))>=0) self.assertTrue(response.content.find('Removed "{0}" from "{1}" forum role = "{2}"'.format(username, course.id, rolename))>=0)
self.assertFalse(has_forum_access(username, course.id, rolename)) self.assertFalse(has_forum_access(username, course.id, rolename))
def test_add_and_readd_forum_admin_users(self): def test_add_and_read_forum_admin_users(self):
course = self.toy course = self.toy
self.initialize_roles(course.id) self.initialize_roles(course.id)
url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
......
...@@ -36,6 +36,9 @@ table.stat_table td { ...@@ -36,6 +36,9 @@ table.stat_table td {
a.selectedmode { background-color: yellow; } a.selectedmode { background-color: yellow; }
textarea {
height: 200px;
}
</style> </style>
<script language="JavaScript" type="text/javascript"> <script language="JavaScript" type="text/javascript">
...@@ -58,8 +61,8 @@ function goto( mode) ...@@ -58,8 +61,8 @@ function goto( mode)
%endif %endif
<a href="#" onclick="goto('Admin');" class="${modeflag.get('Admin')}">Admin</a> | <a href="#" onclick="goto('Admin');" class="${modeflag.get('Admin')}">Admin</a> |
<a href="#" onclick="goto('Forum Admin');" class="${modeflag.get('Forum Admin')}">Forum Admin</a> | <a href="#" onclick="goto('Forum Admin');" class="${modeflag.get('Forum Admin')}">Forum Admin</a> |
<a href="#" onclick="goto('Enrollment');" class="${modeflag.get('Enrollment')}">Enrollment</a> <a href="#" onclick="goto('Enrollment');" class="${modeflag.get('Enrollment')}">Enrollment</a> |
] <a href="#" onclick="goto('Manage Groups');" class="${modeflag.get('Manage Groups')}">Manage Groups</a> ]
</h2> </h2>
<div style="text-align:right"><span id="djangopid">${djangopid}</span> <div style="text-align:right"><span id="djangopid">${djangopid}</span>
...@@ -168,7 +171,8 @@ function goto( mode) ...@@ -168,7 +171,8 @@ function goto( mode)
<p> <p>
<input type="submit" name="action" value="List course staff members"> <input type="submit" name="action" value="List course staff members">
<p> <p>
<input type="text" name="staffuser"> <input type="submit" name="action" value="Remove course staff"> <input type="text" name="staffuser">
<input type="submit" name="action" value="Remove course staff">
<input type="submit" name="action" value="Add course staff"> <input type="submit" name="action" value="Add course staff">
<hr width="40%" style="align:left"> <hr width="40%" style="align:left">
%endif %endif
...@@ -250,7 +254,7 @@ function goto( mode) ...@@ -250,7 +254,7 @@ function goto( mode)
%endif %endif
<p>Add students: enter emails, separated by returns or commas;</p> <p>Add students: enter emails, separated by new lines or commas;</p>
<textarea rows="6" cols="70" name="enroll_multiple"></textarea> <textarea rows="6" cols="70" name="enroll_multiple"></textarea>
<input type="submit" name="action" value="Enroll multiple students"> <input type="submit" name="action" value="Enroll multiple students">
...@@ -258,6 +262,24 @@ function goto( mode) ...@@ -258,6 +262,24 @@ function goto( mode)
##----------------------------------------------------------------------------- ##-----------------------------------------------------------------------------
%if modeflag.get('Manage Groups'):
%if instructor_access:
<hr width="40%" style="align:left">
<p>
<input type="submit" name="action" value="List beta testers">
<p>
Enter usernames or emails for students who should be beta-testers, one per line, or separated by commas. They will get to
see course materials early, as configured via the <tt>days_early_for_beta</tt> option in the course policy.
</p>
<p>
<textarea cols="50" row="30" name="betausers"></textarea>
<input type="submit" name="action" value="Remove beta testers">
<input type="submit" name="action" value="Add beta testers">
</p>
<hr width="40%" style="align:left">
%endif
%endif
</form> </form>
##----------------------------------------------------------------------------- ##-----------------------------------------------------------------------------
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment