Commit 4afa3155 by David Ormsbee

Merge pull request #1350 from MITx/feature/victor/cohorts

Feature/victor/cohorts
parents d237e68a 439acf2d
...@@ -34,7 +34,7 @@ MITX_FEATURES = { ...@@ -34,7 +34,7 @@ MITX_FEATURES = {
'GITHUB_PUSH': False, 'GITHUB_PUSH': False,
'ENABLE_DISCUSSION_SERVICE': False, 'ENABLE_DISCUSSION_SERVICE': False,
'AUTH_USE_MIT_CERTIFICATES' : False, 'AUTH_USE_MIT_CERTIFICATES' : False,
'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests 'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests
} }
ENABLE_JASMINE = False ENABLE_JASMINE = False
...@@ -281,7 +281,7 @@ INSTALLED_APPS = ( ...@@ -281,7 +281,7 @@ INSTALLED_APPS = (
'contentstore', 'contentstore',
'auth', 'auth',
'student', # misleading name due to sharing with lms 'student', # misleading name due to sharing with lms
'course_groups', # not used in cms (yet), but tests run
# For asset pipelining # For asset pipelining
'pipeline', 'pipeline',
'staticfiles', 'staticfiles',
......
"""
This file contains the logic for cohort groups, as exposed internally to the
forums, and to the cohort admin views.
"""
from django.contrib.auth.models import User
from django.http import Http404
import logging
from courseware import courses
from student.models import get_user_by_username_or_email
from .models import CourseUserGroup
log = logging.getLogger(__name__)
def is_course_cohorted(course_id):
"""
Given a course id, return a boolean for whether or not the course is
cohorted.
Raises:
Http404 if the course doesn't exist.
"""
return courses.get_course_by_id(course_id).is_cohorted
def get_cohort_id(user, course_id):
"""
Given a course id and a user, return the id of the cohort that user is
assigned to in that course. If they don't have a cohort, return None.
"""
cohort = get_cohort(user, course_id)
return None if cohort is None else cohort.id
def is_commentable_cohorted(course_id, commentable_id):
"""
Given a course and a commentable id, return whether or not this commentable
is cohorted.
Raises:
Http404 if the course doesn't exist.
"""
course = courses.get_course_by_id(course_id)
if not course.is_cohorted:
# this is the easy case :)
ans = False
elif commentable_id in course.top_level_discussion_topic_ids:
# top level discussions have to be manually configured as cohorted
# (default is not)
ans = commentable_id in course.cohorted_discussions()
else:
# inline discussions are cohorted by default
ans = True
log.debug("is_commentable_cohorted({0}, {1}) = {2}".format(course_id,
commentable_id,
ans))
return ans
def get_cohort(user, course_id):
c = _get_cohort(user, course_id)
log.debug("get_cohort({0}, {1}) = {2}".format(
user, course_id,
c.id if c is not None else None))
return c
def _get_cohort(user, course_id):
"""
Given a django User and a course_id, return the user's cohort in that
cohort.
TODO: In classes with auto-cohorting, put the user in a cohort if they
aren't in one already.
Arguments:
user: a Django User object.
course_id: string in the format 'org/course/run'
Returns:
A CourseUserGroup object if the course is cohorted and the User has a
cohort, else None.
Raises:
ValueError if the course_id doesn't exist.
"""
# First check whether the course is cohorted (users shouldn't be in a cohort
# in non-cohorted courses, but settings can change after )
try:
course = courses.get_course_by_id(course_id)
except Http404:
raise ValueError("Invalid course_id")
if not course.is_cohorted:
return None
try:
group = CourseUserGroup.objects.get(course_id=course_id,
group_type=CourseUserGroup.COHORT,
users__id=user.id)
except CourseUserGroup.DoesNotExist:
group = None
if group:
return group
# TODO: add auto-cohorting logic here once we know what that will be.
return None
def get_course_cohorts(course_id):
"""
Get a list of all the cohorts in the given course.
Arguments:
course_id: string in the format 'org/course/run'
Returns:
A list of CourseUserGroup objects. Empty if there are no cohorts.
"""
return list(CourseUserGroup.objects.filter(course_id=course_id,
group_type=CourseUserGroup.COHORT))
### Helpers for cohort management views
def get_cohort_by_name(course_id, name):
"""
Return the CourseUserGroup object for the given cohort. Raises DoesNotExist
it isn't present.
"""
return CourseUserGroup.objects.get(course_id=course_id,
group_type=CourseUserGroup.COHORT,
name=name)
def get_cohort_by_id(course_id, cohort_id):
"""
Return the CourseUserGroup object for the given cohort. Raises DoesNotExist
it isn't present. Uses the course_id for extra validation...
"""
return CourseUserGroup.objects.get(course_id=course_id,
group_type=CourseUserGroup.COHORT,
id=cohort_id)
def add_cohort(course_id, name):
"""
Add a cohort to a course. Raises ValueError if a cohort of the same name already
exists.
"""
log.debug("Adding cohort %s to %s", name, course_id)
if CourseUserGroup.objects.filter(course_id=course_id,
group_type=CourseUserGroup.COHORT,
name=name).exists():
raise ValueError("Can't create two cohorts with the same name")
return CourseUserGroup.objects.create(course_id=course_id,
group_type=CourseUserGroup.COHORT,
name=name)
class CohortConflict(Exception):
"""
Raised when user to be added is already in another cohort in same course.
"""
pass
def add_user_to_cohort(cohort, username_or_email):
"""
Look up the given user, and if successful, add them to the specified cohort.
Arguments:
cohort: CourseUserGroup
username_or_email: string. Treated as email if has '@'
Returns:
User object.
Raises:
User.DoesNotExist if can't find user.
ValueError if user already present in this cohort.
CohortConflict if user already in another cohort.
"""
user = get_user_by_username_or_email(username_or_email)
# If user in any cohorts in this course already, complain
course_cohorts = CourseUserGroup.objects.filter(
course_id=cohort.course_id,
users__id=user.id,
group_type=CourseUserGroup.COHORT)
if course_cohorts.exists():
if course_cohorts[0] == cohort:
raise ValueError("User {0} already present in cohort {1}".format(
user.username,
cohort.name))
else:
raise CohortConflict("User {0} is in another cohort {1} in course"
.format(user.username,
course_cohorts[0].name))
cohort.users.add(user)
return user
def get_course_cohort_names(course_id):
"""
Return a list of the cohort names in a course.
"""
return [c.name for c in get_course_cohorts(course_id)]
def delete_empty_cohort(course_id, name):
"""
Remove an empty cohort. Raise ValueError if cohort is not empty.
"""
cohort = get_cohort_by_name(course_id, name)
if cohort.users.exists():
raise ValueError(
"Can't delete non-empty cohort {0} in course {1}".format(
name, course_id))
cohort.delete()
import logging
from django.contrib.auth.models import User
from django.db import models
log = logging.getLogger(__name__)
class CourseUserGroup(models.Model):
"""
This model represents groups of users in a course. Groups may have different types,
which may be treated specially. For example, a user can be in at most one cohort per
course, and cohorts are used to split up the forums by group.
"""
class Meta:
unique_together = (('name', 'course_id'), )
name = models.CharField(max_length=255,
help_text=("What is the name of this group? "
"Must be unique within a course."))
users = models.ManyToManyField(User, db_index=True, related_name='course_groups',
help_text="Who is in this group?")
# Note: groups associated with particular runs of a course. E.g. Fall 2012 and Spring
# 2013 versions of 6.00x will have separate groups.
course_id = models.CharField(max_length=255, db_index=True,
help_text="Which course is this group associated with?")
# For now, only have group type 'cohort', but adding a type field to support
# things like 'question_discussion', 'friends', 'off-line-class', etc
COHORT = 'cohort'
GROUP_TYPE_CHOICES = ((COHORT, 'Cohort'),)
group_type = models.CharField(max_length=20, choices=GROUP_TYPE_CHOICES)
import django.test
from django.contrib.auth.models import User
from django.conf import settings
from override_settings import override_settings
from course_groups.models import CourseUserGroup
from course_groups.cohorts import get_cohort, get_course_cohorts
from xmodule.modulestore.django import modulestore
class TestCohorts(django.test.TestCase):
def test_get_cohort(self):
# Need to fix this, but after we're testing on staging. (Looks like
# problem is that when get_cohort internally tries to look up the
# course.id, it fails, even though we loaded it through the modulestore.
# Proper fix: give all tests a standard modulestore that uses the test
# dir.
course = modulestore().get_course("edX/toy/2012_Fall")
self.assertEqual(course.id, "edX/toy/2012_Fall")
user = User.objects.create(username="test", email="a@b.com")
other_user = User.objects.create(username="test2", email="a2@b.com")
self.assertIsNone(get_cohort(user, course.id), "No cohort created yet")
cohort = CourseUserGroup.objects.create(name="TestCohort",
course_id=course.id,
group_type=CourseUserGroup.COHORT)
cohort.users.add(user)
self.assertIsNone(get_cohort(user, course.id),
"Course isn't cohorted, so shouldn't have a cohort")
# Make the course cohorted...
course.metadata["cohort_config"] = {"cohorted": True}
self.assertEquals(get_cohort(user, course.id).id, cohort.id,
"Should find the right cohort")
self.assertEquals(get_cohort(other_user, course.id), None,
"other_user shouldn't have a cohort")
def test_get_course_cohorts(self):
course1_id = 'a/b/c'
course2_id = 'e/f/g'
# add some cohorts to course 1
cohort = CourseUserGroup.objects.create(name="TestCohort",
course_id=course1_id,
group_type=CourseUserGroup.COHORT)
cohort = CourseUserGroup.objects.create(name="TestCohort2",
course_id=course1_id,
group_type=CourseUserGroup.COHORT)
# second course should have no cohorts
self.assertEqual(get_course_cohorts(course2_id), [])
cohorts = sorted([c.name for c in get_course_cohorts(course1_id)])
self.assertEqual(cohorts, ['TestCohort', 'TestCohort2'])
from django_future.csrf import ensure_csrf_cookie
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
from django.contrib.auth.models import User
from django.core.context_processors import csrf
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseForbidden, Http404
from django.shortcuts import redirect
import json
import logging
import re
from courseware.courses import get_course_with_access
from mitxmako.shortcuts import render_to_response, render_to_string
from .models import CourseUserGroup
from . import cohorts
import track.views
log = logging.getLogger(__name__)
def json_http_response(data):
"""
Return an HttpResponse with the data json-serialized and the right content
type header.
"""
return HttpResponse(json.dumps(data), content_type="application/json")
def split_by_comma_and_whitespace(s):
"""
Split a string both by commas and whitespice. Returns a list.
"""
return re.split(r'[\s,]+', s)
@ensure_csrf_cookie
def list_cohorts(request, course_id):
"""
Return json dump of dict:
{'success': True,
'cohorts': [{'name': name, 'id': id}, ...]}
"""
get_course_with_access(request.user, course_id, 'staff')
all_cohorts = [{'name': c.name, 'id': c.id}
for c in cohorts.get_course_cohorts(course_id)]
return json_http_response({'success': True,
'cohorts': all_cohorts})
@ensure_csrf_cookie
@require_POST
def add_cohort(request, course_id):
"""
Return json of dict:
{'success': True,
'cohort': {'id': id,
'name': name}}
or
{'success': False,
'msg': error_msg} if there's an error
"""
get_course_with_access(request.user, course_id, 'staff')
name = request.POST.get("name")
if not name:
return json_http_response({'success': False,
'msg': "No name specified"})
try:
cohort = cohorts.add_cohort(course_id, name)
except ValueError as err:
return json_http_response({'success': False,
'msg': str(err)})
return json_http_response({'success': 'True',
'cohort': {
'id': cohort.id,
'name': cohort.name
}})
@ensure_csrf_cookie
def users_in_cohort(request, course_id, cohort_id):
"""
Return users in the cohort. Show up to 100 per page, and page
using the 'page' GET attribute in the call. Format:
Returns:
Json dump of dictionary in the following format:
{'success': True,
'page': page,
'num_pages': paginator.num_pages,
'users': [{'username': ..., 'email': ..., 'name': ...}]
}
"""
get_course_with_access(request.user, course_id, 'staff')
# this will error if called with a non-int cohort_id. That's ok--it
# shoudn't happen for valid clients.
cohort = cohorts.get_cohort_by_id(course_id, int(cohort_id))
paginator = Paginator(cohort.users.all(), 100)
page = request.GET.get('page')
try:
users = paginator.page(page)
except PageNotAnInteger:
# return the first page
page = 1
users = paginator.page(page)
except EmptyPage:
# Page is out of range. Return last page
page = paginator.num_pages
contacts = paginator.page(page)
user_info = [{'username': u.username,
'email': u.email,
'name': '{0} {1}'.format(u.first_name, u.last_name)}
for u in users]
return json_http_response({'success': True,
'page': page,
'num_pages': paginator.num_pages,
'users': user_info})
@ensure_csrf_cookie
@require_POST
def add_users_to_cohort(request, course_id, cohort_id):
"""
Return json dict of:
{'success': True,
'added': [{'username': username,
'name': name,
'email': email}, ...],
'conflict': [{'username_or_email': ...,
'msg': ...}], # in another cohort
'present': [str1, str2, ...], # already there
'unknown': [str1, str2, ...]}
"""
get_course_with_access(request.user, course_id, 'staff')
cohort = cohorts.get_cohort_by_id(course_id, cohort_id)
users = request.POST.get('users', '')
added = []
present = []
conflict = []
unknown = []
for username_or_email in split_by_comma_and_whitespace(users):
try:
user = cohorts.add_user_to_cohort(cohort, username_or_email)
added.append({'username': user.username,
'name': "{0} {1}".format(user.first_name, user.last_name),
'email': user.email,
})
except ValueError:
present.append(username_or_email)
except User.DoesNotExist:
unknown.append(username_or_email)
except cohorts.CohortConflict as err:
conflict.append({'username_or_email': username_or_email,
'msg': str(err)})
return json_http_response({'success': True,
'added': added,
'present': present,
'conflict': conflict,
'unknown': unknown})
@ensure_csrf_cookie
@require_POST
def remove_user_from_cohort(request, course_id, cohort_id):
"""
Expects 'username': username in POST data.
Return json dict of:
{'success': True} or
{'success': False,
'msg': error_msg}
"""
get_course_with_access(request.user, course_id, 'staff')
username = request.POST.get('username')
if username is None:
return json_http_response({'success': False,
'msg': 'No username specified'})
cohort = cohorts.get_cohort_by_id(course_id, cohort_id)
try:
user = User.objects.get(username=username)
cohort.users.remove(user)
return json_http_response({'success': True})
except User.DoesNotExist:
log.debug('no user')
return json_http_response({'success': False,
'msg': "No user '{0}'".format(username)})
def debug_cohort_mgmt(request, course_id):
"""
Debugging view for dev.
"""
# add staff check to make sure it's safe if it's accidentally deployed.
get_course_with_access(request.user, course_id, 'staff')
context = {'cohorts_ajax_url': reverse('cohorts',
kwargs={'course_id': course_id})}
return render_to_response('/course_groups/debug.html', context)
...@@ -5,16 +5,11 @@ when you run "manage.py test". ...@@ -5,16 +5,11 @@ when you run "manage.py test".
Replace this with more appropriate tests for your application. Replace this with more appropriate tests for your application.
""" """
import logging import logging
from datetime import datetime
from hashlib import sha1
from django.test import TestCase from django.test import TestCase
from mock import patch, Mock from mock import Mock
from nose.plugins.skip import SkipTest
from .models import (User, UserProfile, CourseEnrollment, from .models import unique_id_for_user
replicate_user, USER_FIELDS_TO_COPY,
unique_id_for_user)
from .views import process_survey_link, _cert_info from .views import process_survey_link, _cert_info
COURSE_1 = 'edX/toy/2012_Fall' COURSE_1 = 'edX/toy/2012_Fall'
...@@ -22,185 +17,6 @@ COURSE_2 = 'edx/full/6.002_Spring_2012' ...@@ -22,185 +17,6 @@ COURSE_2 = 'edx/full/6.002_Spring_2012'
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class ReplicationTest(TestCase):
multi_db = True
def test_user_replication(self):
"""Test basic user replication."""
raise SkipTest()
portal_user = User.objects.create_user('rusty', 'rusty@edx.org', 'fakepass')
portal_user.first_name='Rusty'
portal_user.last_name='Skids'
portal_user.is_staff=True
portal_user.is_active=True
portal_user.is_superuser=True
portal_user.last_login=datetime(2012, 1, 1)
portal_user.date_joined=datetime(2011, 1, 1)
# This is an Askbot field and will break if askbot is not included
if hasattr(portal_user, 'seen_response_count'):
portal_user.seen_response_count = 10
portal_user.save(using='default')
# We replicate this user to Course 1, then pull the same user and verify
# that the fields copied over properly.
replicate_user(portal_user, COURSE_1)
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
# Make sure the fields we care about got copied over for this user.
for field in USER_FIELDS_TO_COPY:
self.assertEqual(getattr(portal_user, field),
getattr(course_user, field),
"{0} not copied from {1} to {2}".format(
field, portal_user, course_user
))
# This hasattr lameness is here because we don't want this test to be
# triggered when we're being run by CMS tests (Askbot doesn't exist
# there, so the test will fail).
#
# seen_response_count isn't a field we care about, so it shouldn't have
# been copied over.
if hasattr(portal_user, 'seen_response_count'):
portal_user.seen_response_count = 20
replicate_user(portal_user, COURSE_1)
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
self.assertEqual(portal_user.seen_response_count, 20)
self.assertEqual(course_user.seen_response_count, 0)
# Another replication should work for an email change however, since
# it's a field we care about.
portal_user.email = "clyde@edx.org"
replicate_user(portal_user, COURSE_1)
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
self.assertEqual(portal_user.email, course_user.email)
# During this entire time, the user data should never have made it over
# to COURSE_2
self.assertRaises(User.DoesNotExist,
User.objects.using(COURSE_2).get,
id=portal_user.id)
def test_enrollment_for_existing_user_info(self):
"""Test the effect of Enrolling in a class if you've already got user
data to be copied over."""
raise SkipTest()
# Create our User
portal_user = User.objects.create_user('jack', 'jack@edx.org', 'fakepass')
portal_user.first_name = "Jack"
portal_user.save()
# Set up our UserProfile info
portal_user_profile = UserProfile.objects.create(
user=portal_user,
name="Jack Foo",
level_of_education=None,
gender='m',
mailing_address=None,
goals="World domination",
)
portal_user_profile.save()
# Now let's see if creating a CourseEnrollment copies all the relevant
# data.
portal_enrollment = CourseEnrollment.objects.create(user=portal_user,
course_id=COURSE_1)
portal_enrollment.save()
# Grab all the copies we expect
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
self.assertEquals(portal_user, course_user)
self.assertRaises(User.DoesNotExist,
User.objects.using(COURSE_2).get,
id=portal_user.id)
course_enrollment = CourseEnrollment.objects.using(COURSE_1).get(id=portal_enrollment.id)
self.assertEquals(portal_enrollment, course_enrollment)
self.assertRaises(CourseEnrollment.DoesNotExist,
CourseEnrollment.objects.using(COURSE_2).get,
id=portal_enrollment.id)
course_user_profile = UserProfile.objects.using(COURSE_1).get(id=portal_user_profile.id)
self.assertEquals(portal_user_profile, course_user_profile)
self.assertRaises(UserProfile.DoesNotExist,
UserProfile.objects.using(COURSE_2).get,
id=portal_user_profile.id)
log.debug("Make sure our seen_response_count is not replicated.")
if hasattr(portal_user, 'seen_response_count'):
portal_user.seen_response_count = 200
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
self.assertEqual(portal_user.seen_response_count, 200)
self.assertEqual(course_user.seen_response_count, 0)
portal_user.save()
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
self.assertEqual(portal_user.seen_response_count, 200)
self.assertEqual(course_user.seen_response_count, 0)
portal_user.email = 'jim@edx.org'
portal_user.save()
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
self.assertEqual(portal_user.email, 'jim@edx.org')
self.assertEqual(course_user.email, 'jim@edx.org')
def test_enrollment_for_user_info_after_enrollment(self):
"""Test the effect of modifying User data after you've enrolled."""
raise SkipTest()
# Create our User
portal_user = User.objects.create_user('patty', 'patty@edx.org', 'fakepass')
portal_user.first_name = "Patty"
portal_user.save()
# Set up our UserProfile info
portal_user_profile = UserProfile.objects.create(
user=portal_user,
name="Patty Foo",
level_of_education=None,
gender='f',
mailing_address=None,
goals="World peace",
)
portal_user_profile.save()
# Now let's see if creating a CourseEnrollment copies all the relevant
# data when things are saved.
portal_enrollment = CourseEnrollment.objects.create(user=portal_user,
course_id=COURSE_1)
portal_enrollment.save()
portal_user.last_name = "Bar"
portal_user.save()
portal_user_profile.gender = 'm'
portal_user_profile.save()
# Grab all the copies we expect, and make sure it doesn't end up in
# places we don't expect.
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
self.assertEquals(portal_user, course_user)
self.assertRaises(User.DoesNotExist,
User.objects.using(COURSE_2).get,
id=portal_user.id)
course_enrollment = CourseEnrollment.objects.using(COURSE_1).get(id=portal_enrollment.id)
self.assertEquals(portal_enrollment, course_enrollment)
self.assertRaises(CourseEnrollment.DoesNotExist,
CourseEnrollment.objects.using(COURSE_2).get,
id=portal_enrollment.id)
course_user_profile = UserProfile.objects.using(COURSE_1).get(id=portal_user_profile.id)
self.assertEquals(portal_user_profile, course_user_profile)
self.assertRaises(UserProfile.DoesNotExist,
UserProfile.objects.using(COURSE_2).get,
id=portal_user_profile.id)
class CourseEndingTest(TestCase): class CourseEndingTest(TestCase):
"""Test things related to course endings: certificates, surveys, etc""" """Test things related to course endings: certificates, surveys, etc"""
......
...@@ -223,7 +223,7 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -223,7 +223,7 @@ class CourseDescriptor(SequenceDescriptor):
return policy_str return policy_str
@classmethod @classmethod
def from_xml(cls, xml_data, system, org=None, course=None): def from_xml(cls, xml_data, system, org=None, course=None):
instance = super(CourseDescriptor, cls).from_xml(xml_data, system, org, course) instance = super(CourseDescriptor, cls).from_xml(xml_data, system, org, course)
...@@ -248,7 +248,7 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -248,7 +248,7 @@ class CourseDescriptor(SequenceDescriptor):
except ValueError: except ValueError:
system.error_tracker("Unable to decode grading policy as json") system.error_tracker("Unable to decode grading policy as json")
policy = None policy = None
# cdodge: import the grading policy information that is on disk and put into the # cdodge: import the grading policy information that is on disk and put into the
# descriptor 'definition' bucket as a dictionary so that it is persisted in the DB # descriptor 'definition' bucket as a dictionary so that it is persisted in the DB
instance.definition['data']['grading_policy'] = policy instance.definition['data']['grading_policy'] = policy
...@@ -303,28 +303,28 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -303,28 +303,28 @@ class CourseDescriptor(SequenceDescriptor):
@property @property
def enrollment_start(self): def enrollment_start(self):
return self._try_parse_time("enrollment_start") return self._try_parse_time("enrollment_start")
@enrollment_start.setter @enrollment_start.setter
def enrollment_start(self, value): def enrollment_start(self, value):
if isinstance(value, time.struct_time): if isinstance(value, time.struct_time):
self.metadata['enrollment_start'] = stringify_time(value) self.metadata['enrollment_start'] = stringify_time(value)
@property @property
def enrollment_end(self): def enrollment_end(self):
return self._try_parse_time("enrollment_end") return self._try_parse_time("enrollment_end")
@enrollment_end.setter @enrollment_end.setter
def enrollment_end(self, value): def enrollment_end(self, value):
if isinstance(value, time.struct_time): if isinstance(value, time.struct_time):
self.metadata['enrollment_end'] = stringify_time(value) self.metadata['enrollment_end'] = stringify_time(value)
@property @property
def grader(self): def grader(self):
return self._grading_policy['GRADER'] return self._grading_policy['GRADER']
@property @property
def raw_grader(self): def raw_grader(self):
return self._grading_policy['RAW_GRADER'] return self._grading_policy['RAW_GRADER']
@raw_grader.setter @raw_grader.setter
def raw_grader(self, value): def raw_grader(self, value):
# NOTE WELL: this change will not update the processed graders. If we need that, this needs to call grader_from_conf # NOTE WELL: this change will not update the processed graders. If we need that, this needs to call grader_from_conf
...@@ -334,12 +334,12 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -334,12 +334,12 @@ class CourseDescriptor(SequenceDescriptor):
@property @property
def grade_cutoffs(self): def grade_cutoffs(self):
return self._grading_policy['GRADE_CUTOFFS'] return self._grading_policy['GRADE_CUTOFFS']
@grade_cutoffs.setter @grade_cutoffs.setter
def grade_cutoffs(self, value): def grade_cutoffs(self, value):
self._grading_policy['GRADE_CUTOFFS'] = value self._grading_policy['GRADE_CUTOFFS'] = value
self.definition['data'].setdefault('grading_policy',{})['GRADE_CUTOFFS'] = value self.definition['data'].setdefault('grading_policy',{})['GRADE_CUTOFFS'] = value
@property @property
def lowest_passing_grade(self): def lowest_passing_grade(self):
...@@ -361,6 +361,41 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -361,6 +361,41 @@ class CourseDescriptor(SequenceDescriptor):
return self.metadata.get("show_calculator", None) == "Yes" return self.metadata.get("show_calculator", None) == "Yes"
@property @property
def is_cohorted(self):
"""
Return whether the course is cohorted.
"""
config = self.metadata.get("cohort_config")
if config is None:
return False
return bool(config.get("cohorted"))
@property
def top_level_discussion_topic_ids(self):
"""
Return list of topic ids defined in course policy.
"""
topics = self.metadata.get("discussion_topics", {})
return [d["id"] for d in topics.values()]
@property
def cohorted_discussions(self):
"""
Return the set of discussions that is explicitly cohorted. It may be
the empty set. Note that all inline discussions are automatically
cohorted based on the course's is_cohorted setting.
"""
config = self.metadata.get("cohort_config")
if config is None:
return set()
return set(config.get("cohorted_discussions", []))
@property
def is_new(self): def is_new(self):
""" """
Returns if the course has been flagged as new in the metadata. If Returns if the course has been flagged as new in the metadata. If
......
...@@ -18,8 +18,10 @@ class DiscussionModule(XModule): ...@@ -18,8 +18,10 @@ class DiscussionModule(XModule):
} }
return self.system.render_template('discussion/_discussion_module.html', context) return self.system.render_template('discussion/_discussion_module.html', context)
def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): def __init__(self, system, location, definition, descriptor,
XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
if isinstance(instance_state, str): if isinstance(instance_state, str):
instance_state = json.loads(instance_state) instance_state = json.loads(instance_state)
......
...@@ -45,13 +45,24 @@ class DummySystem(ImportSystem): ...@@ -45,13 +45,24 @@ class DummySystem(ImportSystem):
raise Exception("Shouldn't be called") raise Exception("Shouldn't be called")
class ImportTestCase(unittest.TestCase): class BaseCourseTestCase(unittest.TestCase):
'''Make sure module imports work properly, including for malformed inputs''' '''Make sure module imports work properly, including for malformed inputs'''
@staticmethod @staticmethod
def get_system(load_error_modules=True): def get_system(load_error_modules=True):
'''Get a dummy system''' '''Get a dummy system'''
return DummySystem(load_error_modules) return DummySystem(load_error_modules)
def get_course(self, name):
"""Get a test course by directory name. If there's more than one, error."""
print "Importing {0}".format(name)
modulestore = XMLModuleStore(DATA_DIR, course_dirs=[name])
courses = modulestore.get_courses()
self.assertEquals(len(courses), 1)
return courses[0]
class ImportTestCase(BaseCourseTestCase):
def test_fallback(self): def test_fallback(self):
'''Check that malformed xml loads as an ErrorDescriptor.''' '''Check that malformed xml loads as an ErrorDescriptor.'''
...@@ -207,11 +218,7 @@ class ImportTestCase(unittest.TestCase): ...@@ -207,11 +218,7 @@ class ImportTestCase(unittest.TestCase):
"""Make sure that metadata is inherited properly""" """Make sure that metadata is inherited properly"""
print "Starting import" print "Starting import"
initial_import = XMLModuleStore(DATA_DIR, course_dirs=['toy']) course = self.get_course('toy')
courses = initial_import.get_courses()
self.assertEquals(len(courses), 1)
course = courses[0]
def check_for_key(key, node): def check_for_key(key, node):
"recursive check for presence of key" "recursive check for presence of key"
...@@ -227,16 +234,8 @@ class ImportTestCase(unittest.TestCase): ...@@ -227,16 +234,8 @@ class ImportTestCase(unittest.TestCase):
"""Make sure that when two courses share content with the same """Make sure that when two courses share content with the same
org and course names, policy applies to the right one.""" org and course names, policy applies to the right one."""
def get_course(name): toy = self.get_course('toy')
print "Importing {0}".format(name) two_toys = self.get_course('two_toys')
modulestore = XMLModuleStore(DATA_DIR, course_dirs=[name])
courses = modulestore.get_courses()
self.assertEquals(len(courses), 1)
return courses[0]
toy = get_course('toy')
two_toys = get_course('two_toys')
self.assertEqual(toy.url_name, "2012_Fall") self.assertEqual(toy.url_name, "2012_Fall")
self.assertEqual(two_toys.url_name, "TT_2012_Fall") self.assertEqual(two_toys.url_name, "TT_2012_Fall")
...@@ -279,8 +278,8 @@ class ImportTestCase(unittest.TestCase): ...@@ -279,8 +278,8 @@ class ImportTestCase(unittest.TestCase):
"""Ensure that colons in url_names convert to file paths properly""" """Ensure that colons in url_names convert to file paths properly"""
print "Starting import" print "Starting import"
# Not using get_courses because we need the modulestore object too afterward
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy']) modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy'])
courses = modulestore.get_courses() courses = modulestore.get_courses()
self.assertEquals(len(courses), 1) self.assertEquals(len(courses), 1)
course = courses[0] course = courses[0]
...@@ -317,7 +316,7 @@ class ImportTestCase(unittest.TestCase): ...@@ -317,7 +316,7 @@ class ImportTestCase(unittest.TestCase):
toy_id = "edX/toy/2012_Fall" toy_id = "edX/toy/2012_Fall"
course = modulestore.get_courses()[0] course = modulestore.get_course(toy_id)
chapters = course.get_children() chapters = course.get_children()
ch1 = chapters[0] ch1 = chapters[0]
sections = ch1.get_children() sections = ch1.get_children()
...@@ -355,3 +354,30 @@ class ImportTestCase(unittest.TestCase): ...@@ -355,3 +354,30 @@ class ImportTestCase(unittest.TestCase):
<slider var="a" style="width:400px;float:left;"/>\ <slider var="a" style="width:400px;float:left;"/>\
<plot style="margin-top:15px;margin-bottom:15px;"/>""".strip() <plot style="margin-top:15px;margin-bottom:15px;"/>""".strip()
self.assertEqual(gst_sample.definition['render'], render_string_from_sample_gst_xml) self.assertEqual(gst_sample.definition['render'], render_string_from_sample_gst_xml)
def test_cohort_config(self):
"""
Check that cohort config parsing works right.
"""
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy'])
toy_id = "edX/toy/2012_Fall"
course = modulestore.get_course(toy_id)
# No config -> False
self.assertFalse(course.is_cohorted)
# empty config -> False
course.metadata['cohort_config'] = {}
self.assertFalse(course.is_cohorted)
# false config -> False
course.metadata['cohort_config'] = {'cohorted': False}
self.assertFalse(course.is_cohorted)
# and finally...
course.metadata['cohort_config'] = {'cohorted': True}
self.assertTrue(course.is_cohorted)
// structure stolen from http://briancray.com/posts/javascript-module-pattern
var CohortManager = (function ($) {
// private variables and functions
// using jQuery
function getCookie(name) {
var cookieValue = null;
if (document.cookie && document.cookie != '') {
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = $.trim(cookies[i]);
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) == (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
var csrftoken = getCookie('csrftoken');
function csrfSafeMethod(method) {
// these HTTP methods do not require CSRF protection
return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
}
$.ajaxSetup({
crossDomain: false, // obviates need for sameOrigin test
beforeSend: function(xhr, settings) {
if (!csrfSafeMethod(settings.type)) {
xhr.setRequestHeader("X-CSRFToken", csrftoken);
}
}
});
// constructor
var module = function () {
var el = $(".cohort_manager");
// localized jquery
var $$ = function (selector) {
return $(selector, el)
}
var state_init = "init";
var state_summary = "summary";
var state_detail = "detail";
var state = state_init;
var url = el.data('ajax_url');
var self = this;
// Pull out the relevant parts of the html
// global stuff
var errors = $$(".errors");
// cohort summary display
var summary = $$(".summary");
var cohorts = $$(".cohorts");
var show_cohorts_button = $$(".controls .show_cohorts");
var add_cohort_input = $$(".cohort_name");
var add_cohort_button = $$(".add_cohort");
// single cohort user display
var detail = $$(".detail");
var detail_header = $(".header", detail);
var detail_users = $$(".users");
var detail_page_num = $$(".page_num");
var users_area = $$(".users_area");
var add_members_button = $$(".add_members");
var op_results = $$(".op_results");
var cohort_id = null;
var cohort_title = null;
var detail_url = null;
var page = null;
// *********** Summary view methods
function show_cohort(item) {
// item is a li that has a data-href link to the cohort base url
var el = $(this);
cohort_title = el.text();
detail_url = el.data('href');
cohort_id = el.data('id');
state = state_detail;
render();
}
function add_to_cohorts_list(item) {
var li = $('<li><a></a></li>');
$("a", li).text(item.name)
.data('href', url + '/' + item.id)
.addClass('link')
.click(show_cohort);
cohorts.append(li);
};
function log_error(msg) {
errors.empty();
errors.append($("<li />").text(msg).addClass("error"));
};
function load_cohorts(response) {
cohorts.empty();
if (response && response.success) {
response.cohorts.forEach(add_to_cohorts_list);
} else {
log_error(response.msg || "There was an error loading cohorts");
}
summary.show();
};
function added_cohort(response) {
if (response && response.success) {
add_to_cohorts_list(response.cohort);
} else {
log_error(response.msg || "There was an error adding a cohort");
}
}
// *********** Detail view methods
function remove_user_from_cohort(username, cohort_id, row) {
var delete_url = detail_url + '/delete';
var data = {'username': username}
$.post(delete_url, data).done(function() {row.remove()})
.fail(function(jqXHR, status, error) {
log_error('Error removing user ' + username +
' from cohort. ' + status + ' ' + error);
});
}
function add_to_users_list(item) {
var tr = $('<tr><td class="name"></td><td class="username"></td>' +
'<td class="email"></td>' +
'<td class="remove"></td></tr>');
var current_cohort_id = cohort_id;
$(".name", tr).text(item.name);
$(".username", tr).text(item.username);
$(".email", tr).text(item.email);
$(".remove", tr).html('<a href="#">remove</a>')
.click(function() {
remove_user_from_cohort(item.username, current_cohort_id, tr);
});
detail_users.append(tr);
};
function show_users(response) {
detail_users.html("<tr><th>Name</th><th>Username</th><th>Email</th></tr>");
if (response && response.success) {
response.users.forEach(add_to_users_list);
detail_page_num.text("Page " + response.page + " of " + response.num_pages);
} else {
log_error(response.msg ||
"There was an error loading users for " + cohort.title);
}
detail.show();
}
function added_users(response) {
function adder(note, color) {
return function(item) {
var li = $('<li></li>')
if (typeof item === "object" && item.username) {
li.text(note + ' ' + item.name + ', ' + item.username + ', ' + item.email);
} else if (typeof item === "object" && item.msg) {
li.text(note + ' ' + item.username_or_email + ', ' + item.msg);
} else {
// string
li.text(note + ' ' + item);
}
li.css('color', color);
op_results.append(li);
}
}
op_results.empty();
if (response && response.success) {
response.added.forEach(adder("Added", "green"));
response.present.forEach(adder("Already present:", "black"));
response.conflict.forEach(adder("In another cohort:", "purple"));
response.unknown.forEach(adder("Unknown user:", "red"));
users_area.val('')
} else {
log_error(response.msg || "There was an error adding users");
}
}
// ******* Rendering
function render() {
// Load and render the right thing based on the state
// start with both divs hidden
summary.hide();
detail.hide();
// and clear out the errors
errors.empty();
if (state == state_summary) {
$.ajax(url).done(load_cohorts).fail(function() {
log_error("Error trying to load cohorts");
});
} else if (state == state_detail) {
detail_header.text("Members of " + cohort_title);
$.ajax(detail_url).done(show_users).fail(function() {
log_error("Error trying to load users in cohort");
});
}
}
show_cohorts_button.click(function() {
state = state_summary;
render();
});
add_cohort_input.change(function() {
if (!$(this).val()) {
add_cohort_button.removeClass('button').addClass('button-disabled');
} else {
add_cohort_button.removeClass('button-disabled').addClass('button');
}
});
add_cohort_button.click(function() {
var add_url = url + '/add';
data = {'name': add_cohort_input.val()}
$.post(add_url, data).done(added_cohort);
});
add_members_button.click(function() {
var add_url = detail_url + '/add';
data = {'users': users_area.val()}
$.post(add_url, data).done(added_users);
});
};
// prototype
module.prototype = {
constructor: module,
};
// return module
return module;
})(jQuery);
$(window).load(function () {
var my_module = new CohortManager();
})
...@@ -258,6 +258,11 @@ Supported fields at the course level: ...@@ -258,6 +258,11 @@ Supported fields at the course level:
* "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. * "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.
* "cohort_config" : dictionary with keys
- "cohorted" : boolean. Set to true if this course uses student cohorts. If so, all inline discussions are automatically cohorted, and top-level discussion topics are configurable with an optional 'cohorted': bool parameter (with default value false).
- "cohorted_discussions": list of discussions that should be cohorted.
- ... more to come. ('auto_cohort', how to auto cohort, etc)
* TODO: there are others * TODO: there are others
### Grading policy file contents ### Grading policy file contents
......
...@@ -83,7 +83,7 @@ def get_opt_course_with_access(user, course_id, action): ...@@ -83,7 +83,7 @@ def get_opt_course_with_access(user, course_id, action):
return None return None
return get_course_with_access(user, course_id, action) return get_course_with_access(user, course_id, action)
def course_image_url(course): def course_image_url(course):
"""Try to look up the image url for the course. If it's not found, """Try to look up the image url for the course. If it's not found,
log an error and return the dead link""" log an error and return the dead link"""
......
...@@ -39,6 +39,7 @@ log = logging.getLogger("mitx.courseware") ...@@ -39,6 +39,7 @@ log = logging.getLogger("mitx.courseware")
template_imports = {'urllib': urllib} template_imports = {'urllib': urllib}
def user_groups(user): def user_groups(user):
""" """
TODO (vshnayder): This is not used. When we have a new plan for groups, adjust appropriately. TODO (vshnayder): This is not used. When we have a new plan for groups, adjust appropriately.
......
...@@ -24,9 +24,9 @@ urlpatterns = patterns('django_comment_client.base.views', ...@@ -24,9 +24,9 @@ urlpatterns = patterns('django_comment_client.base.views',
url(r'comments/(?P<comment_id>[\w\-]+)/downvote$', 'vote_for_comment', {'value': 'down'}, name='downvote_comment'), url(r'comments/(?P<comment_id>[\w\-]+)/downvote$', 'vote_for_comment', {'value': 'down'}, name='downvote_comment'),
url(r'comments/(?P<comment_id>[\w\-]+)/unvote$', 'undo_vote_for_comment', name='undo_vote_for_comment'), url(r'comments/(?P<comment_id>[\w\-]+)/unvote$', 'undo_vote_for_comment', name='undo_vote_for_comment'),
url(r'(?P<commentable_id>[\w\-]+)/threads/create$', 'create_thread', name='create_thread'), url(r'^(?P<commentable_id>[\w\-.]+)/threads/create$', 'create_thread', name='create_thread'),
# TODO should we search within the board? # TODO should we search within the board?
url(r'(?P<commentable_id>[\w\-]+)/threads/search_similar$', 'search_similar_threads', name='search_similar_threads'), url(r'^(?P<commentable_id>[\w\-.]+)/threads/search_similar$', 'search_similar_threads', name='search_similar_threads'),
url(r'(?P<commentable_id>[\w\-]+)/follow$', 'follow_commentable', name='follow_commentable'), url(r'^(?P<commentable_id>[\w\-.]+)/follow$', 'follow_commentable', name='follow_commentable'),
url(r'(?P<commentable_id>[\w\-]+)/unfollow$', 'unfollow_commentable', name='unfollow_commentable'), url(r'^(?P<commentable_id>[\w\-.]+)/unfollow$', 'unfollow_commentable', name='unfollow_commentable'),
) )
...@@ -21,12 +21,15 @@ from django.contrib.auth.models import User ...@@ -21,12 +21,15 @@ from django.contrib.auth.models import User
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response, render_to_string
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from course_groups.cohorts import get_cohort_id, is_commentable_cohorted
from django_comment_client.utils import JsonResponse, JsonError, extract, get_courseware_context from django_comment_client.utils import JsonResponse, JsonError, extract, get_courseware_context
from django_comment_client.permissions import check_permissions_by_view from django_comment_client.permissions import check_permissions_by_view, cached_has_permission
from django_comment_client.models import Role from django_comment_client.models import Role
log = logging.getLogger(__name__)
def permitted(fn): def permitted(fn):
@functools.wraps(fn) @functools.wraps(fn)
def wrapper(request, *args, **kwargs): def wrapper(request, *args, **kwargs):
...@@ -58,10 +61,12 @@ def ajax_content_response(request, course_id, content, template_name): ...@@ -58,10 +61,12 @@ def ajax_content_response(request, course_id, content, template_name):
'annotated_content_info': annotated_content_info, 'annotated_content_info': annotated_content_info,
}) })
@require_POST @require_POST
@login_required @login_required
@permitted @permitted
def create_thread(request, course_id, commentable_id): def create_thread(request, course_id, commentable_id):
log.debug("Creating new thread in %r, id %r", course_id, commentable_id)
course = get_course_with_access(request.user, course_id, 'load') course = get_course_with_access(request.user, course_id, 'load')
post = request.POST post = request.POST
...@@ -83,6 +88,23 @@ def create_thread(request, course_id, commentable_id): ...@@ -83,6 +88,23 @@ def create_thread(request, course_id, commentable_id):
'course_id' : course_id, 'course_id' : course_id,
'user_id' : request.user.id, 'user_id' : request.user.id,
}) })
# Cohort the thread if the commentable is cohorted.
if is_commentable_cohorted(course_id, commentable_id):
user_group_id = get_cohort_id(request.user, course_id)
# TODO (vshnayder): once we have more than just cohorts, we'll want to
# change this to a single get_group_for_user_and_commentable function
# that can do different things depending on the commentable_id
if cached_has_permission(request.user, "see_all_cohorts", course_id):
# admins can optionally choose what group to post as
group_id = post.get('group_id', user_group_id)
else:
# regular users always post with their own id.
group_id = user_group_id
thread.update_attributes(group_id=group_id)
thread.save() thread.save()
if post.get('auto_subscribe', 'false').lower() == 'true': if post.get('auto_subscribe', 'false').lower() == 'true':
user = cc.User.from_django_user(request.user) user = cc.User.from_django_user(request.user)
......
...@@ -4,7 +4,7 @@ import django_comment_client.forum.views ...@@ -4,7 +4,7 @@ import django_comment_client.forum.views
urlpatterns = patterns('django_comment_client.forum.views', urlpatterns = patterns('django_comment_client.forum.views',
url(r'users/(?P<user_id>\w+)/followed$', 'followed_threads', name='followed_threads'), url(r'users/(?P<user_id>\w+)/followed$', 'followed_threads', name='followed_threads'),
url(r'users/(?P<user_id>\w+)$', 'user_profile', name='user_profile'), url(r'users/(?P<user_id>\w+)$', 'user_profile', name='user_profile'),
url(r'(?P<discussion_id>[\w\-]+)/threads/(?P<thread_id>\w+)$', 'single_thread', name='single_thread'), url(r'^(?P<discussion_id>[\w\-.]+)/threads/(?P<thread_id>\w+)$', 'single_thread', name='single_thread'),
url(r'(?P<discussion_id>[\w\-]+)/inline$', 'inline_discussion', name='inline_discussion'), url(r'^(?P<discussion_id>[\w\-.]+)/inline$', 'inline_discussion', name='inline_discussion'),
url(r'', 'forum_form_discussion', name='forum_form_discussion'), url(r'', 'forum_form_discussion', name='forum_form_discussion'),
) )
...@@ -11,12 +11,14 @@ from django.contrib.auth.models import User ...@@ -11,12 +11,14 @@ from django.contrib.auth.models import User
from mitxmako.shortcuts import render_to_response, render_to_string from mitxmako.shortcuts import render_to_response, render_to_string
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from course_groups.cohorts import get_cohort_id
from courseware.access import has_access from courseware.access import has_access
from urllib import urlencode from urllib import urlencode
from operator import methodcaller from operator import methodcaller
from django_comment_client.permissions import check_permissions_by_view from django_comment_client.permissions import check_permissions_by_view
from django_comment_client.utils import merge_dict, extract, strip_none, strip_blank, get_courseware_context from django_comment_client.utils import (merge_dict, extract, strip_none,
strip_blank, get_courseware_context)
import django_comment_client.utils as utils import django_comment_client.utils as utils
import comment_client as cc import comment_client as cc
...@@ -33,7 +35,6 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG ...@@ -33,7 +35,6 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
This may raise cc.utils.CommentClientError or This may raise cc.utils.CommentClientError or
cc.utils.CommentClientUnknownError if something goes wrong. cc.utils.CommentClientUnknownError if something goes wrong.
""" """
default_query_params = { default_query_params = {
'page': 1, 'page': 1,
'per_page': per_page, 'per_page': per_page,
...@@ -58,8 +59,17 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG ...@@ -58,8 +59,17 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
user.default_sort_key = request.GET.get('sort_key') user.default_sort_key = request.GET.get('sort_key')
user.save() user.save()
#if the course-user is cohorted, then add the group id
group_id = get_cohort_id(user,course_id)
if group_id:
default_query_params["group_id"] = group_id
query_params = merge_dict(default_query_params, query_params = merge_dict(default_query_params,
strip_none(extract(request.GET, ['page', 'sort_key', 'sort_order', 'text', 'tags', 'commentable_ids']))) strip_none(extract(request.GET,
['page', 'sort_key',
'sort_order', 'text',
'tags', 'commentable_ids'])))
threads, page, num_pages = cc.Thread.search(query_params) threads, page, num_pages = cc.Thread.search(query_params)
...@@ -218,7 +228,7 @@ def single_thread(request, course_id, discussion_id, thread_id): ...@@ -218,7 +228,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
# course_id, # course_id,
#) #)
annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info) annotated_content_info = utils.get_metadata_for_threads(course_id, threads, request.user, user_info)
context = { context = {
......
...@@ -23,7 +23,7 @@ class Command(BaseCommand): ...@@ -23,7 +23,7 @@ class Command(BaseCommand):
student_role.add_permission(per) student_role.add_permission(per)
for per in ["edit_content", "delete_thread", "openclose_thread", for per in ["edit_content", "delete_thread", "openclose_thread",
"endorse_comment", "delete_comment"]: "endorse_comment", "delete_comment", "see_all_cohorts"]:
moderator_role.add_permission(per) moderator_role.add_permission(per)
for per in ["manage_moderator"]: for per in ["manage_moderator"]:
......
from django.contrib.auth.models import User from django.contrib.auth.models import User, Group
from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from student.models import CourseEnrollment, \ from django.test.client import RequestFactory
replicate_enrollment_save, \ from django.conf import settings
replicate_enrollment_delete, \
update_user_information, \ from mock import Mock
replicate_user_save
from override_settings import override_settings
import xmodule.modulestore.django
from student.models import CourseEnrollment
from django.db.models.signals import m2m_changed, pre_delete, pre_save, post_delete, post_save from django.db.models.signals import m2m_changed, pre_delete, pre_save, post_delete, post_save
from django.dispatch.dispatcher import _make_id from django.dispatch.dispatcher import _make_id
import string import string
...@@ -13,6 +19,57 @@ import random ...@@ -13,6 +19,57 @@ import random
from .permissions import has_permission from .permissions import has_permission
from .models import Role, Permission from .models import Role, Permission
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import Location
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.xml import XMLModuleStore
import comment_client
from courseware.tests.tests import PageLoader, TEST_DATA_XML_MODULESTORE
#@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
#class TestCohorting(PageLoader):
# """Check that cohorting works properly"""
#
# def setUp(self):
# xmodule.modulestore.django._MODULESTORES = {}
#
# # Assume courses are there
# self.toy = modulestore().get_course("edX/toy/2012_Fall")
#
# # Create two accounts
# self.student = 'view@test.com'
# self.student2 = 'view2@test.com'
# self.password = 'foo'
# self.create_account('u1', self.student, self.password)
# self.create_account('u2', self.student2, self.password)
# self.activate_user(self.student)
# self.activate_user(self.student2)
#
# def test_create_thread(self):
# my_save = Mock()
# comment_client.perform_request = my_save
#
# resp = self.client.post(
# reverse('django_comment_client.base.views.create_thread',
# kwargs={'course_id': 'edX/toy/2012_Fall',
# 'commentable_id': 'General'}),
# {'some': "some",
# 'data': 'data'})
# self.assertTrue(my_save.called)
#
# #self.assertEqual(resp.status_code, 200)
# #self.assertEqual(my_save.something, "expected", "complaint if not true")
#
# self.toy.metadata["cohort_config"] = {"cohorted": True}
#
# # call the view again ...
#
# # assert that different things happened
class PermissionsTestCase(TestCase): class PermissionsTestCase(TestCase):
def random_str(self, length=15, chars=string.ascii_uppercase + string.digits): def random_str(self, length=15, chars=string.ascii_uppercase + string.digits):
return ''.join(random.choice(chars) for x in range(length)) return ''.join(random.choice(chars) for x in range(length))
......
...@@ -207,6 +207,9 @@ def initialize_discussion_info(course): ...@@ -207,6 +207,9 @@ def initialize_discussion_info(course):
"sort_key": entry["sort_key"], "sort_key": entry["sort_key"],
"start_date": entry["start_date"]} "start_date": entry["start_date"]}
# TODO. BUG! : course location is not unique across multiple course runs!
# (I think Kevin already noticed this) Need to send course_id with requests, store it
# in the backend.
default_topics = {'General': {'id' :course.location.html_id()}} default_topics = {'General': {'id' :course.location.html_id()}}
discussion_topics = course.metadata.get('discussion_topics', default_topics) discussion_topics = course.metadata.get('discussion_topics', default_topics)
for topic, entry in discussion_topics.items(): for topic, entry in discussion_topics.items():
......
...@@ -24,6 +24,7 @@ from courseware import grades ...@@ -24,6 +24,7 @@ from courseware import grades
from courseware.access import (has_access, get_access_group_name, from courseware.access import (has_access, get_access_group_name,
course_beta_test_group_name) course_beta_test_group_name)
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from courseware.models import StudentModule
from django_comment_client.models import (Role, from django_comment_client.models import (Role,
FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR, FORUM_ROLE_MODERATOR,
...@@ -31,7 +32,6 @@ from django_comment_client.models import (Role, ...@@ -31,7 +32,6 @@ from django_comment_client.models import (Role,
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
from courseware.models import StudentModule
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -49,6 +49,9 @@ template_imports = {'urllib': urllib} ...@@ -49,6 +49,9 @@ template_imports = {'urllib': urllib}
FORUM_ROLE_ADD = 'add' FORUM_ROLE_ADD = 'add'
FORUM_ROLE_REMOVE = 'remove' FORUM_ROLE_REMOVE = 'remove'
def split_by_comma_and_whitespace(s):
return re.split(r'[\s,]', s)
@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):
...@@ -193,8 +196,8 @@ def instructor_dashboard(request, course_id): ...@@ -193,8 +196,8 @@ def instructor_dashboard(request, course_id):
# get the form data # get the form data
unique_student_identifier=request.POST.get('unique_student_identifier','') unique_student_identifier=request.POST.get('unique_student_identifier','')
problem_to_reset=request.POST.get('problem_to_reset','') problem_to_reset=request.POST.get('problem_to_reset','')
if problem_to_reset[-4:]==".xml": if problem_to_reset[-4:]==".xml":
problem_to_reset=problem_to_reset[:-4] problem_to_reset=problem_to_reset[:-4]
# try to uniquely id student by email address or username # try to uniquely id student by email address or username
...@@ -213,8 +216,8 @@ def instructor_dashboard(request, course_id): ...@@ -213,8 +216,8 @@ def instructor_dashboard(request, course_id):
try: try:
(org, course_name, run)=course_id.split("/") (org, course_name, run)=course_id.split("/")
module_state_key="i4x://"+org+"/"+course_name+"/problem/"+problem_to_reset module_state_key="i4x://"+org+"/"+course_name+"/problem/"+problem_to_reset
module_to_reset=StudentModule.objects.get(student_id=student_to_reset.id, module_to_reset=StudentModule.objects.get(student_id=student_to_reset.id,
course_id=course_id, course_id=course_id,
module_state_key=module_state_key) module_state_key=module_state_key)
msg+="Found module to reset. " msg+="Found module to reset. "
except Exception as e: except Exception as e:
...@@ -230,14 +233,14 @@ def instructor_dashboard(request, course_id): ...@@ -230,14 +233,14 @@ def instructor_dashboard(request, course_id):
# save # save
module_to_reset.state=json.dumps(problem_state) module_to_reset.state=json.dumps(problem_state)
module_to_reset.save() module_to_reset.save()
track.views.server_track(request, track.views.server_track(request,
'{instructor} reset attempts from {old_attempts} to 0 for {student} on problem {problem} in {course}'.format( '{instructor} reset attempts from {old_attempts} to 0 for {student} on problem {problem} in {course}'.format(
old_attempts=old_number_of_attempts, old_attempts=old_number_of_attempts,
student=student_to_reset, student=student_to_reset,
problem=module_to_reset.module_state_key, problem=module_to_reset.module_state_key,
instructor=request.user, instructor=request.user,
course=course_id), course=course_id),
{}, {},
page='idashboard') page='idashboard')
msg+="<font color='green'>Module state successfully reset!</font>" msg+="<font color='green'>Module state successfully reset!</font>"
except: except:
...@@ -252,12 +255,12 @@ def instructor_dashboard(request, course_id): ...@@ -252,12 +255,12 @@ def instructor_dashboard(request, course_id):
else: else:
student_to_reset=User.objects.get(username=unique_student_identifier) student_to_reset=User.objects.get(username=unique_student_identifier)
progress_url=reverse('student_progress',kwargs={'course_id':course_id,'student_id': student_to_reset.id}) progress_url=reverse('student_progress',kwargs={'course_id':course_id,'student_id': student_to_reset.id})
track.views.server_track(request, track.views.server_track(request,
'{instructor} requested progress page for {student} in {course}'.format( '{instructor} requested progress page for {student} in {course}'.format(
student=student_to_reset, student=student_to_reset,
instructor=request.user, instructor=request.user,
course=course_id), course=course_id),
{}, {},
page='idashboard') page='idashboard')
msg+="<a href='{0}' target='_blank'> Progress page for username: {1} with email address: {2}</a>.".format(progress_url,student_to_reset.username,student_to_reset.email) msg+="<a href='{0}' target='_blank'> Progress page for username: {1} with email address: {2}</a>.".format(progress_url,student_to_reset.username,student_to_reset.email)
except: except:
...@@ -392,14 +395,14 @@ def instructor_dashboard(request, course_id): ...@@ -392,14 +395,14 @@ def instructor_dashboard(request, course_id):
users = request.POST['betausers'] users = request.POST['betausers']
log.debug("users: {0!r}".format(users)) log.debug("users: {0!r}".format(users))
group = get_beta_group(course) group = get_beta_group(course)
for username_or_email in _split_by_comma_and_whitespace(users): for username_or_email in split_by_comma_and_whitespace(users):
msg += "<p>{0}</p>".format( msg += "<p>{0}</p>".format(
add_user_to_group(request, username_or_email, group, 'beta testers', 'beta-tester')) add_user_to_group(request, username_or_email, group, 'beta testers', 'beta-tester'))
elif action == 'Remove beta testers': elif action == 'Remove beta testers':
users = request.POST['betausers'] users = request.POST['betausers']
group = get_beta_group(course) group = get_beta_group(course)
for username_or_email in _split_by_comma_and_whitespace(users): for username_or_email in split_by_comma_and_whitespace(users):
msg += "<p>{0}</p>".format( msg += "<p>{0}</p>".format(
remove_user_from_group(request, username_or_email, group, 'beta testers', 'beta-tester')) remove_user_from_group(request, username_or_email, group, 'beta testers', 'beta-tester'))
...@@ -562,6 +565,7 @@ def instructor_dashboard(request, course_id): ...@@ -562,6 +565,7 @@ def instructor_dashboard(request, course_id):
'djangopid' : os.getpid(), 'djangopid' : os.getpid(),
'mitx_version' : getattr(settings,'MITX_VERSION_STRING',''), 'mitx_version' : getattr(settings,'MITX_VERSION_STRING',''),
'offline_grade_log' : offline_grades_available(course_id), 'offline_grade_log' : offline_grades_available(course_id),
'cohorts_ajax_url' : reverse('cohorts', kwargs={'course_id': course_id}),
} }
return render_to_response('courseware/instructor_dashboard.html', context) return render_to_response('courseware/instructor_dashboard.html', context)
...@@ -870,21 +874,11 @@ def grade_summary(request, course_id): ...@@ -870,21 +874,11 @@ 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"""
new_students = _split_by_comma_and_whitespace(students) new_students = split_by_comma_and_whitespace(students)
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]
......
...@@ -577,6 +577,7 @@ INSTALLED_APPS = ( ...@@ -577,6 +577,7 @@ INSTALLED_APPS = (
'open_ended_grading', 'open_ended_grading',
'psychometrics', 'psychometrics',
'licenses', 'licenses',
'course_groups',
#For the wiki #For the wiki
'wiki', # The new django-wiki from benjaoming 'wiki', # The new django-wiki from benjaoming
......
...@@ -17,7 +17,7 @@ from path import path ...@@ -17,7 +17,7 @@ from path import path
MITX_FEATURES['DISABLE_START_DATES'] = True MITX_FEATURES['DISABLE_START_DATES'] = True
# Until we have discussion actually working in test mode, just turn it off # Until we have discussion actually working in test mode, just turn it off
MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = False #MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True WIKI_ENABLED = True
......
...@@ -10,12 +10,12 @@ class Thread(models.Model): ...@@ -10,12 +10,12 @@ class Thread(models.Model):
'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id', 'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id',
'created_at', 'updated_at', 'comments_count', 'unread_comments_count', 'created_at', 'updated_at', 'comments_count', 'unread_comments_count',
'at_position_list', 'children', 'type', 'highlighted_title', 'at_position_list', 'children', 'type', 'highlighted_title',
'highlighted_body', 'endorsed', 'read' 'highlighted_body', 'endorsed', 'read', 'group_id'
] ]
updatable_fields = [ updatable_fields = [
'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
'closed', 'tags', 'user_id', 'commentable_id', 'closed', 'tags', 'user_id', 'commentable_id', 'group_id'
] ]
initializable_fields = updatable_fields initializable_fields = updatable_fields
......
<section class="cohort_manager" data-ajax_url="${cohorts_ajax_url}">
<h3>Cohort groups</h3>
<div class="controls" style="padding-top:15px">
<a href="#" class="button show_cohorts">Show cohorts</a>
</div>
<ul class="errors">
</ul>
<div class="summary" style="display:none">
<h3>Cohorts in the course</h3>
<ul class="cohorts">
</ul>
<p>
<input class="cohort_name"/>
<a href="#" class="button add_cohort">Add cohort</a>
</p>
</div>
<div class="detail" style="display:none">
<h3 class="header"></h3>
<table class="users">
</table>
<span class="page_num"></span>
<p>
Add users by username or email. One per line or comma-separated.
</p>
<textarea cols="50" row="30" class="users_area" style="height: 200px"></textarea>
<a href="#" class="button add_members">Add cohort members</a>
<ul class="op_results">
</ul>
</div>
</section>
<!DOCTYPE html>
<html>
<head>
<%block name="title"><title>edX</title></%block>
<script type="text/javascript" src="/static/js/vendor/jquery.min.js"></script>
<script type="text/javascript" src="/static/js/course_groups/cohorts.js"></script>
</head>
<body class="<%block name='bodyclass'/>">
<%include file="/course_groups/cohort_management.html" />
</body>
</html>
...@@ -6,6 +6,8 @@ ...@@ -6,6 +6,8 @@
<%static:css group='course'/> <%static:css group='course'/>
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.axislabels.js')}"></script> <script type="text/javascript" src="${static.url('js/vendor/flot/jquery.flot.axislabels.js')}"></script>
<script type="text/javascript" src="${static.url('js/course_groups/cohorts.js')}"></script>
</%block> </%block>
<%include file="/courseware/course_navigation.html" args="active_page='instructor'" /> <%include file="/courseware/course_navigation.html" args="active_page='instructor'" />
...@@ -282,16 +284,21 @@ function goto( mode) ...@@ -282,16 +284,21 @@ function goto( mode)
<input type="submit" name="action" value="Add beta testers"> <input type="submit" name="action" value="Add beta testers">
</p> </p>
<hr width="40%" style="align:left"> <hr width="40%" style="align:left">
%if course.is_cohorted:
<%include file="/course_groups/cohort_management.html" />
%endif
%endif %endif
%endif %endif
</form> </form>
##-----------------------------------------------------------------------------
%if msg: %if msg:
<p></p><p>${msg}</p> <p></p><p>${msg}</p>
%endif %endif
##----------------------------------------------------------------------------- ##-----------------------------------------------------------------------------
##-----------------------------------------------------------------------------
%if datatable and modeflag.get('Psychometrics') is None: %if datatable and modeflag.get('Psychometrics') is None:
......
...@@ -274,6 +274,26 @@ if settings.COURSEWARE_ENABLED: ...@@ -274,6 +274,26 @@ if settings.COURSEWARE_ENABLED:
'open_ended_grading.peer_grading_service.save_grade', name='peer_grading_save_grade'), 'open_ended_grading.peer_grading_service.save_grade', name='peer_grading_save_grade'),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading/save_calibration_essay$', url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/peer_grading/save_calibration_essay$',
'open_ended_grading.peer_grading_service.save_calibration_essay', name='peer_grading_save_calibration_essay'), 'open_ended_grading.peer_grading_service.save_calibration_essay', name='peer_grading_save_calibration_essay'),
# Cohorts management
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts$',
'course_groups.views.list_cohorts', name="cohorts"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts/add$',
'course_groups.views.add_cohort',
name="add_cohort"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts/(?P<cohort_id>[0-9]+)$',
'course_groups.views.users_in_cohort',
name="list_cohort"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts/(?P<cohort_id>[0-9]+)/add$',
'course_groups.views.add_users_to_cohort',
name="add_to_cohort"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts/(?P<cohort_id>[0-9]+)/delete$',
'course_groups.views.remove_user_from_cohort',
name="remove_from_cohort"),
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/cohorts/debug$',
'course_groups.views.debug_cohort_mgmt',
name="debug_cohort_mgmt"),
) )
# discussion forums live within courseware, so courseware must be enabled first # discussion forums live within courseware, so courseware must be enabled first
......
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