diff --git a/common/djangoapps/static_replace.py b/common/djangoapps/static_replace.py index ba3cfb302a..ce3dc55031 100644 --- a/common/djangoapps/static_replace.py +++ b/common/djangoapps/static_replace.py @@ -17,8 +17,8 @@ def try_staticfiles_lookup(path): except Exception as err: log.warning("staticfiles_storage couldn't find path {}: {}".format( path, str(err))) - # Just return a dead link--don't kill everything. - url = "file_not_found" + # Just return the original path; don't kill everything. + url = path return url diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 0fbe70c0b3..6d1cbb5afb 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -257,8 +257,11 @@ def add_user_to_default_group(user, group): ########################## REPLICATION SIGNALS ################################# @receiver(post_save, sender=User) def replicate_user_save(sender, **kwargs): - user_obj = kwargs['instance'] - return replicate_model(User.save, user_obj, user_obj.id) + user_obj = kwargs['instance'] + if not should_replicate(user_obj): + return + for course_db_name in db_names_to_replicate_to(user_obj.id): + replicate_user(user_obj, course_db_name) @receiver(post_save, sender=CourseEnrollment) def replicate_enrollment_save(sender, **kwargs): @@ -287,8 +290,8 @@ def replicate_enrollment_save(sender, **kwargs): @receiver(post_delete, sender=CourseEnrollment) def replicate_enrollment_delete(sender, **kwargs): - enrollment_obj = kwargs['instance'] - return replicate_model(CourseEnrollment.delete, enrollment_obj, enrollment_obj.user_id) + enrollment_obj = kwargs['instance'] + return replicate_model(CourseEnrollment.delete, enrollment_obj, enrollment_obj.user_id) @receiver(post_save, sender=UserProfile) def replicate_userprofile_save(sender, **kwargs): @@ -311,23 +314,20 @@ def replicate_user(portal_user, course_db_name): overridden. """ try: - # If the user exists in the Course DB, update the appropriate fields and - # save it back out to the Course DB. course_user = User.objects.using(course_db_name).get(id=portal_user.id) - for field in USER_FIELDS_TO_COPY: - setattr(course_user, field, getattr(portal_user, field)) - - mark_handled(course_user) log.debug("User {0} found in Course DB, replicating fields to {1}" .format(course_user, course_db_name)) - course_user.save(using=course_db_name) # Just being explicit. - except User.DoesNotExist: - # Otherwise, just make a straight copy to the Course DB. - mark_handled(portal_user) log.debug("User {0} not found in Course DB, creating copy in {1}" .format(portal_user, course_db_name)) - portal_user.save(using=course_db_name) + course_user = User() + + for field in USER_FIELDS_TO_COPY: + setattr(course_user, field, getattr(portal_user, field)) + + mark_handled(course_user) + course_user.save(using=course_db_name) + unmark(course_user) def replicate_model(model_method, instance, user_id): """ @@ -337,13 +337,14 @@ def replicate_model(model_method, instance, user_id): if not should_replicate(instance): return - mark_handled(instance) course_db_names = db_names_to_replicate_to(user_id) log.debug("Replicating {0} for user {1} to DBs: {2}" .format(model_method, user_id, course_db_names)) + mark_handled(instance) for db_name in course_db_names: model_method(instance, using=db_name) + unmark(instance) ######### Replication Helpers ######### @@ -371,7 +372,7 @@ def db_names_to_replicate_to(user_id): def marked_handled(instance): """Have we marked this instance as being handled to avoid infinite loops caused by saving models in post_save hooks for the same models?""" - return hasattr(instance, '_do_not_copy_to_course_db') + return hasattr(instance, '_do_not_copy_to_course_db') and instance._do_not_copy_to_course_db def mark_handled(instance): """You have to mark your instance with this function or else we'll go into @@ -384,6 +385,11 @@ def mark_handled(instance): """ instance._do_not_copy_to_course_db = True +def unmark(instance): + """If we don't unmark a model after we do replication, then consecutive + save() calls won't be properly replicated.""" + instance._do_not_copy_to_course_db = False + def should_replicate(instance): """Should this instance be replicated? We need to be a Portal server and the instance has to not have been marked_handled.""" @@ -398,9 +404,3 @@ def should_replicate(instance): return False return True - - - - - - diff --git a/common/djangoapps/student/tests.py b/common/djangoapps/student/tests.py index ad7ddb70d1..b33678fbac 100644 --- a/common/djangoapps/student/tests.py +++ b/common/djangoapps/student/tests.py @@ -4,6 +4,7 @@ when you run "manage.py test". Replace this with more appropriate tests for your application. """ +import logging from datetime import datetime from django.test import TestCase @@ -13,6 +14,8 @@ from .models import User, UserProfile, CourseEnrollment, replicate_user, USER_FI COURSE_1 = 'edX/toy/2012_Fall' COURSE_2 = 'edx/full/6.002_Spring_2012' +log = logging.getLogger(__name__) + class ReplicationTest(TestCase): multi_db = True @@ -47,23 +50,18 @@ class ReplicationTest(TestCase): field, portal_user, course_user )) - if hasattr(portal_user, 'seen_response_count'): - # Since it's the first copy over of User data, we should have all of it - self.assertEqual(portal_user.seen_response_count, - course_user.seen_response_count) - - # But if we replicate again, the user already exists in the Course DB, - # so it shouldn't update the seen_response_count (which is Askbot - # controlled). # This hasattr lameness is here because we don't want this test to be # triggered when we're being run by CMS tests (Askbot doesn't exist # there, so the test will fail). + # + # seen_response_count isn't a field we care about, so it shouldn't have + # been copied over. if hasattr(portal_user, 'seen_response_count'): portal_user.seen_response_count = 20 replicate_user(portal_user, COURSE_1) course_user = User.objects.using(COURSE_1).get(id=portal_user.id) self.assertEqual(portal_user.seen_response_count, 20) - self.assertEqual(course_user.seen_response_count, 10) + self.assertEqual(course_user.seen_response_count, 0) # Another replication should work for an email change however, since # it's a field we care about. @@ -123,6 +121,25 @@ class ReplicationTest(TestCase): UserProfile.objects.using(COURSE_2).get, id=portal_user_profile.id) + log.debug("Make sure our seen_response_count is not replicated.") + if hasattr(portal_user, 'seen_response_count'): + portal_user.seen_response_count = 200 + course_user = User.objects.using(COURSE_1).get(id=portal_user.id) + self.assertEqual(portal_user.seen_response_count, 200) + self.assertEqual(course_user.seen_response_count, 0) + portal_user.save() + + course_user = User.objects.using(COURSE_1).get(id=portal_user.id) + self.assertEqual(portal_user.seen_response_count, 200) + self.assertEqual(course_user.seen_response_count, 0) + + portal_user.email = 'jim@edx.org' + portal_user.save() + course_user = User.objects.using(COURSE_1).get(id=portal_user.id) + self.assertEqual(portal_user.email, 'jim@edx.org') + self.assertEqual(course_user.email, 'jim@edx.org') + + def test_enrollment_for_user_info_after_enrollment(self): """Test the effect of modifying User data after you've enrolled.""" diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 1951426ea7..ea1770109b 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1,13 +1,14 @@ import datetime +import feedparser +import itertools import json import logging import random import string import sys -import uuid -import feedparser +import time import urllib -import itertools +import uuid from django.conf import settings from django.contrib.auth import logout, authenticate, login @@ -26,17 +27,19 @@ from bs4 import BeautifulSoup from django.core.cache import cache from django_future.csrf import ensure_csrf_cookie -from student.models import Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment +from student.models import (Registration, UserProfile, + PendingNameChange, PendingEmailChange, + CourseEnrollment) from util.cache import cache_if_anonymous from xmodule.course_module import CourseDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError -from models import Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment from datetime import date from collections import namedtuple -from courseware.courses import course_staff_group_name, has_staff_access_to_course, get_courses_by_university +from courseware.courses import (course_staff_group_name, has_staff_access_to_course, + get_courses_by_university) log = logging.getLogger("mitx.student") Article = namedtuple('Article', 'title url author image deck publication publish_date') @@ -47,7 +50,8 @@ def csrf_token(context): csrf_token = context.get('csrf_token', '') if csrf_token == 'NOTPROVIDED': return '' - return u'
' % (csrf_token) + return (u'' % (csrf_token)) @ensure_csrf_cookie @@ -162,6 +166,24 @@ def change_enrollment_view(request): """Delegate to change_enrollment to actually do the work.""" return HttpResponse(json.dumps(change_enrollment(request))) +def enrollment_allowed(user, course): + """If the course has an enrollment period, check whether we are in it. + Also respects the DARK_LAUNCH setting""" + now = time.gmtime() + start = course.enrollment_start + end = course.enrollment_end + + if (start is None or now > start) and (end is None or now < end): + # in enrollment period. + return True + + if settings.MITX_FEATURES['DARK_LAUNCH']: + if has_staff_access_to_course(user, course): + # if dark launch, staff can enroll outside enrollment window + return True + return False + + def change_enrollment(request): if request.method != "POST": raise Http404 @@ -174,7 +196,8 @@ def change_enrollment(request): course_id = request.POST.get("course_id", None) if course_id == None: - return HttpResponse(json.dumps({'success': False, 'error': 'There was an error receiving the course id.'})) + return HttpResponse(json.dumps({'success': False, + 'error': 'There was an error receiving the course id.'})) if action == "enroll": # Make sure the course exists @@ -187,12 +210,20 @@ def change_enrollment(request): return {'success': False, 'error': 'The course requested does not exist.'} if settings.MITX_FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'): - # require that user be in the staff_* group (or be an overall admin) to be able to enroll - # eg staff_6.002x or staff_6.00x + # require that user be in the staff_* group (or be an + # overall admin) to be able to enroll eg staff_6.002x or + # staff_6.00x if not has_staff_access_to_course(user, course): staff_group = course_staff_group_name(course) - log.debug('user %s denied enrollment to %s ; not in %s' % (user,course.location.url(),staff_group)) - return {'success': False, 'error' : '%s membership required to access course.' % staff_group} + log.debug('user %s denied enrollment to %s ; not in %s' % ( + user, course.location.url(), staff_group)) + return {'success': False, + 'error' : '%s membership required to access course.' % staff_group} + + if not enrollment_allowed(user, course): + return {'success': False, + 'error': 'enrollment in {} not allowed at this time' + .format(course.display_name)} enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id) return {'success': True} diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 7ec9f54cd2..e7d480f4e9 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -21,18 +21,35 @@ class CourseDescriptor(SequenceDescriptor): try: self.start = time.strptime(self.metadata["start"], "%Y-%m-%dT%H:%M") except KeyError: - self.start = time.gmtime(0) #The epoch msg = "Course loaded without a start date. id = %s" % self.id - log.critical(msg) except ValueError as e: - self.start = time.gmtime(0) #The epoch msg = "Course loaded with a bad start date. %s '%s'" % (self.id, e) - log.critical(msg) # Don't call the tracker from the exception handler. if msg is not None: + self.start = time.gmtime(0) # The epoch + log.critical(msg) system.error_tracker(msg) + def try_parse_time(key): + """ + Parse an optional metadata key: if present, must be valid. + Return None if not present. + """ + if key in self.metadata: + try: + return time.strptime(self.metadata[key], "%Y-%m-%dT%H:%M") + except ValueError as e: + msg = "Course %s loaded with a bad metadata key %s '%s'" % ( + self.id, self.metadata[key], e) + log.warning(msg) + return None + + self.enrollment_start = try_parse_time("enrollment_start") + self.enrollment_end = try_parse_time("enrollment_end") + + + def has_started(self): return time.gmtime() > self.start @@ -100,7 +117,7 @@ class CourseDescriptor(SequenceDescriptor): for s in c.get_children(): if s.metadata.get('graded', False): xmoduledescriptors = list(yield_descriptor_descendents(s)) - + # The xmoduledescriptors included here are only the ones that have scores. section_description = { 'section_descriptor' : s, 'xmoduledescriptors' : filter(lambda child: child.has_score, xmoduledescriptors) } diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 6b1c32ae65..6a9b57cfef 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -89,6 +89,19 @@ div { } } + &.processing { + p.status { + @include inline-block(); + background: url('../images/spinner.gif') center center no-repeat; + height: 20px; + width: 20px; + } + + input { + border-color: #aaa; + } + } + &.incorrect, &.ui-icon-close { p.status { @include inline-block(); @@ -146,7 +159,7 @@ div { width: 14px; } - &.processing, &.ui-icon-check { + &.processing, &.ui-icon-processing { @include inline-block(); background: url('../images/spinner.gif') center center no-repeat; height: 20px; diff --git a/common/lib/xmodule/xmodule/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss index 235e2e3277..8d0c4ac522 100644 --- a/common/lib/xmodule/xmodule/css/video/display.scss +++ b/common/lib/xmodule/xmodule/css/video/display.scss @@ -14,7 +14,7 @@ div.video { section.video-player { height: 0; - overflow: hidden; + // overflow: hidden; padding-bottom: 56.25%; position: relative; @@ -45,12 +45,13 @@ div.video { div.slider { @extend .clearfix; background: #c2c2c2; - border: none; - border-bottom: 1px solid #000; + border: 1px solid #000; @include border-radius(0); border-top: 1px solid #000; @include box-shadow(inset 0 1px 0 #eee, 0 1px 0 #555); height: 7px; + margin-left: -1px; + margin-right: -1px; @include transition(height 2.0s ease-in-out); div.ui-widget-header { @@ -58,43 +59,12 @@ div.video { @include box-shadow(inset 0 1px 0 #999); } - .ui-tooltip.qtip .ui-tooltip-content { - background: $mit-red; - border: 1px solid darken($mit-red, 20%); - @include border-radius(2px); - @include box-shadow(inset 0 1px 0 lighten($mit-red, 10%)); - color: #fff; - font: bold 12px $body-font-family; - margin-bottom: 6px; - margin-right: 0; - overflow: visible; - padding: 4px; - text-align: center; - text-shadow: 0 -1px 0 darken($mit-red, 10%); - -webkit-font-smoothing: antialiased; - - &::after { - background: $mit-red; - border-bottom: 1px solid darken($mit-red, 20%); - border-right: 1px solid darken($mit-red, 20%); - bottom: -5px; - content: " "; - display: block; - height: 7px; - left: 50%; - margin-left: -3px; - position: absolute; - @include transform(rotate(45deg)); - width: 7px; - } - } - a.ui-slider-handle { - background: $mit-red url(../images/slider-handle.png) center center no-repeat; + background: $pink url(../images/slider-handle.png) center center no-repeat; @include background-size(50%); - border: 1px solid darken($mit-red, 20%); + border: 1px solid darken($pink, 20%); @include border-radius(15px); - @include box-shadow(inset 0 1px 0 lighten($mit-red, 10%)); + @include box-shadow(inset 0 1px 0 lighten($pink, 10%)); cursor: pointer; height: 15px; margin-left: -7px; @@ -103,7 +73,7 @@ div.video { width: 15px; &:focus, &:hover { - background-color: lighten($mit-red, 10%); + background-color: lighten($pink, 10%); outline: none; } } diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 9f99f5a526..071e453901 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -227,7 +227,7 @@ class XModule(HTMLSnippet): def get_display_items(self): ''' Returns a list of descendent module instances that will display - immediately inside this module + immediately inside this module. ''' items = [] for child in self.get_children(): @@ -238,7 +238,7 @@ class XModule(HTMLSnippet): def displayable_items(self): ''' Returns list of displayable modules contained by this module. If this - module is visible, should return [self] + module is visible, should return [self]. ''' return [self] diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 85f883d2f0..aa160ee22a 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -145,15 +145,11 @@ def progress_summary(student, course, grader, student_module_cache): instance_modules for the student """ chapters = [] - for c in course.get_children(): - # Don't include chapters that aren't displayable (e.g. due to error) - if c not in c.displayable_items(): - continue + # Don't include chapters that aren't displayable (e.g. due to error) + for c in course.get_display_items(): sections = [] - for s in c.get_children(): + for s in c.get_display_items(): # Same for sections - if s not in s.displayable_items(): - continue graded = s.metadata.get('graded', False) scores = [] for module in yield_module_descendents(s): diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 0fb5c9983e..daffa44d2a 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -58,8 +58,22 @@ def mongo_store_config(data_dir): } } +def xml_store_config(data_dir): + return { + 'default': { + 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', + 'OPTIONS': { + 'data_dir': data_dir, + 'default_class': 'xmodule.hidden_module.HiddenDescriptor', + 'eager': True, + } + } +} + + TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT -TEST_DATA_MODULESTORE = mongo_store_config(TEST_DATA_DIR) +TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) +TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) REAL_DATA_DIR = settings.GITHUB_REPO_ROOT REAL_DATA_MODULESTORE = mongo_store_config(REAL_DATA_DIR) @@ -149,8 +163,27 @@ class ActivateLoginTestCase(TestCase): class PageLoader(ActivateLoginTestCase): ''' Base class that adds a function to load all pages in a modulestore ''' + def _enroll(self, course): + """Post to the enrollment view, and return the parsed json response""" + resp = self.client.post('/change_enrollment', { + 'enrollment_action': 'enroll', + 'course_id': course.id, + }) + return parse_json(resp) + + def try_enroll(self, course): + """Try to enroll. Return bool success instead of asserting it.""" + data = self._enroll(course) + print 'Enrollment in {} result: {}'.format(course.location.url(), data) + return data['success'] + def enroll(self, course): """Enroll the currently logged-in user, and check that it worked.""" + data = self._enroll(course) + self.assertTrue(data['success']) + + def unenroll(self, course): + """Unenroll the currently logged-in user, and check that it worked.""" resp = self.client.post('/change_enrollment', { 'enrollment_action': 'enroll', 'course_id': course.id, @@ -159,6 +192,7 @@ class PageLoader(ActivateLoginTestCase): self.assertTrue(data['success']) def check_pages_load(self, course_name, data_dir, modstore): + """Make all locations in course load""" print "Checking course {0} in {1}".format(course_name, data_dir) import_from_xml(modstore, data_dir, [course_name]) @@ -191,7 +225,7 @@ class PageLoader(ActivateLoginTestCase): self.assertTrue(all_ok) -@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class TestCoursesLoadTestCase(PageLoader): '''Check that all pages in test courses load properly''' @@ -207,7 +241,7 @@ class TestCoursesLoadTestCase(PageLoader): self.check_pages_load('full', TEST_DATA_DIR, modulestore()) -@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestViewAuth(PageLoader): """Check that view authentication works properly""" @@ -215,15 +249,15 @@ class TestViewAuth(PageLoader): # can't do imports there without manually hacking settings. def setUp(self): - print "sys.path: {}".format(sys.path) xmodule.modulestore.django._MODULESTORES = {} - modulestore().collection.drop() - import_from_xml(modulestore(), TEST_DATA_DIR, ['toy']) - import_from_xml(modulestore(), TEST_DATA_DIR, ['full']) courses = modulestore().get_courses() - # get the two courses sorted out - courses.sort(key=lambda c: c.location.course) - [self.full, self.toy] = courses + + def find_course(name): + """Assumes the course is present""" + return [c for c in courses if c.location.course==name][0] + + self.full = find_course("full") + self.toy = find_course("toy") # Create two accounts self.student = 'view@test.com' @@ -304,26 +338,35 @@ class TestViewAuth(PageLoader): self.check_for_get_code(200, url) - def test_dark_launch(self): - """Make sure that when dark launch is on, students can't access course - pages, but instructors can""" - - # test.py turns off start dates, enable them and set them correctly. - # Because settings is global, be careful not to mess it up for other tests - # (Can't use override_settings because we're only changing part of the - # MITX_FEATURES dict) + def run_wrapped(self, test): + """ + test.py turns off start dates. Enable them and DARK_LAUNCH. + Because settings is global, be careful not to mess it up for other tests + (Can't use override_settings because we're only changing part of the + MITX_FEATURES dict) + """ oldDSD = settings.MITX_FEATURES['DISABLE_START_DATES'] oldDL = settings.MITX_FEATURES['DARK_LAUNCH'] try: settings.MITX_FEATURES['DISABLE_START_DATES'] = False settings.MITX_FEATURES['DARK_LAUNCH'] = True - self._do_test_dark_launch() + test() finally: settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD settings.MITX_FEATURES['DARK_LAUNCH'] = oldDL + def test_dark_launch(self): + """Make sure that when dark launch is on, students can't access course + pages, but instructors can""" + self.run_wrapped(self._do_test_dark_launch) + + def test_enrollment_period(self): + """Check that enrollment periods work""" + self.run_wrapped(self._do_test_enrollment_period) + + def _do_test_dark_launch(self): """Actually do the test, relying on settings to be right.""" @@ -338,6 +381,7 @@ class TestViewAuth(PageLoader): self.assertTrue(settings.MITX_FEATURES['DARK_LAUNCH']) def reverse_urls(names, course): + """Reverse a list of course urls""" return [reverse(name, kwargs={'course_id': course.id}) for name in names] def dark_student_urls(course): @@ -424,6 +468,53 @@ class TestViewAuth(PageLoader): check_staff(self.toy) check_staff(self.full) + def _do_test_enrollment_period(self): + """Actually do the test, relying on settings to be right.""" + + # Make courses start in the future + tomorrow = time.time() + 24 * 3600 + nextday = tomorrow + 24 * 3600 + yesterday = time.time() - 24 * 3600 + + print "changing" + # toy course's enrollment period hasn't started + self.toy.enrollment_start = time.gmtime(tomorrow) + self.toy.enrollment_end = time.gmtime(nextday) + + # full course's has + self.full.enrollment_start = time.gmtime(yesterday) + self.full.enrollment_end = time.gmtime(tomorrow) + + print "login" + # First, try with an enrolled student + print '=== Testing student access....' + self.login(self.student, self.password) + self.assertFalse(self.try_enroll(self.toy)) + self.assertTrue(self.try_enroll(self.full)) + + print '=== Testing course instructor access....' + # Make the instructor staff in the toy course + group_name = course_staff_group_name(self.toy) + g = Group.objects.create(name=group_name) + g.user_set.add(user(self.instructor)) + + print "logout/login" + self.logout() + self.login(self.instructor, self.password) + print "Instructor should be able to enroll in toy course" + self.assertTrue(self.try_enroll(self.toy)) + + print '=== Testing staff access....' + # now make the instructor global staff, but not in the instructor group + g.user_set.remove(user(self.instructor)) + u = user(self.instructor) + u.is_staff = True + u.save() + + # unenroll and try again + self.unenroll(self.toy) + self.assertTrue(self.try_enroll(self.toy)) + @override_settings(MODULESTORE=REAL_DATA_MODULESTORE) class RealCoursesLoadTestCase(PageLoader): diff --git a/lms/static/sass/course.scss b/lms/static/sass/course.scss index cc1b49a0a2..c874076a31 100644 --- a/lms/static/sass/course.scss +++ b/lms/static/sass/course.scss @@ -7,6 +7,7 @@ @import 'base/base'; @import 'base/extends'; @import 'base/animations'; +@import 'shared/tooltips'; // Course base / layout styles @import 'course/layout/courseware_subnav'; diff --git a/lms/static/sass/course/_info.scss b/lms/static/sass/course/_info.scss index 1dac5354b6..1651ad4da8 100644 --- a/lms/static/sass/course/_info.scss +++ b/lms/static/sass/course/_info.scss @@ -15,15 +15,15 @@ div.info-wrapper { > ol { list-style: none; - padding-left: 0; margin-bottom: lh(); + padding-left: 0; > li { @extend .clearfix; border-bottom: 1px solid lighten($border-color, 10%); + list-style-type: disk; margin-bottom: lh(); padding-bottom: lh(.5); - list-style-type: disk; &:first-child { margin: 0 (-(lh(.5))) lh(); @@ -41,10 +41,10 @@ div.info-wrapper { h2 { float: left; - margin: 0 flex-gutter() 0 0; - width: flex-grid(2, 9); font-size: $body-font-size; font-weight: bold; + margin: 0 flex-gutter() 0 0; + width: flex-grid(2, 9); } section.update-description { @@ -68,15 +68,15 @@ div.info-wrapper { section.handouts { @extend .sidebar; - border-left: 1px solid #d3d3d3; + border-left: 1px solid $border-color; @include border-radius(0 4px 4px 0); - @include box-shadow(none); border-right: 0; + @include box-shadow(none); h1 { @extend .bottom-border; - padding: lh(.5) lh(.5); margin-bottom: 0; + padding: lh(.5) lh(.5); } ol { @@ -90,8 +90,9 @@ div.info-wrapper { &.expandable, &.collapsable { h4 { - font-weight: normal; + color: $blue; font-size: 1em; + font-weight: normal; padding: lh(.25) 0 lh(.25) lh(1.5); } } @@ -145,7 +146,8 @@ div.info-wrapper { filter: alpha(opacity=60); + h4 { - background-color: #e3e3e3; + @extend a:hover; + text-decoration: underline; } } diff --git a/lms/static/sass/course/base/_base.scss b/lms/static/sass/course/base/_base.scss index cd68b4bbaf..034e047754 100644 --- a/lms/static/sass/course/base/_base.scss +++ b/lms/static/sass/course/base/_base.scss @@ -3,6 +3,7 @@ body { } body, h1, h2, h3, h4, h5, h6, p, p a:link, p a:visited, a { + text-align: left; font-family: $sans-serif; } diff --git a/lms/static/sass/course/base/_extends.scss b/lms/static/sass/course/base/_extends.scss index 7b3e1cba84..c5e61f593e 100644 --- a/lms/static/sass/course/base/_extends.scss +++ b/lms/static/sass/course/base/_extends.scss @@ -25,24 +25,12 @@ h1.top-header { } } -.action-link { - a { - color: $mit-red; - - &:hover { - color: darken($mit-red, 20%); - text-decoration: none; - } - } -} - .content { @include box-sizing(border-box); display: table-cell; padding: lh(); vertical-align: top; width: flex-grid(9) + flex-gutter(); - overflow: hidden; @media print { @include box-shadow(none); @@ -164,7 +152,6 @@ h1.top-header { .topbar { @extend .clearfix; border-bottom: 1px solid $border-color; - font-size: 14px; @media print { display: none; @@ -193,17 +180,17 @@ h1.top-header { h2 { display: block; - width: 700px; float: left; font-size: 0.9em; font-weight: 600; - line-height: 40px; letter-spacing: 0; - text-transform: none; - text-shadow: 0 1px 0 #fff; - white-space: nowrap; - text-overflow: ellipsis; + line-height: 40px; overflow: hidden; + text-overflow: ellipsis; + text-shadow: 0 1px 0 #fff; + text-transform: none; + white-space: nowrap; + width: 700px; .provider { font: inherit; @@ -211,4 +198,4 @@ h1.top-header { color: #6d6d6d; } } -} \ No newline at end of file +} diff --git a/lms/static/sass/course/courseware/_courseware.scss b/lms/static/sass/course/courseware/_courseware.scss index fa3e844e88..198902c146 100644 --- a/lms/static/sass/course/courseware/_courseware.scss +++ b/lms/static/sass/course/courseware/_courseware.scss @@ -146,13 +146,13 @@ div.course-wrapper { @include border-radius(0); a.ui-slider-handle { - @include box-shadow(inset 0 1px 0 lighten($mit-red, 10%)); + @include box-shadow(inset 0 1px 0 lighten($pink, 10%)); background: $mit-red url(../images/slider-bars.png) center center no-repeat; - border: 1px solid darken($mit-red, 20%); + border: 1px solid darken($pink, 20%); cursor: pointer; &:hover, &:focus { - background-color: lighten($mit-red, 10%); + background-color: lighten($pink, 10%); outline: none; } } diff --git a/lms/static/sass/course/courseware/_sidebar.scss b/lms/static/sass/course/courseware/_sidebar.scss index 7f24659533..51e9cbd90d 100644 --- a/lms/static/sass/course/courseware/_sidebar.scss +++ b/lms/static/sass/course/courseware/_sidebar.scss @@ -13,7 +13,7 @@ section.course-index { div#accordion { h3 { @include border-radius(0); - border-top: 1px solid #e3e3e3; + border-top: 1px solid lighten($border-color, 10%); font-size: em(16, 18); margin: 0; overflow: hidden; @@ -34,6 +34,7 @@ section.course-index { } &.ui-accordion-header { + border-bottom: none; color: #000; a { diff --git a/lms/static/sass/course/discussion/_answers.scss b/lms/static/sass/course/discussion/_answers.scss index f0de650206..8ab22aa833 100644 --- a/lms/static/sass/course/discussion/_answers.scss +++ b/lms/static/sass/course/discussion/_answers.scss @@ -17,7 +17,6 @@ div.answer-controls { margin-left: flex-gutter(); nav { - @extend .action-link; float: right; margin-top: 34px; @@ -144,7 +143,7 @@ div.answer-actions { text-decoration: none; &.question-delete { - // color: $mit-red; + color: $mit-red; } } } diff --git a/lms/static/sass/course/discussion/_forms.scss b/lms/static/sass/course/discussion/_forms.scss index 3d484729b1..ae02ab3b20 100644 --- a/lms/static/sass/course/discussion/_forms.scss +++ b/lms/static/sass/course/discussion/_forms.scss @@ -92,7 +92,7 @@ form.answer-form { margin-left: 2.5%; padding-left: 1.5%; border-left: 1px dashed #ddd; - color: $mit-red;; + color: $mit-red; } ul, ol, pre { diff --git a/lms/static/sass/course/wiki/_sidebar.scss b/lms/static/sass/course/wiki/_sidebar.scss index 8c9f97d27d..22574c7a5a 100644 --- a/lms/static/sass/course/wiki/_sidebar.scss +++ b/lms/static/sass/course/wiki/_sidebar.scss @@ -14,14 +14,6 @@ div#wiki_panel { } } - form { - input[type="submit"]{ - @extend .light-button; - text-transform: none; - text-shadow: none; - } - } - div#wiki_create_form { @extend .clearfix; padding: lh(.5) lh() lh(.5) 0; @@ -53,4 +45,12 @@ div#wiki_panel { } } } + + input#wiki_search_input_submit { + padding: 4px 8px; + } + + input#wiki_search_input { + margin-right: 10px; + } } diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 9c2b71f5c0..9581f5e016 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -203,28 +203,23 @@ display: block; left: 0px; position: absolute; + z-index: 50; top: 0px; @include transition(all, 0.15s, linear); right: 0px; } .arrow { - border-top: 8px solid; - border-left: 8px solid; - border-color: rgba(0,0,0, 0.7); - @include box-shadow(inset 0 1px 0 0 rgba(255,255,255, 0.8), -1px 0 1px 0 rgba(255,255,255, 0.8)); - content: ""; - display: block; - height: 55px; - left: 50%; - margin-left: -10px; - margin-top: -30px; - opacity: 0; position: absolute; - top: 50%; - @include transform(rotate(-45deg)); + z-index: 100; + width: 100%; + font-size: 70px; + line-height: 110px; + text-align: center; + text-decoration: none; + color: rgba(0, 0, 0, .7); + opacity: 0; @include transition(all, 0.15s, linear); - width: 55px; } &:hover { diff --git a/lms/static/sass/shared/_forms.scss b/lms/static/sass/shared/_forms.scss index 760dc0bf63..842ffb0086 100644 --- a/lms/static/sass/shared/_forms.scss +++ b/lms/static/sass/shared/_forms.scss @@ -1,52 +1,54 @@ form { font-size: 1em; +} - label { - color: $base-font-color; - font: italic 300 1rem/1.6rem $serif; - margin-bottom: 5px; - text-shadow: 0 1px rgba(255,255,255, 0.4); - -webkit-font-smoothing: antialiased; +label { + color: $base-font-color; + font: italic 300 1rem/1.6rem $serif; + margin-bottom: 5px; + text-shadow: 0 1px rgba(255,255,255, 0.4); + -webkit-font-smoothing: antialiased; +} + +textarea, +input[type="text"], +input[type="email"], +input[type="password"] { + background: rgb(250,250,250); + border: 1px solid rgb(200,200,200); + @include border-radius(3px); + @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6), inset 0 0 3px 0 rgba(0,0,0, 0.1)); + @include box-sizing(border-box); + font: italic 300 1rem/1.6rem $serif; + height: 35px; + padding: 5px 12px; + vertical-align: top; + -webkit-font-smoothing: antialiased; + + &:last-child { + margin-right: 0px; } - textarea, - input[type="text"], - input[type="email"], - input[type="password"] { - background: rgb(250,250,250); - border: 1px solid rgb(200,200,200); - @include border-radius(3px); - @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6), inset 0 0 3px 0 rgba(0,0,0, 0.1)); - @include box-sizing(border-box); - font: italic 300 1rem/1.6rem $serif; - height: 35px; - padding: 5px 12px; - vertical-align: top; - -webkit-font-smoothing: antialiased; - - &:last-child { - margin-right: 0px; - } - - &:focus { - border-color: lighten($blue, 20%); - @include box-shadow(0 0 6px 0 rgba($blue, 0.4), inset 0 0 4px 0 rgba(0,0,0, 0.15)); - outline: none; - } - } - - textarea { - height: 60px; - } - - input[type="submit"] { - @include button(shiny, $blue); - @include border-radius(3px); - font: normal 1.2rem/1.6rem $sans-serif; - height: 35px; - letter-spacing: 1px; - text-transform: uppercase; - vertical-align: top; - -webkit-font-smoothing: antialiased; + &:focus { + border-color: lighten($blue, 20%); + @include box-shadow(0 0 6px 0 rgba($blue, 0.4), inset 0 0 4px 0 rgba(0,0,0, 0.15)); + outline: none; } } + +textarea { + height: 60px; +} + +input[type="submit"], +input[type="button"], +.button { + @include border-radius(3px); + @include button(shiny, $blue); + font: normal 1.2rem/1.6rem $sans-serif; + letter-spacing: 1px; + padding: 4px 20px; + text-transform: uppercase; + vertical-align: top; + -webkit-font-smoothing: antialiased; +} diff --git a/lms/static/sass/shared/_tooltips.scss b/lms/static/sass/shared/_tooltips.scss new file mode 100644 index 0000000000..eefbc09bef --- /dev/null +++ b/lms/static/sass/shared/_tooltips.scss @@ -0,0 +1,12 @@ +.ui-tooltip.qtip .ui-tooltip-content { + background: rgba($pink, .8); + border: 0; + color: #fff; + font: bold 12px $body-font-family; + margin-bottom: 6px; + margin-right: 0; + overflow: visible; + padding: 4px; + text-align: center; + -webkit-font-smoothing: antialiased; +} diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 480568a5b9..fc8e9abf30 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -73,7 +73,7 @@ %> - +