Make course ids and usage ids opaque to LMS and Studio [partial commit]

This commit adds the non-courseware lms/djangoapps and lms/lib.

These keys are now objects with a limited interface, and the particular
internal representation is managed by the data storage layer (the
modulestore).

For the LMS, there should be no outward-facing changes to the system.
The keys are, for now, a change to internal representation only. For
Studio, the new serialized form of the keys is used in urls, to allow
for further migration in the future.

Co-Author: Andy Armstrong <andya@edx.org>
Co-Author: Christina Roberts <christina@edx.org>
Co-Author: David Baumgold <db@edx.org>
Co-Author: Diana Huang <dkh@edx.org>
Co-Author: Don Mitchell <dmitchell@edx.org>
Co-Author: Julia Hansbrough <julia@edx.org>
Co-Author: Nimisha Asthagiri <nasthagiri@edx.org>
Co-Author: Sarina Canelake <sarina@edx.org>

[LMS-2370]
This commit is contained in:
Calen Pennington
2014-04-30 10:17:54 -04:00
parent 7852906ce0
commit cd746bf8e5
114 changed files with 1992 additions and 1916 deletions

View File

@@ -68,6 +68,9 @@ from .tools import (
strip_if_string,
)
from xmodule.modulestore import Location
from xmodule.modulestore.locations import SlashSeparatedCourseKey
from xmodule.modulestore.keys import UsageKey
from opaque_keys import InvalidKeyError
log = logging.getLogger(__name__)
@@ -191,9 +194,9 @@ def require_level(level):
def decorator(func): # pylint: disable=C0111
def wrapped(*args, **kwargs): # pylint: disable=C0111
request = args[0]
course = get_course_by_id(kwargs['course_id'])
course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(kwargs['course_id']))
if has_access(request.user, course, level):
if has_access(request.user, level, course):
return func(*args, **kwargs)
else:
return HttpResponseForbidden()
@@ -242,6 +245,7 @@ def students_update_enrollment(request, course_id):
]
}
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
action = request.GET.get('action')
identifiers_raw = request.GET.get('identifiers')
@@ -331,6 +335,7 @@ def bulk_beta_modify_access(request, course_id):
anything split_input_list can handle.
- action is one of ['add', 'remove']
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
action = request.GET.get('action')
identifiers_raw = request.GET.get('identifiers')
identifiers = _split_input_list(identifiers_raw)
@@ -413,8 +418,9 @@ def modify_access(request, course_id):
rolename is one of ['instructor', 'staff', 'beta']
action is one of ['allow', 'revoke']
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course = get_course_with_access(
request.user, course_id, 'instructor', depth=None
request.user, 'instructor', course_id, depth=None
)
try:
user = get_student_from_identifier(request.GET.get('unique_student_identifier'))
@@ -494,8 +500,9 @@ def list_course_role_members(request, course_id):
]
}
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course = get_course_with_access(
request.user, course_id, 'instructor', depth=None
request.user, 'instructor', course_id, depth=None
)
rolename = request.GET.get('rolename')
@@ -513,7 +520,7 @@ def list_course_role_members(request, course_id):
}
response_payload = {
'course_id': course_id,
'course_id': course_id.to_deprecated_string(),
rolename: map(extract_user_info, list_with_level(
course, rolename
)),
@@ -528,13 +535,14 @@ def get_grading_config(request, course_id):
"""
Respond with json which contains a html formatted grade summary.
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course = get_course_with_access(
request.user, course_id, 'staff', depth=None
request.user, 'staff', course_id, depth=None
)
grading_config_summary = analytics.basic.dump_grading_context(course)
response_payload = {
'course_id': course_id,
'course_id': course_id.to_deprecated_string(),
'grading_config_summary': grading_config_summary,
}
return JsonResponse(response_payload)
@@ -552,6 +560,8 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=W06
TO DO accept requests for different attribute sets.
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
available_features = analytics.basic.AVAILABLE_FEATURES
query_features = [
'username', 'name', 'email', 'language', 'location', 'year_of_birth',
@@ -578,7 +588,7 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=W06
if not csv:
response_payload = {
'course_id': course_id,
'course_id': course_id.to_deprecated_string(),
'students': student_data,
'students_count': len(student_data),
'queried_features': query_features,
@@ -601,6 +611,7 @@ def get_anon_ids(request, course_id): # pylint: disable=W0613
# TODO: the User.objects query and CSV generation here could be
# centralized into analytics. Currently analytics has similar functionality
# but not quite what's needed.
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
def csv_response(filename, header, rows):
"""Returns a CSV http response for the given header and rows (excel/utf-8)."""
response = HttpResponse(mimetype='text/csv')
@@ -620,7 +631,7 @@ def get_anon_ids(request, course_id): # pylint: disable=W0613
).order_by('id')
header = ['User ID', 'Anonymized user ID']
rows = [[s.id, unique_id_for_user(s)] for s in students]
return csv_response(course_id.replace('/', '-') + '-anon-ids.csv', header, rows)
return csv_response(course_id.to_deprecated_string().replace('/', '-') + '-anon-ids.csv', header, rows)
@ensure_csrf_cookie
@@ -635,6 +646,7 @@ def get_distribution(request, course_id):
empty response['feature_results'] object.
A list of available will be available in the response['available_features']
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
feature = request.GET.get('feature')
# alternate notations of None
if feature in (None, 'null', ''):
@@ -650,7 +662,7 @@ def get_distribution(request, course_id):
))
response_payload = {
'course_id': course_id,
'course_id': course_id.to_deprecated_string(),
'queried_feature': feature,
'available_features': available_features,
'feature_display_names': analytics.distributions.DISPLAY_NAMES,
@@ -689,12 +701,13 @@ def get_student_progress_url(request, course_id):
'progress_url': '/../...'
}
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
user = get_student_from_identifier(request.GET.get('unique_student_identifier'))
progress_url = reverse('student_progress', kwargs={'course_id': course_id, 'student_id': user.id})
progress_url = reverse('student_progress', kwargs={'course_id': course_id.to_deprecated_string(), 'student_id': user.id})
response_payload = {
'course_id': course_id,
'course_id': course_id.to_deprecated_string(),
'progress_url': progress_url,
}
return JsonResponse(response_payload)
@@ -725,8 +738,9 @@ def reset_student_attempts(request, course_id):
requires instructor access
mutually exclusive with all_students
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course = get_course_with_access(
request.user, course_id, 'staff', depth=None
request.user, 'staff', course_id, depth=None
)
problem_to_reset = strip_if_string(request.GET.get('problem_to_reset'))
@@ -749,10 +763,13 @@ def reset_student_attempts(request, course_id):
# instructor authorization
if all_students or delete_module:
if not has_access(request.user, course, 'instructor'):
if not has_access(request.user, 'instructor', course):
return HttpResponseForbidden("Requires instructor access.")
module_state_key = _msk_from_problem_urlname(course_id, problem_to_reset)
try:
module_state_key = UsageKey.from_string(problem_to_reset)
except InvalidKeyError:
return HttpResponseBadRequest()
response_payload = {}
response_payload['problem_to_reset'] = problem_to_reset
@@ -768,7 +785,7 @@ def reset_student_attempts(request, course_id):
return HttpResponse(error_msg, status=500)
response_payload['student'] = student_identifier
elif all_students:
instructor_task.api.submit_reset_problem_attempts_for_all_students(request, course_id, module_state_key)
instructor_task.api.submit_reset_problem_attempts_for_all_students(request, module_state_key)
response_payload['task'] = 'created'
response_payload['student'] = 'All Students'
else:
@@ -794,6 +811,7 @@ def rescore_problem(request, course_id):
all_students and unique_student_identifier cannot both be present.
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
problem_to_reset = strip_if_string(request.GET.get('problem_to_reset'))
student_identifier = request.GET.get('unique_student_identifier', None)
student = None
@@ -810,17 +828,20 @@ def rescore_problem(request, course_id):
"Cannot rescore with all_students and unique_student_identifier."
)
module_state_key = _msk_from_problem_urlname(course_id, problem_to_reset)
try:
module_state_key = UsageKey.from_string(problem_to_reset)
except InvalidKeyError:
return HttpResponseBadRequest()
response_payload = {}
response_payload['problem_to_reset'] = problem_to_reset
if student:
response_payload['student'] = student_identifier
instructor_task.api.submit_rescore_problem_for_student(request, course_id, module_state_key, student)
instructor_task.api.submit_rescore_problem_for_student(request, module_state_key, student)
response_payload['task'] = 'created'
elif all_students:
instructor_task.api.submit_rescore_problem_for_all_students(request, course_id, module_state_key)
instructor_task.api.submit_rescore_problem_for_all_students(request, module_state_key)
response_payload['task'] = 'created'
else:
return HttpResponseBadRequest()
@@ -874,6 +895,7 @@ def list_background_email_tasks(request, course_id): # pylint: disable=unused-a
"""
List background email tasks.
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
task_type = 'bulk_course_email'
# Specifying for the history of a single task type
tasks = instructor_task.api.get_instructor_task_history(course_id, task_type=task_type)
@@ -893,22 +915,26 @@ def list_instructor_tasks(request, course_id):
Takes optional query paremeters.
- With no arguments, lists running tasks.
- `problem_urlname` lists task history for problem
- `problem_urlname` and `unique_student_identifier` lists task
- `problem_location_str` lists task history for problem
- `problem_location_str` and `unique_student_identifier` lists task
history for problem AND student (intersection)
"""
problem_urlname = strip_if_string(request.GET.get('problem_urlname', False))
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
problem_location_str = strip_if_string(request.GET.get('problem_location_str', False))
student = request.GET.get('unique_student_identifier', None)
if student is not None:
student = get_student_from_identifier(student)
if student and not problem_urlname:
if student and not problem_location_str:
return HttpResponseBadRequest(
"unique_student_identifier must accompany problem_urlname"
"unique_student_identifier must accompany problem_location_str"
)
if problem_urlname:
module_state_key = _msk_from_problem_urlname(course_id, problem_urlname)
if problem_location_str:
try:
module_state_key = UsageKey.from_string(problem_location_str)
except InvalidKeyError:
return HttpResponseBadRequest()
if student:
# Specifying for a single student's history on this problem
tasks = instructor_task.api.get_instructor_task_history(course_id, module_state_key, student)
@@ -932,6 +958,7 @@ def list_report_downloads(_request, course_id):
"""
List grade CSV files that are available for download for this course.
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
report_store = ReportStore.from_config()
response_payload = {
@@ -950,8 +977,9 @@ def calculate_grades_csv(request, course_id):
"""
AlreadyRunningError is raised if the course's grades are already being updated.
"""
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
try:
instructor_task.api.submit_calculate_grades_csv(request, course_id)
instructor_task.api.submit_calculate_grades_csv(request, course_key)
success_status = _("Your grade report is being generated! You can view the status of the generation task in the 'Pending Instructor Tasks' section.")
return JsonResponse({"status": success_status})
except AlreadyRunningError:
@@ -976,8 +1004,9 @@ def list_forum_members(request, course_id):
Takes query parameter `rolename`.
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course = get_course_by_id(course_id)
has_instructor_access = has_access(request.user, course, 'instructor')
has_instructor_access = has_access(request.user, 'instructor', course)
has_forum_admin = has_forum_access(
request.user, course_id, FORUM_ROLE_ADMINISTRATOR
)
@@ -1016,7 +1045,7 @@ def list_forum_members(request, course_id):
}
response_payload = {
'course_id': course_id,
'course_id': course_id.to_deprecated_string(),
rolename: map(extract_user_info, users),
}
return JsonResponse(response_payload)
@@ -1036,6 +1065,7 @@ def send_email(request, course_id):
- 'subject' specifies email's subject
- 'message' specifies email's content
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
send_to = request.POST.get("send_to")
subject = request.POST.get("subject")
message = request.POST.get("message")
@@ -1047,8 +1077,7 @@ def send_email(request, course_id):
# Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes)
instructor_task.api.submit_bulk_course_email(request, course_id, email.id) # pylint: disable=E1101
response_payload = {'course_id': course_id}
response_payload = {'course_id': course_id.to_deprecated_string()}
return JsonResponse(response_payload)
@@ -1075,8 +1104,9 @@ def update_forum_role_membership(request, course_id):
- `rolename` is one of [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]
- `action` is one of ['allow', 'revoke']
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
course = get_course_by_id(course_id)
has_instructor_access = has_access(request.user, course, 'instructor')
has_instructor_access = has_access(request.user, 'instructor', course)
has_forum_admin = has_forum_access(
request.user, course_id, FORUM_ROLE_ADMINISTRATOR
)
@@ -1101,7 +1131,7 @@ def update_forum_role_membership(request, course_id):
))
user = get_student_from_identifier(unique_student_identifier)
target_is_instructor = has_access(user, course, 'instructor')
target_is_instructor = has_access(user, 'instructor', course)
# cannot revoke instructor
if target_is_instructor and action == 'revoke' and rolename == FORUM_ROLE_ADMINISTRATOR:
return HttpResponseBadRequest("Cannot revoke instructor forum admin privileges.")
@@ -1112,7 +1142,7 @@ def update_forum_role_membership(request, course_id):
return HttpResponseBadRequest("Role does not exist.")
response_payload = {
'course_id': course_id,
'course_id': course_id.to_deprecated_string(),
'action': action,
}
return JsonResponse(response_payload)
@@ -1131,6 +1161,7 @@ def proxy_legacy_analytics(request, course_id):
`aname` is a query parameter specifying which analytic to query.
"""
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
analytics_name = request.GET.get('aname')
# abort if misconfigured
@@ -1140,7 +1171,7 @@ def proxy_legacy_analytics(request, course_id):
url = "{}get?aname={}&course_id={}&apikey={}".format(
settings.ANALYTICS_SERVER_URL,
analytics_name,
course_id,
course_id.to_deprecated_string(),
settings.ANALYTICS_API_KEY,
)
@@ -1175,9 +1206,9 @@ def _display_unit(unit):
"""
name = getattr(unit, 'display_name', None)
if name:
return u'{0} ({1})'.format(name, unit.location.url())
return u'{0} ({1})'.format(name, unit.location.to_deprecated_string())
else:
return unit.location.url()
return unit.location.to_deprecated_string()
@handle_dashboard_error
@@ -1189,7 +1220,7 @@ def change_due_date(request, course_id):
"""
Grants a due date extension to a student for a particular unit.
"""
course = get_course_by_id(course_id)
course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(course_id))
student = get_student_from_identifier(request.GET.get('student'))
unit = find_unit(course, request.GET.get('url'))
due_date = parse_datetime(request.GET.get('due_datetime'))
@@ -1210,7 +1241,7 @@ def reset_due_date(request, course_id):
"""
Rescinds a due date extension for a student on a particular unit.
"""
course = get_course_by_id(course_id)
course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(course_id))
student = get_student_from_identifier(request.GET.get('student'))
unit = find_unit(course, request.GET.get('url'))
set_due_date_extension(course, unit, student, None)
@@ -1230,7 +1261,7 @@ def show_unit_extensions(request, course_id):
"""
Shows all of the students which have due date extensions for the given unit.
"""
course = get_course_by_id(course_id)
course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(course_id))
unit = find_unit(course, request.GET.get('url'))
return JsonResponse(dump_module_extensions(course, unit))
@@ -1246,7 +1277,7 @@ def show_student_extensions(request, course_id):
particular course.
"""
student = get_student_from_identifier(request.GET.get('student'))
course = get_course_by_id(course_id)
course = get_course_by_id(SlashSeparatedCourseKey.from_deprecated_string(course_id))
return JsonResponse(dump_student_extensions(course, student))
@@ -1267,23 +1298,3 @@ def _split_input_list(str_list):
new_list = [s for s in new_list if s != '']
return new_list
def _msk_from_problem_urlname(course_id, urlname):
"""
Convert a 'problem urlname' (name that instructor's input into dashboard)
to a module state key (db field)
"""
if urlname.endswith(".xml"):
urlname = urlname[:-4]
# Combined open ended problems also have state that can be deleted. However,
# prepending "problem" will only allow capa problems to be reset.
# Get around this for xblock problems.
if "/" not in urlname:
urlname = "problem/" + urlname
parts = Location.parse_course_id(course_id)
parts['urlname'] = urlname
module_state_key = u"i4x://{org}/{course}/{urlname}".format(**parts)
return module_state_key