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})
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
from collections import defaultdict from collections import defaultdict
import csv import csv
import itertools
import json import json
import logging import logging
import os import os
...@@ -19,9 +20,13 @@ from mitxmako.shortcuts import render_to_response ...@@ -19,9 +20,13 @@ from mitxmako.shortcuts import render_to_response
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from courseware import grades from courseware import grades
from courseware.access import has_access, get_access_group_name from courseware.access import (has_access, get_access_group_name,
from courseware.courses import get_course_with_access course_beta_test_group_name)
from django_comment_client.models import Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA from courseware.courses import get_course_with_access
from django_comment_client.models import (Role,
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA)
from django_comment_client.utils import has_forum_access from django_comment_client.utils import has_forum_access
from psychometrics import psychoanalyze from psychometrics import psychoanalyze
from student.models import CourseEnrollment, CourseEnrollmentAllowed from student.models import CourseEnrollment, CourseEnrollmentAllowed
...@@ -44,13 +49,12 @@ FORUM_ROLE_REMOVE = 'remove' ...@@ -44,13 +49,12 @@ FORUM_ROLE_REMOVE = 'remove'
@ensure_csrf_cookie @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
def instructor_dashboard(request, course_id): def instructor_dashboard(request, course_id):
"""Display the instructor dashboard for a course.""" """Display the instructor dashboard for a course."""
course = get_course_with_access(request.user, course_id, 'staff') course = get_course_with_access(request.user, course_id, 'staff')
instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists
forum_admin_access = has_forum_access(request.user, course_id, FORUM_ROLE_ADMINISTRATOR) forum_admin_access = has_forum_access(request.user, course_id, FORUM_ROLE_ADMINISTRATOR)
msg = '' msg = ''
...@@ -105,6 +109,16 @@ def instructor_dashboard(request, course_id): ...@@ -105,6 +109,16 @@ def instructor_dashboard(request, course_id):
except Group.DoesNotExist: except Group.DoesNotExist:
group = Group(name=grpname) # create the group group = Group(name=grpname) # create the group
group.save() group.save()
def get_beta_group(course):
"""
Get the group for beta testers of course.
"""
# Not using get_group because there is no access control action called
# 'beta', so adding it to get_access_group_name doesn't really make
# sense.
name = course_beta_test_group_name(course.location)
(group, created) = Group.objects.get_or_create(name=name)
return group return group
# process actions from form POST # process actions from form POST
...@@ -237,11 +251,7 @@ def instructor_dashboard(request, course_id): ...@@ -237,11 +251,7 @@ def instructor_dashboard(request, course_id):
elif 'List course staff' in action: elif 'List course staff' in action:
group = get_staff_group(course) group = get_staff_group(course)
msg += 'Staff group = {0}'.format(group.name) msg += 'Staff group = {0}'.format(group.name)
log.debug('staffgrp={0}'.format(group.name)) datatable = _group_members_table(group, "List of Staff", course_id)
uset = group.user_set.all()
datatable = {'header': ['Username', 'Full name']}
datatable['data'] = [[x.username, x.profile.name] for x in uset]
datatable['title'] = 'List of Staff in course {0}'.format(course_id)
track.views.server_track(request, 'list-staff', {}, page='idashboard') track.views.server_track(request, 'list-staff', {}, page='idashboard')
elif 'List course instructors' in action and request.user.is_staff: elif 'List course instructors' in action and request.user.is_staff:
...@@ -256,17 +266,8 @@ def instructor_dashboard(request, course_id): ...@@ -256,17 +266,8 @@ def instructor_dashboard(request, course_id):
elif action == 'Add course staff': elif action == 'Add course staff':
uname = request.POST['staffuser'] uname = request.POST['staffuser']
try: group = get_staff_group(course)
user = User.objects.get(username=uname) msg += add_user_to_group(request, uname, group, 'staff', 'staff')
except User.DoesNotExist:
msg += '<font color="red">Error: unknown username "{0}"</font>'.format(uname)
user = None
if user is not None:
group = get_staff_group(course)
msg += '<font color="green">Added {0} to staff group = {1}</font>'.format(user, group.name)
log.debug('staffgrp={0}'.format(group.name))
user.groups.add(group)
track.views.server_track(request, 'add-staff {0}'.format(user), {}, page='idashboard')
elif action == 'Add instructor' and request.user.is_staff: elif action == 'Add instructor' and request.user.is_staff:
uname = request.POST['instructor'] uname = request.POST['instructor']
...@@ -284,17 +285,8 @@ def instructor_dashboard(request, course_id): ...@@ -284,17 +285,8 @@ def instructor_dashboard(request, course_id):
elif action == 'Remove course staff': elif action == 'Remove course staff':
uname = request.POST['staffuser'] uname = request.POST['staffuser']
try: group = get_staff_group(course)
user = User.objects.get(username=uname) msg += remove_user_from_group(request, uname, group, 'staff', 'staff')
except User.DoesNotExist:
msg += '<font color="red">Error: unknown username "{0}"</font>'.format(uname)
user = None
if user is not None:
group = get_staff_group(course)
msg += '<font color="green">Removed {0} from staff group = {1}</font>'.format(user, group.name)
log.debug('staffgrp={0}'.format(group.name))
user.groups.remove(group)
track.views.server_track(request, 'remove-staff {0}'.format(user), {}, page='idashboard')
elif action == 'Remove instructor' and request.user.is_staff: elif action == 'Remove instructor' and request.user.is_staff:
uname = request.POST['instructor'] uname = request.POST['instructor']
...@@ -311,25 +303,49 @@ def instructor_dashboard(request, course_id): ...@@ -311,25 +303,49 @@ def instructor_dashboard(request, course_id):
track.views.server_track(request, 'remove-instructor {0}'.format(user), {}, page='idashboard') track.views.server_track(request, 'remove-instructor {0}'.format(user), {}, page='idashboard')
#---------------------------------------- #----------------------------------------
# Group management
elif 'List beta testers' in action:
group = get_beta_group(course)
msg += 'Beta test group = {0}'.format(group.name)
datatable = _group_members_table(group, "List of beta_testers", course_id)
track.views.server_track(request, 'list-beta-testers', {}, page='idashboard')
elif action == 'Add beta testers':
users = request.POST['betausers']
log.debug("users: {0!r}".format(users))
group = get_beta_group(course)
for username_or_email in _split_by_comma_and_whitespace(users):
msg += "<p>{0}</p>".format(
add_user_to_group(request, username_or_email, group, 'beta testers', 'beta-tester'))
elif action == 'Remove beta testers':
users = request.POST['betausers']
group = get_beta_group(course)
for username_or_email in _split_by_comma_and_whitespace(users):
msg += "<p>{0}</p>".format(
remove_user_from_group(request, username_or_email, group, 'beta testers', 'beta-tester'))
#----------------------------------------
# forum administration # forum administration
elif action == 'List course forum admins': elif action == 'List course forum admins':
rolename = FORUM_ROLE_ADMINISTRATOR rolename = FORUM_ROLE_ADMINISTRATOR
datatable = {} datatable = {}
msg += _list_course_forum_members(course_id, rolename, datatable) msg += _list_course_forum_members(course_id, rolename, datatable)
track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard') track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard')
elif action == 'Remove forum admin': elif action == 'Remove forum admin':
uname = request.POST['forumadmin'] uname = request.POST['forumadmin']
msg += _update_forum_role_membership(uname, course, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_REMOVE) msg += _update_forum_role_membership(uname, course, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_REMOVE)
track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_ADMINISTRATOR, course_id), track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_ADMINISTRATOR, course_id),
{}, page='idashboard') {}, page='idashboard')
elif action == 'Add forum admin': elif action == 'Add forum admin':
uname = request.POST['forumadmin'] uname = request.POST['forumadmin']
msg += _update_forum_role_membership(uname, course, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_ADD) msg += _update_forum_role_membership(uname, course, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_ADD)
track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_ADMINISTRATOR, course_id), track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_ADMINISTRATOR, course_id),
{}, page='idashboard') {}, page='idashboard')
elif action == 'List course forum moderators': elif action == 'List course forum moderators':
...@@ -337,35 +353,35 @@ def instructor_dashboard(request, course_id): ...@@ -337,35 +353,35 @@ def instructor_dashboard(request, course_id):
datatable = {} datatable = {}
msg += _list_course_forum_members(course_id, rolename, datatable) msg += _list_course_forum_members(course_id, rolename, datatable)
track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard') track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard')
elif action == 'Remove forum moderator': elif action == 'Remove forum moderator':
uname = request.POST['forummoderator'] uname = request.POST['forummoderator']
msg += _update_forum_role_membership(uname, course, FORUM_ROLE_MODERATOR, FORUM_ROLE_REMOVE) msg += _update_forum_role_membership(uname, course, FORUM_ROLE_MODERATOR, FORUM_ROLE_REMOVE)
track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_MODERATOR, course_id), track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_MODERATOR, course_id),
{}, page='idashboard') {}, page='idashboard')
elif action == 'Add forum moderator': elif action == 'Add forum moderator':
uname = request.POST['forummoderator'] uname = request.POST['forummoderator']
msg += _update_forum_role_membership(uname, course, FORUM_ROLE_MODERATOR, FORUM_ROLE_ADD) msg += _update_forum_role_membership(uname, course, FORUM_ROLE_MODERATOR, FORUM_ROLE_ADD)
track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_MODERATOR, course_id), track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_MODERATOR, course_id),
{}, page='idashboard') {}, page='idashboard')
elif action == 'List course forum community TAs': elif action == 'List course forum community TAs':
rolename = FORUM_ROLE_COMMUNITY_TA rolename = FORUM_ROLE_COMMUNITY_TA
datatable = {} datatable = {}
msg += _list_course_forum_members(course_id, rolename, datatable) msg += _list_course_forum_members(course_id, rolename, datatable)
track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard') track.views.server_track(request, 'list-{0}'.format(rolename), {}, page='idashboard')
elif action == 'Remove forum community TA': elif action == 'Remove forum community TA':
uname = request.POST['forummoderator'] uname = request.POST['forummoderator']
msg += _update_forum_role_membership(uname, course, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_REMOVE) msg += _update_forum_role_membership(uname, course, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_REMOVE)
track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_COMMUNITY_TA, course_id), track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_REMOVE, uname, FORUM_ROLE_COMMUNITY_TA, course_id),
{}, page='idashboard') {}, page='idashboard')
elif action == 'Add forum community TA': elif action == 'Add forum community TA':
uname = request.POST['forummoderator'] uname = request.POST['forummoderator']
msg += _update_forum_role_membership(uname, course, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_ADD) msg += _update_forum_role_membership(uname, course, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_ADD)
track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_COMMUNITY_TA, course_id), track.views.server_track(request, '{0} {1} as {2} for {3}'.format(FORUM_ROLE_ADD, uname, FORUM_ROLE_COMMUNITY_TA, course_id),
{}, page='idashboard') {}, page='idashboard')
#---------------------------------------- #----------------------------------------
...@@ -418,7 +434,7 @@ def instructor_dashboard(request, course_id): ...@@ -418,7 +434,7 @@ def instructor_dashboard(request, course_id):
msg2, datatable = _do_remote_gradebook(request.user, course, 'get-sections') msg2, datatable = _do_remote_gradebook(request.user, course, 'get-sections')
msg += msg2 msg += msg2
elif action in ['List students in section in remote gradebook', elif action in ['List students in section in remote gradebook',
'Overload enrollment list using remote gradebook', 'Overload enrollment list using remote gradebook',
'Merge enrollment list with remote gradebook']: 'Merge enrollment list with remote gradebook']:
...@@ -431,7 +447,7 @@ def instructor_dashboard(request, course_id): ...@@ -431,7 +447,7 @@ def instructor_dashboard(request, course_id):
overload = 'Overload' in action overload = 'Overload' in action
ret = _do_enroll_students(course, course_id, students, overload=overload) ret = _do_enroll_students(course, course_id, students, overload=overload)
datatable = ret['datatable'] datatable = ret['datatable']
#---------------------------------------- #----------------------------------------
# psychometrics # psychometrics
...@@ -448,7 +464,7 @@ def instructor_dashboard(request, course_id): ...@@ -448,7 +464,7 @@ def instructor_dashboard(request, course_id):
#---------------------------------------- #----------------------------------------
# offline grades? # offline grades?
if use_offline: if use_offline:
msg += "<br/><font color='orange'>Grades from %s</font>" % offline_grades_available(course_id) msg += "<br/><font color='orange'>Grades from %s</font>" % offline_grades_available(course_id)
...@@ -482,17 +498,17 @@ def _do_remote_gradebook(user, course, action, args=None, files=None): ...@@ -482,17 +498,17 @@ def _do_remote_gradebook(user, course, action, args=None, files=None):
if not rg: if not rg:
msg = "No remote gradebook defined in course metadata" msg = "No remote gradebook defined in course metadata"
return msg, {} return msg, {}
rgurl = settings.MITX_FEATURES.get('REMOTE_GRADEBOOK_URL','') rgurl = settings.MITX_FEATURES.get('REMOTE_GRADEBOOK_URL','')
if not rgurl: if not rgurl:
msg = "No remote gradebook url defined in settings.MITX_FEATURES" msg = "No remote gradebook url defined in settings.MITX_FEATURES"
return msg, {} return msg, {}
rgname = rg.get('name','') rgname = rg.get('name','')
if not rgname: if not rgname:
msg = "No gradebook name defined in course remote_gradebook metadata" msg = "No gradebook name defined in course remote_gradebook metadata"
return msg, {} return msg, {}
if args is None: if args is None:
args = {} args = {}
data = dict(submit=action, gradebook=rgname, user=user.email) data = dict(submit=action, gradebook=rgname, user=user.email)
...@@ -522,15 +538,15 @@ def _do_remote_gradebook(user, course, action, args=None, files=None): ...@@ -522,15 +538,15 @@ def _do_remote_gradebook(user, course, action, args=None, files=None):
return msg, datatable return msg, datatable
def _list_course_forum_members(course_id, rolename, datatable): def _list_course_forum_members(course_id, rolename, datatable):
''' """
Fills in datatable with forum membership information, for a given role, Fills in datatable with forum membership information, for a given role,
so that it will be displayed on instructor dashboard. so that it will be displayed on instructor dashboard.
course_ID = the ID string for a course course_ID = the ID string for a course
rolename = one of "Administrator", "Moderator", "Community TA" rolename = one of "Administrator", "Moderator", "Community TA"
Returns message status string to append to displayed message, if role is unknown. Returns message status string to append to displayed message, if role is unknown.
''' """
# make sure datatable is set up properly for display first, before checking for errors # make sure datatable is set up properly for display first, before checking for errors
datatable['header'] = ['Username', 'Full name', 'Roles'] datatable['header'] = ['Username', 'Full name', 'Roles']
datatable['title'] = 'List of Forum {0}s in course {1}'.format(rolename, course_id) datatable['title'] = 'List of Forum {0}s in course {1}'.format(rolename, course_id)
...@@ -549,13 +565,13 @@ def _list_course_forum_members(course_id, rolename, datatable): ...@@ -549,13 +565,13 @@ def _list_course_forum_members(course_id, rolename, datatable):
def _update_forum_role_membership(uname, course, rolename, add_or_remove): def _update_forum_role_membership(uname, course, rolename, add_or_remove):
''' '''
Supports adding a user to a course's forum role Supports adding a user to a course's forum role
uname = username string for user uname = username string for user
course = course object course = course object
rolename = one of "Administrator", "Moderator", "Community TA" rolename = one of "Administrator", "Moderator", "Community TA"
add_or_remove = one of "add" or "remove" add_or_remove = one of "add" or "remove"
Returns message status string to append to displayed message, Status is returned if user Returns message status string to append to displayed message, Status is returned if user
or role is unknown, or if entry already exists when adding, or if entry doesn't exist when removing. or role is unknown, or if entry already exists when adding, or if entry doesn't exist when removing.
''' '''
# check that username and rolename are valid: # check that username and rolename are valid:
...@@ -575,21 +591,105 @@ def _update_forum_role_membership(uname, course, rolename, add_or_remove): ...@@ -575,21 +591,105 @@ def _update_forum_role_membership(uname, course, rolename, add_or_remove):
if add_or_remove == FORUM_ROLE_REMOVE: if add_or_remove == FORUM_ROLE_REMOVE:
if not alreadyexists: if not alreadyexists:
msg ='<font color="red">Error: user "{0}" does not have rolename "{1}", cannot remove</font>'.format(uname, rolename) msg ='<font color="red">Error: user "{0}" does not have rolename "{1}", cannot remove</font>'.format(uname, rolename)
else: else:
user.roles.remove(role) user.roles.remove(role)
msg = '<font color="green">Removed "{0}" from "{1}" forum role = "{2}"</font>'.format(user, course.id, rolename) msg = '<font color="green">Removed "{0}" from "{1}" forum role = "{2}"</font>'.format(user, course.id, rolename)
else: else:
if alreadyexists: if alreadyexists:
msg = '<font color="red">Error: user "{0}" already has rolename "{1}", cannot add</font>'.format(uname, rolename) msg = '<font color="red">Error: user "{0}" already has rolename "{1}", cannot add</font>'.format(uname, rolename)
else: else:
if (rolename == FORUM_ROLE_ADMINISTRATOR and not has_access(user, course, 'staff')): if (rolename == FORUM_ROLE_ADMINISTRATOR and not has_access(user, course, 'staff')):
msg = '<font color="red">Error: user "{0}" should first be added as staff before adding as a forum administrator, cannot add</font>'.format(uname) msg = '<font color="red">Error: user "{0}" should first be added as staff before adding as a forum administrator, cannot add</font>'.format(uname)
else: else:
user.roles.add(role) user.roles.add(role)
msg = '<font color="green">Added "{0}" to "{1}" forum role = "{2}"</font>'.format(user, course.id, rolename) msg = '<font color="green">Added "{0}" to "{1}" forum role = "{2}"</font>'.format(user, course.id, rolename)
return msg return msg
def _group_members_table(group, title, course_id):
"""
Return a data table of usernames and names of users in group_name.
Arguments:
group -- a django group.
title -- a descriptive title to show the user
Returns:
a dictionary with keys
'header': ['Username', 'Full name'],
'data': [[username, name] for all users]
'title': "{title} in course {course}"
"""
uset = group.user_set.all()
datatable = {'header': ['Username', 'Full name']}
datatable['data'] = [[x.username, x.profile.name] for x in uset]
datatable['title'] = '{0} in course {1}'.format(title, course_id)
return datatable
def _add_or_remove_user_group(request, username_or_email, group, group_title, event_name, do_add):
"""
Implementation for both add and remove functions, to get rid of shared code. do_add is bool that determines which
to do.
"""
user = None
try:
if '@' in username_or_email:
user = User.objects.get(email=username_or_email)
else:
user = User.objects.get(username=username_or_email)
except User.DoesNotExist:
msg = '<font color="red">Error: unknown username or email "{0}"</font>'.format(username_or_email)
user = None
if user is not None:
action = "Added" if do_add else "Removed"
prep = "to" if do_add else "from"
msg = '<font color="green">{action} {0} {prep} {1} group = {2}</font>'.format(user, group_title, group.name,
action=action, prep=prep)
if do_add:
user.groups.add(group)
else:
user.groups.remove(group)
event = "add" if do_add else "remove"
track.views.server_track(request, '{event}-{0} {1}'.format(event_name, user, event=event),
{}, page='idashboard')
return msg
def add_user_to_group(request, username_or_email, group, group_title, event_name):
"""
Look up the given user by username (if no '@') or email (otherwise), and add them to group.
Arguments:
request: django request--used for tracking log
username_or_email: who to add. Decide if it's an email by presense of an '@'
group: django group object
group_title: what to call this group in messages to user--e.g. "beta-testers".
event_name: what to call this event when logging to tracking logs.
Returns:
html to insert in the message field
"""
return _add_or_remove_user_group(request, username_or_email, group, group_title, event_name, True)
def remove_user_from_group(request, username_or_email, group, group_title, event_name):
"""
Look up the given user by username (if no '@') or email (otherwise), and remove them from group.
Arguments:
request: django request--used for tracking log
username_or_email: who to remove. Decide if it's an email by presense of an '@'
group: django group object
group_title: what to call this group in messages to user--e.g. "beta-testers".
event_name: what to call this event when logging to tracking logs.
Returns:
html to insert in the message field
"""
return _add_or_remove_user_group(request, username_or_email, group, group_title, event_name, False)
def get_student_grade_summary_data(request, course, course_id, get_grades=True, get_raw_scores=False, use_offline=False): def get_student_grade_summary_data(request, course, course_id, get_grades=True, get_raw_scores=False, use_offline=False):
''' '''
...@@ -694,12 +794,20 @@ def grade_summary(request, course_id): ...@@ -694,12 +794,20 @@ def grade_summary(request, course_id):
# enrollment # enrollment
def _split_by_comma_and_whitespace(s):
"""
Split a string both by on commas and whitespice.
"""
# Note: split() with no args removes empty strings from output
lists = [x.split() for x in s.split(',')]
# return all of them
return itertools.chain(*lists)
def _do_enroll_students(course, course_id, students, overload=False): def _do_enroll_students(course, course_id, students, overload=False):
"""Do the actual work of enrolling multiple students, presented as a string """Do the actual work of enrolling multiple students, presented as a string
of emails separated by commas or returns""" of emails separated by commas or returns"""
ns = [x.split('\n') for x in students.split(',')] new_students = _split_by_comma_and_whitespace(students)
new_students = [item for sublist in ns for item in sublist]
new_students = [str(s.strip()) for s in new_students] new_students = [str(s.strip()) for s in new_students]
new_students_lc = [x.lower() for x in new_students] new_students_lc = [x.lower() for x in new_students]
...@@ -750,7 +858,7 @@ def _do_enroll_students(course, course_id, students, overload=False): ...@@ -750,7 +858,7 @@ def _do_enroll_students(course, course_id, students, overload=False):
def sf(stat): return [x for x in status if status[x]==stat] def sf(stat): return [x for x in status if status[x]==stat]
data = dict(added=sf('added'), rejected=sf('rejected')+sf('exists'), data = dict(added=sf('added'), rejected=sf('rejected')+sf('exists'),
deleted=sf('deleted'), datatable=datatable) deleted=sf('deleted'), datatable=datatable)
return data return data
......
...@@ -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