Commit 3d91f430 by Daniel Friedman Committed by cahrens

Support cohorting students via a CSV File.

TNL-735
parent 69fd063d
......@@ -11,6 +11,8 @@ LMS: Add support for user partitioning based on cohort. TNL-710
Platform: Add base support for cohorted group configurations. TNL-649
LMS: Support assigning students to cohorts via a CSV file upload. TNL-735
Common: Add configurable reset button to units
Studio: Add support xblock validation messages on Studio unit/container page. TNL-683
......
"""
A script to create some dummy users
"""
import uuid
from django.core.management.base import BaseCommand
from student.models import CourseEnrollment
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from student.views import _do_create_account, get_random_post_override
from student.views import _do_create_account
def get_random_post_override():
"""
Generate unique user data for dummy users.
"""
identification = uuid.uuid4().hex[:8]
return {
'username': 'user_{id}'.format(id=identification),
'email': 'email_{id}@example.com'.format(id=identification),
'password': '12345',
'name': 'User {id}'.format(id=identification),
}
def create(num, course_key):
......
"""
Utility methods related to file handling.
"""
from datetime import datetime
import os
from pytz import UTC
from django.core.exceptions import PermissionDenied
from django.core.files.storage import DefaultStorage, get_valid_filename
from django.utils.translation import ugettext as _
from django.utils.translation import ungettext
class FileValidationException(Exception):
"""
An exception thrown during file validation.
"""
pass
def store_uploaded_file(
request, file_key, allowed_file_types, base_storage_filename, max_file_size, validator=None,
):
"""
Stores an uploaded file to django file storage.
Args:
request (HttpRequest): A request object from which a file will be retrieved.
file_key (str): The key for retrieving the file from `request.FILES`. If no entry exists with this
key, a `ValueError` will be thrown.
allowed_file_types (list): a list of allowable file type extensions. These should start with a period
and be specified in lower-case. For example, ['.txt', '.csv']. If the uploaded file does not end
with one of these extensions, a `PermissionDenied` exception will be thrown. Note that the uploaded file
extension does not need to be lower-case.
base_storage_filename (str): the filename to be used for the stored file, not including the extension.
The same extension as the uploaded file will be appended to this value.
max_file_size (int): the maximum file size in bytes that the uploaded file can be. If the uploaded file
is larger than this size, a `PermissionDenied` exception will be thrown.
validator (function): an optional validation method that, if defined, will be passed the stored file (which
is copied from the uploaded file). This method can do validation on the contents of the file and throw
a `FileValidationException` if the file is not properly formatted. If any exception is thrown, the stored
file will be deleted before the exception is re-raised. Note that the implementor of the validator function
should take care to close the stored file if they open it for reading.
Returns:
Storage: the file storage object where the file can be retrieved from
str: stored_file_name: the name of the stored file (including extension)
"""
if file_key not in request.FILES:
raise ValueError("No file uploaded with key '" + file_key + "'.")
uploaded_file = request.FILES[file_key]
try:
file_extension = os.path.splitext(uploaded_file.name)[1].lower()
if not file_extension in allowed_file_types:
file_types = "', '".join(allowed_file_types)
msg = ungettext(
"The file must end with the extension '{file_types}'.",
"The file must end with one of the following extensions: '{file_types}'.",
len(allowed_file_types)).format(file_types=file_types)
raise PermissionDenied(msg)
if uploaded_file.size > max_file_size:
msg = _("Maximum upload file size is {file_size} bytes.").format(file_size=max_file_size)
raise PermissionDenied(msg)
stored_file_name = base_storage_filename + file_extension
file_storage = DefaultStorage()
file_storage.save(stored_file_name, uploaded_file)
if validator:
try:
validator(file_storage, stored_file_name)
except:
file_storage.delete(stored_file_name)
raise
finally:
uploaded_file.close()
return file_storage, stored_file_name
# pylint: disable=invalid-name
def course_filename_prefix_generator(course_id, separator='_'):
"""
Generates a course-identifying unicode string for use in a file
name.
Args:
course_id (object): A course identification object.
Returns:
str: A unicode string which can safely be inserted into a
filename.
"""
return get_valid_filename(unicode(separator).join([course_id.org, course_id.course, course_id.run]))
# pylint: disable=invalid-name
def course_and_time_based_filename_generator(course_id, base_name):
"""
Generates a filename (without extension) based on the current time and the supplied filename.
Args:
course_id (object): A course identification object (must have org, course, and run).
base_name (str): A name describing what type of file this is. Any characters that are not safe for
filenames will be converted per django.core.files.storage.get_valid_filename (Specifically,
leading and trailing spaces are removed; other spaces are converted to underscores; and anything
that is not a unicode alphanumeric, dash, underscore, or dot, is removed).
Returns:
str: a concatenation of the org, course and run from the input course_id, the input base_name,
and the current time. Note that there will be no extension.
"""
return u"{course_prefix}_{base_name}_{timestamp_str}".format(
course_prefix=course_filename_prefix_generator(course_id),
base_name=get_valid_filename(base_name),
timestamp_str=datetime.now(UTC).strftime("%Y-%m-%d-%H%M%S") # pylint: disable=maybe-no-member
)
class UniversalNewlineIterator(object):
"""
This iterable class can be used as a wrapper around a file-like
object which does not inherently support being read in
universal-newline mode. It returns a line at a time.
"""
def __init__(self, original_file, buffer_size=4096):
self.original_file = original_file
self.buffer_size = buffer_size
def __iter__(self):
return self.generate_lines()
@staticmethod
def sanitize(string):
"""
Replace CR and CRLF with LF within `string`.
"""
return string.replace('\r\n', '\n').replace('\r', '\n')
def generate_lines(self):
"""
Return data from `self.original_file` a line at a time,
replacing CR and CRLF with LF.
"""
buf = self.original_file.read(self.buffer_size)
line = ''
while buf:
for char in buf:
if line.endswith('\r') and char == '\n':
last_line = line
line = ''
yield self.sanitize(last_line)
elif line.endswith('\r') or line.endswith('\n'):
last_line = line
line = char
yield self.sanitize(last_line)
else:
line += char
buf = self.original_file.read(self.buffer_size)
if not buf and line:
yield self.sanitize(line)
......@@ -77,13 +77,20 @@ define(['sinon', 'underscore'], function(sinon, _) {
JSON.stringify(jsonResponse));
};
respondWithError = function(requests, requestIndex) {
respondWithError = function(requests, statusCode, jsonResponse, requestIndex) {
if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1;
}
requests[requestIndex].respond(500,
if (_.isUndefined(statusCode)) {
statusCode = 500;
}
if (_.isUndefined(jsonResponse)) {
jsonResponse = {};
}
requests[requestIndex].respond(statusCode,
{ 'Content-Type': 'application/json' },
JSON.stringify({ }));
JSON.stringify(jsonResponse)
);
};
respondToDelete = function(requests, requestIndex) {
......
......@@ -107,10 +107,7 @@ class StaffDebugTest(UniqueCourseTest):
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.rescore()
msg = staff_debug_page.idash_msg[0]
# Since we aren't running celery stuff, this will fail badly
# for now, but is worth excercising that bad of a response
self.assertEqual(u'Failed to rescore problem. '
'Unknown Error Occurred.', msg)
self.assertEqual(u'Successfully rescored problem for user STAFF_TESTER', msg)
def test_student_state_delete(self):
"""
......@@ -176,10 +173,7 @@ class StaffDebugTest(UniqueCourseTest):
staff_debug_page = staff_page.open_staff_debug_info()
staff_debug_page.rescore()
msg = staff_debug_page.idash_msg[0]
# Since we aren't running celery stuff, this will fail badly
# for now, but is worth excercising that bad of a response
self.assertEqual(u'Failed to rescore problem. '
'Unknown Error Occurred.', msg)
self.assertEqual(u'Successfully rescored problem for user STAFF_TESTER', msg)
def test_student_state_delete_for_problem_loaded_via_ajax(self):
"""
......
username,email,ignored_column,cohort
instructor_user,,June,ManualCohort1
,student_user@example.com,Spring,AutoCohort1
Ωπ,,Fall,ManualCohort1
username,email
instructor_user,
,student_user@example.com
email,cohort
instructor_user@example.com,ManualCohort1
student_user@example.com,AutoCohort1
unicode_student_user@example.com,ManualCohort1
username,cohort
instructor_user,ManualCohort1
student_user,AutoCohort1
Ωπ,ManualCohort1
\ No newline at end of file
......@@ -8,7 +8,6 @@ import urlparse
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.core import exceptions
from django.core.files.storage import get_storage_class
from django.http import Http404, HttpResponseBadRequest
from django.utils.translation import ugettext as _
from django.views.decorators import csrf
......@@ -17,6 +16,7 @@ from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware.access import has_access
from util.file import store_uploaded_file
from courseware.courses import get_course_with_access, get_course_by_id
import django_comment_client.settings as cc_settings
from django_comment_client.utils import (
......@@ -558,7 +558,6 @@ def upload(request, course_id): # ajax upload file to a question or answer
"""
# check upload permission
result = ''
error = ''
new_file_name = ''
try:
......@@ -570,29 +569,11 @@ def upload(request, course_id): # ajax upload file to a question or answer
#request.user.assert_can_upload_file()
# check file type
f = request.FILES['file-upload']
file_extension = os.path.splitext(f.name)[1].lower()
if not file_extension in cc_settings.ALLOWED_UPLOAD_FILE_TYPES:
file_types = "', '".join(cc_settings.ALLOWED_UPLOAD_FILE_TYPES)
msg = _("allowed file types are '%(file_types)s'") % \
{'file_types': file_types}
raise exceptions.PermissionDenied(msg)
# generate new file name
new_file_name = str(time.time()).replace('.', str(random.randint(0, 100000))) + file_extension
file_storage = get_storage_class()()
# use default storage to store file
file_storage.save(new_file_name, f)
# check file size
# byte
size = file_storage.size(new_file_name)
if size > cc_settings.MAX_UPLOAD_FILE_SIZE:
file_storage.delete(new_file_name)
msg = _("Maximum upload file size is %(file_size)s bytes.") % \
{'file_size': cc_settings.MAX_UPLOAD_FILE_SIZE}
raise exceptions.PermissionDenied(msg)
base_file_name = str(time.time()).replace('.', str(random.randint(0, 100000)))
file_storage, new_file_name = store_uploaded_file(
request, 'file-upload', cc_settings.ALLOWED_UPLOAD_FILE_TYPES, base_file_name,
max_file_size=cc_settings.MAX_UPLOAD_FILE_SIZE
)
except exceptions.PermissionDenied, err:
error = unicode(err)
......
......@@ -6,8 +6,11 @@ import datetime
import ddt
import io
import json
import os
import random
import requests
import shutil
import tempfile
from unittest import TestCase
from urllib import quote
......@@ -3289,3 +3292,166 @@ class TestCourseRegistrationCodes(ModuleStoreTestCase):
self.assertEqual(response['Content-Type'], 'text/csv')
body = response.content.replace('\r', '')
self.assertTrue(body.startswith(EXPECTED_COUPON_CSV_HEADER))
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
class TestBulkCohorting(ModuleStoreTestCase):
"""
Test adding users to cohorts in bulk via CSV upload.
"""
def setUp(self):
super(TestBulkCohorting, self).setUp()
self.course = CourseFactory.create()
self.staff_user = StaffFactory(course_key=self.course.id)
self.non_staff_user = UserFactory.create()
self.tempdir = tempfile.mkdtemp()
def tearDown(self):
if os.path.exists(self.tempdir):
shutil.rmtree(self.tempdir)
def call_add_users_to_cohorts(self, csv_data, suffix='.csv', method='POST'):
"""
Call `add_users_to_cohorts` with a file generated from `csv_data`.
"""
# this temporary file will be removed in `self.tearDown()`
__, file_name = tempfile.mkstemp(suffix=suffix, dir=self.tempdir)
with open(file_name, 'w') as file_pointer:
file_pointer.write(csv_data.encode('utf-8'))
with open(file_name, 'r') as file_pointer:
url = reverse('add_users_to_cohorts', kwargs={'course_id': unicode(self.course.id)})
if method == 'POST':
return self.client.post(url, {'uploaded-file': file_pointer})
elif method == 'GET':
return self.client.get(url, {'uploaded-file': file_pointer})
def expect_error_on_file_content(self, file_content, error, file_suffix='.csv'):
"""
Verify that we get the error we expect for a given file input.
"""
self.client.login(username=self.staff_user.username, password='test')
response = self.call_add_users_to_cohorts(file_content, suffix=file_suffix)
self.assertEqual(response.status_code, 400)
result = json.loads(response.content)
self.assertEqual(result['error'], error)
def verify_success_on_file_content(self, file_content, mock_store_upload, mock_cohort_task):
"""
Verify that `addd_users_to_cohorts` successfully validates the
file content, uploads the input file, and triggers the
background task.
"""
mock_store_upload.return_value = (None, 'fake_file_name.csv')
self.client.login(username=self.staff_user.username, password='test')
response = self.call_add_users_to_cohorts(file_content)
self.assertEqual(response.status_code, 204)
self.assertTrue(mock_store_upload.called)
self.assertTrue(mock_cohort_task.called)
def test_no_cohort_field(self):
"""
Verify that we get a descriptive verification error when we haven't
included a cohort field in the uploaded CSV.
"""
self.expect_error_on_file_content(
'username,email\n', "The file must contain a 'cohort' column containing cohort names."
)
def test_no_username_or_email_field(self):
"""
Verify that we get a descriptive verification error when we haven't
included a username or email field in the uploaded CSV.
"""
self.expect_error_on_file_content(
'cohort\n', "The file must contain a 'username' column, an 'email' column, or both."
)
def test_empty_csv(self):
"""
Verify that we get a descriptive verification error when we haven't
included any data in the uploaded CSV.
"""
self.expect_error_on_file_content(
'', "The file must contain a 'cohort' column containing cohort names."
)
def test_wrong_extension(self):
"""
Verify that we get a descriptive verification error when we haven't
uploaded a file with a '.csv' extension.
"""
self.expect_error_on_file_content(
'', "The file must end with the extension '.csv'.", file_suffix='.notcsv'
)
def test_non_staff_no_access(self):
"""
Verify that we can't access the view when we aren't a staff user.
"""
self.client.login(username=self.non_staff_user.username, password='test')
response = self.call_add_users_to_cohorts('')
self.assertEqual(response.status_code, 403)
def test_post_only(self):
"""
Verify that we can't call the view when we aren't using POST.
"""
self.client.login(username=self.staff_user.username, password='test')
response = self.call_add_users_to_cohorts('', method='GET')
self.assertEqual(response.status_code, 405)
@patch('instructor.views.api.instructor_task.api.submit_cohort_students')
@patch('instructor.views.api.store_uploaded_file')
def test_success_username(self, mock_store_upload, mock_cohort_task):
"""
Verify that we store the input CSV and call a background task when
the CSV has username and cohort columns.
"""
self.verify_success_on_file_content(
'username,cohort\nfoo_username,bar_cohort', mock_store_upload, mock_cohort_task
)
@patch('instructor.views.api.instructor_task.api.submit_cohort_students')
@patch('instructor.views.api.store_uploaded_file')
def test_success_email(self, mock_store_upload, mock_cohort_task):
"""
Verify that we store the input CSV and call the cohorting background
task when the CSV has email and cohort columns.
"""
self.verify_success_on_file_content(
'email,cohort\nfoo_email,bar_cohort', mock_store_upload, mock_cohort_task
)
@patch('instructor.views.api.instructor_task.api.submit_cohort_students')
@patch('instructor.views.api.store_uploaded_file')
def test_success_username_and_email(self, mock_store_upload, mock_cohort_task):
"""
Verify that we store the input CSV and call the cohorting background
task when the CSV has username, email and cohort columns.
"""
self.verify_success_on_file_content(
'username,email,cohort\nfoo_username,bar_email,baz_cohort', mock_store_upload, mock_cohort_task
)
@patch('instructor.views.api.instructor_task.api.submit_cohort_students')
@patch('instructor.views.api.store_uploaded_file')
def test_success_carriage_return(self, mock_store_upload, mock_cohort_task):
"""
Verify that we store the input CSV and call the cohorting background
task when lines in the CSV are delimited by carriage returns.
"""
self.verify_success_on_file_content(
'username,email,cohort\rfoo_username,bar_email,baz_cohort', mock_store_upload, mock_cohort_task
)
@patch('instructor.views.api.instructor_task.api.submit_cohort_students')
@patch('instructor.views.api.store_uploaded_file')
def test_success_carriage_return_line_feed(self, mock_store_upload, mock_cohort_task):
"""
Verify that we store the input CSV and call the cohorting background
task when lines in the CSV are delimited by carriage returns and line
feeds.
"""
self.verify_success_on_file_content(
'username,email,cohort\r\nfoo_username,bar_email,baz_cohort', mock_store_upload, mock_cohort_task
)
......@@ -15,7 +15,7 @@ from django.conf import settings
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST
from django.views.decorators.cache import cache_control
from django.core.exceptions import ValidationError
from django.core.exceptions import ValidationError, PermissionDenied
from django.core.mail.message import EmailMessage
from django.db import IntegrityError
from django.core.urlresolvers import reverse
......@@ -25,7 +25,9 @@ from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbid
from django.utils.html import strip_tags
import string # pylint: disable=deprecated-module
import random
import unicodecsv
import urllib
from util.file import store_uploaded_file, course_and_time_based_filename_generator, FileValidationException, UniversalNewlineIterator
from util.json_request import JsonResponse
from instructor.views.instructor_task_helpers import extract_email_features, extract_task_features
......@@ -958,6 +960,51 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_POST
@require_level('staff')
def add_users_to_cohorts(request, course_id):
"""
View method that accepts an uploaded file (using key "uploaded-file")
containing cohort assignments for users. This method spawns a celery task
to do the assignments, and a CSV file with results is provided via data downloads.
"""
course_key = SlashSeparatedCourseKey.from_string(course_id)
try:
def validator(file_storage, file_to_validate):
"""
Verifies that the expected columns are present.
"""
with file_storage.open(file_to_validate) as f:
reader = unicodecsv.reader(UniversalNewlineIterator(f), encoding='utf-8')
try:
fieldnames = next(reader)
except StopIteration:
fieldnames = []
msg = None
if "cohort" not in fieldnames:
msg = _("The file must contain a 'cohort' column containing cohort names.")
elif "email" not in fieldnames and "username" not in fieldnames:
msg = _("The file must contain a 'username' column, an 'email' column, or both.")
if msg:
raise FileValidationException(msg)
__, filename = store_uploaded_file(
request, 'uploaded-file', ['.csv'],
course_and_time_based_filename_generator(course_key, "cohorts"),
max_file_size=2000000, # limit to 2 MB
validator=validator
)
# The task will assume the default file storage.
instructor_task.api.submit_cohort_students(request, course_key, filename)
except (FileValidationException, PermissionDenied) as err:
return JsonResponse({"error": unicode(err)}, status=400)
return JsonResponse()
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
def get_coupon_codes(request, course_id): # pylint: disable=unused-argument
"""
......
......@@ -83,4 +83,8 @@ urlpatterns = patterns('', # nopep8
# spoc gradebook
url(r'^gradebook$',
'instructor.views.api.spoc_gradebook', name='spoc_gradebook'),
# Cohort management
url(r'add_users_to_cohorts$',
'instructor.views.api.add_users_to_cohorts', name="add_users_to_cohorts"),
)
......@@ -13,12 +13,15 @@ from celery.states import READY_STATES
from xmodule.modulestore.django import modulestore
from instructor_task.models import InstructorTask
from instructor_task.tasks import (rescore_problem,
reset_problem_attempts,
delete_problem_state,
send_bulk_course_email,
calculate_grades_csv,
calculate_students_features_csv)
from instructor_task.tasks import (
rescore_problem,
reset_problem_attempts,
delete_problem_state,
send_bulk_course_email,
calculate_grades_csv,
calculate_students_features_csv,
cohort_students,
)
from instructor_task.api_helper import (check_arguments_for_rescoring,
encode_problem_and_student_input,
......@@ -233,3 +236,17 @@ def submit_calculate_students_features_csv(request, course_key, features):
task_key = ""
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
def submit_cohort_students(request, course_key, file_name):
"""
Request to have students cohorted in bulk.
Raises AlreadyRunningError if students are currently being cohorted.
"""
task_type = 'cohort_students'
task_class = cohort_students
task_input = {'file_name': file_name}
task_key = ""
return submit_task(request, task_type, task_class, course_key, task_input, task_key)
......@@ -238,6 +238,7 @@ class S3ReportStore(ReportStore):
settings.AWS_ACCESS_KEY_ID,
settings.AWS_SECRET_ACCESS_KEY
)
self.bucket = conn.get_bucket(bucket_name)
@classmethod
......@@ -327,13 +328,10 @@ class S3ReportStore(ReportStore):
can be plugged straight into an href
"""
course_dir = self.key_for(course_id, '')
return sorted(
[
(key.key.split("/")[-1], key.generate_url(expires_in=300))
for key in self.bucket.list(prefix=course_dir.key)
],
reverse=True
)
return [
(key.key.split("/")[-1], key.generate_url(expires_in=300))
for key in sorted(self.bucket.list(prefix=course_dir.key), reverse=True, key=lambda k: k.last_modified)
]
class LocalFSReportStore(ReportStore):
......@@ -410,10 +408,10 @@ class LocalFSReportStore(ReportStore):
course_dir = self.path_to(course_id, '')
if not os.path.exists(course_dir):
return []
return sorted(
[
(filename, ("file://" + urllib.quote(os.path.join(course_dir, filename))))
for filename in os.listdir(course_dir)
],
reverse=True
)
files = [(filename, os.path.join(course_dir, filename)) for filename in os.listdir(course_dir)]
files.sort(key=lambda (filename, full_path): os.path.getmtime(full_path), reverse=True)
return [
(filename, ("file://" + urllib.quote(full_path)))
for filename, full_path in files
]
......@@ -31,7 +31,8 @@ from instructor_task.tasks_helper import (
reset_attempts_module_state,
delete_problem_module_state,
upload_grades_csv,
upload_students_csv
upload_students_csv,
cohort_students_and_upload
)
from bulk_email.tasks import perform_delegate_email_batches
......@@ -153,3 +154,15 @@ def calculate_students_features_csv(entry_id, xmodule_instance_args):
action_name = ugettext_noop('generated')
task_fn = partial(upload_students_csv, xmodule_instance_args)
return run_main_task(entry_id, task_fn, action_name)
@task(base=BaseInstructorTask) # pylint: disable=E1102
def cohort_students(entry_id, xmodule_instance_args):
"""
Cohort students in bulk, and upload the results.
"""
# Translators: This is a past-tense verb that is inserted into task progress messages as {action}.
# An example of such a message is: "Progress: {action} {succeeded} of {attempted} so far"
action_name = ugettext_noop('cohorted')
task_fn = partial(cohort_students_and_upload, xmodule_instance_args)
return run_main_task(entry_id, task_fn, action_name)
......@@ -7,18 +7,23 @@ import json
import urllib
from datetime import datetime
from time import time
import unicodecsv
from celery import Task, current_task
from celery.utils.log import get_task_logger
from celery.states import SUCCESS, FAILURE
from django.contrib.auth.models import User
from django.core.files.storage import DefaultStorage
from django.db import transaction, reset_queries
import dogstats_wrapper as dog_stats_api
from pytz import UTC
from xmodule.modulestore.django import modulestore
from track.views import task_track
from util.file import course_filename_prefix_generator, UniversalNewlineIterator
from xmodule.modulestore.django import modulestore
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort
from courseware.grades import iterate_grades_for
from courseware.models import StudentModule
from courseware.model_data import FieldDataCache
......@@ -515,7 +520,7 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp):
report_store.store_rows(
course_id,
u"{course_prefix}_{csv_name}_{timestamp_str}.csv".format(
course_prefix=urllib.quote(unicode(course_id).replace("/", "_")),
course_prefix=course_filename_prefix_generator(course_id),
csv_name=csv_name,
timestamp_str=timestamp.strftime("%Y-%m-%d-%H%M")
),
......@@ -624,3 +629,88 @@ def upload_students_csv(_xmodule_instance_args, _entry_id, course_id, task_input
upload_csv_to_report_store(rows, 'student_profile_info', course_id, start_date)
return task_progress.update_task_state(extra_meta=current_step)
def cohort_students_and_upload(_xmodule_instance_args, _entry_id, course_id, task_input, action_name):
"""
Within a given course, cohort students in bulk, then upload the results
using a `ReportStore`.
"""
start_time = time()
start_date = datetime.now(UTC)
# Iterate through rows to get total assignments for task progress
with DefaultStorage().open(task_input['file_name']) as f:
total_assignments = 0
for _line in unicodecsv.DictReader(UniversalNewlineIterator(f)):
total_assignments += 1
task_progress = TaskProgress(action_name, total_assignments, start_time)
current_step = {'step': 'Cohorting Students'}
task_progress.update_task_state(extra_meta=current_step)
# cohorts_status is a mapping from cohort_name to metadata about
# that cohort. The metadata will include information about users
# successfully added to the cohort, users not found, and a cached
# reference to the corresponding cohort object to prevent
# redundant cohort queries.
cohorts_status = {}
with DefaultStorage().open(task_input['file_name']) as f:
for row in unicodecsv.DictReader(UniversalNewlineIterator(f), encoding='utf-8'):
# Try to use the 'email' field to identify the user. If it's not present, use 'username'.
username_or_email = row.get('email') or row.get('username')
cohort_name = row.get('cohort') or ''
task_progress.attempted += 1
if not cohorts_status.get(cohort_name):
cohorts_status[cohort_name] = {
'Cohort Name': cohort_name,
'Students Added': 0,
'Students Not Found': set()
}
try:
cohorts_status[cohort_name]['cohort'] = CourseUserGroup.objects.get(
course_id=course_id,
group_type=CourseUserGroup.COHORT,
name=cohort_name
)
cohorts_status[cohort_name]["Exists"] = True
except CourseUserGroup.DoesNotExist:
cohorts_status[cohort_name]["Exists"] = False
if not cohorts_status[cohort_name]['Exists']:
task_progress.failed += 1
continue
try:
with transaction.commit_on_success():
add_user_to_cohort(cohorts_status[cohort_name]['cohort'], username_or_email)
cohorts_status[cohort_name]['Students Added'] += 1
task_progress.succeeded += 1
except User.DoesNotExist:
cohorts_status[cohort_name]['Students Not Found'].add(username_or_email)
task_progress.failed += 1
except ValueError:
# Raised when the user is already in the given cohort
task_progress.skipped += 1
task_progress.update_task_state(extra_meta=current_step)
current_step['step'] = 'Uploading CSV'
task_progress.update_task_state(extra_meta=current_step)
# Filter the output of `add_users_to_cohorts` in order to upload the result.
output_header = ['Cohort Name', 'Exists', 'Students Added', 'Students Not Found']
output_rows = [
[
','.join(status_dict.get(column_name, '')) if column_name == 'Students Not Found'
else status_dict[column_name]
for column_name in output_header
]
for _cohort_name, status_dict in cohorts_status.iteritems()
]
output_rows.insert(0, output_header)
upload_csv_to_report_store(output_rows, 'cohort_results', course_id, start_date)
return task_progress.update_task_state(extra_meta=current_step)
......@@ -14,6 +14,7 @@ from instructor_task.api import (
submit_delete_problem_state_for_all_students,
submit_bulk_course_email,
submit_calculate_students_features_csv,
submit_cohort_students,
)
from instructor_task.api_helper import AlreadyRunningError
......@@ -201,3 +202,11 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa
features=[]
)
self._test_resubmission(api_call)
def test_submit_cohort_students(self):
api_call = lambda: submit_cohort_students(
self.create_task_request(self.instructor),
self.course.id,
file_name=u'filename.csv'
)
self._test_resubmission(api_call)
......@@ -6,6 +6,7 @@ import os
import json
from mock import Mock
import shutil
import unicodecsv
from uuid import uuid4
from celery.states import SUCCESS, FAILURE
......@@ -26,7 +27,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from instructor_task.api_helper import encode_problem_and_student_input
from instructor_task.models import PROGRESS, QUEUING
from instructor_task.models import PROGRESS, QUEUING, ReportStore
from instructor_task.tests.factories import InstructorTaskFactory
from instructor_task.views import instructor_task_status
......@@ -246,3 +247,27 @@ class TestReportMixin(object):
reports_download_path = settings.GRADES_DOWNLOAD['ROOT_PATH']
if os.path.exists(reports_download_path):
shutil.rmtree(reports_download_path)
def verify_rows_in_csv(self, expected_rows, verify_order=True):
"""
Verify that the last ReportStore CSV contains the expected content.
Arguments:
expected_rows (iterable): An iterable of dictionaries,
where each dict represents a row of data in the last
ReportStore CSV. Each dict maps keys from the CSV
header to values in that row's corresponding cell.
verify_order (boolean): When True, we verify that both the
content and order of `expected_rows` matches the
actual csv rows. When False (default), we only verify
that the content matches.
"""
report_store = ReportStore.from_config()
report_csv_filename = report_store.links_for(self.course.id)[0][0]
with open(report_store.path_to(self.course.id, report_csv_filename)) as csv_file:
# Expand the dict reader generator so we don't lose it's content
csv_rows = [row for row in unicodecsv.DictReader(csv_file)]
if verify_order:
self.assertEqual(csv_rows, expected_rows)
else:
self.assertItemsEqual(csv_rows, expected_rows)
......@@ -5,7 +5,6 @@ Runs tasks on answers to course problems to validate that code
paths actually work.
"""
import csv
import json
import logging
from mock import patch
......@@ -28,7 +27,7 @@ from instructor_task.api import (submit_rescore_problem_for_all_students,
submit_rescore_problem_for_student,
submit_reset_problem_attempts_for_all_students,
submit_delete_problem_state_for_all_students)
from instructor_task.models import InstructorTask, ReportStore
from instructor_task.models import InstructorTask
from instructor_task.tasks_helper import upload_grades_csv
from instructor_task.tests.test_base import (InstructorTaskModuleTestCase, TestReportMixin, TEST_COURSE_ORG,
TEST_COURSE_NUMBER, OPTION_1, OPTION_2)
......@@ -602,23 +601,6 @@ class TestGradeReportConditionalContent(TestReportMixin, TestIntegrationTask):
"""
self.assertDictContainsSubset({'attempted': 2, 'succeeded': 2, 'failed': 0}, task_result)
def verify_rows_in_csv(self, expected_rows):
"""
Verify that the grades CSV contains the expected content.
Arguments:
expected_rows (iterable): An iterable of dictionaries, where
each dict represents a row of data in the grades
report CSV. Each dict maps keys from the CSV header
to values in that row's corresponding cell.
"""
report_store = ReportStore.from_config()
report_csv_filename = report_store.links_for(self.course.id)[0][0]
with open(report_store.path_to(self.course.id, report_csv_filename)) as csv_file:
# Expand the dict reader generator so we don't lose it's content
csv_rows = [row for row in csv.DictReader(csv_file)]
self.assertEqual(csv_rows, expected_rows)
def verify_grades_in_csv(self, students_grades):
"""
Verify that the grades CSV contains the expected grades data.
......
"""
Tests for instructor_task/models.py.
"""
from cStringIO import StringIO
import mock
import time
from datetime import datetime
from unittest import TestCase
from instructor_task.models import LocalFSReportStore, S3ReportStore
from instructor_task.tests.test_base import TestReportMixin
from opaque_keys.edx.locator import CourseLocator
class MockKey(object):
"""
Mocking a boto S3 Key object.
"""
def __init__(self, bucket):
self.last_modified = datetime.now()
self.bucket = bucket
def set_contents_from_string(self, contents, headers): # pylint: disable=unused-argument
""" Expected method on a Key object. """
self.bucket.store_key(self)
def generate_url(self, expires_in): # pylint: disable=unused-argument
""" Expected method on a Key object. """
return "http://fake-edx-s3.edx.org/"
class MockBucket(object):
""" Mocking a boto S3 Bucket object. """
def __init__(self, _name):
self.keys = []
def store_key(self, key):
""" Not a Bucket method, created just to store the keys in the Bucket for testing purposes. """
self.keys.append(key)
def list(self, prefix): # pylint: disable=unused-argument
""" Expected method on a Bucket object. """
return self.keys
class MockS3Connection(object):
""" Mocking a boto S3 Connection """
def __init__(self, access_key, secret_key):
pass
def get_bucket(self, bucket_name):
""" Expected method on an S3Connection object. """
return MockBucket(bucket_name)
class ReportStoreTestMixin(object):
"""
Mixin for report store tests.
"""
def setUp(self):
self.course_id = CourseLocator(org="testx", course="coursex", run="runx")
def create_report_store(self):
"""
Subclasses should override this and return their report store.
"""
pass
def test_links_for_order(self):
"""
Test that ReportStore.links_for() returns file download links
in reverse chronological order.
"""
report_store = self.create_report_store()
report_store.store(self.course_id, 'old_file', StringIO())
time.sleep(1) # Ensure we have a unique timestamp.
report_store.store(self.course_id, 'middle_file', StringIO())
time.sleep(1) # Ensure we have a unique timestamp.
report_store.store(self.course_id, 'new_file', StringIO())
self.assertEqual(
[link[0] for link in report_store.links_for(self.course_id)],
['new_file', 'middle_file', 'old_file']
)
class LocalFSReportStoreTestCase(ReportStoreTestMixin, TestReportMixin, TestCase):
"""
Test the LocalFSReportStore model.
"""
def create_report_store(self):
""" Create and return a LocalFSReportStore. """
return LocalFSReportStore.from_config()
@mock.patch('instructor_task.models.S3Connection', new=MockS3Connection)
@mock.patch('instructor_task.models.Key', new=MockKey)
@mock.patch('instructor_task.models.settings.AWS_SECRET_ACCESS_KEY', create=True, new="access_key")
@mock.patch('instructor_task.models.settings.AWS_ACCESS_KEY_ID', create=True, new="access_id")
class S3ReportStoreTestCase(ReportStoreTestMixin, TestReportMixin, TestCase):
"""
Test the S3ReportStore model.
"""
def create_report_store(self):
""" Create and return a S3ReportStore. """
return S3ReportStore.from_config()
......@@ -42,6 +42,17 @@ update_module_store_settings(
default_store=os.environ.get('DEFAULT_STORE', 'draft'),
)
############################ STATIC FILES #############################
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
MEDIA_ROOT = TEST_ROOT / "uploads"
MEDIA_URL = "/static/uploads/"
################################# CELERY ######################################
CELERY_ALWAYS_EAGER = True
CELERY_RESULT_BACKEND = 'cache'
BROKER_TRANSPORT = 'memory'
###################### Grade Downloads ######################
GRADES_DOWNLOAD = {
'STORAGE_TYPE': 'localfs',
......
......@@ -60,6 +60,7 @@
'js/staff_debug_actions': 'js/staff_debug_actions',
// Backbone classes loaded explicitly until they are converted to use RequireJS
'js/views/file_uploader': 'js/views/file_uploader',
'js/models/cohort': 'js/models/cohort',
'js/collections/cohort': 'js/collections/cohort',
'js/views/cohort_editor': 'js/views/cohort_editor',
......@@ -82,7 +83,8 @@
exports: 'gettext'
},
'string_utils': {
deps: ['underscore']
deps: ['underscore'],
exports: 'interpolate_text'
},
'date': {
exports: 'Date'
......@@ -283,7 +285,9 @@
},
'js/views/cohorts': {
exports: 'CohortsView',
deps: ['backbone', 'js/views/cohort_editor']
deps: ['jquery', 'underscore', 'backbone', 'gettext', 'string_utils', 'js/views/cohort_editor',
'js/views/notification', 'js/models/notification', 'js/views/file_uploader'
]
},
'js/models/notification': {
exports: 'NotificationModel',
......@@ -293,6 +297,12 @@
exports: 'NotificationView',
deps: ['backbone', 'jquery', 'underscore']
},
'js/views/file_uploader': {
exports: 'FileUploaderView',
deps: ['backbone', 'jquery', 'underscore', 'gettext', 'string_utils', 'js/views/notification',
'js/models/notification', 'jquery.fileupload'
]
},
'js/student_account/enrollment': {
exports: 'edx.student.account.EnrollmentInterface',
deps: ['jquery', 'jquery.cookie']
......@@ -385,6 +395,7 @@
'lms/include/js/spec/photocapture_spec.js',
'lms/include/js/spec/staff_debug_actions_spec.js',
'lms/include/js/spec/views/notification_spec.js',
'lms/include/js/spec/views/file_uploader_spec.js',
'lms/include/js/spec/dashboard/donation.js',
'lms/include/js/spec/shoppingcart/shoppingcart_spec.js',
'lms/include/js/spec/student_account/account_spec.js',
......
......@@ -28,7 +28,8 @@ define(['backbone', 'jquery', 'js/common_helpers/ajax_helpers', 'js/common_helpe
cohorts.url = '/mock_service';
requests = AjaxHelpers.requests(test);
cohortsView = new CohortsView({
model: cohorts
model: cohorts,
upload_cohorts_csv_url: "http://upload-csv-file-url/"
});
cohortsView.render();
if (initialCohortID) {
......@@ -91,12 +92,13 @@ define(['backbone', 'jquery', 'js/common_helpers/ajax_helpers', 'js/common_helpe
};
beforeEach(function () {
setFixtures("<div></div>");
setFixtures('<ul class="instructor-nav"><li class="nav-item"><<a href data-section="membership" class="active-section">Membership</a></li></ul><div></div>');
TemplateHelpers.installTemplate('templates/instructor/instructor_dashboard_2/cohorts');
TemplateHelpers.installTemplate('templates/instructor/instructor_dashboard_2/add-cohort-form');
TemplateHelpers.installTemplate('templates/instructor/instructor_dashboard_2/cohort-selector');
TemplateHelpers.installTemplate('templates/instructor/instructor_dashboard_2/cohort-editor');
TemplateHelpers.installTemplate('templates/instructor/instructor_dashboard_2/notification');
TemplateHelpers.installTemplate('templates/file-upload');
});
it("Show an error if no cohorts are defined", function() {
......@@ -106,6 +108,18 @@ define(['backbone', 'jquery', 'js/common_helpers/ajax_helpers', 'js/common_helpe
'warning',
'Add Cohort Group'
);
// If no cohorts have been created, can't upload a CSV file.
expect(cohortsView.$('.wrapper-cohort-supplemental')).toHaveClass('is-hidden');
});
it("Syncs data when membership tab is clicked", function() {
createCohortsView(this, 1);
verifyHeader(1, 'Cat Lovers', catLoversInitialCount);
$(cohortsView.getSectionCss("membership")).click();
AjaxHelpers.expectRequest(requests, 'GET', '/mock_service');
respondToRefresh(1001, 2);
verifyHeader(1, 'Cat Lovers', 1001);
});
describe("Cohort Selector", function () {
......@@ -115,6 +129,34 @@ define(['backbone', 'jquery', 'js/common_helpers/ajax_helpers', 'js/common_helpe
expect(cohortsView.$('.cohort-management-group-header .title-value').text()).toBe('');
});
it('can upload a CSV of cohort assignments if a cohort exists', function () {
var uploadCsvToggle, fileUploadForm, fileUploadFormCss='#file-upload-form';
createCohortsView(this);
// Should see the control to toggle CSV file upload.
expect(cohortsView.$('.wrapper-cohort-supplemental')).not.toHaveClass('is-hidden');
// But upload form should not be visible until toggle is clicked.
expect(cohortsView.$(fileUploadFormCss).length).toBe(0);
uploadCsvToggle = cohortsView.$('.toggle-cohort-management-secondary');
expect(uploadCsvToggle.text()).
toContain('Assign students to cohort groups by uploading a CSV file');
uploadCsvToggle.click();
// After toggle is clicked, it should be hidden.
expect(uploadCsvToggle).toHaveClass('is-hidden');
fileUploadForm = cohortsView.$(fileUploadFormCss);
expect(fileUploadForm.length).toBe(1);
cohortsView.$(fileUploadForm).fileupload('add', {files: [{name: 'upload_file.txt'}]});
cohortsView.$('.submit-file-button').click();
// No file will actually be uploaded because "uploaded_file.txt" doesn't actually exist.
AjaxHelpers.expectRequest(requests, 'POST', "http://upload-csv-file-url/", new FormData());
AjaxHelpers.respondWithJson(requests, {});
expect(cohortsView.$('.file-upload-form-result .message-confirmation .message-title').text().trim())
.toBe("Your file 'upload_file.txt' has been uploaded. Please allow a few minutes for processing.");
});
it('can select a cohort', function () {
createCohortsView(this, 1);
verifyHeader(1, 'Cat Lovers', catLoversInitialCount);
......
define(['backbone', 'jquery', 'js/views/file_uploader', 'js/common_helpers/template_helpers',
'js/common_helpers/ajax_helpers', 'js/models/notification', 'string_utils'],
function (Backbone, $, FileUploaderView, TemplateHelpers, AjaxHelpers, NotificationModel) {
describe("FileUploaderView", function () {
var verifyTitle, verifyInputLabel, verifyInputTip, verifySubmitButton, verifyExtensions, verifyText,
verifyFileUploadOption, verifyNotificationMessage, verifySubmitButtonEnabled, mimicUpload,
respondWithSuccess, respondWithError, fileUploaderView, url="http://test_url/";
verifyText = function (css, expectedText) {
expect(fileUploaderView.$(css).text().trim()).toBe(expectedText);
};
verifyTitle = function (expectedTitle) { verifyText('.form-title', expectedTitle); };
verifyInputLabel = function (expectedLabel) { verifyText('.field-label', expectedLabel); };
verifyInputTip = function (expectedTip) { verifyText('.tip', expectedTip); };
verifySubmitButton = function (expectedButton) { verifyText('.submit-file-button', expectedButton); };
verifyExtensions = function (expectedExtensions) {
var acceptAttribute = fileUploaderView.$('input.input-file').attr('accept');
if (expectedExtensions) {
expect(acceptAttribute).toBe(expectedExtensions);
}
else {
expect(acceptAttribute).toBe(undefined);
}
};
verifySubmitButtonEnabled = function (expectedEnabled) {
var submitButton = fileUploaderView.$('.submit-file-button');
if (expectedEnabled) {
expect(submitButton).not.toHaveClass("is-disabled");
}
else {
expect(submitButton).toHaveClass("is-disabled");
}
};
verifyFileUploadOption = function (option, expectedValue) {
expect(fileUploaderView.$('#file-upload-form').fileupload('option', option)).toBe(expectedValue);
};
verifyNotificationMessage = function (expectedMessage, type) {
verifyText('.file-upload-form-result .message-' + type + ' .message-title', expectedMessage);
};
mimicUpload = function (test) {
var requests = AjaxHelpers.requests(test);
var param = {files: [{name: 'upload_file.txt'}]};
fileUploaderView.$('#file-upload-form').fileupload('add', param);
verifySubmitButtonEnabled(true);
fileUploaderView.$('.submit-file-button').click();
// No file will actually be uploaded because "uploaded_file.txt" doesn't actually exist.
AjaxHelpers.expectRequest(requests, 'POST', url, new FormData());
return requests;
};
respondWithSuccess = function (requests) {
AjaxHelpers.respondWithJson(requests, {});
};
respondWithError = function (requests, errorMessage) {
if (errorMessage) {
AjaxHelpers.respondWithError(requests, 500, {error: errorMessage});
}
else {
AjaxHelpers.respondWithError(requests);
}
};
beforeEach(function () {
setFixtures("<div></div>");
TemplateHelpers.installTemplate('templates/file-upload');
TemplateHelpers.installTemplate('templates/instructor/instructor_dashboard_2/notification');
fileUploaderView = new FileUploaderView({url: url}).render();
});
it('has default values', function () {
verifyTitle("");
verifyInputLabel("");
verifyInputTip("");
verifySubmitButton("Upload File");
verifyExtensions(null);
verifySubmitButtonEnabled(false);
});
it ('can set text values and extensions', function () {
fileUploaderView = new FileUploaderView({
title: "file upload title",
inputLabel: "test label",
inputTip: "test tip",
submitButtonText: "upload button text",
extensions: ".csv,.txt"
}).render();
verifyTitle("file upload title");
verifyInputLabel("test label");
verifyInputTip("test tip");
verifySubmitButton("upload button text");
verifyExtensions(".csv,.txt");
});
it ('can store upload URL', function () {
expect(fileUploaderView.$('#file-upload-form').attr('action')).toBe(url);
});
it ('sets autoUpload to false', function () {
verifyFileUploadOption('autoUpload', false);
});
it ('sets replaceFileInput to false', function () {
verifyFileUploadOption('replaceFileInput', false);
});
it ('handles errors with default message', function () {
var requests = mimicUpload(this);
respondWithError(requests);
verifyNotificationMessage("Your upload of 'upload_file.txt' failed.", "error");
});
it ('handles errors with custom message', function () {
fileUploaderView = new FileUploaderView({
url: url,
errorNotification: function (file, event, data) {
var message = interpolate_text("Custom error for '{file}'", {file: file});
return new NotificationModel({
type: "customized",
title: message
});
}
}).render();
var requests = mimicUpload(this);
respondWithError(requests, "server error");
verifyNotificationMessage("Custom error for 'upload_file.txt'", "customized");
});
it ('handles server error message', function () {
var requests = mimicUpload(this);
respondWithError(requests, "server error");
verifyNotificationMessage("server error", "error");
});
it ('handles success with default message', function () {
var requests = mimicUpload(this);
respondWithSuccess(requests);
verifyNotificationMessage("Your upload of 'upload_file.txt' succeeded.", "confirmation");
});
it ('handles success with custom message', function () {
fileUploaderView = new FileUploaderView({
url: url,
successNotification: function (file, event, data) {
var message = interpolate_text("Custom success message for '{file}'", {file: file});
return new NotificationModel({
type: "customized",
title: message
});
}
}).render();
var requests = mimicUpload(this);
respondWithSuccess(requests);
verifyNotificationMessage("Custom success message for 'upload_file.txt'", "customized");
});
});
});
(function($, _, Backbone, gettext, interpolate_text, CohortEditorView, NotificationModel, NotificationView) {
(function($, _, Backbone, gettext, interpolate_text, CohortEditorView, NotificationModel, NotificationView, FileUploaderView) {
var hiddenClass = 'is-hidden',
disabledClass = 'is-disabled';
......@@ -8,15 +8,26 @@
'click .action-create': 'showAddCohortForm',
'click .action-cancel': 'cancelAddCohortForm',
'click .action-save': 'saveAddCohortForm',
'click .link-cross-reference': 'showSection'
'click .link-cross-reference': 'showSection',
'click .toggle-cohort-management-secondary': 'showCsvUpload'
},
initialize: function(options) {
var model = this.model;
this.template = _.template($('#cohorts-tpl').text());
this.selectorTemplate = _.template($('#cohort-selector-tpl').text());
this.addCohortFormTemplate = _.template($('#add-cohort-form-tpl').text());
this.advanced_settings_url = options.advanced_settings_url;
this.model.on('sync', this.onSync, this);
this.upload_cohorts_csv_url = options.upload_cohorts_csv_url;
model.on('sync', this.onSync, this);
// Update cohort counts when the user clicks back on the membership tab
// (for example, after uploading a csv file of cohort assignments and then
// checking results on data download tab).
$(this.getSectionCss('membership')).click(function () {
model.fetch();
});
},
render: function() {
......@@ -36,16 +47,20 @@
onSync: function() {
var selectedCohort = this.lastSelectedCohortId && this.model.get(this.lastSelectedCohortId),
hasCohorts = this.model.length > 0;
hasCohorts = this.model.length > 0,
cohortNavElement = this.$('.cohort-management-nav'),
additionalCohortControlElement = this.$('.wrapper-cohort-supplemental');
this.hideAddCohortForm();
if (hasCohorts) {
this.$('.cohort-management-nav').removeClass(hiddenClass);
cohortNavElement.removeClass(hiddenClass);
additionalCohortControlElement.removeClass(hiddenClass);
this.renderSelector(selectedCohort);
if (selectedCohort) {
this.showCohortEditor(selectedCohort);
}
} else {
this.$('.cohort-management-nav').addClass(hiddenClass);
cohortNavElement.addClass(hiddenClass);
additionalCohortControlElement.addClass(hiddenClass);
this.showNotification({
type: 'warning',
title: gettext('You currently have no cohort groups configured'),
......@@ -176,8 +191,41 @@
showSection: function(event) {
event.preventDefault();
var section = $(event.currentTarget).data("section");
$(".instructor-nav .nav-item a[data-section='" + section + "']").click();
$(this.getSectionCss(section)).click();
$(window).scrollTop(0);
},
showCsvUpload: function(event) {
event.preventDefault();
$(event.currentTarget).addClass(hiddenClass);
var uploadElement = this.$('.csv-upload').removeClass(hiddenClass);
if (!this.fileUploaderView) {
this.fileUploaderView = new FileUploaderView({
el: uploadElement,
title: gettext("Assign students to cohort groups by uploading a CSV file."),
inputLabel: gettext("Choose a .csv file"),
inputTip: gettext("Only properly formatted .csv files will be accepted."),
submitButtonText: gettext("Upload File and Assign Students"),
extensions: ".csv",
url: this.upload_cohorts_csv_url,
successNotification: function (file, event, data) {
var message = interpolate_text(gettext(
"Your file '{file}' has been uploaded. Please allow a few minutes for processing."
), {file: file});
return new NotificationModel({
type: "confirmation",
title: message
});
}
}).render();
}
},
getSectionCss: function (section) {
return ".instructor-nav .nav-item a[data-section='" + section + "']";
}
});
}).call(this, $, _, Backbone, gettext, interpolate_text, CohortEditorView, NotificationModel, NotificationView);
}).call(this, $, _, Backbone, gettext, interpolate_text, CohortEditorView, NotificationModel, NotificationView, FileUploaderView);
/**
* A view for uploading a file.
*
* Currently only single-file upload is supported (to support multiple-file uploads, the HTML
* input must be changed to specify "multiple" and the notification messaging needs to be changed
* to support the display of multiple status messages).
*
* There is no associated model, but the view supports the following options:
*
* @param title, the title to display.
* @param inputLabel, a label that will be added for the file input field. Note that this label is only shown to
* screen readers.
* @param inputTip, a tooltip linked to the file input field. Can be used to state what sort of file can be uploaded.
* @param extensions, the allowed file extensions of the uploaded file, as a comma-separated string (ex, ".csv,.txt").
* Some browsers will enforce that only files with these extensions can be uploaded, but others
* (for instance, Firefox), will not. By default, no extensions are specified and any file can be uploaded.
* @param submitButtonText, text to display on the submit button to upload the file. The default value for this is
* "Upload File".
* @param url, the url for posting the uploaded file.
* @param successNotification, optional callback that can return a success NotificationModel for display
* after a file was successfully uploaded. This method will be passed the uploaded file, event, and data.
* @param errorNotification, optional callback that can return a success NotificationModel for display
* after a file failed to upload. This method will be passed the attempted file, event, and data.
*/
(function (Backbone, $, _, gettext, interpolate_text, NotificationModel, NotificationView) {
// Requires JQuery-File-Upload.
var FileUploaderView = Backbone.View.extend({
initialize: function (options) {
this.template = _.template($('#file-upload-tpl').text());
this.options = options;
},
render: function () {
var options = this.options,
get_option_with_default = function(option, default_value) {
var optionVal = options[option];
return optionVal ? optionVal : default_value;
},
submitButton, resultNotification;
this.$el.html(this.template({
title: get_option_with_default("title", ""),
inputLabel: get_option_with_default("inputLabel", ""),
inputTip: get_option_with_default("inputTip", ""),
extensions: get_option_with_default("extensions", ""),
submitButtonText: get_option_with_default("submitButtonText", gettext("Upload File")),
url: get_option_with_default("url", "")
}));
submitButton = this.$el.find('.submit-file-button');
resultNotification = this.$el.find('.result'),
this.$el.find('#file-upload-form').fileupload({
dataType: 'json',
type: 'POST',
done: this.successHandler.bind(this),
fail: this.errorHandler.bind(this),
autoUpload: false,
replaceFileInput: false,
add: function (e, data) {
var file = data.files[0];
submitButton.removeClass("is-disabled");
submitButton.unbind('click');
submitButton.click(function (event) {
event.preventDefault();
data.submit();
});
resultNotification.html("");
}
});
return this;
},
successHandler: function (event, data) {
var file = data.files[0].name;
var notificationModel;
if (this.options.successNotification) {
notificationModel = this.options.successNotification(file, event, data);
}
else {
notificationModel = new NotificationModel({
type: "confirmation",
title: interpolate_text(gettext("Your upload of '{file}' succeeded."), {file: file})
});
}
var notification = new NotificationView({
el: this.$('.result'),
model: notificationModel
});
notification.render();
},
errorHandler: function (event, data) {
var file = data.files[0].name, message = null, jqXHR = data.response().jqXHR;
var notificationModel;
if (this.options.errorNotification) {
notificationModel = this.options.errorNotification(file, event, data);
}
else {
if (jqXHR.responseText) {
try {
message = JSON.parse(jqXHR.responseText).error;
}
catch (err) {
}
}
if (!message) {
message = interpolate_text(gettext("Your upload of '{file}' failed."), {file: file});
}
notificationModel = new NotificationModel({
type: "error",
title: message
});
}
var notification = new NotificationView({
el: this.$('.result'),
model: notificationModel
});
notification.render();
}
});
this.FileUploaderView = FileUploaderView;
}).call(this, Backbone, $, _, gettext, interpolate_text, NotificationModel, NotificationView);
......@@ -41,6 +41,8 @@ lib_paths:
- xmodule_js/common_static/js/vendor/flot/jquery.flot.js
- xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js
- xmodule_js/common_static/js/vendor/URI.min.js
- xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload.js
- xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport.js
- xmodule_js/common_static/js/vendor/url.min.js
- xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js
- xmodule_js/common_static/coffee/src/xblock
......@@ -76,6 +78,7 @@ fixture_paths:
- templates/dashboard
- templates/student_account
- templates/student_profile
- templates/file-upload.underscore
requirejs:
paths:
......
......@@ -106,6 +106,19 @@
}
}
}
// UI: visual dividers
.divider-lv0 {
border-top: ($baseline/5) solid $gray-l4;
}
.divider-lv1 {
border-top: ($baseline/10) solid $gray-l4;
}
.divider-lv2 {
border-top: ($baseline/20) solid $gray-l4;
}
}
// instructor dashboard 2
......@@ -311,6 +324,12 @@ section.instructor-dashboard-content-2 {
color: $gray;
}
}
.subsection-title {
@extend %hd-lv5;
@extend %t-weight4;
margin-bottom: ($baseline/2);
}
}
......@@ -434,7 +453,7 @@ section.instructor-dashboard-content-2 {
.tip {
@extend %t-copy-sub1;
margin-top: ($baseline/4);
color: $gray-l3;
color: $gray-l2;
}
.field-text {
......@@ -449,6 +468,10 @@ section.instructor-dashboard-content-2 {
padding: ($baseline/2) ($baseline*0.75);
}
}
.input-file {
margin-bottom: ($baseline/2);
}
}
.form-submit, .form-cancel {
......@@ -472,7 +495,6 @@ section.instructor-dashboard-content-2 {
.cohort-management-nav {
@include clearfix();
margin-bottom: $baseline;
.cohort-management-nav-form {
width: 60%;
......@@ -486,10 +508,10 @@ section.instructor-dashboard-content-2 {
.action-create {
@include idashbutton($blue);
@extend %t-weight4;
float: right;
text-align: right;
text-shadow: none;
font-weight: 600;
}
// STATE: is disabled
......@@ -538,10 +560,6 @@ section.instructor-dashboard-content-2 {
}
// cohort group
.cohort-management-group {
border: 1px solid $gray-l5;
}
.cohort-management-group-header {
border-bottom: ($baseline/10) solid $gray-l4;
background: $gray-l5;
......@@ -610,6 +628,7 @@ section.instructor-dashboard-content-2 {
.cohort-management-group-add {
@extend %cohort-management-form;
border: 1px solid $gray-l5;
padding: $baseline $baseline 0 $baseline;
.message-title {
......@@ -646,10 +665,51 @@ section.instructor-dashboard-content-2 {
}
}
// CSV-based file upload for auto cohort assigning
.toggle-cohort-management-secondary {
@extend %t-copy-sub1;
}
.cohort-management-file-upload {
.message-title {
@extend %t-title7;
}
.form-introduction {
@extend %t-copy-sub1;
margin-bottom: $baseline;
p {
color: $gray-l1;
}
}
}
.file-upload-form {
@extend %cohort-management-form;
.form-fields {
margin-bottom: $baseline;
}
.action-submit {
@include idashbutton($blue);
// needed to override very poor specificity and base rules for blue button
@include font-size(14);
margin-bottom: 0;
font-weight: 700;
text-shadow: none;
}
}
.cohort-management-supplemental {
@extend %t-copy-sub1;
margin-top: ($baseline/2);
margin-top: $baseline;
padding: ($baseline/2) $baseline;
background: $gray-l5;
border-radius: ($baseline/10);
.icon {
margin-right: ($baseline/4);
......
<div class="wrapper-form">
<h3 class="form-title subsection-title"><%- title %></h3>
<div class="file-upload-form-result result"></div>
<form class="file-upload-form" id="file-upload-form" method="post" action="<%= url %>" enctype="multipart/form-data">
<div class="form-fields">
<div class="field field-file is-required">
<label class="field-label sr" for="file-upload-form-file"><%- inputLabel %></label>
<input id="file-upload-form-file" class="input input-file" name="uploaded-file" type="file"
<% if (extensions) { %>
accept="<%- extensions %>"
<% } %>
/>
<span class="tip tip-stacked"><%- inputTip %></span>
</div>
</div>
<div class="form-actions">
<button id="file-upload-form-submit" type="submit" class="submit-file-button action action-submit is-disabled"><%- submitButtonText %></button>
</div>
</form>
</div>
......@@ -3,11 +3,12 @@
<span class="description"></span>
</h2>
<!-- nav -->
<div class="cohort-management-nav">
<h3 class="subsection-title"><%- gettext('Assign students to cohort groups manually') %></h3>
<form action="" method="post" name="" id="cohort-management-nav-form" class="cohort-management-nav-form">
<div class="cohort-management-nav-form-select field field-select">
<label for="cohort-select" class="label sr">${_("Select a cohort group to manage")}</label>
<label for="cohort-select" class="label sr"><%- gettext("Select a cohort group to manage") %></label>
<select class="input cohort-select" name="cohort-select" id="cohort-select">
</select>
</div>
......@@ -26,13 +27,22 @@
<!-- individual group -->
<div class="cohort-management-group"></div>
<div class="cohort-management-supplemental">
<p class="">
<i class="icon icon-info-sign"></i>
<%= interpolate(
gettext('To review all student cohort group assignments, download course profile information on %(link_start)s the Data Download page. %(link_end)s'),
{link_start: '<a href="" class="link-cross-reference" data-section="data_download">', link_end: '</a>'},
true
) %>
</p>
<div class="wrapper-cohort-supplemental">
<hr class="divider divider-lv1" />
<!-- Uploading a CSV file of cohort assignments. -->
<a class="toggle-cohort-management-secondary" href="#cohort-management-file-upload"><%- gettext('Assign students to cohort groups by uploading a CSV file') %></a>
<div class="cohort-management-file-upload csv-upload is-hidden" id="cohort-management-file-upload"></div>
<div class="cohort-management-supplemental">
<p class="">
<i class="icon icon-info-sign"></i>
<%= interpolate(
gettext('To review student cohort group assignments or see the results of uploading a CSV file, download course profile information or cohort results on %(link_start)s the Data Download page. %(link_end)s'),
{link_start: '<a href="" class="link-cross-reference" data-section="data_download">', link_end: '</a>'},
true
) %>
</p>
</div>
</div>
......@@ -47,15 +47,17 @@
<script type="text/javascript" src="${static.url('js/vendor/codemirror-compressed.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/tinymce/js/tinymce/tinymce.full.min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/tinymce/js/tinymce/jquery.tinymce.min.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/jQuery-File-Upload/js/jquery.fileupload.js')}"></script>
<%static:js group='module-descriptor-js'/>
<%static:js group='instructor_dash'/>
<%static:js group='application'/>
## Backbone classes declared explicitly until RequireJS is supported
<script type="text/javascript" src="${static.url('js/models/notification.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/notification.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/file_uploader.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/cohort.js')}"></script>
<script type="text/javascript" src="${static.url('js/collections/cohort.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/notification.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/cohort_editor.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/cohorts.js')}"></script>
</%block>
......@@ -67,6 +69,10 @@
<%static:include path="instructor/instructor_dashboard_2/${template_name}.underscore" />
</script>
% endfor
<script type="text/template" id="file-upload-tpl">
<%static:include path="file-upload.underscore" />
</script>
</%block>
## NOTE that instructor is set as the active page so that the instructor button lights up, even though this is the instructor_2 page.
......
......@@ -249,6 +249,7 @@
<div class="cohort-management membership-section"
data-ajax_url="${section_data['cohorts_ajax_url']}"
data-advanced-settings-url="${section_data['advanced_settings_url']}"
data-upload_cohorts_csv_url="${section_data['upload_cohorts_csv_url']}"
>
</div>
......@@ -262,7 +263,8 @@
var cohortsView = new CohortsView({
el: cohortManagementElement,
model: cohorts,
advanced_settings_url: cohortManagementElement.data('advanced-settings-url')
advanced_settings_url: cohortManagementElement.data('advanced-settings-url'),
upload_cohorts_csv_url: cohortManagementElement.data('upload_cohorts_csv_url')
});
cohorts.fetch().done(function() {
cohortsView.render();
......
......@@ -12,6 +12,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from courseware.courses import get_course_with_access
from edxmako.shortcuts import render_to_response
from util.json_request import JsonResponse
from . import cohorts
from .models import CourseUserGroup
......@@ -23,7 +24,7 @@ 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")
return JsonResponse(data)
def split_by_comma_and_whitespace(cstr):
......
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