diff --git a/.gitignore b/.gitignore index 3653c832fa..81d9a57d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ Gemfile.lock lms/static/sass/*.css cms/static/sass/*.css lms/lib/comment_client/python +nosetests.xml +cover_html/ diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index 7d16f0c6f9..410f74ee07 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -14,6 +14,10 @@ $yellow: #fff8af; $cream: #F6EFD4; $border-color: #ddd; +// edX colors +$blue: rgb(29,157,217); +$pink: rgb(182,37,104); + @mixin hide-text { background-color: transparent; border: 0; diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index cab57e6819..0069935b0b 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -140,7 +140,20 @@ def dashboard(request): if not user.is_active: message = render_to_string('registration/activate_account_notice.html', {'email': user.email}) - context = {'courses': courses, 'message': message} + + # Global staff can see what courses errored on their dashboard + staff_access = False + errored_courses = {} + if has_access(user, 'global', 'staff'): + # Show any courses that errored on load + staff_access = True + errored_courses = modulestore().get_errored_courses() + + context = {'courses': courses, + 'message': message, + 'staff_access': staff_access, + 'errored_courses': errored_courses,} + return render_to_response('dashboard.html', context) diff --git a/common/djangoapps/track/views.py b/common/djangoapps/track/views.py index b5f9c54665..434e75a63f 100644 --- a/common/djangoapps/track/views.py +++ b/common/djangoapps/track/views.py @@ -1,6 +1,7 @@ import json import logging import os +import pytz import datetime import dateutil.parser @@ -84,15 +85,33 @@ def server_track(request, event_type, event, page=None): "time": datetime.datetime.utcnow().isoformat(), } - if event_type=="/event_logs" and request.user.is_staff: # don't log + if event_type.startswith("/event_logs") and request.user.is_staff: # don't log return log_event(event) @login_required @ensure_csrf_cookie -def view_tracking_log(request): +def view_tracking_log(request,args=''): if not request.user.is_staff: return redirect('/') - record_instances = TrackingLog.objects.all().order_by('-time')[0:100] + nlen = 100 + username = '' + if args: + for arg in args.split('/'): + if arg.isdigit(): + nlen = int(arg) + if arg.startswith('username='): + username = arg[9:] + + record_instances = TrackingLog.objects.all().order_by('-time') + if username: + record_instances = record_instances.filter(username=username) + record_instances = record_instances[0:nlen] + + # fix dtstamp + fmt = '%a %d-%b-%y %H:%M:%S' # "%Y-%m-%d %H:%M:%S %Z%z" + for rinst in record_instances: + rinst.dtstr = rinst.time.replace(tzinfo=pytz.utc).astimezone(pytz.timezone('US/Eastern')).strftime(fmt) + return render_to_response('tracking_log.html',{'records':record_instances}) diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 7b89510a12..f19e7555a1 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -150,6 +150,7 @@ def optioninput(element, value, status, render_template, msg=''): 'state': status, 'msg': msg, 'options': osetdict, + 'inline': element.get('inline',''), } html = render_template("optioninput.html", context) @@ -206,7 +207,7 @@ def extract_choices(element): raise Exception("[courseware.capa.inputtypes.extract_choices] \ Expected a tag; got %s instead" % choice.tag) - choice_text = ''.join([x.text for x in choice]) + choice_text = ''.join([etree.tostring(x) for x in choice]) choices.append((choice.get("name"), choice_text)) @@ -294,7 +295,9 @@ def textline(element, value, status, render_template, msg=""): hidden = element.get('hidden', '') # if specified, then textline is hidden and id is stored in div of name given by hidden escapedict = {'"': '"'} value = saxutils.escape(value, escapedict) # otherwise, answers with quotes in them crashes the system! - context = {'id': eid, 'value': value, 'state': status, 'count': count, 'size': size, 'msg': msg, 'hidden': hidden} + context = {'id': eid, 'value': value, 'state': status, 'count': count, 'size': size, 'msg': msg, 'hidden': hidden, + 'inline': element.get('inline',''), + } html = render_template("textinput.html", context) try: xhtml = etree.XML(html) diff --git a/common/lib/capa/capa/templates/textinput.html b/common/lib/capa/capa/templates/textinput.html index 08aa8379a7..9b66654117 100644 --- a/common/lib/capa/capa/templates/textinput.html +++ b/common/lib/capa/capa/templates/textinput.html @@ -1,12 +1,14 @@ -
+<% doinline = "inline" if inline else "" %> + +
% if state == 'unsubmitted': -
+
% elif state == 'correct': -
+
% elif state == 'incorrect': -
+
% elif state == 'incomplete': -
+
% endif % if hidden:
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index a9f29b9a13..5cc4a09165 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -18,7 +18,7 @@ class CourseDescriptor(SequenceDescriptor): class Textbook: def __init__(self, title, book_url): self.title = title - self.book_url = book_url + self.book_url = book_url self.table_of_contents = self._get_toc_from_s3() @classmethod @@ -30,11 +30,11 @@ class CourseDescriptor(SequenceDescriptor): return self.table_of_contents def _get_toc_from_s3(self): - ''' + """ Accesses the textbook's table of contents (default name "toc.xml") at the URL self.book_url Returns XML tree representation of the table of contents - ''' + """ toc_url = self.book_url + 'toc.xml' # Get the table of contents from S3 @@ -72,6 +72,22 @@ class CourseDescriptor(SequenceDescriptor): self.enrollment_start = self._try_parse_time("enrollment_start") self.enrollment_end = self._try_parse_time("enrollment_end") + # NOTE: relies on the modulestore to call set_grading_policy() right after + # init. (Modulestore is in charge of figuring out where to load the policy from) + + + def set_grading_policy(self, policy_str): + """Parse the policy specified in policy_str, and save it""" + try: + self._grading_policy = load_grading_policy(policy_str) + except: + self.system.error_tracker("Failed to load grading policy") + # Setting this to an empty dictionary will lead to errors when + # grading needs to happen, but should allow course staff to see + # the error log. + self._grading_policy = {} + + @classmethod def definition_from_xml(cls, xml_object, system): textbooks = [] @@ -87,25 +103,11 @@ class CourseDescriptor(SequenceDescriptor): @property def grader(self): - return self.__grading_policy['GRADER'] + return self._grading_policy['GRADER'] @property def grade_cutoffs(self): - return self.__grading_policy['GRADE_CUTOFFS'] - - @lazyproperty - def __grading_policy(self): - policy_string = "" - - try: - with self.system.resources_fs.open("grading_policy.json") as grading_policy_file: - policy_string = grading_policy_file.read() - except (IOError, ResourceNotFoundError): - log.warning("Unable to load course settings file from grading_policy.json in course " + self.id) - - grading_policy = load_grading_policy(policy_string) - - return grading_policy + return self._grading_policy['GRADE_CUTOFFS'] @lazyproperty def grading_context(self): diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index e08001f6ea..e6ebdb316f 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -27,6 +27,10 @@ section.problem { } } + .inline { + display: inline; + } + div { p { &.answer { diff --git a/common/lib/xmodule/xmodule/graders.py b/common/lib/xmodule/xmodule/graders.py index fca862aa9f..3f0bb63186 100644 --- a/common/lib/xmodule/xmodule/graders.py +++ b/common/lib/xmodule/xmodule/graders.py @@ -1,6 +1,7 @@ import abc import json import logging +import sys from collections import namedtuple @@ -14,11 +15,11 @@ def load_grading_policy(course_policy_string): """ This loads a grading policy from a string (usually read from a file), which can be a JSON object or an empty string. - + The JSON object can have the keys GRADER and GRADE_CUTOFFS. If either is missing, it reverts to the default. """ - + default_policy_string = """ { "GRADER" : [ @@ -56,7 +57,7 @@ def load_grading_policy(course_policy_string): } } """ - + # Load the global settings as a dictionary grading_policy = json.loads(default_policy_string) @@ -64,15 +65,15 @@ def load_grading_policy(course_policy_string): course_policy = {} if course_policy_string: course_policy = json.loads(course_policy_string) - + # Override any global settings with the course settings grading_policy.update(course_policy) # Here is where we should parse any configurations, so that we can fail early grading_policy['GRADER'] = grader_from_conf(grading_policy['GRADER']) - + return grading_policy - + def aggregate_scores(scores, section_name="summary"): """ @@ -130,9 +131,11 @@ def grader_from_conf(conf): raise ValueError("Configuration has no appropriate grader class.") except (TypeError, ValueError) as error: - errorString = "Unable to parse grader configuration:\n " + str(subgraderconf) + "\n Error was:\n " + str(error) - log.critical(errorString) - raise ValueError(errorString) + # Add info and re-raise + msg = ("Unable to parse grader configuration:\n " + + str(subgraderconf) + + "\n Error was:\n " + str(error)) + raise ValueError(msg), None, sys.exc_info()[2] return WeightedSubsectionsGrader(subgraders) diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index c0b82fef90..6c424c26f2 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -86,7 +86,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): # online and has imported all current (fall 2012) courses from xml if not system.resources_fs.exists(filepath): candidates = cls.backcompat_paths(filepath) - log.debug("candidates = {0}".format(candidates)) + #log.debug("candidates = {0}".format(candidates)) for candidate in candidates: if system.resources_fs.exists(candidate): filepath = candidate diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index e0fecb243d..3eca72987e 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -4,16 +4,17 @@ import os import re from collections import defaultdict +from cStringIO import StringIO from fs.osfs import OSFS from importlib import import_module from lxml import etree from lxml.html import HtmlComment from path import path + from xmodule.errortracker import ErrorLog, make_error_tracker -from xmodule.x_module import XModuleDescriptor, XMLParsingSystem from xmodule.course_module import CourseDescriptor from xmodule.mako_module import MakoDescriptorSystem -from cStringIO import StringIO +from xmodule.x_module import XModuleDescriptor, XMLParsingSystem from . import ModuleStoreBase, Location from .exceptions import ItemNotFoundError @@ -134,12 +135,12 @@ class XMLModuleStore(ModuleStoreBase): self.data_dir = path(data_dir) self.modules = defaultdict(dict) # course_id -> dict(location -> XModuleDescriptor) self.courses = {} # course_dir -> XModuleDescriptor for the course + self.errored_courses = {} # course_dir -> errorlog, for dirs that failed to load if default_class is None: self.default_class = None else: module_path, _, class_name = default_class.rpartition('.') - #log.debug('module_path = %s' % module_path) class_ = getattr(import_module(module_path), class_name) self.default_class = class_ @@ -162,18 +163,27 @@ class XMLModuleStore(ModuleStoreBase): ''' Load a course, keeping track of errors as we go along. ''' + # Special-case code here, since we don't have a location for the + # course before it loads. + # So, make a tracker to track load-time errors, then put in the right + # place after the course loads and we have its location + errorlog = make_error_tracker() + course_descriptor = None try: - # Special-case code here, since we don't have a location for the - # course before it loads. - # So, make a tracker to track load-time errors, then put in the right - # place after the course loads and we have its location - errorlog = make_error_tracker() course_descriptor = self.load_course(course_dir, errorlog.tracker) + except Exception as e: + msg = "Failed to load course '{0}': {1}".format(course_dir, str(e)) + log.exception(msg) + errorlog.tracker(msg) + + if course_descriptor is not None: self.courses[course_dir] = course_descriptor self._location_errors[course_descriptor.location] = errorlog - except: - msg = "Failed to load course '%s'" % course_dir - log.exception(msg) + else: + # Didn't load course. Instead, save the errors elsewhere. + self.errored_courses[course_dir] = errorlog + + def __unicode__(self): ''' @@ -202,6 +212,28 @@ class XMLModuleStore(ModuleStoreBase): return {} + def read_grading_policy(self, paths, tracker): + """Load a grading policy from the specified paths, in order, if it exists.""" + # Default to a blank policy + policy_str = "" + + for policy_path in paths: + if not os.path.exists(policy_path): + continue + log.debug("Loading grading policy from {0}".format(policy_path)) + try: + with open(policy_path) as grading_policy_file: + policy_str = grading_policy_file.read() + # if we successfully read the file, stop looking at backups + break + except (IOError): + msg = "Unable to load course settings file from '{0}'".format(policy_path) + tracker(msg) + log.warning(msg) + + return policy_str + + def load_course(self, course_dir, tracker): """ Load a course into this module store @@ -242,9 +274,16 @@ class XMLModuleStore(ModuleStoreBase): course = course_dir url_name = course_data.get('url_name', course_data.get('slug')) + policy_dir = None if url_name: - policy_path = self.data_dir / course_dir / 'policies' / '{0}.json'.format(url_name) + policy_dir = self.data_dir / course_dir / 'policies' / url_name + policy_path = policy_dir / 'policy.json' policy = self.load_policy(policy_path, tracker) + + # VS[compat]: remove once courses use the policy dirs. + if policy == {}: + old_policy_path = self.data_dir / course_dir / 'policies' / '{0}.json'.format(url_name) + policy = self.load_policy(old_policy_path, tracker) else: policy = {} # VS[compat] : 'name' is deprecated, but support it for now... @@ -268,6 +307,15 @@ class XMLModuleStore(ModuleStoreBase): # after we have the course descriptor. XModuleDescriptor.compute_inherited_metadata(course_descriptor) + # Try to load grading policy + paths = [self.data_dir / course_dir / 'grading_policy.json'] + if policy_dir: + paths = [policy_dir / 'grading_policy.json'] + paths + + policy_str = self.read_grading_policy(paths, tracker) + course_descriptor.set_grading_policy(policy_str) + + log.debug('========> Done with course import from {0}'.format(course_dir)) return course_descriptor @@ -318,6 +366,12 @@ class XMLModuleStore(ModuleStoreBase): """ return self.courses.values() + def get_errored_courses(self): + """ + Return a dictionary of course_dir -> [(msg, exception_str)], for each + course_dir where course loading failed. + """ + return dict( (k, self.errored_courses[k].errors) for k in self.errored_courses) def create_item(self, location): raise NotImplementedError("XMLModuleStores are read-only") diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index 34e4767a62..3454366c1a 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -233,6 +233,9 @@ class ImportTestCase(unittest.TestCase): self.assertEqual(toy_ch.display_name, "Overview") self.assertEqual(two_toys_ch.display_name, "Two Toy Overview") + # Also check that the grading policy loaded + self.assertEqual(two_toys.grade_cutoffs['C'], 0.5999) + def test_definition_loading(self): """When two courses share the same org and course name and diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index c7042efda2..b6f791ffc6 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -38,7 +38,7 @@ def get_metadata_from_xml(xml_object, remove=True): if meta is None: return '' dmdata = meta.text - log.debug('meta for %s loaded: %s' % (xml_object,dmdata)) + #log.debug('meta for %s loaded: %s' % (xml_object,dmdata)) if remove: xml_object.remove(meta) return dmdata diff --git a/common/test/data/test_start_date/README.md b/common/test/data/test_start_date/README.md new file mode 100644 index 0000000000..0810107838 --- /dev/null +++ b/common/test/data/test_start_date/README.md @@ -0,0 +1 @@ +Simple course. If start dates are on, non-staff users should see Overview, but not Ch 2. diff --git a/common/test/data/test_start_date/course.xml b/common/test/data/test_start_date/course.xml new file mode 120000 index 0000000000..49041310f6 --- /dev/null +++ b/common/test/data/test_start_date/course.xml @@ -0,0 +1 @@ +roots/2012_Fall.xml \ No newline at end of file diff --git a/common/test/data/test_start_date/course/2012_Fall.xml b/common/test/data/test_start_date/course/2012_Fall.xml new file mode 100644 index 0000000000..77eca9f46c --- /dev/null +++ b/common/test/data/test_start_date/course/2012_Fall.xml @@ -0,0 +1,15 @@ + + + + + + + + +

Welcome

+ +
+ +
diff --git a/common/test/data/test_start_date/html/toylab.html b/common/test/data/test_start_date/html/toylab.html new file mode 100644 index 0000000000..81df84bd63 --- /dev/null +++ b/common/test/data/test_start_date/html/toylab.html @@ -0,0 +1,3 @@ +Lab 2A: Superposition Experiment + +

Isn't the toy course great?

diff --git a/common/test/data/test_start_date/html/toylab.xml b/common/test/data/test_start_date/html/toylab.xml new file mode 100644 index 0000000000..ab78aeb494 --- /dev/null +++ b/common/test/data/test_start_date/html/toylab.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/common/test/data/test_start_date/policies/2012_Fall.json b/common/test/data/test_start_date/policies/2012_Fall.json new file mode 100644 index 0000000000..a12ccecf1c --- /dev/null +++ b/common/test/data/test_start_date/policies/2012_Fall.json @@ -0,0 +1,27 @@ +{ + "course/2012_Fall": { + "graceperiod": "2 days 5 hours 59 minutes 59 seconds", + "start": "2011-07-17T12:00", + "display_name": "Toy Course" + }, + "chapter/Overview": { + "display_name": "Overview" + }, + "chapter/Ch2": { + "display_name": "Chapter 2", + "start": "2015-07-17T12:00" + }, + "videosequence/Toy_Videos": { + "display_name": "Toy Videos", + "format": "Lecture Sequence" + }, + "html/toylab": { + "display_name": "Toy lab" + }, + "video/Video_Resources": { + "display_name": "Video Resources" + }, + "video/Welcome": { + "display_name": "Welcome" + } +} diff --git a/common/test/data/test_start_date/roots/2012_Fall.xml b/common/test/data/test_start_date/roots/2012_Fall.xml new file mode 100644 index 0000000000..30dd5e0180 --- /dev/null +++ b/common/test/data/test_start_date/roots/2012_Fall.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/common/test/data/toy/html/toylab.xml b/common/test/data/toy/html/toylab.xml new file mode 100644 index 0000000000..ab78aeb494 --- /dev/null +++ b/common/test/data/toy/html/toylab.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/common/test/data/two_toys/policies/TT_2012_Fall/grading_policy.json b/common/test/data/two_toys/policies/TT_2012_Fall/grading_policy.json new file mode 100644 index 0000000000..13942c1715 --- /dev/null +++ b/common/test/data/two_toys/policies/TT_2012_Fall/grading_policy.json @@ -0,0 +1,35 @@ +{ + "GRADER" : [ + { + "type" : "Homework", + "min_count" : 12, + "drop_count" : 2, + "short_label" : "HW", + "weight" : 0.15 + }, + { + "type" : "Lab", + "min_count" : 12, + "drop_count" : 2, + "category" : "Labs", + "weight" : 0.15 + }, + { + "type" : "Midterm", + "name" : "Midterm Exam", + "short_label" : "Midterm", + "weight" : 0.3 + }, + { + "type" : "Final", + "name" : "Final Exam", + "short_label" : "Final", + "weight" : 0.4 + } + ], + "GRADE_CUTOFFS" : { + "A" : 0.87, + "B" : 0.7, + "C" : 0.5999 + } +} diff --git a/common/test/data/two_toys/policies/TT_2012_Fall.json b/common/test/data/two_toys/policies/TT_2012_Fall/policy.json similarity index 100% rename from common/test/data/two_toys/policies/TT_2012_Fall.json rename to common/test/data/two_toys/policies/TT_2012_Fall/policy.json diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index eaf70d7814..281580cf33 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -63,6 +63,9 @@ def has_access(user, obj, action): if isinstance(obj, Location): return _has_access_location(user, obj, action) + if isinstance(obj, basestring): + return _has_access_string(user, obj, action) + # Passing an unknown object here is a coding error, so rather than # returning a default, complain. raise TypeError("Unknown object type in has_access(): '{0}'" @@ -238,6 +241,30 @@ def _has_access_location(user, location, action): return _dispatch(checkers, action, user, location) +def _has_access_string(user, perm, action): + """ + Check if user has certain special access, specified as string. Valid strings: + + 'global' + + Valid actions: + + 'staff' -- global staff access. + """ + + def check_staff(): + if perm != 'global': + debug("Deny: invalid permission '%s'", perm) + return False + return _has_global_staff_access(user) + + checkers = { + 'staff': check_staff + } + + return _dispatch(checkers, action, user, perm) + + ##### Internal helper methods below def _dispatch(table, action, user, obj): @@ -266,6 +293,15 @@ def _course_staff_group_name(location): """ return 'staff_%s' % Location(location).course +def _has_global_staff_access(user): + if user.is_staff: + debug("Allow: user.is_staff") + return True + else: + debug("Deny: not user.is_staff") + return False + + def _has_staff_access_to_location(user, location): ''' Returns True if the given user has staff access to a location. For now this diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index b4407b7f93..c92cbb1425 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -30,7 +30,6 @@ def get_course_by_id(course_id): raise Http404("Course not found.") - def get_course_with_access(user, course_id, action): """ Given a course_id, look up the corresponding course descriptor, @@ -142,6 +141,35 @@ def get_course_info_section(course, section_key): raise KeyError("Invalid about key " + str(section_key)) +# TODO: Fix this such that these are pulled in as extra course-specific tabs. +# arjun will address this by the end of October if no one does so prior to +# then. +def get_course_syllabus_section(course, section_key): + """ + This returns the snippet of html to be rendered on the syllabus page, + given the key for the section. + + Valid keys: + - syllabus + - guest_syllabus + """ + + # Many of these are stored as html files instead of some semantic + # markup. This can change without effecting this interface when we find a + # good format for defining so many snippets of text/html. + + if section_key in ['syllabus', 'guest_syllabus']: + try: + with course.system.resources_fs.open(path("syllabus") / section_key + ".html") as htmlFile: + return replace_urls(htmlFile.read().decode('utf-8'), + course.metadata['data_dir']) + except ResourceNotFoundError: + log.exception("Missing syllabus section {key} in course {url}".format( + key=section_key, url=course.location.url())) + return "! Syllabus missing !" + + raise KeyError("Invalid about key " + str(section_key)) + def get_courses_by_university(user, domain=None): ''' diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 3d8d8b0ebe..1af3ab1bda 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -29,6 +29,8 @@ def grade(student, request, course, student_module_cache=None): output from the course grader, augmented with the final letter grade. The keys in the output are: + course: a CourseDescriptor + - grade : A final letter grade. - percent : The final percent for the class (rounded up). - section_breakdown : A breakdown of each section that makes @@ -42,7 +44,7 @@ def grade(student, request, course, student_module_cache=None): grading_context = course.grading_context if student_module_cache == None: - student_module_cache = StudentModuleCache(student, grading_context['all_descriptors']) + student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors']) totaled_scores = {} # This next complicated loop is just to collect the totaled_scores, which is @@ -56,7 +58,8 @@ def grade(student, request, course, student_module_cache=None): should_grade_section = False # If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0% for moduledescriptor in section['xmoduledescriptors']: - if student_module_cache.lookup(moduledescriptor.category, moduledescriptor.location.url() ): + if student_module_cache.lookup( + course.id, moduledescriptor.category, moduledescriptor.location.url()): should_grade_section = True break @@ -64,10 +67,9 @@ def grade(student, request, course, student_module_cache=None): scores = [] # TODO: We need the request to pass into here. If we could forgo that, our arguments # would be simpler - course_id = CourseDescriptor.location_to_id(course.location) section_module = get_module(student, request, section_descriptor.location, student_module_cache, - course_id) + course.id) if section_module is None: # student doesn't have access to this module, or something else # went wrong. @@ -76,7 +78,7 @@ def grade(student, request, course, student_module_cache=None): # TODO: We may be able to speed this up by only getting a list of children IDs from section_module # Then, we may not need to instatiate any problems if they are already in the database for module in yield_module_descendents(section_module): - (correct, total) = get_score(student, module, student_module_cache) + (correct, total) = get_score(course.id, student, module, student_module_cache) if correct is None and total is None: continue @@ -171,7 +173,9 @@ def progress_summary(student, course, grader, student_module_cache): graded = s.metadata.get('graded', False) scores = [] for module in yield_module_descendents(s): - (correct, total) = get_score(student, module, student_module_cache) + # course is a module, not a descriptor... + course_id = course.descriptor.id + (correct, total) = get_score(course_id, student, module, student_module_cache) if correct is None and total is None: continue @@ -200,7 +204,7 @@ def progress_summary(student, course, grader, student_module_cache): return chapters -def get_score(user, problem, student_module_cache): +def get_score(course_id, user, problem, student_module_cache): """ Return the score for a user on a problem, as a tuple (correct, total). @@ -215,10 +219,11 @@ def get_score(user, problem, student_module_cache): correct = 0.0 # If the ID is not in the cache, add the item - instance_module = get_instance_module(user, problem, student_module_cache) + instance_module = get_instance_module(course_id, user, problem, student_module_cache) # instance_module = student_module_cache.lookup(problem.category, problem.id) # if instance_module is None: # instance_module = StudentModule(module_type=problem.category, + # course_id=????, # module_state_key=problem.id, # student=user, # state=None, diff --git a/lms/djangoapps/courseware/management/commands/check_course.py b/lms/djangoapps/courseware/management/commands/check_course.py index 4b44581c3f..adb8bff709 100644 --- a/lms/djangoapps/courseware/management/commands/check_course.py +++ b/lms/djangoapps/courseware/management/commands/check_course.py @@ -84,6 +84,7 @@ class Command(BaseCommand): # TODO (cpennington): Get coursename in a legitimate way course_location = 'i4x://edx/6002xs12/course/6.002_Spring_2012' student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( + course_id, sample_user, modulestore().get_item(course_location)) course = get_module(sample_user, None, course_location, student_module_cache) diff --git a/lms/djangoapps/courseware/migrations/0003_done_grade_cache.py b/lms/djangoapps/courseware/migrations/0003_done_grade_cache.py index 96b320bc8f..0ab35551c9 100644 --- a/lms/djangoapps/courseware/migrations/0003_done_grade_cache.py +++ b/lms/djangoapps/courseware/migrations/0003_done_grade_cache.py @@ -9,6 +9,8 @@ class Migration(SchemaMigration): def forwards(self, orm): + # NOTE (vshnayder): This constraint has the wrong field order, so it doesn't actually + # do anything in sqlite. Migration 0004 actually removes this index for sqlite. # Removing unique constraint on 'StudentModule', fields ['module_id', 'module_type', 'student'] db.delete_unique('courseware_studentmodule', ['module_id', 'module_type', 'student_id']) diff --git a/lms/djangoapps/courseware/migrations/0004_add_field_studentmodule_course_id.py b/lms/djangoapps/courseware/migrations/0004_add_field_studentmodule_course_id.py new file mode 100644 index 0000000000..ff79901824 --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0004_add_field_studentmodule_course_id.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'StudentModule.course_id' + db.add_column('courseware_studentmodule', 'course_id', + self.gf('django.db.models.fields.CharField')(default="", max_length=255, db_index=True), + keep_default=False) + + # Removing unique constraint on 'StudentModule', fields ['module_id', 'student'] + db.delete_unique('courseware_studentmodule', ['module_id', 'student_id']) + + # NOTE: manually remove this constaint (from 0001)--0003 tries, but fails for sqlite. + # Removing unique constraint on 'StudentModule', fields ['module_id', 'module_type', 'student'] + if db.backend_name == "sqlite3": + db.delete_unique('courseware_studentmodule', ['student_id', 'module_id', 'module_type']) + + # Adding unique constraint on 'StudentModule', fields ['course_id', 'module_state_key', 'student'] + db.create_unique('courseware_studentmodule', ['student_id', 'module_id', 'course_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'StudentModule', fields ['studnet_id', 'module_state_key', 'course_id'] + db.delete_unique('courseware_studentmodule', ['student_id', 'module_id', 'course_id']) + + # Deleting field 'StudentModule.course_id' + db.delete_column('courseware_studentmodule', 'course_id') + + # Adding unique constraint on 'StudentModule', fields ['module_id', 'student'] + db.create_unique('courseware_studentmodule', ['module_id', 'student_id']) + + # Adding unique constraint on 'StudentModule', fields ['module_id', 'module_type', 'student'] + db.create_unique('courseware_studentmodule', ['student_id', 'module_id', 'module_type']) + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'about': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'avatar_type': ('django.db.models.fields.CharField', [], {'default': "'n'", 'max_length': '1'}), + 'bronze': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'consecutive_days_visit_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}), + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'date_of_birth': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}), + 'display_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'email_isvalid': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'email_key': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}), + 'email_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'gold': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'gravatar': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ignored_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'interesting_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'last_seen': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'location': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'new_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'questions_per_page': ('django.db.models.fields.SmallIntegerField', [], {'default': '10'}), + 'real_name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}), + 'reputation': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1'}), + 'seen_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'show_country': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'silver': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'w'", 'max_length': '2'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}), + 'website': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'courseware.studentmodule': { + 'Meta': {'unique_together': "(('course_id', 'student', 'module_state_key'),)", 'object_name': 'StudentModule'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['courseware'] diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 4389a5f169..393cb0918b 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -22,6 +22,9 @@ from django.contrib.auth.models import User class StudentModule(models.Model): + """ + Keeps student state for a particular module in a particular course. + """ # For a homework problem, contains a JSON # object consisting of state MODULE_TYPES = (('problem', 'problem'), @@ -37,9 +40,10 @@ class StudentModule(models.Model): # Filename for homeworks, etc. module_state_key = models.CharField(max_length=255, db_index=True, db_column='module_id') student = models.ForeignKey(User, db_index=True) + course_id = models.CharField(max_length=255, db_index=True) class Meta: - unique_together = (('student', 'module_state_key'),) + unique_together = (('student', 'module_state_key', 'course_id'),) ## Internal state of the object state = models.TextField(null=True, blank=True) @@ -57,7 +61,8 @@ class StudentModule(models.Model): modified = models.DateTimeField(auto_now=True, db_index=True) def __unicode__(self): - return '/'.join([self.module_type, self.student.username, self.module_state_key, str(self.state)[:20]]) + return '/'.join([self.course_id, self.module_type, + self.student.username, self.module_state_key, str(self.state)[:20]]) # TODO (cpennington): Remove these once the LMS switches to using XModuleDescriptors @@ -67,20 +72,20 @@ class StudentModuleCache(object): """ A cache of StudentModules for a specific student """ - def __init__(self, user, descriptors, select_for_update=False): + def __init__(self, course_id, user, descriptors, select_for_update=False): ''' Find any StudentModule objects that are needed by any descriptor in descriptors. Avoids making multiple queries to the database. Note: Only modules that have store_state = True or have shared state will have a StudentModule. - + Arguments user: The user for which to fetch maching StudentModules descriptors: An array of XModuleDescriptors. select_for_update: Flag indicating whether the rows should be locked until end of transaction ''' if user.is_authenticated(): - module_ids = self._get_module_state_keys(descriptors) + module_ids = self._get_module_state_keys(descriptors) # This works around a limitation in sqlite3 on the number of parameters # that can be put into a single query @@ -89,78 +94,86 @@ class StudentModuleCache(object): for id_chunk in [module_ids[i:i + chunk_size] for i in xrange(0, len(module_ids), chunk_size)]: if select_for_update: self.cache.extend(StudentModule.objects.select_for_update().filter( + course_id=course_id, student=user, module_state_key__in=id_chunk) ) else: self.cache.extend(StudentModule.objects.filter( + course_id=course_id, student=user, module_state_key__in=id_chunk) ) else: self.cache = [] - - + + @classmethod - def cache_for_descriptor_descendents(cls, user, descriptor, depth=None, descriptor_filter=lambda descriptor: True, select_for_update=False): + def cache_for_descriptor_descendents(cls, course_id, user, descriptor, depth=None, + descriptor_filter=lambda descriptor: True, + select_for_update=False): """ + course_id: the course in the context of which we want StudentModules. + user: the django user for whom to load modules. descriptor: An XModuleDescriptor depth is the number of levels of descendent modules to load StudentModules for, in addition to the supplied descriptor. If depth is None, load all descendent StudentModules - descriptor_filter is a function that accepts a descriptor and return wether the StudentModule + descriptor_filter is a function that accepts a descriptor and return wether the StudentModule should be cached select_for_update: Flag indicating whether the rows should be locked until end of transaction """ - + def get_child_descriptors(descriptor, depth, descriptor_filter): if descriptor_filter(descriptor): descriptors = [descriptor] else: descriptors = [] - + if depth is None or depth > 0: new_depth = depth - 1 if depth is not None else depth for child in descriptor.get_children(): descriptors.extend(get_child_descriptors(child, new_depth, descriptor_filter)) - + return descriptors - - + + descriptors = get_child_descriptors(descriptor, depth, descriptor_filter) - - return StudentModuleCache(user, descriptors, select_for_update) - + + return StudentModuleCache(course_id, user, descriptors, select_for_update) + def _get_module_state_keys(self, descriptors): ''' Get a list of the state_keys needed for StudentModules required for this module descriptor - - descriptor_filter is a function that accepts a descriptor and return wether the StudentModule + + descriptor_filter is a function that accepts a descriptor and return wether the StudentModule should be cached ''' keys = [] for descriptor in descriptors: if descriptor.stores_state: keys.append(descriptor.location.url()) - + shared_state_key = getattr(descriptor, 'shared_state_key', None) if shared_state_key is not None: keys.append(shared_state_key) return keys - def lookup(self, module_type, module_state_key): + def lookup(self, course_id, module_type, module_state_key): ''' - Look for a student module with the given type and id in the cache. + Look for a student module with the given course_id, type, and id in the cache. cache -- list of student modules returns first found object, or None ''' for o in self.cache: - if o.module_type == module_type and o.module_state_key == module_state_key: + if (o.course_id == course_id and + o.module_type == module_type and + o.module_state_key == module_state_key): return o return None diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index fbd43b8247..a61e8b9e23 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -70,7 +70,8 @@ def toc_for_course(user, request, course, active_chapter, active_section, course None if this is not the case. ''' - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(user, course, depth=2) + student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( + course_id, user, course, depth=2) course = get_module(user, request, course.location, student_module_cache, course_id) chapters = list() @@ -159,12 +160,13 @@ def get_module(user, request, location, student_module_cache, course_id, positio shared_module = None if user.is_authenticated(): if descriptor.stores_state: - instance_module = student_module_cache.lookup(descriptor.category, - descriptor.location.url()) + instance_module = student_module_cache.lookup( + course_id, descriptor.category, descriptor.location.url()) shared_state_key = getattr(descriptor, 'shared_state_key', None) if shared_state_key is not None: - shared_module = student_module_cache.lookup(descriptor.category, + shared_module = student_module_cache.lookup(course_id, + descriptor.category, shared_state_key) @@ -241,7 +243,7 @@ def get_module(user, request, location, student_module_cache, course_id, positio return module -def get_instance_module(user, module, student_module_cache): +def get_instance_module(course_id, user, module, student_module_cache): """ Returns instance_module is a StudentModule specific to this module for this student, or None if this is an anonymous user @@ -252,11 +254,12 @@ def get_instance_module(user, module, student_module_cache): + str(module.id) + " which does not store state.") return None - instance_module = student_module_cache.lookup(module.category, - module.location.url()) + instance_module = student_module_cache.lookup( + course_id, module.category, module.location.url()) if not instance_module: instance_module = StudentModule( + course_id=course_id, student=user, module_type=module.category, module_state_key=module.id, @@ -285,6 +288,7 @@ def get_shared_instance_module(course_id, user, module, student_module_cache): shared_state_key) if not shared_module: shared_module = StudentModule( + course_id=course_id, student=user, module_type=descriptor.category, module_state_key=shared_state_key, @@ -317,14 +321,14 @@ def xqueue_callback(request, course_id, userid, id, dispatch): # Retrieve target StudentModule user = User.objects.get(id=userid) - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( + student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, user, modulestore().get_instance(course_id, id), depth=0, select_for_update=True) instance = get_module(user, request, id, student_module_cache, course_id) if instance is None: log.debug("No module {0} for user {1}--access denied?".format(id, user)) raise Http404 - instance_module = get_instance_module(user, instance, student_module_cache) + instance_module = get_instance_module(course_id, user, instance, student_module_cache) if instance_module is None: log.debug("Couldn't find instance of module '%s' for user '%s'", id, user) @@ -387,7 +391,7 @@ def modx_dispatch(request, dispatch, location, course_id): return HttpResponse(json.dumps({'success': file_too_big_msg})) p[fileinput_id] = inputfiles - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( + student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, request.user, modulestore().get_instance(course_id, location)) instance = get_module(request.user, request, location, student_module_cache, course_id) @@ -397,7 +401,7 @@ def modx_dispatch(request, dispatch, location, course_id): log.debug("No module {0} for user {1}--access denied?".format(location, user)) raise Http404 - instance_module = get_instance_module(request.user, instance, student_module_cache) + instance_module = get_instance_module(course_id, request.user, instance, student_module_cache) shared_module = get_shared_instance_module(course_id, request.user, instance, student_module_cache) # Don't track state for anonymous users (who don't have student modules) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 583628d1f2..98444c176d 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -149,8 +149,7 @@ def index(request, course_id, chapter=None, section=None, section_descriptor = get_section(course, chapter, section) if section_descriptor is not None: student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( - request.user, - section_descriptor) + course_id, request.user, section_descriptor) module = get_module(request.user, request, section_descriptor.location, student_module_cache, course_id) @@ -233,6 +232,19 @@ def course_info(request, course_id): return render_to_response('courseware/info.html', {'course': course, 'staff_access': staff_access,}) +# TODO arjun: remove when custom tabs in place, see courseware/syllabus.py +@ensure_csrf_cookie +def syllabus(request, course_id): + """ + Display the course's syllabus.html, or 404 if there is no such course. + + Assumes the course_id is in a valid format. + """ + course = get_course_with_access(request.user, course_id, 'load') + staff_access = has_access(request.user, course, 'staff') + + return render_to_response('courseware/syllabus.html', {'course': course, + 'staff_access': staff_access,}) def registered_for_course(course, user): '''Return CourseEnrollment if user is registered for course, else False''' @@ -310,7 +322,8 @@ def progress(request, course_id, student_id=None): raise Http404 student = User.objects.get(id=int(student_id)) - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(request.user, course) + student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( + course_id, request.user, course) course_module = get_module(request.user, request, course.location, student_module_cache, course_id) diff --git a/lms/djangoapps/lms_migration/management/__init__.py b/lms/djangoapps/lms_migration/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/lms_migration/management/commands/__init__.py b/lms/djangoapps/lms_migration/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/lms_migration/management/commands/create_groups.py b/lms/djangoapps/lms_migration/management/commands/create_groups.py new file mode 100644 index 0000000000..7b52795606 --- /dev/null +++ b/lms/djangoapps/lms_migration/management/commands/create_groups.py @@ -0,0 +1,58 @@ +#!/usr/bin/python +# +# File: create_groups.py +# +# Create all staff_* groups for classes in data directory. + +import os, sys, string, re + +from django.core.management.base import BaseCommand +from django.conf import settings +from django.contrib.auth.models import User, Group +from path import path +from lxml import etree + +def create_groups(): + ''' + Create staff and instructor groups for all classes in the data_dir + ''' + + data_dir = settings.DATA_DIR + print "data_dir = %s" % data_dir + + for course_dir in os.listdir(data_dir): + + if course_dir.startswith('.'): + continue + if not os.path.isdir(path(data_dir) / course_dir): + continue + + cxfn = path(data_dir) / course_dir / 'course.xml' + try: + coursexml = etree.parse(cxfn) + except Exception as err: + print "Oops, cannot read %s, skipping" % cxfn + continue + cxmlroot = coursexml.getroot() + course = cxmlroot.get('course') # TODO (vshnayder!!): read metadata from policy file(s) instead of from course.xml + if course is None: + print "oops, can't get course id for %s" % course_dir + continue + print "course=%s for course_dir=%s" % (course,course_dir) + + create_group('staff_%s' % course) # staff group + create_group('instructor_%s' % course) # instructor group (can manage staff group list) + +def create_group(gname): + if Group.objects.filter(name=gname): + print " group exists for %s" % gname + return + g = Group(name=gname) + g.save() + print " created group %s" % gname + +class Command(BaseCommand): + help = "Create groups associated with all courses in data_dir." + + def handle(self, *args, **options): + create_groups() diff --git a/lms/djangoapps/lms_migration/management/commands/create_user.py b/lms/djangoapps/lms_migration/management/commands/create_user.py new file mode 100644 index 0000000000..333608d467 --- /dev/null +++ b/lms/djangoapps/lms_migration/management/commands/create_user.py @@ -0,0 +1,146 @@ +#!/usr/bin/python +# +# File: create_user.py +# +# Create user. Prompt for groups and ExternalAuthMap + +import os, sys, string, re +import datetime +from getpass import getpass +import json +from random import choice +import readline + +from django.core.management.base import BaseCommand +from student.models import UserProfile, Registration +from external_auth.models import ExternalAuthMap +from django.contrib.auth.models import User, Group + +class MyCompleter(object): # Custom completer + + def __init__(self, options): + self.options = sorted(options) + + def complete(self, text, state): + if state == 0: # on first trigger, build possible matches + if text: # cache matches (entries that start with entered text) + self.matches = [s for s in self.options + if s and s.startswith(text)] + else: # no text entered, all matches possible + self.matches = self.options[:] + + # return match indexed by state + try: + return self.matches[state] + except IndexError: + return None + +def GenPasswd(length=8, chars=string.letters + string.digits): + return ''.join([choice(chars) for i in range(length)]) + +#----------------------------------------------------------------------------- +# main command + +class Command(BaseCommand): + help = "Create user, interactively; can add ExternalAuthMap for MIT user if email@MIT.EDU resolves properly." + + def handle(self, *args, **options): + + while True: + uname = raw_input('username: ') + if User.objects.filter(username=uname): + print "username %s already taken" % uname + else: + break + + make_eamap = False + if raw_input('Create MIT ExternalAuth? [n] ').lower()=='y': + email = '%s@MIT.EDU' % uname + if not email.endswith('@MIT.EDU'): + print "Failed - email must be @MIT.EDU" + sys.exit(-1) + mit_domain = 'ssl:MIT' + if ExternalAuthMap.objects.filter(external_id = email, external_domain = mit_domain): + print "Failed - email %s already exists as external_id" % email + sys.exit(-1) + make_eamap = True + password = GenPasswd(12) + + # get name from kerberos + kname = os.popen("finger %s | grep 'name:'" % email).read().strip().split('name: ')[1].strip() + name = raw_input('Full name: [%s] ' % kname).strip() + if name=='': + name = kname + print "name = %s" % name + else: + while True: + password = getpass() + password2 = getpass() + if password == password2: + break + print "Oops, passwords do not match, please retry" + + while True: + email = raw_input('email: ') + if User.objects.filter(email=email): + print "email %s already taken" % email + else: + break + + name = raw_input('Full name: ') + + + user = User(username=uname, email=email, is_active=True) + user.set_password(password) + try: + user.save() + except IntegrityError: + print "Oops, failed to create user %s, IntegrityError" % user + raise + + r = Registration() + r.register(user) + + up = UserProfile(user=user) + up.name = name + up.save() + + if make_eamap: + credentials = "/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN=%s/emailAddress=%s" % (name,email) + eamap = ExternalAuthMap(external_id = email, + external_email = email, + external_domain = mit_domain, + external_name = name, + internal_password = password, + external_credentials = json.dumps(credentials), + ) + eamap.user = user + eamap.dtsignup = datetime.datetime.now() + eamap.save() + + print "User %s created successfully!" % user + + if not raw_input('Add user %s to any groups? [n] ' % user).lower()=='y': + sys.exit(0) + + print "Here are the groups available:" + + groups = [str(g.name) for g in Group.objects.all()] + print groups + + completer = MyCompleter(groups) + readline.set_completer(completer.complete) + readline.parse_and_bind('tab: complete') + + while True: + gname = raw_input("Add group (tab to autocomplete, empty line to end): ") + if not gname: + break + if not gname in groups: + print "Unknown group %s" % gname + continue + g = Group.objects.get(name=gname) + user.groups.add(g) + print "Added %s to group %s" % (user,g) + + print "Done!" diff --git a/lms/djangoapps/lms_migration/migrate.py b/lms/djangoapps/lms_migration/migrate.py index dfdf86b4ac..a7d04a655d 100644 --- a/lms/djangoapps/lms_migration/migrate.py +++ b/lms/djangoapps/lms_migration/migrate.py @@ -2,13 +2,21 @@ # migration tools for content team to go from stable-edx4edx to LMS+CMS # +import json import logging +import os from pprint import pprint import xmodule.modulestore.django as xmodule_django from xmodule.modulestore.django import modulestore from django.http import HttpResponse from django.conf import settings +import track.views + +try: + from django.views.decorators.csrf import csrf_exempt +except ImportError: + from django.contrib.csrf.middleware import csrf_exempt log = logging.getLogger("mitx.lms_migrate") LOCAL_DEBUG = True @@ -18,6 +26,15 @@ def escape(s): """escape HTML special characters in string""" return str(s).replace('<','<').replace('>','>') +def getip(request): + ''' + Extract IP address of requester from header, even if behind proxy + ''' + ip = request.META.get('HTTP_X_REAL_IP','') # nginx reverse proxy + if not ip: + ip = request.META.get('REMOTE_ADDR','None') + return ip + def manage_modulestores(request,reload_dir=None): ''' Manage the static in-memory modulestores. @@ -32,9 +49,7 @@ def manage_modulestores(request,reload_dir=None): #---------------------------------------- # check on IP address of requester - ip = request.META.get('HTTP_X_REAL_IP','') # nginx reverse proxy - if not ip: - ip = request.META.get('REMOTE_ADDR','None') + ip = getip(request) if LOCAL_DEBUG: html += '

IP address: %s ' % ip @@ -48,7 +63,7 @@ def manage_modulestores(request,reload_dir=None): html += 'Permission denied' html += "" log.debug('request denied, ALLOWED_IPS=%s' % ALLOWED_IPS) - return HttpResponse(html) + return HttpResponse(html, status=403) #---------------------------------------- # reload course if specified @@ -108,3 +123,66 @@ def manage_modulestores(request,reload_dir=None): html += "" return HttpResponse(html) + +@csrf_exempt +def gitreload(request, reload_dir=None): + ''' + This can be used as a github WebHook Service Hook, for reloading of the content repo used by the LMS. + + If reload_dir is not None, then instruct the xml loader to reload that course directory. + ''' + html = "" + ip = getip(request) + + html += '

IP address: %s ' % ip + html += '

User: %s ' % request.user + + ALLOWED_IPS = [] # allow none by default + if hasattr(settings,'ALLOWED_GITRELOAD_IPS'): # allow override in settings + ALLOWED_IPS = ALLOWED_GITRELOAD_IPS + + if not (ip in ALLOWED_IPS or 'any' in ALLOWED_IPS): + if request.user and request.user.is_staff: + log.debug('request allowed because user=%s is staff' % request.user) + else: + html += 'Permission denied' + html += "" + log.debug('request denied from %s, ALLOWED_IPS=%s' % (ip,ALLOWED_IPS)) + return HttpResponse(html) + + #---------------------------------------- + # see if request is from github (POST with JSON) + + if reload_dir is None and 'payload' in request.POST: + payload = request.POST['payload'] + log.debug("payload=%s" % payload) + gitargs = json.loads(payload) + log.debug("gitargs=%s" % gitargs) + reload_dir = gitargs['repository']['name'] + log.debug("github reload_dir=%s" % reload_dir) + gdir = settings.DATA_DIR / reload_dir + if not os.path.exists(gdir): + log.debug("====> ERROR in gitreload - no such directory %s" % reload_dir) + return HttpResponse('Error') + cmd = "cd %s; git reset --hard HEAD; git clean -f -d; git pull origin; chmod g+w course.xml" % gdir + log.debug(os.popen(cmd).read()) + if hasattr(settings,'GITRELOAD_HOOK'): # hit this hook after reload, if set + gh = settings.GITRELOAD_HOOK + if gh: + ghurl = '%s/%s' % (gh,reload_dir) + r = requests.get(ghurl) + log.debug("GITRELOAD_HOOK to %s: %s" % (ghurl, r.text)) + + #---------------------------------------- + # reload course if specified + + if reload_dir is not None: + def_ms = modulestore() + if reload_dir not in def_ms.courses: + html += "

Error: '%s' is not a valid course directory

" % reload_dir + else: + html += "

Reloaded course directory '%s'

" % reload_dir + def_ms.try_load_course(reload_dir) + track.views.server_track(request, 'reloaded %s' % reload_dir, {}, page='migrate') + + return HttpResponse(html) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 059254bdff..a72ad92957 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -47,6 +47,7 @@ LOGGING = get_logger_config(LOG_DIR, syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514), debug=False) +COURSE_LISTINGS = ENV_TOKENS['COURSE_LISTINGS'] ############################## SECURE AUTH ITEMS ############################### # Secret things: passwords, access keys, etc. diff --git a/lms/envs/common.py b/lms/envs/common.py index af8a69a8eb..151bffaa32 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -55,6 +55,10 @@ MITX_FEATURES = { # course_ids (see dev_int.py for an example) 'SUBDOMAIN_COURSE_LISTINGS' : False, + # TODO: This will be removed once course-specific tabs are in place. see + # courseware/courses.py + 'ENABLE_SYLLABUS' : True, + 'ENABLE_TEXTBOOK' : True, 'ENABLE_DISCUSSION' : False, 'ENABLE_DISCUSSION_SERVICE': True, @@ -260,6 +264,14 @@ USE_L10N = True # Messages MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' +#################################### GITHUB ####################################### +# gitreload is used in LMS-workflow to pull content from github +# gitreload requests are only allowed from these IP addresses, which are +# the advertised public IPs of the github WebHook servers. +# These are listed, eg at https://github.com/MITx/mitx/admin/hooks + +ALLOWED_GITRELOAD_IPS = ['207.97.227.253', '50.57.128.197', '108.171.174.178'] + #################################### AWS ####################################### # S3BotoStorage insists on a timeout for uploaded assets. We should make it # permanent instead, but rather than trying to figure out exactly where that diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 6720c2050d..b269d293dd 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -73,6 +73,8 @@ MITX_FEATURES['ENABLE_LMS_MIGRATION'] = True MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = False # require that user be in the staff_* group to be able to enroll MITX_FEATURES['USE_XQA_SERVER'] = 'http://xqa:server@content-qa.mitx.mit.edu/xqa' +INSTALLED_APPS += ('lms_migration',) + LMS_MIGRATION_ALLOWED_IPS = ['127.0.0.1'] ################################ OpenID Auth ################################# diff --git a/lms/envs/dev_ike.py b/lms/envs/dev_ike.py index 309ea1ac42..3ae141a037 100644 --- a/lms/envs/dev_ike.py +++ b/lms/envs/dev_ike.py @@ -17,14 +17,19 @@ MITX_FEATURES['ENABLE_TEXTBOOK'] = False MITX_FEATURES['ENABLE_DISCUSSION'] = False MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = True # require that user be in the staff_* group to be able to enroll +MITX_FEATURES['DISABLE_START_DATES'] = True +# MITX_FEATURES['USE_DJANGO_PIPELINE']=False # don't recompile scss + myhost = socket.gethostname() if ('edxvm' in myhost) or ('ocw' in myhost): MITX_FEATURES['DISABLE_LOGIN_BUTTON'] = True # auto-login with MIT certificate MITX_FEATURES['USE_XQA_SERVER'] = 'https://qisx.mit.edu/xqa' # needs to be ssl or browser blocks it + MITX_FEATURES['USE_DJANGO_PIPELINE']=False # don't recompile scss if ('domU' in myhost): EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' MITX_FEATURES['REROUTE_ACTIVATION_EMAIL'] = 'ichuang@mitx.mit.edu' # nonempty string = address for all activation emails + MITX_FEATURES['USE_DJANGO_PIPELINE']=False # don't recompile scss SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') # django 1.4 for nginx ssl proxy @@ -33,4 +38,9 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') # django 1.4 fo INSTALLED_APPS = tuple([ app for app in INSTALLED_APPS if not app.startswith('debug_toolbar') ]) MIDDLEWARE_CLASSES = tuple([ mcl for mcl in MIDDLEWARE_CLASSES if not mcl.startswith('debug_toolbar') ]) -TEMPLATE_LOADERS = tuple([ app for app in TEMPLATE_LOADERS if not app.startswith('askbot') ]) +#TEMPLATE_LOADERS = tuple([ app for app in TEMPLATE_LOADERS if not app.startswith('askbot') ]) +#TEMPLATE_LOADERS = tuple([ app for app in TEMPLATE_LOADERS if not app.startswith('mitxmako') ]) +TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ) diff --git a/lms/static/sass/course.scss b/lms/static/sass/course.scss index 9f0f4eaf49..824e84fade 100644 --- a/lms/static/sass/course.scss +++ b/lms/static/sass/course.scss @@ -30,6 +30,7 @@ // pages @import "course/info"; +@import "course/syllabus"; // TODO arjun replace w/ custom tabs, see courseware/courses.py @import "course/textbook"; @import "course/profile"; @import "course/gradebook"; diff --git a/lms/static/sass/course/_syllabus.scss b/lms/static/sass/course/_syllabus.scss new file mode 100644 index 0000000000..2f2b9eedcd --- /dev/null +++ b/lms/static/sass/course/_syllabus.scss @@ -0,0 +1,64 @@ +div.syllabus { + + padding: 0px 10px; + + text-align: center; + + h1 { + @extend .top-header + } + + .notes { + width: 740px; + margin: 0px auto 10px; + } + + table { + + text-align: left; + + margin: 10px auto; + + thead { + font-weight: bold; + border-bottom: 1px solid black; + } + + tr.first { + td { + padding-top: 15px; + } + } + + td { + + vertical-align: middle; + + padding: 5px 10px; + + &.day, &.due { + white-space: nowrap; + } + + &.no_class { + text-align: center; + } + + &.important { + color: red; + } + + &.week_separator { + padding: 0px; + + hr { + margin: 10px; + } + + } + + } + + } + +} diff --git a/lms/static/sass/course/courseware/_courseware.scss b/lms/static/sass/course/courseware/_courseware.scss index 60444fb8c1..5621396679 100644 --- a/lms/static/sass/course/courseware/_courseware.scss +++ b/lms/static/sass/course/courseware/_courseware.scss @@ -58,18 +58,20 @@ div.course-wrapper { @extend h1.top-header; @include border-radius(0 4px 0 0); margin-bottom: -16px; + border-bottom: 0; h1 { margin: 0; + font-size: 1em; } h2 { float: right; - margin-right: 0; - margin-top: 8px; + margin: 12px 0 0; text-align: right; padding-right: 0; border-right: 0; + font-size: em(14, 24); } } diff --git a/lms/static/sass/multicourse/_course_about.scss b/lms/static/sass/multicourse/_course_about.scss index fcf4382109..92d19dff86 100644 --- a/lms/static/sass/multicourse/_course_about.scss +++ b/lms/static/sass/multicourse/_course_about.scss @@ -274,6 +274,17 @@ } } } + + .prerequisites, .syllabus { + ul { + li { + font: normal 1em/1.6em $serif; + } + ul { + margin: 5px 0px 10px; + } + } + } .faq { @include clearfix; diff --git a/lms/templates/courseware/course_navigation.html b/lms/templates/courseware/course_navigation.html index 904b81517b..63fec53c4c 100644 --- a/lms/templates/courseware/course_navigation.html +++ b/lms/templates/courseware/course_navigation.html @@ -19,6 +19,9 @@ def url_class(url):
  1. Courseware
  2. Course Info
  3. +% if settings.MITX_FEATURES.get('ENABLE_SYLLABUS'): +
  4. Syllabus
  5. +% endif % if user.is_authenticated(): % if settings.MITX_FEATURES.get('ENABLE_TEXTBOOK'): % for index, textbook in enumerate(course.textbooks): diff --git a/lms/templates/courseware/syllabus.html b/lms/templates/courseware/syllabus.html new file mode 100644 index 0000000000..38c1fbd6f8 --- /dev/null +++ b/lms/templates/courseware/syllabus.html @@ -0,0 +1,24 @@ +<%inherit file="/main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="headextra"> + <%static:css group='course'/> + + +<%block name="title">${course.number} Course Info + +<%include file="/courseware/course_navigation.html" args="active_page='syllabus'" /> +<%! + from courseware.courses import get_course_syllabus_section +%> + +
    +
    +

    Syllabus

    + % if user.is_authenticated(): + ${get_course_syllabus_section(course, 'syllabus')} + % else: + ${get_course_syllabus_section(course, 'guest_syllabus')} + % endif +
    +
    diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index cfe8a0953c..770e849841 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -107,6 +107,21 @@
% endif + % if staff_access and len(errored_courses) > 0: +
+

Course-loading errors

+ + % for course_dir, errors in errored_courses.items(): +

${course_dir | h}

+
    + % for (msg, err) in errors: +
  • ${msg} +
    • ${err}
    +
  • + % endfor +
+ % endfor + % endif
diff --git a/lms/templates/tracking_log.html b/lms/templates/tracking_log.html index 66d375c2f3..24b249a583 100644 --- a/lms/templates/tracking_log.html +++ b/lms/templates/tracking_log.html @@ -3,7 +3,7 @@ % for rec in records: - + diff --git a/lms/urls.py b/lms/urls.py index a4626896d2..6e6ad4300e 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -126,6 +126,8 @@ if settings.COURSEWARE_ENABLED: #Inside the course url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/info$', 'courseware.views.course_info', name="info"), + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/syllabus$', + 'courseware.views.syllabus', name="syllabus"), # TODO arjun remove when custom tabs in place, see courseware/courses.py url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/book/(?P[^/]*)/$', 'staticbook.views.index', name="book"), url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/book/(?P[^/]*)/(?P[^/]*)$', @@ -217,11 +219,14 @@ if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'): urlpatterns += ( url(r'^migrate/modules$', 'lms_migration.migrate.manage_modulestores'), url(r'^migrate/reload/(?P[^/]+)$', 'lms_migration.migrate.manage_modulestores'), + url(r'^gitreload$', 'lms_migration.migrate.gitreload'), + url(r'^gitreload/(?P[^/]+)$', 'lms_migration.migrate.gitreload'), ) if settings.MITX_FEATURES.get('ENABLE_SQL_TRACKING_LOGS'): urlpatterns += ( url(r'^event_logs$', 'track.views.view_tracking_log'), + url(r'^event_logs/(?P.+)$', 'track.views.view_tracking_log'), ) urlpatterns = patterns(*urlpatterns) diff --git a/requirements.txt b/requirements.txt index 7e69577073..c5cafe64b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,6 +28,7 @@ django-override-settings mock>=0.8, <0.9 PyYAML South +pytz django-celery django-countries django-kombu diff --git a/setup-test-dirs.sh b/setup-test-dirs.sh new file mode 100755 index 0000000000..c4db61ec16 --- /dev/null +++ b/setup-test-dirs.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +# Create symlinks from ~/mitx_all/data or $ROOT/data, with root passed as first arg +# to all the test courses in mitx/common/test/data/ + +# posix compliant sanity check +if [ -z $BASH ] || [ $BASH = "/bin/sh" ]; then +echo "Please use the bash interpreter to run this script" +exit 1 +fi + +ROOT="${1:-$HOME/mitx_all}" + +if [[ ! -d "$ROOT" ]]; then + echo "'$ROOT' is not a directory" + exit 1 +fi + +if [[ ! -d "$ROOT/mitx" ]]; then + echo "'$ROOT' is not the root mitx_all directory" + exit 1 +fi + +if [[ ! -d "$ROOT/data" ]]; then + echo "'$ROOT' is not the root mitx_all directory" + exit 1 +fi + +echo "ROOT is $ROOT" + +cd $ROOT/data + +for course in $(/bin/ls ../mitx/common/test/data/) +do + # Get rid of the symlink if it already exists + if [[ -L "$course" ]]; then + echo "Removing link to '$course'" + rm -f $course + fi + echo "Make link to '$course'" + # Create it + ln -s "../mitx/common/test/data/$course" +done + +# go back to where we came from +cd - diff --git a/utility-scripts/create_groups.py b/utility-scripts/create_groups.py deleted file mode 100644 index 0e3245bb4d..0000000000 --- a/utility-scripts/create_groups.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/python -# -# File: create_groups.py -# -# Create all staff_* groups for classes in data directory. - -import os, sys, string, re - -sys.path.append(os.path.abspath('.')) -os.environ['DJANGO_SETTINGS_MODULE'] = 'lms.envs.dev' - -try: - from lms.envs.dev import * -except Exception as err: - print "Run this script from the top-level mitx directory (mitx_all/mitx), not a subdirectory." - sys.exit(-1) - -from django.conf import settings -from django.contrib.auth.models import User, Group -from path import path -from lxml import etree - -data_dir = settings.DATA_DIR -print "data_dir = %s" % data_dir - -for course_dir in os.listdir(data_dir): - # print course_dir - if not os.path.isdir(path(data_dir) / course_dir): - continue - - cxfn = path(data_dir) / course_dir / 'course.xml' - coursexml = etree.parse(cxfn) - cxmlroot = coursexml.getroot() - course = cxmlroot.get('course') - if course is None: - print "oops, can't get course id for %s" % course_dir - continue - print "course=%s for course_dir=%s" % (course,course_dir) - - gname = 'staff_%s' % course - if Group.objects.filter(name=gname): - print "group exists for %s" % gname - continue - g = Group(name=gname) - g.save() - print "created group %s" % gname diff --git a/utility-scripts/create_user.py b/utility-scripts/create_user.py deleted file mode 100644 index 3ce9ce0ecf..0000000000 --- a/utility-scripts/create_user.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/python -# -# File: create_user.py -# -# Create user. Prompt for groups and ExternalAuthMap - -import os, sys, string, re -import datetime -from getpass import getpass -import json -import readline - -sys.path.append(os.path.abspath('.')) -os.environ['DJANGO_SETTINGS_MODULE'] = 'lms.envs.dev' - -try: - from lms.envs.dev import * -except Exception as err: - print "Run this script from the top-level mitx directory (mitx_all/mitx), not a subdirectory." - sys.exit(-1) - -from student.models import UserProfile, Registration -from external_auth.models import ExternalAuthMap -from django.contrib.auth.models import User, Group -from random import choice - -class MyCompleter(object): # Custom completer - - def __init__(self, options): - self.options = sorted(options) - - def complete(self, text, state): - if state == 0: # on first trigger, build possible matches - if text: # cache matches (entries that start with entered text) - self.matches = [s for s in self.options - if s and s.startswith(text)] - else: # no text entered, all matches possible - self.matches = self.options[:] - - # return match indexed by state - try: - return self.matches[state] - except IndexError: - return None - -def GenPasswd(length=8, chars=string.letters + string.digits): - return ''.join([choice(chars) for i in range(length)]) - -#----------------------------------------------------------------------------- -# main - -while True: - uname = raw_input('username: ') - if User.objects.filter(username=uname): - print "username %s already taken" % uname - else: - break - -make_eamap = False -if raw_input('Create MIT ExternalAuth? [n] ').lower()=='y': - email = '%s@MIT.EDU' % uname - if not email.endswith('@MIT.EDU'): - print "Failed - email must be @MIT.EDU" - sys.exit(-1) - mit_domain = 'ssl:MIT' - if ExternalAuthMap.objects.filter(external_id = email, external_domain = mit_domain): - print "Failed - email %s already exists as external_id" % email - sys.exit(-1) - make_eamap = True - password = GenPasswd(12) - - # get name from kerberos - kname = os.popen("finger %s | grep 'name:'" % email).read().strip().split('name: ')[1].strip() - name = raw_input('Full name: [%s] ' % kname).strip() - if name=='': - name = kname - print "name = %s" % name -else: - while True: - password = getpass() - password2 = getpass() - if password == password2: - break - print "Oops, passwords do not match, please retry" - - while True: - email = raw_input('email: ') - if User.objects.filter(email=email): - print "email %s already taken" % email - else: - break - - name = raw_input('Full name: ') - - -user = User(username=uname, email=email, is_active=True) -user.set_password(password) -try: - user.save() -except IntegrityError: - print "Oops, failed to create user %s, IntegrityError" % user - raise - -r = Registration() -r.register(user) - -up = UserProfile(user=user) -up.name = name -up.save() - -if make_eamap: - credentials = "/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN=%s/emailAddress=%s" % (name,email) - eamap = ExternalAuthMap(external_id = email, - external_email = email, - external_domain = mit_domain, - external_name = name, - internal_password = password, - external_credentials = json.dumps(credentials), - ) - eamap.user = user - eamap.dtsignup = datetime.datetime.now() - eamap.save() - -print "User %s created successfully!" % user - -if not raw_input('Add user %s to any groups? [n] ' % user).lower()=='y': - sys.exit(0) - -print "Here are the groups available:" - -groups = [str(g.name) for g in Group.objects.all()] -print groups - -completer = MyCompleter(groups) -readline.set_completer(completer.complete) -readline.parse_and_bind('tab: complete') - -while True: - gname = raw_input("Add group (tab to autocomplete, empty line to end): ") - if not gname: - break - if not gname in groups: - print "Unknown group %s" % gname - continue - g = Group.objects.get(name=gname) - user.groups.add(g) - print "Added %s to group %s" % (user,g) - -print "Done!"
datetimeusernameipaddrsourcetype
${rec.time}${rec.dtstr} ${rec.username} ${rec.ip} ${rec.event_source}