Big access control refactor
* All access control logic is now in access.py * It exports a single method for general use: has_access(user, object, action) - possible actions depend on object type (e.g. 'see_exists', 'enroll', 'staff') * Removed DARK_LAUNCH feature flag--it is now the default behavior * Replaced check_course with three separate more focused functions that use has_access Minor things: * note on using pdb in testing * moved time parsing helper into timeparse.py * x_modules now have a .start attribute (None if not in metadata)
This commit is contained in:
@@ -38,8 +38,8 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
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 get_courses_by_university
|
||||
from courseware.access import has_access
|
||||
|
||||
log = logging.getLogger("mitx.student")
|
||||
Article = namedtuple('Article', 'title url author image deck publication publish_date')
|
||||
@@ -166,22 +166,6 @@ 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):
|
||||
@@ -209,18 +193,7 @@ def change_enrollment(request):
|
||||
.format(user.username, enrollment.course_id))
|
||||
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
|
||||
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}
|
||||
|
||||
if not enrollment_allowed(user, course):
|
||||
if not has_access(user, course, 'enroll'):
|
||||
return {'success': False,
|
||||
'error': 'enrollment in {} not allowed at this time'
|
||||
.format(course.display_name)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from fs.errors import ResourceNotFoundError
|
||||
import time
|
||||
import dateutil.parser
|
||||
import logging
|
||||
|
||||
from xmodule.util.decorators import lazyproperty
|
||||
from xmodule.graders import load_grading_policy
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.seq_module import SequenceDescriptor, SequenceModule
|
||||
from xmodule.timeparse import parse_time
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,7 +19,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
msg = None
|
||||
try:
|
||||
self.start = time.strptime(self.metadata["start"], "%Y-%m-%dT%H:%M")
|
||||
self.start = parse_time(self.metadata["start"])
|
||||
except KeyError:
|
||||
msg = "Course loaded without a start date. id = %s" % self.id
|
||||
except ValueError as e:
|
||||
@@ -31,25 +31,8 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
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")
|
||||
|
||||
|
||||
|
||||
self.enrollment_start = self._try_parse_time("enrollment_start")
|
||||
self.enrollment_end = self._try_parse_time("enrollment_end")
|
||||
|
||||
def has_started(self):
|
||||
return time.gmtime() > self.start
|
||||
@@ -154,6 +137,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
"""Return the course_id for this course"""
|
||||
return self.location_to_id(self.location)
|
||||
|
||||
@property
|
||||
|
||||
11
common/lib/xmodule/xmodule/timeparse.py
Normal file
11
common/lib/xmodule/xmodule/timeparse.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Helper functions for handling time in the format we like.
|
||||
"""
|
||||
import time
|
||||
|
||||
def parse_time(time_str):
|
||||
"""
|
||||
Takes a time string in our format ("%Y-%m-%dT%H:%M"), and returns
|
||||
it as a time_struct. Raises ValueError if the string is not in the right format.
|
||||
"""
|
||||
return time.strptime(time_str, "%Y-%m-%dT%H:%M")
|
||||
@@ -8,8 +8,9 @@ from lxml import etree
|
||||
from lxml.etree import XMLSyntaxError
|
||||
from pprint import pprint
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.timeparse import parse_time
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
@@ -384,6 +385,9 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
|
||||
self.category = self.location.category
|
||||
self.shared_state_key = kwargs.get('shared_state_key')
|
||||
|
||||
# look for a start time, setting to None if not present
|
||||
self.start = self._try_parse_time('start')
|
||||
|
||||
self._child_instances = None
|
||||
self._inherited_metadata = set()
|
||||
|
||||
@@ -596,6 +600,23 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
|
||||
metadata=self.metadata
|
||||
))
|
||||
|
||||
# ================================ Internal helpers =======================
|
||||
|
||||
def _try_parse_time(self, key):
|
||||
"""
|
||||
Parse an optional metadata key containing a time: if present, must be valid.
|
||||
Return None if not present.
|
||||
"""
|
||||
if key in self.metadata:
|
||||
try:
|
||||
parse_time(self.metadata[key])
|
||||
except ValueError as e:
|
||||
msg = "Descriptor {} loaded with a bad metadata key '{}': '{}'".format(
|
||||
self.location.url(), self.metadata[key], e)
|
||||
log.warning(msg)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
class DescriptorSystem(object):
|
||||
def __init__(self, load_item, resources_fs, error_tracker, **kwargs):
|
||||
|
||||
@@ -65,3 +65,4 @@ To run a single nose test:
|
||||
nosetests common/lib/xmodule/xmodule/tests/test_stringify.py:test_stringify
|
||||
|
||||
|
||||
Very handy: if you uncomment the `--pdb` argument in `NOSE_ARGS` in `lms/envs/test.py`, it will drop you into pdb on error. This lets you go up and down the stack and see what the values of the variables are. Check out http://docs.python.org/library/pdb.html
|
||||
|
||||
266
lms/djangoapps/courseware/access.py
Normal file
266
lms/djangoapps/courseware/access.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""This file contains (or should), all access control logic for the courseware.
|
||||
Ideally, it will be the only place that needs to know about any special settings
|
||||
like DISABLE_START_DATES"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.timeparse import parse_time
|
||||
from xmodule.x_module import XModule, XModuleDescriptor
|
||||
|
||||
|
||||
DEBUG_ACCESS = True
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
# to avoid overly verbose output, this is off by default
|
||||
if DEBUG_ACCESS:
|
||||
log.debug(*args, **kwargs)
|
||||
|
||||
def has_access(user, obj, action):
|
||||
"""
|
||||
Check whether a user has the access to do action on obj. Handles any magic
|
||||
switching based on various settings.
|
||||
|
||||
Things this module understands:
|
||||
- start dates for modules
|
||||
- DISABLE_START_DATES
|
||||
- different access for staff, course staff, and students.
|
||||
|
||||
user: a Django user object. May be anonymous.
|
||||
|
||||
obj: The object to check access for. For now, a module or descriptor.
|
||||
|
||||
action: A string specifying the action that the client is trying to perform.
|
||||
|
||||
actions depend on the obj type, but include e.g. 'enroll' for courses. See the
|
||||
type-specific functions below for the known actions for that type.
|
||||
|
||||
Returns a bool. It is up to the caller to actually deny access in a way
|
||||
that makes sense in context.
|
||||
"""
|
||||
# delegate the work to type-specific functions.
|
||||
# (start with more specific types, then get more general)
|
||||
if isinstance(obj, CourseDescriptor):
|
||||
return _has_access_course_desc(user, obj, action)
|
||||
|
||||
if isinstance(obj, XModuleDescriptor):
|
||||
return _has_access_descriptor(user, obj, action)
|
||||
|
||||
if isinstance(obj, XModule):
|
||||
return _has_access_xmodule(user, obj, action)
|
||||
|
||||
if isinstance(obj, Location):
|
||||
return _has_access_location(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(). Object type: '{}'"
|
||||
.format(type(obj)))
|
||||
|
||||
# ================ Implementation helpers ================================
|
||||
|
||||
def _has_access_course_desc(user, course, action):
|
||||
"""
|
||||
Check if user has access to a course descriptor.
|
||||
|
||||
Valid actions:
|
||||
|
||||
'load' -- load the courseware, see inside the course
|
||||
'enroll' -- enroll. Checks for enrollment window,
|
||||
ACCESS_REQUIRE_STAFF_FOR_COURSE,
|
||||
'see_exists' -- can see that the course exists.
|
||||
'staff' -- staff access to course.
|
||||
"""
|
||||
def can_load():
|
||||
"Can this user load this course?"
|
||||
# delegate to generic descriptor check
|
||||
return _has_access_descriptor(user, course, action)
|
||||
|
||||
def can_enroll():
|
||||
"""
|
||||
If the course has an enrollment period, check whether we are in it.
|
||||
(staff can always enroll)
|
||||
"""
|
||||
|
||||
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, so any user is allowed to enroll.
|
||||
return True
|
||||
|
||||
# otherwise, need staff access
|
||||
return _has_staff_access_to_descriptor(user, course)
|
||||
|
||||
def see_exists():
|
||||
"""
|
||||
Can see if can enroll, but also if can load it: if user enrolled in a course and now
|
||||
it's past the enrollment period, they should still see it.
|
||||
|
||||
TODO (vshnayder): This means that courses with limited enrollment periods will not appear
|
||||
to non-staff visitors after the enrollment period is over. If this is not what we want, will
|
||||
need to change this logic.
|
||||
"""
|
||||
# VS[compat] -- this setting should go away once all courses have
|
||||
# properly configured enrollment_start times (if course should be
|
||||
# staff-only, set enrollment_start far in the future.)
|
||||
if settings.MITX_FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'):
|
||||
# if this feature is on, only allow courses that have ispublic set to be
|
||||
# seen by non-staff
|
||||
if course.metadata.get('ispublic'):
|
||||
return True
|
||||
return _has_staff_access_to_descriptor(user, course)
|
||||
|
||||
return can_enroll() or can_load()
|
||||
|
||||
checkers = {
|
||||
'load': can_load,
|
||||
'enroll': can_enroll,
|
||||
'see_exists': see_exists,
|
||||
'staff': lambda: _has_staff_access_to_descriptor(user, course)
|
||||
}
|
||||
|
||||
return _dispatch(checkers, action, user, course)
|
||||
|
||||
|
||||
def _has_access_descriptor(user, descriptor, action):
|
||||
"""
|
||||
Check if user has access to this descriptor.
|
||||
|
||||
Valid actions:
|
||||
'load' -- load this descriptor, showing it to the user.
|
||||
'staff' -- staff access to descriptor.
|
||||
|
||||
NOTE: This is the fallback logic for descriptors that don't have custom policy
|
||||
(e.g. courses). If you call this method directly instead of going through
|
||||
has_access(), it will not do the right thing.
|
||||
"""
|
||||
def can_load():
|
||||
# If start dates are off, can always load
|
||||
if settings.MITX_FEATURES['DISABLE_START_DATES']:
|
||||
return True
|
||||
|
||||
# Check start date
|
||||
if descriptor.start is not None:
|
||||
now = time.gmtime()
|
||||
if now > descriptor.start:
|
||||
# after start date, everyone can see it
|
||||
return True
|
||||
# otherwise, need staff access
|
||||
return _has_staff_access_to_descriptor(user, descriptor)
|
||||
|
||||
# No start date, so can always load.
|
||||
return True
|
||||
|
||||
checkers = {
|
||||
'load': can_load,
|
||||
'staff': lambda: _has_staff_access_to_descriptor(user, descriptor)
|
||||
}
|
||||
|
||||
return _dispatch(checkers, action, user, descriptor)
|
||||
|
||||
|
||||
|
||||
|
||||
def _has_access_xmodule(user, xmodule, action):
|
||||
"""
|
||||
Check if user has access to this xmodule.
|
||||
|
||||
Valid actions:
|
||||
- same as the valid actions for xmodule.descriptor
|
||||
"""
|
||||
# Delegate to the descriptor
|
||||
return has_access(user, xmodule.descriptor, action)
|
||||
|
||||
|
||||
def _has_access_location(user, location, action):
|
||||
"""
|
||||
Check if user has access to this location.
|
||||
|
||||
Valid actions:
|
||||
'staff' : True if the user has staff access to this location
|
||||
|
||||
NOTE: if you add other actions, make sure that
|
||||
|
||||
has_access(user, location, action) == has_access(user, get_item(location), action)
|
||||
|
||||
And in general, prefer checking access on loaded items, rather than locations.
|
||||
"""
|
||||
checkers = {
|
||||
'staff': lambda: _has_staff_access_to_location(user, location)
|
||||
}
|
||||
|
||||
return _dispatch(checkers, action, user, location)
|
||||
|
||||
|
||||
##### Internal helper methods below
|
||||
|
||||
def _dispatch(table, action, user, obj):
|
||||
"""
|
||||
Helper: call table[action], raising a nice pretty error if there is no such key.
|
||||
|
||||
user and object passed in only for error messages and debugging
|
||||
"""
|
||||
if action in table:
|
||||
result = table[action]()
|
||||
debug("%s user %s, object %s, action %s",
|
||||
'ALLOWED' if result else 'DENIED',
|
||||
user, obj, action)
|
||||
return result
|
||||
|
||||
raise ValueError("Unknown action for object type '{}': '{}'".format(
|
||||
type(obj), action))
|
||||
|
||||
def _course_staff_group_name(location):
|
||||
"""
|
||||
Get the name of the staff group for a location. Right now, that's staff_COURSE.
|
||||
|
||||
location: something that can passed to Location.
|
||||
"""
|
||||
return 'staff_%s' % Location(location).course
|
||||
|
||||
def _has_staff_access_to_location(user, location):
|
||||
'''
|
||||
Returns True if the given user has staff access to a location. For now this
|
||||
is equivalent to having staff access to the course location.course.
|
||||
|
||||
This means that user is in the staff_* group, or is an overall admin.
|
||||
|
||||
TODO (vshnayder): this needs to be changed to allow per-course_id permissions, not per-course
|
||||
(e.g. staff in 2012 is different from 2013, but maybe some people always have access)
|
||||
|
||||
course is a string: the course field of the location being accessed.
|
||||
'''
|
||||
if user is None or (not user.is_authenticated()):
|
||||
return False
|
||||
if user.is_staff:
|
||||
return True
|
||||
|
||||
# If not global staff, is the user in the Auth group for this class?
|
||||
user_groups = [x[1] for x in user.groups.values_list()]
|
||||
staff_group = _course_staff_group_name(location)
|
||||
if staff_group in user_groups:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_staff_access_to_course_id(user, course_id):
|
||||
"""Helper method that takes a course_id instead of a course name"""
|
||||
loc = CourseDescriptor.id_to_location(course_id)
|
||||
return _has_staff_access_to_location(user, loc)
|
||||
|
||||
|
||||
def _has_staff_access_to_descriptor(user, descriptor):
|
||||
"""Helper method that checks whether the user has staff access to
|
||||
the course of the location.
|
||||
|
||||
location: something that can be passed to Location
|
||||
"""
|
||||
return _has_staff_access_to_location(user, descriptor.location)
|
||||
|
||||
@@ -12,49 +12,51 @@ from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from static_replace import replace_urls, try_staticfiles_lookup
|
||||
from courseware.access import has_access
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_course(user, course_id, course_must_be_open=True, course_required=True):
|
||||
def get_course_by_id(course_id):
|
||||
"""
|
||||
Given a django user and a course_id, this returns the course
|
||||
object. By default, if the course is not found or the course is
|
||||
not open yet, this method will raise a 404.
|
||||
Given a course id, return the corresponding course descriptor.
|
||||
|
||||
If course_must_be_open is False, the course will be returned
|
||||
without a 404 even if it is not open.
|
||||
|
||||
If course_required is False, a course_id of None is acceptable. The
|
||||
course returned will be None. Even if the course is not required,
|
||||
if a course_id is given that does not exist a 404 will be raised.
|
||||
|
||||
This behavior is modified by MITX_FEATURES['DARK_LAUNCH']:
|
||||
if dark launch is enabled, course_must_be_open is ignored for
|
||||
users that have staff access.
|
||||
If course_id is not valid, raises a 404.
|
||||
"""
|
||||
course = None
|
||||
if course_required or course_id:
|
||||
try:
|
||||
course_loc = CourseDescriptor.id_to_location(course_id)
|
||||
course = modulestore().get_item(course_loc)
|
||||
try:
|
||||
course_loc = CourseDescriptor.id_to_location(course_id)
|
||||
return modulestore().get_item(course_loc)
|
||||
except (KeyError, ItemNotFoundError):
|
||||
raise Http404("Course not found.")
|
||||
|
||||
except (KeyError, ItemNotFoundError):
|
||||
raise Http404("Course not found.")
|
||||
|
||||
started = course.has_started() or settings.MITX_FEATURES['DISABLE_START_DATES']
|
||||
|
||||
must_be_open = course_must_be_open
|
||||
if (settings.MITX_FEATURES['DARK_LAUNCH'] and
|
||||
has_staff_access_to_course(user, course)):
|
||||
must_be_open = False
|
||||
|
||||
if must_be_open and not started:
|
||||
raise Http404("This course has not yet started.")
|
||||
def get_course_with_access(user, course_id, action):
|
||||
"""
|
||||
Given a course_id, look up the corresponding course descriptor,
|
||||
check that the user has the access to perform the specified action
|
||||
on the course, and return the descriptor.
|
||||
|
||||
Raises a 404 if the course_id is invalid, or the user doesn't have access.
|
||||
"""
|
||||
course = get_course_by_id(course_id)
|
||||
if not has_access(user, course, action):
|
||||
# Deliberately return a non-specific error message to avoid
|
||||
# leaking info about access control settings
|
||||
raise Http404("Course not found.")
|
||||
return course
|
||||
|
||||
|
||||
def get_opt_course_with_access(user, course_id, action):
|
||||
"""
|
||||
Same as get_course_with_access, except that if course_id is None,
|
||||
return None without performing any access checks.
|
||||
"""
|
||||
if course_id is None:
|
||||
return None
|
||||
return get_course_with_access(user, course_id, action)
|
||||
|
||||
|
||||
def course_image_url(course):
|
||||
"""Try to look up the image url for the course. If it's not found,
|
||||
log an error and return the dead link"""
|
||||
@@ -140,66 +142,10 @@ def get_course_info_section(course, section_key):
|
||||
|
||||
raise KeyError("Invalid about key " + str(section_key))
|
||||
|
||||
def course_staff_group_name(course):
|
||||
'''
|
||||
course should be either a CourseDescriptor instance, or a string (the
|
||||
.course entry of a Location)
|
||||
'''
|
||||
if isinstance(course, str) or isinstance(course, unicode):
|
||||
coursename = course
|
||||
else:
|
||||
# should be a CourseDescriptor, so grab its location.course:
|
||||
coursename = course.location.course
|
||||
return 'staff_%s' % coursename
|
||||
|
||||
def has_staff_access_to_course(user, course):
|
||||
'''
|
||||
Returns True if the given user has staff access to the course.
|
||||
This means that user is in the staff_* group, or is an overall admin.
|
||||
TODO (vshnayder): this needs to be changed to allow per-course_id permissions, not per-course
|
||||
(e.g. staff in 2012 is different from 2013, but maybe some people always have access)
|
||||
|
||||
course is the course field of the location being accessed.
|
||||
'''
|
||||
if user is None or (not user.is_authenticated()) or course is None:
|
||||
return False
|
||||
if user.is_staff:
|
||||
return True
|
||||
|
||||
# note this is the Auth group, not UserTestGroup
|
||||
user_groups = [x[1] for x in user.groups.values_list()]
|
||||
staff_group = course_staff_group_name(course)
|
||||
if staff_group in user_groups:
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_staff_access_to_course_id(user, course_id):
|
||||
"""Helper method that takes a course_id instead of a course name"""
|
||||
loc = CourseDescriptor.id_to_location(course_id)
|
||||
return has_staff_access_to_course(user, loc.course)
|
||||
|
||||
|
||||
def has_staff_access_to_location(user, location):
|
||||
"""Helper method that checks whether the user has staff access to
|
||||
the course of the location.
|
||||
|
||||
location: something that can be passed to Location
|
||||
"""
|
||||
return has_staff_access_to_course(user, Location(location).course)
|
||||
|
||||
def has_access_to_course(user, course):
|
||||
'''course is the .course element of a location'''
|
||||
if course.metadata.get('ispublic'):
|
||||
return True
|
||||
return has_staff_access_to_course(user,course)
|
||||
|
||||
def get_courses_by_university(user):
|
||||
'''
|
||||
Returns dict of lists of courses available, keyed by course.org (ie university).
|
||||
Courses are sorted by course.number.
|
||||
|
||||
if ACCESS_REQUIRE_STAFF_FOR_COURSE then list only includes those accessible
|
||||
to user.
|
||||
'''
|
||||
# TODO: Clean up how 'error' is done.
|
||||
# filter out any courses that errored.
|
||||
@@ -208,9 +154,7 @@ def get_courses_by_university(user):
|
||||
courses = sorted(courses, key=lambda course: course.number)
|
||||
universities = defaultdict(list)
|
||||
for course in courses:
|
||||
if settings.MITX_FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'):
|
||||
if not has_access_to_course(user,course):
|
||||
continue
|
||||
universities[course.org].append(course)
|
||||
if has_access(user, course, 'see_exists'):
|
||||
universities[course.org].append(course)
|
||||
return universities
|
||||
|
||||
|
||||
@@ -2,25 +2,25 @@ import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import Http404
|
||||
from django.http import HttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from capa.xqueue_interface import XQueueInterface
|
||||
from django.contrib.auth.models import User
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from courseware.access import has_access
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
from models import StudentModule, StudentModuleCache
|
||||
from static_replace import replace_urls
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xmodule_modifiers import replace_static_urls, add_histogram, wrap_xmodule
|
||||
|
||||
from courseware.courses import (has_staff_access_to_course,
|
||||
has_staff_access_to_location)
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
|
||||
@@ -140,6 +140,8 @@ def get_module(user, request, location, student_module_cache, position=None):
|
||||
Returns: xmodule instance
|
||||
|
||||
'''
|
||||
# has_access below needs an actual Location
|
||||
location = Location(location)
|
||||
descriptor = modulestore().get_item(location)
|
||||
|
||||
#TODO Only check the cache if this module can possibly have state
|
||||
@@ -163,7 +165,7 @@ def get_module(user, request, location, student_module_cache, position=None):
|
||||
# TODO (vshnayder): fix hardcoded urls (use reverse)
|
||||
# Setup system context for module instance
|
||||
|
||||
ajax_url = reverse('modx_dispatch',
|
||||
ajax_url = reverse('modx_dispatch',
|
||||
kwargs=dict(course_id=descriptor.location.course_id,
|
||||
id=descriptor.location.url(),
|
||||
dispatch=''),
|
||||
@@ -208,7 +210,7 @@ def get_module(user, request, location, student_module_cache, position=None):
|
||||
# a module is coming through get_html and is therefore covered
|
||||
# by the replace_static_urls code below
|
||||
replace_urls=replace_urls,
|
||||
is_staff=has_staff_access_to_location(user, location),
|
||||
is_staff=has_access(user, location, 'staff'),
|
||||
node_path=settings.NODE_PATH
|
||||
)
|
||||
# pass position specified in URL to module through ModuleSystem
|
||||
@@ -223,7 +225,7 @@ def get_module(user, request, location, student_module_cache, position=None):
|
||||
)
|
||||
|
||||
if settings.MITX_FEATURES.get('DISPLAY_HISTOGRAMS_TO_STAFF'):
|
||||
if has_staff_access_to_course(user, module.location.course):
|
||||
if has_access(user, module, 'staff'):
|
||||
module.get_html = add_histogram(module.get_html, module, user)
|
||||
|
||||
return module
|
||||
|
||||
@@ -18,8 +18,10 @@ from override_settings import override_settings
|
||||
|
||||
import xmodule.modulestore.django
|
||||
|
||||
# Need access to internal func to put users in the right group
|
||||
from courseware.access import _course_staff_group_name
|
||||
|
||||
from student.models import Registration
|
||||
from courseware.courses import course_staff_group_name
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
@@ -310,7 +312,7 @@ class TestViewAuth(PageLoader):
|
||||
self.check_for_get_code(404, url)
|
||||
|
||||
# Make the instructor staff in the toy course
|
||||
group_name = course_staff_group_name(self.toy)
|
||||
group_name = _course_staff_group_name(self.toy.location)
|
||||
g = Group.objects.create(name=group_name)
|
||||
g.user_set.add(user(self.instructor))
|
||||
|
||||
@@ -340,25 +342,22 @@ class TestViewAuth(PageLoader):
|
||||
|
||||
def run_wrapped(self, test):
|
||||
"""
|
||||
test.py turns off start dates. Enable them and DARK_LAUNCH.
|
||||
test.py turns off start dates. Enable them.
|
||||
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
|
||||
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
|
||||
"""Make sure that before course start, students can't access course
|
||||
pages, but instructors can"""
|
||||
self.run_wrapped(self._do_test_dark_launch)
|
||||
|
||||
@@ -378,7 +377,6 @@ class TestViewAuth(PageLoader):
|
||||
self.assertFalse(self.toy.has_started())
|
||||
self.assertFalse(self.full.has_started())
|
||||
self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES'])
|
||||
self.assertTrue(settings.MITX_FEATURES['DARK_LAUNCH'])
|
||||
|
||||
def reverse_urls(names, course):
|
||||
"""Reverse a list of course urls"""
|
||||
@@ -444,7 +442,7 @@ class TestViewAuth(PageLoader):
|
||||
|
||||
print '=== Testing course instructor access....'
|
||||
# Make the instructor staff in the toy course
|
||||
group_name = course_staff_group_name(self.toy)
|
||||
group_name = _course_staff_group_name(self.toy.location)
|
||||
g = Group.objects.create(name=group_name)
|
||||
g.user_set.add(user(self.instructor))
|
||||
|
||||
@@ -494,7 +492,7 @@ class TestViewAuth(PageLoader):
|
||||
|
||||
print '=== Testing course instructor access....'
|
||||
# Make the instructor staff in the toy course
|
||||
group_name = course_staff_group_name(self.toy)
|
||||
group_name = _course_staff_group_name(self.toy.location)
|
||||
g = Group.objects.create(name=group_name)
|
||||
g.user_set.add(user(self.instructor))
|
||||
|
||||
|
||||
@@ -15,21 +15,19 @@ from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.cache import cache_control
|
||||
|
||||
from module_render import toc_for_course, get_module, get_section
|
||||
from models import StudentModuleCache
|
||||
from student.models import UserProfile
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.search import path_to_location
|
||||
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
from util.cache import cache, cache_if_anonymous
|
||||
from student.models import UserTestGroup, CourseEnrollment
|
||||
from courseware import grades
|
||||
from courseware.courses import (check_course, get_courses_by_university,
|
||||
has_staff_access_to_course_id)
|
||||
|
||||
from courseware.access import has_access
|
||||
from courseware.courses import (get_course_with_access, get_courses_by_university)
|
||||
from models import StudentModuleCache
|
||||
from module_render import toc_for_course, get_module, get_section
|
||||
from student.models import UserProfile
|
||||
from student.models import UserTestGroup, CourseEnrollment
|
||||
from util.cache import cache, cache_if_anonymous
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
|
||||
from xmodule.modulestore.search import path_to_location
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
@@ -93,7 +91,8 @@ def render_accordion(request, course, chapter, section):
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def index(request, course_id, chapter=None, section=None,
|
||||
position=None):
|
||||
''' Displays courseware accordion, and any associated content.
|
||||
"""
|
||||
Displays courseware accordion, and any associated content.
|
||||
If course, chapter, and section aren't all specified, just returns
|
||||
the accordion. If they are specified, returns an error if they don't
|
||||
point to a valid module.
|
||||
@@ -109,8 +108,8 @@ def index(request, course_id, chapter=None, section=None,
|
||||
Returns:
|
||||
|
||||
- HTTPresponse
|
||||
'''
|
||||
course = check_course(request.user, course_id)
|
||||
"""
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
registered = registered_for_course(course, request.user)
|
||||
if not registered:
|
||||
# TODO (vshnayder): do course instructors need to be registered to see course?
|
||||
@@ -204,7 +203,7 @@ def course_info(request, course_id):
|
||||
|
||||
Assumes the course_id is in a valid format.
|
||||
"""
|
||||
course = check_course(request.user, course_id)
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
return render_to_response('info.html', {'course': course})
|
||||
|
||||
@@ -221,7 +220,7 @@ def registered_for_course(course, user):
|
||||
@ensure_csrf_cookie
|
||||
@cache_if_anonymous
|
||||
def course_about(request, course_id):
|
||||
course = check_course(request.user, course_id, course_must_be_open=False)
|
||||
course = get_course_with_access(request.user, course_id, 'see_exists')
|
||||
registered = registered_for_course(course, request.user)
|
||||
return render_to_response('portal/course_about.html', {'course': course, 'registered': registered})
|
||||
|
||||
@@ -253,14 +252,14 @@ def profile(request, course_id, student_id=None):
|
||||
|
||||
Course staff are allowed to see the profiles of students in their class.
|
||||
"""
|
||||
course = check_course(request.user, course_id)
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
if student_id is None or student_id == request.user.id:
|
||||
# always allowed to see your own profile
|
||||
student = request.user
|
||||
else:
|
||||
# Requesting access to a different student's profile
|
||||
if not has_staff_access_to_course_id(request.user, course_id):
|
||||
if not has_access(request.user, course, 'staff'):
|
||||
raise Http404
|
||||
student = User.objects.get(id=int(student_id))
|
||||
|
||||
@@ -297,10 +296,7 @@ def gradebook(request, course_id):
|
||||
- only displayed to course staff
|
||||
- shows students who are enrolled.
|
||||
"""
|
||||
if not has_staff_access_to_course_id(request.user, course_id):
|
||||
raise Http404
|
||||
|
||||
course = check_course(request.user, course_id)
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
|
||||
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username')
|
||||
|
||||
@@ -322,10 +318,7 @@ def gradebook(request, course_id):
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def grade_summary(request, course_id):
|
||||
"""Display the grade summary for a course."""
|
||||
if not has_staff_access_to_course_id(request.user, course_id):
|
||||
raise Http404
|
||||
|
||||
course = check_course(request.user, course_id)
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
|
||||
# For now, just a static page
|
||||
context = {'course': course }
|
||||
@@ -335,11 +328,10 @@ def grade_summary(request, course_id):
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def instructor_dashboard(request, course_id):
|
||||
"""Display the instructor dashboard for a course."""
|
||||
if not has_staff_access_to_course_id(request.user, course_id):
|
||||
course = get_course_with_access(request.user, course_id, 'staff')
|
||||
if not has_access(request.user, course, 'staff'):
|
||||
raise Http404
|
||||
|
||||
course = check_course(request.user, course_id)
|
||||
|
||||
# For now, just a static page
|
||||
context = {'course': course }
|
||||
return render_to_response('instructor_dashboard.html', context)
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils import simplejson
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from courseware.courses import check_course
|
||||
from courseware.courses import get_opt_course_with_access
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
@@ -51,7 +51,7 @@ def update_template_dictionary(dictionary, request=None, course=None, article=No
|
||||
|
||||
|
||||
def view(request, article_path, course_id=None):
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
course = get_opt_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
(article, err) = get_article(request, article_path, course)
|
||||
if err:
|
||||
@@ -67,7 +67,7 @@ def view(request, article_path, course_id=None):
|
||||
|
||||
|
||||
def view_revision(request, revision_number, article_path, course_id=None):
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
course = get_opt_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
(article, err) = get_article(request, article_path, course)
|
||||
if err:
|
||||
@@ -91,7 +91,7 @@ def view_revision(request, revision_number, article_path, course_id=None):
|
||||
|
||||
|
||||
def root_redirect(request, course_id=None):
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
course = get_opt_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
#TODO: Add a default namespace to settings.
|
||||
namespace = course.wiki_namespace if course else "edX"
|
||||
@@ -109,7 +109,7 @@ def root_redirect(request, course_id=None):
|
||||
|
||||
|
||||
def create(request, article_path, course_id=None):
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
course = get_opt_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
article_path_components = article_path.split('/')
|
||||
|
||||
@@ -170,7 +170,7 @@ def create(request, article_path, course_id=None):
|
||||
|
||||
|
||||
def edit(request, article_path, course_id=None):
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
course = get_opt_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
(article, err) = get_article(request, article_path, course)
|
||||
if err:
|
||||
@@ -218,7 +218,7 @@ def edit(request, article_path, course_id=None):
|
||||
|
||||
|
||||
def history(request, article_path, page=1, course_id=None):
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
course = get_opt_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
(article, err) = get_article(request, article_path, course)
|
||||
if err:
|
||||
@@ -300,7 +300,7 @@ def history(request, article_path, page=1, course_id=None):
|
||||
|
||||
|
||||
def revision_feed(request, page=1, namespace=None, course_id=None):
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
course = get_opt_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
page_size = 10
|
||||
|
||||
@@ -333,7 +333,7 @@ def revision_feed(request, page=1, namespace=None, course_id=None):
|
||||
|
||||
|
||||
def search_articles(request, namespace=None, course_id=None):
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
course = get_opt_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
# blampe: We should check for the presence of other popular django search
|
||||
# apps and use those if possible. Only fall back on this as a last resort.
|
||||
@@ -382,7 +382,7 @@ def search_articles(request, namespace=None, course_id=None):
|
||||
|
||||
|
||||
def search_add_related(request, course_id, slug, namespace):
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
course = get_opt_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
(article, err) = get_article(request, slug, namespace if namespace else course_id)
|
||||
if err:
|
||||
@@ -415,7 +415,7 @@ def search_add_related(request, course_id, slug, namespace):
|
||||
|
||||
|
||||
def add_related(request, course_id, slug, namespace):
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
course = get_opt_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
(article, err) = get_article(request, slug, namespace if namespace else course_id)
|
||||
if err:
|
||||
@@ -439,7 +439,7 @@ def add_related(request, course_id, slug, namespace):
|
||||
|
||||
|
||||
def remove_related(request, course_id, namespace, slug, related_id):
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
course = get_opt_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
(article, err) = get_article(request, slug, namespace if namespace else course_id)
|
||||
|
||||
@@ -462,7 +462,7 @@ def remove_related(request, course_id, namespace, slug, related_id):
|
||||
|
||||
|
||||
def random_article(request, course_id=None):
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
course = get_opt_course_with_access(request.user, course_id, 'load')
|
||||
|
||||
from random import randint
|
||||
num_arts = Article.objects.count()
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from courseware.courses import check_course
|
||||
from courseware.courses import get_course_with_access
|
||||
from lxml import etree
|
||||
|
||||
@login_required
|
||||
def index(request, course_id, page=0):
|
||||
course = check_course(request.user, course_id)
|
||||
course = get_course_with_access(request.user, course_id, 'load')
|
||||
raw_table_of_contents = open('lms/templates/book_toc.xml', 'r') # TODO: This will need to come from S3
|
||||
table_of_contents = etree.parse(raw_table_of_contents).getroot()
|
||||
return render_to_response('staticbook.html', {'page': int(page), 'course': course, 'table_of_contents': table_of_contents})
|
||||
return render_to_response('staticbook.html',
|
||||
{'page': int(page), 'course': course,
|
||||
'table_of_contents': table_of_contents})
|
||||
|
||||
|
||||
def index_shifted(request, course_id, page):
|
||||
|
||||
@@ -48,7 +48,6 @@ MITX_FEATURES = {
|
||||
## DO NOT SET TO True IN THIS FILE
|
||||
## Doing so will cause all courses to be released on production
|
||||
'DISABLE_START_DATES': False, # When True, all courses will be active, regardless of start date
|
||||
'DARK_LAUNCH': False, # When True, courses will be active for staff only
|
||||
|
||||
'ENABLE_TEXTBOOK' : True,
|
||||
'ENABLE_DISCUSSION' : True,
|
||||
@@ -95,12 +94,12 @@ system_node_path = os.environ.get("NODE_PATH", None)
|
||||
if system_node_path is None:
|
||||
system_node_path = "/usr/local/lib/node_modules"
|
||||
|
||||
node_paths = [COMMON_ROOT / "static/js/vendor",
|
||||
node_paths = [COMMON_ROOT / "static/js/vendor",
|
||||
COMMON_ROOT / "static/coffee/src",
|
||||
system_node_path
|
||||
]
|
||||
NODE_PATH = ':'.join(node_paths)
|
||||
|
||||
|
||||
################################## MITXWEB #####################################
|
||||
# This is where we stick our compiled template files. Most of the app uses Mako
|
||||
# templates
|
||||
|
||||
@@ -7,7 +7,7 @@ def url_class(url):
|
||||
return ""
|
||||
%>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%! from courseware.courses import has_staff_access_to_course_id %>
|
||||
<%! from courseware.access import has_access %>
|
||||
|
||||
<nav class="${active_page} course-material">
|
||||
<div class="inner-wrapper">
|
||||
@@ -28,7 +28,7 @@ def url_class(url):
|
||||
% if user.is_authenticated():
|
||||
<li class="profile"><a href="${reverse('profile', args=[course.id])}" class="${url_class('profile')}">Profile</a></li>
|
||||
% endif
|
||||
% if has_staff_access_to_course_id(user, course.id):
|
||||
% if has_access(user, course, 'staff'):
|
||||
<li class="instructor"><a href="${reverse('instructor_dashboard', args=[course.id])}" class="${url_class('instructor')}">Instructor</a></li>
|
||||
% endif
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<%!
|
||||
from django.core.urlresolvers import reverse
|
||||
<%!
|
||||
from django.core.urlresolvers import reverse
|
||||
from courseware.courses import course_image_url, get_course_about_section
|
||||
from courseware.access import has_access
|
||||
%>
|
||||
<%inherit file="main.html" />
|
||||
|
||||
@@ -17,7 +18,7 @@
|
||||
$("#unenroll_course_number").text( $(event.target).data("course-number") );
|
||||
|
||||
});
|
||||
|
||||
|
||||
$(document).delegate('#unenroll_form', 'ajax:success', function(data, json, xhr) {
|
||||
if(json.success) {
|
||||
location.href="${reverse('dashboard')}";
|
||||
@@ -33,7 +34,7 @@
|
||||
</%block>
|
||||
|
||||
<section class="container dashboard">
|
||||
|
||||
|
||||
%if message:
|
||||
<section class="dashboard-banner">
|
||||
${message}
|
||||
@@ -66,7 +67,7 @@
|
||||
|
||||
<article class="my-course">
|
||||
<%
|
||||
if course.has_started() or settings.MITX_FEATURES['DISABLE_START_DATES']:
|
||||
if has_access(user, course, 'load'):
|
||||
course_target = reverse('info', args=[course.id])
|
||||
else:
|
||||
course_target = reverse('about_course', args=[course.id])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<%!
|
||||
from django.core.urlresolvers import reverse
|
||||
<%!
|
||||
from django.core.urlresolvers import reverse
|
||||
from courseware.courses import course_image_url, get_course_about_section
|
||||
from courseware.access import has_access
|
||||
%>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
$(".register").click(function() {
|
||||
$("#class_enroll_form").submit();
|
||||
});
|
||||
|
||||
|
||||
$(document).delegate('#class_enroll_form', 'ajax:success', function(data, json, xhr) {
|
||||
if(json.success) {
|
||||
location.href="${reverse('dashboard')}";
|
||||
@@ -64,7 +65,7 @@
|
||||
%if registered:
|
||||
<%
|
||||
## TODO: move this logic into a view
|
||||
if course.has_started() or settings.MITX_FEATURES['DISABLE_START_DATES']:
|
||||
if has_access(user, course, 'load'):
|
||||
course_target = reverse('info', args=[course.id])
|
||||
else:
|
||||
course_target = reverse('about_course', args=[course.id])
|
||||
|
||||
Reference in New Issue
Block a user