From 92ce8259908443b8b84b53725ff839851e2091db Mon Sep 17 00:00:00 2001 From: Piotr Mitros Date: Wed, 14 Nov 2012 18:55:20 -0500 Subject: [PATCH 001/501] Analytics experiments --- lms/envs/logsettings.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lms/envs/logsettings.py b/lms/envs/logsettings.py index 2b001b0517..c877308997 100644 --- a/lms/envs/logsettings.py +++ b/lms/envs/logsettings.py @@ -79,7 +79,7 @@ def get_logger_config(log_dir, 'level': 'INFO' }, 'tracking': { - 'handlers': ['tracking'], + 'handlers': ['tracking', 'http'], 'level': 'DEBUG', 'propagate': False, }, @@ -121,6 +121,12 @@ def get_logger_config(log_dir, 'maxBytes': 1024 * 1024 * 2, 'backupCount': 5, }, + 'http' : { + 'level': 'DEBUG', + 'class': 'logging.handlers.HTTPHandler', + 'host':'127.0.0.1:14141', + 'url':'/logger', + } }) else: logger_config['handlers'].update({ From 660481bee5a60f0c062aca3b0d5fdfc2f57dc14e Mon Sep 17 00:00:00 2001 From: Piotr Mitros Date: Wed, 14 Nov 2012 20:33:29 -0500 Subject: [PATCH 002/501] Better URL --- lms/envs/logsettings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/envs/logsettings.py b/lms/envs/logsettings.py index c877308997..0c0e0a577e 100644 --- a/lms/envs/logsettings.py +++ b/lms/envs/logsettings.py @@ -125,7 +125,7 @@ def get_logger_config(log_dir, 'level': 'DEBUG', 'class': 'logging.handlers.HTTPHandler', 'host':'127.0.0.1:14141', - 'url':'/logger', + 'url':'/an_evt', } }) else: From d1fe2c3361d2d516a80eecc67df2bbfd6998860a Mon Sep 17 00:00:00 2001 From: JM Van Thong Date: Mon, 26 Nov 2012 18:18:18 -0500 Subject: [PATCH 003/501] Added analytics tab in instructor dashbaord, and call to the analytics back-end to display sample data. --- lms/djangoapps/instructor/views.py | 10 ++++++++++ lms/envs/common.py | 3 +++ lms/envs/dev.py | 7 +++++++ .../courseware/instructor_dashboard.html | 18 +++++++++++++++++- 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index f985cc43a0..31faf8acb6 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -12,6 +12,7 @@ from django.http import HttpResponse from django_future.csrf import ensure_csrf_cookie from django.views.decorators.cache import cache_control from mitxmako.shortcuts import render_to_response +import requests from courseware import grades from courseware.access import has_access, get_access_group_name @@ -266,8 +267,16 @@ def instructor_dashboard(request, course_id): if idash_mode=='Psychometrics': problems = psychoanalyze.problems_with_psychometric_data(course_id) + #---------------------------------------- + # analytics + analytics_json = None + if idash_mode == 'Analytics': + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_daily_activity?sid=2") + #analytics_html = req.text + analytics_json = req.json + #---------------------------------------- # context for rendering context = {'course': course, @@ -282,6 +291,7 @@ def instructor_dashboard(request, course_id): 'plots': plots, # psychometrics 'course_errors': modulestore().get_item_errors(course.location), 'djangopid' : os.getpid(), + 'analytics_json' : analytics_json, } return render_to_response('courseware/instructor_dashboard.html', context) diff --git a/lms/envs/common.py b/lms/envs/common.py index dd9013bcb3..5e22e70307 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -81,6 +81,9 @@ MITX_FEATURES = { 'AUTH_USE_OPENID': False, 'AUTH_USE_MIT_CERTIFICATES' : False, 'AUTH_USE_OPENID_PROVIDER': False, + + # analytics experiments + 'ENABLE_INSTRUCTOR_ANALYTICS' : False, } # Used for A/B testing diff --git a/lms/envs/dev.py b/lms/envs/dev.py index bf72284425..0db028866a 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -21,6 +21,8 @@ MITX_FEATURES['SUBDOMAIN_BRANDING'] = True MITX_FEATURES['FORCE_UNIVERSITY_DOMAIN'] = None # show all university courses if in dev (ie don't use HTTP_HOST) MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard) +MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True + WIKI_ENABLED = True @@ -177,3 +179,8 @@ PIPELINE_SASS_ARGUMENTS = '-r {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.for MITX_FEATURES['ENABLE_PEARSON_HACK_TEST'] = True PEARSON_TEST_USER = "pearsontest" PEARSON_TEST_PASSWORD = "12345" + +########################## ANALYTICS TESTING ######################## + +ANALYTICS_SERVER_URL = "http://127.0.0.1:14141/" + diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 74bc25fcbe..de619a6144 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -57,7 +57,11 @@ function goto( mode) Psychometrics | %endif Admin | - Forum Admin ] + Forum Admin + %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_ANALYTICS'): + | Analytics + %endif + ]
${djangopid}
@@ -165,6 +169,18 @@ function goto( mode) +##----------------------------------------------------------------------------- +%if modeflag.get('Analytics'): + + % for r in analytics_json: + + + + % endfor +
${r['day']}${r['student_count']}
+ +%endif + ##----------------------------------------------------------------------------- %if modeflag.get('Psychometrics') is None: From 5ecacb0103b64729a49ce3d1e032ec098c1c831d Mon Sep 17 00:00:00 2001 From: JM Van Thong Date: Thu, 29 Nov 2012 12:06:57 -0500 Subject: [PATCH 004/501] Implemented 3 analytics to the instructor dashboard: StudentsEnrolled, StudentsPerHomework, DailyActivityAnalyzer. --- lms/djangoapps/instructor/views.py | 15 ++++++++-- .../courseware/instructor_dashboard.html | 29 ++++++++++++++++--- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index 31faf8acb6..666a5d2025 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -271,12 +271,19 @@ def instructor_dashboard(request, course_id): # analytics analytics_json = None + students_enrolled_json = None + daily_activity_json = None if idash_mode == 'Analytics': - req = requests.get(settings.ANALYTICS_SERVER_URL + "get_daily_activity?sid=2") - #analytics_html = req.text + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsPerHomework&course_id=%s" % course_id) analytics_json = req.json - + + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsEnrolled&course_id=%s" % course_id) + students_enrolled_json = req.json + + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=DailyActivityAnalyzer&from=2012-11-19&to=2012-11-27") + daily_activity_json = req.json + #---------------------------------------- # context for rendering context = {'course': course, @@ -292,6 +299,8 @@ def instructor_dashboard(request, course_id): 'course_errors': modulestore().get_item_errors(course.location), 'djangopid' : os.getpid(), 'analytics_json' : analytics_json, + 'students_enrolled_json' : students_enrolled_json, + 'daily_activity_json' : daily_activity_json, } return render_to_response('courseware/instructor_dashboard.html', context) diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index de619a6144..0d12fbc444 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -171,13 +171,34 @@ function goto( mode) ##----------------------------------------------------------------------------- %if modeflag.get('Analytics'): - - % for r in analytics_json: + +

Number of students enrolled: ${students_enrolled_json['data']['nb_students_enrolled']} +

+ +

+ Students who attempted at least one exercise: +

+ + % for k,v in analytics_json['data'].items(): - + % endfor -
ModuleNumber of students
${r['day']}${r['student_count']}${k} ${v}
+ +

+ +

+ Daily activity: + + + % for k,v in daily_activity_json['data'].items(): + + + + % endfor +
DayNumber of students
${k} ${v}
+

+ %endif From 1732fc4804c6f5847c2a19451319ca71118346cf Mon Sep 17 00:00:00 2001 From: JM Van Thong Date: Mon, 3 Dec 2012 18:44:19 -0500 Subject: [PATCH 005/501] Improve analytics table style and added scroll. --- .../courseware/instructor_dashboard.html | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 0d12fbc444..6d696a3558 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -33,6 +33,10 @@ table.stat_table td { border-color: #666666; background-color: #ffffff; } +.divScroll { + height: 200px; + overflow: scroll; +} a.selectedmode { background-color: yellow; } @@ -176,21 +180,25 @@ function goto( mode)

- Students who attempted at least one exercise: - - +

Students who attempted at least one exercise:

+ +
+
ModuleNumber of students
+ % for k,v in analytics_json['data'].items(): % endfor
ModuleNumber of students
${k} ${v}
+ +

- Daily activity: - - +

Daily activity:

+
DayNumber of students
+ % for k,v in daily_activity_json['data'].items(): From 39d48b1966fca0d3b5629907f5060875be573478 Mon Sep 17 00:00:00 2001 From: JM Van Thong Date: Tue, 4 Dec 2012 12:00:27 -0500 Subject: [PATCH 006/501] Change daily activity to be course specific. --- lms/djangoapps/instructor/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index 666a5d2025..ffada9a482 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -281,7 +281,7 @@ def instructor_dashboard(request, course_id): req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsEnrolled&course_id=%s" % course_id) students_enrolled_json = req.json - req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=DailyActivityAnalyzer&from=2012-11-19&to=2012-11-27") + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=DailyActivityAnalyzer&from=2012-11-19&to=2012-12-04&course_id=%s" % course_id) daily_activity_json = req.json #---------------------------------------- From b10b6e2b3f1ce98d74eb24f51519e2f121334526 Mon Sep 17 00:00:00 2001 From: JM Van Thong Date: Wed, 5 Dec 2012 23:21:00 -0500 Subject: [PATCH 007/501] Added more analytics to instructor dashboard. --- lms/djangoapps/instructor/views.py | 24 ++++++++++++++++- .../courseware/instructor_dashboard.html | 26 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index ffada9a482..f638d55f61 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -5,6 +5,8 @@ import csv import logging import os import urllib +import datetime +from datetime import datetime, timedelta from django.conf import settings from django.contrib.auth.models import User, Group @@ -273,15 +275,33 @@ def instructor_dashboard(request, course_id): analytics_json = None students_enrolled_json = None daily_activity_json = None + students_daily_activity_json = None + students_per_problem_correct_json = None if idash_mode == 'Analytics': req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsPerHomework&course_id=%s" % course_id) analytics_json = req.json + # get the day + to_day = datetime.today().date() + from_day = to_day - timedelta(days=7) + + # number of students active in the past 7 days (including current day) + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsDailyActivity&course_id=%s&from=%s" % (course_id,from_day)) + students_daily_activity_json = req.json + + # number of students per problem who have problem graded correct + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsPerProblemCorrect&course_id=%s&from=%s" % (course_id,from_day)) + students_per_problem_correct_json = req.json + + # number of students enrolled in this course req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsEnrolled&course_id=%s" % course_id) students_enrolled_json = req.json - req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=DailyActivityAnalyzer&from=2012-11-19&to=2012-12-04&course_id=%s" % course_id) + # number of students active in the past 7 days (including current day) --- online version! + to_day = datetime.today().date() + from_day = to_day - timedelta(days=7) + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=DailyActivityAnalyzer&course_id=%s&from=%s&to=%s" % (course_id,from_day, to_day)) daily_activity_json = req.json #---------------------------------------- @@ -301,6 +321,8 @@ def instructor_dashboard(request, course_id): 'analytics_json' : analytics_json, 'students_enrolled_json' : students_enrolled_json, 'daily_activity_json' : daily_activity_json, + 'students_daily_activity_json' : students_daily_activity_json, + 'students_per_problem_correct_json' : students_per_problem_correct_json, } return render_to_response('courseware/instructor_dashboard.html', context) diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 6d696a3558..f15647ccd2 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -179,6 +179,30 @@ function goto( mode)

Number of students enrolled: ${students_enrolled_json['data']['nb_students_enrolled']}

+

+

Daily activity for the past 7 days:

+
DayNumber of students
${k} ${v}
+ + % for k,v in students_daily_activity_json['data'].items(): + + + + % endfor +
DayNumber of students
${k} ${v}
+

+ +

+

Number of students with correct problems for the past 7 days:

+ + + % for k,v in students_per_problem_correct_json['data'].items(): + + + + % endfor +
ProblemNumber of students
${k} ${v}
+

+

Students who attempted at least one exercise:

@@ -196,7 +220,7 @@ function goto( mode)

-

Daily activity:

+

Daily activity (online version):

% for k,v in daily_activity_json['data'].items(): From 27b8f01ffac2e8aa47e499e8ac0a5846e5b68227 Mon Sep 17 00:00:00 2001 From: JM Van Thong Date: Fri, 7 Dec 2012 17:26:33 -0500 Subject: [PATCH 008/501] Added the following analyzers: StudentsActive, OverallGradeDistribution, StudentsDropoffPerDay. --- lms/djangoapps/instructor/views.py | 43 +++++++++---- .../courseware/instructor_dashboard.html | 63 ++++++++++++++----- 2 files changed, 80 insertions(+), 26 deletions(-) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index f638d55f61..5c743561a9 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -274,36 +274,52 @@ def instructor_dashboard(request, course_id): analytics_json = None students_enrolled_json = None + students_active_json = None daily_activity_json = None students_daily_activity_json = None students_per_problem_correct_json = None + overall_grade_distribution = None + dropoff_per_day = None if idash_mode == 'Analytics': - req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsPerHomework&course_id=%s" % course_id) - analytics_json = req.json - # get the day + # get current day to_day = datetime.today().date() from_day = to_day - timedelta(days=7) - # number of students active in the past 7 days (including current day) - req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsDailyActivity&course_id=%s&from=%s" % (course_id,from_day)) - students_daily_activity_json = req.json - - # number of students per problem who have problem graded correct - req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsPerProblemCorrect&course_id=%s&from=%s" % (course_id,from_day)) - students_per_problem_correct_json = req.json - # number of students enrolled in this course req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsEnrolled&course_id=%s" % course_id) students_enrolled_json = req.json - # number of students active in the past 7 days (including current day) --- online version! + # number of students active in the past 7 days (including current day), i.e. with at least one activity for the period + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsActive&course_id=%s&from=%s" % (course_id,from_day)) + students_active_json = req.json + + # number of students per problem who have problem graded correct + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsPerProblemCorrect&course_id=%s&from=%s" % (course_id,from_day)) + students_per_problem_correct_json = req.json + + # grade distribution for the course + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=OverallGradeDistribution&course_id=%s" % (course_id,)) + overall_grade_distribution = req.json + + # number of students distribution drop off per day + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsDropoffPerDay&course_id=%s&from=%s" % (course_id,from_day)) + dropoff_per_day = req.json + + # the following is ++incorrect++ use of studentmodule table + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsDailyActivity&course_id=%s&from=%s" % (course_id,from_day)) + students_daily_activity_json = req.json + + # number of students active in the past 7 days (including current day) --- online version! experimental to_day = datetime.today().date() from_day = to_day - timedelta(days=7) req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=DailyActivityAnalyzer&course_id=%s&from=%s&to=%s" % (course_id,from_day, to_day)) daily_activity_json = req.json + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsPerHomework&course_id=%s" % course_id) + analytics_json = req.json + #---------------------------------------- # context for rendering context = {'course': course, @@ -320,9 +336,12 @@ def instructor_dashboard(request, course_id): 'djangopid' : os.getpid(), 'analytics_json' : analytics_json, 'students_enrolled_json' : students_enrolled_json, + 'students_active_json' : students_active_json, 'daily_activity_json' : daily_activity_json, 'students_daily_activity_json' : students_daily_activity_json, 'students_per_problem_correct_json' : students_per_problem_correct_json, + 'overall_grade_distribution' : overall_grade_distribution, + 'dropoff_per_day' : dropoff_per_day, } return render_to_response('courseware/instructor_dashboard.html', context) diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index f15647ccd2..b06eb43944 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -176,23 +176,16 @@ function goto( mode) ##----------------------------------------------------------------------------- %if modeflag.get('Analytics'): -

Number of students enrolled: ${students_enrolled_json['data']['nb_students_enrolled']} +

+ Number of students enrolled: ${students_enrolled_json['data']['nb_students_enrolled']} +

+

+ Number of active students for the past 7 days: ${students_active_json['data']['nb_students_active']}

-

Daily activity for the past 7 days:

-
DayNumber of students
- - % for k,v in students_daily_activity_json['data'].items(): - - - - % endfor -
DayNumber of students
${k} ${v}
-

+

Number of active students per problems who have this problem graded as correct:

-

-

Number of students with correct problems for the past 7 days:

% for k,v in students_per_problem_correct_json['data'].items(): @@ -204,7 +197,38 @@ function goto( mode)

-

Students who attempted at least one exercise:

+

Grade distribution:

+ +
+
ProblemNumber of students
+ + % for k,v in overall_grade_distribution['data'].items(): + + + + % endfor +
GradeNumber of students
${k} ${v}
+ + +

+ +

+

Number of students who dropped off per day before becoming inactive:

+ +
+ + + % for k,v in dropoff_per_day['data'].items(): + + + + % endfor +
DayNumber of students
${k} ${v}
+
+

+ +

+

Students who attempted at least one exercise:

@@ -231,6 +255,17 @@ function goto( mode)

+

+

Daily activity for the past 7 days:

+ + + % for k,v in students_daily_activity_json['data'].items(): + + + + % endfor +
DayNumber of students
${k} ${v}
+

%endif From e40b0d0d081e0a1a7e40c29349e5f224aef6ffdd Mon Sep 17 00:00:00 2001 From: JM Van Thong Date: Fri, 14 Dec 2012 17:16:57 -0500 Subject: [PATCH 009/501] Fixed data order when loading json record from request. --- lms/djangoapps/instructor/views.py | 32 +++++++++---------- .../courseware/instructor_dashboard.html | 18 ++--------- 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index 5c743561a9..5da0209ace 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -7,6 +7,8 @@ import os import urllib import datetime from datetime import datetime, timedelta +import json +from collections import OrderedDict from django.conf import settings from django.contrib.auth.models import User, Group @@ -272,11 +274,10 @@ def instructor_dashboard(request, course_id): #---------------------------------------- # analytics - analytics_json = None + attempted_problems = None students_enrolled_json = None students_active_json = None daily_activity_json = None - students_daily_activity_json = None students_per_problem_correct_json = None overall_grade_distribution = None dropoff_per_day = None @@ -287,29 +288,32 @@ def instructor_dashboard(request, course_id): to_day = datetime.today().date() from_day = to_day - timedelta(days=7) + # WARNING: do not use req.json because the preloaded json doesn't preserve the order of the original record + # number of students enrolled in this course req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsEnrolled&course_id=%s" % course_id) - students_enrolled_json = req.json + students_enrolled_json = json.loads(req.content, object_pairs_hook=OrderedDict) # number of students active in the past 7 days (including current day), i.e. with at least one activity for the period req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsActive&course_id=%s&from=%s" % (course_id,from_day)) - students_active_json = req.json + students_active_json = json.loads(req.content, object_pairs_hook=OrderedDict) # number of students per problem who have problem graded correct req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsPerProblemCorrect&course_id=%s&from=%s" % (course_id,from_day)) - students_per_problem_correct_json = req.json + students_per_problem_correct_json = json.loads(req.content, object_pairs_hook=OrderedDict) - # grade distribution for the course + # grade distribution for the course +++ this is not the desired distribution +++ req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=OverallGradeDistribution&course_id=%s" % (course_id,)) - overall_grade_distribution = req.json + overall_grade_distribution = json.loads(req.content, object_pairs_hook=OrderedDict) # number of students distribution drop off per day req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsDropoffPerDay&course_id=%s&from=%s" % (course_id,from_day)) - dropoff_per_day = req.json + dropoff_per_day = json.loads(req.content, object_pairs_hook=OrderedDict) + + # number of students per problem who attempted this problem at least once + req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsAttemptedProblems&course_id=%s" % course_id) + attempted_problems = json.loads(req.content, object_pairs_hook=OrderedDict) - # the following is ++incorrect++ use of studentmodule table - req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsDailyActivity&course_id=%s&from=%s" % (course_id,from_day)) - students_daily_activity_json = req.json # number of students active in the past 7 days (including current day) --- online version! experimental to_day = datetime.today().date() @@ -317,9 +321,6 @@ def instructor_dashboard(request, course_id): req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=DailyActivityAnalyzer&course_id=%s&from=%s&to=%s" % (course_id,from_day, to_day)) daily_activity_json = req.json - req = requests.get(settings.ANALYTICS_SERVER_URL + "get_analytics?aname=StudentsPerHomework&course_id=%s" % course_id) - analytics_json = req.json - #---------------------------------------- # context for rendering context = {'course': course, @@ -334,14 +335,13 @@ def instructor_dashboard(request, course_id): 'plots': plots, # psychometrics 'course_errors': modulestore().get_item_errors(course.location), 'djangopid' : os.getpid(), - 'analytics_json' : analytics_json, 'students_enrolled_json' : students_enrolled_json, 'students_active_json' : students_active_json, 'daily_activity_json' : daily_activity_json, - 'students_daily_activity_json' : students_daily_activity_json, 'students_per_problem_correct_json' : students_per_problem_correct_json, 'overall_grade_distribution' : overall_grade_distribution, 'dropoff_per_day' : dropoff_per_day, + 'attempted_problems' : attempted_problems, } return render_to_response('courseware/instructor_dashboard.html', context) diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index b06eb43944..e347ab2c60 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -177,10 +177,10 @@ function goto( mode) %if modeflag.get('Analytics'):

- Number of students enrolled: ${students_enrolled_json['data']['nb_students_enrolled']} + Number of students enrolled: ${students_enrolled_json['data']['value']}

- Number of active students for the past 7 days: ${students_active_json['data']['nb_students_active']} + Number of active students for the past 7 days: ${students_active_json['data']['value']}

@@ -233,7 +233,7 @@ function goto( mode)

- % for k,v in analytics_json['data'].items(): + % for k,v in attempted_problems['data'].items(): @@ -255,18 +255,6 @@ function goto( mode)
ModuleNumber of students
${k} ${v}

-

-

Daily activity for the past 7 days:

- - - % for k,v in students_daily_activity_json['data'].items(): - - - - % endfor -
DayNumber of students
${k} ${v}
-

- %endif ##----------------------------------------------------------------------------- From 8ba41635572002b45a5725b42d53e1bdda3096c2 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 7 Dec 2012 12:48:57 -0500 Subject: [PATCH 010/501] WIP. Data loads, but not all of it --- cms/djangoapps/contentstore/views.py | 2 +- common/lib/capa/capa/capa_problem.py | 18 +- common/lib/xmodule/xmodule/abtest_module.py | 33 +-- common/lib/xmodule/xmodule/capa_module.py | 154 +++++------- common/lib/xmodule/xmodule/course_module.py | 82 ++++--- common/lib/xmodule/xmodule/error_module.py | 7 +- common/lib/xmodule/xmodule/mako_module.py | 9 +- common/lib/xmodule/xmodule/model.py | 79 +++++++ common/lib/xmodule/xmodule/modulestore/xml.py | 25 +- common/lib/xmodule/xmodule/template_module.py | 4 - common/lib/xmodule/xmodule/x_module.py | 222 +++++------------- common/lib/xmodule/xmodule/xml_module.py | 17 +- jenkins/base.sh | 12 + lms/djangoapps/courseware/courses.py | 8 +- lms/djangoapps/courseware/module_render.py | 4 +- rakefile | 2 +- 16 files changed, 296 insertions(+), 382 deletions(-) create mode 100644 common/lib/xmodule/xmodule/model.py create mode 100644 jenkins/base.sh diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 8f10eadc4b..833662b218 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -499,7 +499,7 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_ """ system = preview_module_system(request, preview_id, descriptor) try: - module = descriptor.xmodule_constructor(system)(instance_state, shared_state) + module = descriptor.xmodule(system) except: module = ErrorDescriptor.from_descriptor( descriptor, diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 451891d067..eb39d8a2c6 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -83,7 +83,7 @@ class LoncapaProblem(object): Main class for capa Problems. ''' - def __init__(self, problem_text, id, state=None, seed=None, system=None): + def __init__(self, problem_text, id, correct_map=None, done=None, seed=None, system=None): ''' Initializes capa Problem. @@ -91,7 +91,8 @@ class LoncapaProblem(object): - problem_text (string): xml defining the problem - id (string): identifier for this problem; often a filename (no spaces) - - state (dict): student state + - correct_map (dict): data specifying whether the student has completed the problem + - done (bool): Whether the student has answered the problem - seed (int): random number generator seed (int) - system (ModuleSystem): ModuleSystem instance which provides OS, rendering, and user context @@ -103,16 +104,11 @@ class LoncapaProblem(object): self.problem_id = id self.system = system self.seed = seed + self.done = done + self.correct_map = CorrectMap() - if state: - if 'seed' in state: - self.seed = state['seed'] - if 'student_answers' in state: - self.student_answers = state['student_answers'] - if 'correct_map' in state: - self.correct_map.set_dict(state['correct_map']) - if 'done' in state: - self.done = state['done'] + if correct_map is not None: + self.correct_map.set_dict(correct_map) # TODO: Does this deplete the Linux entropy pool? Is this fast enough? if not self.seed: diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py index 3091b6ec02..0f655ded6c 100644 --- a/common/lib/xmodule/xmodule/abtest_module.py +++ b/common/lib/xmodule/xmodule/abtest_module.py @@ -7,6 +7,7 @@ from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor from xmodule.xml_module import XmlDescriptor from xmodule.exceptions import InvalidDefinitionError +from .model import String, Scope DEFAULT = "_DEFAULT_GROUP" @@ -68,37 +69,7 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): template_dir_name = "abtest" - def __init__(self, system, definition=None, **kwargs): - """ - definition is a dictionary with the following layout: - {'data': { - 'experiment': 'the name of the experiment', - 'group_portions': { - 'group_a': 0.1, - 'group_b': 0.2 - }, - 'group_contents': { - 'group_a': [ - 'url://for/content/module/1', - 'url://for/content/module/2', - ], - 'group_b': [ - 'url://for/content/module/3', - ], - DEFAULT: [ - 'url://for/default/content/1' - ] - } - }, - 'children': [ - 'url://for/content/module/1', - 'url://for/content/module/2', - 'url://for/content/module/3', - 'url://for/default/content/1', - ]} - """ - kwargs['shared_state_key'] = definition['data']['experiment'] - RawDescriptor.__init__(self, system, definition, **kwargs) + experiment = String(help="Experiment that this A/B test belongs to", scope=Scope.content) @classmethod def definition_from_xml(cls, xml_object, system): diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 9922b1b8a0..6ad6de2be6 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -19,6 +19,9 @@ from progress import Progress from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor from xmodule.exceptions import NotFoundError +from .model import Int, Scope, ModuleScope, ModelType, String, Boolean, Object, Float + +Date = Timedelta = ModelType log = logging.getLogger("mitx.courseware") @@ -77,6 +80,17 @@ class CapaModule(XModule): ''' icon_class = 'problem' + attempts = Int(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.student_state) + max_attempts = Int(help="Maximum number of attempts that a student is allowed", scope=Scope.settings) + due = Date(help="Date that this problem is due by", scope=Scope.settings) + graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) + show_answer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed") + force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings) + rerandomize = String(help="When to rerandomize the problem", default="always") + data = String(help="XML data for the problem", scope=Scope.content) + correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.student_state) + done = Boolean(help="Whether the student has answered the problem", scope=Scope.student_state) + js = {'coffee': [resource_string(__name__, 'js/src/capa/display.coffee'), resource_string(__name__, 'js/src/collapsible.coffee'), resource_string(__name__, 'js/src/javascript_loader.coffee'), @@ -87,51 +101,15 @@ class CapaModule(XModule): js_module_name = "Problem" css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]} - def __init__(self, system, location, definition, descriptor, instance_state=None, - shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, instance_state, - shared_state, **kwargs) + def __init__(self, system, location, descriptor, model_data): + XModule.__init__(self, system, location, descriptor, model_data) - self.attempts = 0 - self.max_attempts = None - - dom2 = etree.fromstring(definition['data']) - - display_due_date_string = self.metadata.get('due', None) - if display_due_date_string is not None: - self.display_due_date = dateutil.parser.parse(display_due_date_string) - #log.debug("Parsed " + display_due_date_string + - # " to " + str(self.display_due_date)) - else: - self.display_due_date = None - - grace_period_string = self.metadata.get('graceperiod', None) - if grace_period_string is not None and self.display_due_date: - self.grace_period = parse_timedelta(grace_period_string) - self.close_date = self.display_due_date + self.grace_period + if self.graceperiod is not None and self.due: + self.close_date = self.due + self.graceperiod #log.debug("Then parsed " + grace_period_string + # " to closing date" + str(self.close_date)) else: - self.grace_period = None - self.close_date = self.display_due_date - - self.max_attempts = self.metadata.get('attempts', None) - if self.max_attempts is not None: - self.max_attempts = int(self.max_attempts) - - self.show_answer = self.metadata.get('showanswer', 'closed') - - self.force_save_button = self.metadata.get('force_save_button', 'false') - - if self.show_answer == "": - self.show_answer = "closed" - - if instance_state is not None: - instance_state = json.loads(instance_state) - if instance_state is not None and 'attempts' in instance_state: - self.attempts = instance_state['attempts'] - - self.name = only_one(dom2.xpath('/problem/@name')) + self.close_date = self.due if self.rerandomize == 'never': self.seed = 1 @@ -148,8 +126,8 @@ class CapaModule(XModule): try: # TODO (vshnayder): move as much as possible of this work and error # checking to descriptor load time - self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(), - instance_state, seed=self.seed, system=self.system) + self.lcp = LoncapaProblem(self.data, self.location.html_id(), + self.correct_map, self.done, self.seed, self.system) except Exception as err: msg = 'cannot create LoncapaProblem {loc}: {err}'.format( loc=self.location.url(), err=err) @@ -168,33 +146,21 @@ class CapaModule(XModule): (self.location.url(), msg)) self.lcp = LoncapaProblem( problem_text, self.location.html_id(), - instance_state, seed=self.seed, system=self.system) + self.correct_map, self.done, self.seed, self.system) else: # add extra info and raise raise Exception(msg), None, sys.exc_info()[2] - @property - def rerandomize(self): - """ - Property accessor that returns self.metadata['rerandomize'] in a - canonical form - """ - rerandomize = self.metadata.get('rerandomize', 'always') - if rerandomize in ("", "always", "true"): - return "always" - elif rerandomize in ("false", "per_student"): - return "per_student" - elif rerandomize == "never": - return "never" - elif rerandomize == "onreset": - return "onreset" - else: - raise Exception("Invalid rerandomize attribute " + rerandomize) + if self.rerandomize in ("", "true"): + self.rerandomize = "always" + elif self.rerandomize == "false": + self.rerandomize = "per_student" - def get_instance_state(self): - state = self.lcp.get_state() - state['attempts'] = self.attempts - return json.dumps(state) + def sync_lcp_state(self): + lcp_state = self.lcp.get_state() + self.done = lcp_state['done'] + self.correct_map = lcp_state['correct_map'] + self.seed = lcp_state['seed'] def get_score(self): return self.lcp.get_score() @@ -211,7 +177,7 @@ class CapaModule(XModule): if total > 0: try: return Progress(score, total) - except Exception as err: + except Exception: log.exception("Got bad progress") return None return None @@ -261,8 +227,8 @@ class CapaModule(XModule): # Next, generate a fresh LoncapaProblem self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(), - state=None, # Tabula rasa seed=self.seed, system=self.system) + self.sync_lcp_state() # Prepend a scary warning to the student warning = '
'\ @@ -280,8 +246,8 @@ class CapaModule(XModule): html = warning try: html += self.lcp.get_html() - except Exception, err: # Couldn't do it. Give up - log.exception(err) + except Exception: # Couldn't do it. Give up + log.exception("Unable to generate html from LoncapaProblem") raise content = {'name': self.display_name, @@ -311,7 +277,7 @@ class CapaModule(XModule): # User submitted a problem, and hasn't reset. We don't want # more submissions. - if self.lcp.done and self.rerandomize == "always": + if self.done and self.rerandomize == "always": check_button = False save_button = False @@ -320,7 +286,7 @@ class CapaModule(XModule): reset_button = False # User hasn't submitted an answer yet -- we don't want resets - if not self.lcp.done: + if not self.done: reset_button = False # We may not need a "save" button if infinite number of attempts and @@ -406,7 +372,7 @@ class CapaModule(XModule): return self.attempts > 0 if self.show_answer == 'answered': - return self.lcp.done + return self.done if self.show_answer == 'closed': return self.closed() @@ -429,6 +395,7 @@ class CapaModule(XModule): queuekey = get['queuekey'] score_msg = get['xqueue_body'] self.lcp.update_score(score_msg, queuekey) + self.sync_lcp_state() return dict() # No AJAX return is needed @@ -445,8 +412,9 @@ class CapaModule(XModule): raise NotFoundError('Answer is not available') else: answers = self.lcp.get_question_answers() + self.sync_lcp_state() - # answers (eg ) may have embedded images + # answers (eg ) may have embedded images # but be careful, some problems are using non-string answer dicts new_answers = dict() for answer_id in answers: @@ -512,7 +480,7 @@ class CapaModule(XModule): raise NotFoundError('Problem is closed') # Problem submitted. Student should reset before checking again - if self.lcp.done and self.rerandomize == "always": + if self.done and self.rerandomize == "always": event_info['failure'] = 'unreset' self.system.track_function('save_problem_check_fail', event_info) raise NotFoundError('Problem must be reset before it can be checked again') @@ -522,14 +490,13 @@ class CapaModule(XModule): current_time = datetime.datetime.now() prev_submit_time = self.lcp.get_recentmost_queuetime() waittime_between_requests = self.system.xqueue['waittime'] - if (current_time-prev_submit_time).total_seconds() < waittime_between_requests: + if (current_time - prev_submit_time).total_seconds() < waittime_between_requests: msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests - return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback + return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback try: - old_state = self.lcp.get_state() - lcp_id = self.lcp.problem_id correct_map = self.lcp.grade_answers(answers) + self.sync_lcp_state() except StudentInputError as inst: log.exception("StudentInputError in capa_module:problem_check") return {'success': inst.message} @@ -554,11 +521,11 @@ class CapaModule(XModule): # 'success' will always be incorrect event_info['correct_map'] = correct_map.get_dict() event_info['success'] = success - event_info['attempts'] = self.attempts + event_info['attempts'] = self.attempts self.system.track_function('save_problem_check', event_info) - if hasattr(self.system,'psychometrics_handler'): # update PsychometricsData using callback - self.system.psychometrics_handler(self.get_instance_state()) + if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback + self.system.psychometrics_handler(self.get_instance_state()) # render problem into HTML html = self.get_problem_html(encapsulate=False) @@ -589,7 +556,7 @@ class CapaModule(XModule): # Problem submitted. Student should reset before saving # again. - if self.lcp.done and self.rerandomize == "always": + if self.done and self.rerandomize == "always": event_info['failure'] = 'done' self.system.track_function('save_problem_fail', event_info) return {'success': False, @@ -617,7 +584,7 @@ class CapaModule(XModule): return {'success': False, 'error': "Problem is closed"} - if not self.lcp.done: + if not self.done: event_info['failure'] = 'not_done' self.system.track_function('reset_problem_fail', event_info) return {'success': False, @@ -629,9 +596,13 @@ class CapaModule(XModule): # in next line) self.lcp.seed = None - self.lcp = LoncapaProblem(self.definition['data'], - self.location.html_id(), self.lcp.get_state(), - system=self.system) + self.lcp = LoncapaProblem(self.data, + self.location.html_id(), + self.lcp.correct_map, + self.lcp.done, + self.lcp.seed, + self.system) + self.sync_lcp_state() event_info['new_state'] = self.lcp.get_state() self.system.track_function('reset_problem', event_info) @@ -647,6 +618,8 @@ class CapaDescriptor(RawDescriptor): module_class = CapaModule + weight = Float(help="How much to weight this problem by", scope=Scope.settings) + stores_state = True has_score = True template_dir_name = 'problem' @@ -665,12 +638,3 @@ class CapaDescriptor(RawDescriptor): 'problems/' + path[8:], path[8:], ] - - def __init__(self, *args, **kwargs): - super(CapaDescriptor, self).__init__(*args, **kwargs) - - weight_string = self.metadata.get('weight', None) - if weight_string: - self.weight = float(weight_string) - else: - self.weight = None diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 474cec0a45..019a79e7ab 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -12,6 +12,9 @@ import requests import time import copy +from .model import Scope, ModelType, List, String, Object, Boolean + +Date = ModelType log = logging.getLogger(__name__) @@ -21,6 +24,39 @@ edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False, class CourseDescriptor(SequenceDescriptor): module_class = SequenceModule + textbooks = List(help="List of pairs of (title, url) for textbooks used in this course", default=[], scope=Scope.content) + wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) + enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings) + enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings) + end = Date(help="Date that this class ends", scope=Scope.settings) + advertised_start = Date(help="Date that this course is advertised to start", scope=Scope.settings) + grading_policy = Object(help="Grading policy definition for this class", scope=Scope.content) + + info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') + + # An extra property is used rather than the wiki_slug/number because + # there are courses that change the number for different runs. This allows + # courses to share the same css_class across runs even if they have + # different numbers. + # + # TODO get rid of this as soon as possible or potentially build in a robust + # way to add in course-specific styling. There needs to be a discussion + # about the right way to do this, but arjun will address this ASAP. Also + # note that the courseware template needs to change when this is removed. + css_class = String(help="DO NOT USE THIS", scope=Scope.settings) + + # TODO: This is a quick kludge to allow CS50 (and other courses) to + # specify their own discussion forums as external links by specifying a + # "discussion_link" in their policy JSON file. This should later get + # folded in with Syllabus, Course Info, and additional Custom tabs in a + # more sensible framework later. + discussion_link = String(help="DO NOT USE THIS", scope=Scope.settings) + + # TODO: same as above, intended to let internal CS50 hide the progress tab + # until we get grade integration set up. + # Explicit comparison to True because we always want to return a bool. + hide_progress_tab = Boolean(help="DO NOT USE THIS", scope=Scope.settings) + template_dir_name = 'course' class Textbook: @@ -69,10 +105,11 @@ class CourseDescriptor(SequenceDescriptor): return table_of_contents - def __init__(self, system, definition=None, **kwargs): - super(CourseDescriptor, self).__init__(system, definition, **kwargs) + def __init__(self, *args, **kwargs): + super(CourseDescriptor, self).__init__(*args, **kwargs) + self.textbooks = [] - for title, book_url in self.definition['data']['textbooks']: + for title, book_url in self.textbooks: try: self.textbooks.append(self.Textbook(title, book_url)) except: @@ -81,7 +118,8 @@ class CourseDescriptor(SequenceDescriptor): log.exception("Couldn't load textbook ({0}, {1})".format(title, book_url)) continue - self.wiki_slug = self.definition['data']['wiki_slug'] or self.location.course + if self.wiki_slug is None: + self.wiki_slug = self.location.course msg = None if self.start is None: @@ -98,7 +136,7 @@ class CourseDescriptor(SequenceDescriptor): # disable the syllabus content for courses that do not provide a syllabus self.syllabus_present = self.system.resources_fs.exists(path('syllabus')) - self.set_grading_policy(self.definition['data'].get('grading_policy', None)) + self.set_grading_policy(self.grading_policy) def defaut_grading_policy(self): """ @@ -203,7 +241,7 @@ class CourseDescriptor(SequenceDescriptor): # cdodge: import the grading policy information that is on disk and put into the # descriptor 'definition' bucket as a dictionary so that it is persisted in the DB - instance.definition['data']['grading_policy'] = policy + instance.grading_policy = policy # now set the current instance. set_grading_policy() will apply some inheritance rules instance.set_grading_policy(policy) @@ -395,38 +433,14 @@ class CourseDescriptor(SequenceDescriptor): @property def start_date_text(self): - displayed_start = self._try_parse_time('advertised_start') or self.start - return time.strftime("%b %d, %Y", displayed_start) + return time.strftime("%b %d, %Y", self.advertised_start or self.start) @property def end_date_text(self): return time.strftime("%b %d, %Y", self.end) - # An extra property is used rather than the wiki_slug/number because - # there are courses that change the number for different runs. This allows - # courses to share the same css_class across runs even if they have - # different numbers. - # - # TODO get rid of this as soon as possible or potentially build in a robust - # way to add in course-specific styling. There needs to be a discussion - # about the right way to do this, but arjun will address this ASAP. Also - # note that the courseware template needs to change when this is removed. - @property - def css_class(self): - return self.metadata.get('css_class', '') - @property - def info_sidebar_name(self): - return self.metadata.get('info_sidebar_name', 'Course Handouts') - @property - def discussion_link(self): - """TODO: This is a quick kludge to allow CS50 (and other courses) to - specify their own discussion forums as external links by specifying a - "discussion_link" in their policy JSON file. This should later get - folded in with Syllabus, Course Info, and additional Custom tabs in a - more sensible framework later.""" - return self.metadata.get('discussion_link', None) @property def forum_posts_allowed(self): @@ -443,12 +457,6 @@ class CourseDescriptor(SequenceDescriptor): return True - @property - def hide_progress_tab(self): - """TODO: same as above, intended to let internal CS50 hide the progress tab - until we get grade integration set up.""" - # Explicit comparison to True because we always want to return a bool. - return self.metadata.get('hide_progress_tab') == True @property def end_of_course_survey_url(self): diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py index 65fceb77c7..0f95bcd256 100644 --- a/common/lib/xmodule/xmodule/error_module.py +++ b/common/lib/xmodule/xmodule/error_module.py @@ -74,12 +74,11 @@ class ErrorDescriptor(JSONEditingDescriptor): } # real metadata stays in the content, but add a display name - metadata = {'display_name': 'Error: ' + location.name} + model_data = {'display_name': 'Error: ' + location.name} return ErrorDescriptor( system, - definition, - location=location, - metadata=metadata + location, + model_data, ) def get_context(self): diff --git a/common/lib/xmodule/xmodule/mako_module.py b/common/lib/xmodule/xmodule/mako_module.py index f5f2fae23b..bdf3cb4749 100644 --- a/common/lib/xmodule/xmodule/mako_module.py +++ b/common/lib/xmodule/xmodule/mako_module.py @@ -21,20 +21,19 @@ class MakoModuleDescriptor(XModuleDescriptor): the descriptor as the `module` parameter to that template """ - def __init__(self, system, definition=None, **kwargs): + def __init__(self, system, location, model_data): if getattr(system, 'render_template', None) is None: raise TypeError('{system} must have a render_template function' ' in order to use a MakoDescriptor'.format( system=system)) - super(MakoModuleDescriptor, self).__init__(system, definition, **kwargs) + super(MakoModuleDescriptor, self).__init__(system, location, model_data) def get_context(self): """ Return the context to render the mako template with """ return {'module': self, - 'metadata': self.metadata, - 'editable_metadata_fields' : self.editable_metadata_fields + 'editable_metadata_fields': self.editable_fields } def get_html(self): @@ -44,6 +43,6 @@ class MakoModuleDescriptor(XModuleDescriptor): # cdodge: encapsulate a means to expose "editable" metadata fields (i.e. not internal system metadata) @property def editable_metadata_fields(self): - subset = [name for name in self.metadata.keys() if name not in self.system_metadata_fields] + subset = [field.name for field in self.fields if field.name not in self.system_metadata_fields] return subset diff --git a/common/lib/xmodule/xmodule/model.py b/common/lib/xmodule/xmodule/model.py new file mode 100644 index 0000000000..b58ffa267a --- /dev/null +++ b/common/lib/xmodule/xmodule/model.py @@ -0,0 +1,79 @@ +from collections import namedtuple + +class ModuleScope(object): + USAGE, DEFINITION, TYPE, ALL = xrange(4) + + +class Scope(namedtuple('ScopeBase', 'student module')): + pass + +Scope.content = Scope(student=False, module=ModuleScope.DEFINITION) +Scope.student_state = Scope(student=True, module=ModuleScope.USAGE) +Scope.settings = Scope(student=True, module=ModuleScope.USAGE) +Scope.student_preferences = Scope(student=True, module=ModuleScope.TYPE) +Scope.student_info = Scope(student=True, module=ModuleScope.ALL) + + +class ModelType(object): + sequence = 0 + + def __init__(self, help=None, default=None, scope=Scope.content): + self._seq = self.sequence + self._name = "unknown" + self.help = help + self.default = default + self.scope = scope + ModelType.sequence += 1 + + @property + def name(self): + return self._name + + def __get__(self, instance, owner): + if instance is None: + return self + + return instance._model_data.get(self.name, self.default) + + def __set__(self, instance, value): + instance._model_data[self.name] = value + + def __delete__(self, instance): + del instance._model_data[self.name] + + def __repr__(self): + return "<{0.__class__.__name} {0.__name__}>".format(self) + + def __lt__(self, other): + return self._seq < other._seq + +Int = Float = Boolean = Object = List = String = Any = ModelType + + +class ModelMetaclass(type): + def __new__(cls, name, bases, attrs): + # Find registered methods + reg_methods = {} + for value in attrs.itervalues(): + for reg_type, names in getattr(value, "_method_registrations", {}).iteritems(): + for n in names: + reg_methods[reg_type + n] = value + attrs['registered_methods'] = reg_methods + + if attrs.get('has_children', False): + attrs['children'] = ModelType(help='The children of this XModule', default=[], scope=None) + + @property + def child_map(self): + return dict((child.name, child) for child in self.children) + attrs['child_map'] = child_map + + fields = [] + for n, v in attrs.items(): + if isinstance(v, ModelType): + v._name = n + fields.append(v) + fields.sort() + attrs['fields'] = fields + + return super(ModelMetaclass, cls).__new__(cls, name, bases, attrs) diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index e3ad1fb0dd..7c6887696e 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -187,12 +187,13 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): err_msg ) - descriptor.metadata['data_dir'] = course_dir + setattr(descriptor, 'data_dir', course_dir) xmlstore.modules[course_id][descriptor.location] = descriptor - for child in descriptor.get_children(): - parent_tracker.add_parent(child.location, descriptor.location) + if hasattr(descriptor, 'children'): + for child in descriptor.children: + parent_tracker.add_parent(child.location, descriptor.location) return descriptor render_template = lambda: '' @@ -425,14 +426,14 @@ class XMLModuleStore(ModuleStoreBase): # breaks metadata inheritance via get_children(). Instead # (actually, in addition to, for now), we do a final inheritance pass # after we have the course descriptor. - XModuleDescriptor.compute_inherited_metadata(course_descriptor) + #XModuleDescriptor.compute_inherited_metadata(course_descriptor) # now import all pieces of course_info which is expected to be stored # in /info or /info/ self.load_extra_content(system, course_descriptor, 'course_info', self.data_dir / course_dir / 'info', course_dir, url_name) # now import all static tabs which are expected to be stored in - # in /tabs or /tabs/ + # in /tabs or /tabs/ self.load_extra_content(system, course_descriptor, 'static_tab', self.data_dir / course_dir / 'tabs', course_dir, url_name) self.load_extra_content(system, course_descriptor, 'custom_tag_template', self.data_dir / course_dir / 'custom_tags', course_dir, url_name) @@ -444,30 +445,30 @@ class XMLModuleStore(ModuleStoreBase): def load_extra_content(self, system, course_descriptor, category, base_dir, course_dir, url_name): if url_name: - path = base_dir / url_name + path = base_dir / url_name if not os.path.exists(path): path = base_dir - for filepath in glob.glob(path/ '*'): + for filepath in glob.glob(path / '*'): with open(filepath) as f: try: html = f.read().decode('utf-8') # tabs are referenced in policy.json through a 'slug' which is just the filename without the .html suffix slug = os.path.splitext(os.path.basename(filepath))[0] loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug) - module = HtmlDescriptor(system, definition={'data' : html}, **{'location' : loc}) + module = HtmlDescriptor(system, loc, {'data': html}) # VS[compat]: # Hack because we need to pull in the 'display_name' for static tabs (because we need to edit them) # from the course policy if category == "static_tab": for tab in course_descriptor.tabs or []: if tab.get('url_slug') == slug: - module.metadata['display_name'] = tab['name'] - module.metadata['data_dir'] = course_dir - self.modules[course_descriptor.id][module.location] = module + module.display_name = tab['name'] + module.data_dir = course_dir + self.modules[course_descriptor.id][module.location] = module except Exception, e: - logging.exception("Failed to load {0}. Skipping... Exception: {1}".format(filepath, str(e))) + logging.exception("Failed to load {0}. Skipping... Exception: {1}".format(filepath, str(e))) system.error_tracker("ERROR: " + str(e)) def get_instance(self, course_id, location, depth=0): diff --git a/common/lib/xmodule/xmodule/template_module.py b/common/lib/xmodule/xmodule/template_module.py index 07ef2c6511..f14254c011 100644 --- a/common/lib/xmodule/xmodule/template_module.py +++ b/common/lib/xmodule/xmodule/template_module.py @@ -67,10 +67,6 @@ class CustomTagDescriptor(RawDescriptor): return template.render(**params) - def __init__(self, system, definition, **kwargs): - '''Render and save the template for this descriptor instance''' - super(CustomTagDescriptor, self).__init__(system, definition, **kwargs) - @property def rendered_html(self): return self.render_template(self.system, self.definition['data']) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 690f78fd53..9cf45652ca 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -2,19 +2,41 @@ import logging import pkg_resources import yaml import os +import time -from functools import partial from lxml import etree from pprint import pprint from collections import namedtuple from pkg_resources import resource_listdir, resource_string, resource_isdir from xmodule.modulestore import Location -from xmodule.timeparse import parse_time, stringify_time +from .model import ModelMetaclass, String, Scope, ModuleScope, ModelType -from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX -from xmodule.modulestore.exceptions import ItemNotFoundError -import time +Date = ModelType + + +class Date(ModelType): + time_format = "%Y-%m-%dT%H:%M" + + def from_json(self, value): + """ + Parse an optional metadata key containing a time: if present, complain + if it doesn't parse. + Return None if not present or invalid. + """ + try: + return time.strptime(value, self.time_format) + except ValueError as e: + msg = "Field {0} has bad value '{1}': '{2}'".format( + self._name, value, e) + log.warning(msg) + return None + + def to_json(self, value): + """ + Convert a time struct to a string + """ + return time.strftime(self.time_format, value) log = logging.getLogger('mitx.' + __name__) @@ -157,6 +179,10 @@ class XModule(HTMLSnippet): See the HTML module for a simple example. ''' + __metaclass__ = ModelMetaclass + + display_name = String(help="Display name for this module", scope=Scope(student=False, module=ModuleScope.USAGE)) + # The default implementation of get_icon_class returns the icon_class # attribute of the class # @@ -165,8 +191,7 @@ class XModule(HTMLSnippet): # in the module icon_class = 'other' - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): + def __init__(self, system, location, descriptor, model_data): ''' Construct a new xmodule @@ -214,63 +239,25 @@ class XModule(HTMLSnippet): ''' self.system = system self.location = Location(location) - self.definition = definition self.descriptor = descriptor - self.instance_state = instance_state - self.shared_state = shared_state self.id = self.location.url() self.url_name = self.location.name self.category = self.location.category - self.metadata = kwargs.get('metadata', {}) - self._loaded_children = None + self._model_data = model_data - @property - def display_name(self): - ''' - Return a display name for the module: use display_name if defined in - metadata, otherwise convert the url name. - ''' - return self.metadata.get('display_name', - self.url_name.replace('_', ' ')) + if self.display_name is None: + self.display_name = self.url_name.replace('_', ' ') def __unicode__(self): return ''.format(self.id) - def get_children(self): - ''' - Return module instances for all the children of this module. - ''' - if self._loaded_children is None: - child_locations = self.get_children_locations() - children = [self.system.get_module(loc) for loc in child_locations] - # get_module returns None if the current user doesn't have access - # to the location. - self._loaded_children = [c for c in children if c is not None] - - return self._loaded_children - - def get_children_locations(self): - ''' - Returns the locations of each of child modules. - - Overriding this changes the behavior of get_children and - anything that uses get_children, such as get_display_items. - - This method will not instantiate the modules of the children - unless absolutely necessary, so it is cheaper to call than get_children - - These children will be the same children returned by the - descriptor unless descriptor.has_dynamic_children() is true. - ''' - return self.definition.get('children', []) - def get_display_items(self): ''' Returns a list of descendent module instances that will display immediately inside this module. ''' items = [] - for child in self.get_children(): + for child in self.children(): items.extend(child.displayable_items()) return items @@ -290,18 +277,6 @@ class XModule(HTMLSnippet): ### Functions used in the LMS - def get_instance_state(self): - ''' State of the object, as stored in the database - ''' - return '{}' - - def get_shared_state(self): - ''' - Get state that should be shared with other instances - using the same 'shared_state_key' attribute. - ''' - return '{}' - def get_score(self): ''' Score the student received on the problem. ''' @@ -391,7 +366,10 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): """ entry_point = "xmodule.v1" module_class = XModule + __metaclass__ = ModelMetaclass + display_name = String(help="Display name for this module", scope=Scope(student=False, module=ModuleScope.USAGE)) + start = Date(help="Start time when this module is visible", scope=Scope(student=False, module=ModuleScope.USAGE)) # Attributes for inspection of the descriptor stores_state = False # Indicates whether the xmodule state should be # stored in a database (independent of shared state) @@ -424,8 +402,8 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): # ============================= STRUCTURAL MANIPULATION =================== def __init__(self, system, - definition=None, - **kwargs): + location, + model_data): """ Construct a new XModuleDescriptor. The only required arguments are the system, used for interaction with external resources, and the @@ -467,116 +445,36 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): instance of the module data """ self.system = system - self.metadata = kwargs.get('metadata', {}) - self.definition = definition if definition is not None else {} - self.location = Location(kwargs.get('location')) + self.location = Location(location) self.url_name = self.location.name self.category = self.location.category - self.shared_state_key = kwargs.get('shared_state_key') + self._model_data = model_data self._child_instances = None self._inherited_metadata = set() - @property - def display_name(self): - ''' - Return a display name for the module: use display_name if defined in - metadata, otherwise convert the url name. - ''' - return self.metadata.get('display_name', - self.url_name.replace('_', ' ')) - - @property - def start(self): - """ - If self.metadata contains start, return it. Else return None. - """ - if 'start' not in self.metadata: - return None - return self._try_parse_time('start') - - @start.setter - def start(self, value): - if isinstance(value, time.struct_time): - self.metadata['start'] = stringify_time(value) - - @property - def own_metadata(self): - """ - Return the metadata that is not inherited, but was defined on this module. - """ - return dict((k, v) for k, v in self.metadata.items() - if k not in self._inherited_metadata) - - @staticmethod - def compute_inherited_metadata(node): - """Given a descriptor, traverse all of its descendants and do metadata - inheritance. Should be called on a CourseDescriptor after importing a - course. - - NOTE: This means that there is no such thing as lazy loading at the - moment--this accesses all the children.""" - for c in node.get_children(): - c.inherit_metadata(node.metadata) - XModuleDescriptor.compute_inherited_metadata(c) - - def inherit_metadata(self, metadata): - """ - Updates this module with metadata inherited from a containing module. - Only metadata specified in self.inheritable_metadata will - be inherited - """ - # Set all inheritable metadata from kwargs that are - # in self.inheritable_metadata and aren't already set in metadata - for attr in self.inheritable_metadata: - if attr not in self.metadata and attr in metadata: - self._inherited_metadata.add(attr) - self.metadata[attr] = metadata[attr] - - def get_children(self): - """Returns a list of XModuleDescriptor instances for the children of - this module""" - if self._child_instances is None: - self._child_instances = [] - for child_loc in self.definition.get('children', []): - try: - child = self.system.load_item(child_loc) - except ItemNotFoundError: - log.exception('Unable to load item {loc}, skipping'.format(loc=child_loc)) - continue - # TODO (vshnayder): this should go away once we have - # proper inheritance support in mongo. The xml - # datastore does all inheritance on course load. - child.inherit_metadata(self.metadata) - self._child_instances.append(child) - - return self._child_instances - def get_child_by_url_name(self, url_name): """ Return a child XModuleDescriptor with the specified url_name, if it exists, and None otherwise. """ - for c in self.get_children(): + for c in self.children: if c.url_name == url_name: return c return None - def xmodule_constructor(self, system): + def xmodule(self, system): """ Returns a constructor for an XModule. This constructor takes two arguments: instance_state and shared_state, and returns a fully instantiated XModule """ - return partial( - self.module_class, + return self.module_class( system, self.location, - self.definition, self, - metadata=self.metadata + system.xmodule_model_data(self.model_data), ) - def has_dynamic_children(self): """ Returns True if this descriptor has dynamic children for a given @@ -701,31 +599,14 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): return eq def __repr__(self): - return ("{class_}({system!r}, {definition!r}, location={location!r}," - " metadata={metadata!r})".format( + return ("{class_}({system!r}, location={location!r}," + " model_data={model_data!r})".format( class_=self.__class__.__name__, system=self.system, - definition=self.definition, location=self.location, - metadata=self.metadata + model_data=self._model_data, )) - # ================================ Internal helpers ======================= - - def _try_parse_time(self, key): - """ - Parse an optional metadata key containing a time: if present, complain - if it doesn't parse. - Return None if not present or invalid. - """ - if key in self.metadata: - try: - return parse_time(self.metadata[key]) - except ValueError as e: - msg = "Descriptor {0} loaded with a bad metadata key '{1}': '{2}'".format( - self.location.url(), self.metadata[key], e) - log.warning(msg) - return None class DescriptorSystem(object): @@ -867,6 +748,9 @@ class ModuleSystem(object): '''provide uniform access to attributes (like etree)''' self.__dict__[attr] = val + def xmodule_module_data(self, module_data): + return module_data + def __repr__(self): return repr(self.__dict__) diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 38fcaddd20..36bea6edb2 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -287,9 +287,9 @@ class XmlDescriptor(XModuleDescriptor): filepath = cls._format_filepath(xml_object.tag, name_to_pathname(url_name)) definition_xml = cls.load_file(filepath, system.resources_fs, location) else: - definition_xml = xml_object # this is just a pointer, not the real definition content + definition_xml = xml_object # this is just a pointer, not the real definition content - definition = cls.load_definition(definition_xml, system, location) # note this removes metadata + definition = cls.load_definition(definition_xml, system, location) # note this removes metadata # VS[compat] -- make Ike's github preview links work in both old and # new file layouts if is_pointer_tag(xml_object): @@ -299,13 +299,13 @@ class XmlDescriptor(XModuleDescriptor): metadata = cls.load_metadata(definition_xml) # move definition metadata into dict - dmdata = definition.get('definition_metadata','') + dmdata = definition.get('definition_metadata', '') if dmdata: metadata['definition_metadata_raw'] = dmdata try: metadata.update(json.loads(dmdata)) except Exception as err: - log.debug('Error %s in loading metadata %s' % (err,dmdata)) + log.debug('Error %s in loading metadata %s' % (err, dmdata)) metadata['definition_metadata_err'] = str(err) # Set/override any metadata specified by policy @@ -313,11 +313,14 @@ class XmlDescriptor(XModuleDescriptor): if k in system.policy: cls.apply_policy(metadata, system.policy[k]) + model_data = {} + model_data.update(metadata) + model_data.update(definition) + return cls( system, - definition, - location=location, - metadata=metadata, + location, + model_data, ) @classmethod diff --git a/jenkins/base.sh b/jenkins/base.sh new file mode 100644 index 0000000000..c7175e6e52 --- /dev/null +++ b/jenkins/base.sh @@ -0,0 +1,12 @@ + +function github_status { + gcli status create mitx mitx $GIT_COMMIT \ + --params=$1 \ + target_url:$BUILD_URL \ + description:"Build #$BUILD_NUMBER is running" \ + -f csv +} + +function github_mark_failed_on_exit { + trap '[ $? == "0" ] || github_status state:failed' EXIT +} \ No newline at end of file diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 643382b485..5312a228a4 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -85,7 +85,7 @@ 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""" if isinstance(modulestore(), XMLModuleStore): - path = course.metadata['data_dir'] + "/images/course_image.jpg" + path = course.data_dir + "/images/course_image.jpg" return try_staticfiles_lookup(path) else: loc = course.location._replace(tag='c4x', category='asset', name='images_course_image.jpg') @@ -162,7 +162,9 @@ def get_course_about_section(course, section_key): key=section_key, url=course.location.url())) return None elif section_key == "title": - return course.metadata.get('display_name', course.url_name) + if course.display_name is None: + return course.url_name + return course.display_name elif section_key == "university": return course.location.org elif section_key == "number": @@ -220,7 +222,7 @@ def get_course_syllabus_section(course, section_key): filepath = find_file(fs, dirs, section_key + ".html") with fs.open(filepath) as htmlFile: return replace_urls(htmlFile.read().decode('utf-8'), - course.metadata['data_dir'], course_namespace=course.location) + course.data_dir, course_namespace=course.location) except ResourceNotFoundError: log.exception("Missing syllabus section {key} in course {url}".format( key=section_key, url=course.location.url())) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 9343301fb7..9c40767e99 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -245,7 +245,7 @@ def _get_module(user, request, location, student_module_cache, course_id, positi make_psychometrics_data_update_handler(instance_module)) try: - module = descriptor.xmodule_constructor(system)(instance_state, shared_state) + module = descriptor.xmodule(system) except: log.exception("Error creating module from descriptor {0}".format(descriptor)) @@ -259,7 +259,7 @@ def _get_module(user, request, location, student_module_cache, course_id, positi error_msg=exc_info_to_str(sys.exc_info())) # Make an error module - return err_descriptor.xmodule_constructor(system)(None, None) + return err_descriptor.xmodule(system) _get_html = module.get_html diff --git a/rakefile b/rakefile index ca20de9a39..adf16cc462 100644 --- a/rakefile +++ b/rakefile @@ -40,7 +40,7 @@ end def django_admin(system, env, command, *args) django_admin = ENV['DJANGO_ADMIN_PATH'] || select_executable('django-admin.py', 'django-admin') - return "#{django_admin} #{command} --settings=#{system}.envs.#{env} --pythonpath=. #{args.join(' ')}" + return "#{django_admin} #{command} --traceback --settings=#{system}.envs.#{env} --pythonpath=. #{args.join(' ')}" end def django_for_jasmine(system, django_reload) From cbfc7b201af3742c52b5527eae3132d9f6b9aaa7 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 10 Dec 2012 15:40:23 -0500 Subject: [PATCH 011/501] WIP more changes to model definitions. Next Up: actually wiring model data into the rdbms --- cms/djangoapps/contentstore/views.py | 4 +- cms/templates/edit_subsection.html | 8 +- cms/templates/new_item.html | 2 +- cms/templates/overview.html | 6 +- cms/templates/unit.html | 8 +- cms/templates/widgets/header.html | 2 +- cms/templates/widgets/navigation.html | 2 +- cms/templates/widgets/sequence-edit.html | 2 +- cms/templates/widgets/units.html | 2 +- common/djangoapps/student/views.py | 2 +- common/djangoapps/xmodule_modifiers.py | 2 +- common/lib/xmodule/setup.py | 2 +- common/lib/xmodule/xmodule/capa_module.py | 59 +++++--- common/lib/xmodule/xmodule/course_module.py | 12 +- .../lib/xmodule/xmodule/discussion_module.py | 14 +- common/lib/xmodule/xmodule/html_module.py | 11 +- .../xmodule/js/src/video/display.coffee | 1 - common/lib/xmodule/xmodule/model.py | 106 +++++++++++--- .../lib/xmodule/xmodule/modulestore/mongo.py | 3 +- common/lib/xmodule/xmodule/modulestore/xml.py | 8 +- common/lib/xmodule/xmodule/plugin.py | 64 +++++++++ common/lib/xmodule/xmodule/seq_module.py | 38 ++--- common/lib/xmodule/xmodule/vertical_module.py | 8 +- common/lib/xmodule/xmodule/video_module.py | 23 ++- common/lib/xmodule/xmodule/x_module.py | 132 +++++++----------- lms/djangoapps/courseware/grades.py | 6 +- lms/djangoapps/courseware/module_render.py | 21 ++- lms/setup.py | 18 +++ lms/templates/courseware/welcome-back.html | 4 +- lms/templates/video.html | 2 +- lms/xmodule_namespace.py | 23 +++ local-requirements.txt | 1 + 32 files changed, 376 insertions(+), 220 deletions(-) create mode 100644 common/lib/xmodule/xmodule/plugin.py create mode 100644 lms/setup.py create mode 100644 lms/xmodule_namespace.py diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 833662b218..bf706f2996 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -115,7 +115,7 @@ def index(request): return render_to_response('index.html', { 'new_course_template' : Location('i4x', 'edx', 'templates', 'course', 'Empty'), - 'courses': [(course.metadata.get('display_name'), + 'courses': [(course.title, reverse('course_index', args=[ course.location.org, course.location.course, @@ -269,7 +269,7 @@ def edit_unit(request, location): for template in templates: if template.location.category in COMPONENT_TYPES: component_templates[template.location.category].append(( - template.display_name, + template.lms.display_name, template.location.url(), )) diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index d3b6f73f13..53567c73e1 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -21,7 +21,7 @@
- +
@@ -62,11 +62,11 @@
% if subsection.start != parent_item.start and subsection.start: % if parent_start_date is None: -

The date above differs from the release date of ${parent_item.display_name}, which is unset. +

The date above differs from the release date of ${parent_item.lms.display_name}, which is unset. % else: -

The date above differs from the release date of ${parent_item.display_name} – ${parent_start_date.strftime('%m/%d/%Y')} at ${parent_start_date.strftime('%H:%M')}. +

The date above differs from the release date of ${parent_item.lms.display_name} – ${parent_start_date.strftime('%m/%d/%Y')} at ${parent_start_date.strftime('%H:%M')}. % endif - Sync to ${parent_item.display_name}.

+ Sync to ${parent_item.lms.display_name}.

% endif
diff --git a/cms/templates/new_item.html b/cms/templates/new_item.html index 60da39fd2a..3c81b81c7e 100644 --- a/cms/templates/new_item.html +++ b/cms/templates/new_item.html @@ -8,7 +8,7 @@
${module_type}
% for template in module_templates: - ${template.display_name} + ${template.lms.display_name} % endfor
diff --git a/cms/templates/overview.html b/cms/templates/overview.html index 2a46908c55..fad28dbf07 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -132,9 +132,9 @@

- ${section.display_name} + ${section.lms.display_name} @@ -174,7 +174,7 @@ - ${subsection.display_name} + ${subsection.lms.display_name}

diff --git a/cms/templates/unit.html b/cms/templates/unit.html index 0599411a67..efd8123890 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -27,7 +27,7 @@
Delete Draft @@ -100,12 +100,12 @@
  1. - ${section.display_name} + ${section.lms.display_name}
    1. - ${subsection.display_name} + ${subsection.lms.display_name} ${units.enum_units(subsection, actions=False, selected=unit.location)}
    2. diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 5f41452339..76a259296d 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -8,7 +8,7 @@ % if context_course: <% ctx_loc = context_course.location %> › - ${context_course.display_name} › + ${context_course.lms.display_name} › % endif
diff --git a/cms/templates/widgets/navigation.html b/cms/templates/widgets/navigation.html index f7e79bceb3..00b147675d 100644 --- a/cms/templates/widgets/navigation.html +++ b/cms/templates/widgets/navigation.html @@ -60,7 +60,7 @@ data-type="${module.js_module_name}" data-preview-type="${module.module_class.js_module_name}"> - ${module.display_name} + ${module.lms.display_name} % endfor <%include file="module-dropdown.html"/> diff --git a/cms/templates/widgets/sequence-edit.html b/cms/templates/widgets/sequence-edit.html index e9d796784d..daee13fc01 100644 --- a/cms/templates/widgets/sequence-edit.html +++ b/cms/templates/widgets/sequence-edit.html @@ -40,7 +40,7 @@ ${child.display_name} + data-preview-type="${child.module_class.js_module_name}">${child.lms.display_name} handle %endfor diff --git a/cms/templates/widgets/units.html b/cms/templates/widgets/units.html index 8e23b05bf8..f7cad23355 100644 --- a/cms/templates/widgets/units.html +++ b/cms/templates/widgets/units.html @@ -22,7 +22,7 @@ This def will enumerate through a passed in subsection and list all of the units
- ${unit.display_name} + ${unit.lms.display_name} % if actions:
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index e7562f83d0..bfde1b1419 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -231,7 +231,7 @@ def change_enrollment(request): if not has_access(user, course, 'enroll'): return {'success': False, 'error': 'enrollment in {} not allowed at this time' - .format(course.display_name)} + .format(course.lms.display_name)} org, course_num, run=course_id.split("/") statsd.increment("common.student.enrollment", diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 5c19a2f1d7..81f0205c3e 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -32,7 +32,7 @@ def wrap_xmodule(get_html, module, template, context=None): def _get_html(): context.update({ 'content': get_html(), - 'display_name' : module.metadata.get('display_name') if module.metadata is not None else None, + 'display_name': module.lms.display_name, 'class_': module.__class__.__name__, 'module_name': module.js_module_name }) diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 24cddd2047..b3a8dabe1b 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -40,6 +40,6 @@ setup( "static_tab = xmodule.html_module:StaticTabDescriptor", "custom_tag_template = xmodule.raw_module:RawDescriptor", "about = xmodule.html_module:AboutDescriptor" - ] + ], } ) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 6ad6de2be6..acb4b63e1c 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -21,8 +21,6 @@ from xmodule.raw_module import RawDescriptor from xmodule.exceptions import NotFoundError from .model import Int, Scope, ModuleScope, ModelType, String, Boolean, Object, Float -Date = Timedelta = ModelType - log = logging.getLogger("mitx.courseware") #----------------------------------------------------------------------------- @@ -45,25 +43,34 @@ def only_one(lst, default="", process=lambda x: x): raise Exception('Malformed XML: expected at most one element in list.') -def parse_timedelta(time_str): - """ - time_str: A string with the following components: - day[s] (optional) - hour[s] (optional) - minute[s] (optional) - second[s] (optional) +class Timedelta(ModelType): + def from_json(self, time_str): + """ + time_str: A string with the following components: + day[s] (optional) + hour[s] (optional) + minute[s] (optional) + second[s] (optional) - Returns a datetime.timedelta parsed from the string - """ - parts = TIMEDELTA_REGEX.match(time_str) - if not parts: - return - parts = parts.groupdict() - time_params = {} - for (name, param) in parts.iteritems(): - if param: - time_params[name] = int(param) - return timedelta(**time_params) + Returns a datetime.timedelta parsed from the string + """ + parts = TIMEDELTA_REGEX.match(time_str) + if not parts: + return + parts = parts.groupdict() + time_params = {} + for (name, param) in parts.iteritems(): + if param: + time_params[name] = int(param) + return timedelta(**time_params) + + def to_json(self, value): + values = [] + for attr in ('days', 'hours', 'minutes', 'seconds'): + cur_value = getattr(value, attr, 0) + if cur_value > 0: + values.append("%d %s" % (cur_value, attr)) + return ' '.join(values) class ComplexEncoder(json.JSONEncoder): @@ -82,7 +89,7 @@ class CapaModule(XModule): attempts = Int(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.student_state) max_attempts = Int(help="Maximum number of attempts that a student is allowed", scope=Scope.settings) - due = Date(help="Date that this problem is due by", scope=Scope.settings) + due = String(help="Date that this problem is due by", scope=Scope.settings) graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) show_answer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed") force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings) @@ -90,6 +97,7 @@ class CapaModule(XModule): data = String(help="XML data for the problem", scope=Scope.content) correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.student_state) done = Boolean(help="Whether the student has answered the problem", scope=Scope.student_state) + display_name = String(help="Display name for this module", scope=Scope.settings) js = {'coffee': [resource_string(__name__, 'js/src/capa/display.coffee'), resource_string(__name__, 'js/src/collapsible.coffee'), @@ -104,8 +112,13 @@ class CapaModule(XModule): def __init__(self, system, location, descriptor, model_data): XModule.__init__(self, system, location, descriptor, model_data) - if self.graceperiod is not None and self.due: - self.close_date = self.due + self.graceperiod + if self.due: + due_date = dateutil.parser.parse(self.due) + else: + due_date = None + + if self.graceperiod is not None and due_date: + self.close_date = due_date + self.graceperiod #log.debug("Then parsed " + grace_period_string + # " to closing date" + str(self.close_date)) else: diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 019a79e7ab..c4dc63c1b4 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -13,8 +13,7 @@ import time import copy from .model import Scope, ModelType, List, String, Object, Boolean - -Date = ModelType +from .x_module import Date log = logging.getLogger(__name__) @@ -31,6 +30,10 @@ class CourseDescriptor(SequenceDescriptor): end = Date(help="Date that this class ends", scope=Scope.settings) advertised_start = Date(help="Date that this course is advertised to start", scope=Scope.settings) grading_policy = Object(help="Grading policy definition for this class", scope=Scope.content) + show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings) + start = Date(help="Start time when this module is visible", scope=Scope.settings) + display_name = String(help="Display name for this module", scope=Scope.settings) + has_children = True info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') @@ -342,10 +345,6 @@ class CourseDescriptor(SequenceDescriptor): def tabs(self, value): self.metadata['tabs'] = value - @property - def show_calculator(self): - return self.metadata.get("show_calculator", None) == "Yes" - @lazyproperty def grading_context(self): """ @@ -433,6 +432,7 @@ class CourseDescriptor(SequenceDescriptor): @property def start_date_text(self): + print self.advertised_start, self.start return time.strftime("%b %d, %Y", self.advertised_start or self.start) @property diff --git a/common/lib/xmodule/xmodule/discussion_module.py b/common/lib/xmodule/xmodule/discussion_module.py index 1deceac5d0..a193604278 100644 --- a/common/lib/xmodule/xmodule/discussion_module.py +++ b/common/lib/xmodule/xmodule/discussion_module.py @@ -3,8 +3,7 @@ from pkg_resources import resource_string, resource_listdir from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor - -import json +from .model import String, Scope class DiscussionModule(XModule): js = {'coffee': @@ -12,18 +11,19 @@ class DiscussionModule(XModule): resource_string(__name__, 'js/src/discussion/display.coffee')] } js_module_name = "InlineDiscussion" + + data = String(help="XML definition of inline discussion", scope=Scope.content) + def get_html(self): context = { 'discussion_id': self.discussion_id, } return self.system.render_template('discussion/_discussion_module.html', context) - def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) - if isinstance(instance_state, str): - instance_state = json.loads(instance_state) - xml_data = etree.fromstring(definition['data']) + xml_data = etree.fromstring(self.data) self.discussion_id = xml_data.attrib['id'] self.title = xml_data.attrib['for'] self.discussion_category = xml_data.attrib['discussion_category'] diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 709f86bf45..6d86fb90a8 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -15,6 +15,7 @@ from .html_checker import check_html from xmodule.modulestore import Location from xmodule.contentstore.content import XASSET_SRCREF_PREFIX, StaticContent +from .model import Scope, String log = logging.getLogger("mitx.courseware") @@ -26,15 +27,11 @@ class HtmlModule(XModule): ] } js_module_name = "HTMLModule" + + data = String(help="Html contents to display for this module", scope=Scope.content) def get_html(self): - return self.html - - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - self.html = self.definition['data'] + return self.data diff --git a/common/lib/xmodule/xmodule/js/src/video/display.coffee b/common/lib/xmodule/xmodule/js/src/video/display.coffee index bb87679d37..dbcab4a670 100644 --- a/common/lib/xmodule/xmodule/js/src/video/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/video/display.coffee @@ -2,7 +2,6 @@ class @Video constructor: (element) -> @el = $(element).find('.video') @id = @el.attr('id').replace(/video_/, '') - @caption_data_dir = @el.data('caption-data-dir') @caption_asset_path = @el.data('caption-asset-path') @show_captions = @el.data('show-captions') == "true" window.player = null diff --git a/common/lib/xmodule/xmodule/model.py b/common/lib/xmodule/xmodule/model.py index b58ffa267a..edd94f14a9 100644 --- a/common/lib/xmodule/xmodule/model.py +++ b/common/lib/xmodule/xmodule/model.py @@ -1,4 +1,6 @@ from collections import namedtuple +from .plugin import Plugin + class ModuleScope(object): USAGE, DEFINITION, TYPE, ALL = xrange(4) @@ -15,6 +17,13 @@ Scope.student_info = Scope(student=True, module=ModuleScope.ALL) class ModelType(object): + """ + A field class that can be used as a class attribute to define what data the class will want + to refer to. + + When the class is instantiated, it will be available as an instance attribute of the same + name, by proxying through to self._model_data on the containing object. + """ sequence = 0 def __init__(self, help=None, default=None, scope=Scope.content): @@ -33,10 +42,13 @@ class ModelType(object): if instance is None: return self - return instance._model_data.get(self.name, self.default) + if self.name not in instance._model_data: + return self.default + + return self.from_json(instance._model_data[self.name]) def __set__(self, instance, value): - instance._model_data[self.name] = value + instance._model_data[self.name] = self.to_json(value) def __delete__(self, instance): del instance._model_data[self.name] @@ -47,27 +59,27 @@ class ModelType(object): def __lt__(self, other): return self._seq < other._seq + def to_json(self, value): + return value + + def from_json(self, value): + return value + Int = Float = Boolean = Object = List = String = Any = ModelType class ModelMetaclass(type): + """ + A metaclass to be used for classes that want to use ModelTypes as class attributes + to define data access. + + All class attributes that are ModelTypes will be added to the 'fields' attribute on + the instance. + + Additionally, any namespaces registered in the `xmodule.namespace` will be added to + the instance + """ def __new__(cls, name, bases, attrs): - # Find registered methods - reg_methods = {} - for value in attrs.itervalues(): - for reg_type, names in getattr(value, "_method_registrations", {}).iteritems(): - for n in names: - reg_methods[reg_type + n] = value - attrs['registered_methods'] = reg_methods - - if attrs.get('has_children', False): - attrs['children'] = ModelType(help='The children of this XModule', default=[], scope=None) - - @property - def child_map(self): - return dict((child.name, child) for child in self.children) - attrs['child_map'] = child_map - fields = [] for n, v in attrs.items(): if isinstance(v, ModelType): @@ -77,3 +89,61 @@ class ModelMetaclass(type): attrs['fields'] = fields return super(ModelMetaclass, cls).__new__(cls, name, bases, attrs) + + +class NamespacesMetaclass(type): + """ + A metaclass to be used for classes that want to include namespaced fields in their + instances. + + Any namespaces registered in the `xmodule.namespace` will be added to + the instance + """ + def __new__(cls, name, bases, attrs): + for ns_name, namespace in Namespace.load_classes(): + if issubclass(namespace, Namespace): + attrs[ns_name] = NamespaceDescriptor(namespace) + + return super(NamespacesMetaclass, cls).__new__(cls, name, bases, attrs) + + +class ParentModelMetaclass(type): + """ + A ModelMetaclass that transforms the attribute `has_children = True` + into a List field with an empty scope. + """ + def __new__(cls, name, bases, attrs): + if attrs.get('has_children', False): + attrs['children'] = List(help='The children of this XModule', default=[], scope=None) + else: + attrs['has_children'] = False + + return super(ParentModelMetaclass, cls).__new__(cls, name, bases, attrs) + + +class NamespaceDescriptor(object): + def __init__(self, namespace): + self._namespace = namespace + + def __get__(self, instance, owner): + if owner is None: + return self + return self._namespace(instance) + + +class Namespace(Plugin): + """ + A baseclass that sets up machinery for ModelType fields that proxies the contained fields + requests for _model_data to self._container._model_data. + """ + __metaclass__ = ModelMetaclass + __slots__ = ['container'] + + entry_point = 'xmodule.namespace' + + def __init__(self, container): + self._container = container + + @property + def _model_data(self): + return self._container._model_data diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index c65c031c9a..c0ba73423c 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -1,6 +1,5 @@ import pymongo import sys -import logging from bson.son import SON from fs.osfs import OSFS @@ -9,8 +8,8 @@ from path import path from importlib import import_module from xmodule.errortracker import null_error_tracker, exc_info_to_str -from xmodule.x_module import XModuleDescriptor from xmodule.mako_module import MakoDescriptorSystem +from xmodule.x_module import XModuleDescriptor from xmodule.error_module import ErrorDescriptor from . import ModuleStoreBase, Location diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 7c6887696e..8463f945a8 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -192,7 +192,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): xmlstore.modules[course_id][descriptor.location] = descriptor if hasattr(descriptor, 'children'): - for child in descriptor.children: + for child in descriptor.get_children(): parent_tracker.add_parent(child.location, descriptor.location) return descriptor @@ -318,8 +318,6 @@ class XMLModuleStore(ModuleStoreBase): # Didn't load course. Instead, save the errors elsewhere. self.errored_courses[course_dir] = errorlog - - def __unicode__(self): ''' String representation - for debugging @@ -345,8 +343,6 @@ class XMLModuleStore(ModuleStoreBase): log.warning(msg + " " + str(err)) return {} - - def load_course(self, course_dir, tracker): """ Load a course into this module store @@ -363,7 +359,7 @@ class XMLModuleStore(ModuleStoreBase): # been imported into the cms from xml course_file = StringIO(clean_out_mako_templating(course_file.read())) - course_data = etree.parse(course_file,parser=edx_xml_parser).getroot() + course_data = etree.parse(course_file, parser=edx_xml_parser).getroot() org = course_data.get('org') diff --git a/common/lib/xmodule/xmodule/plugin.py b/common/lib/xmodule/xmodule/plugin.py new file mode 100644 index 0000000000..5cf9c647aa --- /dev/null +++ b/common/lib/xmodule/xmodule/plugin.py @@ -0,0 +1,64 @@ +import pkg_resources +import logging + +log = logging.getLogger(__name__) + +class PluginNotFoundError(Exception): + pass + + +class Plugin(object): + """ + Base class for a system that uses entry_points to load plugins. + + Implementing classes are expected to have the following attributes: + + entry_point: The name of the entry point to load plugins from + """ + + _plugin_cache = None + + @classmethod + def load_class(cls, identifier, default=None): + """ + Loads a single class instance specified by identifier. If identifier + specifies more than a single class, then logs a warning and returns the + first class identified. + + If default is not None, will return default if no entry_point matching + identifier is found. Otherwise, will raise a ModuleMissingError + """ + if cls._plugin_cache is None: + cls._plugin_cache = {} + + if identifier not in cls._plugin_cache: + identifier = identifier.lower() + classes = list(pkg_resources.iter_entry_points( + cls.entry_point, name=identifier)) + + if len(classes) > 1: + log.warning("Found multiple classes for {entry_point} with " + "identifier {id}: {classes}. " + "Returning the first one.".format( + entry_point=cls.entry_point, + id=identifier, + classes=", ".join( + class_.module_name for class_ in classes))) + + if len(classes) == 0: + if default is not None: + return default + raise PluginNotFoundError(identifier) + + cls._plugin_cache[identifier] = classes[0].load() + return cls._plugin_cache[identifier] + + @classmethod + def load_classes(cls): + """ + Returns a list of containing the identifiers and their corresponding classes for all + of the available instances of this plugin + """ + return [(class_.name, class_.load()) + for class_ + in pkg_resources.iter_entry_points(cls.entry_point)] diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 155ad99480..6e80d1cf61 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -8,6 +8,7 @@ from xmodule.xml_module import XmlDescriptor from xmodule.x_module import XModule from xmodule.progress import Progress from xmodule.exceptions import NotFoundError +from .model import Int, Scope from pkg_resources import resource_string log = logging.getLogger("mitx.common.lib.seq_module") @@ -16,6 +17,12 @@ log = logging.getLogger("mitx.common.lib.seq_module") # OBSOLETE: This obsoletes 'type' class_priority = ['video', 'problem'] +def display_name(module): + if hasattr(module, 'display_name'): + return module.display_name + + if hasattr(module, 'lms'): + return module.lms.display_name class SequenceModule(XModule): ''' Layout module which lays out content in a temporal sequence @@ -26,22 +33,18 @@ class SequenceModule(XModule): css = {'scss': [resource_string(__name__, 'css/sequence/display.scss')]} js_module_name = "Sequence" - def __init__(self, system, location, definition, descriptor, instance_state=None, - shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - # NOTE: Position is 1-indexed. This is silly, but there are now student - # positions saved on prod, so it's not easy to fix. - self.position = 1 + has_children = True - if instance_state is not None: - state = json.loads(instance_state) - if 'position' in state: - self.position = int(state['position']) + # NOTE: Position is 1-indexed. This is silly, but there are now student + # positions saved on prod, so it's not easy to fix. + position = Int(help="Last tab viewed in this sequence", default=1, scope=Scope.student_state) + + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) # if position is specified in system, then use that instead - if system.get('position'): - self.position = int(system.get('position')) + if self.system.get('position'): + self.position = int(self.system.get('position')) self.rendered = False @@ -79,9 +82,9 @@ class SequenceModule(XModule): childinfo = { 'content': child.get_html(), 'title': "\n".join( - grand_child.display_name.strip() + display_name(grand_child) for grand_child in child.get_children() - if 'display_name' in grand_child.metadata + if display_name(grand_child) is not None ), 'progress_status': Progress.to_js_status_str(progress), 'progress_detail': Progress.to_js_detail_str(progress), @@ -89,7 +92,7 @@ class SequenceModule(XModule): 'id': child.id, } if childinfo['title']=='': - childinfo['title'] = child.metadata.get('display_name','') + childinfo['title'] = display_name(child) contents.append(childinfo) params = {'items': contents, @@ -116,7 +119,8 @@ class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor): mako_template = 'widgets/sequence-edit.html' module_class = SequenceModule - stores_state = True # For remembering where in the sequence the student is + has_children = True + stores_state = True # For remembering where in the sequence the student is js = {'coffee': [resource_string(__name__, 'js/src/sequence/edit.coffee')]} js_module_name = "SequenceDescriptor" diff --git a/common/lib/xmodule/xmodule/vertical_module.py b/common/lib/xmodule/xmodule/vertical_module.py index 397bd3e136..a4cd779285 100644 --- a/common/lib/xmodule/xmodule/vertical_module.py +++ b/common/lib/xmodule/xmodule/vertical_module.py @@ -11,8 +11,10 @@ class_priority = ['video', 'problem'] class VerticalModule(XModule): ''' Layout module for laying out submodules vertically.''' - def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) + has_children = True + + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) self.contents = None def get_html(self): @@ -45,6 +47,8 @@ class VerticalModule(XModule): class VerticalDescriptor(SequenceDescriptor): module_class = VerticalModule + has_children = True + js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]} js_module_name = "VerticalDescriptor" diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 8a9e0aadd7..34ce353afd 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -9,6 +9,7 @@ from xmodule.raw_module import RawDescriptor from xmodule.modulestore.mongo import MongoModuleStore from xmodule.modulestore.django import modulestore from xmodule.contentstore.content import StaticContent +from .model import Int, Scope, String log = logging.getLogger(__name__) @@ -27,22 +28,20 @@ class VideoModule(XModule): css = {'scss': [resource_string(__name__, 'css/video/display.scss')]} js_module_name = "Video" - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - xmltree = etree.fromstring(self.definition['data']) + data = String(help="XML data for the problem", scope=Scope.content) + position = Int(help="Current position in the video", scope=Scope.student_state) + display_name = String(help="Display name for this module", scope=Scope.settings) + + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) + + xmltree = etree.fromstring(self.data) self.youtube = xmltree.get('youtube') self.position = 0 self.show_captions = xmltree.get('show_captions', 'true') self.source = self._get_source(xmltree) self.track = self._get_track(xmltree) - if instance_state is not None: - state = json.loads(instance_state) - if 'position' in state: - self.position = int(float(state['position'])) - def _get_source(self, xmltree): # find the first valid source return self._get_first_external(xmltree, 'source') @@ -102,7 +101,7 @@ class VideoModule(XModule): else: # VS[compat] # cdodge: filesystem static content support. - caption_asset_path = "/static/{0}/subs/".format(self.metadata['data_dir']) + caption_asset_path = "/static/{0}/subs/".format(self.descriptor.data_dir) return self.system.render_template('video.html', { 'streams': self.video_list(), @@ -111,8 +110,6 @@ class VideoModule(XModule): 'source': self.source, 'track' : self.track, 'display_name': self.display_name, - # TODO (cpennington): This won't work when we move to data that isn't on the filesystem - 'data_dir': self.metadata['data_dir'], 'caption_asset_path': caption_asset_path, 'show_captions': self.show_captions }) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 9cf45652ca..9cad93ad5b 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -1,5 +1,4 @@ import logging -import pkg_resources import yaml import os import time @@ -10,9 +9,9 @@ from collections import namedtuple from pkg_resources import resource_listdir, resource_string, resource_isdir from xmodule.modulestore import Location -from .model import ModelMetaclass, String, Scope, ModuleScope, ModelType -Date = ModelType +from .model import ModelMetaclass, ParentModelMetaclass, NamespacesMetaclass, ModelType +from .plugin import Plugin class Date(ModelType): @@ -38,6 +37,10 @@ class Date(ModelType): """ return time.strftime(self.time_format, value) + +class XModuleMetaclass(ParentModelMetaclass, NamespacesMetaclass, ModelMetaclass): + pass + log = logging.getLogger('mitx.' + __name__) @@ -45,67 +48,6 @@ def dummy_track(event_type, event): pass -class ModuleMissingError(Exception): - pass - - -class Plugin(object): - """ - Base class for a system that uses entry_points to load plugins. - - Implementing classes are expected to have the following attributes: - - entry_point: The name of the entry point to load plugins from - """ - - _plugin_cache = None - - @classmethod - def load_class(cls, identifier, default=None): - """ - Loads a single class instance specified by identifier. If identifier - specifies more than a single class, then logs a warning and returns the - first class identified. - - If default is not None, will return default if no entry_point matching - identifier is found. Otherwise, will raise a ModuleMissingError - """ - if cls._plugin_cache is None: - cls._plugin_cache = {} - - if identifier not in cls._plugin_cache: - identifier = identifier.lower() - classes = list(pkg_resources.iter_entry_points( - cls.entry_point, name=identifier)) - - if len(classes) > 1: - log.warning("Found multiple classes for {entry_point} with " - "identifier {id}: {classes}. " - "Returning the first one.".format( - entry_point=cls.entry_point, - id=identifier, - classes=", ".join( - class_.module_name for class_ in classes))) - - if len(classes) == 0: - if default is not None: - return default - raise ModuleMissingError(identifier) - - cls._plugin_cache[identifier] = classes[0].load() - return cls._plugin_cache[identifier] - - @classmethod - def load_classes(cls): - """ - Returns a list of containing the identifiers and their corresponding classes for all - of the available instances of this plugin - """ - return [(class_.name, class_.load()) - for class_ - in pkg_resources.iter_entry_points(cls.entry_point)] - - class HTMLSnippet(object): """ A base class defining an interface for an object that is able to present an @@ -179,9 +121,7 @@ class XModule(HTMLSnippet): See the HTML module for a simple example. ''' - __metaclass__ = ModelMetaclass - - display_name = String(help="Display name for this module", scope=Scope(student=False, module=ModuleScope.USAGE)) + __metaclass__ = XModuleMetaclass # The default implementation of get_icon_class returns the icon_class # attribute of the class @@ -244,9 +184,22 @@ class XModule(HTMLSnippet): self.url_name = self.location.name self.category = self.location.category self._model_data = model_data + self._loaded_children = None - if self.display_name is None: - self.display_name = self.url_name.replace('_', ' ') + def get_children(self): + ''' + Return module instances for all the children of this module. + ''' + if not self.has_children: + return [] + + if self._loaded_children is None: + children = [self.system.get_module(loc) for loc in self.children] + # get_module returns None if the current user doesn't have access + # to the location. + self._loaded_children = [c for c in children if c is not None] + + return self._loaded_children def __unicode__(self): return ''.format(self.id) @@ -257,7 +210,7 @@ class XModule(HTMLSnippet): immediately inside this module. ''' items = [] - for child in self.children(): + for child in self.get_children(): items.extend(child.displayable_items()) return items @@ -366,10 +319,8 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): """ entry_point = "xmodule.v1" module_class = XModule - __metaclass__ = ModelMetaclass + __metaclass__ = XModuleMetaclass - display_name = String(help="Display name for this module", scope=Scope(student=False, module=ModuleScope.USAGE)) - start = Date(help="Start time when this module is visible", scope=Scope(student=False, module=ModuleScope.USAGE)) # Attributes for inspection of the descriptor stores_state = False # Indicates whether the xmodule state should be # stored in a database (independent of shared state) @@ -430,8 +381,6 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): metadata: A dictionary containing the following optional keys: goals: A list of strings of learning goals associated with this module - display_name: The name to use for displaying this module to the - user url_name: The name to use for this module in urls and other places where a unique name is needed. format: The format of this module ('Homework', 'Lab', etc) @@ -452,12 +401,36 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): self._child_instances = None self._inherited_metadata = set() + self._child_instances = None + + def get_children(self): + """Returns a list of XModuleDescriptor instances for the children of + this module""" + if not self.has_children: + return [] + + if self._child_instances is None: + self._child_instances = [] + for child_loc in self.children: + try: + child = self.system.load_item(child_loc) + except ItemNotFoundError: + log.exception('Unable to load item {loc}, skipping'.format(loc=child_loc)) + continue + # TODO (vshnayder): this should go away once we have + # proper inheritance support in mongo. The xml + # datastore does all inheritance on course load. + #child.inherit_metadata(self.metadata) + self._child_instances.append(child) + + return self._child_instances + def get_child_by_url_name(self, url_name): """ Return a child XModuleDescriptor with the specified url_name, if it exists, and None otherwise. """ - for c in self.children: + for c in self.get_children(): if c.url_name == url_name: return c return None @@ -472,7 +445,7 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): system, self.location, self, - system.xmodule_model_data(self.model_data), + system.xmodule_model_data(self._model_data), ) def has_dynamic_children(self): @@ -686,6 +659,7 @@ class ModuleSystem(object): get_module, render_template, replace_urls, + xmodule_model_data, user=None, filestore=None, debug=False, @@ -739,6 +713,7 @@ class ModuleSystem(object): self.node_path = node_path self.anonymous_student_id = anonymous_student_id self.user_is_staff = user is not None and user.is_staff + self.xmodule_model_data = xmodule_model_data def get(self, attr): ''' provide uniform access to attributes (like etree).''' @@ -748,9 +723,6 @@ class ModuleSystem(object): '''provide uniform access to attributes (like etree)''' self.__dict__[attr] = val - def xmodule_module_data(self, module_data): - return module_data - def __repr__(self): return repr(self.__dict__) diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 36932f9e42..1283844ade 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -148,7 +148,7 @@ def grade(student, request, course, student_module_cache=None, keep_raw_scores=F format_scores = [] for section in sections: section_descriptor = section['section_descriptor'] - section_name = section_descriptor.metadata.get('display_name') + section_name = section_descriptor.display_name should_grade_section = False # If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0% @@ -276,14 +276,14 @@ def progress_summary(student, request, course, student_module_cache): # Don't include chapters that aren't displayable (e.g. due to error) for chapter_module in course_module.get_display_items(): # Skip if the chapter is hidden - hidden = chapter_module.metadata.get('hide_from_toc','false') + hidden = chapter_module._model_data.get('hide_from_toc','false') if hidden.lower() == 'true': continue sections = [] for section_module in chapter_module.get_display_items(): # Skip if the section is hidden - hidden = section_module.metadata.get('hide_from_toc','false') + hidden = section_module._model_data.get('hide_from_toc','false') if hidden.lower() == 'true': continue diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 9c40767e99..57a125adba 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -88,8 +88,7 @@ def toc_for_course(user, request, course, active_chapter, active_section): chapters = list() for chapter in course_module.get_display_items(): - hide_from_toc = chapter.metadata.get('hide_from_toc','false').lower() == 'true' - if hide_from_toc: + if chapter.lms.hide_from_toc: continue sections = list() @@ -97,18 +96,17 @@ def toc_for_course(user, request, course, active_chapter, active_section): active = (chapter.url_name == active_chapter and section.url_name == active_section) - hide_from_toc = section.metadata.get('hide_from_toc', 'false').lower() == 'true' - if not hide_from_toc: - sections.append({'display_name': section.display_name, + if not section.lms.hide_from_toc: + sections.append({'display_name': section.lms.display_name, 'url_name': section.url_name, - 'format': section.metadata.get('format', ''), - 'due': section.metadata.get('due', ''), + 'format': section.lms.format, + 'due': section.lms.due, 'active': active, - 'graded': section.metadata.get('graded', False), + 'graded': section.lms.graded, }) - chapters.append({'display_name': chapter.display_name, + chapters.append({'display_name': chapter.lms.display_name, 'url_name': chapter.url_name, 'sections': sections, 'active': chapter.url_name == active_chapter}) @@ -146,7 +144,8 @@ def get_module(user, request, location, student_module_cache, course_id, positio log.exception("Error in get_module") return None -def _get_module(user, request, location, student_module_cache, course_id, position=None, wrap_xmodule_display = True): + +def _get_module(user, request, location, student_module_cache, course_id, position=None, wrap_xmodule_display=True): """ Actually implement get_module. See docstring there for details. """ @@ -268,7 +267,7 @@ def _get_module(user, request, location, student_module_cache, course_id, positi module.get_html = replace_static_urls( _get_html, - module.metadata['data_dir'] if 'data_dir' in module.metadata else '', + getattr(module, 'data_dir', ''), course_namespace = module.location._replace(category=None, name=None)) # Allow URLs of the form '/course/' refer to the root of multicourse directory diff --git a/lms/setup.py b/lms/setup.py new file mode 100644 index 0000000000..1755b7d37e --- /dev/null +++ b/lms/setup.py @@ -0,0 +1,18 @@ +from setuptools import setup, find_packages + +setup( + name="edX LMS", + version="0.1", + install_requires=['distribute'], + requires=[ + 'xmodule', + ], + + # See http://guide.python-distribute.org/creation.html#entry-points + # for a description of entry_points + entry_points={ + 'xmodule.namespace': [ + 'lms = lms.xmodule_namespace:LmsNamespace' + ], + } +) \ No newline at end of file diff --git a/lms/templates/courseware/welcome-back.html b/lms/templates/courseware/welcome-back.html index 5d4e0fe1e3..389a54aa53 100644 --- a/lms/templates/courseware/welcome-back.html +++ b/lms/templates/courseware/welcome-back.html @@ -1,3 +1,3 @@ -

${chapter_module.display_name}

+

${chapter_module.lms.display_name}

-

You were most recently in ${prev_section.display_name}. If you're done with that, choose another section on the left.

+

You were most recently in ${prev_section.lms.display_name}. If you're done with that, choose another section on the left.

diff --git a/lms/templates/video.html b/lms/templates/video.html index 18c1bcbced..1698ae256c 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -2,7 +2,7 @@

${display_name}

% endif -
+
diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py new file mode 100644 index 0000000000..3673de73df --- /dev/null +++ b/lms/xmodule_namespace.py @@ -0,0 +1,23 @@ +from xmodule.model import Namespace, Boolean, Scope, String +from xmodule.x_module import Date + +class LmsNamespace(Namespace): + hide_from_toc = Boolean( + help="Whether to display this module in the table of contents", + default=False, + scope=Scope.settings + ) + graded = Boolean( + help="Whether this module contributes to the final course grade", + default=False, + scope=Scope.settings + ) + format = String( + help="What format this module is in (used for deciding which " + "grader to apply, and what to show in the TOC)", + scope=Scope.settings + ) + + display_name = String(help="Display name for this module", scope=Scope.settings) + start = Date(help="Start time when this module is visible", scope=Scope.settings) + due = String(help="Date that this problem is due by", scope=Scope.settings, default='') diff --git a/local-requirements.txt b/local-requirements.txt index a4d153dd36..2e951a180c 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -1,3 +1,4 @@ # Python libraries to install that are local to the mitx repo -e common/lib/capa -e common/lib/xmodule +-e lms From c5e3380b711201f8e389d6b4653d37925a97d39d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 11 Dec 2012 13:36:23 -0500 Subject: [PATCH 012/501] WIP: Save student state via StudentModule. Inheritance doesn't work --- common/djangoapps/xmodule_modifiers.py | 17 +-- common/lib/capa/capa/capa_problem.py | 20 ++- common/lib/xmodule/xmodule/capa_module.py | 86 ++++++----- common/lib/xmodule/xmodule/model.py | 9 +- common/lib/xmodule/xmodule/modulestore/xml.py | 2 +- lms/djangoapps/courseware/models.py | 3 + lms/djangoapps/courseware/module_render.py | 135 ++++++++++-------- lms/templates/staff_problem_info.html | 5 +- lms/xmodule_namespace.py | 6 +- 9 files changed, 167 insertions(+), 116 deletions(-) diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 81f0205c3e..5a13582a2d 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -108,36 +108,33 @@ def add_histogram(get_html, module, user): # TODO (ichuang): Remove after fall 2012 LMS migration done if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'): - [filepath, filename] = module.definition.get('filename', ['', None]) + [filepath, filename] = module.lms.filename osfs = module.system.filestore if filename is not None and osfs.exists(filename): # if original, unmangled filename exists then use it (github # doesn't like symlinks) filepath = filename data_dir = osfs.root_path.rsplit('/')[-1] - giturl = module.metadata.get('giturl','https://github.com/MITx') - edit_link = "%s/%s/tree/master/%s" % (giturl,data_dir,filepath) + edit_link = "%s/%s/tree/master/%s" % (module.lms.giturl, data_dir, filepath) else: edit_link = False # Need to define all the variables that are about to be used - giturl = "" data_dir = "" - source_file = module.metadata.get('source_file','') # source used to generate the problem XML, eg latex or word + source_file = module.lms.source_file # source used to generate the problem XML, eg latex or word # useful to indicate to staff if problem has been released or not # TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access, instead of now>mstart comparison here now = time.gmtime() is_released = "unknown" - mstart = getattr(module.descriptor,'start') + mstart = getattr(module.descriptor.lms,'start') if mstart is not None: is_released = "Yes!" if (now > mstart) else "Not yet" - staff_context = {'definition': module.definition.get('data'), - 'metadata': json.dumps(module.metadata, indent=4), + staff_context = {'fields': [(field.name, getattr(module, field.name)) for field in module.fields], 'location': module.location, - 'xqa_key': module.metadata.get('xqa_key',''), + 'xqa_key': module.lms.xqa_key, 'source_file' : source_file, - 'source_url': '%s/%s/tree/master/%s' % (giturl,data_dir,source_file), + 'source_url': '%s/%s/tree/master/%s' % (module.lms.giturl, data_dir, source_file), 'category': str(module.__class__.__name__), # Template uses element_id in js function names, so can't allow dashes 'element_id': module.location.html_id().replace('-','_'), diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index eb39d8a2c6..85670063c5 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -83,7 +83,7 @@ class LoncapaProblem(object): Main class for capa Problems. ''' - def __init__(self, problem_text, id, correct_map=None, done=None, seed=None, system=None): + def __init__(self, problem_text, id, state=None, seed=None, system=None): ''' Initializes capa Problem. @@ -91,8 +91,7 @@ class LoncapaProblem(object): - problem_text (string): xml defining the problem - id (string): identifier for this problem; often a filename (no spaces) - - correct_map (dict): data specifying whether the student has completed the problem - - done (bool): Whether the student has answered the problem + - state (dict): student state - seed (int): random number generator seed (int) - system (ModuleSystem): ModuleSystem instance which provides OS, rendering, and user context @@ -103,12 +102,19 @@ class LoncapaProblem(object): self.do_reset() self.problem_id = id self.system = system + if self.system is None: + raise Exception() self.seed = seed - self.done = done - self.correct_map = CorrectMap() - if correct_map is not None: - self.correct_map.set_dict(correct_map) + if state: + if 'seed' in state: + self.seed = state['seed'] + if 'student_answers' in state: + self.student_answers = state['student_answers'] + if 'correct_map' in state: + self.correct_map.set_dict(state['correct_map']) + if 'done' in state: + self.done = state['done'] # TODO: Does this deplete the Linux entropy pool? Is this fast enough? if not self.seed: diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index acb4b63e1c..7e8ad9210f 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -92,12 +92,14 @@ class CapaModule(XModule): due = String(help="Date that this problem is due by", scope=Scope.settings) graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) show_answer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed") - force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings) + force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings, default=False) rerandomize = String(help="When to rerandomize the problem", default="always") data = String(help="XML data for the problem", scope=Scope.content) - correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.student_state) + correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.student_state, default={}) + student_answers = Object(help="Dictionary with the current student responses", scope=Scope.student_state) done = Boolean(help="Whether the student has answered the problem", scope=Scope.student_state) display_name = String(help="Display name for this module", scope=Scope.settings) + seed = Int(help="Random seed for this student", scope=Scope.student_state) js = {'coffee': [resource_string(__name__, 'js/src/capa/display.coffee'), resource_string(__name__, 'js/src/collapsible.coffee'), @@ -124,23 +126,23 @@ class CapaModule(XModule): else: self.close_date = self.due - if self.rerandomize == 'never': - self.seed = 1 - elif self.rerandomize == "per_student" and hasattr(self.system, 'id'): - # TODO: This line is badly broken: - # (1) We're passing student ID to xmodule. - # (2) There aren't bins of students. -- we only want 10 or 20 randomizations, and want to assign students - # to these bins, and may not want cohorts. So e.g. hash(your-id, problem_id) % num_bins. - # - analytics really needs small number of bins. - self.seed = system.id - else: - self.seed = None + if self.seed is None: + if self.rerandomize == 'never': + self.seed = 1 + elif self.rerandomize == "per_student" and hasattr(self.system, 'id'): + # TODO: This line is badly broken: + # (1) We're passing student ID to xmodule. + # (2) There aren't bins of students. -- we only want 10 or 20 randomizations, and want to assign students + # to these bins, and may not want cohorts. So e.g. hash(your-id, problem_id) % num_bins. + # - analytics really needs small number of bins. + self.seed = system.id + else: + self.seed = None try: # TODO (vshnayder): move as much as possible of this work and error # checking to descriptor load time - self.lcp = LoncapaProblem(self.data, self.location.html_id(), - self.correct_map, self.done, self.seed, self.system) + self.lcp = self.new_lcp(self.get_state_for_lcp()) except Exception as err: msg = 'cannot create LoncapaProblem {loc}: {err}'.format( loc=self.location.url(), err=err) @@ -157,9 +159,7 @@ class CapaModule(XModule): problem_text = ('' 'Problem %s has an error:%s' % (self.location.url(), msg)) - self.lcp = LoncapaProblem( - problem_text, self.location.html_id(), - self.correct_map, self.done, self.seed, self.system) + self.lcp = self.new_lcp(self.get_state_for_lcp(), text=problem_text) else: # add extra info and raise raise Exception(msg), None, sys.exc_info()[2] @@ -169,10 +169,30 @@ class CapaModule(XModule): elif self.rerandomize == "false": self.rerandomize = "per_student" - def sync_lcp_state(self): + def new_lcp(self, state, text=None): + if text is None: + text = self.data + + return LoncapaProblem( + problem_text=text, + id=self.location.html_id(), + state=state, + system=self.system, + ) + + def get_state_for_lcp(self): + return { + 'done': self.done, + 'correct_map': self.correct_map, + 'student_answers': self.student_answers, + 'seed': self.seed, + } + + def set_state_from_lcp(self): lcp_state = self.lcp.get_state() self.done = lcp_state['done'] self.correct_map = lcp_state['correct_map'] + self.student_answers = lcp_state['student_answers'] self.seed = lcp_state['seed'] def get_score(self): @@ -239,9 +259,8 @@ class CapaModule(XModule): student_answers.pop(answer_id) # Next, generate a fresh LoncapaProblem - self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(), - seed=self.seed, system=self.system) - self.sync_lcp_state() + self.lcp = self.new_lcp(None) + self.set_state_from_lcp() # Prepend a scary warning to the student warning = '
'\ @@ -305,7 +324,7 @@ class CapaModule(XModule): # We may not need a "save" button if infinite number of attempts and # non-randomized. The problem author can force it. It's a bit weird for # randomization to control this; should perhaps be cleaned up. - if (self.force_save_button == "false") and (self.max_attempts is None and self.rerandomize != "always"): + if (not self.force_save_button) and (self.max_attempts is None and self.rerandomize != "always"): save_button = False context = {'problem': content, @@ -326,7 +345,7 @@ class CapaModule(XModule): id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "
" # now do the substitutions which are filesystem based, e.g. '/static/' prefixes - return self.system.replace_urls(html, self.metadata['data_dir'], course_namespace=self.location) + return self.system.replace_urls(html, self.descriptor.data_dir, course_namespace=self.location) def handle_ajax(self, dispatch, get): ''' @@ -408,7 +427,7 @@ class CapaModule(XModule): queuekey = get['queuekey'] score_msg = get['xqueue_body'] self.lcp.update_score(score_msg, queuekey) - self.sync_lcp_state() + self.set_state_from_lcp() return dict() # No AJAX return is needed @@ -425,14 +444,18 @@ class CapaModule(XModule): raise NotFoundError('Answer is not available') else: answers = self.lcp.get_question_answers() - self.sync_lcp_state() + self.set_state_from_lcp() # answers (eg ) may have embedded images # but be careful, some problems are using non-string answer dicts new_answers = dict() for answer_id in answers: try: +<<<<<<< HEAD new_answer = {answer_id: self.system.replace_urls(answers[answer_id], self.metadata['data_dir'], course_namespace=self.location)} +======= + new_answer = {answer_id: self.system.replace_urls(answers[answer_id], self.descriptor.data_dir)} +>>>>>>> WIP: Save student state via StudentModule. Inheritance doesn't work except TypeError: log.debug('Unable to perform URL substitution on answers[%s]: %s' % (answer_id, answers[answer_id])) new_answer = {answer_id: answers[answer_id]} @@ -509,7 +532,7 @@ class CapaModule(XModule): try: correct_map = self.lcp.grade_answers(answers) - self.sync_lcp_state() + self.set_state_from_lcp() except StudentInputError as inst: log.exception("StudentInputError in capa_module:problem_check") return {'success': inst.message} @@ -609,13 +632,8 @@ class CapaModule(XModule): # in next line) self.lcp.seed = None - self.lcp = LoncapaProblem(self.data, - self.location.html_id(), - self.lcp.correct_map, - self.lcp.done, - self.lcp.seed, - self.system) - self.sync_lcp_state() + self.set_state_from_lcp() + self.lcp = self.new_lcp() event_info['new_state'] = self.lcp.get_state() self.system.track_function('reset_problem', event_info) diff --git a/common/lib/xmodule/xmodule/model.py b/common/lib/xmodule/xmodule/model.py index edd94f14a9..9286b0337e 100644 --- a/common/lib/xmodule/xmodule/model.py +++ b/common/lib/xmodule/xmodule/model.py @@ -10,8 +10,8 @@ class Scope(namedtuple('ScopeBase', 'student module')): pass Scope.content = Scope(student=False, module=ModuleScope.DEFINITION) +Scope.settings = Scope(student=False, module=ModuleScope.USAGE) Scope.student_state = Scope(student=True, module=ModuleScope.USAGE) -Scope.settings = Scope(student=True, module=ModuleScope.USAGE) Scope.student_preferences = Scope(student=True, module=ModuleScope.TYPE) Scope.student_info = Scope(student=True, module=ModuleScope.ALL) @@ -54,7 +54,7 @@ class ModelType(object): del instance._model_data[self.name] def __repr__(self): - return "<{0.__class__.__name} {0.__name__}>".format(self) + return "<{0.__class__.__name__} {0._name}>".format(self) def __lt__(self, other): return self._seq < other._seq @@ -100,9 +100,12 @@ class NamespacesMetaclass(type): the instance """ def __new__(cls, name, bases, attrs): + namespaces = [] for ns_name, namespace in Namespace.load_classes(): if issubclass(namespace, Namespace): attrs[ns_name] = NamespaceDescriptor(namespace) + namespaces.append(ns_name) + attrs['namespaces'] = namespaces return super(NamespacesMetaclass, cls).__new__(cls, name, bases, attrs) @@ -114,7 +117,7 @@ class ParentModelMetaclass(type): """ def __new__(cls, name, bases, attrs): if attrs.get('has_children', False): - attrs['children'] = List(help='The children of this XModule', default=[], scope=None) + attrs['children'] = List(help='The children of this XModule', default=[], scope=Scope.settings) else: attrs['has_children'] = False diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 8463f945a8..349975ea77 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -175,7 +175,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): # Normally, we don't want lots of exception traces in our logs from common # content problems. But if you're debugging the xml loading code itself, # uncomment the next line. - # log.exception(msg) + log.exception(msg) self.error_tracker(msg) err_msg = msg + "\n" + exc_info_to_str(sys.exc_info()) diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index ffc7c929de..2b7b12ac45 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -64,6 +64,9 @@ class StudentModule(models.Model): return '/'.join([self.course_id, self.module_type, self.student.username, self.module_state_key, str(self.state)[:20]]) + def __repr__(self): + return 'StudentModule%r' % ((self.course_id, self.module_type, self.student, self.module_state_key, str(self.state)[:20]),) + # TODO (cpennington): Remove these once the LMS switches to using XModuleDescriptors diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 57a125adba..f83ff35f1f 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -11,6 +11,8 @@ from django.http import Http404 from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt +from collections import namedtuple + from requests.auth import HTTPBasicAuth from capa.xqueue_interface import XQueueInterface @@ -26,6 +28,8 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.x_module import ModuleSystem from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor +from xmodule.runtime import DbModel, KeyValueStore +from xmodule.model import Scope from xmodule_modifiers import replace_course_urls, replace_static_urls, add_histogram, wrap_xmodule from xmodule.modulestore.exceptions import ItemNotFoundError @@ -145,6 +149,70 @@ def get_module(user, request, location, student_module_cache, course_id, positio return None +class LmsKeyValueStore(KeyValueStore): + def __init__(self, course_id, user, descriptor_model_data, student_module_cache): + self._course_id = course_id + self._user = user + self._descriptor_model_data = descriptor_model_data + self._student_module_cache = student_module_cache + + def _student_module(self, key): + student_module = self._student_module_cache.lookup( + self._course_id, key.module_scope_id.category, key.module_scope_id.url() + ) + return student_module + + def get(self, key): + if not key.scope.student: + return self._descriptor_model_data[key.field_name] + + if key.scope == Scope.student_state: + student_module = self._student_module(key) + + if student_module is None: + raise KeyError(key.field_name) + + return json.loads(student_module.state)[key.field_name] + + def set(self, key, value): + if not key.scope.student: + self._descriptor_model_data[key.field_name] = value + + if key.scope == Scope.student_state: + student_module = self._student_module(key) + if student_module is None: + student_module = StudentModule( + course_id=self._course_id, + student=self._user, + module_type=key.module_scope_id.category, + module_state_key=key.module_scope_id, + state=json.dumps({}) + ) + self._student_module_cache.append(student_module) + state = json.loads(student_module.state) + state[key.field_name] = value + student_module.state = json.dumps(state) + student_module.save() + + def delete(self, key): + if not key.scope.student: + del self._descriptor_model_data[key.field_name] + + if key.scope == Scope.student_state: + student_module = self._student_module(key) + + if student_module is None: + raise KeyError(key.field_name) + + state = json.loads(student_module.state) + del state[key.field_name] + student_module.state = json.dumps(state) + student_module.save() + + +LmsUsage = namedtuple('LmsUsage', 'id, def_id') + + def _get_module(user, request, location, student_module_cache, course_id, position=None, wrap_xmodule_display=True): """ Actually implement get_module. See docstring there for details. @@ -162,23 +230,6 @@ def _get_module(user, request, location, student_module_cache, course_id, positi h.update(str(user.id)) anonymous_student_id = h.hexdigest() - # Only check the cache if this module can possibly have state - instance_module = None - shared_module = None - if user.is_authenticated(): - if descriptor.stores_state: - instance_module = student_module_cache.lookup( - course_id, descriptor.category, descriptor.location.url()) - - shared_state_key = getattr(descriptor, 'shared_state_key', None) - if shared_state_key is not None: - shared_module = student_module_cache.lookup(course_id, - descriptor.category, - shared_state_key) - - instance_state = instance_module.state if instance_module is not None else None - shared_state = shared_module.state if shared_module is not None else None - # Setup system context for module instance ajax_url = reverse('modx_dispatch', kwargs=dict(course_id=course_id, @@ -218,6 +269,14 @@ def _get_module(user, request, location, student_module_cache, course_id, positi return get_module(user, request, location, student_module_cache, course_id, position) + def xmodule_model_data(descriptor_model_data): + return DbModel( + LmsKeyValueStore(course_id, user, descriptor_model_data, student_module_cache), + descriptor.module_class, + user.id, + LmsUsage(location, location) + ) + # TODO (cpennington): When modules are shared between courses, the static # prefix is going to have to be specific to the module, not the directory # that the xml was loaded from @@ -235,6 +294,7 @@ def _get_module(user, request, location, student_module_cache, course_id, positi replace_urls=replace_urls, node_path=settings.NODE_PATH, anonymous_student_id=anonymous_student_id, + xmodule_model_data=xmodule_model_data ) # pass position specified in URL to module through ModuleSystem system.set('position', position) @@ -453,19 +513,6 @@ def modx_dispatch(request, dispatch, location, course_id): log.debug("No module {0} for user {1}--access denied?".format(location, user)) raise Http404 - instance_module = get_instance_module(course_id, request.user, instance, student_module_cache) - shared_module = get_shared_instance_module(course_id, request.user, instance, student_module_cache) - - # Don't track state for anonymous users (who don't have student modules) - if instance_module is not None: - oldgrade = instance_module.grade - # The max grade shouldn't change under normal circumstances, but - # sometimes the problem changes with the same name but a new max grade. - # This updates the module if that happens. - old_instance_max_grade = instance_module.max_grade - old_instance_state = instance_module.state - old_shared_state = shared_module.state if shared_module is not None else None - # Let the module handle the AJAX try: ajax_return = instance.handle_ajax(dispatch, p) @@ -476,34 +523,6 @@ def modx_dispatch(request, dispatch, location, course_id): log.exception("error processing ajax call") raise - # Save the state back to the database - # Don't track state for anonymous users (who don't have student modules) - if instance_module is not None: - instance_module.state = instance.get_instance_state() - instance_module.max_grade=instance.max_score() - if instance.get_score(): - instance_module.grade = instance.get_score()['score'] - if (instance_module.grade != oldgrade or - instance_module.state != old_instance_state or - instance_module.max_grade != old_instance_max_grade): - instance_module.save() - - #Bin score into range and increment stats - score_bucket=get_score_bucket(instance_module.grade, instance_module.max_grade) - org, course_num, run=course_id.split("/") - statsd.increment("lms.courseware.question_answered", - tags=["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run), - "score_bucket:{0}".format(score_bucket), - "type:ajax"]) - - - if shared_module is not None: - shared_module.state = instance.get_shared_state() - if shared_module.state != old_shared_state: - shared_module.save() - # Return whatever the module wanted to return to the client/caller return HttpResponse(ajax_return) diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 21044c1f80..ad4852335f 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -46,8 +46,9 @@ github = ${edit_link | h} %if source_file: source_url = ${source_file | h} %endif -definition =
${definition | h}
-metadata = ${metadata | h} +%for name, field in fields: +${name} =
${field | h}
+%endfor category = ${category | h}
%if render_histogram: diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index 3673de73df..80b5fc6e61 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -1,4 +1,4 @@ -from xmodule.model import Namespace, Boolean, Scope, String +from xmodule.model import Namespace, Boolean, Scope, String, List from xmodule.x_module import Date class LmsNamespace(Namespace): @@ -21,3 +21,7 @@ class LmsNamespace(Namespace): display_name = String(help="Display name for this module", scope=Scope.settings) start = Date(help="Start time when this module is visible", scope=Scope.settings) due = String(help="Date that this problem is due by", scope=Scope.settings, default='') + filename = List(help="DO NOT USE", scope=Scope.content, default=['', None]) + source_file = String(help="DO NOT USE", scope=Scope.settings) + giturl = String(help="DO NOT USE", scope=Scope.settings, default='https://github.com/MITx') + xqa_key = String(help="DO NOT USE", scope=Scope.settings) From 25754b50ff0c8d737e39b776822f89869867b9ab Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 12 Dec 2012 09:22:26 -0500 Subject: [PATCH 013/501] WIP. Trying to fix inheritance --- common/lib/xmodule/xmodule/modulestore/xml.py | 27 +++++- common/lib/xmodule/xmodule/runtime.py | 92 +++++++++++++++++++ common/lib/xmodule/xmodule/x_module.py | 5 - 3 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 common/lib/xmodule/xmodule/runtime.py diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 349975ea77..677b2e938e 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -422,7 +422,32 @@ class XMLModuleStore(ModuleStoreBase): # breaks metadata inheritance via get_children(). Instead # (actually, in addition to, for now), we do a final inheritance pass # after we have the course descriptor. - #XModuleDescriptor.compute_inherited_metadata(course_descriptor) + def compute_inherited_metadata(descriptor): + """Given a descriptor, traverse all of its descendants and do metadata + inheritance. Should be called on a CourseDescriptor after importing a + course. + + NOTE: This means that there is no such thing as lazy loading at the + moment--this accesses all the children.""" + for child in descriptor.get_children(): + inherit_metadata(child, descriptor.metadata) + compute_inherited_metadata(child) + + def inherit_metadata(descriptor, metadata): + """ + Updates this module with metadata inherited from a containing module. + Only metadata specified in self.inheritable_metadata will + be inherited + """ + # Set all inheritable metadata from kwargs that are + # in self.inheritable_metadata and aren't already set in metadata + for attr in self.inheritable_metadata: + if attr not in self.metadata and attr in metadata: + self._inherited_metadata.add(attr) + self.metadata[attr] = metadata[attr] + + + compute_inherited_metadata(course_descriptor) # now import all pieces of course_info which is expected to be stored # in /info or /info/ diff --git a/common/lib/xmodule/xmodule/runtime.py b/common/lib/xmodule/xmodule/runtime.py new file mode 100644 index 0000000000..8be4bfe346 --- /dev/null +++ b/common/lib/xmodule/xmodule/runtime.py @@ -0,0 +1,92 @@ +from collections import MutableMapping, namedtuple + +from .model import ModuleScope, ModelType + + +class KeyValueStore(object): + """The abstract interface for Key Value Stores.""" + + # Keys are structured to retain information about the scope of the data. + # Stores can use this information however they like to store and retrieve + # data. + Key = namedtuple("Key", "scope, student_id, module_scope_id, field_name") + + def get(key): + pass + + def set(key, value): + pass + + def delete(key): + pass + + +class DbModel(MutableMapping): + """A dictionary-like interface to the fields on a module.""" + + def __init__(self, kvs, module_cls, student_id, usage): + self._kvs = kvs + self._student_id = student_id + self._module_cls = module_cls + self._usage = usage + + def __repr__(self): + return "<{0.__class__.__name__} {0._module_cls!r}>".format(self) + + def __str__(self): + return str(dict(self.iteritems())) + + def _getfield(self, name): + if (not hasattr(self._module_cls, name) or + not isinstance(getattr(self._module_cls, name), ModelType)): + + raise KeyError(name) + + return getattr(self._module_cls, name) + + def _key(self, name): + field = self._getfield(name) + module = field.scope.module + + if module == ModuleScope.ALL: + module_id = None + elif module == ModuleScope.USAGE: + module_id = self._usage.id + elif module == ModuleScope.DEFINITION: + module_id = self._usage.def_id + elif module == ModuleScope.TYPE: + module_id = self.module_type.__name__ + + if field.scope.student: + student_id = self._student_id + else: + student_id = None + + key = KeyValueStore.Key( + scope=field.scope, + student_id=student_id, + module_scope_id=module_id, + field_name=name + ) + return key + + def __getitem__(self, name): + return self._kvs.get(self._key(name)) + + def __setitem__(self, name, value): + self._kvs.set(self._key(name), value) + + def __delitem__(self, name): + self._kvs.delete(self._key(name)) + + def __iter__(self): + return iter(self.keys()) + + def __len__(self): + return len(self.keys()) + + def keys(self): + fields = [field.name for field in self._module_cls.fields] + for namespace_name in self._module_cls.namespaces: + fields.extend(field.name for field in getattr(self._module_cls, namespace_name)) + return fields diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 9cad93ad5b..a2393aa235 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -417,15 +417,10 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): except ItemNotFoundError: log.exception('Unable to load item {loc}, skipping'.format(loc=child_loc)) continue - # TODO (vshnayder): this should go away once we have - # proper inheritance support in mongo. The xml - # datastore does all inheritance on course load. - #child.inherit_metadata(self.metadata) self._child_instances.append(child) return self._child_instances - def get_child_by_url_name(self, url_name): """ Return a child XModuleDescriptor with the specified url_name, if it exists, and None otherwise. From 45544396a89ebb235bd2c70582ea74caa28ef59d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 12 Dec 2012 12:47:31 -0500 Subject: [PATCH 014/501] Make computed defaults work, even in namespaces --- common/lib/xmodule/xmodule/model.py | 45 ++++++++++++++++++++++++----- lms/xmodule_namespace.py | 6 +++- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/common/lib/xmodule/xmodule/model.py b/common/lib/xmodule/xmodule/model.py index 9286b0337e..89fc9d56d1 100644 --- a/common/lib/xmodule/xmodule/model.py +++ b/common/lib/xmodule/xmodule/model.py @@ -26,11 +26,12 @@ class ModelType(object): """ sequence = 0 - def __init__(self, help=None, default=None, scope=Scope.content): + def __init__(self, help=None, default=None, scope=Scope.content, computed_default=None): self._seq = self.sequence self._name = "unknown" self.help = help self.default = default + self.computed_default = computed_default self.scope = scope ModelType.sequence += 1 @@ -43,6 +44,9 @@ class ModelType(object): return self if self.name not in instance._model_data: + if self.default is None and self.computed_default is not None: + return self.computed_default(instance) + return self.default return self.from_json(instance._model_data[self.name]) @@ -136,17 +140,44 @@ class NamespaceDescriptor(object): class Namespace(Plugin): """ - A baseclass that sets up machinery for ModelType fields that proxies the contained fields - requests for _model_data to self._container._model_data. + A baseclass that sets up machinery for ModelType fields that makes those fields be called + with the container as the field instance """ __metaclass__ = ModelMetaclass - __slots__ = ['container'] entry_point = 'xmodule.namespace' def __init__(self, container): self._container = container - @property - def _model_data(self): - return self._container._model_data + def __getattribute__(self, name): + container = super(Namespace, self).__getattribute__('_container') + namespace_attr = getattr(type(self), name, None) + + if namespace_attr is None or not isinstance(namespace_attr, ModelType): + return super(Namespace, self).__getattribute__(name) + + return namespace_attr.__get__(container, type(container)) + + def __setattr__(self, name, value): + try: + container = super(Namespace, self).__getattribute__('_container') + except AttributeError: + super(Namespace, self).__setattr__(name, value) + return + + container_class_attr = getattr(type(container), name, None) + + if container_class_attr is None or not isinstance(container_class_attr, ModelType): + return super(Namespace, self).__setattr__(name, value) + + return container_class_attr.__set__(container) + + def __delattr__(self, name): + container = super(Namespace, self).__getattribute__('_container') + container_class_attr = getattr(type(container), name, None) + + if container_class_attr is None or not isinstance(container_class_attr, ModelType): + return super(Namespace, self).__detattr__(name) + + return container_class_attr.__delete__(container) diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index 80b5fc6e61..7f30108b73 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -18,7 +18,11 @@ class LmsNamespace(Namespace): scope=Scope.settings ) - display_name = String(help="Display name for this module", scope=Scope.settings) + display_name = String( + help="Display name for this module", + scope=Scope.settings, + computed_default=lambda module: module.url_name.replace('_', ' ') + ) start = Date(help="Start time when this module is visible", scope=Scope.settings) due = String(help="Date that this problem is due by", scope=Scope.settings, default='') filename = List(help="DO NOT USE", scope=Scope.content, default=['', None]) From 57b3ceba2709537bc2669460af5c00a25fab05d8 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 12 Dec 2012 12:47:50 -0500 Subject: [PATCH 015/501] Add a field type that treats a string as an int --- common/lib/xmodule/xmodule/capa_module.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 7e8ad9210f..1f49435ca2 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -21,6 +21,16 @@ from xmodule.raw_module import RawDescriptor from xmodule.exceptions import NotFoundError from .model import Int, Scope, ModuleScope, ModelType, String, Boolean, Object, Float + +class StringyInt(Int): + """ + A model type that converts from strings to integers when reading from json + """ + def from_json(self, value): + if isinstance(value, basestring): + return int(value) + return value + log = logging.getLogger("mitx.courseware") #----------------------------------------------------------------------------- @@ -88,7 +98,7 @@ class CapaModule(XModule): icon_class = 'problem' attempts = Int(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.student_state) - max_attempts = Int(help="Maximum number of attempts that a student is allowed", scope=Scope.settings) + max_attempts = StringyInt(help="Maximum number of attempts that a student is allowed", scope=Scope.settings) due = String(help="Date that this problem is due by", scope=Scope.settings) graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) show_answer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed") From 7e224f58479d4fdb17edd76b20b3ec2a0d798571 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 12 Dec 2012 12:48:49 -0500 Subject: [PATCH 016/501] Convert a bunch more references from metadata to fields --- common/lib/xmodule/xmodule/capa_module.py | 5 +++ common/lib/xmodule/xmodule/course_module.py | 41 ++++--------------- .../lib/xmodule/xmodule/discussion_module.py | 11 +++++ common/lib/xmodule/xmodule/modulestore/xml.py | 15 ++++--- lms/djangoapps/courseware/access.py | 2 +- lms/djangoapps/courseware/grades.py | 14 +++---- lms/djangoapps/courseware/tests/tests.py | 4 +- lms/djangoapps/django_comment_client/utils.py | 6 +-- lms/xmodule_namespace.py | 3 +- 9 files changed, 47 insertions(+), 54 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 1f49435ca2..b8a85595dc 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -670,6 +670,11 @@ class CapaDescriptor(RawDescriptor): # actually use type and points? metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points') + # The capa format specifies that what we call max_attempts in the code + # is the attribute `attempts`. This will do that conversion + metadata_translations = dict(RawDescriptor.metadata_translations) + metadata_translations['attempts'] = 'max_attempts' + # VS[compat] # TODO (cpennington): Delete this method once all fall 2012 course are being # edited in the cms diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index c4dc63c1b4..b2a1917912 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -33,6 +33,9 @@ class CourseDescriptor(SequenceDescriptor): show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings) start = Date(help="Start time when this module is visible", scope=Scope.settings) display_name = String(help="Display name for this module", scope=Scope.settings) + tabs = List(help="List of tabs to enable in this course", scope=Scope.settings) + end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings) + discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings, default=[]) has_children = True info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') @@ -128,7 +131,7 @@ class CourseDescriptor(SequenceDescriptor): if self.start is None: msg = "Course loaded without a valid start date. id = %s" % self.id # hack it -- start in 1970 - self.metadata['start'] = stringify_time(time.gmtime(0)) + self.lms.start = time.gmtime(0) log.critical(msg) system.error_tracker(msg) @@ -198,8 +201,6 @@ class CourseDescriptor(SequenceDescriptor): grading_policy['GRADER'] = grader_from_conf(grading_policy['GRADER']) self._grading_policy = grading_policy - - @classmethod def read_grading_policy(cls, paths, system): """Load a grading policy from the specified paths, in order, if it exists.""" @@ -221,14 +222,13 @@ class CourseDescriptor(SequenceDescriptor): return policy_str - @classmethod def from_xml(cls, xml_data, system, org=None, course=None): instance = super(CourseDescriptor, cls).from_xml(xml_data, system, org, course) # bleh, have to parse the XML here to just pull out the url_name attribute course_file = StringIO(xml_data) - xml_obj = etree.parse(course_file,parser=edx_xml_parser).getroot() + xml_obj = etree.parse(course_file, parser=edx_xml_parser).getroot() policy_dir = None url_name = xml_obj.get('url_name', xml_obj.get('slug')) @@ -241,7 +241,7 @@ class CourseDescriptor(SequenceDescriptor): paths = [policy_dir + '/grading_policy.json'] + paths policy = json.loads(cls.read_grading_policy(paths, system)) - + # cdodge: import the grading policy information that is on disk and put into the # descriptor 'definition' bucket as a dictionary so that it is persisted in the DB instance.grading_policy = policy @@ -250,7 +250,6 @@ class CourseDescriptor(SequenceDescriptor): instance.set_grading_policy(policy) return instance - @classmethod def definition_from_xml(cls, xml_object, system): @@ -334,17 +333,6 @@ class CourseDescriptor(SequenceDescriptor): self.definition['data'].setdefault('grading_policy',{})['GRADE_CUTOFFS'] = value - @property - def tabs(self): - """ - Return the tabs config, as a python object, or None if not specified. - """ - return self.metadata.get('tabs') - - @tabs.setter - def tabs(self, value): - self.metadata['tabs'] = value - @lazyproperty def grading_context(self): """ @@ -383,14 +371,14 @@ class CourseDescriptor(SequenceDescriptor): for c in self.get_children(): sections = [] for s in c.get_children(): - if s.metadata.get('graded', False): + if s.lms.graded: xmoduledescriptors = list(yield_descriptor_descendents(s)) xmoduledescriptors.append(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) } - section_format = s.metadata.get('format', "") + section_format = s.lms.format graded_sections[ section_format ] = graded_sections.get( section_format, [] ) + [section_description] all_descriptors.extend(xmoduledescriptors) @@ -447,7 +435,7 @@ class CourseDescriptor(SequenceDescriptor): try: blackout_periods = [(parse_time(start), parse_time(end)) for start, end - in self.metadata.get('discussion_blackouts', [])] + in self.discussion_blackouts] now = time.gmtime() for start, end in blackout_periods: if start <= now <= end: @@ -457,17 +445,6 @@ class CourseDescriptor(SequenceDescriptor): return True - - @property - def end_of_course_survey_url(self): - """ - Pull from policy. Once we have our own survey module set up, can change this to point to an automatically - created survey for each class. - - Returns None if no url specified. - """ - return self.metadata.get('end_of_course_survey_url') - @property def title(self): return self.display_name diff --git a/common/lib/xmodule/xmodule/discussion_module.py b/common/lib/xmodule/xmodule/discussion_module.py index a193604278..19a2b4300f 100644 --- a/common/lib/xmodule/xmodule/discussion_module.py +++ b/common/lib/xmodule/xmodule/discussion_module.py @@ -12,6 +12,10 @@ class DiscussionModule(XModule): } js_module_name = "InlineDiscussion" + discussion_id = String(scope=Scope.settings) + discussion_category = String(scope=Scope.settings) + discussion_target = String(scope=Scope.settings) + data = String(help="XML definition of inline discussion", scope=Scope.content) def get_html(self): @@ -31,3 +35,10 @@ class DiscussionModule(XModule): class DiscussionDescriptor(RawDescriptor): module_class = DiscussionModule template_dir_name = "discussion" + + # The discussion XML format uses `id` and `for` attributes, + # but these would overload other module attributes, so we prefix them + # for actual use in the code + metadata_translations = dict(RawDescriptor.metadata_translations) + metadata_translations['id'] = 'discussion_id' + metadata_translations['for'] = 'discussion_target' diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 677b2e938e..ce6d9df093 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -430,10 +430,10 @@ class XMLModuleStore(ModuleStoreBase): NOTE: This means that there is no such thing as lazy loading at the moment--this accesses all the children.""" for child in descriptor.get_children(): - inherit_metadata(child, descriptor.metadata) + inherit_metadata(child, descriptor._model_data) compute_inherited_metadata(child) - def inherit_metadata(descriptor, metadata): + def inherit_metadata(descriptor, model_data): """ Updates this module with metadata inherited from a containing module. Only metadata specified in self.inheritable_metadata will @@ -441,11 +441,10 @@ class XMLModuleStore(ModuleStoreBase): """ # Set all inheritable metadata from kwargs that are # in self.inheritable_metadata and aren't already set in metadata - for attr in self.inheritable_metadata: - if attr not in self.metadata and attr in metadata: - self._inherited_metadata.add(attr) - self.metadata[attr] = metadata[attr] - + for attr in descriptor.inheritable_metadata: + if attr not in descriptor._model_data and attr in model_data: + descriptor._inherited_metadata.add(attr) + descriptor._model_data[attr] = model_data[attr] compute_inherited_metadata(course_descriptor) @@ -485,7 +484,7 @@ class XMLModuleStore(ModuleStoreBase): if category == "static_tab": for tab in course_descriptor.tabs or []: if tab.get('url_slug') == slug: - module.display_name = tab['name'] + module.lms.display_name = tab['name'] module.data_dir = course_dir self.modules[course_descriptor.id][module.location] = module except Exception, e: diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 0bd2311021..c6342e9d13 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -155,7 +155,7 @@ def _has_access_course_desc(user, course, action): 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'): + if course.lms.ispublic: debug("Allow: ACCESS_REQUIRE_STAFF_FOR_COURSE and ispublic") return True return _has_staff_access_to_descriptor(user, course) diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 1283844ade..f6fd7bda88 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -179,12 +179,12 @@ def grade(student, request, course, student_module_cache=None, keep_raw_scores=F else: correct = total - graded = module_descriptor.metadata.get("graded", False) + graded = module_descriptor.lms.graded if not total > 0: #We simply cannot grade a problem that is 12/0, because we might need it as a percentage graded = False - scores.append(Score(correct, total, graded, module_descriptor.metadata.get('display_name'))) + scores.append(Score(correct, total, graded, module_descriptor.lms.display_name)) section_total, graded_total = graders.aggregate_scores(scores, section_name) if keep_raw_scores: @@ -288,7 +288,7 @@ def progress_summary(student, request, course, student_module_cache): continue # Same for sections - graded = section_module.metadata.get('graded', False) + graded = section_module.lms.graded scores = [] module_creator = lambda descriptor : section_module.system.get_module(descriptor.location) @@ -301,20 +301,20 @@ def progress_summary(student, request, course, student_module_cache): continue scores.append(Score(correct, total, graded, - module_descriptor.metadata.get('display_name'))) + module_descriptor.lms.display_name)) scores.reverse() section_total, graded_total = graders.aggregate_scores( - scores, section_module.metadata.get('display_name')) + scores, section_module.lms.display_name) - format = section_module.metadata.get('format', "") + format = section_module.lms.format sections.append({ 'display_name': section_module.display_name, 'url_name': section_module.url_name, 'scores': scores, 'section_total': section_total, 'format': format, - 'due': section_module.metadata.get("due", ""), + 'due': section_module.due, 'graded': graded, }) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 5af79d4983..7a6d498660 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -488,8 +488,8 @@ class TestViewAuth(PageLoader): # Make courses start in the future tomorrow = time.time() + 24*3600 - self.toy.metadata['start'] = stringify_time(time.gmtime(tomorrow)) - self.full.metadata['start'] = stringify_time(time.gmtime(tomorrow)) + self.toy.lms.start = time.gmtime(tomorrow) + self.full.lms.start = time.gmtime(tomorrow) self.assertFalse(self.toy.has_started()) self.assertFalse(self.full.has_started()) diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index b3a1626d22..174805a999 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -142,9 +142,9 @@ def initialize_discussion_info(course): for location, module in all_modules.items(): if location.category == 'discussion': - id = module.metadata['id'] - category = module.metadata['discussion_category'] - title = module.metadata['for'] + id = module.discussion_id + category = module.discussion_category + title = module.discussion_target sort_key = module.metadata.get('sort_key', title) category = " / ".join([x.strip() for x in category.split("/")]) last_category = category.split("/")[-1] diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index 7f30108b73..c78052ed1b 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -15,7 +15,7 @@ class LmsNamespace(Namespace): format = String( help="What format this module is in (used for deciding which " "grader to apply, and what to show in the TOC)", - scope=Scope.settings + scope=Scope.settings, ) display_name = String( @@ -29,3 +29,4 @@ class LmsNamespace(Namespace): source_file = String(help="DO NOT USE", scope=Scope.settings) giturl = String(help="DO NOT USE", scope=Scope.settings, default='https://github.com/MITx') xqa_key = String(help="DO NOT USE", scope=Scope.settings) + ispublic = Boolean(help="Whether this course is open to the public, or only to admins", scope=Scope.settings) From 01411ae66e9a596d19e43595a64d4192708de36d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 13 Dec 2012 11:06:10 -0500 Subject: [PATCH 017/501] WIP: Trying to get tests working --- common/djangoapps/mitxmako/shortcuts.py | 2 +- common/djangoapps/xmodule_modifiers.py | 1 + common/lib/capa/capa/capa_problem.py | 2 +- common/lib/capa/capa/customrender.py | 2 +- common/lib/capa/capa/inputtypes.py | 2 +- common/lib/capa/capa/responsetypes.py | 2 +- common/lib/capa/capa/xqueue_interface.py | 2 +- common/lib/xmodule/xmodule/capa_module.py | 3 +- common/lib/xmodule/xmodule/course_module.py | 4 +- common/lib/xmodule/xmodule/error_module.py | 31 ++++--- common/lib/xmodule/xmodule/fields.py | 30 +++++++ common/lib/xmodule/xmodule/model.py | 18 ++-- common/lib/xmodule/xmodule/modulestore/xml.py | 3 +- .../xmodule/modulestore/xml_importer.py | 52 ++++++----- common/lib/xmodule/xmodule/runtime.py | 30 +++++-- .../xmodule/xmodule/self_assessment_module.py | 88 ++++++------------- common/lib/xmodule/xmodule/tests/__init__.py | 3 +- .../lib/xmodule/xmodule/tests/test_import.py | 42 +++++---- .../lib/xmodule/xmodule/tests/test_model.py | 8 ++ .../lib/xmodule/xmodule/tests/test_runtime.py | 0 common/lib/xmodule/xmodule/x_module.py | 29 +----- common/lib/xmodule/xmodule/xml_module.py | 2 + lms/djangoapps/courseware/grades.py | 16 ++-- lms/djangoapps/courseware/module_render.py | 1 + lms/djangoapps/instructor/views.py | 2 +- lms/lib/comment_client/utils.py | 2 +- lms/templates/staff_problem_info.html | 19 +++- lms/xmodule_namespace.py | 14 ++- local-requirements.txt | 2 +- lms/setup.py => setup.py | 4 +- 30 files changed, 219 insertions(+), 197 deletions(-) create mode 100644 common/lib/xmodule/xmodule/fields.py create mode 100644 common/lib/xmodule/xmodule/tests/test_model.py create mode 100644 common/lib/xmodule/xmodule/tests/test_runtime.py rename lms/setup.py => setup.py (85%) diff --git a/common/djangoapps/mitxmako/shortcuts.py b/common/djangoapps/mitxmako/shortcuts.py index 181d3befd5..8f39efdc02 100644 --- a/common/djangoapps/mitxmako/shortcuts.py +++ b/common/djangoapps/mitxmako/shortcuts.py @@ -14,7 +14,7 @@ import logging -log = logging.getLogger("mitx." + __name__) +log = logging.getLogger(__name__) from django.template import Context from django.http import HttpResponse diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 5a13582a2d..30098c94fc 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -131,6 +131,7 @@ def add_histogram(get_html, module, user): is_released = "Yes!" if (now > mstart) else "Not yet" staff_context = {'fields': [(field.name, getattr(module, field.name)) for field in module.fields], + 'lms_fields': [(field.name, getattr(module.lms, field.name)) for field in module.lms.fields], 'location': module.location, 'xqa_key': module.lms.xqa_key, 'source_file' : source_file, diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index 85670063c5..d5ec6a1439 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -72,7 +72,7 @@ global_context = {'random': random, # These should be removed from HTML output, including all subelements html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup"] -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) #----------------------------------------------------------------------------- # main class for this module diff --git a/common/lib/capa/capa/customrender.py b/common/lib/capa/capa/customrender.py index ef1044e8b1..0268d7b266 100644 --- a/common/lib/capa/capa/customrender.py +++ b/common/lib/capa/capa/customrender.py @@ -17,7 +17,7 @@ from lxml import etree import xml.sax.saxutils as saxutils from registry import TagRegistry -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) registry = TagRegistry() diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py index 0b2250f98d..8a15434d1d 100644 --- a/common/lib/capa/capa/inputtypes.py +++ b/common/lib/capa/capa/inputtypes.py @@ -44,7 +44,7 @@ import sys from registry import TagRegistry -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) ######################################################################### diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 418ee9d8ae..2074e8f648 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -33,7 +33,7 @@ from lxml import etree from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME? import xqueue_interface -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) #----------------------------------------------------------------------------- diff --git a/common/lib/capa/capa/xqueue_interface.py b/common/lib/capa/capa/xqueue_interface.py index 0214488cce..a7d31db7e1 100644 --- a/common/lib/capa/capa/xqueue_interface.py +++ b/common/lib/capa/capa/xqueue_interface.py @@ -7,7 +7,7 @@ import logging import requests -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) dateformat = '%Y%m%d%H%M%S' def make_hashkey(seed): diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index b8a85595dc..36da929926 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -551,8 +551,7 @@ class CapaModule(XModule): msg = "Error checking problem: " + str(err) msg += '\nTraceback:\n' + traceback.format_exc() return {'success': msg} - log.exception("Error in capa_module problem checking") - raise Exception("error in capa_module") + raise self.attempts = self.attempts + 1 self.lcp.done = True diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index b2a1917912..b8d4032a18 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -131,9 +131,9 @@ class CourseDescriptor(SequenceDescriptor): if self.start is None: msg = "Course loaded without a valid start date. id = %s" % self.id # hack it -- start in 1970 - self.lms.start = time.gmtime(0) + self.start = time.gmtime(0) log.critical(msg) - system.error_tracker(msg) + self.system.error_tracker(msg) # NOTE: relies on the modulestore to call set_grading_policy() right after # init. (Modulestore is in charge of figuring out where to load the policy from) diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py index 0f95bcd256..67b52d383c 100644 --- a/common/lib/xmodule/xmodule/error_module.py +++ b/common/lib/xmodule/xmodule/error_module.py @@ -8,6 +8,7 @@ from xmodule.x_module import XModule from xmodule.editing_module import JSONEditingDescriptor from xmodule.errortracker import exc_info_to_str from xmodule.modulestore import Location +from .model import String, Scope log = logging.getLogger(__name__) @@ -52,6 +53,10 @@ class ErrorDescriptor(JSONEditingDescriptor): """ module_class = ErrorModule + contents = String(scope=Scope.content) + error_msg = String(scope=Scope.content) + display_name = String(scope=Scope.settings) + @classmethod def _construct(self, system, contents, error_msg, location): @@ -66,15 +71,12 @@ class ErrorDescriptor(JSONEditingDescriptor): name=hashlib.sha1(contents).hexdigest() ) - definition = { - 'data': { - 'error_msg': str(error_msg), - 'contents': contents, - } - } - # real metadata stays in the content, but add a display name - model_data = {'display_name': 'Error: ' + location.name} + model_data = { + 'error_msg': str(error_msg), + 'contents': contents, + 'display_name': 'Error: ' + location.name + } return ErrorDescriptor( system, location, @@ -84,7 +86,7 @@ class ErrorDescriptor(JSONEditingDescriptor): def get_context(self): return { 'module': self, - 'data': self.definition['data']['contents'], + 'data': self.contents, } @classmethod @@ -100,10 +102,7 @@ class ErrorDescriptor(JSONEditingDescriptor): def from_descriptor(cls, descriptor, error_msg='Error not available'): return cls._construct( descriptor.system, - json.dumps({ - 'definition': descriptor.definition, - 'metadata': descriptor.metadata, - }, indent=4), + json.dumps(descriptor._model_data, indent=4), error_msg, location=descriptor.location, ) @@ -147,14 +146,14 @@ class ErrorDescriptor(JSONEditingDescriptor): files, etc. That would just get re-wrapped on import. ''' try: - xml = etree.fromstring(self.definition['data']['contents']) + xml = etree.fromstring(self.contents) return etree.tostring(xml) except etree.XMLSyntaxError: # still not valid. root = etree.Element('error') - root.text = self.definition['data']['contents'] + root.text = self.contents err_node = etree.SubElement(root, 'error_msg') - err_node.text = self.definition['data']['error_msg'] + err_node.text = self.error_msg return etree.tostring(root) diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py new file mode 100644 index 0000000000..715aea0c7c --- /dev/null +++ b/common/lib/xmodule/xmodule/fields.py @@ -0,0 +1,30 @@ +import time +import logging + +from .model import ModelType + +log = logging.getLogger(__name__) + + +class Date(ModelType): + time_format = "%Y-%m-%dT%H:%M" + + def from_json(self, value): + """ + Parse an optional metadata key containing a time: if present, complain + if it doesn't parse. + Return None if not present or invalid. + """ + try: + return time.strptime(value, self.time_format) + except ValueError as e: + msg = "Field {0} has bad value '{1}': '{2}'".format( + self._name, value, e) + log.warning(msg) + return None + + def to_json(self, value): + """ + Convert a time struct to a string + """ + return time.strftime(self.time_format, value) diff --git a/common/lib/xmodule/xmodule/model.py b/common/lib/xmodule/xmodule/model.py index 89fc9d56d1..93ed12f69d 100644 --- a/common/lib/xmodule/xmodule/model.py +++ b/common/lib/xmodule/xmodule/model.py @@ -43,14 +43,14 @@ class ModelType(object): if instance is None: return self - if self.name not in instance._model_data: + try: + return self.from_json(instance._model_data[self.name]) + except KeyError: if self.default is None and self.computed_default is not None: return self.computed_default(instance) return self.default - return self.from_json(instance._model_data[self.name]) - def __set__(self, instance, value): instance._model_data[self.name] = self.to_json(value) @@ -166,18 +166,18 @@ class Namespace(Plugin): super(Namespace, self).__setattr__(name, value) return - container_class_attr = getattr(type(container), name, None) + namespace_attr = getattr(type(self), name, None) - if container_class_attr is None or not isinstance(container_class_attr, ModelType): + if namespace_attr is None or not isinstance(namespace_attr, ModelType): return super(Namespace, self).__setattr__(name, value) - return container_class_attr.__set__(container) + return namespace_attr.__set__(container, value) def __delattr__(self, name): container = super(Namespace, self).__getattribute__('_container') - container_class_attr = getattr(type(container), name, None) + namespace_attr = getattr(type(self), name, None) - if container_class_attr is None or not isinstance(container_class_attr, ModelType): + if namespace_attr is None or not isinstance(namespace_attr, ModelType): return super(Namespace, self).__detattr__(name) - return container_class_attr.__delete__(container) + return namespace_attr.__delete__(container) diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index ce6d9df093..72c9093bbf 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -28,7 +28,7 @@ edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False, etree.set_default_parser(edx_xml_parser) -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) # VS[compat] @@ -160,7 +160,6 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): etree.tostring(xml_data), self, self.org, self.course, xmlstore.default_class) except Exception as err: - print err, self.load_error_modules if not self.load_error_modules: raise diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 35375d7c51..f90291aaa2 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -8,6 +8,7 @@ from .xml import XMLModuleStore from .exceptions import DuplicateItemError from xmodule.modulestore import Location from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX +from xmodule.model import Scope log = logging.getLogger(__name__) @@ -123,7 +124,7 @@ def import_from_xml(store, data_dir, course_dirs=None, # Quick scan to get course Location as well as the course_data_path for module in module_store.modules[course_id].itervalues(): if module.category == 'course': - course_data_path = path(data_dir) / module.metadata['data_dir'] + course_data_path = path(data_dir) / module.data_dir course_location = module.location if static_content_store is not None: @@ -159,18 +160,17 @@ def import_from_xml(store, data_dir, course_dirs=None, module.definition['children'] = new_locs - if module.category == 'course': # HACK: for now we don't support progress tabs. There's a special metadata configuration setting for this. - module.metadata['hide_progress_tab'] = True + module.hide_progress_tab = True - # cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which - # does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS, - # but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that - + # cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which + # does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS, + # but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that - # if there is *any* tabs - then there at least needs to be some predefined ones if module.tabs is None or len(module.tabs) == 0: - module.tabs = [{"type": "courseware"}, - {"type": "course_info", "name": "Course Info"}, + module.tabs = [{"type": "courseware"}, + {"type": "course_info", "name": "Course Info"}, {"type": "discussion", "name": "Discussion"}, {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge @@ -180,39 +180,43 @@ def import_from_xml(store, data_dir, course_dirs=None, course_items.append(module) - if 'data' in module.definition: - module_data = module.definition['data'] - + if hasattr(module, 'data'): # cdodge: now go through any link references to '/static/' and make sure we've imported # it as a StaticContent asset - try: + try: remap_dict = {} # use the rewrite_links as a utility means to enumerate through all links # in the module data. We use that to load that reference into our asset store # IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to # do the rewrites natively in that code. - # For example, what I'm seeing is -> + # For example, what I'm seeing is -> # Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's # no good, so we have to do this kludge - if isinstance(module_data, str) or isinstance(module_data, unicode): # some module 'data' fields are non strings which blows up the link traversal code - lxml_rewrite_links(module_data, lambda link: verify_content_links(module, course_data_path, - static_content_store, link, remap_dict)) + if isinstance(module.data, str) or isinstance(module.data, unicode): # some module 'data' fields are non strings which blows up the link traversal code + lxml_rewrite_links(module.data, lambda link: verify_content_links(module, course_data_path, + static_content_store, link, remap_dict)) for key in remap_dict.keys(): - module_data = module_data.replace(key, remap_dict[key]) + module.data = module.data.replace(key, remap_dict[key]) - except Exception, e: + except Exception: logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location)) - store.update_item(module.location, module_data) + store.update_item(module.location, module.data) - if 'children' in module.definition: - store.update_children(module.location, module.definition['children']) + if module.has_children: + store.update_children(module.location, module.children) - # NOTE: It's important to use own_metadata here to avoid writing - # inherited metadata everywhere. - store.update_metadata(module.location, dict(module.own_metadata)) + metadata = {} + for field in module.fields + module.lms.fields: + # Only save metadata that wasn't inherited + if (field.scope == Scope.settings and + field.name in module._inherited_metadata and + field.name in module._model_data): + + metadata[field.name] = module._model_data[field.name] + store.update_metadata(module.location, metadata) return module_store, course_items diff --git a/common/lib/xmodule/xmodule/runtime.py b/common/lib/xmodule/xmodule/runtime.py index 8be4bfe346..7030f04f9f 100644 --- a/common/lib/xmodule/xmodule/runtime.py +++ b/common/lib/xmodule/xmodule/runtime.py @@ -33,19 +33,33 @@ class DbModel(MutableMapping): def __repr__(self): return "<{0.__class__.__name__} {0._module_cls!r}>".format(self) - def __str__(self): - return str(dict(self.iteritems())) - def _getfield(self, name): - if (not hasattr(self._module_cls, name) or - not isinstance(getattr(self._module_cls, name), ModelType)): + # First, get the field from the class, if defined + module_field = getattr(self._module_cls, name, None) + if module_field is not None and isinstance(module_field, ModelType): + return module_field - raise KeyError(name) + # If the class doesn't have the field, and it also + # doesn't have any namespaces, then the the name isn't a field + # so KeyError + if not hasattr(self._module_cls, 'namespaces'): + return KeyError(name) - return getattr(self._module_cls, name) + # Resolve the field name in the first namespace where it's + # available + for namespace_name in self._module_cls.namespaces: + namespace = getattr(self._module_cls, namespace_name) + namespace_field = getattr(type(namespace), name, None) + if namespace_field is not None and isinstance(module_field, ModelType): + return namespace_field + + # Not in the class or in any of the namespaces, so name + # really doesn't name a field + raise KeyError(name) def _key(self, name): field = self._getfield(name) + print name, field module = field.scope.module if module == ModuleScope.ALL: @@ -88,5 +102,5 @@ class DbModel(MutableMapping): def keys(self): fields = [field.name for field in self._module_cls.fields] for namespace_name in self._module_cls.namespaces: - fields.extend(field.name for field in getattr(self._module_cls, namespace_name)) + fields.extend(field.name for field in getattr(self._module_cls, namespace_name).fields) return fields diff --git a/common/lib/xmodule/xmodule/self_assessment_module.py b/common/lib/xmodule/xmodule/self_assessment_module.py index 2edf5467b2..2f4674cf7c 100644 --- a/common/lib/xmodule/xmodule/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/self_assessment_module.py @@ -25,6 +25,7 @@ from .stringify import stringify_children from .x_module import XModule from .xml_module import XmlDescriptor from xmodule.modulestore import Location +from .model import List, String, Scope, Int log = logging.getLogger("mitx.courseware") @@ -61,67 +62,21 @@ class SelfAssessmentModule(XModule): js = {'coffee': [resource_string(__name__, 'js/src/selfassessment/display.coffee')]} js_module_name = "SelfAssessment" - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) + student_answers = List(scope=Scope.student_state, default=[]) + scores = List(scope=Scope.student_state, default=[]) + hints = List(scope=Scope.student_state, default=[]) + state = String(scope=Scope.student_state, default=INITIAL) - """ - Definition file should have 4 blocks -- prompt, rubric, submitmessage, hintprompt, - and two optional attributes: - attempts, which should be an integer that defaults to 1. - If it's > 1, the student will be able to re-submit after they see - the rubric. - max_score, which should be an integer that defaults to 1. - It defines the maximum number of points a student can get. Assumed to be integer scale - from 0 to max_score, with an interval of 1. + # Used for progress / grading. Currently get credit just for + # completion (doesn't matter if you self-assessed correct/incorrect). + max_score = Int(scope=Scope.settings, default=MAX_SCORE) - Note: all the submissions are stored. - - Sample file: - - - - Insert prompt text here. (arbitrary html) - - - Insert grading rubric here. (arbitrary html) - - - Please enter a hint below: (arbitrary html) - - - Thanks for submitting! (arbitrary html) - - - """ - - # Load instance state - if instance_state is not None: - instance_state = json.loads(instance_state) - else: - instance_state = {} - - # Note: score responses are on scale from 0 to max_score - self.student_answers = instance_state.get('student_answers', []) - self.scores = instance_state.get('scores', []) - self.hints = instance_state.get('hints', []) - - self.state = instance_state.get('state', 'initial') - - # Used for progress / grading. Currently get credit just for - # completion (doesn't matter if you self-assessed correct/incorrect). - - self._max_score = int(self.metadata.get('max_score', MAX_SCORE)) - - self.attempts = instance_state.get('attempts', 0) - - self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS)) - - self.rubric = definition['rubric'] - self.prompt = definition['prompt'] - self.submit_message = definition['submitmessage'] - self.hint_prompt = definition['hintprompt'] + attempts = Int(scope=Scope.student_state, default=0), Int + max_attempts = Int(scope=Scope.settings, default=MAX_ATTEMPTS) + rubric = String(scope=Scope.content) + prompt = String(scope=Scope.content) + submit_message = String(scope=Scope.content) + hint_prompt = String(scope=Scope.content) def _allow_reset(self): """Can the module be reset?""" @@ -432,6 +387,21 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor): js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]} js_module_name = "HTMLEditingDescriptor" + # The capa format specifies that what we call max_attempts in the code + # is the attribute `attempts`. This will do that conversion + metadata_translations = dict(XmlDescriptor.metadata_translations) + metadata_translations['attempts'] = 'max_attempts' + + # Used for progress / grading. Currently get credit just for + # completion (doesn't matter if you self-assessed correct/incorrect). + max_score = Int(scope=Scope.settings, default=MAX_SCORE) + + max_attempts = Int(scope=Scope.settings, default=MAX_ATTEMPTS) + rubric = String(scope=Scope.content) + prompt = String(scope=Scope.content) + submit_message = String(scope=Scope.content) + hint_prompt = String(scope=Scope.content) + @classmethod def definition_from_xml(cls, xml_object, system): """ diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index ed64c45118..c16c6d7596 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -30,7 +30,8 @@ i4xs = ModuleSystem( debug=True, xqueue={'interface':None, 'callback_url':'/', 'default_queuename': 'testqueue', 'waittime': 10}, node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), - anonymous_student_id = 'student' + anonymous_student_id = 'student', + xmodule_model_data = lambda x: x, ) diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index 77532959d7..d243ca1609 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -91,8 +91,10 @@ class ImportTestCase(unittest.TestCase): self.assertEqual(re_import_descriptor.__class__.__name__, 'ErrorDescriptor') - self.assertEqual(descriptor.definition['data'], - re_import_descriptor.definition['data']) + self.assertEqual(descriptor.contents, + re_import_descriptor.contents) + self.assertEqual(descriptor.error_msg, + re_import_descriptor.error_msg) def test_fixed_xml_tag(self): """Make sure a tag that's been fixed exports as the original tag type""" @@ -126,23 +128,19 @@ class ImportTestCase(unittest.TestCase): url_name = 'test1' start_xml = ''' + due="{due}" url_name="{url_name}" unicorn="purple"> Two houses, ... - '''.format(grace=v, org=ORG, course=COURSE, url_name=url_name) + '''.format(due=v, org=ORG, course=COURSE, url_name=url_name) descriptor = system.process_xml(start_xml) - print descriptor, descriptor.metadata - self.assertEqual(descriptor.metadata['graceperiod'], v) - self.assertEqual(descriptor.metadata['unicorn'], 'purple') + print descriptor, descriptor._model_data + self.assertEqual(descriptor.lms.due, v) - # Check that the child inherits graceperiod correctly + # Check that the child inherits due correctly child = descriptor.get_children()[0] - self.assertEqual(child.metadata['graceperiod'], v) - - # check that the child does _not_ inherit any unicorns - self.assertTrue('unicorn' not in child.metadata) + self.assertEqual(child.lms.due, v) # Now export and check things resource_fs = MemoryFS() @@ -169,12 +167,12 @@ class ImportTestCase(unittest.TestCase): # did we successfully strip the url_name from the definition contents? self.assertTrue('url_name' not in course_xml.attrib) - # Does the chapter tag now have a graceperiod attribute? + # Does the chapter tag now have a due attribute? # hardcoded path to child with resource_fs.open('chapter/ch.xml') as f: chapter_xml = etree.fromstring(f.read()) self.assertEqual(chapter_xml.tag, 'chapter') - self.assertFalse('graceperiod' in chapter_xml.attrib) + self.assertFalse('due' in chapter_xml.attrib) def test_is_pointer_tag(self): """ @@ -216,7 +214,7 @@ class ImportTestCase(unittest.TestCase): def check_for_key(key, node): "recursive check for presence of key" print "Checking {0}".format(node.location.url()) - self.assertTrue(key in node.metadata) + self.assertTrue(key in node._model_data) for c in node.get_children(): check_for_key(key, c) @@ -244,15 +242,15 @@ class ImportTestCase(unittest.TestCase): toy_ch = toy.get_children()[0] two_toys_ch = two_toys.get_children()[0] - self.assertEqual(toy_ch.display_name, "Overview") - self.assertEqual(two_toys_ch.display_name, "Two Toy Overview") + self.assertEqual(toy_ch.lms.display_name, "Overview") + self.assertEqual(two_toys_ch.lms.display_name, "Two Toy Overview") # Also check that the grading policy loaded self.assertEqual(two_toys.grade_cutoffs['C'], 0.5999) # Also check that keys from policy are run through the # appropriate attribute maps -- 'graded' should be True, not 'true' - self.assertEqual(toy.metadata['graded'], True) + self.assertEqual(toy.lms.graded, True) def test_definition_loading(self): @@ -271,8 +269,8 @@ class ImportTestCase(unittest.TestCase): location = Location(["i4x", "edX", "toy", "video", "Welcome"]) toy_video = modulestore.get_instance(toy_id, location) two_toy_video = modulestore.get_instance(two_toy_id, location) - self.assertEqual(toy_video.metadata['youtube'], "1.0:p2Q6BrNhdh8") - self.assertEqual(two_toy_video.metadata['youtube'], "1.0:p2Q6BrNhdh9") + self.assertEqual(toy_video.youtube, "1.0:p2Q6BrNhdh8") + self.assertEqual(two_toy_video.youtube, "1.0:p2Q6BrNhdh9") def test_colon_in_url_name(self): @@ -306,7 +304,7 @@ class ImportTestCase(unittest.TestCase): cloc = course.location loc = Location(cloc.tag, cloc.org, cloc.course, 'html', 'secret:toylab') html = modulestore.get_instance(course_id, loc) - self.assertEquals(html.display_name, "Toy lab") + self.assertEquals(html.lms.display_name, "Toy lab") def test_url_name_mangling(self): """ @@ -351,4 +349,4 @@ class ImportTestCase(unittest.TestCase): location = Location(["i4x", "edX", "sa_test", "selfassessment", "SampleQuestion"]) sa_sample = modulestore.get_instance(sa_id, location) #10 attempts is hard coded into SampleQuestion, which is the url_name of a selfassessment xml tag - self.assertEqual(sa_sample.metadata['attempts'], '10') + self.assertEqual(sa_sample.max_attempts, '10') diff --git a/common/lib/xmodule/xmodule/tests/test_model.py b/common/lib/xmodule/xmodule/tests/test_model.py new file mode 100644 index 0000000000..0e42df80a1 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_model.py @@ -0,0 +1,8 @@ + +class ModelMetaclassTester(object): + __metaclass__ = ModelMetaclass + + field_a = Int(scope=Scope.settings) + field_b = Int(scope=Scope.content) + +def test_model_metaclass(): diff --git a/common/lib/xmodule/xmodule/tests/test_runtime.py b/common/lib/xmodule/xmodule/tests/test_runtime.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index a2393aa235..f03fbe6183 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -1,7 +1,6 @@ import logging import yaml import os -import time from lxml import etree from pprint import pprint @@ -10,38 +9,14 @@ from pkg_resources import resource_listdir, resource_string, resource_isdir from xmodule.modulestore import Location -from .model import ModelMetaclass, ParentModelMetaclass, NamespacesMetaclass, ModelType +from .model import ModelMetaclass, ParentModelMetaclass, NamespacesMetaclass from .plugin import Plugin -class Date(ModelType): - time_format = "%Y-%m-%dT%H:%M" - - def from_json(self, value): - """ - Parse an optional metadata key containing a time: if present, complain - if it doesn't parse. - Return None if not present or invalid. - """ - try: - return time.strptime(value, self.time_format) - except ValueError as e: - msg = "Field {0} has bad value '{1}': '{2}'".format( - self._name, value, e) - log.warning(msg) - return None - - def to_json(self, value): - """ - Convert a time struct to a string - """ - return time.strftime(self.time_format, value) - - class XModuleMetaclass(ParentModelMetaclass, NamespacesMetaclass, ModelMetaclass): pass -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) def dummy_track(event_type, event): diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 36bea6edb2..50c4f7aa6d 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -311,11 +311,13 @@ class XmlDescriptor(XModuleDescriptor): # Set/override any metadata specified by policy k = policy_key(location) if k in system.policy: + if k == 'video/labintro': print k, metadata, system.policy[k] cls.apply_policy(metadata, system.policy[k]) model_data = {} model_data.update(metadata) model_data.update(definition) + if k == 'video/labintro': print model_data return cls( system, diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index f6fd7bda88..032378f863 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -148,7 +148,7 @@ def grade(student, request, course, student_module_cache=None, keep_raw_scores=F format_scores = [] for section in sections: section_descriptor = section['section_descriptor'] - section_name = section_descriptor.display_name + section_name = section_descriptor.lms.display_name should_grade_section = False # If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0% @@ -276,15 +276,13 @@ def progress_summary(student, request, course, student_module_cache): # Don't include chapters that aren't displayable (e.g. due to error) for chapter_module in course_module.get_display_items(): # Skip if the chapter is hidden - hidden = chapter_module._model_data.get('hide_from_toc','false') - if hidden.lower() == 'true': + if chapter_module.lms.hide_from_toc: continue sections = [] for section_module in chapter_module.get_display_items(): # Skip if the section is hidden - hidden = section_module._model_data.get('hide_from_toc','false') - if hidden.lower() == 'true': + if section_module.lms.hide_from_toc: continue # Same for sections @@ -309,17 +307,17 @@ def progress_summary(student, request, course, student_module_cache): format = section_module.lms.format sections.append({ - 'display_name': section_module.display_name, + 'display_name': section_module.lms.display_name, 'url_name': section_module.url_name, 'scores': scores, 'section_total': section_total, 'format': format, - 'due': section_module.due, + 'due': section_module.lms.due, 'graded': graded, }) - chapters.append({'course': course.display_name, - 'display_name': chapter_module.display_name, + chapters.append({'course': course.lms.display_name, + 'display_name': chapter_module.lms.display_name, 'url_name': chapter_module.url_name, 'sections': sections}) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index f83ff35f1f..be207730b3 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -92,6 +92,7 @@ def toc_for_course(user, request, course, active_chapter, active_section): chapters = list() for chapter in course_module.get_display_items(): + print chapter, chapter._model_data, chapter._model_data.get('hide_from_toc'), chapter.lms.hide_from_toc if chapter.lms.hide_from_toc: continue diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index 0b6392a7fc..0e05ad5bc5 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -76,7 +76,7 @@ def instructor_dashboard(request, course_id): data = [['# Enrolled', CourseEnrollment.objects.filter(course_id=course_id).count()]] data += compute_course_stats(course).items() if request.user.is_staff: - data.append(['metadata', escape(str(course.metadata))]) + data.append(['metadata', escape(str(course._model_data))]) datatable['data'] = data def return_csv(fn, datatable): diff --git a/lms/lib/comment_client/utils.py b/lms/lib/comment_client/utils.py index f50797d5e0..9a7043565a 100644 --- a/lms/lib/comment_client/utils.py +++ b/lms/lib/comment_client/utils.py @@ -3,7 +3,7 @@ import logging import requests import settings -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger(__name__) def strip_none(dic): return dict([(k, v) for k, v in dic.iteritems() if v is not None]) diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index ad4852335f..e0c957a05c 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -46,9 +46,22 @@ github = ${edit_link | h} %if source_file: source_url = ${source_file | h} %endif -%for name, field in fields: -${name} =
${field | h}
-%endfor +
+

Module Fields

+ %for name, field in fields: +
+ ${name} =
${field | h}
+
+ %endfor +
+
+

edX Fields

+ %for name, field in lms_fields: +
+ ${name} =
${field | h}
+
+ %endfor +
category = ${category | h}
%if render_histogram: diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index c78052ed1b..976bd4483f 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -1,8 +1,17 @@ from xmodule.model import Namespace, Boolean, Scope, String, List -from xmodule.x_module import Date +from xmodule.fields import Date + + +class StringyBoolean(Boolean): + def from_json(self, value): + print "StringyBoolean ", value + if isinstance(value, basestring): + return value.lower() == 'true' + return value + class LmsNamespace(Namespace): - hide_from_toc = Boolean( + hide_from_toc = StringyBoolean( help="Whether to display this module in the table of contents", default=False, scope=Scope.settings @@ -16,6 +25,7 @@ class LmsNamespace(Namespace): help="What format this module is in (used for deciding which " "grader to apply, and what to show in the TOC)", scope=Scope.settings, + default='', ) display_name = String( diff --git a/local-requirements.txt b/local-requirements.txt index 2e951a180c..201467d11e 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -1,4 +1,4 @@ # Python libraries to install that are local to the mitx repo -e common/lib/capa -e common/lib/xmodule --e lms +-e . diff --git a/lms/setup.py b/setup.py similarity index 85% rename from lms/setup.py rename to setup.py index 1755b7d37e..84f242cf3d 100644 --- a/lms/setup.py +++ b/setup.py @@ -1,13 +1,13 @@ from setuptools import setup, find_packages setup( - name="edX LMS", + name="edX Apps", version="0.1", install_requires=['distribute'], requires=[ 'xmodule', ], - + py_modules=['lms.xmodule_namespace'], # See http://guide.python-distribute.org/creation.html#entry-points # for a description of entry_points entry_points={ From eb1ab3ea7db15fc75485d02e65bf49535215945a Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 13 Dec 2012 15:34:10 -0500 Subject: [PATCH 018/501] Add tests of xmodule.model and xmodule.runtime, and fix some exposed bugs --- common/lib/xmodule/xmodule/model.py | 2 - common/lib/xmodule/xmodule/runtime.py | 11 +- .../lib/xmodule/xmodule/tests/test_model.py | 154 +++++++++++++++++- .../lib/xmodule/xmodule/tests/test_runtime.py | 67 ++++++++ 4 files changed, 222 insertions(+), 12 deletions(-) diff --git a/common/lib/xmodule/xmodule/model.py b/common/lib/xmodule/xmodule/model.py index 93ed12f69d..380c81dbe9 100644 --- a/common/lib/xmodule/xmodule/model.py +++ b/common/lib/xmodule/xmodule/model.py @@ -133,8 +133,6 @@ class NamespaceDescriptor(object): self._namespace = namespace def __get__(self, instance, owner): - if owner is None: - return self return self._namespace(instance) diff --git a/common/lib/xmodule/xmodule/runtime.py b/common/lib/xmodule/xmodule/runtime.py index 7030f04f9f..6f20997894 100644 --- a/common/lib/xmodule/xmodule/runtime.py +++ b/common/lib/xmodule/xmodule/runtime.py @@ -11,13 +11,13 @@ class KeyValueStore(object): # data. Key = namedtuple("Key", "scope, student_id, module_scope_id, field_name") - def get(key): + def get(self, key): pass - def set(key, value): + def set(self, key, value): pass - def delete(key): + def delete(self, key): pass @@ -50,7 +50,7 @@ class DbModel(MutableMapping): for namespace_name in self._module_cls.namespaces: namespace = getattr(self._module_cls, namespace_name) namespace_field = getattr(type(namespace), name, None) - if namespace_field is not None and isinstance(module_field, ModelType): + if namespace_field is not None and isinstance(namespace_field, ModelType): return namespace_field # Not in the class or in any of the namespaces, so name @@ -59,7 +59,6 @@ class DbModel(MutableMapping): def _key(self, name): field = self._getfield(name) - print name, field module = field.scope.module if module == ModuleScope.ALL: @@ -69,7 +68,7 @@ class DbModel(MutableMapping): elif module == ModuleScope.DEFINITION: module_id = self._usage.def_id elif module == ModuleScope.TYPE: - module_id = self.module_type.__name__ + module_id = self._module_cls.__name__ if field.scope.student: student_id = self._student_id diff --git a/common/lib/xmodule/xmodule/tests/test_model.py b/common/lib/xmodule/xmodule/tests/test_model.py index 0e42df80a1..b0ae9e5844 100644 --- a/common/lib/xmodule/xmodule/tests/test_model.py +++ b/common/lib/xmodule/xmodule/tests/test_model.py @@ -1,8 +1,154 @@ +from mock import patch +from unittest import TestCase +from nose.tools import assert_in, assert_equals, assert_raises -class ModelMetaclassTester(object): - __metaclass__ = ModelMetaclass +from xmodule.model import * - field_a = Int(scope=Scope.settings) - field_b = Int(scope=Scope.content) def test_model_metaclass(): + class ModelMetaclassTester(object): + __metaclass__ = ModelMetaclass + + field_a = Int(scope=Scope.settings) + field_b = Int(scope=Scope.content) + + def __init__(self, model_data): + self._model_data = model_data + + assert hasattr(ModelMetaclassTester, 'field_a') + assert hasattr(ModelMetaclassTester, 'field_b') + + assert_in(ModelMetaclassTester.field_a, ModelMetaclassTester.fields) + assert_in(ModelMetaclassTester.field_b, ModelMetaclassTester.fields) + + +def test_parent_metaclass(): + + class HasChildren(object): + __metaclass__ = ParentModelMetaclass + + has_children = True + + class WithoutChildren(object): + __metaclass = ParentModelMetaclass + + assert hasattr(HasChildren, 'children') + assert not hasattr(WithoutChildren, 'children') + + assert isinstance(HasChildren.children, List) + assert_equals(Scope.settings, HasChildren.children.scope) + + +def test_field_access(): + class FieldTester(object): + __metaclass__ = ModelMetaclass + + field_a = Int(scope=Scope.settings) + field_b = Int(scope=Scope.content, default=10) + field_c = Int(scope=Scope.student_state, computed_default=lambda s: s.field_a + s.field_b) + + def __init__(self, model_data): + self._model_data = model_data + + field_tester = FieldTester({'field_a': 5, 'field_x': 15}) + + assert_equals(5, field_tester.field_a) + assert_equals(10, field_tester.field_b) + assert_equals(15, field_tester.field_c) + assert not hasattr(field_tester, 'field_x') + + field_tester.field_a = 20 + assert_equals(20, field_tester._model_data['field_a']) + assert_equals(10, field_tester.field_b) + assert_equals(30, field_tester.field_c) + + del field_tester.field_a + assert_equals(None, field_tester.field_a) + assert hasattr(FieldTester, 'field_a') + + +class TestNamespace(Namespace): + field_x = List(scope=Scope.content) + field_y = String(scope=Scope.student_state, default="default_value") + + +@patch('xmodule.model.Namespace.load_classes', return_value=[('test', TestNamespace)]) +def test_namespace_metaclass(mock_load_classes): + class TestClass(object): + __metaclass__ = NamespacesMetaclass + + assert hasattr(TestClass, 'test') + assert hasattr(TestClass.test, 'field_x') + assert hasattr(TestClass.test, 'field_y') + + assert_in(TestNamespace.field_x, TestClass.test.fields) + assert_in(TestNamespace.field_y, TestClass.test.fields) + assert isinstance(TestClass.test, Namespace) + + +@patch('xmodule.model.Namespace.load_classes', return_value=[('test', TestNamespace)]) +def test_namespace_field_access(mock_load_classes): + class Metaclass(ModelMetaclass, NamespacesMetaclass): + pass + + class FieldTester(object): + __metaclass__ = Metaclass + + field_a = Int(scope=Scope.settings) + field_b = Int(scope=Scope.content, default=10) + field_c = Int(scope=Scope.student_state, computed_default=lambda s: s.field_a + s.field_b) + + def __init__(self, model_data): + self._model_data = model_data + + field_tester = FieldTester({ + 'field_a': 5, + 'field_x': [1, 2, 3], + }) + + assert_equals(5, field_tester.field_a) + assert_equals(10, field_tester.field_b) + assert_equals(15, field_tester.field_c) + assert_equals([1, 2, 3], field_tester.test.field_x) + assert_equals('default_value', field_tester.test.field_y) + + field_tester.test.field_x = ['a', 'b'] + assert_equals(['a', 'b'], field_tester._model_data['field_x']) + + del field_tester.test.field_x + assert_equals(None, field_tester.test.field_x) + + assert_raises(AttributeError, getattr, field_tester.test, 'field_z') + assert_raises(AttributeError, delattr, field_tester.test, 'field_z') + + # Namespaces are created on the fly, so setting a new attribute on one + # has no long-term effect + field_tester.test.field_z = 'foo' + assert_raises(AttributeError, getattr, field_tester.test, 'field_z') + assert 'field_z' not in field_tester._model_data + + +def test_field_serialization(): + + class CustomField(ModelType): + def from_json(self, value): + return value['value'] + + def to_json(self, value): + return {'value': value} + + class FieldTester(object): + __metaclass__ = ModelMetaclass + + field = CustomField() + + def __init__(self, model_data): + self._model_data = model_data + + field_tester = FieldTester({ + 'field': {'value': 4} + }) + + assert_equals(4, field_tester.field) + field_tester.field = 5 + assert_equals({'value': 5}, field_tester._model_data['field']) diff --git a/common/lib/xmodule/xmodule/tests/test_runtime.py b/common/lib/xmodule/xmodule/tests/test_runtime.py index e69de29bb2..1bbe3235b8 100644 --- a/common/lib/xmodule/xmodule/tests/test_runtime.py +++ b/common/lib/xmodule/xmodule/tests/test_runtime.py @@ -0,0 +1,67 @@ +from nose.tools import assert_equals +from mock import patch + +from xmodule.model import * +from xmodule.runtime import * + +class Metaclass(NamespacesMetaclass, ParentModelMetaclass, ModelMetaclass): + pass + + +class TestNamespace(Namespace): + n_content = String(scope=Scope.content, default='nc') + n_settings = String(scope=Scope.settings, default='ns') + n_student_state = String(scope=Scope.student_state, default='nss') + n_student_preferences = String(scope=Scope.student_preferences, default='nsp') + n_student_info = String(scope=Scope.student_info, default='nsi') + n_by_type = String(scope=Scope(False, ModuleScope.TYPE), default='nbt') + n_for_all = String(scope=Scope(False, ModuleScope.ALL), default='nfa') + n_student_def = String(scope=Scope(True, ModuleScope.DEFINITION), default='nsd') + + +with patch('xmodule.model.Namespace.load_classes', return_value=[('test', TestNamespace)]): + class TestModel(object): + __metaclass__ = Metaclass + + content = String(scope=Scope.content, default='c') + settings = String(scope=Scope.settings, default='s') + student_state = String(scope=Scope.student_state, default='ss') + student_preferences = String(scope=Scope.student_preferences, default='sp') + student_info = String(scope=Scope.student_info, default='si') + by_type = String(scope=Scope(False, ModuleScope.TYPE), default='bt') + for_all = String(scope=Scope(False, ModuleScope.ALL), default='fa') + student_def = String(scope=Scope(True, ModuleScope.DEFINITION), default='sd') + + def __init__(self, model_data): + self._model_data = model_data + + +class DictKeyValueStore(KeyValueStore): + def __init__(self): + self.db = {} + + def get(self, key): + return self.db[key] + + def set(self, key, value): + self.db[key] = value + + def delete(self, key): + del self.db[key] + + +Usage = namedtuple('Usage', 'id, def_id') + + +def test_empty(): + tester = TestModel(DbModel(DictKeyValueStore(), TestModel, 's0', Usage('u0', 'd0'))) + + for collection in (tester, tester.test): + for field in collection.fields: + print "Getting %s from %r" % (field.name, collection) + assert_equals(field.default, getattr(collection, field.name)) + new_value = 'new ' + field.name + print "Setting %s to %s on %r" % (field.name, new_value, collection) + setattr(collection, field.name, new_value) + print "Checking %s on %r" % (field.name, collection) + assert_equals(new_value, getattr(collection, field.name)) From b1c9b2ca0effda8ebe4e31b6a5247c7fe80eb8ab Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 13 Dec 2012 16:02:23 -0500 Subject: [PATCH 019/501] Add more tests of DbModel --- .../lib/xmodule/xmodule/tests/test_runtime.py | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_runtime.py b/common/lib/xmodule/xmodule/tests/test_runtime.py index 1bbe3235b8..e71c78e4af 100644 --- a/common/lib/xmodule/xmodule/tests/test_runtime.py +++ b/common/lib/xmodule/xmodule/tests/test_runtime.py @@ -4,6 +4,7 @@ from mock import patch from xmodule.model import * from xmodule.runtime import * + class Metaclass(NamespacesMetaclass, ParentModelMetaclass, ModelMetaclass): pass @@ -53,15 +54,52 @@ class DictKeyValueStore(KeyValueStore): Usage = namedtuple('Usage', 'id, def_id') -def test_empty(): +def check_field(collection, field): + print "Getting %s from %r" % (field.name, collection) + assert_equals(field.default, getattr(collection, field.name)) + new_value = 'new ' + field.name + print "Setting %s to %s on %r" % (field.name, new_value, collection) + setattr(collection, field.name, new_value) + print "Checking %s on %r" % (field.name, collection) + assert_equals(new_value, getattr(collection, field.name)) + print "Deleting %s from %r" % (field.name, collection) + delattr(collection, field.name) + print "Back to defaults for %s in %r" % (field.name, collection) + assert_equals(field.default, getattr(collection, field.name)) + + +def test_namespace_actions(): tester = TestModel(DbModel(DictKeyValueStore(), TestModel, 's0', Usage('u0', 'd0'))) for collection in (tester, tester.test): for field in collection.fields: - print "Getting %s from %r" % (field.name, collection) - assert_equals(field.default, getattr(collection, field.name)) + yield check_field, collection, field + + +def test_db_model_keys(): + key_store = DictKeyValueStore() + tester = TestModel(DbModel(key_store, TestModel, 's0', Usage('u0', 'd0'))) + + for collection in (tester, tester.test): + for field in collection.fields: new_value = 'new ' + field.name - print "Setting %s to %s on %r" % (field.name, new_value, collection) setattr(collection, field.name, new_value) - print "Checking %s on %r" % (field.name, collection) - assert_equals(new_value, getattr(collection, field.name)) + + print key_store.db + assert_equals('new content', key_store.db[KeyValueStore.Key(Scope.content, None, 'd0', 'content')]) + assert_equals('new settings', key_store.db[KeyValueStore.Key(Scope.settings, None, 'u0', 'settings')]) + assert_equals('new student_state', key_store.db[KeyValueStore.Key(Scope.student_state, 's0', 'u0', 'student_state')]) + assert_equals('new student_preferences', key_store.db[KeyValueStore.Key(Scope.student_preferences, 's0', 'TestModel', 'student_preferences')]) + assert_equals('new student_info', key_store.db[KeyValueStore.Key(Scope.student_info, 's0', None, 'student_info')]) + assert_equals('new by_type', key_store.db[KeyValueStore.Key(Scope(False, ModuleScope.TYPE), None, 'TestModel', 'by_type')]) + assert_equals('new for_all', key_store.db[KeyValueStore.Key(Scope(False, ModuleScope.ALL), None, None, 'for_all')]) + assert_equals('new student_def', key_store.db[KeyValueStore.Key(Scope(True, ModuleScope.DEFINITION), 's0', 'd0', 'student_def')]) + + assert_equals('new n_content', key_store.db[KeyValueStore.Key(Scope.content, None, 'd0', 'n_content')]) + assert_equals('new n_settings', key_store.db[KeyValueStore.Key(Scope.settings, None, 'u0', 'n_settings')]) + assert_equals('new n_student_state', key_store.db[KeyValueStore.Key(Scope.student_state, 's0', 'u0', 'n_student_state')]) + assert_equals('new n_student_preferences', key_store.db[KeyValueStore.Key(Scope.student_preferences, 's0', 'TestModel', 'n_student_preferences')]) + assert_equals('new n_student_info', key_store.db[KeyValueStore.Key(Scope.student_info, 's0', None, 'n_student_info')]) + assert_equals('new n_by_type', key_store.db[KeyValueStore.Key(Scope(False, ModuleScope.TYPE), None, 'TestModel', 'n_by_type')]) + assert_equals('new n_for_all', key_store.db[KeyValueStore.Key(Scope(False, ModuleScope.ALL), None, None, 'n_for_all')]) + assert_equals('new n_student_def', key_store.db[KeyValueStore.Key(Scope(True, ModuleScope.DEFINITION), 's0', 'd0', 'n_student_def')]) From 7977491ddf5ba8dadc3d45b8072e75b9a03d7238 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 14 Dec 2012 09:04:34 -0500 Subject: [PATCH 020/501] WIP: LMS Courseware appears to be working --- common/lib/xmodule/xmodule/course_module.py | 5 ++ .../lib/xmodule/xmodule/discussion_module.py | 6 +++ lms/djangoapps/django_comment_client/utils.py | 8 ++- lms/templates/staff_problem_info.html | 51 +++++++++---------- 4 files changed, 39 insertions(+), 31 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index b8d4032a18..596344d934 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -36,6 +36,11 @@ class CourseDescriptor(SequenceDescriptor): tabs = List(help="List of tabs to enable in this course", scope=Scope.settings) end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings) discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings, default=[]) + discussion_topics = Object( + help="Map of topics names to ids", + scope=Scope.settings, + computed_default=lambda c: {'General': {'id': c.location.html_id()}}, + ) has_children = True info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') diff --git a/common/lib/xmodule/xmodule/discussion_module.py b/common/lib/xmodule/xmodule/discussion_module.py index 19a2b4300f..b373f337fb 100644 --- a/common/lib/xmodule/xmodule/discussion_module.py +++ b/common/lib/xmodule/xmodule/discussion_module.py @@ -15,6 +15,7 @@ class DiscussionModule(XModule): discussion_id = String(scope=Scope.settings) discussion_category = String(scope=Scope.settings) discussion_target = String(scope=Scope.settings) + sort_key = String(scope=Scope.settings) data = String(help="XML definition of inline discussion", scope=Scope.content) @@ -42,3 +43,8 @@ class DiscussionDescriptor(RawDescriptor): metadata_translations = dict(RawDescriptor.metadata_translations) metadata_translations['id'] = 'discussion_id' metadata_translations['for'] = 'discussion_target' + + discussion_id = String(scope=Scope.settings) + discussion_category = String(scope=Scope.settings) + discussion_target = String(scope=Scope.settings) + sort_key = String(scope=Scope.settings) diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 174805a999..bb91dfaa28 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -145,12 +145,12 @@ def initialize_discussion_info(course): id = module.discussion_id category = module.discussion_category title = module.discussion_target - sort_key = module.metadata.get('sort_key', title) + sort_key = module.sort_key category = " / ".join([x.strip() for x in category.split("/")]) last_category = category.split("/")[-1] discussion_id_map[id] = {"location": location, "title": last_category + " / " + title} unexpanded_category_map[category].append({"title": title, "id": id, - "sort_key": sort_key, "start_date": module.start}) + "sort_key": sort_key, "start_date": module.lms.start}) category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)} for category_path, entries in unexpanded_category_map.items(): @@ -189,9 +189,7 @@ def initialize_discussion_info(course): "sort_key": entry["sort_key"], "start_date": entry["start_date"]} - default_topics = {'General': {'id' :course.location.html_id()}} - discussion_topics = course.metadata.get('discussion_topics', default_topics) - for topic, entry in discussion_topics.items(): + for topic, entry in course.discussion_topics.items(): category_map['entries'][topic] = {"id": entry["id"], "sort_key": entry.get("sort_key", topic), "start_date": time.gmtime()} diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index e0c957a05c..4fee7c22fb 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -46,22 +46,18 @@ github = ${edit_link | h} %if source_file: source_url = ${source_file | h} %endif -
-

Module Fields

+ + %for name, field in fields: -
- ${name} =
${field | h}
-
+ %endfor - -
-

edX Fields

+
Module Fields
${name}
${field | h}
+ + %for name, field in lms_fields: -
- ${name} =
${field | h}
-
+ %endfor - +
edX Fields
${name}
${field | h}
category = ${category | h}
%if render_histogram: @@ -75,18 +71,21 @@ category = ${category | h} From 8991cdd3b5560a13680706d6d510f53d97819e74 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 17 Dec 2012 13:12:39 -0500 Subject: [PATCH 021/501] Stop raising BaseExceptions --- cms/djangoapps/contentstore/utils.py | 4 ++-- common/djangoapps/static_replace.py | 2 +- common/lib/xmodule/xmodule/modulestore/mongo.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index da2993e463..d6dcc3811d 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -37,10 +37,10 @@ def get_course_location_for_item(location): # make sure we found exactly one match on this above course search found_cnt = len(courses) if found_cnt == 0: - raise BaseException('Could not find course at {0}'.format(course_search_location)) + raise Exception('Could not find course at {0}'.format(course_search_location)) if found_cnt > 1: - raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses)) + raise Exception('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses)) location = courses[0].location diff --git a/common/djangoapps/static_replace.py b/common/djangoapps/static_replace.py index e75362d784..3bc8181126 100644 --- a/common/djangoapps/static_replace.py +++ b/common/djangoapps/static_replace.py @@ -49,7 +49,7 @@ def replace(static_url, prefix=None, course_namespace=None): # use the utility functions in StaticContent.py if static_url.group('prefix') == '/static/' and not isinstance(modulestore(), XMLModuleStore): if course_namespace is None: - raise BaseException('You must pass in course_namespace when remapping static content urls with MongoDB stores') + raise Exception('You must pass in course_namespace when remapping static content urls with MongoDB stores') url = StaticContent.convert_legacy_static_url(static_url.group('rest'), course_namespace) else: url = try_staticfiles_lookup(prefix + static_url.group('rest')) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index c0ba73423c..c40bde1072 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -314,10 +314,10 @@ class MongoModuleStore(ModuleStoreBase): # make sure we found exactly one match on this above course search found_cnt = len(courses) if found_cnt == 0: - raise BaseException('Could not find course at {0}'.format(course_search_location)) + raise Exception('Could not find course at {0}'.format(course_search_location)) if found_cnt > 1: - raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses)) + raise Exception('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses)) return courses[0] From d11a9b1d2104551baf115f19ce7b84da89319fd9 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 17 Dec 2012 13:14:48 -0500 Subject: [PATCH 022/501] Convert ABTest module to new property based format. Doesn't account for definition_to_xml needing redefinition --- common/lib/xmodule/xmodule/abtest_module.py | 65 +++++++++++---------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py index 0f655ded6c..4ee74eb29c 100644 --- a/common/lib/xmodule/xmodule/abtest_module.py +++ b/common/lib/xmodule/xmodule/abtest_module.py @@ -1,4 +1,3 @@ -import json import random import logging from lxml import etree @@ -7,7 +6,7 @@ from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor from xmodule.xml_module import XmlDescriptor from xmodule.exceptions import InvalidDefinitionError -from .model import String, Scope +from .model import String, Scope, Object, ModuleScope DEFAULT = "_DEFAULT_GROUP" @@ -37,25 +36,34 @@ class ABTestModule(XModule): Implements an A/B test with an aribtrary number of competing groups """ - def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) + group_portions = Object(help="What proportions of students should go in each group", default={DEFAULT: 1}, scope=Scope.content) + group_assignments = Object(help="What group this user belongs to", scope=Scope(student=True, module=ModuleScope.TYPE), default={}) + group_content = Object(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []}) - if shared_state is None: + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) + if self.group is None: self.group = group_from_value( - self.definition['data']['group_portions'].items(), + self.group_portions, random.uniform(0, 1) ) - else: - shared_state = json.loads(shared_state) - self.group = shared_state['group'] - def get_shared_state(self): - return json.dumps({'group': self.group}) - + @property + def group(self): + return self.group_assignments.get(self.experiment) + + @group.setter + def group(self, value): + self.group_assigments[self.experiment] = value + + @group.deleter + def group(self): + del self.group_assignments[self.experiment] + def get_children_locations(self): - return self.definition['data']['group_content'][self.group] - + return self.group_content[self.group] + def displayable_items(self): # Most modules return "self" as the displayable_item. We never display ourself # (which is why we don't implement get_html). We only display our children. @@ -70,6 +78,9 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): template_dir_name = "abtest" experiment = String(help="Experiment that this A/B test belongs to", scope=Scope.content) + group_portions = Object(help="What proportions of students should go in each group", default={}) + group_assignments = Object(help="What group this user belongs to", scope=Scope(student=True, module=ModuleScope.TYPE), default={}) + group_content = Object(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []}) @classmethod def definition_from_xml(cls, xml_object, system): @@ -88,19 +99,12 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): "ABTests must specify an experiment. Not found in:\n{xml}" .format(xml=etree.tostring(xml_object, pretty_print=True))) - definition = { - 'data': { - 'experiment': experiment, - 'group_portions': {}, - 'group_content': {DEFAULT: []}, - }, - 'children': []} for group in xml_object: if group.tag == 'default': name = DEFAULT else: name = group.get('name') - definition['data']['group_portions'][name] = float(group.get('portion', 0)) + self.group_portions[name] = float(group.get('portion', 0)) child_content_urls = [] for child in group: @@ -110,8 +114,8 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): log.exception("Unable to load child when parsing ABTest. Continuing...") continue - definition['data']['group_content'][name] = child_content_urls - definition['children'].extend(child_content_urls) + self.group_content[name] = child_content_urls + self.children.extend(child_content_urls) default_portion = 1 - sum( portion for (name, portion) in definition['data']['group_portions'].items()) @@ -119,20 +123,20 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): if default_portion < 0: raise InvalidDefinitionError("ABTest portions must add up to less than or equal to 1") - definition['data']['group_portions'][DEFAULT] = default_portion - definition['children'].sort() + self.group_portions[DEFAULT] = default_portion + self.children.sort() return definition def definition_to_xml(self, resource_fs): xml_object = etree.Element('abtest') - xml_object.set('experiment', self.definition['data']['experiment']) - for name, group in self.definition['data']['group_content'].items(): + xml_object.set('experiment', self.experiment) + for name, group in self.group_content.items(): if name == DEFAULT: group_elem = etree.SubElement(xml_object, 'default') else: group_elem = etree.SubElement(xml_object, 'group', attrib={ - 'portion': str(self.definition['data']['group_portions'][name]), + 'portion': str(self.group_portions[name]), 'name': name, }) @@ -141,7 +145,6 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): group_elem.append(etree.fromstring(child.export_to_xml(resource_fs))) return xml_object - - + def has_dynamic_children(self): return True From 040abfcc6820b44e38393638a36d6d14f9cb1a4d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 17 Dec 2012 13:18:27 -0500 Subject: [PATCH 023/501] Don't call update_metadata anymore when updating course tabs, because the updates are implicit --- common/lib/xmodule/xmodule/modulestore/mongo.py | 1 - 1 file changed, 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index c40bde1072..d145523d16 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -380,7 +380,6 @@ class MongoModuleStore(ModuleStoreBase): tab['name'] = metadata.get('display_name') break course.tabs = existing_tabs - self.update_metadata(course.location, course.metadata) self._update_single_item(location, {'metadata': metadata}) From 8c42e0f52ed3b8c051c79504bac61c085a6ad100 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 17 Dec 2012 13:18:53 -0500 Subject: [PATCH 024/501] Import course objects first, so that later updates that try and edit the course object don't fail --- .../xmodule/modulestore/xml_importer.py | 171 ++++++++++-------- 1 file changed, 91 insertions(+), 80 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index f90291aaa2..1132fe77bb 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -89,6 +89,91 @@ def verify_content_links(module, base_dir, static_content_store, link, remap_dic return link + +def import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None): + # remap module to the new namespace + if target_location_namespace is not None: + # This looks a bit wonky as we need to also change the 'name' of the imported course to be what + # the caller passed in + if module.location.category != 'course': + module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course) + else: + module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course, name=target_location_namespace.name) + + # then remap children pointers since they too will be re-namespaced + children_locs = module.definition.get('children') + if children_locs is not None: + new_locs = [] + for child in children_locs: + child_loc = Location(child) + new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course) + + new_locs.append(new_child_loc.url()) + + module.definition['children'] = new_locs + + if hasattr(module, 'data'): + # cdodge: now go through any link references to '/static/' and make sure we've imported + # it as a StaticContent asset + try: + remap_dict = {} + + # use the rewrite_links as a utility means to enumerate through all links + # in the module data. We use that to load that reference into our asset store + # IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to + # do the rewrites natively in that code. + # For example, what I'm seeing is -> + # Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's + # no good, so we have to do this kludge + if isinstance(module.data, str) or isinstance(module.data, unicode): # some module 'data' fields are non strings which blows up the link traversal code + lxml_rewrite_links(module.data, lambda link: verify_content_links(module, course_data_path, + static_content_store, link, remap_dict)) + + for key in remap_dict.keys(): + module.data = module.data.replace(key, remap_dict[key]) + + except Exception: + logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location)) + + modulestore.update_item(module.location, module.data) + + if module.has_children: + modulestore.update_children(module.location, module.children) + + metadata = {} + for field in module.fields + module.lms.fields: + # Only save metadata that wasn't inherited + if (field.scope == Scope.settings and + field.name in module._inherited_metadata and + field.name in module._model_data): + + metadata[field.name] = module._model_data[field.name] + modulestore.update_metadata(module.location, metadata) + + +def import_course_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None): + # HACK: for now we don't support progress tabs. There's a special metadata configuration setting for this. + module.hide_progress_tab = True + + # cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which + # does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS, + # but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that - + # if there is *any* tabs - then there at least needs to be some predefined ones + if module.tabs is None or len(module.tabs) == 0: + module.tabs = [{"type": "courseware"}, + {"type": "course_info", "name": "Course Info"}, + {"type": "discussion", "name": "Discussion"}, + {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge + + # a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg + # so let's make sure we import in case there are no other references to it in the modules + verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg') + import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace) + + def import_from_xml(store, data_dir, course_dirs=None, default_class='xmodule.raw_module.RawDescriptor', load_error_modules=True, static_content_store=None, target_location_namespace=None): @@ -134,89 +219,15 @@ def import_from_xml(store, data_dir, course_dirs=None, import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store, _namespace_rename, subpath='static') + # Import course modules first, because importing some of the children requires the course to exist for module in module_store.modules[course_id].itervalues(): - - # remap module to the new namespace - if target_location_namespace is not None: - # This looks a bit wonky as we need to also change the 'name' of the imported course to be what - # the caller passed in - if module.location.category != 'course': - module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, - course=target_location_namespace.course) - else: - module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, - course=target_location_namespace.course, name=target_location_namespace.name) - - # then remap children pointers since they too will be re-namespaced - children_locs = module.definition.get('children') - if children_locs is not None: - new_locs = [] - for child in children_locs: - child_loc = Location(child) - new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, - course=target_location_namespace.course) - - new_locs.append(new_child_loc.url()) - - module.definition['children'] = new_locs - if module.category == 'course': - # HACK: for now we don't support progress tabs. There's a special metadata configuration setting for this. - module.hide_progress_tab = True + import_course_from_xml(store, static_content_store, course_data_path, module, target_location_namespace) - # cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which - # does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS, - # but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that - - # if there is *any* tabs - then there at least needs to be some predefined ones - if module.tabs is None or len(module.tabs) == 0: - module.tabs = [{"type": "courseware"}, - {"type": "course_info", "name": "Course Info"}, - {"type": "discussion", "name": "Discussion"}, - {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge - - # a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg - # so let's make sure we import in case there are no other references to it in the modules - verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg') - - course_items.append(module) - - if hasattr(module, 'data'): - # cdodge: now go through any link references to '/static/' and make sure we've imported - # it as a StaticContent asset - try: - remap_dict = {} - - # use the rewrite_links as a utility means to enumerate through all links - # in the module data. We use that to load that reference into our asset store - # IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to - # do the rewrites natively in that code. - # For example, what I'm seeing is -> - # Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's - # no good, so we have to do this kludge - if isinstance(module.data, str) or isinstance(module.data, unicode): # some module 'data' fields are non strings which blows up the link traversal code - lxml_rewrite_links(module.data, lambda link: verify_content_links(module, course_data_path, - static_content_store, link, remap_dict)) - - for key in remap_dict.keys(): - module.data = module.data.replace(key, remap_dict[key]) - - except Exception: - logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location)) - - store.update_item(module.location, module.data) - - if module.has_children: - store.update_children(module.location, module.children) - - metadata = {} - for field in module.fields + module.lms.fields: - # Only save metadata that wasn't inherited - if (field.scope == Scope.settings and - field.name in module._inherited_metadata and - field.name in module._model_data): - - metadata[field.name] = module._model_data[field.name] - store.update_metadata(module.location, metadata) + # Import the rest of the modules + for module in module_store.modules[course_id].itervalues(): + if module.category != 'course': + import_module_from_xml(store, static_content_store, course_data_path, module, target_location_namespace) return module_store, course_items From 5cb31c0e325054c294999c2fc920f7d57a97b8a9 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 17 Dec 2012 13:19:18 -0500 Subject: [PATCH 025/501] Make load_from_json on descriptors pass the right sort of arguments --- common/lib/xmodule/xmodule/x_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index f03fbe6183..a38b8e5549 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -456,7 +456,7 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): system: A DescriptorSystem for interacting with external resources """ - return cls(system=system, **json_data) + return cls(system=system, location=json_data['location'], model_data=json_data) # ================================= XML PARSING ============================ @staticmethod From 3adb1e71090adcdf1b64872198d4d2c4dd903602 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 17 Dec 2012 13:19:38 -0500 Subject: [PATCH 026/501] Make grading not require get_instance_module --- lms/djangoapps/courseware/grades.py | 39 ++++++++++++++++------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 032378f863..b81147f905 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -9,7 +9,7 @@ from django.conf import settings from django.contrib.auth.models import User from models import StudentModuleCache -from module_render import get_module, get_instance_module +from module_render import get_module from xmodule import graders from xmodule.capa_module import CapaModule from xmodule.course_module import CourseDescriptor @@ -338,6 +338,9 @@ def get_score(course_id, user, problem_descriptor, module_creator, student_modul Can return None if user doesn't have access, or if something else went wrong. cache: A StudentModuleCache """ + if not user.is_authenticated(): + return (None, None) + if not (problem_descriptor.stores_state and problem_descriptor.has_score): # These are not problems, and do not have a score return (None, None) @@ -347,29 +350,29 @@ def get_score(course_id, user, problem_descriptor, module_creator, student_modul instance_module = student_module_cache.lookup( course_id, problem_descriptor.category, problem_descriptor.location.url()) - if not instance_module: + if instance_module: + if instance_module.max_grade is None: + return (None, None) + + correct = instance_module.grade if instance_module.grade is not None else 0 + total = instance_module.max_grade + else: # If the problem was not in the cache, we need to instantiate the problem. # Otherwise, the max score (cached in instance_module) won't be available problem = module_creator(problem_descriptor) if problem is None: return (None, None) - instance_module = get_instance_module(course_id, user, problem, student_module_cache) - # If this problem is ungraded/ungradable, bail - if not instance_module or instance_module.max_grade is None: - return (None, None) + correct = 0 + total = problem.max_score() - correct = instance_module.grade if instance_module.grade is not None else 0 - total = instance_module.max_grade - - if correct is not None and total is not None: - #Now we re-weight the problem, if specified - weight = getattr(problem_descriptor, 'weight', None) - if weight is not None: - if total == 0: - log.exception("Cannot reweight a problem with zero total points. Problem: " + str(instance_module)) - return (correct, total) - correct = correct * weight / total - total = weight + #Now we re-weight the problem, if specified + weight = getattr(problem_descriptor, 'weight', None) + if weight is not None: + if total == 0: + log.exception("Cannot reweight a problem with zero total points. Problem: " + str(instance_module)) + return (correct, total) + correct = correct * weight / total + total = weight return (correct, total) From 306dbcff9c708f89572148f0b1d661d2193da68c Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 17 Dec 2012 13:19:53 -0500 Subject: [PATCH 027/501] Rationalize StudentModule unicode and repr strings --- lms/djangoapps/courseware/models.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 2b7b12ac45..9c318427ec 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -60,13 +60,17 @@ class StudentModule(models.Model): created = models.DateTimeField(auto_now_add=True, db_index=True) modified = models.DateTimeField(auto_now=True, db_index=True) - def __unicode__(self): - return '/'.join([self.course_id, self.module_type, - self.student.username, self.module_state_key, str(self.state)[:20]]) - def __repr__(self): - return 'StudentModule%r' % ((self.course_id, self.module_type, self.student, self.module_state_key, str(self.state)[:20]),) + return 'StudentModule<%r>' % ({ + 'course_id': self.course_id, + 'module_type': self.module_type, + 'student': self.student.username, + 'module_state_key': self.module_state_key, + 'state': str(self.state)[:20], + },) + def __unicode__(self): + return unicode(repr(self)) # TODO (cpennington): Remove these once the LMS switches to using XModuleDescriptors From 13cab34a7d8c384bc92fda9101a90d36ed260f2e Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 17 Dec 2012 13:20:33 -0500 Subject: [PATCH 028/501] Always use the url form of the location when making StudentModules --- lms/djangoapps/courseware/module_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index be207730b3..c9e46c7ce0 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -186,7 +186,7 @@ class LmsKeyValueStore(KeyValueStore): course_id=self._course_id, student=self._user, module_type=key.module_scope_id.category, - module_state_key=key.module_scope_id, + module_state_key=key.module_scope_id.url(), state=json.dumps({}) ) self._student_module_cache.append(student_module) From 64848a32ee53f23adb27237e9aff7663df6e35e7 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 17 Dec 2012 13:20:55 -0500 Subject: [PATCH 029/501] Delete get_instance_module and get_shared_instance_module, as they are obsolete with the new properties --- lms/djangoapps/courseware/module_render.py | 59 ---------------------- lms/djangoapps/courseware/views.py | 2 +- 2 files changed, 1 insertion(+), 60 deletions(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index c9e46c7ce0..ec9c6b54eb 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -341,65 +341,6 @@ def _get_module(user, request, location, student_module_cache, course_id, positi return module -# TODO (vshnayder): Rename this? It's very confusing. -def get_instance_module(course_id, user, module, student_module_cache): - """ - Returns the StudentModule specific to this module for this student, - or None if this is an anonymous user - """ - if user.is_authenticated(): - if not module.descriptor.stores_state: - log.exception("Attempted to get the instance_module for a module " - + str(module.id) + " which does not store state.") - return None - - instance_module = student_module_cache.lookup( - course_id, module.category, module.location.url()) - - if not instance_module: - instance_module = StudentModule( - course_id=course_id, - student=user, - module_type=module.category, - module_state_key=module.id, - state=module.get_instance_state(), - max_grade=module.max_score()) - instance_module.save() - student_module_cache.append(instance_module) - - return instance_module - else: - return None - -def get_shared_instance_module(course_id, user, module, student_module_cache): - """ - Return shared_module is a StudentModule specific to all modules with the same - 'shared_state_key' attribute, or None if the module does not elect to - share state - """ - if user.is_authenticated(): - # To get the shared_state_key, we need to descriptor - descriptor = modulestore().get_instance(course_id, module.location) - - shared_state_key = getattr(module, 'shared_state_key', None) - if shared_state_key is not None: - shared_module = student_module_cache.lookup(module.category, - shared_state_key) - if not shared_module: - shared_module = StudentModule( - course_id=course_id, - student=user, - module_type=descriptor.category, - module_state_key=shared_state_key, - state=module.get_shared_state()) - shared_module.save() - student_module_cache.append(shared_module) - else: - shared_module = None - - return shared_module - else: - return None @csrf_exempt def xqueue_callback(request, course_id, userid, id, dispatch): diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index fffbd2fc18..0c6e2245b9 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -24,7 +24,7 @@ from courseware.access import has_access from courseware.courses import (get_course_with_access, get_courses_by_university) import courseware.tabs as tabs from courseware.models import StudentModuleCache -from module_render import toc_for_course, get_module, get_instance_module +from module_render import toc_for_course, get_module from student.models import UserProfile from multicourse import multicourse_settings From fbd9499c5130d0bdf512cb812ec1c5be01f2b0ee Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 17 Dec 2012 13:21:24 -0500 Subject: [PATCH 030/501] Make debug message use the available request.user object --- lms/djangoapps/courseware/module_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index ec9c6b54eb..0aac05568c 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -452,7 +452,7 @@ def modx_dispatch(request, dispatch, location, course_id): if instance is None: # Either permissions just changed, or someone is trying to be clever # and load something they shouldn't have access to. - log.debug("No module {0} for user {1}--access denied?".format(location, user)) + log.debug("No module {0} for user {1}--access denied?".format(location, request.user)) raise Http404 # Let the module handle the AJAX From 2879853eee15fe8e600034b57af3f1b9d77a3ff9 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 17 Dec 2012 13:23:21 -0500 Subject: [PATCH 031/501] Pep8 fixes --- lms/djangoapps/courseware/tests/tests.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 7a6d498660..6b2988d1db 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -742,11 +742,11 @@ class TestCourseGrader(PageLoader): """ problem_location = "i4x://edX/graded/problem/{0}".format(problem_url_name) - modx_url = reverse('modx_dispatch', + modx_url = reverse('modx_dispatch', kwargs={ - 'course_id' : self.graded_course.id, - 'location' : problem_location, - 'dispatch' : 'problem_check', } + 'course_id': self.graded_course.id, + 'location': problem_location, + 'dispatch': 'problem_check', } ) resp = self.client.post(modx_url, { From 8693d288c86d4813d731ec258cbbbb7d6cd7864c Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 18 Dec 2012 09:32:36 -0500 Subject: [PATCH 032/501] Fix errors from running unit tests (some tests still fail) --- common/lib/xmodule/xmodule/abtest_module.py | 23 +++++--- common/lib/xmodule/xmodule/course_module.py | 4 +- common/lib/xmodule/xmodule/html_module.py | 4 +- common/lib/xmodule/xmodule/raw_module.py | 2 +- .../xmodule/xmodule/self_assessment_module.py | 2 +- common/lib/xmodule/xmodule/seq_module.py | 2 +- common/lib/xmodule/xmodule/x_module.py | 54 ++++++++++++++++--- common/lib/xmodule/xmodule/xml_module.py | 9 ++-- lms/djangoapps/courseware/access.py | 4 +- lms/djangoapps/courseware/grades.py | 2 + lms/djangoapps/courseware/module_render.py | 2 + 11 files changed, 78 insertions(+), 30 deletions(-) diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py index 4ee74eb29c..9c639e8f30 100644 --- a/common/lib/xmodule/xmodule/abtest_module.py +++ b/common/lib/xmodule/xmodule/abtest_module.py @@ -79,7 +79,6 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): experiment = String(help="Experiment that this A/B test belongs to", scope=Scope.content) group_portions = Object(help="What proportions of students should go in each group", default={}) - group_assignments = Object(help="What group this user belongs to", scope=Scope(student=True, module=ModuleScope.TYPE), default={}) group_content = Object(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []}) @classmethod @@ -99,12 +98,16 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): "ABTests must specify an experiment. Not found in:\n{xml}" .format(xml=etree.tostring(xml_object, pretty_print=True))) + group_portions = {} + group_content = {} + children = [] + for group in xml_object: if group.tag == 'default': name = DEFAULT else: name = group.get('name') - self.group_portions[name] = float(group.get('portion', 0)) + group_portions[name] = float(group.get('portion', 0)) child_content_urls = [] for child in group: @@ -114,19 +117,23 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): log.exception("Unable to load child when parsing ABTest. Continuing...") continue - self.group_content[name] = child_content_urls - self.children.extend(child_content_urls) + group_content[name] = child_content_urls + children.extend(child_content_urls) default_portion = 1 - sum( - portion for (name, portion) in definition['data']['group_portions'].items()) + portion for (name, portion) in group_portions.items() + ) if default_portion < 0: raise InvalidDefinitionError("ABTest portions must add up to less than or equal to 1") - self.group_portions[DEFAULT] = default_portion - self.children.sort() + group_portions[DEFAULT] = default_portion + children.sort() - return definition + return { + 'group_portions': group_portions, + 'group_content': group_content, + }, children def definition_to_xml(self, resource_fs): xml_object = etree.Element('abtest') diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 596344d934..510857303a 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -270,12 +270,12 @@ class CourseDescriptor(SequenceDescriptor): wiki_slug = wiki_tag.attrib.get("slug", default=None) xml_object.remove(wiki_tag) - definition = super(CourseDescriptor, cls).definition_from_xml(xml_object, system) + definition, children = super(CourseDescriptor, cls).definition_from_xml(xml_object, system) definition.setdefault('data', {})['textbooks'] = textbooks definition['data']['wiki_slug'] = wiki_slug - return definition + return definition, children def has_ended(self): """ diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 6d86fb90a8..562eeeb361 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -87,7 +87,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): if filename is None: definition_xml = copy.deepcopy(xml_object) cls.clean_metadata_from_xml(definition_xml) - return {'data': stringify_children(definition_xml)} + return {'data': stringify_children(definition_xml)}, [] else: # html is special. cls.filename_extension is 'xml', but # if 'filename' is in the definition, that means to load @@ -131,7 +131,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): # for Fall 2012 LMS migration: keep filename (and unmangled filename) definition['filename'] = [ filepath, filename ] - return definition + return definition, [] except (ResourceNotFoundError) as err: msg = 'Unable to load file contents at path {0}: {1} '.format( diff --git a/common/lib/xmodule/xmodule/raw_module.py b/common/lib/xmodule/xmodule/raw_module.py index 5ff16098ac..bdbc049712 100644 --- a/common/lib/xmodule/xmodule/raw_module.py +++ b/common/lib/xmodule/xmodule/raw_module.py @@ -13,7 +13,7 @@ class RawDescriptor(XmlDescriptor, XMLEditingDescriptor): """ @classmethod def definition_from_xml(cls, xml_object, system): - return {'data': etree.tostring(xml_object, pretty_print=True)} + return {'data': etree.tostring(xml_object, pretty_print=True)}, [] def definition_to_xml(self, resource_fs): try: diff --git a/common/lib/xmodule/xmodule/self_assessment_module.py b/common/lib/xmodule/xmodule/self_assessment_module.py index 2f4674cf7c..ca6eae9913 100644 --- a/common/lib/xmodule/xmodule/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/self_assessment_module.py @@ -428,7 +428,7 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor): 'prompt': parse('prompt'), 'submitmessage': parse('submitmessage'), 'hintprompt': parse('hintprompt'), - } + }, [] def definition_to_xml(self, resource_fs): diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 6e80d1cf61..a136473653 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -136,7 +136,7 @@ class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor): if system.error_tracker is not None: system.error_tracker("ERROR: " + str(e)) continue - return {'children': children} + return {}, children def definition_to_xml(self, resource_fs): xml_object = etree.Element('sequential') diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index a38b8e5549..6c499aa611 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -165,11 +165,8 @@ class XModule(HTMLSnippet): ''' Return module instances for all the children of this module. ''' - if not self.has_children: - return [] - if self._loaded_children is None: - children = [self.system.get_module(loc) for loc in self.children] + children = [self.system.get_module(loc) for loc in self.get_children_locations()] # get_module returns None if the current user doesn't have access # to the location. self._loaded_children = [c for c in children if c is not None] @@ -179,6 +176,24 @@ class XModule(HTMLSnippet): def __unicode__(self): return ''.format(self.id) + def get_children_locations(self): + ''' + Returns the locations of each of child modules. + + Overriding this changes the behavior of get_children and + anything that uses get_children, such as get_display_items. + + This method will not instantiate the modules of the children + unless absolutely necessary, so it is cheaper to call than get_children + + These children will be the same children returned by the + descriptor unless descriptor.has_dynamic_children() is true. + ''' + if not self.has_children: + return [] + + return self.children + def get_display_items(self): ''' Returns a list of descendent module instances that will display @@ -437,7 +452,7 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): on the contents of json_data. json_data must contain a 'location' element, and must be suitable to be - passed into the subclasses `from_json` method. + passed into the subclasses `from_json` method as model_data """ class_ = XModuleDescriptor.load_class( json_data['location']['category'], @@ -451,12 +466,35 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): Creates an instance of this descriptor from the supplied json_data. This may be overridden by subclasses - json_data: A json object specifying the definition and any optional - keyword arguments for the XModuleDescriptor + json_data: A json object with the keys 'definition' and 'metadata', + definition: A json object with the keys 'data' and 'children' + data: A json value + children: A list of edX Location urls + metadata: A json object with any keys + + This json_data is transformed to model_data using the following rules: + 1) The model data contains all of the fields from metadata + 2) The model data contains the 'children' array + 3) If 'definition.data' is a json object, model data contains all of its fields + Otherwise, it contains the single field 'data' + 4) Any value later in this list overrides a value earlier in this list system: A DescriptorSystem for interacting with external resources """ - return cls(system=system, location=json_data['location'], model_data=json_data) + model_data = {} + model_data.update(json_data.get('metadata', {})) + + definition = json_data.get('definition', {}) + if 'children' in definition: + model_data['children'] = definition['children'] + + if 'data' in definition: + if isinstance(definition['data'], dict): + model_data.update(definition['data']) + else: + model_data['data'] = definition['data'] + + return cls(system=system, location=json_data['location'], model_data=model_data) # ================================= XML PARSING ============================ @staticmethod diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 50c4f7aa6d..754d9b523e 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -220,7 +220,7 @@ class XmlDescriptor(XModuleDescriptor): definition_metadata = get_metadata_from_xml(definition_xml) cls.clean_metadata_from_xml(definition_xml) - definition = cls.definition_from_xml(definition_xml, system) + definition, children = cls.definition_from_xml(definition_xml, system) if definition_metadata: definition['definition_metadata'] = definition_metadata @@ -228,7 +228,7 @@ class XmlDescriptor(XModuleDescriptor): # for Fall 2012 LMS migration: keep filename (and unmangled filename) definition['filename'] = [ filepath, filename ] - return definition + return definition, children @classmethod def load_metadata(cls, xml_object): @@ -289,7 +289,7 @@ class XmlDescriptor(XModuleDescriptor): else: definition_xml = xml_object # this is just a pointer, not the real definition content - definition = cls.load_definition(definition_xml, system, location) # note this removes metadata + definition, children = cls.load_definition(definition_xml, system, location) # note this removes metadata # VS[compat] -- make Ike's github preview links work in both old and # new file layouts if is_pointer_tag(xml_object): @@ -311,13 +311,12 @@ class XmlDescriptor(XModuleDescriptor): # Set/override any metadata specified by policy k = policy_key(location) if k in system.policy: - if k == 'video/labintro': print k, metadata, system.policy[k] cls.apply_policy(metadata, system.policy[k]) model_data = {} model_data.update(metadata) model_data.update(definition) - if k == 'video/labintro': print model_data + model_data['children'] = children return cls( system, diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index c6342e9d13..4318bf81bf 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -226,9 +226,9 @@ def _has_access_descriptor(user, descriptor, action, course_context=None): return True # Check start date - if descriptor.start is not None: + if descriptor.lms.start is not None: now = time.gmtime() - if now > descriptor.start: + if now > descriptor.lms.start: # after start date, everyone can see it debug("Allow: now > start date") return True diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index b81147f905..1d894d3707 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -36,6 +36,8 @@ def yield_dynamic_descriptor_descendents(descriptor, module_creator): def get_dynamic_descriptor_children(descriptor): if descriptor.has_dynamic_children(): module = module_creator(descriptor) + if module is None: + print "FOO", descriptor child_locations = module.get_children_locations() return [descriptor.system.load_item(child_location) for child_location in child_locations ] else: diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 0aac05568c..3fd1abec55 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -313,9 +313,11 @@ def _get_module(user, request, location, student_module_cache, course_id, positi import_system = descriptor.system if has_access(user, location, 'staff', course_id): err_descriptor = ErrorDescriptor.from_xml(str(descriptor), import_system, + org=descriptor.location.org, course=descriptor.location.course, error_msg=exc_info_to_str(sys.exc_info())) else: err_descriptor = NonStaffErrorDescriptor.from_xml(str(descriptor), import_system, + org=descriptor.location.org, course=descriptor.location.course, error_msg=exc_info_to_str(sys.exc_info())) # Make an error module From d61c91c139e58d45723862d15c5d5459bee46b11 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 18 Dec 2012 09:54:25 -0500 Subject: [PATCH 033/501] Fix errors around error descriptors and custom tag modules --- common/lib/xmodule/xmodule/error_module.py | 8 ++++++-- common/lib/xmodule/xmodule/raw_module.py | 8 ++++++-- common/lib/xmodule/xmodule/template_module.py | 9 ++------- lms/djangoapps/courseware/views.py | 11 +++-------- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py index 67b52d383c..37e98b5b77 100644 --- a/common/lib/xmodule/xmodule/error_module.py +++ b/common/lib/xmodule/xmodule/error_module.py @@ -22,6 +22,10 @@ log = logging.getLogger(__name__) class ErrorModule(XModule): + + contents = String(scope=Scope.content) + error_msg = String(scope=Scope.content) + def get_html(self): '''Show an error to staff. TODO (vshnayder): proper style, divs, etc. @@ -29,8 +33,8 @@ class ErrorModule(XModule): # staff get to see all the details return self.system.render_template('module-error.html', { 'staff_access': True, - 'data': self.definition['data']['contents'], - 'error': self.definition['data']['error_msg'], + 'data': self.contents, + 'error': self.error_msg, }) diff --git a/common/lib/xmodule/xmodule/raw_module.py b/common/lib/xmodule/xmodule/raw_module.py index bdbc049712..5e50bdf6a0 100644 --- a/common/lib/xmodule/xmodule/raw_module.py +++ b/common/lib/xmodule/xmodule/raw_module.py @@ -3,25 +3,29 @@ from xmodule.editing_module import XMLEditingDescriptor from xmodule.xml_module import XmlDescriptor import logging import sys +from .model import String, Scope log = logging.getLogger(__name__) + class RawDescriptor(XmlDescriptor, XMLEditingDescriptor): """ Module that provides a raw editing view of its data and children. It requires that the definition xml is valid. """ + data = String(help="XML data for the module", scope=Scope.content) + @classmethod def definition_from_xml(cls, xml_object, system): return {'data': etree.tostring(xml_object, pretty_print=True)}, [] def definition_to_xml(self, resource_fs): try: - return etree.fromstring(self.definition['data']) + return etree.fromstring(self.data) except etree.XMLSyntaxError as err: # Can't recover here, so just add some info and # re-raise - lines = self.definition['data'].split('\n') + lines = self.data.split('\n') line, offset = err.position msg = ("Unable to create xml for problem {loc}. " "Context: '{context}'".format( diff --git a/common/lib/xmodule/xmodule/template_module.py b/common/lib/xmodule/xmodule/template_module.py index f14254c011..988aaaf7b7 100644 --- a/common/lib/xmodule/xmodule/template_module.py +++ b/common/lib/xmodule/xmodule/template_module.py @@ -27,11 +27,6 @@ class CustomTagModule(XModule): More information given in the text """ - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) - def get_html(self): return self.descriptor.rendered_html @@ -62,14 +57,14 @@ class CustomTagDescriptor(RawDescriptor): template_loc = self.location._replace(category='custom_tag_template', name=template_name) template_module = modulestore().get_instance(system.course_id, template_loc) - template_module_data = template_module.definition['data'] + template_module_data = template_module.data template = Template(template_module_data) return template.render(**params) @property def rendered_html(self): - return self.render_template(self.system, self.definition['data']) + return self.render_template(self.system, self.data) def export_to_file(self): """ diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 0c6e2245b9..7ed0685704 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -143,10 +143,9 @@ def redirect_to_course_position(course_module, first_time): 'chapter': chapter.url_name, 'section': section.url_name})) -def save_child_position(seq_module, child_name, instance_module): +def save_child_position(seq_module, child_name): """ child_name: url_name of the child - instance_module: the StudentModule object for the seq_module """ for i, c in enumerate(seq_module.get_display_items()): if c.url_name == child_name: @@ -155,8 +154,6 @@ def save_child_position(seq_module, child_name, instance_module): # Only save if position changed if position != seq_module.position: seq_module.position = position - instance_module.state = seq_module.get_instance_state() - instance_module.save() @login_required @ensure_csrf_cookie @@ -222,8 +219,7 @@ def index(request, course_id, chapter=None, section=None, chapter_descriptor = course.get_child_by_url_name(chapter) if chapter_descriptor is not None: - instance_module = get_instance_module(course_id, request.user, course_module, student_module_cache) - save_child_position(course_module, chapter, instance_module) + save_child_position(course_module, chapter) else: raise Http404 @@ -250,8 +246,7 @@ def index(request, course_id, chapter=None, section=None, raise Http404 # Save where we are in the chapter - instance_module = get_instance_module(course_id, request.user, chapter_module, student_module_cache) - save_child_position(chapter_module, section, instance_module) + save_child_position(chapter_module, section) context['content'] = section_module.get_html() From 81e065bdb6224d67368581fd5bce3b35f154e228 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 18 Dec 2012 10:14:42 -0500 Subject: [PATCH 034/501] Fix more errors in tests --- common/lib/xmodule/xmodule/capa_module.py | 16 +++++++++++- common/lib/xmodule/xmodule/course_module.py | 28 ++------------------- common/lib/xmodule/xmodule/html_module.py | 2 ++ 3 files changed, 19 insertions(+), 27 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 36da929926..4728c713af 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -355,7 +355,11 @@ class CapaModule(XModule): id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "
" # now do the substitutions which are filesystem based, e.g. '/static/' prefixes - return self.system.replace_urls(html, self.descriptor.data_dir, course_namespace=self.location) + return self.system.replace_urls( + html, + getattr(self.descriptor, 'data_dir', ''), + course_namespace=self.location + ) def handle_ajax(self, dispatch, get): ''' @@ -461,11 +465,21 @@ class CapaModule(XModule): new_answers = dict() for answer_id in answers: try: +<<<<<<< HEAD <<<<<<< HEAD new_answer = {answer_id: self.system.replace_urls(answers[answer_id], self.metadata['data_dir'], course_namespace=self.location)} ======= new_answer = {answer_id: self.system.replace_urls(answers[answer_id], self.descriptor.data_dir)} >>>>>>> WIP: Save student state via StudentModule. Inheritance doesn't work +======= + new_answer = { + answer_id: self.system.replace_urls( + answers[answer_id], + getattr(self, 'data_dir', ''), + course_namespace=self.location + ) + } +>>>>>>> Fix more errors in tests except TypeError: log.debug('Unable to perform URL substitution on answers[%s]: %s' % (answer_id, answers[answer_id])) new_answer = {answer_id: answers[answer_id]} diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 510857303a..c11024e406 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -290,30 +290,6 @@ class CourseDescriptor(SequenceDescriptor): def has_started(self): return time.gmtime() > self.start - @property - def end(self): - return self._try_parse_time("end") - @end.setter - def end(self, value): - if isinstance(value, time.struct_time): - self.metadata['end'] = stringify_time(value) - @property - def enrollment_start(self): - return self._try_parse_time("enrollment_start") - - @enrollment_start.setter - def enrollment_start(self, value): - if isinstance(value, time.struct_time): - self.metadata['enrollment_start'] = stringify_time(value) - @property - def enrollment_end(self): - return self._try_parse_time("enrollment_end") - - @enrollment_end.setter - def enrollment_end(self, value): - if isinstance(value, time.struct_time): - self.metadata['enrollment_end'] = stringify_time(value) - @property def grader(self): return self._grading_policy['GRADER'] @@ -326,7 +302,7 @@ class CourseDescriptor(SequenceDescriptor): def raw_grader(self, value): # NOTE WELL: this change will not update the processed graders. If we need that, this needs to call grader_from_conf self._grading_policy['RAW_GRADER'] = value - self.definition['data'].setdefault('grading_policy',{})['GRADER'] = value + self.grading_policy['GRADER'] = value @property def grade_cutoffs(self): @@ -335,7 +311,7 @@ class CourseDescriptor(SequenceDescriptor): @grade_cutoffs.setter def grade_cutoffs(self, value): self._grading_policy['GRADE_CUTOFFS'] = value - self.definition['data'].setdefault('grading_policy',{})['GRADE_CUTOFFS'] = value + self.grading_policy['GRADE_CUTOFFS'] = value @lazyproperty diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 562eeeb361..6ec1061451 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -43,6 +43,8 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): module_class = HtmlModule filename_extension = "xml" template_dir_name = "html" + + data = String(help="Html contents to display for this module", scope=Scope.content) js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]} js_module_name = "HTMLEditingDescriptor" From 7679fda17229d1f6f367ea88d19ed3d735fe46f2 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 18 Dec 2012 11:35:08 -0500 Subject: [PATCH 035/501] Remove debugging print statements --- common/lib/xmodule/xmodule/course_module.py | 1 - lms/djangoapps/courseware/grades.py | 2 -- lms/djangoapps/courseware/module_render.py | 1 - lms/xmodule_namespace.py | 1 - 4 files changed, 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index c11024e406..ad68b9a1b3 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -401,7 +401,6 @@ class CourseDescriptor(SequenceDescriptor): @property def start_date_text(self): - print self.advertised_start, self.start return time.strftime("%b %d, %Y", self.advertised_start or self.start) @property diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 1d894d3707..b81147f905 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -36,8 +36,6 @@ def yield_dynamic_descriptor_descendents(descriptor, module_creator): def get_dynamic_descriptor_children(descriptor): if descriptor.has_dynamic_children(): module = module_creator(descriptor) - if module is None: - print "FOO", descriptor child_locations = module.get_children_locations() return [descriptor.system.load_item(child_location) for child_location in child_locations ] else: diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 3fd1abec55..085432cbd9 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -92,7 +92,6 @@ def toc_for_course(user, request, course, active_chapter, active_section): chapters = list() for chapter in course_module.get_display_items(): - print chapter, chapter._model_data, chapter._model_data.get('hide_from_toc'), chapter.lms.hide_from_toc if chapter.lms.hide_from_toc: continue diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index 976bd4483f..3a72a64dff 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -4,7 +4,6 @@ from xmodule.fields import Date class StringyBoolean(Boolean): def from_json(self, value): - print "StringyBoolean ", value if isinstance(value, basestring): return value.lower() == 'true' return value From f3032ba7cf835f479a5254a1f08784aa860cfeee Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 18 Dec 2012 11:35:25 -0500 Subject: [PATCH 036/501] Make course textbooks and wiki slugs work --- common/lib/xmodule/xmodule/course_module.py | 132 +++++++++++--------- 1 file changed, 73 insertions(+), 59 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index ad68b9a1b3..947d75eb97 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -4,7 +4,7 @@ from path import path # NOTE (THK): Only used for detecting presence of syllabus from xmodule.graders import grader_from_conf from xmodule.modulestore import Location from xmodule.seq_module import SequenceDescriptor, SequenceModule -from xmodule.timeparse import parse_time, stringify_time +from xmodule.timeparse import parse_time from xmodule.util.decorators import lazyproperty import json import logging @@ -20,10 +20,79 @@ log = logging.getLogger(__name__) edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False, remove_comments=True, remove_blank_text=True) +class Textbook(object): + def __init__(self, title, book_url): + self.title = title + self.book_url = book_url + self.start_page = int(self.table_of_contents[0].attrib['page']) + + # The last page should be the last element in the table of contents, + # but it may be nested. So recurse all the way down the last element + last_el = self.table_of_contents[-1] + while last_el.getchildren(): + last_el = last_el[-1] + + self.end_page = int(last_el.attrib['page']) + + @lazyproperty + def table_of_contents(self): + """ + Accesses the textbook's table of contents (default name "toc.xml") at the URL self.book_url + + Returns XML tree representation of the table of contents + """ + toc_url = self.book_url + 'toc.xml' + + # Get the table of contents from S3 + log.info("Retrieving textbook table of contents from %s" % toc_url) + try: + r = requests.get(toc_url) + except Exception as err: + msg = 'Error %s: Unable to retrieve textbook table of contents at %s' % (err, toc_url) + log.error(msg) + raise Exception(msg) + + # TOC is XML. Parse it + try: + table_of_contents = etree.fromstring(r.text) + except Exception as err: + msg = 'Error %s: Unable to parse XML for textbook table of contents at %s' % (err, toc_url) + log.error(msg) + raise Exception(msg) + + return table_of_contents + + +class TextbookList(ModelType): + def from_json(self, values): + textbooks = [] + for title, book_url in values: + try: + textbooks.append(Textbook(title, book_url)) + except: + # If we can't get to S3 (e.g. on a train with no internet), don't break + # the rest of the courseware. + log.exception("Couldn't load textbook ({0}, {1})".format(title, book_url)) + continue + + return textbooks + + def to_json(self, values): + json_data = [] + for val in values: + if isinstance(val, Textbook): + json_data.append((textbook.title, textbook.book_url)) + elif isinstance(val, tuple): + json_data.append(val) + else: + continue + return json_data + + class CourseDescriptor(SequenceDescriptor): module_class = SequenceModule - textbooks = List(help="List of pairs of (title, url) for textbooks used in this course", default=[], scope=Scope.content) + textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", default=[], scope=Scope.content) wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings) enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings) @@ -70,65 +139,10 @@ class CourseDescriptor(SequenceDescriptor): template_dir_name = 'course' - class Textbook: - def __init__(self, title, book_url): - self.title = title - self.book_url = book_url - self.table_of_contents = self._get_toc_from_s3() - self.start_page = int(self.table_of_contents[0].attrib['page']) - - # The last page should be the last element in the table of contents, - # but it may be nested. So recurse all the way down the last element - last_el = self.table_of_contents[-1] - while last_el.getchildren(): - last_el = last_el[-1] - - self.end_page = int(last_el.attrib['page']) - - @property - def table_of_contents(self): - return self.table_of_contents - - def _get_toc_from_s3(self): - """ - Accesses the textbook's table of contents (default name "toc.xml") at the URL self.book_url - - Returns XML tree representation of the table of contents - """ - toc_url = self.book_url + 'toc.xml' - - # Get the table of contents from S3 - log.info("Retrieving textbook table of contents from %s" % toc_url) - try: - r = requests.get(toc_url) - except Exception as err: - msg = 'Error %s: Unable to retrieve textbook table of contents at %s' % (err, toc_url) - log.error(msg) - raise Exception(msg) - - # TOC is XML. Parse it - try: - table_of_contents = etree.fromstring(r.text) - except Exception as err: - msg = 'Error %s: Unable to parse XML for textbook table of contents at %s' % (err, toc_url) - log.error(msg) - raise Exception(msg) - - return table_of_contents def __init__(self, *args, **kwargs): super(CourseDescriptor, self).__init__(*args, **kwargs) - self.textbooks = [] - for title, book_url in self.textbooks: - try: - self.textbooks.append(self.Textbook(title, book_url)) - except: - # If we can't get to S3 (e.g. on a train with no internet), don't break - # the rest of the courseware. - log.exception("Couldn't load textbook ({0}, {1})".format(title, book_url)) - continue - if self.wiki_slug is None: self.wiki_slug = self.location.course @@ -272,8 +286,8 @@ class CourseDescriptor(SequenceDescriptor): definition, children = super(CourseDescriptor, cls).definition_from_xml(xml_object, system) - definition.setdefault('data', {})['textbooks'] = textbooks - definition['data']['wiki_slug'] = wiki_slug + definition['textbooks'] = textbooks + definition['wiki_slug'] = wiki_slug return definition, children From 9d822fc359d068509ecdcfcc91326a3aab7ac887 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 18 Dec 2012 11:35:55 -0500 Subject: [PATCH 037/501] Make sequences record whether they have been visited or not (done somewhat implicitly now, would be nice to be more explicit) --- common/lib/xmodule/xmodule/seq_module.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index a136473653..3fc3a5dbaa 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -37,7 +37,7 @@ class SequenceModule(XModule): # NOTE: Position is 1-indexed. This is silly, but there are now student # positions saved on prod, so it's not easy to fix. - position = Int(help="Last tab viewed in this sequence", default=1, scope=Scope.student_state) + position = Int(help="Last tab viewed in this sequence", scope=Scope.student_state) def __init__(self, *args, **kwargs): XModule.__init__(self, *args, **kwargs) @@ -46,6 +46,13 @@ class SequenceModule(XModule): if self.system.get('position'): self.position = int(self.system.get('position')) + # Default to the first child + # Don't set 1 as the default in the property definition, because + # there is code that looks for the existance of the position value + # to determine if the student has visited the sequence before or not + if self.position is None: + self.position = 1 + self.rendered = False def get_instance_state(self): From e1ca413b6f80dbb34e98553b90e43c7b4917bef9 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 18 Dec 2012 13:28:03 -0500 Subject: [PATCH 038/501] Fix import of metadata --- common/lib/xmodule/xmodule/modulestore/xml_importer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 1132fe77bb..bbbb60d8ed 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -147,7 +147,7 @@ def import_module_from_xml(modulestore, static_content_store, course_data_path, for field in module.fields + module.lms.fields: # Only save metadata that wasn't inherited if (field.scope == Scope.settings and - field.name in module._inherited_metadata and + field.name not in module._inherited_metadata and field.name in module._model_data): metadata[field.name] = module._model_data[field.name] From 7cb95aad47b3eeac72c9aaa725a0387cd464de70 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 19 Dec 2012 10:11:39 -0500 Subject: [PATCH 039/501] WIP: Add grade publishing functionality --- common/lib/xmodule/xmodule/capa_module.py | 19 +++++++++++++++++- common/lib/xmodule/xmodule/x_module.py | 7 +++++++ lms/djangoapps/courseware/module_render.py | 23 +++++++++++++++++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 4728c713af..8c2297af1b 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -103,7 +103,7 @@ class CapaModule(XModule): graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) show_answer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed") force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings, default=False) - rerandomize = String(help="When to rerandomize the problem", default="always") + rerandomize = String(help="When to rerandomize the problem", default="always", scope=Scope.settings) data = String(help="XML data for the problem", scope=Scope.content) correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.student_state, default={}) student_answers = Object(help="Dictionary with the current student responses", scope=Scope.student_state) @@ -442,6 +442,7 @@ class CapaModule(XModule): score_msg = get['xqueue_body'] self.lcp.update_score(score_msg, queuekey) self.set_state_from_lcp() + self.publish_grade() return dict() # No AJAX return is needed @@ -519,6 +520,19 @@ class CapaModule(XModule): return answers + def publish_grade(self): + """ + Publishes the student's current grade to the system as an event + """ + score = self.lcp.get_score() + print score + self.system.publish({ + 'event_name': 'grade', + 'value': score['score'], + 'max_value': score['total'], + }) + + def check_problem(self, get): ''' Checks whether answers to a problem are correct, and returns a map of correct/incorrect answers: @@ -570,6 +584,9 @@ class CapaModule(XModule): self.attempts = self.attempts + 1 self.lcp.done = True + self.set_state_from_lcp() + self.publish_grade() + # success = correct if ALL questions in this problem are correct success = 'correct' for answer_id in correct_map: diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 6c499aa611..a05a191e5f 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -672,6 +672,7 @@ class ModuleSystem(object): filestore=None, debug=False, xqueue=None, + publish=None, node_path="", anonymous_student_id=''): ''' @@ -723,6 +724,12 @@ class ModuleSystem(object): self.user_is_staff = user is not None and user.is_staff self.xmodule_model_data = xmodule_model_data + if publish is None: + publish = lambda e: None + + self.publish = publish + + def get(self, attr): ''' provide uniform access to attributes (like etree).''' return self.__dict__.get(attr) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 085432cbd9..839ee80591 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -277,6 +277,26 @@ def _get_module(user, request, location, student_module_cache, course_id, positi LmsUsage(location, location) ) + def publish(event): + if event.get('event_name') != 'grade': + return + + student_module = student_module_cache.lookup( + course_id, descriptor.location.category, descriptor.location.url() + ) + if student_module is None: + student_module = StudentModule( + course_id=course_id, + student=user, + module_type=descriptor.location.category, + module_state_key=descriptor.location.url(), + state=json.dumps({}) + ) + student_module_cache.append(student_module) + student_module.grade = event.get('value') + student_module.max_grade = event.get('max_value') + student_module.save() + # TODO (cpennington): When modules are shared between courses, the static # prefix is going to have to be specific to the module, not the directory # that the xml was loaded from @@ -294,7 +314,8 @@ def _get_module(user, request, location, student_module_cache, course_id, positi replace_urls=replace_urls, node_path=settings.NODE_PATH, anonymous_student_id=anonymous_student_id, - xmodule_model_data=xmodule_model_data + xmodule_model_data=xmodule_model_data, + publish=publish, ) # pass position specified in URL to module through ModuleSystem system.set('position', position) From 6e933ae756bf40b21a4568576e11f1271001287f Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 19 Dec 2012 10:11:53 -0500 Subject: [PATCH 040/501] Fix typo --- common/lib/xmodule/xmodule/abtest_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py index 9c639e8f30..e805d5efa6 100644 --- a/common/lib/xmodule/xmodule/abtest_module.py +++ b/common/lib/xmodule/xmodule/abtest_module.py @@ -55,7 +55,7 @@ class ABTestModule(XModule): @group.setter def group(self, value): - self.group_assigments[self.experiment] = value + self.group_assignments[self.experiment] = value @group.deleter def group(self): From 9c5a922eee46ddc9879b7e556baf26ee518b1f32 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 20 Dec 2012 16:53:29 -0500 Subject: [PATCH 041/501] Create tables for all known scopes, and add tests of the LmsKeyValueStore --- .../migrations/0005_add_xmodule_storage.py | 185 +++++++++ .../migrations/0006_add_field_default.py | 128 +++++++ lms/djangoapps/courseware/model_data.py | 145 +++++++ lms/djangoapps/courseware/models.py | 126 ++++++ lms/djangoapps/courseware/module_render.py | 70 +--- .../courseware/tests/test_model_data.py | 360 ++++++++++++++++++ 6 files changed, 946 insertions(+), 68 deletions(-) create mode 100644 lms/djangoapps/courseware/migrations/0005_add_xmodule_storage.py create mode 100644 lms/djangoapps/courseware/migrations/0006_add_field_default.py create mode 100644 lms/djangoapps/courseware/model_data.py create mode 100644 lms/djangoapps/courseware/tests/test_model_data.py diff --git a/lms/djangoapps/courseware/migrations/0005_add_xmodule_storage.py b/lms/djangoapps/courseware/migrations/0005_add_xmodule_storage.py new file mode 100644 index 0000000000..0d89471fff --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0005_add_xmodule_storage.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'XModuleStudentInfoField' + db.create_table('courseware_xmodulestudentinfofield', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('field_name', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)), + ('value', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + ('student', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)), + ('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)), + )) + db.send_create_signal('courseware', ['XModuleStudentInfoField']) + + # Adding unique constraint on 'XModuleStudentInfoField', fields ['student', 'field_name'] + db.create_unique('courseware_xmodulestudentinfofield', ['student_id', 'field_name']) + + # Adding model 'XModuleContentField' + db.create_table('courseware_xmodulecontentfield', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('field_name', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)), + ('definition_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('value', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)), + ('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)), + )) + db.send_create_signal('courseware', ['XModuleContentField']) + + # Adding unique constraint on 'XModuleContentField', fields ['definition_id', 'field_name'] + db.create_unique('courseware_xmodulecontentfield', ['definition_id', 'field_name']) + + # Adding model 'XModuleSettingsField' + db.create_table('courseware_xmodulesettingsfield', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('field_name', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)), + ('usage_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('value', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)), + ('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)), + )) + db.send_create_signal('courseware', ['XModuleSettingsField']) + + # Adding unique constraint on 'XModuleSettingsField', fields ['usage_id', 'field_name'] + db.create_unique('courseware_xmodulesettingsfield', ['usage_id', 'field_name']) + + # Adding model 'XModuleStudentPrefsField' + db.create_table('courseware_xmodulestudentprefsfield', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('field_name', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)), + ('module_type', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)), + ('value', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + ('student', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)), + ('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)), + )) + db.send_create_signal('courseware', ['XModuleStudentPrefsField']) + + # Adding unique constraint on 'XModuleStudentPrefsField', fields ['student', 'module_type', 'field_name'] + db.create_unique('courseware_xmodulestudentprefsfield', ['student_id', 'module_type', 'field_name']) + + + def backwards(self, orm): + # Removing unique constraint on 'XModuleStudentPrefsField', fields ['student', 'module_type', 'field_name'] + db.delete_unique('courseware_xmodulestudentprefsfield', ['student_id', 'module_type', 'field_name']) + + # Removing unique constraint on 'XModuleSettingsField', fields ['usage_id', 'field_name'] + db.delete_unique('courseware_xmodulesettingsfield', ['usage_id', 'field_name']) + + # Removing unique constraint on 'XModuleContentField', fields ['definition_id', 'field_name'] + db.delete_unique('courseware_xmodulecontentfield', ['definition_id', 'field_name']) + + # Removing unique constraint on 'XModuleStudentInfoField', fields ['student', 'field_name'] + db.delete_unique('courseware_xmodulestudentinfofield', ['student_id', 'field_name']) + + # Deleting model 'XModuleStudentInfoField' + db.delete_table('courseware_xmodulestudentinfofield') + + # Deleting model 'XModuleContentField' + db.delete_table('courseware_xmodulecontentfield') + + # Deleting model 'XModuleSettingsField' + db.delete_table('courseware_xmodulesettingsfield') + + # Deleting model 'XModuleStudentPrefsField' + db.delete_table('courseware_xmodulestudentprefsfield') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'courseware.studentmodule': { + 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.xmodulecontentfield': { + 'Meta': {'unique_together': "(('definition_id', 'field_name'),)", 'object_name': 'XModuleContentField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'definition_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) + }, + 'courseware.xmodulesettingsfield': { + 'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleSettingsField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'usage_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) + }, + 'courseware.xmodulestudentinfofield': { + 'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) + }, + 'courseware.xmodulestudentprefsfield': { + 'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) + } + } + + complete_apps = ['courseware'] \ No newline at end of file diff --git a/lms/djangoapps/courseware/migrations/0006_add_field_default.py b/lms/djangoapps/courseware/migrations/0006_add_field_default.py new file mode 100644 index 0000000000..cd885ee7a6 --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0006_add_field_default.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Changing field 'XModuleContentField.value' + db.alter_column('courseware_xmodulecontentfield', 'value', self.gf('django.db.models.fields.TextField')()) + + # Changing field 'XModuleStudentInfoField.value' + db.alter_column('courseware_xmodulestudentinfofield', 'value', self.gf('django.db.models.fields.TextField')()) + + # Changing field 'XModuleSettingsField.value' + db.alter_column('courseware_xmodulesettingsfield', 'value', self.gf('django.db.models.fields.TextField')()) + + # Changing field 'XModuleStudentPrefsField.value' + db.alter_column('courseware_xmodulestudentprefsfield', 'value', self.gf('django.db.models.fields.TextField')()) + + def backwards(self, orm): + + # Changing field 'XModuleContentField.value' + db.alter_column('courseware_xmodulecontentfield', 'value', self.gf('django.db.models.fields.TextField')(null=True)) + + # Changing field 'XModuleStudentInfoField.value' + db.alter_column('courseware_xmodulestudentinfofield', 'value', self.gf('django.db.models.fields.TextField')(null=True)) + + # Changing field 'XModuleSettingsField.value' + db.alter_column('courseware_xmodulesettingsfield', 'value', self.gf('django.db.models.fields.TextField')(null=True)) + + # Changing field 'XModuleStudentPrefsField.value' + db.alter_column('courseware_xmodulestudentprefsfield', 'value', self.gf('django.db.models.fields.TextField')(null=True)) + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'courseware.studentmodule': { + 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.xmodulecontentfield': { + 'Meta': {'unique_together': "(('definition_id', 'field_name'),)", 'object_name': 'XModuleContentField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'definition_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmodulesettingsfield': { + 'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleSettingsField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'usage_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmodulestudentinfofield': { + 'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmodulestudentprefsfield': { + 'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + } + } + + complete_apps = ['courseware'] \ No newline at end of file diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py new file mode 100644 index 0000000000..e8dfb025ed --- /dev/null +++ b/lms/djangoapps/courseware/model_data.py @@ -0,0 +1,145 @@ +import json +from collections import namedtuple +from .models import ( + StudentModule, + XModuleContentField, + XModuleSettingsField, + XModuleStudentPrefsField, + XModuleStudentInfoField +) + +from xmodule.runtime import DbModel, KeyValueStore +from xmodule.model import Scope + + +class InvalidScopeError(Exception): + pass + +class InvalidWriteError(Exception): + pass + + +class LmsKeyValueStore(KeyValueStore): + """ + This KeyValueStore will read data from descriptor_model_data if it exists, + but will not overwrite any keys set in descriptor_model_data. Attempts to do so will + raise an InvalidWriteError. + + If the scope to write to is not one of the 5 named scopes: + Scope.content + Scope.settings + Scope.student_state + Scope.student_preferences + Scope.student_info + then an InvalidScopeError will be raised. + + Data for Scope.student_state is stored as StudentModule objects via the django orm. + + Data for the other scopes is stored in individual objects that are named for the + scope involved and have the field name as a key + + If the key isn't found in the expected table during a read or a delete, then a KeyError will be raised + """ + def __init__(self, course_id, user, descriptor_model_data, student_module_cache): + self._course_id = course_id + self._user = user + self._descriptor_model_data = descriptor_model_data + self._student_module_cache = student_module_cache + + def _student_module(self, key): + student_module = self._student_module_cache.lookup( + self._course_id, key.module_scope_id.category, key.module_scope_id.url() + ) + return student_module + + def _field_object(self, key): + if key.scope == Scope.content: + return XModuleContentField, {'field_name': key.field_name, 'definition_id': key.module_scope_id} + elif key.scope == Scope.settings: + return XModuleSettingsField, { + 'field_name': key.field_name, + 'usage_id': '%s-%s' % (self._course_id, key.module_scope_id) + } + elif key.scope == Scope.student_preferences: + return XModuleStudentPrefsField, {'field_name': key.field_name, 'student': self._user, 'module_type': key.module_scope_id} + elif key.scope == Scope.student_info: + return XModuleStudentInfoField, {'field_name': key.field_name, 'student': self._user} + + raise InvalidScopeError(key.scope) + + def get(self, key): + if key.field_name in self._descriptor_model_data: + return self._descriptor_model_data[key.field_name] + + if key.scope == Scope.student_state: + student_module = self._student_module(key) + + if student_module is None: + raise KeyError(key.field_name) + + return json.loads(student_module.state)[key.field_name] + + scope_field_cls, search_kwargs = self._field_object(key) + try: + return json.loads(scope_field_cls.objects.get(**search_kwargs).value) + except scope_field_cls.DoesNotExist: + raise KeyError(key.field_name) + + def set(self, key, value): + if key.field_name in self._descriptor_model_data: + raise InvalidWriteError("Not allowed to overwrite descriptor model data", key.field_name) + + if key.scope == Scope.student_state: + student_module = self._student_module(key) + if student_module is None: + student_module = StudentModule( + course_id=self._course_id, + student=self._user, + module_type=key.module_scope_id.category, + module_state_key=key.module_scope_id.url(), + state=json.dumps({}) + ) + self._student_module_cache.append(student_module) + state = json.loads(student_module.state) + state[key.field_name] = value + student_module.state = json.dumps(state) + student_module.save() + return + + scope_field_cls, search_kwargs = self._field_object(key) + json_value = json.dumps(value) + field, created = scope_field_cls.objects.select_for_update().get_or_create( + defaults={'value': json_value}, + **search_kwargs + ) + if not created: + field.value = json_value + field.save() + + def delete(self, key): + if key.field_name in self._descriptor_model_data: + raise InvalidWriteError("Not allowed to deleted descriptor model data", key.field_name) + + if key.scope == Scope.student_state: + student_module = self._student_module(key) + + if student_module is None: + raise KeyError(key.field_name) + + state = json.loads(student_module.state) + del state[key.field_name] + student_module.state = json.dumps(state) + student_module.save() + return + + scope_field_cls, search_kwargs = self._field_object(key) + print scope_field_cls, search_kwargs + query = scope_field_cls.objects.filter(**search_kwargs) + if not query.exists(): + raise KeyError(key.field_name) + + query.delete() + + +LmsUsage = namedtuple('LmsUsage', 'id, def_id') + diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 9c318427ec..d6161e9f34 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -72,6 +72,132 @@ class StudentModule(models.Model): def __unicode__(self): return unicode(repr(self)) + +class XModuleContentField(models.Model): + """ + Stores data set in the Scope.content scope by an xmodule field + """ + + class Meta: + unique_together = (('definition_id', 'field_name'),) + + # The name of the field + field_name = models.CharField(max_length=64, db_index=True) + + # The definition id for the module + definition_id = models.CharField(max_length=255, db_index=True) + + # The value of the field. Defaults to None dumped as json + value = models.TextField(default='null') + + created = models.DateTimeField(auto_now_add=True, db_index=True) + modified = models.DateTimeField(auto_now=True, db_index=True) + + def __repr__(self): + return 'XModuleContentField<%r>' % ({ + 'field_name': self.field_name, + 'definition_id': self.definition_id, + 'value': self.value, + },) + + def __unicode__(self): + return unicode(repr(self)) + + +class XModuleSettingsField(models.Model): + """ + Stores data set in the Scope.settings scope by an xmodule field + """ + + class Meta: + unique_together = (('usage_id', 'field_name'),) + + # The name of the field + field_name = models.CharField(max_length=64, db_index=True) + + # The usage id for the module + usage_id = models.CharField(max_length=255, db_index=True) + + # The value of the field. Defaults to None, dumped as json + value = models.TextField(default='null') + + created = models.DateTimeField(auto_now_add=True, db_index=True) + modified = models.DateTimeField(auto_now=True, db_index=True) + + def __repr__(self): + return 'XModuleSettingsField<%r>' % ({ + 'field_name': self.field_name, + 'usage_id': self.usage_id, + 'value': self.value, + },) + + def __unicode__(self): + return unicode(repr(self)) + + +class XModuleStudentPrefsField(models.Model): + """ + Stores data set in the Scope.student_preferences scope by an xmodule field + """ + + class Meta: + unique_together = (('student', 'module_type', 'field_name'),) + + # The name of the field + field_name = models.CharField(max_length=64, db_index=True) + + # The type of the module for these preferences + module_type = models.CharField(max_length=64, db_index=True) + + # The value of the field. Defaults to None dumped as json + value = models.TextField(default='null') + + student = models.ForeignKey(User, db_index=True) + + created = models.DateTimeField(auto_now_add=True, db_index=True) + modified = models.DateTimeField(auto_now=True, db_index=True) + + def __repr__(self): + return 'XModuleStudentPrefsField<%r>' % ({ + 'field_name': self.field_name, + 'module_type': self.module_type, + 'student': self.student.username, + 'value': self.value, + },) + + def __unicode__(self): + return unicode(repr(self)) + + +class XModuleStudentInfoField(models.Model): + """ + Stores data set in the Scope.student_preferences scope by an xmodule field + """ + + class Meta: + unique_together = (('student', 'field_name'),) + + # The name of the field + field_name = models.CharField(max_length=64, db_index=True) + + # The value of the field. Defaults to None dumped as json + value = models.TextField(default='null') + + student = models.ForeignKey(User, db_index=True) + + created = models.DateTimeField(auto_now_add=True, db_index=True) + modified = models.DateTimeField(auto_now=True, db_index=True) + + def __repr__(self): + return 'XModuleStudentInfoField<%r>' % ({ + 'field_name': self.field_name, + 'student': self.student.username, + 'value': self.value, + },) + + def __unicode__(self): + return unicode(repr(self)) + # TODO (cpennington): Remove these once the LMS switches to using XModuleDescriptors diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 839ee80591..04dee7de0f 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -11,8 +11,6 @@ from django.http import Http404 from django.http import HttpResponse from django.views.decorators.csrf import csrf_exempt -from collections import namedtuple - from requests.auth import HTTPBasicAuth from capa.xqueue_interface import XQueueInterface @@ -28,9 +26,9 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.x_module import ModuleSystem from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor -from xmodule.runtime import DbModel, KeyValueStore -from xmodule.model import Scope +from xmodule.runtime import DbModel from xmodule_modifiers import replace_course_urls, replace_static_urls, add_histogram, wrap_xmodule +from .model_data import LmsKeyValueStore, LmsUsage from xmodule.modulestore.exceptions import ItemNotFoundError from statsd import statsd @@ -149,70 +147,6 @@ def get_module(user, request, location, student_module_cache, course_id, positio return None -class LmsKeyValueStore(KeyValueStore): - def __init__(self, course_id, user, descriptor_model_data, student_module_cache): - self._course_id = course_id - self._user = user - self._descriptor_model_data = descriptor_model_data - self._student_module_cache = student_module_cache - - def _student_module(self, key): - student_module = self._student_module_cache.lookup( - self._course_id, key.module_scope_id.category, key.module_scope_id.url() - ) - return student_module - - def get(self, key): - if not key.scope.student: - return self._descriptor_model_data[key.field_name] - - if key.scope == Scope.student_state: - student_module = self._student_module(key) - - if student_module is None: - raise KeyError(key.field_name) - - return json.loads(student_module.state)[key.field_name] - - def set(self, key, value): - if not key.scope.student: - self._descriptor_model_data[key.field_name] = value - - if key.scope == Scope.student_state: - student_module = self._student_module(key) - if student_module is None: - student_module = StudentModule( - course_id=self._course_id, - student=self._user, - module_type=key.module_scope_id.category, - module_state_key=key.module_scope_id.url(), - state=json.dumps({}) - ) - self._student_module_cache.append(student_module) - state = json.loads(student_module.state) - state[key.field_name] = value - student_module.state = json.dumps(state) - student_module.save() - - def delete(self, key): - if not key.scope.student: - del self._descriptor_model_data[key.field_name] - - if key.scope == Scope.student_state: - student_module = self._student_module(key) - - if student_module is None: - raise KeyError(key.field_name) - - state = json.loads(student_module.state) - del state[key.field_name] - student_module.state = json.dumps(state) - student_module.save() - - -LmsUsage = namedtuple('LmsUsage', 'id, def_id') - - def _get_module(user, request, location, student_module_cache, course_id, position=None, wrap_xmodule_display=True): """ Actually implement get_module. See docstring there for details. diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py new file mode 100644 index 0000000000..ab9067ce71 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_model_data.py @@ -0,0 +1,360 @@ +import factory +import json +from mock import Mock +from django.contrib.auth.models import User + +from functools import partial + +from courseware.model_data import LmsKeyValueStore, InvalidWriteError, InvalidScopeError +from courseware.models import StudentModule, XModuleContentField, XModuleSettingsField, XModuleStudentInfoField, XModuleStudentPrefsField, StudentModuleCache +from xmodule.model import Scope, ModuleScope +from xmodule.modulestore import Location + +from django.test import TestCase + + +def mock_descriptor(): + descriptor = Mock() + descriptor.stores_state = True + descriptor.location = location('def_id') + return descriptor + +location = partial(Location, 'i4x', 'edX', 'test_course', 'problem') +course_id = 'edX/test_course/test' + +content_key = partial(LmsKeyValueStore.Key, Scope.content, None, location('def_id')) +settings_key = partial(LmsKeyValueStore.Key, Scope.settings, None, location('def_id')) +student_state_key = partial(LmsKeyValueStore.Key, Scope.student_state, 'user', location('def_id')) +student_prefs_key = partial(LmsKeyValueStore.Key, Scope.student_preferences, 'user', 'problem') +student_info_key = partial(LmsKeyValueStore.Key, Scope.student_info, 'user', None) + + +class UserFactory(factory.Factory): + FACTORY_FOR = User + + username = 'user' + + +class StudentModuleFactory(factory.Factory): + FACTORY_FOR = StudentModule + + module_type = 'problem' + module_state_key = location('def_id').url() + student = factory.SubFactory(UserFactory) + course_id = course_id + state = None + + +class ContentFactory(factory.Factory): + FACTORY_FOR = XModuleContentField + + field_name = 'content_field' + value = json.dumps('content_value') + definition_id = location('def_id').url() + + +class SettingsFactory(factory.Factory): + FACTORY_FOR = XModuleSettingsField + + field_name = 'settings_field' + value = json.dumps('settings_value') + usage_id = '%s-%s' % (course_id, location('def_id').url()) + + +class StudentPrefsFactory(factory.Factory): + FACTORY_FOR = XModuleStudentPrefsField + + field_name = 'student_pref_field' + value = json.dumps('student_pref_value') + student = factory.SubFactory(UserFactory) + module_type = 'problem' + + +class StudentInfoFactory(factory.Factory): + FACTORY_FOR = XModuleStudentInfoField + + field_name = 'student_info_field' + value = json.dumps('student_info_value') + student = factory.SubFactory(UserFactory) + + +class TestDescriptorFallback(TestCase): + + def setUp(self): + self.desc_md = { + 'field_a': 'content', + 'field_b': 'settings', + } + self.kvs = LmsKeyValueStore(course_id, UserFactory.build(), self.desc_md, None) + + def test_get_from_descriptor(self): + self.assertEquals('content', self.kvs.get(content_key('field_a'))) + self.assertEquals('settings', self.kvs.get(settings_key('field_b'))) + + def test_write_to_descriptor(self): + self.assertRaises(InvalidWriteError, self.kvs.set, content_key('field_a'), 'foo') + self.assertEquals('content', self.desc_md['field_a']) + self.assertRaises(InvalidWriteError, self.kvs.set, settings_key('field_b'), 'foo') + self.assertEquals('settings', self.desc_md['field_b']) + + self.assertRaises(InvalidWriteError, self.kvs.delete, content_key('field_a')) + self.assertEquals('content', self.desc_md['field_a']) + self.assertRaises(InvalidWriteError, self.kvs.delete, settings_key('field_b')) + self.assertEquals('settings', self.desc_md['field_b']) + + +class TestStudentStateFields(TestCase): + pass + +class TestInvalidScopes(TestCase): + def setUp(self): + self.desc_md = {} + self.kvs = LmsKeyValueStore(course_id, UserFactory.build(), self.desc_md, None) + + def test_invalid_scopes(self): + for scope in (Scope(student=True, module=ModuleScope.DEFINITION), + Scope(student=False, module=ModuleScope.TYPE), + Scope(student=False, module=ModuleScope.ALL)): + self.assertRaises(InvalidScopeError, self.kvs.get, LmsKeyValueStore.Key(scope, None, None, 'field')) + self.assertRaises(InvalidScopeError, self.kvs.set, LmsKeyValueStore.Key(scope, None, None, 'field'), 'value') + self.assertRaises(InvalidScopeError, self.kvs.delete, LmsKeyValueStore.Key(scope, None, None, 'field')) + + +class TestStudentModuleStorage(TestCase): + + def setUp(self): + student_module = StudentModuleFactory.create(state=json.dumps({'a_field': 'a_value'})) + self.user = student_module.student + self.desc_md = {} + self.smc = StudentModuleCache(course_id, self.user, [mock_descriptor()]) + self.kvs = LmsKeyValueStore(course_id, self.user, self.desc_md, self.smc) + + + def test_get_existing_field(self): + "Test that getting an existing field in an existing StudentModule works" + self.assertEquals('a_value', self.kvs.get(student_state_key('a_field'))) + + def test_get_missing_field(self): + "Test that getting a missing field from an existing StudentModule raises a KeyError" + self.assertRaises(KeyError, self.kvs.get, student_state_key('not_a_field')) + + def test_set_existing_field(self): + "Test that setting an existing student_state field changes the value" + self.kvs.set(student_state_key('a_field'), 'new_value') + self.assertEquals(1, StudentModule.objects.all().count()) + self.assertEquals({'a_field': 'new_value'}, json.loads(StudentModule.objects.all()[0].state)) + + def test_set_missing_field(self): + "Test that setting a new student_state field changes the value" + self.kvs.set(student_state_key('not_a_field'), 'new_value') + self.assertEquals(1, StudentModule.objects.all().count()) + self.assertEquals({'a_field': 'a_value', 'not_a_field': 'new_value'}, json.loads(StudentModule.objects.all()[0].state)) + + def test_delete_existing_field(self): + "Test that deleting an existing field removes it from the StudentModule" + self.kvs.delete(student_state_key('a_field')) + self.assertEquals(1, StudentModule.objects.all().count()) + self.assertEquals({}, json.loads(StudentModule.objects.all()[0].state)) + + def test_delete_missing_field(self): + "Test that deleting a missing field from an existing StudentModule raises a KeyError" + self.assertRaises(KeyError, self.kvs.delete, student_state_key('not_a_field')) + self.assertEquals(1, StudentModule.objects.all().count()) + self.assertEquals({'a_field': 'a_value'}, json.loads(StudentModule.objects.all()[0].state)) + + +class TestMissingStudentModule(TestCase): + def setUp(self): + self.user = UserFactory.create() + self.desc_md = {} + self.smc = StudentModuleCache(course_id, self.user, [mock_descriptor()]) + self.kvs = LmsKeyValueStore(course_id, self.user, self.desc_md, self.smc) + + def test_get_field_from_missing_student_module(self): + "Test that getting a field from a missing StudentModule raises a KeyError" + self.assertRaises(KeyError, self.kvs.get, student_state_key('a_field')) + + def test_set_field_in_missing_student_module(self): + "Test that setting a field in a missing StudentModule creates the student module" + self.assertEquals(0, len(self.smc.cache)) + self.assertEquals(0, StudentModule.objects.all().count()) + + self.kvs.set(student_state_key('a_field'), 'a_value') + + self.assertEquals(1, len(self.smc.cache)) + self.assertEquals(1, StudentModule.objects.all().count()) + + student_module = StudentModule.objects.all()[0] + self.assertEquals({'a_field': 'a_value'}, json.loads(student_module.state)) + self.assertEquals(self.user, student_module.student) + self.assertEquals(location('def_id').url(), student_module.module_state_key) + self.assertEquals(course_id, student_module.course_id) + + def test_delete_field_from_missing_student_module(self): + "Test that deleting a field from a missing StudentModule raises a KeyError" + self.assertRaises(KeyError, self.kvs.delete, student_state_key('a_field')) + + +class TestSettingsStorage(TestCase): + + def setUp(self): + settings = SettingsFactory.create() + self.user = UserFactory.create() + self.desc_md = {} + self.smc = StudentModuleCache(course_id, self.user, []) + self.kvs = LmsKeyValueStore(course_id, self.user, self.desc_md, self.smc) + + def test_get_existing_field(self): + "Test that getting an existing field in an existing SettingsField works" + self.assertEquals('settings_value', self.kvs.get(settings_key('settings_field'))) + + def test_get_missing_field(self): + "Test that getting a missing field from an existing SettingsField raises a KeyError" + self.assertRaises(KeyError, self.kvs.get, settings_key('not_settings_field')) + + def test_set_existing_field(self): + "Test that setting an existing field changes the value" + self.kvs.set(settings_key('settings_field'), 'new_value') + self.assertEquals(1, XModuleSettingsField.objects.all().count()) + self.assertEquals('new_value', json.loads(XModuleSettingsField.objects.all()[0].value)) + + def test_set_missing_field(self): + "Test that setting a new field changes the value" + self.kvs.set(settings_key('not_settings_field'), 'new_value') + self.assertEquals(2, XModuleSettingsField.objects.all().count()) + self.assertEquals('settings_value', json.loads(XModuleSettingsField.objects.get(field_name='settings_field').value)) + self.assertEquals('new_value', json.loads(XModuleSettingsField.objects.get(field_name='not_settings_field').value)) + + def test_delete_existing_field(self): + "Test that deleting an existing field removes it" + self.kvs.delete(settings_key('settings_field')) + self.assertEquals(0, XModuleSettingsField.objects.all().count()) + + def test_delete_missing_field(self): + "Test that deleting a missing field from an existing SettingsField raises a KeyError" + self.assertRaises(KeyError, self.kvs.delete, settings_key('not_settings_field')) + self.assertEquals(1, XModuleSettingsField.objects.all().count()) + + +class TestContentStorage(TestCase): + + def setUp(self): + content = ContentFactory.create() + self.user = UserFactory.create() + self.desc_md = {} + self.smc = StudentModuleCache(course_id, self.user, []) + self.kvs = LmsKeyValueStore(course_id, self.user, self.desc_md, self.smc) + + def test_get_existing_field(self): + "Test that getting an existing field in an existing ContentField works" + self.assertEquals('content_value', self.kvs.get(content_key('content_field'))) + + def test_get_missing_field(self): + "Test that getting a missing field from an existing ContentField raises a KeyError" + self.assertRaises(KeyError, self.kvs.get, content_key('not_content_field')) + + def test_set_existing_field(self): + "Test that setting an existing field changes the value" + self.kvs.set(content_key('content_field'), 'new_value') + self.assertEquals(1, XModuleContentField.objects.all().count()) + self.assertEquals('new_value', json.loads(XModuleContentField.objects.all()[0].value)) + + def test_set_missing_field(self): + "Test that setting a new field changes the value" + self.kvs.set(content_key('not_content_field'), 'new_value') + self.assertEquals(2, XModuleContentField.objects.all().count()) + self.assertEquals('content_value', json.loads(XModuleContentField.objects.get(field_name='content_field').value)) + self.assertEquals('new_value', json.loads(XModuleContentField.objects.get(field_name='not_content_field').value)) + + def test_delete_existing_field(self): + "Test that deleting an existing field removes it" + self.kvs.delete(content_key('content_field')) + self.assertEquals(0, XModuleContentField.objects.all().count()) + + def test_delete_missing_field(self): + "Test that deleting a missing field from an existing ContentField raises a KeyError" + self.assertRaises(KeyError, self.kvs.delete, content_key('not_content_field')) + self.assertEquals(1, XModuleContentField.objects.all().count()) + + +class TestStudentPrefsStorage(TestCase): + + def setUp(self): + student_pref = StudentPrefsFactory.create() + self.user = student_pref.student + self.desc_md = {} + self.smc = StudentModuleCache(course_id, self.user, []) + self.kvs = LmsKeyValueStore(course_id, self.user, self.desc_md, self.smc) + + def test_get_existing_field(self): + "Test that getting an existing field in an existing StudentPrefsField works" + self.assertEquals('student_pref_value', self.kvs.get(student_prefs_key('student_pref_field'))) + + def test_get_missing_field(self): + "Test that getting a missing field from an existing StudentPrefsField raises a KeyError" + self.assertRaises(KeyError, self.kvs.get, student_prefs_key('not_student_pref_field')) + + def test_set_existing_field(self): + "Test that setting an existing field changes the value" + self.kvs.set(student_prefs_key('student_pref_field'), 'new_value') + self.assertEquals(1, XModuleStudentPrefsField.objects.all().count()) + self.assertEquals('new_value', json.loads(XModuleStudentPrefsField.objects.all()[0].value)) + + def test_set_missing_field(self): + "Test that setting a new field changes the value" + self.kvs.set(student_prefs_key('not_student_pref_field'), 'new_value') + self.assertEquals(2, XModuleStudentPrefsField.objects.all().count()) + self.assertEquals('student_pref_value', json.loads(XModuleStudentPrefsField.objects.get(field_name='student_pref_field').value)) + self.assertEquals('new_value', json.loads(XModuleStudentPrefsField.objects.get(field_name='not_student_pref_field').value)) + + def test_delete_existing_field(self): + "Test that deleting an existing field removes it" + print list(XModuleStudentPrefsField.objects.all()) + self.kvs.delete(student_prefs_key('student_pref_field')) + self.assertEquals(0, XModuleStudentPrefsField.objects.all().count()) + + def test_delete_missing_field(self): + "Test that deleting a missing field from an existing StudentPrefsField raises a KeyError" + self.assertRaises(KeyError, self.kvs.delete, student_prefs_key('not_student_pref_field')) + self.assertEquals(1, XModuleStudentPrefsField.objects.all().count()) + + +class TestStudentInfoStorage(TestCase): + + def setUp(self): + student_info = StudentInfoFactory.create() + self.user = student_info.student + self.desc_md = {} + self.smc = StudentModuleCache(course_id, self.user, []) + self.kvs = LmsKeyValueStore(course_id, self.user, self.desc_md, self.smc) + + def test_get_existing_field(self): + "Test that getting an existing field in an existing StudentInfoField works" + self.assertEquals('student_info_value', self.kvs.get(student_info_key('student_info_field'))) + + def test_get_missing_field(self): + "Test that getting a missing field from an existing StudentInfoField raises a KeyError" + self.assertRaises(KeyError, self.kvs.get, student_info_key('not_student_info_field')) + + def test_set_existing_field(self): + "Test that setting an existing field changes the value" + self.kvs.set(student_info_key('student_info_field'), 'new_value') + self.assertEquals(1, XModuleStudentInfoField.objects.all().count()) + self.assertEquals('new_value', json.loads(XModuleStudentInfoField.objects.all()[0].value)) + + def test_set_missing_field(self): + "Test that setting a new field changes the value" + self.kvs.set(student_info_key('not_student_info_field'), 'new_value') + self.assertEquals(2, XModuleStudentInfoField.objects.all().count()) + self.assertEquals('student_info_value', json.loads(XModuleStudentInfoField.objects.get(field_name='student_info_field').value)) + self.assertEquals('new_value', json.loads(XModuleStudentInfoField.objects.get(field_name='not_student_info_field').value)) + + def test_delete_existing_field(self): + "Test that deleting an existing field removes it" + self.kvs.delete(student_info_key('student_info_field')) + self.assertEquals(0, XModuleStudentInfoField.objects.all().count()) + + def test_delete_missing_field(self): + "Test that deleting a missing field from an existing StudentInfoField raises a KeyError" + self.assertRaises(KeyError, self.kvs.delete, student_info_key('not_student_info_field')) + self.assertEquals(1, XModuleStudentInfoField.objects.all().count()) From db55001bf68b76baaa82785160c28f1c754bafb6 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 20 Dec 2012 16:53:50 -0500 Subject: [PATCH 042/501] Use named scope for student_preferences in abtests --- common/lib/xmodule/xmodule/abtest_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py index e805d5efa6..7143eee08d 100644 --- a/common/lib/xmodule/xmodule/abtest_module.py +++ b/common/lib/xmodule/xmodule/abtest_module.py @@ -37,7 +37,7 @@ class ABTestModule(XModule): """ group_portions = Object(help="What proportions of students should go in each group", default={DEFAULT: 1}, scope=Scope.content) - group_assignments = Object(help="What group this user belongs to", scope=Scope(student=True, module=ModuleScope.TYPE), default={}) + group_assignments = Object(help="What group this user belongs to", scope=Scope.student_preferences, default={}) group_content = Object(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []}) def __init__(self, *args, **kwargs): From 4b6ec85dcbbba59042a2f9bfe23ba783e6d499d4 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 20 Dec 2012 16:54:15 -0500 Subject: [PATCH 043/501] Minor cleanups --- common/lib/xmodule/xmodule/capa_module.py | 1 - common/lib/xmodule/xmodule/course_module.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 8c2297af1b..6fcbc00797 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -525,7 +525,6 @@ class CapaModule(XModule): Publishes the student's current grade to the system as an event """ score = self.lcp.get_score() - print score self.system.publish({ 'event_name': 'grade', 'value': score['score'], diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 947d75eb97..444fcaf52d 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -96,11 +96,11 @@ class CourseDescriptor(SequenceDescriptor): wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings) enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings) + start = Date(help="Start time when this module is visible", scope=Scope.settings) end = Date(help="Date that this class ends", scope=Scope.settings) advertised_start = Date(help="Date that this course is advertised to start", scope=Scope.settings) grading_policy = Object(help="Grading policy definition for this class", scope=Scope.content) show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings) - start = Date(help="Start time when this module is visible", scope=Scope.settings) display_name = String(help="Display name for this module", scope=Scope.settings) tabs = List(help="List of tabs to enable in this course", scope=Scope.settings) end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings) From 9bd42278e95b45a04932bb87849cfe8530adfdcf Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 20 Dec 2012 16:54:30 -0500 Subject: [PATCH 044/501] Don't hide the logs in tests --- lms/envs/test.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lms/envs/test.py b/lms/envs/test.py index b15e3acb4c..fe4f3e0c1b 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -8,7 +8,6 @@ sessions. Assumes structure: /log # Where we're going to write log files """ from .common import * -from logsettings import get_logger_config import os from path import path @@ -44,12 +43,6 @@ STATUS_MESSAGE_PATH = TEST_ROOT / "status_message.json" COURSES_ROOT = TEST_ROOT / "data" DATA_DIR = COURSES_ROOT -LOGGING = get_logger_config(TEST_ROOT / "log", - logging_env="dev", - tracking_filename="tracking.log", - dev_env=True, - debug=True) - COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data" # Where the content data is checked out. This may not exist on jenkins. GITHUB_REPO_ROOT = ENV_ROOT / "data" From e4c06fab4a368cdda845149e82db41bca458e0b7 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 11:12:03 -0500 Subject: [PATCH 045/501] Cleaning up ABTest properties --- common/lib/xmodule/xmodule/abtest_module.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py index 7143eee08d..f33a3db91c 100644 --- a/common/lib/xmodule/xmodule/abtest_module.py +++ b/common/lib/xmodule/xmodule/abtest_module.py @@ -39,13 +39,15 @@ class ABTestModule(XModule): group_portions = Object(help="What proportions of students should go in each group", default={DEFAULT: 1}, scope=Scope.content) group_assignments = Object(help="What group this user belongs to", scope=Scope.student_preferences, default={}) group_content = Object(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []}) + experiment = String(help="Experiment that this A/B test belongs to", scope=Scope.content) + has_children = True def __init__(self, *args, **kwargs): XModule.__init__(self, *args, **kwargs) if self.group is None: self.group = group_from_value( - self.group_portions, + self.group_portions.items(), random.uniform(0, 1) ) @@ -80,6 +82,7 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor): experiment = String(help="Experiment that this A/B test belongs to", scope=Scope.content) group_portions = Object(help="What proportions of students should go in each group", default={}) group_content = Object(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []}) + has_children = True @classmethod def definition_from_xml(cls, xml_object, system): From c7069be2a4cdeaeaa4ebf7bdb877722d0762f66d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 11:12:19 -0500 Subject: [PATCH 046/501] Centralize logic for standardizing rerandomize values --- common/lib/xmodule/xmodule/capa_module.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 6fcbc00797..5ad2a8bffa 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -83,6 +83,16 @@ class Timedelta(ModelType): return ' '.join(values) +class Randomization(String): + def from_json(self, value): + if value in ("", "true"): + return "always" + elif value == "false": + return "per_student" + + to_json = from_json + + class ComplexEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, complex): @@ -103,7 +113,7 @@ class CapaModule(XModule): graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) show_answer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed") force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings, default=False) - rerandomize = String(help="When to rerandomize the problem", default="always", scope=Scope.settings) + rerandomize = Randomization(help="When to rerandomize the problem", default="always", scope=Scope.settings) data = String(help="XML data for the problem", scope=Scope.content) correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.student_state, default={}) student_answers = Object(help="Dictionary with the current student responses", scope=Scope.student_state) @@ -174,11 +184,6 @@ class CapaModule(XModule): # add extra info and raise raise Exception(msg), None, sys.exc_info()[2] - if self.rerandomize in ("", "true"): - self.rerandomize = "always" - elif self.rerandomize == "false": - self.rerandomize = "per_student" - def new_lcp(self, state, text=None): if text is None: text = self.data From 48a1e09133d352903169af2f37c7d3afeabfe6e4 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 11:12:31 -0500 Subject: [PATCH 047/501] Pass in state to LCP on reset --- common/lib/xmodule/xmodule/capa_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 5ad2a8bffa..4ee648b241 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -677,7 +677,7 @@ class CapaModule(XModule): self.lcp.seed = None self.set_state_from_lcp() - self.lcp = self.new_lcp() + self.lcp = self.new_lcp(self.get_state_for_lcp()) event_info['new_state'] = self.lcp.get_state() self.system.track_function('reset_problem', event_info) From 7f8b79694c17cd8cbe1ed6c641809c77a1d8811e Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 11:12:58 -0500 Subject: [PATCH 048/501] Grade problems that have ungraded student modules --- lms/djangoapps/courseware/grades.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index b81147f905..0c6220e561 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -345,27 +345,28 @@ def get_score(course_id, user, problem_descriptor, module_creator, student_modul # These are not problems, and do not have a score return (None, None) - correct = 0.0 - instance_module = student_module_cache.lookup( course_id, problem_descriptor.category, problem_descriptor.location.url()) - if instance_module: - if instance_module.max_grade is None: - return (None, None) - + if instance_module is not None and instance_module.max_grade is not None: correct = instance_module.grade if instance_module.grade is not None else 0 total = instance_module.max_grade else: - # If the problem was not in the cache, we need to instantiate the problem. + # If the problem was not in the cache, or hasn't been graded yet, + # we need to instantiate the problem. # Otherwise, the max score (cached in instance_module) won't be available problem = module_creator(problem_descriptor) if problem is None: return (None, None) - correct = 0 + correct = 0.0 total = problem.max_score() + # Problem may be an error module (if something in the problem builder failed) + # In which case total might be None + if total is None: + return (None, None) + #Now we re-weight the problem, if specified weight = getattr(problem_descriptor, 'weight', None) if weight is not None: From 84cb0ce99bbca77efa9bd39bd3aa79c2b06c28a2 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 13:15:37 -0500 Subject: [PATCH 049/501] Move inheritance logic out into a separate file in the modulestore --- .../contentstore/tests/factories.py | 5 +- cms/djangoapps/contentstore/views.py | 5 +- .../models/settings/course_details.py | 3 +- .../xmodule/modulestore/inheritance.py | 48 ++++++++++++++ common/lib/xmodule/xmodule/modulestore/xml.py | 25 +------- .../xmodule/modulestore/xml_importer.py | 12 +--- common/lib/xmodule/xmodule/x_module.py | 63 ++----------------- common/lib/xmodule/xmodule/xml_module.py | 5 +- .../management/commands/metadata_to_json.py | 2 +- 9 files changed, 68 insertions(+), 100 deletions(-) create mode 100644 common/lib/xmodule/xmodule/modulestore/inheritance.py diff --git a/cms/djangoapps/contentstore/tests/factories.py b/cms/djangoapps/contentstore/tests/factories.py index 3274477098..24897bf795 100644 --- a/cms/djangoapps/contentstore/tests/factories.py +++ b/cms/djangoapps/contentstore/tests/factories.py @@ -1,6 +1,7 @@ from factory import Factory from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore +from xmodule.modulestore.inheritance import own_metadata from time import gmtime from uuid import uuid4 from xmodule.timeparse import stringify_time @@ -48,7 +49,7 @@ class XModuleCourseFactory(Factory): {"type": "progress", "name": "Progress"}] # Update the data in the mongo datastore - store.update_metadata(new_course.location.url(), new_course.own_metadata) + store.update_metadata(new_course.location.url(), own_metadata(new_course)) return new_course @@ -95,7 +96,7 @@ class XModuleItemFactory(Factory): if display_name is not None: new_item.metadata['display_name'] = display_name - store.update_metadata(new_item.location.url(), new_item.own_metadata) + store.update_metadata(new_item.location.url(), own_metadata(new_item)) if new_item.location.category not in DETACHED_CATEGORIES: store.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index bf706f2996..22b46dc4be 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -28,6 +28,7 @@ from django.conf import settings from xmodule.modulestore import Location from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError +from xmodule.modulestore.inheritance import own_metadata from xmodule.x_module import ModuleSystem from xmodule.error_module import ErrorDescriptor from xmodule.errortracker import exc_info_to_str @@ -712,7 +713,7 @@ def clone_item(request): if display_name is not None: new_item.metadata['display_name'] = display_name - get_modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata) + get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item)) if new_item.location.category not in DETACHED_CATEGORIES: get_modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) @@ -1231,7 +1232,7 @@ def initialize_course_tabs(course): {"type": "wiki", "name": "Wiki"}, {"type": "progress", "name": "Progress"}] - modulestore('direct').update_metadata(course.location.url(), course.own_metadata) + modulestore('direct').update_metadata(course.location.url(), own_metadata(new_course)) @ensure_csrf_cookie @login_required diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 2bb9d98be7..59cadf6962 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -1,6 +1,7 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore import Location from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore.inheritance import own_metadata import json from json.encoder import JSONEncoder import time @@ -117,7 +118,7 @@ class CourseDetails: descriptor.enrollment_end = converted if dirty: - get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor)) # NOTE: below auto writes to the db w/o verifying that any of the fields actually changed # to make faster, could compare against db or could have client send over a list of which fields changed. diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py new file mode 100644 index 0000000000..ca88aab8d8 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -0,0 +1,48 @@ +from xmodule.model import Scope + + +def compute_inherited_metadata(descriptor): + """Given a descriptor, traverse all of its descendants and do metadata + inheritance. Should be called on a CourseDescriptor after importing a + course. + + NOTE: This means that there is no such thing as lazy loading at the + moment--this accesses all the children.""" + for child in descriptor.get_children(): + inherit_metadata(child, descriptor._model_data) + compute_inherited_metadata(child) + + +def inherit_metadata(descriptor, model_data): + """ + Updates this module with metadata inherited from a containing module. + Only metadata specified in self.inheritable_metadata will + be inherited + """ + if not hasattr(descriptor, '_inherited_metadata'): + setattr(descriptor, '_inherited_metadata', set()) + + # Set all inheritable metadata from kwargs that are + # in self.inheritable_metadata and aren't already set in metadata + for attr in descriptor.inheritable_metadata: + if attr not in descriptor._model_data and attr in model_data: + descriptor._inherited_metadata.add(attr) + descriptor._model_data[attr] = model_data[attr] + + +def own_metadata(module): + """ + Return a dictionary that contains only non-inherited field keys, + mapped to their values + """ + inherited_metadata = getattr(module, '_inherited_metadata', {}) + metadata = {} + for field in module.fields + module.lms.fields: + # Only save metadata that wasn't inherited + if (field.scope == Scope.settings and + field.name not in inherited_metadata and + field.name in module._model_data): + + metadata[field.name] = module._model_data[field.name] + + return metadata diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 72c9093bbf..ed1bfdb30f 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -22,6 +22,7 @@ from xmodule.html_module import HtmlDescriptor from . import ModuleStoreBase, Location from .exceptions import ItemNotFoundError +from .inheritance import compute_inherited_metadata edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False, remove_comments=True, remove_blank_text=True) @@ -421,30 +422,6 @@ class XMLModuleStore(ModuleStoreBase): # breaks metadata inheritance via get_children(). Instead # (actually, in addition to, for now), we do a final inheritance pass # after we have the course descriptor. - def compute_inherited_metadata(descriptor): - """Given a descriptor, traverse all of its descendants and do metadata - inheritance. Should be called on a CourseDescriptor after importing a - course. - - NOTE: This means that there is no such thing as lazy loading at the - moment--this accesses all the children.""" - for child in descriptor.get_children(): - inherit_metadata(child, descriptor._model_data) - compute_inherited_metadata(child) - - def inherit_metadata(descriptor, model_data): - """ - Updates this module with metadata inherited from a containing module. - Only metadata specified in self.inheritable_metadata will - be inherited - """ - # Set all inheritable metadata from kwargs that are - # in self.inheritable_metadata and aren't already set in metadata - for attr in descriptor.inheritable_metadata: - if attr not in descriptor._model_data and attr in model_data: - descriptor._inherited_metadata.add(attr) - descriptor._model_data[attr] = model_data[attr] - compute_inherited_metadata(course_descriptor) # now import all pieces of course_info which is expected to be stored diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index bbbb60d8ed..356ca772f4 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -8,7 +8,7 @@ from .xml import XMLModuleStore from .exceptions import DuplicateItemError from xmodule.modulestore import Location from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX -from xmodule.model import Scope +from .inheritance import own_metadata log = logging.getLogger(__name__) @@ -143,15 +143,7 @@ def import_module_from_xml(modulestore, static_content_store, course_data_path, if module.has_children: modulestore.update_children(module.location, module.children) - metadata = {} - for field in module.fields + module.lms.fields: - # Only save metadata that wasn't inherited - if (field.scope == Scope.settings and - field.name not in module._inherited_metadata and - field.name in module._model_data): - - metadata[field.name] = module._model_data[field.name] - modulestore.update_metadata(module.location, metadata) + modulestore.update_metadata(module.location, own_metadata(module)) def import_course_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None): diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index a05a191e5f..43d6f58a16 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -114,43 +114,12 @@ class XModule(HTMLSnippet): location: Something Location-like that identifies this xmodule - definition: A dictionary containing 'data' and 'children'. Both are - optional - - 'data': is JSON-like (string, dictionary, list, bool, or None, - optionally nested). - - This defines all of the data necessary for a problem to display - that is intrinsic to the problem. It should not include any - data that would vary between two courses using the same problem - (due dates, grading policy, randomization, etc.) - - 'children': is a list of Location-like values for child modules that - this module depends on - descriptor: the XModuleDescriptor that this module is an instance of. TODO (vshnayder): remove the definition parameter and location--they can come from the descriptor. - instance_state: A string of serialized json that contains the state of - this module for current student accessing the system, or None if - no state has been saved - - shared_state: A string of serialized json that contains the state that - is shared between this module and any modules of the same type with - the same shared_state_key. This state is only shared per-student, - not across different students - - kwargs: Optional arguments. Subclasses should always accept kwargs and - pass them to the parent class constructor. - - Current known uses of kwargs: - - metadata: SCAFFOLDING - This dictionary will be split into - several different types of metadata in the future (course - policy, modification history, etc). A dictionary containing - data that specifies information that is particular to a - problem in the context of a course + model_data: A dictionary-like object that maps field names to values + for those fields. ''' self.system = system self.location = Location(location) @@ -357,31 +326,10 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): system: A DescriptorSystem for interacting with external resources - definition: A dict containing `data` and `children` representing the - problem definition + location: Something Location-like that identifies this xmodule - Current arguments passed in kwargs: - - location: A xmodule.modulestore.Location object indicating the name - and ownership of this problem - - shared_state_key: The key to use for sharing StudentModules with - other modules of this type - - metadata: A dictionary containing the following optional keys: - goals: A list of strings of learning goals associated with this - module - url_name: The name to use for this module in urls and other places - where a unique name is needed. - format: The format of this module ('Homework', 'Lab', etc) - graded (bool): Whether this module is should be graded or not - start (string): The date for which this module will be available - due (string): The due date for this module - graceperiod (string): The amount of grace period to allow when - enforcing the due date - showanswer (string): When to show answers for this module - rerandomize (string): When to generate a newly randomized - instance of the module data + model_data: A dictionary-like object that maps field names to values + for those fields. """ self.system = system self.location = Location(location) @@ -390,7 +338,6 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): self._model_data = model_data self._child_instances = None - self._inherited_metadata = set() self._child_instances = None def get_children(self): diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index 754d9b523e..d19fc26157 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -1,5 +1,6 @@ from xmodule.x_module import (XModuleDescriptor, policy_key) from xmodule.modulestore import Location +from xmodule.modulestore.inheritance import own_metadata from lxml import etree import json import copy @@ -368,10 +369,10 @@ class XmlDescriptor(XModuleDescriptor): (Possible format conversion through an AttrMap). """ attr_map = self.xml_attribute_map.get(attr, AttrMap()) - return attr_map.to_xml(self.own_metadata[attr]) + return attr_map.to_xml(self._model_data[attr]) # Add the non-inherited metadata - for attr in sorted(self.own_metadata): + for attr in sorted(own_metadata(self)): # don't want e.g. data_dir if attr not in self.metadata_to_strip and attr not in self.metadata_to_export_to_policy: val = val_for_xml(attr) diff --git a/lms/djangoapps/courseware/management/commands/metadata_to_json.py b/lms/djangoapps/courseware/management/commands/metadata_to_json.py index 8ef0dee7b3..3d399ef115 100644 --- a/lms/djangoapps/courseware/management/commands/metadata_to_json.py +++ b/lms/djangoapps/courseware/management/commands/metadata_to_json.py @@ -51,7 +51,7 @@ def node_metadata(node): 'start', 'due', 'graded', 'hide_from_toc', 'ispublic', 'xqa_key') - orig = node.own_metadata + orig = own_metadata(node) d = {k: orig[k] for k in to_export if k in orig} return d From 6ece7fd541fa4763390dfb54c53db36f0edf5989 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 13:15:58 -0500 Subject: [PATCH 050/501] Fieldify the video module --- common/lib/xmodule/xmodule/video_module.py | 81 +++++++++++++--------- 1 file changed, 48 insertions(+), 33 deletions(-) diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 34ce353afd..ecc7d7fdad 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -28,41 +28,13 @@ class VideoModule(XModule): css = {'scss': [resource_string(__name__, 'css/video/display.scss')]} js_module_name = "Video" - data = String(help="XML data for the problem", scope=Scope.content) - position = Int(help="Current position in the video", scope=Scope.student_state) + youtube = String(help="Youtube ids for each speed, in the format :[,: ...]", scope=Scope.content) + show_captions = String(help="Whether to display captions with this video", scope=Scope.content) + source = String(help="External source for this video", scope=Scope.content) + track = String(help="Subtitle file", scope=Scope.content) + position = Int(help="Current position in the video", scope=Scope.student_state, default=0) display_name = String(help="Display name for this module", scope=Scope.settings) - def __init__(self, *args, **kwargs): - XModule.__init__(self, *args, **kwargs) - - xmltree = etree.fromstring(self.data) - self.youtube = xmltree.get('youtube') - self.position = 0 - self.show_captions = xmltree.get('show_captions', 'true') - self.source = self._get_source(xmltree) - self.track = self._get_track(xmltree) - - def _get_source(self, xmltree): - # find the first valid source - return self._get_first_external(xmltree, 'source') - - def _get_track(self, xmltree): - # find the first valid track - return self._get_first_external(xmltree, 'track') - - def _get_first_external(self, xmltree, tag): - """ - Will return the first valid element - of the given tag. - 'valid' means has a non-empty 'src' attribute - """ - result = None - for element in xmltree.findall(tag): - src = element.get('src') - if src: - result = src - break - return result def handle_ajax(self, dispatch, get): ''' @@ -115,7 +87,50 @@ class VideoModule(XModule): }) + class VideoDescriptor(RawDescriptor): module_class = VideoModule stores_state = True template_dir_name = "video" + + youtube = String(help="Youtube ids for each speed, in the format :[,: ...]", scope=Scope.content) + show_captions = String(help="Whether to display captions with this video", scope=Scope.content) + source = String(help="External source for this video", scope=Scope.content) + track = String(help="Subtitle file", scope=Scope.content) + + @classmethod + def definition_from_xml(cls, xml_object, system): + return { + 'youtube': xml_object.get('youtube'), + 'show_captions': xml_object.get('show_captions', 'true'), + 'source': _get_first_external(xml_object, 'source'), + 'track': _get_first_external(xml_object, 'track'), + }, [] + + def definition_to_xml(self, resource_fs): + xml_object = etree.Element('video', { + 'youtube': self.youtube, + 'show_captions': self.show_captions, + }) + + if self.source is not None: + SubElement(xml_object, 'source', {'src': self.source}) + + if self.track is not None: + SubElement(xml_object, 'track', {'src': self.track}) + + return xml_object + +def _get_first_external(xmltree, tag): + """ + Will return the first valid element + of the given tag. + 'valid' means has a non-empty 'src' attribute + """ + result = None + for element in xmltree.findall(tag): + src = element.get('src') + if src: + result = src + break + return result From 16d5a769d2317c43641f68a3b6d4734ae3245e90 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 13:21:31 -0500 Subject: [PATCH 051/501] Add a fasttest rake command that runs both fasttest_cms and fasttest_lms --- rakefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rakefile b/rakefile index adf16cc462..312ad90124 100644 --- a/rakefile +++ b/rakefile @@ -193,6 +193,8 @@ TEST_TASK_DIRS = [] run_tests(system, report_dir, args.stop_on_failure) end + task :fasttest => "fasttest_#{system}" + TEST_TASK_DIRS << system desc <<-desc From fa75245e8a6f2358d24398c79eeb7327ee6668a0 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 13:22:53 -0500 Subject: [PATCH 052/501] WIP: Start cleaning up CMS to work with new field format --- .../contentstore/tests/factories.py | 12 ++-- cms/djangoapps/contentstore/views.py | 14 ++--- .../models/settings/course_details.py | 8 +-- .../models/settings/course_grading.py | 26 ++++++--- common/lib/xmodule/xmodule/capa_module.py | 56 +------------------ common/lib/xmodule/xmodule/fields.py | 39 +++++++++++++ common/lib/xmodule/xmodule/html_module.py | 4 +- .../xmodule/xmodule/self_assessment_module.py | 6 +- .../lib/xmodule/xmodule/tests/test_export.py | 15 +---- lms/xmodule_namespace.py | 3 +- 10 files changed, 79 insertions(+), 104 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/factories.py b/cms/djangoapps/contentstore/tests/factories.py index 24897bf795..1ae300daed 100644 --- a/cms/djangoapps/contentstore/tests/factories.py +++ b/cms/djangoapps/contentstore/tests/factories.py @@ -38,10 +38,9 @@ class XModuleCourseFactory(Factory): # This metadata code was copied from cms/djangoapps/contentstore/views.py if display_name is not None: - new_course.metadata['display_name'] = display_name + new_course.lms.display_name = display_name - new_course.metadata['data_dir'] = uuid4().hex - new_course.metadata['start'] = stringify_time(gmtime()) + new_course.start = gmtime() new_course.tabs = [{"type": "courseware"}, {"type": "course_info", "name": "Course Info"}, {"type": "discussion", "name": "Discussion"}, @@ -89,17 +88,14 @@ class XModuleItemFactory(Factory): new_item = store.clone_item(template, dest_location) - # TODO: This needs to be deleted when we have proper storage for static content - new_item.metadata['data_dir'] = parent.metadata['data_dir'] - # replace the display name with an optional parameter passed in from the caller if display_name is not None: - new_item.metadata['display_name'] = display_name + new_item.lms.display_name = display_name store.update_metadata(new_item.location.url(), own_metadata(new_item)) if new_item.location.category not in DETACHED_CATEGORIES: - store.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) + store.update_children(parent_location, parent.children + [new_item.location.url()]) return new_item diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 22b46dc4be..809e43dea3 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -706,17 +706,14 @@ def clone_item(request): new_item = get_modulestore(template).clone_item(template, dest_location) - # TODO: This needs to be deleted when we have proper storage for static content - new_item.metadata['data_dir'] = parent.metadata['data_dir'] - # replace the display name with an optional parameter passed in from the caller if display_name is not None: - new_item.metadata['display_name'] = display_name + new_item.lms.display_name = display_name get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item)) if new_item.location.category not in DETACHED_CATEGORIES: - get_modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()]) + get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()]) return HttpResponse(json.dumps({'id': dest_location.url()})) @@ -1206,13 +1203,10 @@ def create_new_course(request): new_course = modulestore('direct').clone_item(template, dest_location) if display_name is not None: - new_course.metadata['display_name'] = display_name - - # we need a 'data_dir' for legacy reasons - new_course.metadata['data_dir'] = uuid4().hex + new_course.display_name = display_name # set a default start date to now - new_course.metadata['start'] = stringify_time(time.gmtime()) + new_course.start = time.gmtime() initialize_course_tabs(new_course) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 59cadf6962..b1250862f6 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -44,25 +44,25 @@ class CourseDetails: temploc = course_location._replace(category='about', name='syllabus') try: - course.syllabus = get_modulestore(temploc).get_item(temploc).definition['data'] + course.syllabus = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass temploc = temploc._replace(name='overview') try: - course.overview = get_modulestore(temploc).get_item(temploc).definition['data'] + course.overview = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass temploc = temploc._replace(name='effort') try: - course.effort = get_modulestore(temploc).get_item(temploc).definition['data'] + course.effort = get_modulestore(temploc).get_item(temploc).data except ItemNotFoundError: pass temploc = temploc._replace(name='video') try: - raw_video = get_modulestore(temploc).get_item(temploc).definition['data'] + raw_video = get_modulestore(temploc).get_item(temploc).data course.intro_video = CourseDetails.parse_video_tag(raw_video) except ItemNotFoundError: pass diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index e0bab1f225..9ddbe87727 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -201,7 +201,7 @@ class CourseGradingModel: course_location = Location(course_location) descriptor = get_modulestore(course_location).get_item(course_location) - if 'graceperiod' in descriptor.metadata: del descriptor.metadata['graceperiod'] + if 'graceperiod' in descriptor.metadata: del descriptor.metadata['graceperiod'] get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) @staticmethod @@ -226,20 +226,30 @@ class CourseGradingModel: descriptor.metadata['format'] = jsondict.get('graderType') descriptor.metadata['graded'] = True else: - if 'format' in descriptor.metadata: del descriptor.metadata['format'] - if 'graded' in descriptor.metadata: del descriptor.metadata['graded'] + if 'format' in descriptor.metadata: del descriptor.metadata['format'] + if 'graded' in descriptor.metadata: del descriptor.metadata['graded'] - get_modulestore(location).update_metadata(location, descriptor.metadata) + get_modulestore(location).update_metadata(location, descriptor.metadata) @staticmethod def convert_set_grace_period(descriptor): # 5 hours 59 minutes 59 seconds => converted to iso format - rawgrace = descriptor.metadata.get('graceperiod', None) + rawgrace = descriptor.lms.graceperiod if rawgrace: - parsedgrace = {str(key): val for (val, key) in re.findall('\s*(\d+)\s*(\w+)', rawgrace)} - return parsedgrace - else: return None + hours_from_day = rawgrace.days*24 + seconds = rawgrace.seconds + hours_from_seconds = int(seconds / 3600) + seconds -= hours_from_seconds * 3600 + minutes = int(seconds / 60) + seconds -= minutes * 60 + return { + 'hours': hourse_from_days + hours_from_seconds, + 'minutes': minutes_from_seconds, + 'seconds': seconds, + } + else: + return None @staticmethod def parse_grader(json_grader): diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 4ee648b241..f83c31fb5c 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -5,10 +5,8 @@ import dateutil.parser import json import logging import traceback -import re import sys -from datetime import timedelta from lxml import etree from pkg_resources import resource_string @@ -20,6 +18,9 @@ from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor from xmodule.exceptions import NotFoundError from .model import Int, Scope, ModuleScope, ModelType, String, Boolean, Object, Float +from .fields import Timedelta + +log = logging.getLogger("mitx.courseware") class StringyInt(Int): @@ -31,57 +32,6 @@ class StringyInt(Int): return int(value) return value -log = logging.getLogger("mitx.courseware") - -#----------------------------------------------------------------------------- -TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') - - -def only_one(lst, default="", process=lambda x: x): - """ - If lst is empty, returns default - - If lst has a single element, applies process to that element and returns it. - - Otherwise, raises an exception. - """ - if len(lst) == 0: - return default - elif len(lst) == 1: - return process(lst[0]) - else: - raise Exception('Malformed XML: expected at most one element in list.') - - -class Timedelta(ModelType): - def from_json(self, time_str): - """ - time_str: A string with the following components: - day[s] (optional) - hour[s] (optional) - minute[s] (optional) - second[s] (optional) - - Returns a datetime.timedelta parsed from the string - """ - parts = TIMEDELTA_REGEX.match(time_str) - if not parts: - return - parts = parts.groupdict() - time_params = {} - for (name, param) in parts.iteritems(): - if param: - time_params[name] = int(param) - return timedelta(**time_params) - - def to_json(self, value): - values = [] - for attr in ('days', 'hours', 'minutes', 'seconds'): - cur_value = getattr(value, attr, 0) - if cur_value > 0: - values.append("%d %s" % (cur_value, attr)) - return ' '.join(values) - class Randomization(String): def from_json(self, value): diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py index 715aea0c7c..21c360f914 100644 --- a/common/lib/xmodule/xmodule/fields.py +++ b/common/lib/xmodule/xmodule/fields.py @@ -1,6 +1,8 @@ import time import logging +import re +from datetime import timedelta from .model import ModelType log = logging.getLogger(__name__) @@ -15,6 +17,9 @@ class Date(ModelType): if it doesn't parse. Return None if not present or invalid. """ + if value is None: + return None + try: return time.strptime(value, self.time_format) except ValueError as e: @@ -27,4 +32,38 @@ class Date(ModelType): """ Convert a time struct to a string """ + if value is None: + return None + return time.strftime(self.time_format, value) + + +TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) hour(?:s?))?(\s)?((?P\d+?) minute(?:s)?)?(\s)?((?P\d+?) second(?:s)?)?$') +class Timedelta(ModelType): + def from_json(self, time_str): + """ + time_str: A string with the following components: + day[s] (optional) + hour[s] (optional) + minute[s] (optional) + second[s] (optional) + + Returns a datetime.timedelta parsed from the string + """ + parts = TIMEDELTA_REGEX.match(time_str) + if not parts: + return + parts = parts.groupdict() + time_params = {} + for (name, param) in parts.iteritems(): + if param: + time_params[name] = int(param) + return timedelta(**time_params) + + def to_json(self, value): + values = [] + for attr in ('days', 'hours', 'minutes', 'seconds'): + cur_value = getattr(value, attr, 0) + if cur_value > 0: + values.append("%d %s" % (cur_value, attr)) + return ' '.join(values) \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 6ec1061451..55155810e9 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -149,7 +149,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): string to filename.html. ''' try: - return etree.fromstring(self.definition['data']) + return etree.fromstring(self.data) except etree.XMLSyntaxError: pass @@ -161,7 +161,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor): resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True) with resource_fs.open(filepath, 'w') as file: - file.write(self.definition['data']) + file.write(self.data) # write out the relative name relname = path(pathname).basename() diff --git a/common/lib/xmodule/xmodule/self_assessment_module.py b/common/lib/xmodule/xmodule/self_assessment_module.py index ca6eae9913..034ec01253 100644 --- a/common/lib/xmodule/xmodule/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/self_assessment_module.py @@ -18,13 +18,11 @@ from progress import Progress from pkg_resources import resource_string -from .capa_module import only_one, ComplexEncoder +from .capa_module import ComplexEncoder from .editing_module import EditingDescriptor -from .html_checker import check_html from .stringify import stringify_children from .x_module import XModule from .xml_module import XmlDescriptor -from xmodule.modulestore import Location from .model import List, String, Scope, Int log = logging.getLogger("mitx.courseware") @@ -436,7 +434,7 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor): elt = etree.Element('selfassessment') def add_child(k): - child_str = '<{tag}>{body}'.format(tag=k, body=self.definition[k]) + child_str = '<{tag}>{body}'.format(tag=k, body=getattr(self, k)) child_node = etree.fromstring(child_str) elt.append(child_node) diff --git a/common/lib/xmodule/xmodule/tests/test_export.py b/common/lib/xmodule/xmodule/tests/test_export.py index aeebc6da6b..c6ea617d70 100644 --- a/common/lib/xmodule/xmodule/tests/test_export.py +++ b/common/lib/xmodule/xmodule/tests/test_export.py @@ -18,21 +18,12 @@ TEST_DIR = TEST_DIR / 'test' DATA_DIR = TEST_DIR / 'data' -def strip_metadata(descriptor, key): - """ - Recursively strips tag from all children. - """ - print "strip {key} from {desc}".format(key=key, desc=descriptor.location.url()) - descriptor.metadata.pop(key, None) - for d in descriptor.get_children(): - strip_metadata(d, key) - def strip_filenames(descriptor): """ Recursively strips 'filename' from all children's definitions. """ print "strip filename from {desc}".format(desc=descriptor.location.url()) - descriptor.definition.pop('filename', None) + descriptor._model_data.pop('filename', None) for d in descriptor.get_children(): strip_filenames(d) @@ -73,10 +64,6 @@ class RoundTripTestCase(unittest.TestCase): exported_course = courses2[0] print "Checking course equality" - # HACK: data_dir metadata tags break equality because they - # aren't real metadata, and depend on paths. Remove them. - strip_metadata(initial_course, 'data_dir') - strip_metadata(exported_course, 'data_dir') # HACK: filenames change when changing file formats # during imports from old-style courses. Ignore them. diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index 3a72a64dff..81a84edeaa 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -1,5 +1,5 @@ from xmodule.model import Namespace, Boolean, Scope, String, List -from xmodule.fields import Date +from xmodule.fields import Date, Timedelta class StringyBoolean(Boolean): @@ -39,3 +39,4 @@ class LmsNamespace(Namespace): giturl = String(help="DO NOT USE", scope=Scope.settings, default='https://github.com/MITx') xqa_key = String(help="DO NOT USE", scope=Scope.settings) ispublic = Boolean(help="Whether this course is open to the public, or only to admins", scope=Scope.settings) + graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) From 265a2e7630addb6facfc170cde85eee464d4d4ed Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 13:23:09 -0500 Subject: [PATCH 053/501] Give children the None scope --- common/lib/xmodule/xmodule/model.py | 2 +- common/lib/xmodule/xmodule/tests/test_model.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/model.py b/common/lib/xmodule/xmodule/model.py index 380c81dbe9..9d3e96534e 100644 --- a/common/lib/xmodule/xmodule/model.py +++ b/common/lib/xmodule/xmodule/model.py @@ -121,7 +121,7 @@ class ParentModelMetaclass(type): """ def __new__(cls, name, bases, attrs): if attrs.get('has_children', False): - attrs['children'] = List(help='The children of this XModule', default=[], scope=Scope.settings) + attrs['children'] = List(help='The children of this XModule', default=[], scope=None) else: attrs['has_children'] = False diff --git a/common/lib/xmodule/xmodule/tests/test_model.py b/common/lib/xmodule/xmodule/tests/test_model.py index b0ae9e5844..20f1207701 100644 --- a/common/lib/xmodule/xmodule/tests/test_model.py +++ b/common/lib/xmodule/xmodule/tests/test_model.py @@ -36,7 +36,7 @@ def test_parent_metaclass(): assert not hasattr(WithoutChildren, 'children') assert isinstance(HasChildren.children, List) - assert_equals(Scope.settings, HasChildren.children.scope) + assert_equals(None, HasChildren.children.scope) def test_field_access(): From 2509308ce924e71c89e11a461726becadc045f11 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 13:23:21 -0500 Subject: [PATCH 054/501] Remove reference to shared_state_key --- lms/djangoapps/courseware/models.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index d6161e9f34..3bfb29fd5a 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -282,16 +282,7 @@ class StudentModuleCache(object): descriptor_filter is a function that accepts a descriptor and return wether the StudentModule should be cached ''' - keys = [] - for descriptor in descriptors: - if descriptor.stores_state: - keys.append(descriptor.location.url()) - - shared_state_key = getattr(descriptor, 'shared_state_key', None) - if shared_state_key is not None: - keys.append(shared_state_key) - - return keys + return [descriptor.location.url() for descriptor in descriptors if descriptor.stores_state] def lookup(self, course_id, module_type, module_state_key): ''' From 6a612a6e024c3fd1b091c72301f7387861507d9d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 13:23:34 -0500 Subject: [PATCH 055/501] Trim down list of equality attributes for descriptors --- common/lib/xmodule/xmodule/x_module.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 43d6f58a16..3fc5cb538e 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -303,8 +303,7 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): # A list of descriptor attributes that must be equal for the descriptors to # be equal - equality_attributes = ('definition', 'metadata', 'location', - 'shared_state_key', '_inherited_metadata') + equality_attributes = ('_model_data', 'location') # Name of resource directory to load templates from template_dir_name = "default" From 344cb133aab2c55cf61144e433ee417fa090a368 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 14:16:56 -0500 Subject: [PATCH 056/501] Make import/export tests pass --- common/lib/xmodule/xmodule/capa_module.py | 16 ++++++++-------- .../xmodule/xmodule/modulestore/inheritance.py | 11 ++++++++++- .../xmodule/xmodule/self_assessment_module.py | 12 ++++++------ .../lib/xmodule/xmodule/tests/test_import.py | 2 ++ common/lib/xmodule/xmodule/x_module.py | 18 ++++++------------ common/lib/xmodule/xmodule/xml_module.py | 18 ++++++++++++++++-- lms/xmodule_namespace.py | 3 +++ 7 files changed, 51 insertions(+), 29 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index f83c31fb5c..7e66ef534e 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -61,7 +61,7 @@ class CapaModule(XModule): max_attempts = StringyInt(help="Maximum number of attempts that a student is allowed", scope=Scope.settings) due = String(help="Date that this problem is due by", scope=Scope.settings) graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) - show_answer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed") + showanswer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed") force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings, default=False) rerandomize = Randomization(help="When to rerandomize the problem", default="always", scope=Scope.settings) data = String(help="XML data for the problem", scope=Scope.content) @@ -359,26 +359,26 @@ class CapaModule(XModule): def answer_available(self): ''' Is the user allowed to see an answer? ''' - if self.show_answer == '': + if self.showanswer == '': return False - if self.show_answer == "never": + if self.showanswer == "never": return False # Admins can see the answer, unless the problem explicitly prevents it if self.system.user_is_staff: return True - if self.show_answer == 'attempted': + if self.showanswer == 'attempted': return self.attempts > 0 - if self.show_answer == 'answered': + if self.showanswer == 'answered': return self.done - if self.show_answer == 'closed': + if self.showanswer == 'closed': return self.closed() - if self.show_answer == 'always': + if self.showanswer == 'always': return True return False @@ -409,7 +409,7 @@ class CapaModule(XModule): ''' event_info = dict() event_info['problem_id'] = self.location.url() - self.system.track_function('show_answer', event_info) + self.system.track_function('showanswer', event_info) if not self.answer_available(): raise NotFoundError('Answer is not available') else: diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index ca88aab8d8..38c592564d 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -1,5 +1,14 @@ from xmodule.model import Scope +# A list of metadata that this module can inherit from its parent module +INHERITABLE_METADATA = ( + 'graded', 'start', 'due', 'graceperiod', 'showanswer', 'rerandomize', + # TODO (ichuang): used for Fall 2012 xqa server access + 'xqa_key', + # TODO: This is used by the XMLModuleStore to provide for locations for + # static files, and will need to be removed when that code is removed + 'data_dir' +) def compute_inherited_metadata(descriptor): """Given a descriptor, traverse all of its descendants and do metadata @@ -24,7 +33,7 @@ def inherit_metadata(descriptor, model_data): # Set all inheritable metadata from kwargs that are # in self.inheritable_metadata and aren't already set in metadata - for attr in descriptor.inheritable_metadata: + for attr in INHERITABLE_METADATA: if attr not in descriptor._model_data and attr in model_data: descriptor._inherited_metadata.add(attr) descriptor._model_data[attr] = model_data[attr] diff --git a/common/lib/xmodule/xmodule/self_assessment_module.py b/common/lib/xmodule/xmodule/self_assessment_module.py index 034ec01253..ffcd86ba52 100644 --- a/common/lib/xmodule/xmodule/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/self_assessment_module.py @@ -73,8 +73,8 @@ class SelfAssessmentModule(XModule): max_attempts = Int(scope=Scope.settings, default=MAX_ATTEMPTS) rubric = String(scope=Scope.content) prompt = String(scope=Scope.content) - submit_message = String(scope=Scope.content) - hint_prompt = String(scope=Scope.content) + submitmessage = String(scope=Scope.content) + hintprompt = String(scope=Scope.content) def _allow_reset(self): """Can the module be reset?""" @@ -209,7 +209,7 @@ class SelfAssessmentModule(XModule): else: hint = '' - context = {'hint_prompt': self.hint_prompt, + context = {'hint_prompt': self.hintprompt, 'hint': hint} if self.state == self.REQUEST_HINT: @@ -228,7 +228,7 @@ class SelfAssessmentModule(XModule): if self.state != self.DONE: return "" - return """
{0}
""".format(self.submit_message) + return """
{0}
""".format(self.submitmessage) def save_answer(self, get): @@ -397,8 +397,8 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor): max_attempts = Int(scope=Scope.settings, default=MAX_ATTEMPTS) rubric = String(scope=Scope.content) prompt = String(scope=Scope.content) - submit_message = String(scope=Scope.content) - hint_prompt = String(scope=Scope.content) + submitmessage = String(scope=Scope.content) + hintprompt = String(scope=Scope.content) @classmethod def definition_from_xml(cls, xml_object, system): diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index d243ca1609..803aceef68 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -12,6 +12,7 @@ from xmodule.errortracker import make_error_tracker from xmodule.modulestore import Location from xmodule.modulestore.xml import ImportSystem, XMLModuleStore from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.modulestore.inheritance import compute_inherited_metadata from .test_export import DATA_DIR @@ -134,6 +135,7 @@ class ImportTestCase(unittest.TestCase): '''.format(due=v, org=ORG, course=COURSE, url_name=url_name) descriptor = system.process_xml(start_xml) + compute_inherited_metadata(descriptor) print descriptor, descriptor._model_data self.assertEqual(descriptor.lms.due, v) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 3fc5cb538e..dc1be3eb08 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -281,21 +281,15 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): __metaclass__ = XModuleMetaclass # Attributes for inspection of the descriptor - stores_state = False # Indicates whether the xmodule state should be + + # Indicates whether the xmodule state should be # stored in a database (independent of shared state) - has_score = False # This indicates whether the xmodule is a problem-type. + stores_state = False + + # This indicates whether the xmodule is a problem-type. # It should respond to max_score() and grade(). It can be graded or ungraded # (like a practice problem). - - # A list of metadata that this module can inherit from its parent module - inheritable_metadata = ( - 'graded', 'start', 'due', 'graceperiod', 'showanswer', 'rerandomize', - # TODO (ichuang): used for Fall 2012 xqa server access - 'xqa_key', - # TODO: This is used by the XMLModuleStore to provide for locations for - # static files, and will need to be removed when that code is removed - 'data_dir' - ) + has_score = False # cdodge: this is a list of metadata names which are 'system' metadata # and should not be edited by an end-user diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index d19fc26157..a7c1087db7 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -1,6 +1,7 @@ from xmodule.x_module import (XModuleDescriptor, policy_key) from xmodule.modulestore import Location from xmodule.modulestore.inheritance import own_metadata +from xmodule.model import Object, Scope from lxml import etree import json import copy @@ -78,6 +79,8 @@ class XmlDescriptor(XModuleDescriptor): Mixin class for standardized parsing of from xml """ + xml_attributes = Object(help="Map of unhandled xml attributes, used only for storage between import and export", default={}, scope=Scope.settings) + # Extension to append to filename paths filename_extension = 'xml' @@ -102,7 +105,9 @@ class XmlDescriptor(XModuleDescriptor): 'tabs', 'grading_policy', 'is_draft', 'published_by', 'published_date', 'discussion_blackouts', # VS[compat] -- remove the below attrs once everything is in the CMS - 'course', 'org', 'url_name', 'filename') + 'course', 'org', 'url_name', 'filename', + # Used for storing xml attributes between import and export, for roundtrips + 'xml_attributes') metadata_to_export_to_policy = ('discussion_topics') @@ -319,6 +324,11 @@ class XmlDescriptor(XModuleDescriptor): model_data.update(definition) model_data['children'] = children + model_data['xml_attributes'] = {} + for key, value in metadata.items(): + if key not in set(f.name for f in cls.fields + cls.lms.fields): + model_data['xml_attributes'][key] = value + return cls( system, location, @@ -343,7 +353,7 @@ class XmlDescriptor(XModuleDescriptor): def export_to_xml(self, resource_fs): """ - Returns an xml string representing this module, and all modules + Returns an xml string representign this module, and all modules underneath it. May also write required resources out to resource_fs Assumes that modules have single parentage (that no module appears twice @@ -379,6 +389,10 @@ class XmlDescriptor(XModuleDescriptor): #logging.debug('location.category = {0}, attr = {1}'.format(self.location.category, attr)) xml_object.set(attr, val) + for key, value in self.xml_attributes.items(): + if key not in self.metadata_to_strip: + xml_object.set(key, value) + if self.export_to_file(): # Write the definition to a file url_path = name_to_pathname(self.url_name) diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index 81a84edeaa..14a19c6714 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -40,3 +40,6 @@ class LmsNamespace(Namespace): xqa_key = String(help="DO NOT USE", scope=Scope.settings) ispublic = Boolean(help="Whether this course is open to the public, or only to admins", scope=Scope.settings) graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) + showanswer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed") + rerandomize = String(help="When to rerandomize the problem", default="always", scope=Scope.settings) + From 85e109da5793e8163ef8f883612f2b2e2952fb20 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 21 Dec 2012 14:31:53 -0500 Subject: [PATCH 057/501] Allow field scope of None --- common/lib/xmodule/xmodule/runtime.py | 30 +++++++++++++++------------ 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/common/lib/xmodule/xmodule/runtime.py b/common/lib/xmodule/xmodule/runtime.py index 6f20997894..6dee5b0b00 100644 --- a/common/lib/xmodule/xmodule/runtime.py +++ b/common/lib/xmodule/xmodule/runtime.py @@ -59,21 +59,25 @@ class DbModel(MutableMapping): def _key(self, name): field = self._getfield(name) - module = field.scope.module - - if module == ModuleScope.ALL: + if field.scope is None: module_id = None - elif module == ModuleScope.USAGE: - module_id = self._usage.id - elif module == ModuleScope.DEFINITION: - module_id = self._usage.def_id - elif module == ModuleScope.TYPE: - module_id = self._module_cls.__name__ - - if field.scope.student: - student_id = self._student_id - else: student_id = None + else: + module = field.scope.module + + if module == ModuleScope.ALL: + module_id = None + elif module == ModuleScope.USAGE: + module_id = self._usage.id + elif module == ModuleScope.DEFINITION: + module_id = self._usage.def_id + elif module == ModuleScope.TYPE: + module_id = self._module_cls.__name__ + + if field.scope.student: + student_id = self._student_id + else: + student_id = None key = KeyValueStore.Key( scope=field.scope, From 6427dd6742a6da2dc3a834c4e575f38dd125bdb5 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 28 Dec 2012 14:33:33 -0500 Subject: [PATCH 058/501] WIP: Get the cms running. Component previews work --- cms/djangoapps/contentstore/utils.py | 4 +- cms/djangoapps/contentstore/views.py | 123 +++++++++--------- .../models/settings/course_grading.py | 6 +- cms/templates/edit_subsection.html | 10 +- cms/templates/overview.html | 4 +- cms/xmodule_namespace.py | 20 +++ common/lib/xmodule/xmodule/editing_module.py | 5 +- common/lib/xmodule/xmodule/error_module.py | 2 +- common/lib/xmodule/xmodule/mako_module.py | 4 +- .../lib/xmodule/xmodule/modulestore/draft.py | 16 +-- .../xmodule/modulestore/inheritance.py | 7 +- .../lib/xmodule/xmodule/modulestore/mongo.py | 79 ++++++++++- common/lib/xmodule/xmodule/runtime.py | 11 ++ common/lib/xmodule/xmodule/video_module.py | 81 +++++------- lms/djangoapps/courseware/model_data.py | 5 +- setup.py | 3 +- 16 files changed, 241 insertions(+), 139 deletions(-) create mode 100644 cms/xmodule_namespace.py diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index d6dcc3811d..cb42c5a1fc 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -122,7 +122,7 @@ def compute_unit_state(unit): 'private' content is editabled and not visible in the LMS """ - if unit.metadata.get('is_draft', False): + if unit.cms.is_draft: try: modulestore('direct').get_item(unit.location) return UnitState.draft @@ -142,4 +142,4 @@ def update_item(location, value): if value is None: get_modulestore(location).delete_item(location) else: - get_modulestore(location).update_item(location, value) \ No newline at end of file + get_modulestore(location).update_item(location, value) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 809e43dea3..a6957e6107 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -29,11 +29,14 @@ from django.conf import settings from xmodule.modulestore import Location from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError from xmodule.modulestore.inheritance import own_metadata +from xmodule.model import Scope +from xmodule.runtime import KeyValueStore, DbModel, InvalidScopeError from xmodule.x_module import ModuleSystem from xmodule.error_module import ErrorDescriptor from xmodule.errortracker import exc_info_to_str from static_replace import replace_urls from external_auth.views import ssl_login_shortcut +from xmodule.modulestore.mongo import MongoUsage from mitxmako.shortcuts import render_to_response, render_to_string from xmodule.modulestore.django import modulestore @@ -214,8 +217,13 @@ def edit_subsection(request, location): # remove all metadata from the generic dictionary that is presented in a more normalized UI - policy_metadata = dict((key,value) for key, value in item.metadata.iteritems() - if key not in ['display_name', 'start', 'due', 'format'] and key not in item.system_metadata_fields) + policy_metadata = dict( + (key,value) + for field + in item.fields + if field.name not in ['display_name', 'start', 'due', 'format'] and + field.scope == Scope.settings + ) can_view_live = False subsection_units = item.get_children() @@ -312,11 +320,6 @@ def edit_unit(request, location): unit_state = compute_unit_state(item) - try: - published_date = time.strftime('%B %d, %Y', item.metadata.get('published_date')) - except TypeError: - published_date = None - return render_to_response('unit.html', { 'context_course': course, 'active_tab': 'courseware', @@ -327,11 +330,11 @@ def edit_unit(request, location): 'draft_preview_link': preview_lms_link, 'published_preview_link': lms_link, 'subsection': containing_subsection, - 'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.start))) if containing_subsection.start is not None else None, + 'release_date': get_date_display(datetime.fromtimestamp(time.mktime(containing_subsection.lms.start))) if containing_subsection.lms.start is not None else None, 'section': containing_section, 'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'), 'unit_state': unit_state, - 'published_date': published_date, + 'published_date': item.cms.published_date.strftime('%B %d, %Y') if item.cms.published_date is not None else None, }) @@ -395,9 +398,8 @@ def preview_dispatch(request, preview_id, location, dispatch=None): dispatch: The action to execute """ - instance_state, shared_state = load_preview_state(request, preview_id, location) descriptor = modulestore().get_item(location) - instance = load_preview_module(request, preview_id, descriptor, instance_state, shared_state) + instance = load_preview_module(request, preview_id, descriptor) # Let the module handle the AJAX try: ajax_return = instance.handle_ajax(dispatch, request.POST) @@ -408,42 +410,11 @@ def preview_dispatch(request, preview_id, location, dispatch=None): log.exception("error processing ajax call") raise - save_preview_state(request, preview_id, location, instance.get_instance_state(), instance.get_shared_state()) + print request.session.items() + return HttpResponse(ajax_return) -def load_preview_state(request, preview_id, location): - """ - Load the state of a preview module from the request - - preview_id (str): An identifier specifying which preview this module is used for - location: The Location of the module to dispatch to - """ - if 'preview_states' not in request.session: - request.session['preview_states'] = defaultdict(dict) - - instance_state = request.session['preview_states'][preview_id, location].get('instance') - shared_state = request.session['preview_states'][preview_id, location].get('shared') - - return instance_state, shared_state - - -def save_preview_state(request, preview_id, location, instance_state, shared_state): - """ - Save the state of a preview module to the request - - preview_id (str): An identifier specifying which preview this module is used for - location: The Location of the module to dispatch to - instance_state: The instance state to save - shared_state: The shared state to save - """ - if 'preview_states' not in request.session: - request.session['preview_states'] = defaultdict(dict) - - request.session['preview_states'][preview_id, location]['instance'] = instance_state - request.session['preview_states'][preview_id, location]['shared'] = shared_state - - def render_from_lms(template_name, dictionary, context=None, namespace='main'): """ Render a template using the LMS MAKO_TEMPLATES @@ -451,6 +422,30 @@ def render_from_lms(template_name, dictionary, context=None, namespace='main'): return render_to_string(template_name, dictionary, context, namespace="lms." + namespace) +class SessionKeyValueStore(KeyValueStore): + def __init__(self, request, model_data): + self._model_data = model_data + self._session = request.session + + def get(self, key): + try: + return self._model_data[key.field_name] + except (KeyError, InvalidScopeError): + return self._session[tuple(key)] + + def set(self, key, value): + try: + self._model_data[key.field_name] = value + except (KeyError, InvalidScopeError): + self._session[tuple(key)] = value + + def delete(self, key): + try: + del self._model_data[key.field_name] + except (KeyError, InvalidScopeError): + del self._session[tuple(key)] + + def preview_module_system(request, preview_id, descriptor): """ Returns a ModuleSystem for the specified descriptor that is specialized for @@ -461,6 +456,14 @@ def preview_module_system(request, preview_id, descriptor): descriptor: An XModuleDescriptor """ + def preview_model_data(model_data): + return DbModel( + SessionKeyValueStore(request, model_data), + descriptor.module_class, + preview_id, + MongoUsage(preview_id, descriptor.location.url()), + ) + return ModuleSystem( ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'), # TODO (cpennington): Do we want to track how instructors are using the preview problems? @@ -471,6 +474,7 @@ def preview_module_system(request, preview_id, descriptor): debug=True, replace_urls=replace_urls, user=request.user, + xmodule_model_data=preview_model_data, ) @@ -484,11 +488,10 @@ def get_preview_module(request, preview_id, location): location: A Location """ descriptor = modulestore().get_item(location) - instance_state, shared_state = descriptor.get_sample_state()[0] - return load_preview_module(request, preview_id, descriptor, instance_state, shared_state) + return load_preview_module(request, preview_id, descriptor) -def load_preview_module(request, preview_id, descriptor, instance_state, shared_state): +def load_preview_module(request, preview_id, descriptor): """ Return a preview XModule instantiated from the supplied descriptor, instance_state, and shared_state @@ -502,10 +505,11 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_ try: module = descriptor.xmodule(system) except: + log.debug("Unable to load preview module", exc_info=True) module = ErrorDescriptor.from_descriptor( descriptor, error_msg=exc_info_to_str(sys.exc_info()) - ).xmodule_constructor(system)(None, None) + ).xmodule(system) # cdodge: Special case if module.location.category == 'static_tab': @@ -523,11 +527,9 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_ module.get_html = replace_static_urls( module.get_html, - module.metadata.get('data_dir', module.location.course), + getattr(module, 'data_dir', module.location.course), course_namespace = Location([module.location.tag, module.location.org, module.location.course, None, None]) ) - save_preview_state(request, preview_id, descriptor.location.url(), - module.get_instance_state(), module.get_shared_state()) return module @@ -541,7 +543,7 @@ def get_module_previews(request, descriptor): """ preview_html = [] for idx, (instance_state, shared_state) in enumerate(descriptor.get_sample_state()): - module = load_preview_module(request, str(idx), descriptor, instance_state, shared_state) + module = load_preview_module(request, str(idx), descriptor) preview_html.append(module.get_html()) return preview_html @@ -625,23 +627,26 @@ def save_item(request): # update existing metadata with submitted metadata (which can be partial) # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it' - for metadata_key in posted_metadata.keys(): + for metadata_key, value in posted_metadata.items(): # let's strip out any metadata fields from the postback which have been identified as system metadata # and therefore should not be user-editable, so we should accept them back from the client if metadata_key in existing_item.system_metadata_fields: del posted_metadata[metadata_key] elif posted_metadata[metadata_key] is None: + print "DELETING", metadata_key, value + print metadata_key in existing_item._model_data # remove both from passed in collection as well as the collection read in from the modulestore - if metadata_key in existing_item.metadata: - del existing_item.metadata[metadata_key] + if metadata_key in existing_item._model_data: + del existing_item._model_data[metadata_key] del posted_metadata[metadata_key] - - # overlay the new metadata over the modulestore sourced collection to support partial updates - existing_item.metadata.update(posted_metadata) + else: + existing_item._model_data[metadata_key] = value # commit to datastore - store.update_metadata(item_location, existing_item.metadata) + # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata + print existing_item._model_data._kvs._metadata + store.update_metadata(item_location, existing_item._model_data._kvs._metadata) return HttpResponse() diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index 9ddbe87727..ce229dd196 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -237,15 +237,15 @@ class CourseGradingModel: # 5 hours 59 minutes 59 seconds => converted to iso format rawgrace = descriptor.lms.graceperiod if rawgrace: - hours_from_day = rawgrace.days*24 + hours_from_days = rawgrace.days*24 seconds = rawgrace.seconds hours_from_seconds = int(seconds / 3600) seconds -= hours_from_seconds * 3600 minutes = int(seconds / 60) seconds -= minutes * 60 return { - 'hours': hourse_from_days + hours_from_seconds, - 'minutes': minutes_from_seconds, + 'hours': hours_from_days + hours_from_seconds, + 'minutes': minutes, 'seconds': seconds, } else: diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index 53567c73e1..e98ad526ed 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -25,7 +25,7 @@
- +
@@ -54,13 +54,13 @@
<% - start_date = datetime.fromtimestamp(mktime(subsection.start)) if subsection.start is not None else None - parent_start_date = datetime.fromtimestamp(mktime(parent_item.start)) if parent_item.start is not None else None + start_date = datetime.fromtimestamp(mktime(subsection.lms.start)) if subsection.lms.start is not None else None + parent_start_date = datetime.fromtimestamp(mktime(parent_item.lms.start)) if parent_item.lms.start is not None else None %>
- % if subsection.start != parent_item.start and subsection.start: + % if subsection.lms.start != parent_item.lms.start and subsection.lms.start: % if parent_start_date is None:

The date above differs from the release date of ${parent_item.lms.display_name}, which is unset. % else: @@ -83,7 +83,7 @@

<% # due date uses it own formatting for stringifying the date. As with capa_module.py, there's a utility module available for us to use - due_date = dateutil.parser.parse(subsection.metadata.get('due')) if 'due' in subsection.metadata else None + due_date = dateutil.parser.parse(subsection.lms.due) if subsection.lms.due is not None else None %> diff --git a/cms/templates/overview.html b/cms/templates/overview.html index fad28dbf07..099cfb1a5b 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -141,7 +141,7 @@

-
+
diff --git a/cms/xmodule_namespace.py b/cms/xmodule_namespace.py new file mode 100644 index 0000000000..0ea729e27b --- /dev/null +++ b/cms/xmodule_namespace.py @@ -0,0 +1,20 @@ +import datetime + +from xmodule.model import Namespace, Boolean, Scope, ModelType, String + + +class DateTuple(ModelType): + """ + ModelType that stores datetime objects as time tuples + """ + def from_json(self, value): + return datetime.datetime(*value) + + def to_json(self, value): + return list(value.timetuple()) + + +class CmsNamespace(Namespace): + is_draft = Boolean(help="Whether this module is a draft", default=False, scope=Scope.settings) + published_date = DateTuple(help="Date when the module was published", scope=Scope.settings) + published_by = String(help="Id of the user who published this module", scope=Scope.settings) diff --git a/common/lib/xmodule/xmodule/editing_module.py b/common/lib/xmodule/xmodule/editing_module.py index e025179b63..531fd7d8b9 100644 --- a/common/lib/xmodule/xmodule/editing_module.py +++ b/common/lib/xmodule/xmodule/editing_module.py @@ -1,5 +1,6 @@ from pkg_resources import resource_string from xmodule.mako_module import MakoModuleDescriptor +from xmodule.model import Scope, String import logging log = logging.getLogger(__name__) @@ -14,13 +15,15 @@ class EditingDescriptor(MakoModuleDescriptor): """ mako_template = "widgets/raw-edit.html" + data = String(scope=Scope.content, default='') + # cdodge: a little refactoring here, since we're basically doing the same thing # here as with our parent class, let's call into it to get the basic fields # set and then add our additional fields. Trying to keep it DRY. def get_context(self): _context = MakoModuleDescriptor.get_context(self) # Add our specific template information (the raw data body) - _context.update({'data': self.definition.get('data', '')}) + _context.update({'data': self.data}) return _context diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py index 37e98b5b77..6a06b3ad3a 100644 --- a/common/lib/xmodule/xmodule/error_module.py +++ b/common/lib/xmodule/xmodule/error_module.py @@ -106,7 +106,7 @@ class ErrorDescriptor(JSONEditingDescriptor): def from_descriptor(cls, descriptor, error_msg='Error not available'): return cls._construct( descriptor.system, - json.dumps(descriptor._model_data, indent=4), + descriptor._model_data, error_msg, location=descriptor.location, ) diff --git a/common/lib/xmodule/xmodule/mako_module.py b/common/lib/xmodule/xmodule/mako_module.py index bdf3cb4749..8ae68051a0 100644 --- a/common/lib/xmodule/xmodule/mako_module.py +++ b/common/lib/xmodule/xmodule/mako_module.py @@ -32,9 +32,7 @@ class MakoModuleDescriptor(XModuleDescriptor): """ Return the context to render the mako template with """ - return {'module': self, - 'editable_metadata_fields': self.editable_fields - } + return {'module': self} def get_html(self): return self.system.render_template( diff --git a/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py index 5fbf05ed9b..100bdb1dc6 100644 --- a/common/lib/xmodule/xmodule/modulestore/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/draft.py @@ -15,11 +15,11 @@ def as_draft(location): def wrap_draft(item): """ - Sets `item.metadata['is_draft']` to `True` if the item is a - draft, and false otherwise. Sets the item's location to the + Sets `item.cms.is_draft` to `True` if the item is a + draft, and `False` otherwise. Sets the item's location to the non-draft location in either case """ - item.metadata['is_draft'] = item.location.revision == DRAFT + item.cms.is_draft = item.location.revision == DRAFT item.location = item.location._replace(revision=None) return item @@ -112,7 +112,7 @@ class DraftModuleStore(ModuleStoreBase): """ draft_loc = as_draft(location) draft_item = self.get_item(location) - if not draft_item.metadata['is_draft']: + if not draft_item.cms.is_draft: self.clone_item(location, draft_loc) return super(DraftModuleStore, self).update_item(draft_loc, data) @@ -127,7 +127,7 @@ class DraftModuleStore(ModuleStoreBase): """ draft_loc = as_draft(location) draft_item = self.get_item(location) - if not draft_item.metadata['is_draft']: + if not draft_item.cms.is_draft: self.clone_item(location, draft_loc) return super(DraftModuleStore, self).update_children(draft_loc, children) @@ -143,7 +143,7 @@ class DraftModuleStore(ModuleStoreBase): draft_loc = as_draft(location) draft_item = self.get_item(location) - if not draft_item.metadata['is_draft']: + if not draft_item.cms.is_draft: self.clone_item(location, draft_loc) if 'is_draft' in metadata: @@ -175,8 +175,8 @@ class DraftModuleStore(ModuleStoreBase): draft = self.get_item(location) metadata = {} metadata.update(draft.metadata) - metadata['published_date'] = tuple(datetime.utcnow().timetuple()) - metadata['published_by'] = published_by_id + metadata.cms.published_date = datetime.utcnow() + metadata.cms.published_by = published_by_id super(DraftModuleStore, self).update_item(location, draft.definition.get('data', {})) super(DraftModuleStore, self).update_children(location, draft.definition.get('children', [])) super(DraftModuleStore, self).update_metadata(location, metadata) diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 38c592564d..18cf0b3351 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -52,6 +52,11 @@ def own_metadata(module): field.name not in inherited_metadata and field.name in module._model_data): - metadata[field.name] = module._model_data[field.name] + try: + metadata[field.name] = module._model_data[field.name] + except KeyError: + # Ignore any missing keys in _model_data + pass + return metadata diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index d145523d16..5c55c4d507 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -1,7 +1,9 @@ import pymongo import sys +import logging from bson.son import SON +from collections import namedtuple from fs.osfs import OSFS from itertools import repeat from path import path @@ -11,17 +13,79 @@ from xmodule.errortracker import null_error_tracker, exc_info_to_str from xmodule.mako_module import MakoDescriptorSystem from xmodule.x_module import XModuleDescriptor from xmodule.error_module import ErrorDescriptor +from xmodule.runtime import DbModel, KeyValueStore, InvalidScopeError +from xmodule.model import Scope from . import ModuleStoreBase, Location from .draft import DraftModuleStore from .exceptions import (ItemNotFoundError, DuplicateItemError) + +log = logging.getLogger(__name__) + # TODO (cpennington): This code currently operates under the assumption that # there is only one revision for each item. Once we start versioning inside the CMS, # that assumption will have to change +class MongoKeyValueStore(KeyValueStore): + """ + A KeyValueStore that maps keyed data access to one of the 3 data areas + known to the MongoModuleStore (data, children, and metadata) + """ + def __init__(self, data, children, metadata): + self._data = data + self._children = children + self._metadata = metadata + + def get(self, key): + print "GET", key + if key.field_name == 'children': + return self._children + elif key.scope == Scope.settings: + return self._metadata[key.field_name] + elif key.scope == Scope.content: + if key.field_name == 'data' and not isinstance(self._data, dict): + return self._data + else: + return self._data[key.field_name] + else: + raise InvalidScopeError(key.scope) + + def set(self, key, value): + print "SET", key, value + if key.field_name == 'children': + self._children = value + elif key.scope == Scope.settings: + self._metadata[key.field_name] = value + elif key.scope == Scope.content: + if key.field_name == 'data' and not isinstance(self._data, dict): + self._data = value + else: + self._data[key.field_name] = value + else: + raise InvalidScopeError(key.scope) + + def delete(self, key): + print "DELETE", key + if key.field_name == 'children': + self._children = [] + elif key.scope == Scope.settings: + if key.field_name in self._metadata: + del self._metadata[key.field_name] + elif key.scope == Scope.content: + if key.field_name == 'data' and not isinstance(self._data, dict): + self._data = None + else: + del self._data[key.field_name] + else: + raise InvalidScopeError(key.scope) + + +MongoUsage = namedtuple('MongoUsage', 'id, def_id') + + class CachingDescriptorSystem(MakoDescriptorSystem): """ A system that has a cache of module json that it will use to load modules @@ -64,8 +128,21 @@ class CachingDescriptorSystem(MakoDescriptorSystem): # always load an entire course. We're punting on this until after launch, and then # will build a proper course policy framework. try: - return XModuleDescriptor.load_from_json(json_data, self, self.default_class) + class_ = XModuleDescriptor.load_class( + json_data['location']['category'], + self.default_class + ) + definition = json_data.get('definition', {}) + kvs = MongoKeyValueStore( + definition.get('data', {}), + definition.get('children', []), + json_data.get('metadata', {}), + ) + + model_data = DbModel(kvs, class_, None, MongoUsage(self.course_id, location)) + return class_(self, location, model_data) except: + log.debug("Failed to load descriptor", exc_info=True) return ErrorDescriptor.from_json( json_data, self, diff --git a/common/lib/xmodule/xmodule/runtime.py b/common/lib/xmodule/xmodule/runtime.py index 6dee5b0b00..5a5533133b 100644 --- a/common/lib/xmodule/xmodule/runtime.py +++ b/common/lib/xmodule/xmodule/runtime.py @@ -3,6 +3,13 @@ from collections import MutableMapping, namedtuple from .model import ModuleScope, ModelType +class InvalidScopeError(Exception): + """ + Raised to indicated that operating on the supplied scope isn't allowed by a KeyValueStore + """ + pass + + class KeyValueStore(object): """The abstract interface for Key Value Stores.""" @@ -102,8 +109,12 @@ class DbModel(MutableMapping): def __len__(self): return len(self.keys()) + def __contains__(self, item): + return item in self.keys() + def keys(self): fields = [field.name for field in self._module_cls.fields] for namespace_name in self._module_cls.namespaces: fields.extend(field.name for field in getattr(self._module_cls, namespace_name).fields) + print fields return fields diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index ecc7d7fdad..34ce353afd 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -28,13 +28,41 @@ class VideoModule(XModule): css = {'scss': [resource_string(__name__, 'css/video/display.scss')]} js_module_name = "Video" - youtube = String(help="Youtube ids for each speed, in the format :[,: ...]", scope=Scope.content) - show_captions = String(help="Whether to display captions with this video", scope=Scope.content) - source = String(help="External source for this video", scope=Scope.content) - track = String(help="Subtitle file", scope=Scope.content) - position = Int(help="Current position in the video", scope=Scope.student_state, default=0) + data = String(help="XML data for the problem", scope=Scope.content) + position = Int(help="Current position in the video", scope=Scope.student_state) display_name = String(help="Display name for this module", scope=Scope.settings) + def __init__(self, *args, **kwargs): + XModule.__init__(self, *args, **kwargs) + + xmltree = etree.fromstring(self.data) + self.youtube = xmltree.get('youtube') + self.position = 0 + self.show_captions = xmltree.get('show_captions', 'true') + self.source = self._get_source(xmltree) + self.track = self._get_track(xmltree) + + def _get_source(self, xmltree): + # find the first valid source + return self._get_first_external(xmltree, 'source') + + def _get_track(self, xmltree): + # find the first valid track + return self._get_first_external(xmltree, 'track') + + def _get_first_external(self, xmltree, tag): + """ + Will return the first valid element + of the given tag. + 'valid' means has a non-empty 'src' attribute + """ + result = None + for element in xmltree.findall(tag): + src = element.get('src') + if src: + result = src + break + return result def handle_ajax(self, dispatch, get): ''' @@ -87,50 +115,7 @@ class VideoModule(XModule): }) - class VideoDescriptor(RawDescriptor): module_class = VideoModule stores_state = True template_dir_name = "video" - - youtube = String(help="Youtube ids for each speed, in the format :[,: ...]", scope=Scope.content) - show_captions = String(help="Whether to display captions with this video", scope=Scope.content) - source = String(help="External source for this video", scope=Scope.content) - track = String(help="Subtitle file", scope=Scope.content) - - @classmethod - def definition_from_xml(cls, xml_object, system): - return { - 'youtube': xml_object.get('youtube'), - 'show_captions': xml_object.get('show_captions', 'true'), - 'source': _get_first_external(xml_object, 'source'), - 'track': _get_first_external(xml_object, 'track'), - }, [] - - def definition_to_xml(self, resource_fs): - xml_object = etree.Element('video', { - 'youtube': self.youtube, - 'show_captions': self.show_captions, - }) - - if self.source is not None: - SubElement(xml_object, 'source', {'src': self.source}) - - if self.track is not None: - SubElement(xml_object, 'track', {'src': self.track}) - - return xml_object - -def _get_first_external(xmltree, tag): - """ - Will return the first valid element - of the given tag. - 'valid' means has a non-empty 'src' attribute - """ - result = None - for element in xmltree.findall(tag): - src = element.get('src') - if src: - result = src - break - return result diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index e8dfb025ed..b2f2d3ef48 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -8,13 +8,10 @@ from .models import ( XModuleStudentInfoField ) -from xmodule.runtime import DbModel, KeyValueStore +from xmodule.runtime import KeyValueStore, InvalidScopeError from xmodule.model import Scope -class InvalidScopeError(Exception): - pass - class InvalidWriteError(Exception): pass diff --git a/setup.py b/setup.py index 84f242cf3d..a07f836413 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,8 @@ setup( # for a description of entry_points entry_points={ 'xmodule.namespace': [ - 'lms = lms.xmodule_namespace:LmsNamespace' + 'lms = lms.xmodule_namespace:LmsNamespace', + 'cms = cms.xmodule_namespace:CmsNamespace', ], } ) \ No newline at end of file From 60fa8619cbe10b13144a627a0b6d7c25371af4ba Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 2 Jan 2013 10:37:49 -0500 Subject: [PATCH 059/501] Fixing tests --- .../contentstore/module_info_model.py | 121 +++++++++--------- .../tests/test_course_settings.py | 4 +- cms/djangoapps/contentstore/views.py | 7 +- .../models/settings/course_grading.py | 43 +++++-- common/lib/xmodule/xmodule/capa_module.py | 8 -- common/lib/xmodule/xmodule/course_module.py | 4 +- .../lib/xmodule/xmodule/modulestore/mongo.py | 7 +- .../xmodule/modulestore/store_utilities.py | 18 +-- common/lib/xmodule/xmodule/runtime.py | 1 - .../lib/xmodule/xmodule/tests/test_import.py | 4 +- 10 files changed, 111 insertions(+), 106 deletions(-) diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index 0017010885..c6e97eb73d 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -8,77 +8,78 @@ import re from django.http import HttpResponseBadRequest, Http404 def get_module_info(store, location, parent_location = None, rewrite_static_links = False): - try: - if location.revision is None: - module = store.get_item(location) - else: - module = store.get_item(location) - except ItemNotFoundError: - raise Http404 + try: + if location.revision is None: + module = store.get_item(location) + else: + module = store.get_item(location) + except ItemNotFoundError: + raise Http404 - data = module.definition['data'] - if rewrite_static_links: - data = replace_urls(module.definition['data'], course_namespace = Location([module.location.tag, module.location.org, module.location.course, None, None])) + data = module.data + if rewrite_static_links: + data = replace_urls(module.data, course_namespace = Location([module.location.tag, module.location.org, module.location.course, None, None])) - return { + return { 'id': module.location.url(), 'data': data, - 'metadata': module.metadata + # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata + 'metadata': module._model_data._kvs._metadata } def set_module_info(store, location, post_data): - module = None - isNew = False - try: - if location.revision is None: - module = store.get_item(location) - else: - module = store.get_item(location) - except: - pass + module = None + isNew = False + try: + if location.revision is None: + module = store.get_item(location) + else: + module = store.get_item(location) + except: + pass - if module is None: - # new module at this location - # presume that we have an 'Empty' template - template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) - module = store.clone_item(template_location, location) - isNew = True + if module is None: + # new module at this location + # presume that we have an 'Empty' template + template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) + module = store.clone_item(template_location, location) + isNew = True - if post_data.get('data') is not None: - data = post_data['data'] - store.update_item(location, data) - - # cdodge: note calling request.POST.get('children') will return None if children is an empty array - # so it lead to a bug whereby the last component to be deleted in the UI was not actually - # deleting the children object from the children collection - if 'children' in post_data and post_data['children'] is not None: - children = post_data['children'] - store.update_children(location, children) + if post_data.get('data') is not None: + data = post_data['data'] + store.update_item(location, data) - # cdodge: also commit any metadata which might have been passed along in the - # POST from the client, if it is there - # NOTE, that the postback is not the complete metadata, as there's system metadata which is - # not presented to the end-user for editing. So let's fetch the original and - # 'apply' the submitted metadata, so we don't end up deleting system metadata - if post_data.get('metadata') is not None: - posted_metadata = post_data['metadata'] + # cdodge: note calling request.POST.get('children') will return None if children is an empty array + # so it lead to a bug whereby the last component to be deleted in the UI was not actually + # deleting the children object from the children collection + if 'children' in post_data and post_data['children'] is not None: + children = post_data['children'] + store.update_children(location, children) - # update existing metadata with submitted metadata (which can be partial) - # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it' - for metadata_key in posted_metadata.keys(): + # cdodge: also commit any metadata which might have been passed along in the + # POST from the client, if it is there + # NOTE, that the postback is not the complete metadata, as there's system metadata which is + # not presented to the end-user for editing. So let's fetch the original and + # 'apply' the submitted metadata, so we don't end up deleting system metadata + if post_data.get('metadata') is not None: + posted_metadata = post_data['metadata'] - # let's strip out any metadata fields from the postback which have been identified as system metadata - # and therefore should not be user-editable, so we should accept them back from the client - if metadata_key in module.system_metadata_fields: - del posted_metadata[metadata_key] - elif posted_metadata[metadata_key] is None: - # remove both from passed in collection as well as the collection read in from the modulestore - if metadata_key in module.metadata: - del module.metadata[metadata_key] - del posted_metadata[metadata_key] + # update existing metadata with submitted metadata (which can be partial) + # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it' + for metadata_key in posted_metadata.keys(): - # overlay the new metadata over the modulestore sourced collection to support partial updates - module.metadata.update(posted_metadata) + # let's strip out any metadata fields from the postback which have been identified as system metadata + # and therefore should not be user-editable, so we should accept them back from the client + if metadata_key in module.system_metadata_fields: + del posted_metadata[metadata_key] + elif posted_metadata[metadata_key] is None: + # remove both from passed in collection as well as the collection read in from the modulestore + if metadata_key in module._model_data: + del module._model_data[metadata_key] + del posted_metadata[metadata_key] + else: + module._model_data[metadata_key] = value - # commit to datastore - store.update_metadata(location, module.metadata) + # commit to datastore + # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata + store.update_metadata(item_location, module._model_data._kvs._metadata) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 74eff6e9cc..a9ac6f9248 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -255,8 +255,10 @@ class CourseGradingTest(CourseTestCase): altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "cutoff add D") - test_grader.grace_period = {'hours' : '4'} + test_grader.grace_period = {'hours': '4'} altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) + print test_grader.__dict__ + print altered_grader.__dict__ self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period") def test_update_grader_from_json(self): diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index a6957e6107..e027efc1bf 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -410,8 +410,6 @@ def preview_dispatch(request, preview_id, location, dispatch=None): log.exception("error processing ajax call") raise - print request.session.items() - return HttpResponse(ajax_return) @@ -634,8 +632,6 @@ def save_item(request): if metadata_key in existing_item.system_metadata_fields: del posted_metadata[metadata_key] elif posted_metadata[metadata_key] is None: - print "DELETING", metadata_key, value - print metadata_key in existing_item._model_data # remove both from passed in collection as well as the collection read in from the modulestore if metadata_key in existing_item._model_data: del existing_item._model_data[metadata_key] @@ -645,7 +641,6 @@ def save_item(request): # commit to datastore # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata - print existing_item._model_data._kvs._metadata store.update_metadata(item_location, existing_item._model_data._kvs._metadata) return HttpResponse() @@ -1231,7 +1226,7 @@ def initialize_course_tabs(course): {"type": "wiki", "name": "Wiki"}, {"type": "progress", "name": "Progress"}] - modulestore('direct').update_metadata(course.location.url(), own_metadata(new_course)) + modulestore('direct').update_metadata(course.location.url(), own_metadata(course)) @ensure_csrf_cookie @login_required diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index ce229dd196..e7c98908f8 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -2,6 +2,7 @@ from xmodule.modulestore import Location from contentstore.utils import get_modulestore import re from util import converters +from datetime import timedelta class CourseGradingModel: @@ -91,7 +92,7 @@ class CourseGradingModel: descriptor.raw_grader = graders_parsed descriptor.grade_cutoffs = jsondict['grade_cutoffs'] - get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period']) return CourseGradingModel.fetch(course_location) @@ -119,7 +120,7 @@ class CourseGradingModel: else: descriptor.raw_grader.append(grader) - get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index]) @@ -155,11 +156,17 @@ class CourseGradingModel: if 'grace_period' in graceperiodjson: graceperiodjson = graceperiodjson['grace_period'] - grace_rep = " ".join(["%s %s" % (value, key) for (key, value) in graceperiodjson.iteritems()]) + timedelta_kwargs = dict( + (key, float(val)) + for key, val + in graceperiodjson.items() + if key in ('days', 'seconds', 'minutes', 'hours') + ) + grace_rep = timedelta(**timedelta_kwargs) descriptor = get_modulestore(course_location).get_item(course_location) - descriptor.metadata['graceperiod'] = grace_rep - get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + descriptor.lms.graceperiod = grace_rep + get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata) @staticmethod def delete_grader(course_location, index): @@ -170,12 +177,12 @@ class CourseGradingModel: course_location = Location(course_location) descriptor = get_modulestore(course_location).get_item(course_location) - index = int(index) + index = int(index) if index < len(descriptor.raw_grader): del descriptor.raw_grader[index] # force propagation to definition descriptor.raw_grader = descriptor.raw_grader - get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) # NOTE cannot delete cutoffs. May be useful to reset @staticmethod @@ -188,7 +195,7 @@ class CourseGradingModel: descriptor = get_modulestore(course_location).get_item(course_location) descriptor.grade_cutoffs = descriptor.defaut_grading_policy['GRADE_CUTOFFS'] - get_modulestore(course_location).update_item(course_location, descriptor.definition['data']) + get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data) return descriptor.grade_cutoffs @@ -202,7 +209,7 @@ class CourseGradingModel: descriptor = get_modulestore(course_location).get_item(course_location) if 'graceperiod' in descriptor.metadata: del descriptor.metadata['graceperiod'] - get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) + get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._data) @staticmethod def get_section_grader_type(location): @@ -240,14 +247,22 @@ class CourseGradingModel: hours_from_days = rawgrace.days*24 seconds = rawgrace.seconds hours_from_seconds = int(seconds / 3600) + hours = hours_from_days + hours_from_seconds seconds -= hours_from_seconds * 3600 minutes = int(seconds / 60) seconds -= minutes * 60 - return { - 'hours': hours_from_days + hours_from_seconds, - 'minutes': minutes, - 'seconds': seconds, - } + + graceperiod = {} + if hours > 0: + graceperiod['hours'] = str(hours) + + if minutes > 0: + graceperiod['minutes'] = str(minutes) + + if seconds > 0: + graceperiod['seconds'] = str(seconds) + + return graceperiod else: return None diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 7e66ef534e..a41e634c36 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -421,13 +421,6 @@ class CapaModule(XModule): new_answers = dict() for answer_id in answers: try: -<<<<<<< HEAD -<<<<<<< HEAD - new_answer = {answer_id: self.system.replace_urls(answers[answer_id], self.metadata['data_dir'], course_namespace=self.location)} -======= - new_answer = {answer_id: self.system.replace_urls(answers[answer_id], self.descriptor.data_dir)} ->>>>>>> WIP: Save student state via StudentModule. Inheritance doesn't work -======= new_answer = { answer_id: self.system.replace_urls( answers[answer_id], @@ -435,7 +428,6 @@ class CapaModule(XModule): course_namespace=self.location ) } ->>>>>>> Fix more errors in tests except TypeError: log.debug('Unable to perform URL substitution on answers[%s]: %s' % (answer_id, answers[answer_id])) new_answer = {answer_id: answers[answer_id]} diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 444fcaf52d..38cf81f3af 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -13,7 +13,7 @@ import time import copy from .model import Scope, ModelType, List, String, Object, Boolean -from .x_module import Date +from .fields import Date log = logging.getLogger(__name__) @@ -99,7 +99,7 @@ class CourseDescriptor(SequenceDescriptor): start = Date(help="Start time when this module is visible", scope=Scope.settings) end = Date(help="Date that this class ends", scope=Scope.settings) advertised_start = Date(help="Date that this course is advertised to start", scope=Scope.settings) - grading_policy = Object(help="Grading policy definition for this class", scope=Scope.content) + grading_policy = Object(help="Grading policy definition for this class", scope=Scope.content, default={}) show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings) display_name = String(help="Display name for this module", scope=Scope.settings) tabs = List(help="List of tabs to enable in this course", scope=Scope.settings) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 5c55c4d507..b59e6c50fb 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -20,6 +20,7 @@ from . import ModuleStoreBase, Location from .draft import DraftModuleStore from .exceptions import (ItemNotFoundError, DuplicateItemError) +from .inheritance import own_metadata log = logging.getLogger(__name__) @@ -40,7 +41,6 @@ class MongoKeyValueStore(KeyValueStore): self._metadata = metadata def get(self, key): - print "GET", key if key.field_name == 'children': return self._children elif key.scope == Scope.settings: @@ -54,7 +54,6 @@ class MongoKeyValueStore(KeyValueStore): raise InvalidScopeError(key.scope) def set(self, key, value): - print "SET", key, value if key.field_name == 'children': self._children = value elif key.scope == Scope.settings: @@ -68,7 +67,6 @@ class MongoKeyValueStore(KeyValueStore): raise InvalidScopeError(key.scope) def delete(self, key): - print "DELETE", key if key.field_name == 'children': self._children = [] elif key.scope == Scope.settings: @@ -457,6 +455,7 @@ class MongoModuleStore(ModuleStoreBase): tab['name'] = metadata.get('display_name') break course.tabs = existing_tabs + self.update_metadata(course.location, own_metadata(course)) self._update_single_item(location, {'metadata': metadata}) @@ -474,7 +473,7 @@ class MongoModuleStore(ModuleStoreBase): course = self.get_course_for_item(item.location) existing_tabs = course.tabs or [] course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name] - self.update_metadata(course.location, course.metadata) + self.update_metadata(course.location, own_metadata(course)) self.collection.remove({'_id': Location(location).dict()}) diff --git a/common/lib/xmodule/xmodule/modulestore/store_utilities.py b/common/lib/xmodule/xmodule/modulestore/store_utilities.py index af346dbb7e..bab92c6c2d 100644 --- a/common/lib/xmodule/xmodule/modulestore/store_utilities.py +++ b/common/lib/xmodule/xmodule/modulestore/store_utilities.py @@ -40,22 +40,24 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele print "Cloning module {0} to {1}....".format(original_loc, module.location) - if 'data' in module.definition: - modulestore.update_item(module.location, module.definition['data']) + modulestore.update_item(module.location, module._model_data._kvs._data) # repoint children - if 'children' in module.definition: + if module.has_children: new_children = [] - for child_loc_url in module.definition['children']: + for child_loc_url in module.children: child_loc = Location(child_loc_url) - child_loc = child_loc._replace(tag = dest_location.tag, org = dest_location.org, - course = dest_location.course) - new_children = new_children + [child_loc.url()] + child_loc = child_loc._replace( + tag = dest_location.tag, + org = dest_location.org, + course = dest_location.course + ) + new_children.append(child_loc.url()) modulestore.update_children(module.location, new_children) # save metadata - modulestore.update_metadata(module.location, module.metadata) + modulestore.update_metadata(module.location, module._model_data._kvs._metadata) # now iterate through all of the assets and clone them # first the thumbnails diff --git a/common/lib/xmodule/xmodule/runtime.py b/common/lib/xmodule/xmodule/runtime.py index 5a5533133b..8cbfc3ae36 100644 --- a/common/lib/xmodule/xmodule/runtime.py +++ b/common/lib/xmodule/xmodule/runtime.py @@ -116,5 +116,4 @@ class DbModel(MutableMapping): fields = [field.name for field in self._module_cls.fields] for namespace_name in self._module_cls.namespaces: fields.extend(field.name for field in getattr(self._module_cls, namespace_name).fields) - print fields return fields diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index 803aceef68..31dbb131f9 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -271,8 +271,8 @@ class ImportTestCase(unittest.TestCase): location = Location(["i4x", "edX", "toy", "video", "Welcome"]) toy_video = modulestore.get_instance(toy_id, location) two_toy_video = modulestore.get_instance(two_toy_id, location) - self.assertEqual(toy_video.youtube, "1.0:p2Q6BrNhdh8") - self.assertEqual(two_toy_video.youtube, "1.0:p2Q6BrNhdh9") + self.assertEqual(etree.fromstring(toy_video.data).get('youtube'), "1.0:p2Q6BrNhdh8") + self.assertEqual(etree.fromstring(two_toy_video.data).get('youtube'), "1.0:p2Q6BrNhdh9") def test_colon_in_url_name(self): From 8e0d218c7dd1b891fd9f54721bed271774bd9efb Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 2 Jan 2013 15:06:43 -0500 Subject: [PATCH 060/501] Stop using .definition and .metadata directly --- .../contentstore/course_info_model.py | 14 ++++----- cms/djangoapps/contentstore/views.py | 2 +- .../models/settings/course_grading.py | 10 +++--- cms/templates/edit_subsection.html | 2 +- cms/templates/widgets/metadata-edit.html | 16 +++++----- common/lib/xmodule/xmodule/mako_module.py | 30 +++++++++++++++--- common/lib/xmodule/xmodule/model.py | 31 +++++++++++++++++++ .../lib/xmodule/xmodule/modulestore/draft.py | 12 +++---- .../lib/xmodule/xmodule/modulestore/mongo.py | 6 ++-- common/lib/xmodule/xmodule/x_module.py | 1 + 10 files changed, 87 insertions(+), 37 deletions(-) diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index c2e8348a66..23fad96caa 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -24,7 +24,7 @@ def get_course_updates(location): # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: - course_html_parsed = etree.fromstring(course_updates.definition['data']) + course_html_parsed = etree.fromstring(course_updates.data) except etree.XMLSyntaxError: course_html_parsed = etree.fromstring("
    ") @@ -61,7 +61,7 @@ def update_course_updates(location, update, passed_id=None): # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: - course_html_parsed = etree.fromstring(course_updates.definition['data']) + course_html_parsed = etree.fromstring(course_updates.data) except etree.XMLSyntaxError: course_html_parsed = etree.fromstring("
      ") @@ -82,8 +82,8 @@ def update_course_updates(location, update, passed_id=None): passed_id = course_updates.location.url() + "/" + str(idx) # update db record - course_updates.definition['data'] = etree.tostring(course_html_parsed) - modulestore('direct').update_item(location, course_updates.definition['data']) + course_updates.data = etree.tostring(course_html_parsed) + modulestore('direct').update_item(location, course_updates.data) return {"id" : passed_id, "date" : update['date'], @@ -105,7 +105,7 @@ def delete_course_update(location, update, passed_id): # TODO use delete_blank_text parser throughout and cache as a static var in a class # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: - course_html_parsed = etree.fromstring(course_updates.definition['data']) + course_html_parsed = etree.fromstring(course_updates.data) except etree.XMLSyntaxError: course_html_parsed = etree.fromstring("
        ") @@ -118,9 +118,9 @@ def delete_course_update(location, update, passed_id): course_html_parsed.remove(element_to_delete) # update db record - course_updates.definition['data'] = etree.tostring(course_html_parsed) + course_updates.data = etree.tostring(course_html_parsed) store = modulestore('direct') - store.update_item(location, course_updates.definition['data']) + store.update_item(location, course_updates.data) return get_course_updates(location) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index e027efc1bf..9ca8e5e8ec 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -218,7 +218,7 @@ def edit_subsection(request, location): # remove all metadata from the generic dictionary that is presented in a more normalized UI policy_metadata = dict( - (key,value) + (field.name, field.read_from(item)) for field in item.fields if field.name not in ['display_name', 'start', 'due', 'format'] and diff --git a/cms/djangoapps/models/settings/course_grading.py b/cms/djangoapps/models/settings/course_grading.py index e7c98908f8..f2795e80da 100644 --- a/cms/djangoapps/models/settings/course_grading.py +++ b/cms/djangoapps/models/settings/course_grading.py @@ -230,13 +230,13 @@ class CourseGradingModel: descriptor = get_modulestore(location).get_item(location) if 'graderType' in jsondict and jsondict['graderType'] != u"Not Graded": - descriptor.metadata['format'] = jsondict.get('graderType') - descriptor.metadata['graded'] = True + descriptor.lms.format = jsondict.get('graderType') + descriptor.lms.graded = True else: - if 'format' in descriptor.metadata: del descriptor.metadata['format'] - if 'graded' in descriptor.metadata: del descriptor.metadata['graded'] + del descriptor.lms.format + del descriptor.lms.graded - get_modulestore(location).update_metadata(location, descriptor.metadata) + get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._data) @staticmethod diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index e98ad526ed..196aedac01 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -73,7 +73,7 @@
        -
        +
        diff --git a/cms/templates/widgets/metadata-edit.html b/cms/templates/widgets/metadata-edit.html index 590baec3c9..51fe400f88 100644 --- a/cms/templates/widgets/metadata-edit.html +++ b/cms/templates/widgets/metadata-edit.html @@ -1,18 +1,17 @@ -% if metadata: <% import hashlib hlskey = hashlib.md5(module.location.url()).hexdigest() %> -% endif diff --git a/common/lib/xmodule/xmodule/mako_module.py b/common/lib/xmodule/xmodule/mako_module.py index 8ae68051a0..66a4b647d3 100644 --- a/common/lib/xmodule/xmodule/mako_module.py +++ b/common/lib/xmodule/xmodule/mako_module.py @@ -1,4 +1,5 @@ -from x_module import XModuleDescriptor, DescriptorSystem +from .x_module import XModuleDescriptor, DescriptorSystem +from .model import Scope import logging @@ -32,7 +33,10 @@ class MakoModuleDescriptor(XModuleDescriptor): """ Return the context to render the mako template with """ - return {'module': self} + return { + 'module': self, + 'editable_metadata_fields': self.editable_metadata_fields, + } def get_html(self): return self.system.render_template( @@ -41,6 +45,24 @@ class MakoModuleDescriptor(XModuleDescriptor): # cdodge: encapsulate a means to expose "editable" metadata fields (i.e. not internal system metadata) @property def editable_metadata_fields(self): - subset = [field.name for field in self.fields if field.name not in self.system_metadata_fields] - return subset + fields = {} + for field in self.fields: + if field.scope != Scope.settings: + continue + + if field.name in self.system_metadata_fields: + continue + + fields[field.name] = field.read_from(self) + + for field in self.lms.fields: + if field.scope != Scope.settings: + continue + + if field.name in self.system_metadata_fields: + continue + + fields[field.name] = field.read_from(self) + + return fields diff --git a/common/lib/xmodule/xmodule/model.py b/common/lib/xmodule/xmodule/model.py index 9d3e96534e..7c0c466531 100644 --- a/common/lib/xmodule/xmodule/model.py +++ b/common/lib/xmodule/xmodule/model.py @@ -64,11 +64,42 @@ class ModelType(object): return self._seq < other._seq def to_json(self, value): + """ + Return value in the form of nested lists and dictionaries (suitable + for passing to json.dumps). + + This is called during field writes to convert the native python + type to the value stored in the database + """ return value def from_json(self, value): + """ + Return value as a native full featured python type (the inverse of to_json) + + Called during field reads to convert the stored value into a full featured python + object + """ return value + def read_from(self, model): + """ + Retrieve the value for this field from the specified model object + """ + return self.__get__(model, model.__class__) + + def write_to(self, model, value): + """ + Set the value for this field to value on the supplied model object + """ + self.__set__(model, value) + + def delete_from(self, model): + """ + Delete the value for this field from the supplied model object + """ + self.__delete__(model) + Int = Float = Boolean = Object = List = String = Any = ModelType diff --git a/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py index 100bdb1dc6..6187fbaded 100644 --- a/common/lib/xmodule/xmodule/modulestore/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/draft.py @@ -173,13 +173,11 @@ class DraftModuleStore(ModuleStoreBase): Save a current draft to the underlying modulestore """ draft = self.get_item(location) - metadata = {} - metadata.update(draft.metadata) - metadata.cms.published_date = datetime.utcnow() - metadata.cms.published_by = published_by_id - super(DraftModuleStore, self).update_item(location, draft.definition.get('data', {})) - super(DraftModuleStore, self).update_children(location, draft.definition.get('children', [])) - super(DraftModuleStore, self).update_metadata(location, metadata) + draft.cms.published_date = datetime.utcnow() + draft.cms.published_by = published_by_id + super(DraftModuleStore, self).update_item(location, draft._model_data._kvs._data) + super(DraftModuleStore, self).update_children(location, draft._model_data._kvs._children) + super(DraftModuleStore, self).update_metadata(location, draft._model_data._kvs._metadata) self.delete_item(location) def unpublish(self, location): diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index b59e6c50fb..52267d6734 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -250,7 +250,7 @@ class MongoModuleStore(ModuleStoreBase): """ Load an XModuleDescriptor from item, using the children stored in data_cache """ - data_dir = item.get('metadata', {}).get('data_dir', item['location']['course']) + data_dir = getattr(item, 'data_dir', item['location']['course']) root = self.fs_root / data_dir if not root.isdir(): @@ -361,9 +361,9 @@ class MongoModuleStore(ModuleStoreBase): if location.category == 'static_tab': course = self.get_course_for_item(item.location) existing_tabs = course.tabs or [] - existing_tabs.append({'type':'static_tab', 'name' : item.metadata.get('display_name'), 'url_slug' : item.location.name}) + existing_tabs.append({'type':'static_tab', 'name' : item.lms.display_name, 'url_slug' : item.location.name}) course.tabs = existing_tabs - self.update_metadata(course.location, course.metadata) + self.update_metadata(course.location, course._model_data._kvs._metadata) return item except pymongo.errors.DuplicateKeyError: diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index dc1be3eb08..86f9f5f189 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -8,6 +8,7 @@ from collections import namedtuple from pkg_resources import resource_listdir, resource_string, resource_isdir from xmodule.modulestore import Location +from xmodule.modulestore.exceptions import ItemNotFoundError from .model import ModelMetaclass, ParentModelMetaclass, NamespacesMetaclass from .plugin import Plugin From 4bec871b0f4fffac4ff08dcd245d837ca57fd2dd Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 2 Jan 2013 15:07:12 -0500 Subject: [PATCH 061/501] Don't return BadRequest classes, return instances --- cms/djangoapps/contentstore/course_info_model.py | 6 +++--- cms/djangoapps/contentstore/views.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index 23fad96caa..95fa1faca6 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -57,7 +57,7 @@ def update_course_updates(location, update, passed_id=None): try: course_updates = modulestore('direct').get_item(location) except ItemNotFoundError: - return HttpResponseBadRequest + return HttpResponseBadRequest() # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. try: @@ -95,12 +95,12 @@ def delete_course_update(location, update, passed_id): Returns the resulting course_updates b/c their ids change. """ if not passed_id: - return HttpResponseBadRequest + return HttpResponseBadRequest() try: course_updates = modulestore('direct').get_item(location) except ItemNotFoundError: - return HttpResponseBadRequest + return HttpResponseBadRequest() # TODO use delete_blank_text parser throughout and cache as a static var in a class # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break. diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 9ca8e5e8ec..e8211993bd 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -204,7 +204,7 @@ def edit_subsection(request, location): # make sure that location references a 'sequential', otherwise return BadRequest if item.location.category != 'sequential': - return HttpResponseBadRequest + return HttpResponseBadRequest() parent_locs = modulestore().get_parent_locations(location) @@ -1021,7 +1021,7 @@ def module_info(request, module_location): elif real_method == 'POST' or real_method == 'PUT': return HttpResponse(json.dumps(set_module_info(get_modulestore(location), location, request.POST)), mimetype="application/json") else: - return HttpResponseBadRequest + return HttpResponseBadRequest() @login_required @ensure_csrf_cookie From 7f39dd974bc4570d7ce3a916949f90b0b754cfbd Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 2 Jan 2013 15:07:56 -0500 Subject: [PATCH 062/501] Minor whitespace cleanup --- cms/djangoapps/contentstore/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index e8211993bd..2b650a525b 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -242,9 +242,9 @@ def edit_subsection(request, location): 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), 'parent_location': course.location, 'parent_item': parent, - 'policy_metadata' : policy_metadata, - 'subsection_units' : subsection_units, - 'can_view_live' : can_view_live + 'policy_metadata': policy_metadata, + 'subsection_units': subsection_units, + 'can_view_live': can_view_live }) From 4040f9749d7aa95b73c4573bfa2a59fc520dc27b Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 2 Jan 2013 15:08:09 -0500 Subject: [PATCH 063/501] Correctly parse timetuple into datetime --- cms/xmodule_namespace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/xmodule_namespace.py b/cms/xmodule_namespace.py index 0ea729e27b..3ade59532a 100644 --- a/cms/xmodule_namespace.py +++ b/cms/xmodule_namespace.py @@ -8,7 +8,7 @@ class DateTuple(ModelType): ModelType that stores datetime objects as time tuples """ def from_json(self, value): - return datetime.datetime(*value) + return datetime.datetime(*value[0:6]) def to_json(self, value): return list(value.timetuple()) From 0bed19648ecef5ea03480fdb9526c5a241df2015 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 2 Jan 2013 15:08:27 -0500 Subject: [PATCH 064/501] Remove filename and giturl references --- common/djangoapps/xmodule_modifiers.py | 16 ---------------- common/lib/xmodule/xmodule/xml_module.py | 4 ---- lms/templates/courseware/xqa_interface.html | 2 -- lms/templates/staff_problem_info.html | 4 ---- lms/xmodule_namespace.py | 2 -- 5 files changed, 28 deletions(-) diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 30098c94fc..7e6220f121 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -106,20 +106,6 @@ def add_histogram(get_html, module, user): histogram = grade_histogram(module_id) render_histogram = len(histogram) > 0 - # TODO (ichuang): Remove after fall 2012 LMS migration done - if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'): - [filepath, filename] = module.lms.filename - osfs = module.system.filestore - if filename is not None and osfs.exists(filename): - # if original, unmangled filename exists then use it (github - # doesn't like symlinks) - filepath = filename - data_dir = osfs.root_path.rsplit('/')[-1] - edit_link = "%s/%s/tree/master/%s" % (module.lms.giturl, data_dir, filepath) - else: - edit_link = False - # Need to define all the variables that are about to be used - data_dir = "" source_file = module.lms.source_file # source used to generate the problem XML, eg latex or word # useful to indicate to staff if problem has been released or not @@ -135,11 +121,9 @@ def add_histogram(get_html, module, user): 'location': module.location, 'xqa_key': module.lms.xqa_key, 'source_file' : source_file, - 'source_url': '%s/%s/tree/master/%s' % (module.lms.giturl, data_dir, source_file), 'category': str(module.__class__.__name__), # Template uses element_id in js function names, so can't allow dashes 'element_id': module.location.html_id().replace('-','_'), - 'edit_link': edit_link, 'user': user, 'xqa_server' : settings.MITX_FEATURES.get('USE_XQA_SERVER','http://xqa:server@content-qa.mitx.mit.edu/xqa'), 'histogram': json.dumps(histogram), diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index a7c1087db7..e50adeb364 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -230,10 +230,6 @@ class XmlDescriptor(XModuleDescriptor): if definition_metadata: definition['definition_metadata'] = definition_metadata - # TODO (ichuang): remove this after migration - # for Fall 2012 LMS migration: keep filename (and unmangled filename) - definition['filename'] = [ filepath, filename ] - return definition, children @classmethod diff --git a/lms/templates/courseware/xqa_interface.html b/lms/templates/courseware/xqa_interface.html index 73f7cc6f52..b58ad56381 100644 --- a/lms/templates/courseware/xqa_interface.html +++ b/lms/templates/courseware/xqa_interface.html @@ -21,8 +21,6 @@ function sendlog(element_id, edit_link, staff_context){ entry: $('#' + element_id + '_xqa_entry').val() }; - if (edit_link) xqaLog["giturl"] = edit_link; - $.ajax({ url: '${xqa_server}/log', type: 'GET', diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 4fee7c22fb..b858e727b8 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -42,10 +42,6 @@ ${module_content}
        is_released = ${is_released} location = ${location | h} -github = ${edit_link | h} -%if source_file: -source_url = ${source_file | h} -%endif %for name, field in fields: diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index 14a19c6714..5fd2c18bb7 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -34,9 +34,7 @@ class LmsNamespace(Namespace): ) start = Date(help="Start time when this module is visible", scope=Scope.settings) due = String(help="Date that this problem is due by", scope=Scope.settings, default='') - filename = List(help="DO NOT USE", scope=Scope.content, default=['', None]) source_file = String(help="DO NOT USE", scope=Scope.settings) - giturl = String(help="DO NOT USE", scope=Scope.settings, default='https://github.com/MITx') xqa_key = String(help="DO NOT USE", scope=Scope.settings) ispublic = Boolean(help="Whether this course is open to the public, or only to admins", scope=Scope.settings) graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings) From 465c85b04b496176021a54f562a4216867b9160b Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 2 Jan 2013 15:08:49 -0500 Subject: [PATCH 065/501] Make model classes inherit their parents fields attribute --- common/lib/xmodule/xmodule/model.py | 7 ++++++- common/lib/xmodule/xmodule/tests/test_model.py | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/model.py b/common/lib/xmodule/xmodule/model.py index 7c0c466531..b8228cb417 100644 --- a/common/lib/xmodule/xmodule/model.py +++ b/common/lib/xmodule/xmodule/model.py @@ -121,7 +121,12 @@ class ModelMetaclass(type): v._name = n fields.append(v) fields.sort() - attrs['fields'] = fields + attrs['fields'] = sum([ + base.fields + for base + in bases + if hasattr(base, 'fields') + ], fields) return super(ModelMetaclass, cls).__new__(cls, name, bases, attrs) diff --git a/common/lib/xmodule/xmodule/tests/test_model.py b/common/lib/xmodule/xmodule/tests/test_model.py index 20f1207701..c5eaaaefc7 100644 --- a/common/lib/xmodule/xmodule/tests/test_model.py +++ b/common/lib/xmodule/xmodule/tests/test_model.py @@ -15,12 +15,21 @@ def test_model_metaclass(): def __init__(self, model_data): self._model_data = model_data + class ChildClass(ModelMetaclassTester): + pass + assert hasattr(ModelMetaclassTester, 'field_a') assert hasattr(ModelMetaclassTester, 'field_b') assert_in(ModelMetaclassTester.field_a, ModelMetaclassTester.fields) assert_in(ModelMetaclassTester.field_b, ModelMetaclassTester.fields) + assert hasattr(ChildClass, 'field_a') + assert hasattr(ChildClass, 'field_b') + + assert_in(ChildClass.field_a, ChildClass.fields) + assert_in(ChildClass.field_b, ChildClass.fields) + def test_parent_metaclass(): From 8874d9d7f89f3c07818e0b0aca38162fe077ab0f Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 3 Jan 2013 12:21:13 -0500 Subject: [PATCH 066/501] Add a draft implementation of the Poll module for Justice --- common/lib/xmodule/setup.py | 3 +- .../xmodule/js/src/poll/display.coffee | 13 +++++ common/lib/xmodule/xmodule/poll_module.py | 54 +++++++++++++++++++ common/test/data/toy/course/2012_Fall.xml | 1 + lms/djangoapps/courseware/module_render.py | 12 +++++ lms/templates/poll.html | 9 ++++ 6 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 common/lib/xmodule/xmodule/js/src/poll/display.coffee create mode 100644 common/lib/xmodule/xmodule/poll_module.py create mode 100644 lms/templates/poll.html diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index b3a8dabe1b..a405d111be 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -39,7 +39,8 @@ setup( "course_info = xmodule.html_module:CourseInfoDescriptor", "static_tab = xmodule.html_module:StaticTabDescriptor", "custom_tag_template = xmodule.raw_module:RawDescriptor", - "about = xmodule.html_module:AboutDescriptor" + "about = xmodule.html_module:AboutDescriptor", + "poll = xmodule.poll_module:PollDescriptor", ], } ) diff --git a/common/lib/xmodule/xmodule/js/src/poll/display.coffee b/common/lib/xmodule/xmodule/js/src/poll/display.coffee new file mode 100644 index 0000000000..f72ac8d319 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/poll/display.coffee @@ -0,0 +1,13 @@ +class @PollModule + constructor: (element) -> + @el = element + @ajaxUrl = @$('.container').data('url') + @$('.upvote').on('click', () => $.postWithPrefix(@url('upvote'), @handleVote)) + @$('.downvote').on('click', () => $.postWithPrefix(@url('downvote'), @handleVote)) + + $: (selector) -> $(selector, @el) + + url: (target) -> "#{@ajaxUrl}/#{target}" + + handleVote: (response) => + @$('.container').replaceWith(response.results) \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/poll_module.py b/common/lib/xmodule/xmodule/poll_module.py new file mode 100644 index 0000000000..db0b3fd0c4 --- /dev/null +++ b/common/lib/xmodule/xmodule/poll_module.py @@ -0,0 +1,54 @@ +import json +import logging + +from lxml import etree +from pkg_resources import resource_string, resource_listdir + +from xmodule.x_module import XModule +from xmodule.raw_module import RawDescriptor +from .model import Int, Scope, Boolean + +log = logging.getLogger(__name__) + + +class PollModule(XModule): + + js = {'coffee': [resource_string(__name__, 'js/src/poll/display.coffee')]} + js_module_name = "PollModule" + + upvotes = Int(help="Number of upvotes this poll has recieved", scope=Scope.content, default=0) + downvotes = Int(help="Number of downvotes this poll has recieved", scope=Scope.content, default=0) + voted = Boolean(help="Whether this student has voted on the poll", scope=Scope.student_state, default=False) + + def handle_ajax(self, dispatch, get): + ''' + Handle ajax calls to this video. + TODO (vshnayder): This is not being called right now, so the position + is not being saved. + ''' + if self.voted: + return json.dumps({'error': 'Already Voted!'}) + elif dispatch == 'upvote': + self.upvotes += 1 + self.voted = True + return json.dumps({'results': self.get_html()}) + elif dispatch == 'downvote': + self.downvotes += 1 + self.voted = True + return json.dumps({'results': self.get_html()}) + + return json.dumps({'error': 'Unknown Command!'}) + + def get_html(self): + return self.system.render_template('poll.html', { + 'upvotes': self.upvotes, + 'downvotes': self.downvotes, + 'voted': self.voted, + 'ajax_url': self.system.ajax_url, + }) + + +class PollDescriptor(RawDescriptor): + module_class = PollModule + stores_state = True + template_dir_name = "poll" \ No newline at end of file diff --git a/common/test/data/toy/course/2012_Fall.xml b/common/test/data/toy/course/2012_Fall.xml index 46dba8b8d8..31244ef60c 100644 --- a/common/test/data/toy/course/2012_Fall.xml +++ b/common/test/data/toy/course/2012_Fall.xml @@ -3,6 +3,7 @@
        Module Fields
        - + % for k,v in students_per_problem_correct_json['data'].items(): - - - - % endfor -
        ProblemNumber of students
        ProblemNumber of students
        ${k} ${v}
        -

        - -

        -

        Grade distribution:

        - -
        - - - % for k,v in overall_grade_distribution['data'].items(): - - - + % endfor
        GradeNumber of students
        ${k} ${v}
        ${k} ${v}
        - + % else: + ${students_per_problem_correct_json['error']} + % endif + % else: + null data + % endif

        -

        -

        Number of students who dropped off per day before becoming inactive:

        +##

        +##

        Students who attempted at least one exercise:

        +## +## % if attempted_problems is not None: +## % if attempted_problems['status'] == 'success': +##
        +## +## +## % for k,v in attempted_problems['data'].items(): +## +## % endfor +##
        ModuleNumber of students
        ${k} ${v}
        +##
        +## % else: +## ${attempted_problems['error']} +## % endif +## % else: +## null data +## % endif +##

        +## +##

        +##

        Number of students who dropped off per day before becoming inactive:

        +## +## % if dropoff_per_day is not None: +## % if dropoff_per_day['status'] == 'success': +##
        +## +## +## % for k,v in dropoff_per_day['data'].items(): +## +## % endfor +##
        DayNumber of students
        ${k} ${v}
        +##
        +## % else: +## ${dropoff_per_day['error']} +## % endif +## % else: +## null data +## % endif +##

        +## +##

        +##

        Grade distribution:

        +## +## % if overall_grade_distribution is not None: +## % if overall_grade_distribution['status'] == 'success': +##
        +## +## +## % for k,v in overall_grade_distribution['data'].items(): +## +## % endfor +##
        GradeNumber of students
        ${k} ${v}
        +##
        +## % else: +## ${dropoff_per_day['error']} +## % endif +## % else: +## null data +## % endif +##

        -
        - - - % for k,v in dropoff_per_day['data'].items(): - - - - % endfor -
        DayNumber of students
        ${k} ${v}
        -
        -

        -

        -

        Students who attempted at least one exercise:

        +##

        +##

        Daily activity (online version):

        +## +## +## % for k,v in daily_activity_json['data'].items(): +## +## +## +## % endfor +##
        DayNumber of students
        ${k} ${v}
        +##

        -
        - - - % for k,v in attempted_problems['data'].items(): - - - - % endfor -
        ModuleNumber of students
        ${k} ${v}
        -
        - -

        - -

        -

        Daily activity (online version):

        - - - % for k,v in daily_activity_json['data'].items(): - - - - % endfor -
        DayNumber of students
        ${k} ${v}
        -

        %endif From 85c37792869b4d0db1ddb1e6db85652d85a9527d Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 22 Jan 2013 15:21:18 -0500 Subject: [PATCH 070/501] Use the v0.1 tag in the XBlock repo. --- github-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/github-requirements.txt b/github-requirements.txt index cf6f097aee..883490da99 100644 --- a/github-requirements.txt +++ b/github-requirements.txt @@ -3,4 +3,4 @@ -e git://github.com/MITx/django-pipeline.git#egg=django-pipeline -e git://github.com/MITx/django-wiki.git@e2e84558#egg=django-wiki -e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev --e git+ssh://git@github.com/MITx/xmodule-debugger@ada10f2991cdd61c60ec223b4e0b9b4e06d7cdc3#egg=XBlock \ No newline at end of file +-e git+ssh://git@github.com/MITx/xmodule-debugger@v0.1#egg=XBlock From 532285e558d2bfa87c468974428abf1b26b23457 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 22 Jan 2013 15:22:30 -0500 Subject: [PATCH 071/501] Only need one of these lines. --- common/lib/xmodule/xmodule/x_module.py | 1 - 1 file changed, 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 5ce74ffc46..3526990745 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -324,7 +324,6 @@ class XModuleDescriptor(HTMLSnippet, ResourceTemplates, XBlock): self._model_data = model_data self._child_instances = None - self._child_instances = None def get_children(self): """Returns a list of XModuleDescriptor instances for the children of From 4f005044d62cb557e916f6e10235df3d332ce776 Mon Sep 17 00:00:00 2001 From: jmvt Date: Wed, 23 Jan 2013 10:17:17 -0500 Subject: [PATCH 072/501] Added time of query display. --- .../courseware/instructor_dashboard.html | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 3260ee569b..967088de19 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -348,7 +348,7 @@ function goto( mode) Number of students enrolled: % if students_enrolled_json is not None: % if students_enrolled_json['status'] == 'success': - ${students_enrolled_json['data']['value']} + ${students_enrolled_json['data']['value']} as of ${students_enrolled_json['time']} % else: ${students_enrolled_json['error']} % endif @@ -361,7 +361,7 @@ function goto( mode) Number of active students for the past 7 days: % if students_active_json is not None: % if students_active_json['status'] == 'success': - ${students_active_json['data']['value']} + ${students_active_json['data']['value']} as of ${students_active_json['time']} % else: ${students_active_json['error']} % endif @@ -400,7 +400,7 @@ function goto( mode)

        -

        Number of active students per problems who have this problem graded as correct:

        +

        Number of students per problem who have this problem graded as correct, as of ${students_per_problem_correct_json['time']}

        % if students_per_problem_correct_json is not None: % if students_per_problem_correct_json['status'] == 'success': @@ -420,28 +420,30 @@ function goto( mode) % endif

        -##

        -##

        Students who attempted at least one exercise:

        -## -## % if attempted_problems is not None: -## % if attempted_problems['status'] == 'success': -##
        -## -## -## % for k,v in attempted_problems['data'].items(): -## -## % endfor -##
        ModuleNumber of students
        ${k} ${v}
        -##
        -## % else: -## ${attempted_problems['error']} -## % endif -## % else: -## null data -## % endif -##

        -## -##

        +

        + Students per module who attempted at least one problem + + % if attempted_problems is not None: + , as of ${attempted_problems['time']} + % if attempted_problems['status'] == 'success': +

        + + + % for k,v in attempted_problems['data'].items(): + + % endfor +
        ModuleNumber of students
        ${k} ${v}
        +
        + % else: + ${attempted_problems['error']} + % endif + % else: + : null data + % endif + +

        +

        + ##

        Number of students who dropped off per day before becoming inactive:

        ## ## % if dropoff_per_day is not None: From 376575dedd26dc935e2171e5f51aef353f9f1e12 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 23 Jan 2013 11:34:24 -0500 Subject: [PATCH 073/501] Make system.xblock_model_data more explicit to fix the problem where error descriptors can't get any data because the schema and the data don't match. --- cms/djangoapps/contentstore/views.py | 4 ++-- common/lib/xmodule/xmodule/tests/__init__.py | 2 +- common/lib/xmodule/xmodule/x_module.py | 2 +- lms/djangoapps/courseware/module_render.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 2fd395cff9..614dd85d34 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -454,9 +454,9 @@ def preview_module_system(request, preview_id, descriptor): descriptor: An XModuleDescriptor """ - def preview_model_data(model_data): + def preview_model_data(descriptor): return DbModel( - SessionKeyValueStore(request, model_data), + SessionKeyValueStore(request, descriptor._model_data), descriptor.module_class, preview_id, MongoUsage(preview_id, descriptor.location.url()), diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index e8b71b53fc..2db8523a72 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -31,7 +31,7 @@ i4xs = ModuleSystem( xqueue={'interface':None, 'callback_url':'/', 'default_queuename': 'testqueue', 'waittime': 10}, node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), anonymous_student_id = 'student', - xblock_model_data = lambda x: x, + xblock_model_data = lambda descriptor: descriptor._model_data, ) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 3526990745..78489f05f4 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -362,7 +362,7 @@ class XModuleDescriptor(HTMLSnippet, ResourceTemplates, XBlock): system, self.location, self, - system.xblock_model_data(self._model_data), + system.xblock_model_data(self), ) def has_dynamic_children(self): diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 4d599d128d..cfa082a329 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -203,9 +203,9 @@ def _get_module(user, request, location, student_module_cache, course_id, positi return get_module(user, request, location, student_module_cache, course_id, position) - def xblock_model_data(descriptor_model_data): + def xblock_model_data(descriptor): return DbModel( - LmsKeyValueStore(course_id, user, descriptor_model_data, student_module_cache), + LmsKeyValueStore(course_id, user, descriptor._model_data, student_module_cache), descriptor.module_class, user.id, LmsUsage(location, location) From 6f322bcd814727e27e6534f732e02b33e33992d8 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 23 Jan 2013 11:34:42 -0500 Subject: [PATCH 074/501] Simplify the construction of the ErrorDescriptor --- lms/djangoapps/courseware/module_render.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index cfa082a329..ea51def3ae 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -276,15 +276,16 @@ def _get_module(user, request, location, student_module_cache, course_id, positi log.exception("Error creating module from descriptor {0}".format(descriptor)) # make an ErrorDescriptor -- assuming that the descriptor's system is ok - import_system = descriptor.system if has_access(user, location, 'staff', course_id): - err_descriptor = ErrorDescriptor.from_xml(str(descriptor), import_system, - org=descriptor.location.org, course=descriptor.location.course, - error_msg=exc_info_to_str(sys.exc_info())) + err_descriptor_class = ErrorDescriptor else: - err_descriptor = NonStaffErrorDescriptor.from_xml(str(descriptor), import_system, - org=descriptor.location.org, course=descriptor.location.course, - error_msg=exc_info_to_str(sys.exc_info())) + err_descriptor_class = NonStaffErrorDescriptor + + err_descriptor = err_descriptor_class.from_xml( + str(descriptor), descriptor.system, + org=descriptor.location.org, course=descriptor.location.course, + error_msg=exc_info_to_str(sys.exc_info()) + ) # Make an error module return err_descriptor.xmodule(system) From 6e7afb968d4c53500b8a7f1e3d137b23f84cd784 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Sat, 5 Jan 2013 12:20:38 -0500 Subject: [PATCH 075/501] Move xqueue handler to new module format, and continue to distinguish grades set by xqueue from those set by ajax in stats --- lms/djangoapps/courseware/module_render.py | 50 +++++++--------------- 1 file changed, 15 insertions(+), 35 deletions(-) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index ea51def3ae..4315b923ce 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -115,7 +115,9 @@ def toc_for_course(user, request, course, active_chapter, active_section): return chapters -def get_module(user, request, location, student_module_cache, course_id, position=None, not_found_ok = False, wrap_xmodule_display = True): +def get_module(user, request, location, student_module_cache, course_id, + position=None, not_found_ok = False, wrap_xmodule_display=True, + grade_bucket_type=None): """ Get an instance of the xmodule class identified by location, setting the state based on an existing StudentModule, or creating one if none @@ -147,7 +149,8 @@ def get_module(user, request, location, student_module_cache, course_id, positio return None -def _get_module(user, request, location, student_module_cache, course_id, position=None, wrap_xmodule_display=True): +def _get_module(user, request, location, student_module_cache, course_id, + position=None, wrap_xmodule_display=True, grade_bucket_type=None): """ Actually implement get_module. See docstring there for details. """ @@ -234,14 +237,16 @@ def _get_module(user, request, location, student_module_cache, course_id, positi #Bin score into range and increment stats score_bucket = get_score_bucket(student_module.grade, student_module.max_grade) org, course_num, run = course_id.split("/") - statsd.increment("lms.courseware.question_answered", - tags=["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run), - "score_bucket:{0}".format(score_bucket), - "type:ajax"]) + tags = ["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run), + "score_bucket:{0}".format(score_bucket)] + if grade_bucket_type is not None: + tags.append('type:%s' % grade_bucket_type) + + statsd.increment("lms.courseware.question_answered", tags=tags) # TODO (cpennington): When modules are shared between courses, the static # prefix is going to have to be specific to the module, not the directory @@ -332,20 +337,11 @@ def xqueue_callback(request, course_id, userid, id, dispatch): student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, user, modulestore().get_instance(course_id, id), depth=0, select_for_update=True) - instance = get_module(user, request, id, student_module_cache, course_id) + instance = get_module(user, request, id, student_module_cache, course_id, grade_bucket_type='xqueue') if instance is None: log.debug("No module {0} for user {1}--access denied?".format(id, user)) raise Http404 - instance_module = get_instance_module(course_id, user, instance, student_module_cache) - - if instance_module is None: - log.debug("Couldn't find instance of module '%s' for user '%s'", id, user) - raise Http404 - - oldgrade = instance_module.grade - old_instance_state = instance_module.state - # Transfer 'queuekey' from xqueue response header to 'get'. This is required to # use the interface defined by 'handle_ajax' get.update({'queuekey': header['lms_key']}) @@ -359,22 +355,6 @@ def xqueue_callback(request, course_id, userid, id, dispatch): log.exception("error processing ajax call") raise - # Save state back to database - instance_module.state = instance.get_instance_state() - if instance.get_score(): - instance_module.grade = instance.get_score()['score'] - if instance_module.grade != oldgrade or instance_module.state != old_instance_state: - instance_module.save() - - #Bin score into range and increment stats - score_bucket=get_score_bucket(instance_module.grade, instance_module.max_grade) - org, course_num, run=course_id.split("/") - statsd.increment("lms.courseware.question_answered", - tags=["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run), - "score_bucket:{0}".format(score_bucket), - "type:xqueue"]) return HttpResponse("") @@ -417,7 +397,7 @@ def modx_dispatch(request, dispatch, location, course_id): student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, request.user, modulestore().get_instance(course_id, location)) - instance = get_module(request.user, request, location, student_module_cache, course_id) + instance = get_module(request.user, request, location, student_module_cache, course_id, grade_bucket_type='ajax') if instance is None: # Either permissions just changed, or someone is trying to be clever # and load something they shouldn't have access to. From 8e5373f99352af80db12cefa538afe6e8b4c1c2b Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 8 Jan 2013 11:44:20 -0500 Subject: [PATCH 076/501] Make Randomization return un-adjusted values as a passthrough --- common/lib/xmodule/xmodule/capa_module.py | 1 + 1 file changed, 1 insertion(+) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 0d04b419a5..ee09d5dc41 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -39,6 +39,7 @@ class Randomization(String): return "always" elif value == "false": return "per_student" + return value to_json = from_json From 9d66169d0679065fb4d306663c27066291496f9f Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 8 Jan 2013 11:44:31 -0500 Subject: [PATCH 077/501] Don't set a seed to None that's already None --- common/lib/xmodule/xmodule/capa_module.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index ee09d5dc41..04bbf98cd5 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -107,8 +107,6 @@ class CapaModule(XModule): # to these bins, and may not want cohorts. So e.g. hash(your-id, problem_id) % num_bins. # - analytics really needs small number of bins. self.seed = system.id - else: - self.seed = None try: # TODO (vshnayder): move as much as possible of this work and error From ce76b64bae30e4fec7dcbf0c7e5da452850a46d0 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 8 Jan 2013 11:44:58 -0500 Subject: [PATCH 078/501] Handle problem resets in such a way that the LoncapaProblem generates the new random seed, and the CapaModule saves that seed --- common/lib/xmodule/xmodule/capa_module.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 04bbf98cd5..589c1b0ce7 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -611,14 +611,18 @@ class CapaModule(XModule): return {'success': False, 'error': "Refresh the page and make an attempt before resetting."} - self.lcp.do_reset() if self.rerandomize in ["always", "onreset"]: # reset random number generator seed (note the self.lcp.get_state() # in next line) - self.lcp.seed = None + seed = None + else: + seed = self.lcp.seed + # Generate a new problem with either the previous seed or a new seed + self.lcp = self.new_lcp({'seed': seed}) + + # Pull in the new problem seed self.set_state_from_lcp() - self.lcp = self.new_lcp(self.get_state_for_lcp()) event_info['new_state'] = self.lcp.get_state() self.system.track_function('reset_problem', event_info) From e5a6509bb043381d39058cba77ab514e2c5d67bb Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 8 Jan 2013 11:51:20 -0500 Subject: [PATCH 079/501] Add a first_time_user attribute to CourseModule so that we can check for that explicitly, rather than looking for an implicitly created StudentModule --- common/lib/xmodule/xmodule/course_module.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 3b73b6a4d1..2bab5580f6 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -89,8 +89,18 @@ class TextbookList(ModelType): return json_data +class CourseModule(SequenceModule): + first_time_user = Boolean(help="Whether this is the first time the user has visited this course", scope=Scope.student_state, default=True) + + def __init__(self, *args, **kwargs): + super(CourseModule, self).__init__(*args, **kwargs) + + if self.first_time_user: + self.first_time_user = False + + class CourseDescriptor(SequenceDescriptor): - module_class = SequenceModule + module_class = CourseModule textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", default=[], scope=Scope.content) wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) From 2eca8bd0e96dd2955d40c7a7d23ae10b5c89127d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 8 Jan 2013 11:51:51 -0500 Subject: [PATCH 080/501] Set default video position to 0, but don't overwrite it during __init__ --- common/lib/xmodule/xmodule/video_module.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index e284c574d3..b71e7bfa0d 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -29,7 +29,7 @@ class VideoModule(XModule): js_module_name = "Video" data = String(help="XML data for the problem", scope=Scope.content) - position = Integer(help="Current position in the video", scope=Scope.student_state) + position = Integer(help="Current position in the video", scope=Scope.student_state, default=0) display_name = String(help="Display name for this module", scope=Scope.settings) def __init__(self, *args, **kwargs): @@ -37,7 +37,6 @@ class VideoModule(XModule): xmltree = etree.fromstring(self.data) self.youtube = xmltree.get('youtube') - self.position = 0 self.show_captions = xmltree.get('show_captions', 'true') self.source = self._get_source(xmltree) self.track = self._get_track(xmltree) From c44898b1de506648bc1c52a3c381531ad77c7dcc Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 8 Jan 2013 11:52:32 -0500 Subject: [PATCH 081/501] Remove random printing during XModuleDescriptor equality check --- common/lib/xmodule/xmodule/x_module.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 78489f05f4..d040e2039a 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -3,7 +3,6 @@ import yaml import os from lxml import etree -from pprint import pprint from collections import namedtuple from pkg_resources import resource_listdir, resource_string, resource_isdir @@ -503,12 +502,6 @@ class XModuleDescriptor(HTMLSnippet, ResourceTemplates, XBlock): all(getattr(self, attr, None) == getattr(other, attr, None) for attr in self.equality_attributes)) - if not eq: - for attr in self.equality_attributes: - pprint((getattr(self, attr, None), - getattr(other, attr, None), - getattr(self, attr, None) == getattr(other, attr, None))) - return eq def __repr__(self): From 4779043a5f65fc5aebf6903b14b716e8b483451d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 8 Jan 2013 11:54:12 -0500 Subject: [PATCH 082/501] Add message during 404 for debugging --- lms/djangoapps/courseware/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 7ed0685704..8096aa0df4 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -133,7 +133,7 @@ def redirect_to_course_position(course_module, first_time): chapter = get_current_child(course_module) if chapter is None: # oops. Something bad has happened. - raise Http404 + raise Http404("No chapter found when loading current position in course") if not first_time: return redirect(reverse('courseware_chapter', kwargs={'course_id': course_id, 'chapter': chapter.url_name})) From abb21ab111e2ecf0f5fdface265c97e899379aab Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 8 Jan 2013 12:10:52 -0500 Subject: [PATCH 083/501] staff_problem_info.html only renders for staff, so don't check for staff access --- lms/templates/staff_problem_info.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index b858e727b8..55902e3dc1 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -67,7 +67,6 @@ category = ${category | h} From 507d2021cdd994c4f72fc68213f7849b245fe345 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 8 Jan 2013 12:11:37 -0500 Subject: [PATCH 084/501] Remove unused reference to StudentModuleCache --- lms/djangoapps/django_comment_client/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index bb91dfaa28..bfe397d499 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -2,7 +2,6 @@ import time from collections import defaultdict from importlib import import_module -from courseware.models import StudentModuleCache from courseware.module_render import get_module from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore From 993c24b72b38564a2647e0a2757806f755382bb2 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 8 Jan 2013 16:24:28 -0500 Subject: [PATCH 085/501] WIP: Model data caching work --- common/lib/xmodule/xmodule/course_module.py | 2 +- lms/djangoapps/courseware/courses.py | 38 +- lms/djangoapps/courseware/grades.py | 98 ++--- .../management/commands/check_course.py | 4 +- lms/djangoapps/courseware/model_data.py | 338 +++++++++++++----- lms/djangoapps/courseware/models.py | 103 ------ lms/djangoapps/courseware/module_render.py | 49 ++- .../courseware/tests/test_model_data.py | 63 ++-- lms/djangoapps/courseware/tests/tests.py | 10 +- lms/djangoapps/courseware/views.py | 29 +- lms/templates/courseware/info.html | 8 +- 11 files changed, 428 insertions(+), 314 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 2bab5580f6..2864aaba1d 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -358,7 +358,7 @@ class CourseDescriptor(SequenceDescriptor): all_descriptors - This contains a list of all xmodules that can effect grading a student. This is used to efficiently fetch - all the xmodule state for a StudentModuleCache without walking + all the xmodule state for a ModelDataCache without walking the descriptor tree again. diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 5312a228a4..a1495edb53 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -19,10 +19,10 @@ from xmodule.contentstore.content import StaticContent from xmodule.modulestore.xml import XMLModuleStore from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.x_module import XModule +from courseware.model_data import ModelDataCache from static_replace import replace_urls, try_staticfiles_lookup from courseware.access import has_access import branding -from courseware.models import StudentModuleCache from xmodule.modulestore.exceptions import ItemNotFoundError log = logging.getLogger(__name__) @@ -148,12 +148,23 @@ def get_course_about_section(course, section_key): request = get_request_for_thread() loc = course.location._replace(category='about', name=section_key) - course_module = get_module(request.user, request, loc, None, course.id, not_found_ok = True, wrap_xmodule_display = True) + + # Use an empty cache + model_data_cache = ModelDataCache([], course.id, request.user) + about_module = get_module( + request.user, + request, + loc, + model_data_cache, + course.id, + not_found_ok=True, + wrap_xmodule_display=True + ) html = '' - if course_module is not None: - html = course_module.get_html() + if about_module is not None: + html = about_module.get_html() return html @@ -174,7 +185,7 @@ def get_course_about_section(course, section_key): -def get_course_info_section(request, cache, course, section_key): +def get_course_info_section(request, course, section_key): """ This returns the snippet of html to be rendered on the course info page, given the key for the section. @@ -188,11 +199,22 @@ def get_course_info_section(request, cache, course, section_key): loc = Location(course.location.tag, course.location.org, course.location.course, 'course_info', section_key) - course_module = get_module(request.user, request, loc, cache, course.id, wrap_xmodule_display = True) + + # Use an empty cache + model_data_cache = ModelDataCache([], course.id, request.user) + info_module = get_module( + request.user, + request, + loc, + model_data_cache, + course.id, + wrap_xmodule_display=True + ) + html = '' - if course_module is not None: - html = course_module.get_html() + if info_module is not None: + html = info_module.get_html() return html diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 0c6220e561..2037c48675 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -8,7 +8,8 @@ from collections import defaultdict from django.conf import settings from django.contrib.auth.models import User -from models import StudentModuleCache +from .model_data import ModelDataCache, LmsKeyValueStore +from xblock.core import Scope from module_render import get_module from xmodule import graders from xmodule.capa_module import CapaModule @@ -57,37 +58,39 @@ def yield_problems(request, course, student): the list, but there may be others as well). """ grading_context = course.grading_context - student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors']) + descriptor_locations = (descriptor.location.url() for descriptor in grading_context['all_descriptors']) + existing_student_modules = set(StudentModule.objects.filter( + module_state_key__in=descriptor_locations + ).values_list('module_state_key', flat=True)) + + sections_to_list = [] for section_format, sections in grading_context['graded_sections'].iteritems(): for section in sections: section_descriptor = section['section_descriptor'] # If the student hasn't seen a single problem in the section, skip it. - skip = True for moduledescriptor in section['xmoduledescriptors']: - if student_module_cache.lookup( - course.id, moduledescriptor.category, moduledescriptor.location.url()): - skip = False + if moduledescriptor.location.url() in existing_student_modules: + sections_to_list.append(section_descriptor) break - if skip: - continue + model_data_cache = ModelDataCache(sections_to_list, course.id, student) + for section_descriptor in sections_to_list: + section_module = get_module(student, request, + section_descriptor.location, model_data_cache, + course.id) + if section_module is None: + # student doesn't have access to this module, or something else + # went wrong. + # log.debug("couldn't get module for student {0} for section location {1}" + # .format(student.username, section_descriptor.location)) + continue - section_module = get_module(student, request, - section_descriptor.location, student_module_cache, - course.id) - if section_module is None: - # student doesn't have access to this module, or something else - # went wrong. - # log.debug("couldn't get module for student {0} for section location {1}" - # .format(student.username, section_descriptor.location)) - continue - - for problem in yield_module_descendents(section_module): - if isinstance(problem, CapaModule): - yield problem + for problem in yield_module_descendents(section_module): + if isinstance(problem, CapaModule): + yield problem def answer_distributions(request, course): """ @@ -116,7 +119,7 @@ def answer_distributions(request, course): return counts -def grade(student, request, course, student_module_cache=None, keep_raw_scores=False): +def grade(student, request, course, model_data_cache=None, keep_raw_scores=False): """ This grades a student as quickly as possible. It retuns the output from the course grader, augmented with the final letter @@ -138,8 +141,8 @@ def grade(student, request, course, student_module_cache=None, keep_raw_scores=F grading_context = course.grading_context raw_scores = [] - if student_module_cache == None: - student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors']) + if model_data_cache == None: + model_data_cache = ModelDataCache(grading_context['all_descriptors'], course.id, student) totaled_scores = {} # This next complicated loop is just to collect the totaled_scores, which is @@ -153,8 +156,14 @@ def grade(student, request, course, student_module_cache=None, keep_raw_scores=F should_grade_section = False # If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0% for moduledescriptor in section['xmoduledescriptors']: - if student_module_cache.lookup( - course.id, moduledescriptor.category, moduledescriptor.location.url()): + # Create a fake key to pull out a StudentModule object from the ModelDataCache + key = LmsKeyValueStore.Key( + Scope.student_state, + student.id, + moduledescriptor.location, + None + ) + if model_data_cache.find(key): should_grade_section = True break @@ -165,11 +174,11 @@ def grade(student, request, course, student_module_cache=None, keep_raw_scores=F # TODO: We need the request to pass into here. If we could forgo that, our arguments # would be simpler return get_module(student, request, descriptor.location, - student_module_cache, course.id) + model_data_cache, course.id) for module_descriptor in yield_dynamic_descriptor_descendents(section_descriptor, create_module): - (correct, total) = get_score(course.id, student, module_descriptor, create_module, student_module_cache) + (correct, total) = get_score(course.id, student, module_descriptor, create_module, model_data_cache) if correct is None and total is None: continue @@ -240,7 +249,7 @@ def grade_for_percentage(grade_cutoffs, percentage): # TODO: This method is not very good. It was written in the old course style and # then converted over and performance is not good. Once the progress page is redesigned # to not have the progress summary this method should be deleted (so it won't be copied). -def progress_summary(student, request, course, student_module_cache): +def progress_summary(student, request, course, model_data_cache): """ This pulls a summary of all problems in the course. @@ -254,7 +263,7 @@ def progress_summary(student, request, course, student_module_cache): Arguments: student: A User object for the student to grade course: A Descriptor containing the course to grade - student_module_cache: A StudentModuleCache initialized with all + model_data_cache: A ModelDataCache initialized with all instance_modules for the student If the student does not have access to load the course module, this function @@ -266,7 +275,7 @@ def progress_summary(student, request, course, student_module_cache): # TODO: We need the request to pass into here. If we could forgo that, our arguments # would be simpler course_module = get_module(student, request, - course.location, student_module_cache, + course.location, model_data_cache, course.id) if not course_module: # This student must not have access to the course. @@ -294,7 +303,7 @@ def progress_summary(student, request, course, student_module_cache): for module_descriptor in yield_dynamic_descriptor_descendents(section_module.descriptor, module_creator): course_id = course.id - (correct, total) = get_score(course_id, student, module_descriptor, module_creator, student_module_cache) + (correct, total) = get_score(course_id, student, module_descriptor, module_creator, model_data_cache) if correct is None and total is None: continue @@ -324,7 +333,7 @@ def progress_summary(student, request, course, student_module_cache): return chapters -def get_score(course_id, user, problem_descriptor, module_creator, student_module_cache): +def get_score(course_id, user, problem_descriptor, module_creator, model_data_cache): """ Return the score for a user on a problem, as a tuple (correct, total). e.g. (5,7) if you got 5 out of 7 points. @@ -336,7 +345,7 @@ def get_score(course_id, user, problem_descriptor, module_creator, student_modul problem_descriptor: an XModuleDescriptor module_creator: a function that takes a descriptor, and returns the corresponding XModule for this user. Can return None if user doesn't have access, or if something else went wrong. - cache: A StudentModuleCache + cache: A ModelDataCache """ if not user.is_authenticated(): return (None, None) @@ -345,16 +354,23 @@ def get_score(course_id, user, problem_descriptor, module_creator, student_modul # These are not problems, and do not have a score return (None, None) - instance_module = student_module_cache.lookup( - course_id, problem_descriptor.category, problem_descriptor.location.url()) + # Create a fake KeyValueStore key to pull out the StudentModule + key = LmsKeyValueStore.Key( + Scope.student_state, + user.id, + problem_descriptor.location, + None + ) - if instance_module is not None and instance_module.max_grade is not None: - correct = instance_module.grade if instance_module.grade is not None else 0 - total = instance_module.max_grade + student_module = model_data_cache.find(key) + + if student_module is not None and student_module.max_grade is not None: + correct = student_module.grade if student_module.grade is not None else 0 + total = student_module.max_grade else: # If the problem was not in the cache, or hasn't been graded yet, # we need to instantiate the problem. - # Otherwise, the max score (cached in instance_module) won't be available + # Otherwise, the max score (cached in student_module) won't be available problem = module_creator(problem_descriptor) if problem is None: return (None, None) @@ -371,7 +387,7 @@ def get_score(course_id, user, problem_descriptor, module_creator, student_modul weight = getattr(problem_descriptor, 'weight', None) if weight is not None: if total == 0: - log.exception("Cannot reweight a problem with zero total points. Problem: " + str(instance_module)) + log.exception("Cannot reweight a problem with zero total points. Problem: " + str(student_module)) return (correct, total) correct = correct * weight / total total = weight diff --git a/lms/djangoapps/courseware/management/commands/check_course.py b/lms/djangoapps/courseware/management/commands/check_course.py index adb8bff709..58f8933cd8 100644 --- a/lms/djangoapps/courseware/management/commands/check_course.py +++ b/lms/djangoapps/courseware/management/commands/check_course.py @@ -13,7 +13,7 @@ import xmodule import mitxmako.middleware as middleware middleware.MakoMiddleware() from xmodule.modulestore.django import modulestore -from courseware.models import StudentModuleCache +from courseware.model_data import ModelDataCache from courseware.module_render import get_module @@ -83,7 +83,7 @@ class Command(BaseCommand): # TODO (cpennington): Get coursename in a legitimate way course_location = 'i4x://edx/6002xs12/course/6.002_Spring_2012' - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( + student_module_cache = ModelDataCache.cache_for_descriptor_descendents( course_id, sample_user, modulestore().get_item(course_location)) course = get_module(sample_user, None, course_location, student_module_cache) diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index 6ae75a2c71..1e2b3323a1 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -1,5 +1,6 @@ import json -from collections import namedtuple +from collections import namedtuple, defaultdict +from itertools import chain from .models import ( StudentModule, XModuleContentField, @@ -16,6 +17,235 @@ class InvalidWriteError(Exception): pass +def chunks(items, chunk_size): + items = list(items) + return (items[i:i + chunk_size] for i in xrange(0, len(items), chunk_size)) + + +class ModelDataCache(object): + """ + A cache of django model objects needed to supply the data + for a module and its decendents + """ + def __init__(self, descriptors, course_id, user, select_for_update=False): + ''' + Find any courseware.models objects that are needed by any descriptor + in descriptors. Attempts to minimize the number of queries to the database. + Note: Only modules that have store_state = True or have shared + state will have a StudentModule. + + Arguments + descriptors: An array of XModuleDescriptors. + course_id: The id of the current course + user: The user for which to cache data + select_for_update: Flag indicating whether the rows should be locked until end of transaction + ''' + self.cache = {} + self.descriptors = descriptors + self.select_for_update = select_for_update + self.course_id = course_id + self.user = user + + if user.is_authenticated(): + for scope, fields in self._fields_to_cache().items(): + for field_object in self._retrieve_fields(scope, fields): + self.cache[self._cache_key_from_field_object(scope, field_object)] = field_object + + @classmethod + def cache_for_descriptor_descendents(cls, course_id, user, descriptor, depth=None, + descriptor_filter=lambda descriptor: True, + select_for_update=False): + """ + course_id: the course in the context of which we want StudentModules. + user: the django user for whom to load modules. + descriptor: An XModuleDescriptor + depth is the number of levels of descendent modules to load StudentModules for, in addition to + the supplied descriptor. If depth is None, load all descendent StudentModules + descriptor_filter is a function that accepts a descriptor and return wether the StudentModule + should be cached + select_for_update: Flag indicating whether the rows should be locked until end of transaction + """ + + def get_child_descriptors(descriptor, depth, descriptor_filter): + if descriptor_filter(descriptor): + descriptors = [descriptor] + else: + descriptors = [] + + if depth is None or depth > 0: + new_depth = depth - 1 if depth is not None else depth + + for child in descriptor.get_children(): + descriptors.extend(get_child_descriptors(child, new_depth, descriptor_filter)) + + return descriptors + + descriptors = get_child_descriptors(descriptor, depth, descriptor_filter) + + return ModelDataCache(descriptors, course_id, user, select_for_update) + + def _query(self, model_class, **kwargs): + """ + Queries model_class with **kwargs, optionally adding select_for_update if + self.select_for_update is set + """ + query = model_class.objects + if self.select_for_update: + query = query.select_for_update() + query = query.filter(**kwargs) + return query + + def _chunked_query(self, model_class, chunk_field, items, chunk_size=500, **kwargs): + """ + Queries model_class with `chunk_field` set to chunks of size `chunk_size`, + and all other parameters from `**kwargs` + + This works around a limitation in sqlite3 on the number of parameters + that can be put into a single query + """ + res = chain.from_iterable( + self._query(model_class, **dict([(chunk_field, chunk)] + kwargs.items())) + for chunk in chunks(items, chunk_size) + ) + return res + + def _retrieve_fields(self, scope, fields): + """ + Queries the database for all of the fields in the specified scope + """ + if scope in (Scope.children, Scope.parent): + return [] + elif scope == Scope.student_state: + return self._chunked_query( + StudentModule, + 'module_state_key__in', + (descriptor.location.url() for descriptor in self.descriptors), + course_id=self.course_id, + student=self.user, + ) + elif scope == Scope.content: + return self._chunked_query( + XModuleContentField, + 'definition_id__in', + (descriptor.location.url() for descriptor in self.descriptors), + field_name__in=set(field.name for field in fields), + ) + elif scope == Scope.settings: + return self._chunked_query( + XModuleSettingsField, + 'usage_id__in', + ( + '%s-%s' % (self.course_id, descriptor.location.url()) + for descriptor in self.descriptors + ), + field_name__in=set(field.name for field in fields), + ) + elif scope == Scope.student_preferences: + return self._chunked_query( + XModuleStudentPrefsField, + 'module_type__in', + set(descriptor.location.category for descriptor in self.descriptors), + student=self.user, + field_name__in=set(field.name for field in fields), + ) + elif scope == Scope.student_info: + self._query( + XModuleStudentInfoField, + student=self.user, + field_name__in=set(field.name for field in fields), + ) + else: + raise InvalidScopeError(scope) + + def _fields_to_cache(self): + """ + Returns a map of scopes to fields in that scope that should be cached + """ + scope_map = defaultdict(set) + for descriptor in self.descriptors: + for field in (descriptor.module_class.fields + descriptor.module_class.lms.fields): + scope_map[field.scope].add(field) + return scope_map + + def _cache_key_from_kvs_key(self, key): + if key.scope == Scope.student_state: + return (key.scope, key.block_scope_id.url()) + elif key.scope == Scope.content: + return (key.scope, key.block_scope_id.url(), key.field_name) + elif key.scope == Scope.settings: + return (key.scope, '%s-%s' % (self.course_id, key.block_scope_id.url()), key.field_name) + elif key.scope == Scope.student_preferences: + return (key.scope, key.block_scope_id, key.field_name) + elif key.scope == Scope.student_info: + return (key.scope, key.field_name) + + def _cache_key_from_field_object(self, scope, field_object): + if scope == Scope.student_state: + return (scope, field_object.module_state_key) + elif scope == Scope.content: + return (scope, field_object.definition_id, field_object.field_name) + elif scope == Scope.settings: + return (scope, field_object.usage_id, field_object.field_name) + elif scope == Scope.student_preferences: + return (scope, field_object.module_type, field_object.field_name) + elif scope == Scope.student_info: + return (scope, field_object.field_name) + + def find(self, key): + ''' + Look for a model data object using an LmsKeyValueStore.Key object + + key: An `LmsKeyValueStore.Key` object selecting the object to find + + returns the found object, or None if the object doesn't exist + ''' + return self.cache.get(self._cache_key_from_kvs_key(key)) + + def find_or_create(self, key): + ''' + Find a model data object in this cache, or create it if it doesn't + exist + ''' + field_object = self.find(key) + + if field_object is not None: + return field_object + + if key.scope == Scope.student_state: + field_object, _ = StudentModule.objects.get_or_create( + course_id=self.course_id, + student=self.user, + module_type=key.block_scope_id.category, + module_state_key=key.block_scope_id.url(), + defaults={'state': json.dumps({})}, + ) + elif key.scope == Scope.content: + field_object, _ = XModuleContentField.objects.get_or_create( + field_name=key.field_name, + definition_id=key.block_scope_id.url() + ) + elif key.scope == Scope.settings: + field_object, _ = XModuleSettingsField.objects.get_or_create( + field_name=key.field_name, + usage_id='%s-%s' % (self.course_id, key.block_scope_id.url()), + ) + elif key.scope == Scope.student_preferences: + field_object, _= XModuleStudentPrefsField.objects.get_or_create( + field_name=key.field_name, + module_type=key.block_scope_id, + student=self.user, + ) + elif key.scope == Scope.student_info: + field_object, _ = XModuleStudentInfoField.objects.get_or_create( + field_name=key.field_name, + student=self.user, + ) + + cache_key = self._cache_key_from_kvs_key(key) + self.cache[cache_key] = field_object + return field_object + + class LmsKeyValueStore(KeyValueStore): """ This KeyValueStore will read data from descriptor_model_data if it exists, @@ -37,32 +267,9 @@ class LmsKeyValueStore(KeyValueStore): If the key isn't found in the expected table during a read or a delete, then a KeyError will be raised """ - def __init__(self, course_id, user, descriptor_model_data, student_module_cache): - self._course_id = course_id - self._user = user + def __init__(self, descriptor_model_data, model_data_cache): self._descriptor_model_data = descriptor_model_data - self._student_module_cache = student_module_cache - - def _student_module(self, key): - student_module = self._student_module_cache.lookup( - self._course_id, key.block_scope_id.category, key.block_scope_id.url() - ) - return student_module - - def _field_object(self, key): - if key.scope == Scope.content: - return XModuleContentField, {'field_name': key.field_name, 'definition_id': key.block_scope_id} - elif key.scope == Scope.settings: - return XModuleSettingsField, { - 'field_name': key.field_name, - 'usage_id': '%s-%s' % (self._course_id, key.block_scope_id) - } - elif key.scope == Scope.student_preferences: - return XModuleStudentPrefsField, {'field_name': key.field_name, 'student': self._user, 'module_type': key.block_scope_id} - elif key.scope == Scope.student_info: - return XModuleStudentInfoField, {'field_name': key.field_name, 'student': self._user} - - raise InvalidScopeError(key.scope) + self._model_data_cache = model_data_cache def get(self, key): if key.field_name in self._descriptor_model_data: @@ -71,74 +278,45 @@ class LmsKeyValueStore(KeyValueStore): if key.scope == Scope.parent: return None - if key.scope == Scope.student_state: - student_module = self._student_module(key) - - if student_module is None: - raise KeyError(key.field_name) - - return json.loads(student_module.state)[key.field_name] - - scope_field_cls, search_kwargs = self._field_object(key) - try: - return json.loads(scope_field_cls.objects.get(**search_kwargs).value) - except scope_field_cls.DoesNotExist: + field_object = self._model_data_cache.find(key) + if field_object is None: raise KeyError(key.field_name) + if key.scope == Scope.student_state: + return json.loads(field_object.state)[key.field_name] + else: + return json.loads(field_object.value) + def set(self, key, value): if key.field_name in self._descriptor_model_data: raise InvalidWriteError("Not allowed to overwrite descriptor model data", key.field_name) - if key.scope == Scope.student_state: - student_module = self._student_module(key) - if student_module is None: - student_module = StudentModule( - course_id=self._course_id, - student=self._user, - module_type=key.block_scope_id.category, - module_state_key=key.block_scope_id.url(), - state=json.dumps({}) - ) - self._student_module_cache.append(student_module) - state = json.loads(student_module.state) - state[key.field_name] = value - student_module.state = json.dumps(state) - student_module.save() - return + field_object = self._model_data_cache.find_or_create(key) - scope_field_cls, search_kwargs = self._field_object(key) - json_value = json.dumps(value) - field, created = scope_field_cls.objects.select_for_update().get_or_create( - defaults={'value': json_value}, - **search_kwargs - ) - if not created: - field.value = json_value - field.save() + if key.scope == Scope.student_state: + state = json.loads(field_object.state) + state[key.field_name] = value + field_object.state = json.dumps(state) + else: + field_object.value = json.dumps(value) + + field_object.save() def delete(self, key): if key.field_name in self._descriptor_model_data: raise InvalidWriteError("Not allowed to deleted descriptor model data", key.field_name) - if key.scope == Scope.student_state: - student_module = self._student_module(key) - - if student_module is None: - raise KeyError(key.field_name) - - state = json.loads(student_module.state) - del state[key.field_name] - student_module.state = json.dumps(state) - student_module.save() - return - - scope_field_cls, search_kwargs = self._field_object(key) - print scope_field_cls, search_kwargs - query = scope_field_cls.objects.filter(**search_kwargs) - if not query.exists(): + field_object = self._model_data_cache.find(key) + if field_object is None: raise KeyError(key.field_name) - query.delete() + if key.scope == Scope.student_state: + state = json.loads(field_object.state) + del state[key.field_name] + field_object.state = json.dumps(state) + field_object.save() + else: + field_object.delete() LmsUsage = namedtuple('LmsUsage', 'id, def_id') diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 3bfb29fd5a..7552ba4660 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -198,106 +198,3 @@ class XModuleStudentInfoField(models.Model): def __unicode__(self): return unicode(repr(self)) -# TODO (cpennington): Remove these once the LMS switches to using XModuleDescriptors - - -class StudentModuleCache(object): - """ - A cache of StudentModules for a specific student - """ - def __init__(self, course_id, user, descriptors, select_for_update=False): - ''' - Find any StudentModule objects that are needed by any descriptor - in descriptors. Avoids making multiple queries to the database. - Note: Only modules that have store_state = True or have shared - state will have a StudentModule. - - Arguments - user: The user for which to fetch maching StudentModules - descriptors: An array of XModuleDescriptors. - select_for_update: Flag indicating whether the rows should be locked until end of transaction - ''' - if user.is_authenticated(): - module_ids = self._get_module_state_keys(descriptors) - - # This works around a limitation in sqlite3 on the number of parameters - # that can be put into a single query - self.cache = [] - chunk_size = 500 - for id_chunk in [module_ids[i:i + chunk_size] for i in xrange(0, len(module_ids), chunk_size)]: - if select_for_update: - self.cache.extend(StudentModule.objects.select_for_update().filter( - course_id=course_id, - student=user, - module_state_key__in=id_chunk) - ) - else: - self.cache.extend(StudentModule.objects.filter( - course_id=course_id, - student=user, - module_state_key__in=id_chunk) - ) - - else: - self.cache = [] - - @classmethod - def cache_for_descriptor_descendents(cls, course_id, user, descriptor, depth=None, - descriptor_filter=lambda descriptor: True, - select_for_update=False): - """ - course_id: the course in the context of which we want StudentModules. - user: the django user for whom to load modules. - descriptor: An XModuleDescriptor - depth is the number of levels of descendent modules to load StudentModules for, in addition to - the supplied descriptor. If depth is None, load all descendent StudentModules - descriptor_filter is a function that accepts a descriptor and return wether the StudentModule - should be cached - select_for_update: Flag indicating whether the rows should be locked until end of transaction - """ - - def get_child_descriptors(descriptor, depth, descriptor_filter): - if descriptor_filter(descriptor): - descriptors = [descriptor] - else: - descriptors = [] - - if depth is None or depth > 0: - new_depth = depth - 1 if depth is not None else depth - - for child in descriptor.get_children(): - descriptors.extend(get_child_descriptors(child, new_depth, descriptor_filter)) - - return descriptors - - descriptors = get_child_descriptors(descriptor, depth, descriptor_filter) - - return StudentModuleCache(course_id, user, descriptors, select_for_update) - - def _get_module_state_keys(self, descriptors): - ''' - Get a list of the state_keys needed for StudentModules - required for this module descriptor - - descriptor_filter is a function that accepts a descriptor and return wether the StudentModule - should be cached - ''' - return [descriptor.location.url() for descriptor in descriptors if descriptor.stores_state] - - def lookup(self, course_id, module_type, module_state_key): - ''' - Look for a student module with the given course_id, type, and id in the cache. - - cache -- list of student modules - - returns first found object, or None - ''' - for o in self.cache: - if (o.course_id == course_id and - o.module_type == module_type and - o.module_state_key == module_state_key): - return o - return None - - def append(self, student_module): - self.cache.append(student_module) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 4315b923ce..d08880352e 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -17,7 +17,7 @@ from capa.xqueue_interface import XQueueInterface from capa.chem import chemcalc from courseware.access import has_access from mitxmako.shortcuts import render_to_string -from models import StudentModule, StudentModuleCache +from models import StudentModule from psychometrics.psychoanalyze import make_psychometrics_data_update_handler from static_replace import replace_urls from xmodule.errortracker import exc_info_to_str @@ -28,7 +28,7 @@ from xmodule.x_module import ModuleSystem from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor from xblock.runtime import DbModel from xmodule_modifiers import replace_course_urls, replace_static_urls, add_histogram, wrap_xmodule -from .model_data import LmsKeyValueStore, LmsUsage +from .model_data import LmsKeyValueStore, LmsUsage, ModelDataCache from xmodule.modulestore.exceptions import ItemNotFoundError from statsd import statsd @@ -60,7 +60,7 @@ def make_track_function(request): return f -def toc_for_course(user, request, course, active_chapter, active_section): +def toc_for_course(user, request, course, active_chapter, active_section, model_data_cache): ''' Create a table of contents from the module store @@ -80,11 +80,11 @@ def toc_for_course(user, request, course, active_chapter, active_section): NOTE: assumes that if we got this far, user has access to course. Returns None if this is not the case. + + model_data_cache must include data from the course module and 2 levels of its descendents ''' - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( - course.id, user, course, depth=2) - course_module = get_module(user, request, course.location, student_module_cache, course.id) + course_module = get_module(user, request, course.location, model_data_cache, course.id) if course_module is None: return None @@ -115,7 +115,7 @@ def toc_for_course(user, request, course, active_chapter, active_section): return chapters -def get_module(user, request, location, student_module_cache, course_id, +def get_module(user, request, location, model_data_cache, course_id, position=None, not_found_ok = False, wrap_xmodule_display=True, grade_bucket_type=None): """ @@ -128,7 +128,7 @@ def get_module(user, request, location, student_module_cache, course_id, - request : current django HTTPrequest. Note: request.user isn't used for anything--all auth and such works based on user. - location : A Location-like object identifying the module to load - - student_module_cache : a StudentModuleCache + - model_data_cache : a ModelDataCache - course_id : the course_id in the context of which to load module - position : extra information from URL for user-specified position within module @@ -138,7 +138,7 @@ def get_module(user, request, location, student_module_cache, course_id, if possible. If not possible, return None. """ try: - return _get_module(user, request, location, student_module_cache, course_id, position, wrap_xmodule_display) + return _get_module(user, request, location, model_data_cache, course_id, position, wrap_xmodule_display) except ItemNotFoundError: if not not_found_ok: log.exception("Error in get_module") @@ -149,7 +149,7 @@ def get_module(user, request, location, student_module_cache, course_id, return None -def _get_module(user, request, location, student_module_cache, course_id, +def _get_module(user, request, location, model_data_cache, course_id, position=None, wrap_xmodule_display=True, grade_bucket_type=None): """ Actually implement get_module. See docstring there for details. @@ -204,11 +204,11 @@ def _get_module(user, request, location, student_module_cache, course_id, Delegate to get_module. It does an access check, so may return None """ return get_module(user, request, location, - student_module_cache, course_id, position) + model_data_cache, course_id, position) def xblock_model_data(descriptor): return DbModel( - LmsKeyValueStore(course_id, user, descriptor._model_data, student_module_cache), + LmsKeyValueStore(descriptor._model_data, model_data_cache), descriptor.module_class, user.id, LmsUsage(location, location) @@ -218,18 +218,13 @@ def _get_module(user, request, location, student_module_cache, course_id, if event.get('event_name') != 'grade': return - student_module = student_module_cache.lookup( - course_id, descriptor.location.category, descriptor.location.url() + student_module, created = StudentModule.objects.get_or_create( + course_id=course_id, + student=user, + module_type=descriptor.location.category, + module_state_key=descriptor.location.url(), + defaults={'state': '{}'}, ) - if student_module is None: - student_module = StudentModule( - course_id=course_id, - student=user, - module_type=descriptor.location.category, - module_state_key=descriptor.location.url(), - state=json.dumps({}) - ) - student_module_cache.append(student_module) student_module.grade = event.get('value') student_module.max_grade = event.get('max_value') student_module.save() @@ -335,9 +330,9 @@ def xqueue_callback(request, course_id, userid, id, dispatch): # Retrieve target StudentModule user = User.objects.get(id=userid) - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, + model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, user, modulestore().get_instance(course_id, id), depth=0, select_for_update=True) - instance = get_module(user, request, id, student_module_cache, course_id, grade_bucket_type='xqueue') + instance = get_module(user, request, id, model_data_cache, course_id, grade_bucket_type='xqueue') if instance is None: log.debug("No module {0} for user {1}--access denied?".format(id, user)) raise Http404 @@ -394,10 +389,10 @@ def modx_dispatch(request, dispatch, location, course_id): return HttpResponse(json.dumps({'success': file_too_big_msg})) p[fileinput_id] = inputfiles - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, + model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, request.user, modulestore().get_instance(course_id, location)) - instance = get_module(request.user, request, location, student_module_cache, course_id, grade_bucket_type='ajax') + instance = get_module(request.user, request, location, model_data_cache, course_id, grade_bucket_type='ajax') if instance is None: # Either permissions just changed, or someone is trying to be clever # and load something they shouldn't have access to. diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py index f18e128984..5d83d2763d 100644 --- a/lms/djangoapps/courseware/tests/test_model_data.py +++ b/lms/djangoapps/courseware/tests/test_model_data.py @@ -5,18 +5,26 @@ from django.contrib.auth.models import User from functools import partial -from courseware.model_data import LmsKeyValueStore, InvalidWriteError, InvalidScopeError -from courseware.models import StudentModule, XModuleContentField, XModuleSettingsField, XModuleStudentInfoField, XModuleStudentPrefsField, StudentModuleCache +from courseware.model_data import LmsKeyValueStore, InvalidWriteError, InvalidScopeError, ModelDataCache +from courseware.models import StudentModule, XModuleContentField, XModuleSettingsField, XModuleStudentInfoField, XModuleStudentPrefsField from xblock.core import Scope, BlockScope from xmodule.modulestore import Location from django.test import TestCase -def mock_descriptor(): +def mock_field(scope, name): + field = Mock() + field.scope = scope + field.name = name + return field + +def mock_descriptor(fields=[], lms_fields=[]): descriptor = Mock() descriptor.stores_state = True descriptor.location = location('def_id') + descriptor.module_class.fields = fields + descriptor.module_class.lms.fields = lms_fields return descriptor location = partial(Location, 'i4x', 'edX', 'test_course', 'problem') @@ -85,7 +93,7 @@ class TestDescriptorFallback(TestCase): 'field_a': 'content', 'field_b': 'settings', } - self.kvs = LmsKeyValueStore(course_id, UserFactory.build(), self.desc_md, None) + self.kvs = LmsKeyValueStore(self.desc_md, None) def test_get_from_descriptor(self): self.assertEquals('content', self.kvs.get(content_key('field_a'))) @@ -103,13 +111,11 @@ class TestDescriptorFallback(TestCase): self.assertEquals('settings', self.desc_md['field_b']) -class TestStudentStateFields(TestCase): - pass - class TestInvalidScopes(TestCase): def setUp(self): self.desc_md = {} - self.kvs = LmsKeyValueStore(course_id, UserFactory.build(), self.desc_md, None) + self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.student_state, 'a_field')])], course_id, self.user) + self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) def test_invalid_scopes(self): for scope in (Scope(student=True, block=BlockScope.DEFINITION), @@ -123,12 +129,10 @@ class TestInvalidScopes(TestCase): class TestStudentModuleStorage(TestCase): def setUp(self): - student_module = StudentModuleFactory.create(state=json.dumps({'a_field': 'a_value'})) - self.user = student_module.student self.desc_md = {} - self.smc = StudentModuleCache(course_id, self.user, [mock_descriptor()]) - self.kvs = LmsKeyValueStore(course_id, self.user, self.desc_md, self.smc) - + self.mdc = Mock() + self.mdc.find.return_value.state = json.dumps({'a_field': 'a_value'}) + self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) def test_get_existing_field(self): "Test that getting an existing field in an existing StudentModule works" @@ -154,7 +158,7 @@ class TestStudentModuleStorage(TestCase): "Test that deleting an existing field removes it from the StudentModule" self.kvs.delete(student_state_key('a_field')) self.assertEquals(1, StudentModule.objects.all().count()) - self.assertEquals({}, json.loads(StudentModule.objects.all()[0].state)) + self.assertEquals({}, self.mdc.find.return_value.state) def test_delete_missing_field(self): "Test that deleting a missing field from an existing StudentModule raises a KeyError" @@ -167,8 +171,8 @@ class TestMissingStudentModule(TestCase): def setUp(self): self.user = UserFactory.create() self.desc_md = {} - self.smc = StudentModuleCache(course_id, self.user, [mock_descriptor()]) - self.kvs = LmsKeyValueStore(course_id, self.user, self.desc_md, self.smc) + self.mdc = ModelDataCache([mock_descriptor()], course_id, self.user) + self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) def test_get_field_from_missing_student_module(self): "Test that getting a field from a missing StudentModule raises a KeyError" @@ -176,12 +180,12 @@ class TestMissingStudentModule(TestCase): def test_set_field_in_missing_student_module(self): "Test that setting a field in a missing StudentModule creates the student module" - self.assertEquals(0, len(self.smc.cache)) + self.assertEquals(0, len(self.mdc.cache)) self.assertEquals(0, StudentModule.objects.all().count()) self.kvs.set(student_state_key('a_field'), 'a_value') - self.assertEquals(1, len(self.smc.cache)) + self.assertEquals(1, len(self.mdc.cache)) self.assertEquals(1, StudentModule.objects.all().count()) student_module = StudentModule.objects.all()[0] @@ -201,8 +205,8 @@ class TestSettingsStorage(TestCase): settings = SettingsFactory.create() self.user = UserFactory.create() self.desc_md = {} - self.smc = StudentModuleCache(course_id, self.user, []) - self.kvs = LmsKeyValueStore(course_id, self.user, self.desc_md, self.smc) + self.mdc = ModelDataCache([mock_descriptor()], course_id, self.user) + self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) def test_get_existing_field(self): "Test that getting an existing field in an existing SettingsField works" @@ -242,8 +246,8 @@ class TestContentStorage(TestCase): content = ContentFactory.create() self.user = UserFactory.create() self.desc_md = {} - self.smc = StudentModuleCache(course_id, self.user, []) - self.kvs = LmsKeyValueStore(course_id, self.user, self.desc_md, self.smc) + self.mdc = ModelDataCache([mock_descriptor()], course_id, self.user) + self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) def test_get_existing_field(self): "Test that getting an existing field in an existing ContentField works" @@ -283,8 +287,11 @@ class TestStudentPrefsStorage(TestCase): student_pref = StudentPrefsFactory.create() self.user = student_pref.student self.desc_md = {} - self.smc = StudentModuleCache(course_id, self.user, []) - self.kvs = LmsKeyValueStore(course_id, self.user, self.desc_md, self.smc) + self.mdc = ModelDataCache([mock_descriptor([ + mock_field(Scope.student_preferences, 'student_pref_field'), + mock_field(Scope.student_preferences, 'not_student_pref_field'), + ])], course_id, self.user) + self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) def test_get_existing_field(self): "Test that getting an existing field in an existing StudentPrefsField works" @@ -309,7 +316,6 @@ class TestStudentPrefsStorage(TestCase): def test_delete_existing_field(self): "Test that deleting an existing field removes it" - print list(XModuleStudentPrefsField.objects.all()) self.kvs.delete(student_prefs_key('student_pref_field')) self.assertEquals(0, XModuleStudentPrefsField.objects.all().count()) @@ -325,8 +331,11 @@ class TestStudentInfoStorage(TestCase): student_info = StudentInfoFactory.create() self.user = student_info.student self.desc_md = {} - self.smc = StudentModuleCache(course_id, self.user, []) - self.kvs = LmsKeyValueStore(course_id, self.user, self.desc_md, self.smc) + self.mdc = ModelDataCache([mock_descriptor([ + mock_field(Scope.student_info, 'student_info_field'), + mock_field(Scope.student_info, 'not_student_info_field'), + ])], course_id, self.user) + self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) def test_get_existing_field(self): "Test that getting an existing field in an existing StudentInfoField works" diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 6b2988d1db..196376d2b3 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -23,7 +23,7 @@ import xmodule.modulestore.django # Need access to internal func to put users in the right group from courseware import grades from courseware.access import _course_staff_group_name -from courseware.models import StudentModuleCache +from courseware.model_data import ModelDataCache from student.models import Registration from xmodule.modulestore.django import modulestore @@ -705,27 +705,27 @@ class TestCourseGrader(PageLoader): self.factory = RequestFactory() def get_grade_summary(self): - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( + model_data_cache = ModelDataCache.cache_for_descriptor_descendents( self.graded_course.id, self.student_user, self.graded_course) fake_request = self.factory.get(reverse('progress', kwargs={'course_id': self.graded_course.id})) return grades.grade(self.student_user, fake_request, - self.graded_course, student_module_cache) + self.graded_course, model_data_cache) def get_homework_scores(self): return self.get_grade_summary()['totaled_scores']['Homework'] def get_progress_summary(self): - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( + model_data_cache = ModelDataCache.cache_for_descriptor_descendents( self.graded_course.id, self.student_user, self.graded_course) fake_request = self.factory.get(reverse('progress', kwargs={'course_id': self.graded_course.id})) progress_summary = grades.progress_summary(self.student_user, fake_request, - self.graded_course, student_module_cache) + self.graded_course, model_data_cache) return progress_summary def check_grade_percent(self, percent): diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 8096aa0df4..201787db08 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -23,7 +23,7 @@ from courseware import grades from courseware.access import has_access from courseware.courses import (get_course_with_access, get_courses_by_university) import courseware.tabs as tabs -from courseware.models import StudentModuleCache +from courseware.model_data import ModelDataCache from module_render import toc_for_course, get_module from student.models import UserProfile @@ -81,7 +81,7 @@ def courses(request): return render_to_response("courses.html", {'universities': universities}) -def render_accordion(request, course, chapter, section): +def render_accordion(request, course, chapter, section, model_data_cache): ''' Draws navigation bar. Takes current position in accordion as parameter. @@ -92,7 +92,7 @@ def render_accordion(request, course, chapter, section): Returns the html string''' # grab the table of contents - toc = toc_for_course(request.user, request, course, chapter, section) + toc = toc_for_course(request.user, request, course, chapter, section, model_data_cache) context = dict([('toc', toc), ('course_id', course.id), @@ -191,24 +191,21 @@ def index(request, course_id, chapter=None, section=None, return redirect(reverse('about_course', args=[course.id])) try: - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( + model_data_cache = ModelDataCache.cache_for_descriptor_descendents( course.id, request.user, course, depth=2) - # Has this student been in this course before? - first_time = student_module_cache.lookup(course_id, 'course', course.location.url()) is None - - course_module = get_module(request.user, request, course.location, student_module_cache, course.id) + course_module = get_module(request.user, request, course.location, model_data_cache, course.id) if course_module is None: log.warning('If you see this, something went wrong: if we got this' ' far, should have gotten a course module for this user') return redirect(reverse('about_course', args=[course.id])) if chapter is None: - return redirect_to_course_position(course_module, first_time) + return redirect_to_course_position(course_module, course_module.first_time_user) context = { 'csrf': csrf(request)['csrf_token'], - 'accordion': render_accordion(request, course, chapter, section), + 'accordion': render_accordion(request, course, chapter, section, model_data_cache), 'COURSE_TITLE': course.title, 'course': course, 'init': '', @@ -224,7 +221,7 @@ def index(request, course_id, chapter=None, section=None, raise Http404 chapter_module = get_module(request.user, request, chapter_descriptor.location, - student_module_cache, course_id) + model_data_cache, course_id) if chapter_module is None: # User may be trying to access a chapter that isn't live yet raise Http404 @@ -235,11 +232,11 @@ def index(request, course_id, chapter=None, section=None, # Specifically asked-for section doesn't exist raise Http404 - section_student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( + section_model_data_cache = ModelDataCache.cache_for_descriptor_descendents( course_id, request.user, section_descriptor) section_module = get_module(request.user, request, section_descriptor.location, - section_student_module_cache, course_id, position) + section_model_data_cache, course_id, position) if section_module is None: # User may be trying to be clever and access something # they don't have access to. @@ -491,12 +488,12 @@ def progress(request, course_id, student_id=None): # additional DB lookup (this kills the Progress page in particular). student = User.objects.prefetch_related("groups").get(id=student.id) - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( + model_data_cache = ModelDataCache.cache_for_descriptor_descendents( course_id, student, course) courseware_summary = grades.progress_summary(student, request, course, - student_module_cache) - grade_summary = grades.grade(student, request, course, student_module_cache) + model_data_cache) + grade_summary = grades.grade(student, request, course, model_data_cache) if courseware_summary is None: #This means the student didn't have access to the course (which the instructor requested) diff --git a/lms/templates/courseware/info.html b/lms/templates/courseware/info.html index ff10e645ed..836e4934a9 100644 --- a/lms/templates/courseware/info.html +++ b/lms/templates/courseware/info.html @@ -27,20 +27,20 @@ $(document).ready(function(){ % if user.is_authenticated():

        Course Updates & News

        - ${get_course_info_section(request, cache, course, 'updates')} + ${get_course_info_section(request, course, 'updates')}

        ${course.info_sidebar_name}

        - ${get_course_info_section(request, cache, course, 'handouts')} + ${get_course_info_section(request, course, 'handouts')}
        % else:

        Course Updates & News

        - ${get_course_info_section(request, cache, course, 'guest_updates')} + ${get_course_info_section(request, course, 'guest_updates')}

        Course Handouts

        - ${get_course_info_section(request, cache, course, 'guest_handouts')} + ${get_course_info_section(request, course, 'guest_handouts')}
        % endif
        From 27b57bb48b636ec29b9e592f4be89731fd7d3dd9 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 15 Jan 2013 08:47:08 -0500 Subject: [PATCH 086/501] Don't update settings based on data from content for discussion modules during init --- common/lib/xmodule/xmodule/discussion_module.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/discussion_module.py b/common/lib/xmodule/xmodule/discussion_module.py index 6a9edbbd70..e3161742df 100644 --- a/common/lib/xmodule/xmodule/discussion_module.py +++ b/common/lib/xmodule/xmodule/discussion_module.py @@ -17,21 +17,12 @@ class DiscussionModule(XModule): discussion_target = String(scope=Scope.settings) sort_key = String(scope=Scope.settings) - data = String(help="XML definition of inline discussion", scope=Scope.content) - def get_html(self): context = { 'discussion_id': self.discussion_id, } return self.system.render_template('discussion/_discussion_module.html', context) - def __init__(self, *args, **kwargs): - XModule.__init__(self, *args, **kwargs) - - xml_data = etree.fromstring(self.data) - self.discussion_id = xml_data.attrib['id'] - self.title = xml_data.attrib['for'] - self.discussion_category = xml_data.attrib['discussion_category'] class DiscussionDescriptor(RawDescriptor): module_class = DiscussionModule From 037fe5f722c77c1a3c4144131c86665926d7b533 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 23 Jan 2013 17:11:02 -0500 Subject: [PATCH 087/501] When checking types to convert data, don't forget about longs. 32-bit Pythons make longs from values that are ints on 64-bit Pythons. --- common/djangoapps/util/converters.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/common/djangoapps/util/converters.py b/common/djangoapps/util/converters.py index 17c45114d1..dd4f47e70e 100644 --- a/common/djangoapps/util/converters.py +++ b/common/djangoapps/util/converters.py @@ -15,10 +15,13 @@ def jsdate_to_time(field): """ if field is None: return field - elif isinstance(field, unicode) or isinstance(field, str): # iso format but ignores time zone assuming it's Z + elif isinstance(field, (unicode, str)): + # ISO format but ignores time zone assuming it's Z. d=datetime.datetime(*map(int, re.split('[^\d]', field)[:6])) # stop after seconds. Debatable return d.utctimetuple() - elif isinstance(field, int) or isinstance(field, float): + elif isinstance(field, (int, long, float)): return time.gmtime(field / 1000) elif isinstance(field, time.struct_time): - return field \ No newline at end of file + return field + else: + raise ValueError("Couldn't convert %r to time" % field) From ac9d162d86045b280003088dc35776b634fcb3f4 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 24 Jan 2013 13:07:55 -0500 Subject: [PATCH 088/501] Fix a failing test. Pennington's Law strikes again. --- lms/djangoapps/courseware/model_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index 1e2b3323a1..383b4032bc 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -149,7 +149,7 @@ class ModelDataCache(object): field_name__in=set(field.name for field in fields), ) elif scope == Scope.student_info: - self._query( + return self._query( XModuleStudentInfoField, student=self.user, field_name__in=set(field.name for field in fields), From 7257a728aa9419ef3bc7d54126c582e5f842ec3b Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 24 Jan 2013 13:52:43 -0500 Subject: [PATCH 089/501] Get the latest version of XBlock automatically --- github-requirements.txt | 1 - local-requirements.txt | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/github-requirements.txt b/github-requirements.txt index 883490da99..468d55ce65 100644 --- a/github-requirements.txt +++ b/github-requirements.txt @@ -3,4 +3,3 @@ -e git://github.com/MITx/django-pipeline.git#egg=django-pipeline -e git://github.com/MITx/django-wiki.git@e2e84558#egg=django-wiki -e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev --e git+ssh://git@github.com/MITx/xmodule-debugger@v0.1#egg=XBlock diff --git a/local-requirements.txt b/local-requirements.txt index 201467d11e..1248ecf77d 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -2,3 +2,8 @@ -e common/lib/capa -e common/lib/xmodule -e . + +# XBlock: +# Might change frequently, so put it in local-requirements.txt, +# but conceptually is an external package, so it is in a separate repo. +-e git+ssh://git@github.com/MITx/xmodule-debugger@8f82a3b7fc#egg=XBlock From cad3a19b4dd3e5c0d82674e22dc207d3df06324e Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Thu, 24 Jan 2013 13:56:46 -0500 Subject: [PATCH 090/501] A script to run just one test, based on the test failure description. --- runone.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100755 runone.py diff --git a/runone.py b/runone.py new file mode 100755 index 0000000000..2227ae0adf --- /dev/null +++ b/runone.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +from django.core import management + +import argparse +import os +import sys + +# I want this: +# ERROR: test_update_and_fetch (mitx.cms.djangoapps.contentstore.tests.test_course_settings.CourseDetailsViewTest) +# to become: +# test --settings=cms.envs.test --pythonpath=. -s cms/djangoapps/contentstore/tests/test_course_settings.py:CourseDetailsViewTest.test_update_and_fetch + +def find_full_path(path_to_file): + """Find the full path where we only have a relative path from somewhere in the tree.""" + for subdir, dirs, files in os.walk("."): + full = os.path.relpath(os.path.join(subdir, path_to_file)) + if os.path.exists(full): + return full + +def main(argv): + parser = argparse.ArgumentParser(description="Run just one test") + parser.add_argument('--nocapture', '-s', action='store_true', help="Don't capture stdout (any stdout output will be printed immediately)") + parser.add_argument('words', metavar="WORDS", nargs='+', help="The description of a test failure, like 'ERROR: test_set_missing_field (courseware.tests.test_model_data.TestStudentModuleStorage)'") + + args = parser.parse_args(argv) + words = [] + # Collect all the words, ignoring what was quoted together, and get rid of parens. + for argword in args.words: + words.extend(w.strip("()") for w in argword.split()) + # If it starts with "ERROR:" or "FAIL:", just ignore that. + if words[0].endswith(':'): + del words[0] + + test_method = words[0] + test_path = words[1].split('.') + if test_path[0] == 'mitx': + del test_path[0] + test_class = test_path[-1] + del test_path[-1] + + test_py_path = "%s.py" % ("/".join(test_path)) + test_py_path = find_full_path(test_py_path) + test_spec = "%s:%s.%s" % (test_py_path, test_class, test_method) + + if test_py_path.startswith('cms'): + settings = 'cms.envs.test' + elif test_py_path.startswith('lms'): + settings = 'lms.envs.test' + else: + raise Exception("Couldn't determine settings to use!") + + django_args = ["django-admin.py", "test", "--pythonpath=."] + django_args.append("--settings=%s" % settings) + if args.nocapture: + django_args.append("-s") + django_args.append(test_spec) + + print " ".join(django_args) + management.execute_from_command_line(django_args) + +if __name__ == "__main__": + main(sys.argv[1:]) From 9a3e122a4861eb7d1f9bbb4c26b8568f9d5916e4 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 24 Jan 2013 14:03:55 -0500 Subject: [PATCH 091/501] Fix a few failing tests --- lms/djangoapps/courseware/tests/test_model_data.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py index 5d83d2763d..da89412238 100644 --- a/lms/djangoapps/courseware/tests/test_model_data.py +++ b/lms/djangoapps/courseware/tests/test_model_data.py @@ -114,6 +114,7 @@ class TestDescriptorFallback(TestCase): class TestInvalidScopes(TestCase): def setUp(self): self.desc_md = {} + self.user = UserFactory.create() self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.student_state, 'a_field')])], course_id, self.user) self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) @@ -205,7 +206,7 @@ class TestSettingsStorage(TestCase): settings = SettingsFactory.create() self.user = UserFactory.create() self.desc_md = {} - self.mdc = ModelDataCache([mock_descriptor()], course_id, self.user) + self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.settings, 'settings_field')])], course_id, self.user) self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) def test_get_existing_field(self): @@ -246,7 +247,7 @@ class TestContentStorage(TestCase): content = ContentFactory.create() self.user = UserFactory.create() self.desc_md = {} - self.mdc = ModelDataCache([mock_descriptor()], course_id, self.user) + self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.content, 'content_field')])], course_id, self.user) self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) def test_get_existing_field(self): From 592d0448640c0051e7f0128ae767eeb7d189bcf2 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Thu, 24 Jan 2013 14:37:46 -0500 Subject: [PATCH 092/501] Fix more tests --- lms/djangoapps/courseware/model_data.py | 18 ++++++++++++++++++ .../courseware/tests/test_model_data.py | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index 383b4032bc..5d535bfdf5 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -267,6 +267,15 @@ class LmsKeyValueStore(KeyValueStore): If the key isn't found in the expected table during a read or a delete, then a KeyError will be raised """ + + _allowed_scopes = ( + Scope.content, + Scope.settings, + Scope.student_state, + Scope.student_preferences, + Scope.student_info, + Scope.children, + ) def __init__(self, descriptor_model_data, model_data_cache): self._descriptor_model_data = descriptor_model_data self._model_data_cache = model_data_cache @@ -278,6 +287,9 @@ class LmsKeyValueStore(KeyValueStore): if key.scope == Scope.parent: return None + if key.scope not in self._allowed_scopes: + raise InvalidScopeError(key.scope) + field_object = self._model_data_cache.find(key) if field_object is None: raise KeyError(key.field_name) @@ -293,6 +305,9 @@ class LmsKeyValueStore(KeyValueStore): field_object = self._model_data_cache.find_or_create(key) + if key.scope not in self._allowed_scopes: + raise InvalidScopeError(key.scope) + if key.scope == Scope.student_state: state = json.loads(field_object.state) state[key.field_name] = value @@ -306,6 +321,9 @@ class LmsKeyValueStore(KeyValueStore): if key.field_name in self._descriptor_model_data: raise InvalidWriteError("Not allowed to deleted descriptor model data", key.field_name) + if key.scope not in self._allowed_scopes: + raise InvalidScopeError(key.scope) + field_object = self._model_data_cache.find(key) if field_object is None: raise KeyError(key.field_name) diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py index da89412238..13ecf9429d 100644 --- a/lms/djangoapps/courseware/tests/test_model_data.py +++ b/lms/djangoapps/courseware/tests/test_model_data.py @@ -131,8 +131,8 @@ class TestStudentModuleStorage(TestCase): def setUp(self): self.desc_md = {} - self.mdc = Mock() - self.mdc.find.return_value.state = json.dumps({'a_field': 'a_value'}) + self.user = UserFactory.create() + self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.student_state, 'a_field')])], course_id, self.user) self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) def test_get_existing_field(self): From 890f02a2324357d3ec56115dad3b7df6a3b6fe5b Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 25 Jan 2013 12:26:17 -0500 Subject: [PATCH 093/501] When installing the local requirements, don't upgrade dependencies. Makes things faster. --- rakefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rakefile b/rakefile index 312ad90124..ac79ee6c6f 100644 --- a/rakefile +++ b/rakefile @@ -121,7 +121,7 @@ default_options = { task :predjango do sh("find . -type f -name *.pyc -delete") - sh('pip install -q --upgrade -r local-requirements.txt') + sh('pip install -q --no-index -r local-requirements.txt') sh('git submodule update --init') end From 97bb56b3020680df938e8580866d84144af2baf6 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 25 Jan 2013 12:33:30 -0500 Subject: [PATCH 094/501] Tweak the dev instructions. --- doc/development.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/development.md b/doc/development.md index d68b49228e..91931e85f9 100644 --- a/doc/development.md +++ b/doc/development.md @@ -9,9 +9,8 @@ This will read the `Gemfile` and install all of the gems specified there. ### Python -In order, run the following: +Run the following:: - pip install -r pre-requirements.txt pip install -r requirements.txt pip install -r test-requirements.txt From 08acf43575b34376c013f96180784b3885775dd3 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 25 Jan 2013 15:49:29 -0500 Subject: [PATCH 095/501] Don't store first_time_user any more, instead interested observers can examine whether .position is None or not. --- common/lib/xmodule/xmodule/course_module.py | 14 +----- common/lib/xmodule/xmodule/seq_module.py | 7 --- lms/djangoapps/courseware/views.py | 56 ++++++++++++--------- 3 files changed, 34 insertions(+), 43 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 2864aaba1d..6b0909ec2e 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -89,18 +89,8 @@ class TextbookList(ModelType): return json_data -class CourseModule(SequenceModule): - first_time_user = Boolean(help="Whether this is the first time the user has visited this course", scope=Scope.student_state, default=True) - - def __init__(self, *args, **kwargs): - super(CourseModule, self).__init__(*args, **kwargs) - - if self.first_time_user: - self.first_time_user = False - - class CourseDescriptor(SequenceDescriptor): - module_class = CourseModule + module_class = SequenceModule textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", default=[], scope=Scope.content) wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) @@ -123,7 +113,7 @@ class CourseDescriptor(SequenceDescriptor): has_children = True info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') - + # An extra property is used rather than the wiki_slug/number because # there are courses that change the number for different runs. This allows # courses to share the same css_class across runs even if they have diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index b3ceb7a229..17d722a52a 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -46,13 +46,6 @@ class SequenceModule(XModule): if self.system.get('position'): self.position = int(self.system.get('position')) - # Default to the first child - # Don't set 1 as the default in the property definition, because - # there is code that looks for the existance of the position value - # to determine if the student has visited the sequence before or not - if self.position is None: - self.position = 1 - self.rendered = False def get_instance_state(self): diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 201787db08..c425494260 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -103,16 +103,18 @@ def render_accordion(request, course, chapter, section, model_data_cache): def get_current_child(xmodule): """ Get the xmodule.position's display item of an xmodule that has a position and - children. Returns None if the xmodule doesn't have a position, or if there - are no children. Otherwise, if position is out of bounds, returns the first child. + children. If xmodule has no position or is out of bounds, return the first child. + Returns None only if there are no children at all. """ - if not hasattr(xmodule, 'position'): - return None + if xmodule.position is None: + pos = 0 + else: + # position is 1-indexed. + pos = xmodule.position - 1 children = xmodule.get_display_items() - # position is 1-indexed. - if 0 <= xmodule.position - 1 < len(children): - child = children[xmodule.position - 1] + if 0 <= pos < len(children): + child = children[pos] elif len(children) > 0: # Something is wrong. Default to first child child = children[0] @@ -121,36 +123,43 @@ def get_current_child(xmodule): return child -def redirect_to_course_position(course_module, first_time): +def redirect_to_course_position(course_module): """ - Load the course state for the user, and return a redirect to the - appropriate place in the course: either the first element if there - is no state, or their previous place if there is. + Return a redirect to the user's current place in the course. + + If this is the user's first time, redirects to COURSE/CHAPTER/SECTION. + If this isn't the users's first time, redirects to COURSE/CHAPTER, + and the view will find the current section and display a message + about reusing the stored position. + + If there is no current position in the course or chapter, then selects + the first child. - If this is the user's first time, send them to the first section instead. """ - course_id = course_module.descriptor.id + urlargs = {'course_id': course_module.descriptor.id} chapter = get_current_child(course_module) if chapter is None: # oops. Something bad has happened. raise Http404("No chapter found when loading current position in course") - if not first_time: - return redirect(reverse('courseware_chapter', kwargs={'course_id': course_id, - 'chapter': chapter.url_name})) + + urlargs['chapter'] = chapter.url_name + if course_module.position is not None: + return redirect(reverse('courseware_chapter', kwargs=urlargs)) + # Relying on default of returning first child section = get_current_child(chapter) - return redirect(reverse('courseware_section', kwargs={'course_id': course_id, - 'chapter': chapter.url_name, - 'section': section.url_name})) + if section is None: + raise Http404("No section found when loading current position in course") + + urlargs['section'] = section.url_name + return redirect(reverse('courseware_section', kwargs=urlargs)) def save_child_position(seq_module, child_name): """ child_name: url_name of the child """ - for i, c in enumerate(seq_module.get_display_items()): + for position, c in enumerate(seq_module.get_display_items(), start=1): if c.url_name == child_name: - # Position is 1-indexed - position = i + 1 # Only save if position changed if position != seq_module.position: seq_module.position = position @@ -201,7 +210,7 @@ def index(request, course_id, chapter=None, section=None, return redirect(reverse('about_course', args=[course.id])) if chapter is None: - return redirect_to_course_position(course_module, course_module.first_time_user) + return redirect_to_course_position(course_module) context = { 'csrf': csrf(request)['csrf_token'], @@ -245,7 +254,6 @@ def index(request, course_id, chapter=None, section=None, # Save where we are in the chapter save_child_position(chapter_module, section) - context['content'] = section_module.get_html() else: # section is none, so display a message From 9d34767e6afd998122e8bd5b74231888232d962f Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Fri, 1 Feb 2013 15:45:43 -0500 Subject: [PATCH 096/501] Fix the remaining module storage tests. --- lms/djangoapps/courseware/model_data.py | 6 +++--- lms/djangoapps/courseware/tests/test_model_data.py | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index 5d535bfdf5..d13bfe9bba 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -25,7 +25,7 @@ def chunks(items, chunk_size): class ModelDataCache(object): """ A cache of django model objects needed to supply the data - for a module and its decendents + for a module and its decendants """ def __init__(self, descriptors, course_id, user, select_for_update=False): ''' @@ -35,10 +35,10 @@ class ModelDataCache(object): state will have a StudentModule. Arguments - descriptors: An array of XModuleDescriptors. + descriptors: A list of XModuleDescriptors. course_id: The id of the current course user: The user for which to cache data - select_for_update: Flag indicating whether the rows should be locked until end of transaction + select_for_update: True if rows should be locked until end of transaction ''' self.cache = {} self.descriptors = descriptors diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py index 13ecf9429d..33a14f3c61 100644 --- a/lms/djangoapps/courseware/tests/test_model_data.py +++ b/lms/djangoapps/courseware/tests/test_model_data.py @@ -131,7 +131,8 @@ class TestStudentModuleStorage(TestCase): def setUp(self): self.desc_md = {} - self.user = UserFactory.create() + student_module = StudentModuleFactory(state=json.dumps({'a_field': 'a_value'})) + self.user = student_module.student self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.student_state, 'a_field')])], course_id, self.user) self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) @@ -159,7 +160,7 @@ class TestStudentModuleStorage(TestCase): "Test that deleting an existing field removes it from the StudentModule" self.kvs.delete(student_state_key('a_field')) self.assertEquals(1, StudentModule.objects.all().count()) - self.assertEquals({}, self.mdc.find.return_value.state) + self.assertRaises(KeyError, self.kvs.get, student_state_key('not_a_field')) def test_delete_missing_field(self): "Test that deleting a missing field from an existing StudentModule raises a KeyError" From d6984cced8682d98d11658338aae6d166cf6059d Mon Sep 17 00:00:00 2001 From: jmvt Date: Mon, 4 Feb 2013 16:08:37 -0500 Subject: [PATCH 097/501] Updated code to handle new analytics result format. --- .../courseware/instructor_dashboard.html | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 967088de19..8053f67a88 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -345,10 +345,10 @@ function goto( mode) %if modeflag.get('Analytics'):

        - Number of students enrolled: + Number of students enrolled for ${students_enrolled_json['data'][0]['course_id']}: % if students_enrolled_json is not None: % if students_enrolled_json['status'] == 'success': - ${students_enrolled_json['data']['value']} as of ${students_enrolled_json['time']} + ${students_enrolled_json['data'][0]['count']} as of ${students_enrolled_json['time']} % else: ${students_enrolled_json['error']} % endif @@ -358,10 +358,10 @@ function goto( mode)

        - Number of active students for the past 7 days: + Number of students active for ${students_active_json['data'][0]['course_id']} for the past 7 days: % if students_active_json is not None: % if students_active_json['status'] == 'success': - ${students_active_json['data']['value']} as of ${students_active_json['time']} + ${students_active_json['data'][0]['count']} as of ${students_active_json['time']} % else: ${students_active_json['error']} % endif @@ -407,8 +407,8 @@ function goto( mode)

        - % for k,v in students_per_problem_correct_json['data'].items(): - + % for row in students_per_problem_correct_json['data']: + % endfor
        ProblemNumber of students
        ${k} ${v}
        ${row['module_id']} ${row['count']}
        @@ -420,6 +420,7 @@ function goto( mode) % endif

        +

        Students per module who attempted at least one problem @@ -429,8 +430,8 @@ function goto( mode)

        - % for k,v in attempted_problems['data'].items(): - + % for row in attempted_problems['data']: + % endfor
        ModuleNumber of students
        ${k} ${v}
        ${row['module_id']} ${row['count']}
        @@ -444,6 +445,27 @@ function goto( mode)

        +

        +

        Grade distribution:

        + + % if overall_grade_distribution is not None: + % if overall_grade_distribution['status'] == 'success': +
        + + + % for row in overall_grade_distribution['data']: + + % endfor +
        GradeNumber of students
        ${row['overall_grade']} ${row['count']}
        +
        + % else: + ${dropoff_per_day['error']} + % endif + % else: + null data + % endif +

        + ##

        Number of students who dropped off per day before becoming inactive:

        ## ## % if dropoff_per_day is not None: @@ -464,26 +486,6 @@ function goto( mode) ## % endif ##

        ## -##

        -##

        Grade distribution:

        -## -## % if overall_grade_distribution is not None: -## % if overall_grade_distribution['status'] == 'success': -##
        -## -## -## % for k,v in overall_grade_distribution['data'].items(): -## -## % endfor -##
        GradeNumber of students
        ${k} ${v}
        -##
        -## % else: -## ${dropoff_per_day['error']} -## % endif -## % else: -## null data -## % endif -##

        ##

        From bda46c423d110529e7b6e7e9ab043590fcdec1ec Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 8 Feb 2013 10:41:02 -0500 Subject: [PATCH 098/501] Fix some issues that were prevent lms start --- .../xmodule/combined_open_ended_module.py | 12 ----------- .../xmodule/combined_open_ended_modulev1.py | 15 ++------------ common/lib/xmodule/xmodule/openendedchild.py | 20 +------------------ .../xmodule/xmodule/peer_grading_module.py | 2 +- lms/xmodule_namespace.py | 2 +- 5 files changed, 5 insertions(+), 46 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 2da15a4086..3e1a622da3 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -1,24 +1,12 @@ -import copy -from fs.errors import ResourceNotFoundError -import itertools import json import logging from lxml import etree -from lxml.html import rewrite_links -from path import path -import os -import sys from pkg_resources import resource_string -from .capa_module import only_one, ComplexEncoder from .editing_module import EditingDescriptor -from .html_checker import check_html -from progress import Progress -from .stringify import stringify_children from .x_module import XModule from .xml_module import XmlDescriptor -from xmodule.modulestore import Location from combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor log = logging.getLogger("mitx.courseware") diff --git a/common/lib/xmodule/xmodule/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/combined_open_ended_modulev1.py index 8bd7df86c1..5775c42ce3 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_modulev1.py @@ -1,31 +1,20 @@ -import copy -from fs.errors import ResourceNotFoundError -import itertools import json import logging from lxml import etree from lxml.html import rewrite_links -from path import path -import os -import sys from pkg_resources import resource_string -from .capa_module import only_one, ComplexEncoder +from .capa_module import ComplexEncoder from .editing_module import EditingDescriptor -from .html_checker import check_html from progress import Progress from .stringify import stringify_children -from .x_module import XModule from .xml_module import XmlDescriptor -from xmodule.modulestore import Location import self_assessment_module import open_ended_module -from combined_open_ended_rubric import CombinedOpenEndedRubric, RubricParsingError -from .stringify import stringify_children +from combined_open_ended_rubric import CombinedOpenEndedRubric import dateutil import dateutil.parser -import datetime from timeparse import parse_timedelta log = logging.getLogger("mitx.courseware") diff --git a/common/lib/xmodule/xmodule/openendedchild.py b/common/lib/xmodule/xmodule/openendedchild.py index ba2de5c930..9111aa96a7 100644 --- a/common/lib/xmodule/xmodule/openendedchild.py +++ b/common/lib/xmodule/xmodule/openendedchild.py @@ -1,27 +1,9 @@ -import copy -from fs.errors import ResourceNotFoundError -import itertools import json import logging -from lxml import etree -from lxml.html import rewrite_links from lxml.html.clean import Cleaner, autolink_html -from path import path -import os -import sys -import hashlib -import capa.xqueue_interface as xqueue_interface import re -from pkg_resources import resource_string - -from .capa_module import only_one, ComplexEncoder -from .editing_module import EditingDescriptor -from .html_checker import check_html from progress import Progress -from .stringify import stringify_children -from .xml_module import XmlDescriptor -from xmodule.modulestore import Location from capa.util import * import open_ended_image_submission @@ -74,7 +56,7 @@ class OpenEndedChild(object): 'done': 'Problem complete', } - def __init__(self, system, location, definition, descriptor, static_data, + def __init__(self, system, location, definition, descriptor, static_data, instance_state=None, shared_state=None, **kwargs): # Load instance state if instance_state is not None: diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 20f71f3b3c..40ebf97fbe 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -24,7 +24,7 @@ from lxml.html import rewrite_links import os from pkg_resources import resource_string -from .capa_module import only_one, ComplexEncoder +from .capa_module import ComplexEncoder from .editing_module import EditingDescriptor from .html_checker import check_html from progress import Progress diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index 268e273c36..ef5afe4608 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -1,4 +1,4 @@ -from xblock.core import Namespace, Boolean, Scope, String, List +from xblock.core import Namespace, Boolean, Scope, String, List, Float from xmodule.fields import Date, Timedelta From bd822b9d2fcefb17f88c41c2059aa211879ada6a Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 13 Feb 2013 14:13:22 -0500 Subject: [PATCH 099/501] Fix tests post-merge --- common/djangoapps/student/views.py | 56 +++++++++---------- .../xmodule/combined_open_ended_module.py | 2 +- .../xmodule/combined_open_ended_modulev1.py | 2 +- .../lib/xmodule/xmodule/conditional_module.py | 9 ++- common/lib/xmodule/xmodule/course_module.py | 44 ++++++--------- common/lib/xmodule/xmodule/gst_module.py | 2 +- common/lib/xmodule/xmodule/modulestore/xml.py | 3 + .../xmodule/modulestore/xml_importer.py | 7 +-- .../lib/xmodule/xmodule/open_ended_module.py | 3 +- .../xmodule/xmodule/peer_grading_module.py | 29 ++++------ .../xmodule/xmodule/self_assessment_module.py | 8 +-- .../xmodule/tests/test_course_module.py | 14 ++--- lms/djangoapps/courseware/access.py | 6 +- lms/djangoapps/courseware/courses.py | 2 +- lms/djangoapps/courseware/module_render.py | 10 ++-- lms/djangoapps/courseware/tabs.py | 7 ++- lms/djangoapps/courseware/tests/tests.py | 4 +- lms/djangoapps/courseware/views.py | 6 +- .../controller_query_service.py | 2 +- lms/djangoapps/open_ended_grading/tests.py | 6 +- lms/templates/course.html | 2 +- lms/templates/courseware/progress.html | 4 +- 22 files changed, 107 insertions(+), 121 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 9be0aa2efa..a96cb8160b 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -44,9 +44,8 @@ from collections import namedtuple from courseware.courses import get_courses, sort_by_announcement from courseware.access import has_access -from courseware.models import StudentModuleCache from courseware.views import get_module_for_descriptor, jump_to -from courseware.module_render import get_instance_module +from courseware.model_data import ModelDataCache from statsd import statsd @@ -1071,14 +1070,14 @@ def accept_name_change(request): @csrf_exempt def test_center_login(request): - # errors are returned by navigating to the error_url, adding a query parameter named "code" + # errors are returned by navigating to the error_url, adding a query parameter named "code" # which contains the error code describing the exceptional condition. def makeErrorURL(error_url, error_code): log.error("generating error URL with error code {}".format(error_code)) return "{}?code={}".format(error_url, error_code); - + # get provided error URL, which will be used as a known prefix for returning error messages to the - # Pearson shell. + # Pearson shell. error_url = request.POST.get("errorURL") # TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson @@ -1089,12 +1088,12 @@ def test_center_login(request): # calculate SHA for query string # TODO: figure out how to get the original query string, so we can hash it and compare. - - + + if 'clientCandidateID' not in request.POST: return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID")); client_candidate_id = request.POST.get("clientCandidateID") - + # TODO: check remaining parameters, and maybe at least log if they're not matching # expected values.... # registration_id = request.POST.get("registrationID") @@ -1108,12 +1107,12 @@ def test_center_login(request): return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID")); # find testcenter_registration that matches the provided exam code: - # Note that we could rely in future on either the registrationId or the exam code, - # or possibly both. But for now we know what to do with an ExamSeriesCode, + # Note that we could rely in future on either the registrationId or the exam code, + # or possibly both. But for now we know what to do with an ExamSeriesCode, # while we currently have no record of RegistrationID values at all. if 'vueExamSeriesCode' not in request.POST: - # we are not allowed to make up a new error code, according to Pearson, - # so instead of "missingExamSeriesCode", we use a valid one that is + # we are not allowed to make up a new error code, according to Pearson, + # so instead of "missingExamSeriesCode", we use a valid one that is # inaccurate but at least distinct. (Sigh.) log.error("missing exam series code for cand ID {}".format(client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID")); @@ -1127,11 +1126,11 @@ def test_center_login(request): if not registrations: log.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned")); - + # TODO: figure out what to do if there are more than one registrations.... # for now, just take the first... registration = registrations[0] - + course_id = registration.course_id course = course_from_id(course_id) # assume it will be found.... if not course: @@ -1149,19 +1148,19 @@ def test_center_login(request): if not timelimit_descriptor: log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location)) return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")); - - timelimit_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, testcenteruser.user, - timelimit_descriptor, depth=None) - timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, + + timelimit_module_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user, + timelimit_descriptor, depth=None) + timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, timelimit_module_cache, course_id, position=None) if not timelimit_module.category == 'timelimit': log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location)) return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")); - + if timelimit_module and timelimit_module.has_ended: log.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at)) return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken")); - + # check if we need to provide an accommodation: time_accommodation_mapping = {'ET12ET' : 'ADDHALFTIME', 'ET30MN' : 'ADD30MIN', @@ -1174,27 +1173,24 @@ def test_center_login(request): # special, hard-coded client ID used by Pearson shell for testing: if client_candidate_id == "edX003671291147": time_accommodation_code = 'TESTING' - + if time_accommodation_code: timelimit_module.accommodation_code = time_accommodation_code - instance_module = get_instance_module(course_id, testcenteruser.user, timelimit_module, timelimit_module_cache) - instance_module.state = timelimit_module.get_instance_state() - instance_module.save() log.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code)) - + # UGLY HACK!!! - # Login assumes that authentication has occurred, and that there is a + # Login assumes that authentication has occurred, and that there is a # backend annotation on the user object, indicating which backend # against which the user was authenticated. We're authenticating here # against the registration entry, and assuming that the request given # this information is correct, we allow the user to be logged in # without a password. This could all be formalized in a backend object - # that does the above checking. + # that does the above checking. # TODO: (brian) create a backend class to do this. - # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) - testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass") + # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) + testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass") login(request, testcenteruser.user) - + # And start the test: return jump_to(request, course_id, location) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 3e1a622da3..7e583472b8 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -190,7 +190,7 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor): } """ - return {'xml_string' : etree.tostring(xml_object), 'metadata' : xml_object.attrib} + return {'xml_string' : etree.tostring(xml_object), 'metadata' : xml_object.attrib}, [] def definition_to_xml(self, resource_fs): diff --git a/common/lib/xmodule/xmodule/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/combined_open_ended_modulev1.py index 5775c42ce3..0b5e856e89 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_modulev1.py @@ -696,7 +696,7 @@ class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor): """Assumes that xml_object has child k""" return xml_object.xpath(k)[0] - return {'task_xml': parse_task('task'), 'prompt': parse('prompt'), 'rubric': parse('rubric')} + return {'task_xml': parse_task('task'), 'prompt': parse('prompt'), 'rubric': parse('rubric')}, [] def definition_to_xml(self, resource_fs): diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py index 787d355c4a..11d2a58231 100644 --- a/common/lib/xmodule/xmodule/conditional_module.py +++ b/common/lib/xmodule/xmodule/conditional_module.py @@ -4,6 +4,7 @@ import logging from xmodule.x_module import XModule from xmodule.modulestore import Location from xmodule.seq_module import SequenceDescriptor +from xblock.core import String, Scope from pkg_resources import resource_string @@ -34,6 +35,7 @@ class ConditionalModule(XModule): js_module_name = "Conditional" css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]} + condition = String(help="Condition for this module", default='', scope=Scope.settings) def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): """ @@ -44,7 +46,6 @@ class ConditionalModule(XModule): """ XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) self.contents = None - self.condition = self.metadata.get('condition', '') self._get_required_modules() children = self.get_display_items() if children: @@ -128,16 +129,18 @@ class ConditionalDescriptor(SequenceDescriptor): stores_state = True has_score = False + required = String(help="List of required xmodule locations, separated by &", default='', scope=Scope.settings) + def __init__(self, *args, **kwargs): super(ConditionalDescriptor, self).__init__(*args, **kwargs) - required_module_list = [tuple(x.split('/', 1)) for x in self.metadata.get('required', '').split('&')] + required_module_list = [tuple(x.split('/', 1)) for x in self.required.split('&')] self.required_module_locations = [] for rm in required_module_list: try: (tag, name) = rm except Exception as err: - msg = "Specification of required module in conditional is broken: %s" % self.metadata.get('required') + msg = "Specification of required module in conditional is broken: %s" % self.required log.warning(msg) self.system.error_tracker(msg) continue diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 8935b23a63..ad8dd06ab5 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -169,6 +169,11 @@ class CourseDescriptor(SequenceDescriptor): computed_default=lambda c: {'General': {'id': c.location.html_id()}}, ) testcenter_info = Object(help="Dictionary of Test Center info", scope=Scope.settings) + announcement = Date(help="Date this course is announced", scope=Scope.settings) + cohort_config = Object(help="Dictionary defining cohort configuration", scope=Scope.settings) + is_new = Boolean(help="Whether this course should be flagged as new", scope=Scope.settings) + no_grade = Boolean(help="True if this course isn't graded", default=False, scope=Scope.settings) + disable_progress_graph = Boolean(help="True if this course shouldn't display the progress graph", default=False, scope=Scope.settings) has_children = True info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') @@ -409,27 +414,12 @@ class CourseDescriptor(SequenceDescriptor): def lowest_passing_grade(self): return min(self._grading_policy['GRADE_CUTOFFS'].values()) - @property - def tabs(self): - """ - Return the tabs config, as a python object, or None if not specified. - """ - return self.metadata.get('tabs') - - @tabs.setter - def tabs(self, value): - self.metadata['tabs'] = value - - @property - def show_calculator(self): - return self.metadata.get("show_calculator", None) == "Yes" - @property def is_cohorted(self): """ Return whether the course is cohorted. """ - config = self.metadata.get("cohort_config") + config = self.cohort_config if config is None: return False @@ -440,7 +430,7 @@ class CourseDescriptor(SequenceDescriptor): """ Return list of topic ids defined in course policy. """ - topics = self.metadata.get("discussion_topics", {}) + topics = self.discussion_topics return [d["id"] for d in topics.values()] @@ -451,7 +441,7 @@ class CourseDescriptor(SequenceDescriptor): the empty set. Note that all inline discussions are automatically cohorted based on the course's is_cohorted setting. """ - config = self.metadata.get("cohort_config") + config = self.cohort_config if config is None: return set() @@ -460,13 +450,13 @@ class CourseDescriptor(SequenceDescriptor): @property - def is_new(self): + def is_newish(self): """ - Returns if the course has been flagged as new in the metadata. If + Returns if the course has been flagged as new. If there is no flag, return a heuristic value considering the announcement and the start dates. """ - flag = self.metadata.get('is_new', None) + flag = self.is_new if flag is None: # Use a heuristic if the course has not been flagged announcement, start, now = self._sorting_dates() @@ -512,12 +502,10 @@ class CourseDescriptor(SequenceDescriptor): def to_datetime(timestamp): return datetime(*timestamp[:6]) - def get_date(field): - timetuple = self._try_parse_time(field) - return to_datetime(timetuple) if timetuple else None - - announcement = get_date('announcement') - start = get_date('advertised_start') or to_datetime(self.start) + announcement = self.announcement + if announcement is not None: + announcement = to_datetime(announcement) + start = self.advertised_start or to_datetime(self.start) now = to_datetime(time.gmtime()) return announcement, start, now @@ -719,7 +707,7 @@ class CourseDescriptor(SequenceDescriptor): def get_test_center_exam(self, exam_series_code): exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code] return exams[0] if len(exams) == 1 else None - + @property def title(self): return self.display_name diff --git a/common/lib/xmodule/xmodule/gst_module.py b/common/lib/xmodule/xmodule/gst_module.py index ef1be96c84..91a9828c3e 100644 --- a/common/lib/xmodule/xmodule/gst_module.py +++ b/common/lib/xmodule/xmodule/gst_module.py @@ -177,7 +177,7 @@ class GraphicalSliderToolDescriptor(MakoModuleDescriptor, XmlDescriptor): return { 'render': parse('render'), 'configuration': parse('configuration') - } + }, [] def definition_to_xml(self, resource_fs): '''Return an xml element representing this definition.''' diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index d5e48832b9..5675ce1037 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -456,6 +456,9 @@ class XMLModuleStore(ModuleStoreBase): def _load_extra_content(self, system, course_descriptor, category, path, course_dir): for filepath in glob.glob(path / '*'): + if not os.path.isfile(filepath): + continue + with open(filepath) as f: try: html = f.read().decode('utf-8') diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 3f04c4036a..d4251360a9 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -199,16 +199,13 @@ def import_from_xml(store, data_dir, course_dirs=None, course_items = [] for course_id in module_store.modules.keys(): - course_data_path = None - course_location = None - # Import course modules first, because importing some of the children requires the course to exist for module in module_store.modules[course_id].itervalues(): if module.category == 'course': import_course_from_xml( store, static_content_store, - course_data_path, + data_dir / module.data_dir, module, target_location_namespace, verbose=verbose @@ -220,7 +217,7 @@ def import_from_xml(store, data_dir, course_dirs=None, import_module_from_xml( store, static_content_store, - course_data_path, + data_dir / module.data_dir, module, target_location_namespace, verbose=verbose diff --git a/common/lib/xmodule/xmodule/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_module.py index 0ad6a26995..0f297c2681 100644 --- a/common/lib/xmodule/xmodule/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_module.py @@ -20,7 +20,6 @@ import capa.xqueue_interface as xqueue_interface from pkg_resources import resource_string -from .capa_module import only_one, ComplexEncoder from .editing_module import EditingDescriptor from .html_checker import check_html from progress import Progress @@ -656,7 +655,7 @@ class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor): """Assumes that xml_object has child k""" return xml_object.xpath(k)[0] - return {'oeparam': parse('openendedparam'), } + return {'oeparam': parse('openendedparam')}, [] def definition_to_xml(self, resource_fs): diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 40ebf97fbe..4315e10a3e 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -32,6 +32,7 @@ from .stringify import stringify_children from .x_module import XModule from .xml_module import XmlDescriptor from xmodule.modulestore import Location +from xblock.core import Scope, Object, Integer, Boolean, String from peer_grading_service import peer_grading_service, GradingServiceError @@ -56,32 +57,26 @@ class PeerGradingModule(XModule): css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]} - def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, **kwargs): - XModule.__init__(self, system, location, definition, descriptor, - instance_state, shared_state, **kwargs) + student_data_for_location = Object(scope=Scope.student_state) + max_grade = Integer(default=MAX_SCORE, scope=Scope.student_state) + use_for_single_location = Boolean(default=USE_FOR_SINGLE_LOCATION, scope=Scope.settings) + is_graded = Boolean(default=IS_GRADED, scope=Scope.settings) + link_to_location = String(default=LINK_TO_LOCATION, scope=Scope.settings) - # Load instance state - if instance_state is not None: - instance_state = json.loads(instance_state) - else: - instance_state = {} + def __init__(self, *args, **kwargs): + super(PeerGradingModule, self).__init__(*args, **kwargs) #We need to set the location here so the child modules can use it - system.set('location', location) - self.system = system + self.system.set('location', self.location) self.peer_gs = peer_grading_service(self.system) - self.use_for_single_location = self.metadata.get('use_for_single_location', USE_FOR_SINGLE_LOCATION) if isinstance(self.use_for_single_location, basestring): self.use_for_single_location = (self.use_for_single_location in TRUE_DICT) - self.is_graded = self.metadata.get('is_graded', IS_GRADED) if isinstance(self.is_graded, basestring): self.is_graded = (self.is_graded in TRUE_DICT) - self.link_to_location = self.metadata.get('link_to_location', USE_FOR_SINGLE_LOCATION) - if self.use_for_single_location == True: + if self.use_for_single_location: #This will raise an exception if the location is invalid link_to_location_object = Location(self.link_to_location) @@ -89,8 +84,6 @@ class PeerGradingModule(XModule): if not self.ajax_url.endswith("/"): self.ajax_url = self.ajax_url + "/" - self.student_data_for_location = instance_state.get('student_data_for_location', {}) - self.max_grade = instance_state.get('max_grade', MAX_SCORE) if not isinstance(self.max_grade, (int, long)): #This could result in an exception, but not wrapping in a try catch block so it moves up the stack self.max_grade = int(self.max_grade) @@ -521,7 +514,7 @@ class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor): """Assumes that xml_object has child k""" return xml_object.xpath(k)[0] - return {} + return {}, [] def definition_to_xml(self, resource_fs): diff --git a/common/lib/xmodule/xmodule/self_assessment_module.py b/common/lib/xmodule/xmodule/self_assessment_module.py index c1235979b3..f53d543e8e 100644 --- a/common/lib/xmodule/xmodule/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/self_assessment_module.py @@ -58,10 +58,10 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): # Used for progress / grading. Currently get credit just for # completion (doesn't matter if you self-assessed correct/incorrect). - max_score = Integer(scope=Scope.settings, default=MAX_SCORE) + max_score = Integer(scope=Scope.settings, default=openendedchild.MAX_SCORE) + max_attempts = Integer(scope=Scope.settings, default=openendedchild.MAX_ATTEMPTS) attempts = Integer(scope=Scope.student_state, default=0) - max_attempts = Integer(scope=Scope.settings, default=MAX_ATTEMPTS) rubric = String(scope=Scope.content) prompt = String(scope=Scope.content) submitmessage = String(scope=Scope.content) @@ -318,9 +318,9 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor): # Used for progress / grading. Currently get credit just for # completion (doesn't matter if you self-assessed correct/incorrect). - max_score = Integer(scope=Scope.settings, default=MAX_SCORE) + max_score = Integer(scope=Scope.settings, default=openendedchild.MAX_SCORE) - max_attempts = Integer(scope=Scope.settings, default=MAX_ATTEMPTS) + max_attempts = Integer(scope=Scope.settings, default=openendedchild.MAX_ATTEMPTS) rubric = String(scope=Scope.content) prompt = String(scope=Scope.content) submitmessage = String(scope=Scope.content) diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py index 712b095696..8de53c3bb2 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_module.py +++ b/common/lib/xmodule/xmodule/tests/test_course_module.py @@ -94,26 +94,26 @@ class IsNewCourseTestCase(unittest.TestCase): @patch('xmodule.course_module.time.gmtime') - def test_is_new(self, gmtime_mock): + def test_is_newish(self, gmtime_mock): gmtime_mock.return_value = NOW descriptor = self.get_dummy_course(start='2012-12-02T12:00', is_new=True) - assert(descriptor.is_new is True) + assert(descriptor.is_newish is True) descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=False) assert(descriptor.is_new is False) descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=True) - assert(descriptor.is_new is True) + assert(descriptor.is_newish is True) descriptor = self.get_dummy_course(start='2013-01-15T12:00') - assert(descriptor.is_new is True) + assert(descriptor.is_newish is True) descriptor = self.get_dummy_course(start='2013-03-00T12:00') - assert(descriptor.is_new is True) + assert(descriptor.is_newish is True) descriptor = self.get_dummy_course(start='2012-10-15T12:00') - assert(descriptor.is_new is False) + assert(descriptor.is_newish is False) descriptor = self.get_dummy_course(start='2012-12-31T12:00') - assert(descriptor.is_new is True) + assert(descriptor.is_newish is True) diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 6842a9ca42..b2c3691eb7 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -442,7 +442,7 @@ def _adjust_start_date_for_beta_testers(user, descriptor): NOTE: If testing manually, make sure MITX_FEATURES['DISABLE_START_DATES'] = False in envs/dev.py! """ - if descriptor.days_early_for_beta is None: + if descriptor.lms.days_early_for_beta is None: # bail early if no beta testing is set up return descriptor.lms.start @@ -456,12 +456,12 @@ def _adjust_start_date_for_beta_testers(user, descriptor): # (fun fact: datetime(*a_time_struct[:6]) is the beautiful syntax for # converting time_structs into datetimes) start_as_datetime = datetime(*descriptor.lms.start[:6]) - delta = timedelta(descriptor.days_early_for_beta) + delta = timedelta(descriptor.lms.days_early_for_beta) effective = start_as_datetime - delta # ...and back to time_struct return effective.timetuple() - return descriptor.start + return descriptor.lms.start def _has_instructor_access_to_location(user, location, course_context=None): diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 417352a43c..12330865e7 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -89,7 +89,7 @@ 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""" if isinstance(modulestore(), XMLModuleStore): - return '/static/' + course.metadata['data_dir'] + "/images/course_image.jpg" + return '/static/' + course.data_dir + "/images/course_image.jpg" else: loc = course.location._replace(tag='c4x', category='asset', name='images_course_image.jpg') path = StaticContent.get_url_path_from_location(loc) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 3ff3985519..ba2a70371c 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -145,7 +145,7 @@ def get_module(user, request, location, model_data_cache, course_id, location = Location(location) descriptor = modulestore().get_instance(course_id, location, depth=depth) return get_module_for_descriptor(user, request, descriptor, model_data_cache, course_id, - position=position, not_found_ok=not_found_ok, + position=position, wrap_xmodule_display=wrap_xmodule_display, grade_bucket_type=grade_bucket_type) except ItemNotFoundError: @@ -205,14 +205,14 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours Delegate to get_module. It does an access check, so may return None """ return get_module_for_descriptor(user, request, descriptor, - student_module_cache, course_id, position) + model_data_cache, course_id, position) def xblock_model_data(descriptor): return DbModel( LmsKeyValueStore(descriptor._model_data, model_data_cache), descriptor.module_class, user.id, - LmsUsage(location, location) + LmsUsage(descriptor.location, descriptor.location) ) def publish(event): @@ -260,7 +260,7 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours # by the replace_static_urls code below replace_urls=partial( static_replace.replace_static_urls, - data_directory=descriptor.metadata.get('data_dir', ''), + data_directory=descriptor.data_dir, course_namespace=descriptor.location._replace(category=None, name=None), ), node_path=settings.NODE_PATH, @@ -282,7 +282,7 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours log.exception("Error creating module from descriptor {0}".format(descriptor)) # make an ErrorDescriptor -- assuming that the descriptor's system is ok - if has_access(user, location, 'staff', course_id): + if has_access(user, descriptor.location, 'staff', course_id): err_descriptor_class = ErrorDescriptor else: err_descriptor_class = NonStaffErrorDescriptor diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index a47141b183..ad8e90bd1a 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -28,6 +28,7 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.xml import XMLModuleStore from xmodule.x_module import XModule from student.models import unique_id_for_user +from courseware.model_data import ModelDataCache from open_ended_grading import open_ended_notifications @@ -321,10 +322,12 @@ def get_static_tab_by_slug(course, tab_slug): return None -def get_static_tab_contents(request, cache, course, tab): +def get_static_tab_contents(request, course, tab): loc = Location(course.location.tag, course.location.org, course.location.course, 'static_tab', tab['url_slug']) - tab_module = get_module(request.user, request, loc, cache, course.id) + model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course.id, + request.user, modulestore().get_instance(course.id, loc), depth=0) + tab_module = get_module(request.user, request, loc, model_data_cache, course.id) logging.debug('course_module = {0}'.format(tab_module)) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 4073af6fb9..db50960800 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -742,11 +742,11 @@ class TestViewAuth(PageLoader): yesterday = time.time() - 24 * 3600 # toy course's hasn't started - self.toy.metadata['start'] = stringify_time(time.gmtime(tomorrow)) + self.toy.lms.start = time.gmtime(tomorrow) self.assertFalse(self.toy.has_started()) # but should be accessible for beta testers - self.toy.days_early_for_beta = 2 + self.toy.lms.days_early_for_beta = 2 # student user shouldn't see it student_user = user(self.student) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index c0eb0d186c..0c06649857 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -443,7 +443,11 @@ def static_tab(request, course_id, tab_slug): if tab is None: raise Http404 - contents = tabs.get_static_tab_contents(request, None, course, tab) + contents = tabs.get_static_tab_contents( + request, + course, + tab + ) if contents is None: raise Http404 diff --git a/lms/djangoapps/open_ended_grading/controller_query_service.py b/lms/djangoapps/open_ended_grading/controller_query_service.py index 83d5617bd2..fbe2f6c006 100644 --- a/lms/djangoapps/open_ended_grading/controller_query_service.py +++ b/lms/djangoapps/open_ended_grading/controller_query_service.py @@ -18,7 +18,7 @@ class ControllerQueryService(GradingService): Interface to staff grading backend. """ def __init__(self, config): - config['system'] = ModuleSystem(None, None, None, render_to_string, None) + config['system'] = ModuleSystem(None, None, None, render_to_string, None, None) super(ControllerQueryService, self).__init__(config) self.check_eta_url = self.url + '/get_submission_eta/' self.is_unique_url = self.url + '/is_name_unique/' diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py index ec2fe5ab38..4c18a0d1a7 100644 --- a/lms/djangoapps/open_ended_grading/tests.py +++ b/lms/djangoapps/open_ended_grading/tests.py @@ -143,10 +143,10 @@ class TestPeerGradingService(ct.PageLoader): location = "i4x://edX/toy/peergrading/init" self.mock_service = peer_grading_service.MockPeerGradingService() - self.system = ModuleSystem(location, None, None, render_to_string, None) - self.descriptor = peer_grading_module.PeerGradingDescriptor(self.system) + self.system = ModuleSystem(location, None, None, render_to_string, None, None) + self.descriptor = peer_grading_module.PeerGradingDescriptor(self.system, location, {}) - self.peer_module = peer_grading_module.PeerGradingModule(self.system, location, "", self.descriptor) + self.peer_module = peer_grading_module.PeerGradingModule(self.system, location, self.descriptor, {}) self.peer_module.peer_gs = self.mock_service self.logout() diff --git a/lms/templates/course.html b/lms/templates/course.html index a2eff572e1..f009955df1 100644 --- a/lms/templates/course.html +++ b/lms/templates/course.html @@ -5,7 +5,7 @@ %> <%page args="course" />

        - %if course.is_new: + %if course.is_newish: New %endif diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index 9b52ff2069..9c3df5237c 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -18,7 +18,7 @@ @@ -32,7 +32,7 @@ ${progress_graph.body(grade_summary, course.grade_cutoffs, "grade-detail-graph",

        Course Progress

        - %if not course.metadata.get('disable_progress_graph',False): + %if not course.disable_progress_graph:
        %endif From f0f7e0dc1cc4aeb0187331556ca850c49aa49027 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 13 Feb 2013 14:22:08 -0500 Subject: [PATCH 100/501] Fix more tests --- common/djangoapps/course_groups/tests/tests.py | 4 ++-- lms/djangoapps/courseware/module_render.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/common/djangoapps/course_groups/tests/tests.py b/common/djangoapps/course_groups/tests/tests.py index b3ad928b39..e8727758c4 100644 --- a/common/djangoapps/course_groups/tests/tests.py +++ b/common/djangoapps/course_groups/tests/tests.py @@ -70,13 +70,13 @@ class TestCohorts(django.test.TestCase): "id": to_id(name)}) for name in discussions) - course.metadata["discussion_topics"] = topics + course.discussion_topics = topics d = {"cohorted": cohorted} if cohorted_discussions is not None: d["cohorted_discussions"] = [to_id(name) for name in cohorted_discussions] - course.metadata["cohort_config"] = d + course.cohort_config = d def setUp(self): diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index ba2a70371c..54498cfc56 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -260,7 +260,7 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours # by the replace_static_urls code below replace_urls=partial( static_replace.replace_static_urls, - data_directory=descriptor.data_dir, + data_directory=getattr(descriptor, 'data_dir', None), course_namespace=descriptor.location._replace(category=None, name=None), ), node_path=settings.NODE_PATH, From 927da344775eb77f6d396e6e9a3e14c4e2524d0e Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 13 Feb 2013 16:15:22 -0500 Subject: [PATCH 101/501] Fix data_dirs for /static link replacement --- common/lib/xmodule/xmodule/modulestore/tests/factories.py | 2 +- lms/djangoapps/courseware/module_render.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index ca4b557e80..5375f3262f 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -99,7 +99,7 @@ class XModuleItemFactory(Factory): new_item = store.clone_item(template, dest_location) # TODO: This needs to be deleted when we have proper storage for static content - new_item.metadata['data_dir'] = parent.metadata['data_dir'] + new_item.data_dir = parent.data_dir # replace the display name with an optional parameter passed in from the caller if display_name is not None: diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 54498cfc56..7204acb4b3 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -303,7 +303,7 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours module.get_html = replace_static_urls( _get_html, - getattr(module, 'data_dir', ''), + getattr(descriptor, 'data_dir', None), course_namespace=module.location._replace(category=None, name=None)) # Allow URLs of the form '/course/' refer to the root of multicourse directory From 50af0be13d21c64dd0e96be2c740b1127b410f6d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 13 Feb 2013 16:17:12 -0500 Subject: [PATCH 102/501] Fix course short description display (now that they're xmodules) --- lms/static/sass/shared/_course_object.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lms/static/sass/shared/_course_object.scss b/lms/static/sass/shared/_course_object.scss index 2c638ed158..e99559a49f 100644 --- a/lms/static/sass/shared/_course_object.scss +++ b/lms/static/sass/shared/_course_object.scss @@ -205,7 +205,10 @@ position: relative; width: 100%; - p { + section { + color: $base-font-color; + font: normal 1em/1.6em $serif; + margin: 0px; height: 100%; overflow: hidden; text-overflow: ellipsis; From 2443aa79a7be753d927fe4b20b732e2d2d36443e Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 13 Feb 2013 16:27:29 -0500 Subject: [PATCH 103/501] Indentation fix --- common/lib/xmodule/xmodule/modulestore/store_utilities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/modulestore/store_utilities.py b/common/lib/xmodule/xmodule/modulestore/store_utilities.py index d3208e585a..657e542765 100644 --- a/common/lib/xmodule/xmodule/modulestore/store_utilities.py +++ b/common/lib/xmodule/xmodule/modulestore/store_utilities.py @@ -55,7 +55,7 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele ) new_children.append(child_loc.url()) - modulestore.update_children(module.location, new_children) + modulestore.update_children(module.location, new_children) # save metadata modulestore.update_metadata(module.location, module._model_data._kvs._metadata) From 04d71469446871a9fcb72b7d7c1d39fa1a22568c Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 13 Feb 2013 16:31:38 -0500 Subject: [PATCH 104/501] Fixing xmodule tests --- .../lib/xmodule/xmodule/tests/test_course_module.py | 2 +- common/lib/xmodule/xmodule/tests/test_import.py | 6 +++--- .../lib/xmodule/xmodule/tests/test_self_assessment.py | 11 +++++------ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py index 8de53c3bb2..97c6be2ed6 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_module.py +++ b/common/lib/xmodule/xmodule/tests/test_course_module.py @@ -101,7 +101,7 @@ class IsNewCourseTestCase(unittest.TestCase): assert(descriptor.is_newish is True) descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=False) - assert(descriptor.is_new is False) + assert(descriptor.is_newish is False) descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=True) assert(descriptor.is_newish is True) diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index c4547241e0..c0ecd7e854 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -370,13 +370,13 @@ class ImportTestCase(BaseCourseTestCase): self.assertFalse(course.is_cohorted) # empty config -> False - course.metadata['cohort_config'] = {} + course.cohort_config = {} self.assertFalse(course.is_cohorted) # false config -> False - course.metadata['cohort_config'] = {'cohorted': False} + course.cohort_config = {'cohorted': False} self.assertFalse(course.is_cohorted) # and finally... - course.metadata['cohort_config'] = {'cohorted': True} + course.cohort_config = {'cohorted': True} self.assertTrue(course.is_cohorted) diff --git a/common/lib/xmodule/xmodule/tests/test_self_assessment.py b/common/lib/xmodule/xmodule/tests/test_self_assessment.py index 617b2b142a..00101d7f40 100644 --- a/common/lib/xmodule/xmodule/tests/test_self_assessment.py +++ b/common/lib/xmodule/xmodule/tests/test_self_assessment.py @@ -28,8 +28,6 @@ class SelfAssessmentTest(unittest.TestCase): location = Location(["i4x", "edX", "sa_test", "selfassessment", "SampleQuestion"]) - metadata = {'attempts': '10'} - descriptor = Mock() def setUp(self): @@ -46,13 +44,14 @@ class SelfAssessmentTest(unittest.TestCase): 'max_score': 1, 'display_name': "Name", 'accept_file_upload': False, - 'close_date': None + 'close_date': None, + 'attempts': '10' + } self.module = SelfAssessmentModule(test_system, self.location, - self.definition, self.descriptor, - static_data, - state, metadata=self.metadata) + self.descriptor, + static_data) def test_get_html(self): html = self.module.get_html(test_system) From 059b9f66e35f8432d4c3d170979dd53e368cb9fa Mon Sep 17 00:00:00 2001 From: Alexander Kryklia Date: Fri, 15 Feb 2013 18:48:39 +0200 Subject: [PATCH 105/501] poll and conditional finished --- common/lib/capa/capa/chem/tests.py | 1 - common/lib/xmodule/setup.py | 7 +- common/lib/xmodule/xmodule/capa_module.py | 1 - .../lib/xmodule/xmodule/conditional_module.py | 234 +++++++++------- .../lib/xmodule/xmodule/css/poll/display.scss | 226 ++++++++++++++++ .../xmodule/xmodule/css/wrapper/display.scss | 0 common/lib/xmodule/xmodule/js/src/.gitignore | 2 +- .../xmodule/js/src/conditional/display.coffee | 57 ++-- .../xmodule/js/src/poll/display.coffee | 13 - .../lib/xmodule/xmodule/js/src/poll/logme.js | 54 ++++ .../lib/xmodule/xmodule/js/src/poll/poll.js | 5 + .../xmodule/xmodule/js/src/poll/poll_main.js | 255 ++++++++++++++++++ .../xmodule/js/src/wrapper/edit.coffee | 10 + common/lib/xmodule/xmodule/modulestore/xml.py | 3 +- common/lib/xmodule/xmodule/poll_module.py | 204 +++++++++++--- common/lib/xmodule/xmodule/stringify.py | 3 +- .../lib/xmodule/xmodule/tests/test_export.py | 5 +- .../lib/xmodule/xmodule/tests/test_import.py | 23 +- .../lib/xmodule/xmodule/tests/test_logic.py | 65 +++++ common/lib/xmodule/xmodule/wrapper_module.py | 26 ++ common/test/data/conditional_and_poll/README | 50 ++++ .../test/data/conditional_and_poll/README.md | 2 + .../about/2013_Spring/overview.html | 79 ++++++ .../about/2013_Spring/prerequisites.html | 1 + .../about/2013_Spring/short_description.html | 1 + .../about/2013_Spring/video.html | 1 + .../conditional_and_poll/chapter/Staff.xml | 3 + .../test/data/conditional_and_poll/course.xml | 1 + .../course/2013_Spring.xml | 6 + .../conditional_and_poll/creating_course.xml | 8 + .../info/2013_Spring/handouts.html | 3 + .../info/2013_Spring/updates.html | 10 + .../policies/2013_Spring/policy.json | 8 + .../roots/2013_Spring.xml | 2 + .../sequential/Problem_Demos.xml | 31 +++ .../data/conditional_and_poll/static/README | 5 + .../static/images/course_image.jpg | Bin 0 -> 12626 bytes .../static/images/professor-sandel.jpg | Bin 0 -> 453715 bytes lms/djangoapps/courseware/model_data.py | 2 +- lms/envs/aws.py | 2 +- .../sass/course/courseware/_courseware.scss | 8 +- lms/templates/conditional_ajax.html | 9 +- lms/templates/conditional_module.html | 28 +- lms/templates/poll.html | 17 +- rakefile | 6 + 45 files changed, 1276 insertions(+), 201 deletions(-) create mode 100644 common/lib/xmodule/xmodule/css/poll/display.scss create mode 100644 common/lib/xmodule/xmodule/css/wrapper/display.scss delete mode 100644 common/lib/xmodule/xmodule/js/src/poll/display.coffee create mode 100644 common/lib/xmodule/xmodule/js/src/poll/logme.js create mode 100644 common/lib/xmodule/xmodule/js/src/poll/poll.js create mode 100644 common/lib/xmodule/xmodule/js/src/poll/poll_main.js create mode 100644 common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee create mode 100644 common/lib/xmodule/xmodule/tests/test_logic.py create mode 100644 common/lib/xmodule/xmodule/wrapper_module.py create mode 100644 common/test/data/conditional_and_poll/README create mode 100644 common/test/data/conditional_and_poll/README.md create mode 100644 common/test/data/conditional_and_poll/about/2013_Spring/overview.html create mode 100644 common/test/data/conditional_and_poll/about/2013_Spring/prerequisites.html create mode 100644 common/test/data/conditional_and_poll/about/2013_Spring/short_description.html create mode 100644 common/test/data/conditional_and_poll/about/2013_Spring/video.html create mode 100644 common/test/data/conditional_and_poll/chapter/Staff.xml create mode 120000 common/test/data/conditional_and_poll/course.xml create mode 100644 common/test/data/conditional_and_poll/course/2013_Spring.xml create mode 100644 common/test/data/conditional_and_poll/creating_course.xml create mode 100644 common/test/data/conditional_and_poll/info/2013_Spring/handouts.html create mode 100644 common/test/data/conditional_and_poll/info/2013_Spring/updates.html create mode 100644 common/test/data/conditional_and_poll/policies/2013_Spring/policy.json create mode 100644 common/test/data/conditional_and_poll/roots/2013_Spring.xml create mode 100644 common/test/data/conditional_and_poll/sequential/Problem_Demos.xml create mode 100644 common/test/data/conditional_and_poll/static/README create mode 100644 common/test/data/conditional_and_poll/static/images/course_image.jpg create mode 100644 common/test/data/conditional_and_poll/static/images/professor-sandel.jpg diff --git a/common/lib/capa/capa/chem/tests.py b/common/lib/capa/capa/chem/tests.py index 571526f915..037e5b0d9c 100644 --- a/common/lib/capa/capa/chem/tests.py +++ b/common/lib/capa/capa/chem/tests.py @@ -277,7 +277,6 @@ class Test_Render_Equations(unittest.TestCase): def test_render9(self): s = "5[Ni(NH3)4]^2+ + 5/2SO4^2-" - #import ipdb; ipdb.set_trace() out = render_to_html(s) correct = u'5[Ni(NH3)4]2++52SO42-' log(out + ' ------- ' + correct, 'html') diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 048804fb60..bf49987511 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -28,6 +28,7 @@ setup( "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", "error = xmodule.error_module:ErrorDescriptor", "peergrading = xmodule.peer_grading_module:PeerGradingDescriptor", + "poll_question = xmodule.poll_module:PollDescriptor", "problem = xmodule.capa_module:CapaDescriptor", "problemset = xmodule.seq_module:SequenceDescriptor", "randomize = xmodule.randomize_module:RandomizeDescriptor", @@ -44,8 +45,8 @@ setup( "static_tab = xmodule.html_module:StaticTabDescriptor", "custom_tag_template = xmodule.raw_module:RawDescriptor", "about = xmodule.html_module:AboutDescriptor", - "poll = xmodule.poll_module:PollDescriptor", - "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor", - ] + "graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor, + "wrapper = xmodule.wrapper_module:WrapperDescriptor", + ], } ) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 92742203c4..b82a8c7113 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -521,7 +521,6 @@ class CapaModule(XModule): answers = self.make_dict_of_responses(get) event_info['answers'] = convert_files_to_filenames(answers) - # Too late. Cannot submit if self.closed(): event_info['failure'] = 'closed' diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py index 11d2a58231..6a0388efed 100644 --- a/common/lib/xmodule/xmodule/conditional_module.py +++ b/common/lib/xmodule/xmodule/conditional_module.py @@ -1,127 +1,129 @@ +"""Conditional module is the xmodule, which you can use for disabling +some xmodules by conditions. +""" + import json import logging +from lxml import etree +from pkg_resources import resource_string from xmodule.x_module import XModule from xmodule.modulestore import Location from xmodule.seq_module import SequenceDescriptor -from xblock.core import String, Scope +from xblock.core import String, Scope, List +from xmodule.modulestore.exceptions import ItemNotFoundError -from pkg_resources import resource_string log = logging.getLogger('mitx.' + __name__) class ConditionalModule(XModule): - ''' + """ Blocks child module from showing unless certain conditions are met. Example: - + + - - + TODO string comparison + multiple answer for every poll - ''' + """ - js = {'coffee': [resource_string(__name__, 'js/src/conditional/display.coffee'), + js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'), + resource_string(__name__, 'js/src/conditional/display.coffee'), resource_string(__name__, 'js/src/collapsible.coffee'), - resource_string(__name__, 'js/src/javascript_loader.coffee'), + ]} js_module_name = "Conditional" css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]} - condition = String(help="Condition for this module", default='', scope=Scope.settings) + contents = String(scope=Scope.content) - def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs): - """ - In addition to the normal XModule init, provide: + # Map + # key: + # value: + conditions_map = { + 'poll_answer': 'poll_answer', # poll_question attr + 'compeleted': 'is_competed', # capa_problem attr + 'attempted': 'is_attempted', # capa_problem attr + 'voted': 'voted' # poll_question attr + } - self.condition = string describing condition required - - """ - XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) - self.contents = None - self._get_required_modules() - children = self.get_display_items() - if children: - self.icon_class = children[0].get_icon_class() - #log.debug('conditional module required=%s' % self.required_modules_list) - - def _get_required_modules(self): - self.required_modules = [] - for descriptor in self.descriptor.get_required_module_descriptors(): - module = self.system.get_module(descriptor) - self.required_modules.append(module) - #log.debug('required_modules=%s' % (self.required_modules)) + def _get_condition(self): + # Get first valid condition. + for xml_attr, attr_name in self.conditions_map.iteritems(): + xml_value = self.descriptor.xml_attributes.get(xml_attr) + if xml_value: + return xml_value, attr_name + raise Exception('Error in conditional module: unknown condition "%s"' + % xml_attr) def is_condition_satisfied(self): - self._get_required_modules() + self.required_modules = [self.system.get_module(descriptor.location) for + descriptor in self.descriptor.get_required_module_descriptors()] - if self.condition == 'require_completed': - # all required modules must be completed, as determined by - # the modules .is_completed() method - for module in self.required_modules: - #log.debug('in is_condition_satisfied; student_answers=%s' % module.lcp.student_answers) - #log.debug('in is_condition_satisfied; instance_state=%s' % module.instance_state) - if not hasattr(module, 'is_completed'): - raise Exception('Error in conditional module: required module %s has no .is_completed() method' % module) - if not module.is_completed(): - log.debug('conditional module: %s not completed' % module) - return False - else: - log.debug('conditional module: %s IS completed' % module) - return True - elif self.condition == 'require_attempted': - # all required modules must be attempted, as determined by - # the modules .is_attempted() method - for module in self.required_modules: - if not hasattr(module, 'is_attempted'): - raise Exception('Error in conditional module: required module %s has no .is_attempted() method' % module) - if not module.is_attempted(): - log.debug('conditional module: %s not attempted' % module) - return False - else: - log.debug('conditional module: %s IS attempted' % module) - return True - else: - raise Exception('Error in conditional module: unknown condition "%s"' % self.condition) + xml_value, attr_name = self._get_condition() - return True + if xml_value and self.required_modules: + for module in self.required_modules: + if not hasattr(module, attr_name): + raise Exception('Error in conditional module: \ + required module {module} has no {module_attr}'.format( + module=module, module_attr=attr_name)) + + attr = getattr(module, attr_name) + if callable(attr): + attr = attr() + + if xml_value != str(attr): + break + else: + return True + return False def get_html(self): - self.is_condition_satisfied() + # Calculate html ids of dependencies + self.required_html_ids = [descriptor.location.html_id() for + descriptor in self.descriptor.get_required_module_descriptors()] + return self.system.render_template('conditional_ajax.html', { 'element_id': self.location.html_id(), 'id': self.id, 'ajax_url': self.system.ajax_url, + 'depends': ';'.join(self.required_html_ids) }) def handle_ajax(self, dispatch, post): - ''' - This is called by courseware.module_render, to handle an AJAX call. - ''' - #log.debug('conditional_module handle_ajax: dispatch=%s' % dispatch) - + """This is called by courseware.moduleodule_render, to handle + an AJAX call. + """ if not self.is_condition_satisfied(): - context = {'module': self} - html = self.system.render_template('conditional_module.html', context) - return json.dumps({'html': html}) + message = self.descriptor.xml_attributes.get('message') + context = {'module': self, + 'message': message} + html = self.system.render_template('conditional_module.html', + context) + return json.dumps({'html': [html], 'passed': False, + 'message': bool(message)}) if self.contents is None: - self.contents = [child.get_html() for child in self.get_display_items()] + self.contents = [self.system.get_module(child_descriptor.location + ).get_html() + for child_descriptor in self.descriptor.get_children()] - # for now, just deal with one child - html = self.contents[0] - - return json.dumps({'html': html}) + html = self.contents + return json.dumps({'html': html, 'passed': True}) class ConditionalDescriptor(SequenceDescriptor): + """Descriptor for conditional xmodule.""" + _tag_name = 'conditional' + module_class = ConditionalModule filename_extension = "xml" @@ -129,28 +131,66 @@ class ConditionalDescriptor(SequenceDescriptor): stores_state = True has_score = False - required = String(help="List of required xmodule locations, separated by &", default='', scope=Scope.settings) + show_tag_list = List(help="Poll answers", scope=Scope.content) - def __init__(self, *args, **kwargs): - super(ConditionalDescriptor, self).__init__(*args, **kwargs) - - required_module_list = [tuple(x.split('/', 1)) for x in self.required.split('&')] - self.required_module_locations = [] - for rm in required_module_list: - try: - (tag, name) = rm - except Exception as err: - msg = "Specification of required module in conditional is broken: %s" % self.required - log.warning(msg) - self.system.error_tracker(msg) - continue - loc = self.location.dict() - loc['category'] = tag - loc['name'] = name - self.required_module_locations.append(Location(loc)) - log.debug('ConditionalDescriptor required_module_locations=%s' % self.required_module_locations) + @staticmethod + def parse_sources(xml_element, system, return_descriptor=False): + """Parse xml_element 'sources' attr and: + if return_descriptor=True - return list of descriptors + if return_descriptor=False - return list of lcoations + """ + result = [] + sources = xml_element.get('sources') + if sources: + locations = [location.strip() for location in sources.split(';')] + for location in locations: + # Check valid location url. + if Location.is_valid(location): + try: + descriptor = system.load_item(location) + if return_descriptor: + result.append(descriptor) + else: + result.append(location) + except ItemNotFoundError: + log.exception("Invalid module by location.") + return result def get_required_module_descriptors(self): - """Returns a list of XModuleDescritpor instances upon which this module depends, but are - not children of this module""" - return [self.system.load_item(loc) for loc in self.required_module_locations] + """Returns a list of XModuleDescritpor instances upon + which this module depends. + """ + return ConditionalDescriptor.parse_sources( + self.xml_attributes, self.system, True) + + @classmethod + def definition_from_xml(cls, xml_object, system): + children = [] + show_tag_list = [] + for child in xml_object: + if child.tag == 'show': + location = ConditionalDescriptor.parse_sources( + child, system) + children.extend(location) + show_tag_list.extend(location) + else: + try: + descriptor = system.process_xml(etree.tostring(child)) + module_url = descriptor.location.url() + children.append(module_url) + except: + log.exception("Unable to load child when parsing Conditional.") + return {'show_tag_list': show_tag_list}, children + + def definition_to_xml(self, resource_fs): + xml_object = etree.Element(self._tag_name) + for child in self.get_children(): + location = str(child.location) + if location in self.show_tag_list: + show_str = '<{tag_name} sources="{sources}" />'.format( + tag_name='show', sources=location) + xml_object.append(etree.fromstring(show_str)) + else: + xml_object.append( + etree.fromstring(child.export_to_xml(resource_fs))) + return xml_object diff --git a/common/lib/xmodule/xmodule/css/poll/display.scss b/common/lib/xmodule/xmodule/css/poll/display.scss new file mode 100644 index 0000000000..53192823be --- /dev/null +++ b/common/lib/xmodule/xmodule/css/poll/display.scss @@ -0,0 +1,226 @@ +section.poll_question { + @media print { + display: block; + width: auto; + padding: 0; + + canvas, img { + page-break-inside: avoid; + } + } + + .inline { + display: inline; + } + + h3 { + margin-top: 0; + margin-bottom: 15px; + color: #fe57a1; + font-size: 1.9em; + + &.problem-header { + section.staff { + margin-top: 30px; + font-size: 80%; + } + } + + @media print { + display: block; + width: auto; + border-right: 0; + } + } + + p { + text-align: justify; + font-weight: bold; + } + + .poll_answer { + margin-bottom: 20px; + + &.short { + clear: both; + } + + .question { + height: auto; + clear: both; + min-height: 30px; + + &.short { + clear: none; + width: 30%; + display: inline; + float: left; + } + + .button { + -webkit-appearance: none; + -webkit-background-clip: padding-box; + -webkit-border-image: none; + -webkit-box-align: center; + -webkit-box-shadow: rgb(255, 255, 255) 0px 1px 0px 0px inset; + -webkit-font-smoothing: antialiased; + -webkit-rtl-ordering: logical; + -webkit-user-select: text; + -webkit-writing-mode: horizontal-tb; + background-clip: padding-box; + background-color: rgb(238, 238, 238); + background-image: -webkit-linear-gradient(top, rgb(238, 238, 238), rgb(210, 210, 210)); + border-bottom-color: rgb(202, 202, 202); + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; + border-bottom-style: solid; + border-bottom-width: 1px; + border-left-color: rgb(202, 202, 202); + border-left-style: solid; + border-left-width: 1px; + border-right-color: rgb(202, 202, 202); + border-right-style: solid; + border-right-width: 1px; + border-top-color: rgb(202, 202, 202); + border-top-left-radius: 3px; + border-top-right-radius: 3px; + border-top-style: solid; + border-top-width: 1px; + box-shadow: rgb(255, 255, 255) 0px 1px 0px 0px inset; + box-sizing: border-box; + color: rgb(51, 51, 51); + cursor: pointer; + + /* display: inline-block; */ + display: inline; + float: left; + + font-family: 'Open Sans', Verdana, Geneva, sans-serif; + font-size: 13px; + font-style: normal; + font-variant: normal; + font-weight: bold; + + letter-spacing: normal; + line-height: 25.59375px; + margin-bottom: 15px; + margin: 0px; + padding: 0px; + text-align: center; + text-decoration: none; + text-indent: 0px; + text-shadow: rgb(248, 248, 248) 0px 1px 0px; + text-transform: none; + vertical-align: top; + white-space: pre-line; + + width: 25px; + height: 25px; + + word-spacing: 0px; + writing-mode: lr-tb; + } + .button.answered { + -webkit-box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset; + background-color: rgb(29, 157, 217); + background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176)); + border-bottom-color: rgb(13, 114, 162); + border-left-color: rgb(13, 114, 162); + border-right-color: rgb(13, 114, 162); + border-top-color: rgb(13, 114, 162); + box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset; + color: rgb(255, 255, 255); + text-shadow: rgb(7, 103, 148) 0px 1px 0px; + } + + .text { + display: inline; + float: left; + width: 80%; + text-align: left; + min-height: 30px; + margin-left: 20px; + height: auto; + margin-bottom: 20px; + cursor: pointer; + + &.short { + width: 100px; + } + } + } + + .stats { + min-height: 40px; + margin-top: 20px; + clear: both; + + &.short { + margin-top: 0; + clear: none; + display: inline; + float: right; + width: 70%; + } + + .bar { + width: 75%; + height: 20px; + border: 1px solid black; + display: inline; + float: left; + margin-right: 10px; + + &.short { + width: 65%; + height: 20px; + margin-top: 3px; + } + + .percent { + background-color: gray; + width: 0px; + height: 20px; + + &.short { } + } + } + + .number { + width: 80px; + display: inline; + float: right; + height: 28px; + text-align: right; + + &.short { + width: 120px; + height: auto; + } + } + } + } + + .poll_answer.answered { + -webkit-box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset; + background-color: rgb(29, 157, 217); + background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176)); + border-bottom-color: rgb(13, 114, 162); + border-left-color: rgb(13, 114, 162); + border-right-color: rgb(13, 114, 162); + border-top-color: rgb(13, 114, 162); + box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset; + color: rgb(255, 255, 255); + text-shadow: rgb(7, 103, 148) 0px 1px 0px; + } + + .graph_answer { + display: none; + clear: both; + width: 400px; + height: 400px; + margin-top: 30px; + margin-left: auto; + margin-right: auto; + } +} diff --git a/common/lib/xmodule/xmodule/css/wrapper/display.scss b/common/lib/xmodule/xmodule/css/wrapper/display.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/lib/xmodule/xmodule/js/src/.gitignore b/common/lib/xmodule/xmodule/js/src/.gitignore index 03534687ca..139597f9cb 100644 --- a/common/lib/xmodule/xmodule/js/src/.gitignore +++ b/common/lib/xmodule/xmodule/js/src/.gitignore @@ -1,2 +1,2 @@ -*.js + diff --git a/common/lib/xmodule/xmodule/js/src/conditional/display.coffee b/common/lib/xmodule/xmodule/js/src/conditional/display.coffee index 33dcb29079..af59a3a465 100644 --- a/common/lib/xmodule/xmodule/js/src/conditional/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/conditional/display.coffee @@ -1,26 +1,45 @@ class @Conditional - constructor: (element) -> + constructor: (element, callerElId) -> @el = $(element).find('.conditional-wrapper') - @id = @el.data('problem-id') - @element_id = @el.attr('id') - @url = @el.data('url') - @render() - $: (selector) -> - $(selector, @el) + @callerElId = callerElId - updateProgress: (response) => - if response.progress_changed - @el.attr progress: response.progress_status - @el.trigger('progressChanged') - - render: (content) -> - if content - @el.html(content) - XModule.loadModules(@el) + if @el.data('passed') is true + return + else if @el.data('passed') is false + @passed = false else - $.postWithPrefix "#{@url}/conditional_get", (response) => - @el.html(response.html) - XModule.loadModules(@el) + @passed = null + if callerElId isnt undefined and @passed isnt null + dependencies = @el.data('depends') + if (typeof dependencies is 'string') and (dependencies.length > 0) and (dependencies.indexOf(callerElId) is -1) + return + + @url = @el.data('url') + @render(element) + + render: (element) -> + $.postWithPrefix "#{@url}/conditional_get", (response) => + if (((response.passed is true) && (@passed is false)) || (@passed is null)) + @el.data 'passed', response.passed + + @el.html '' + @el.append(i) for i in response.html + + parentEl = $(element).parent() + parentId = parentEl.attr 'id' + + if response.message is false + if parentId.indexOf('vert') is 0 + parentEl.hide() + else + $(element).hide() + else + if parentId.indexOf('vert') is 0 + parentEl.show() + else + $(element).show() + + XModule.loadModules @el diff --git a/common/lib/xmodule/xmodule/js/src/poll/display.coffee b/common/lib/xmodule/xmodule/js/src/poll/display.coffee deleted file mode 100644 index f72ac8d319..0000000000 --- a/common/lib/xmodule/xmodule/js/src/poll/display.coffee +++ /dev/null @@ -1,13 +0,0 @@ -class @PollModule - constructor: (element) -> - @el = element - @ajaxUrl = @$('.container').data('url') - @$('.upvote').on('click', () => $.postWithPrefix(@url('upvote'), @handleVote)) - @$('.downvote').on('click', () => $.postWithPrefix(@url('downvote'), @handleVote)) - - $: (selector) -> $(selector, @el) - - url: (target) -> "#{@ajaxUrl}/#{target}" - - handleVote: (response) => - @$('.container').replaceWith(response.results) \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/poll/logme.js b/common/lib/xmodule/xmodule/js/src/poll/logme.js new file mode 100644 index 0000000000..c045757044 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/poll/logme.js @@ -0,0 +1,54 @@ +// Wrapper for RequireJS. It will make the standard requirejs(), require(), and +// define() functions from Require JS available inside the anonymous function. +(function (requirejs, require, define) { + +define('logme', [], function () { + var debugMode; + + // debugMode can be one of the following: + // + // true - All messages passed to logme will be written to the internal + // browser console. + // false - Suppress all output to the internal browser console. + // + // Obviously, if anywhere there is a direct console.log() call, we can't do + // anything about it. That's why use logme() - it will allow to turn off + // the output of debug information with a single change to a variable. + debugMode = true; + + return logme; + + /* + * function: logme + * + * A helper function that provides logging facilities. We don't want + * to call console.log() directly, because sometimes it is not supported + * by the browser. Also when everything is routed through this function. + * the logging output can be easily turned off. + * + * logme() supports multiple parameters. Each parameter will be passed to + * console.log() function separately. + * + */ + function logme() { + var i; + + if ( + (typeof debugMode === 'undefined') || + (debugMode !== true) || + (typeof window.console === 'undefined') + ) { + return; + } + + for (i = 0; i < arguments.length; i++) { + window.console.log(arguments[i]); + } + } // End-of: function logme +}); + +// End of wrapper for RequireJS. As you can see, we are passing +// namespaced Require JS variables to an anonymous function. Within +// it, you can use the standard requirejs(), require(), and define() +// functions as if they were in the global namespace. +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) diff --git a/common/lib/xmodule/xmodule/js/src/poll/poll.js b/common/lib/xmodule/xmodule/js/src/poll/poll.js new file mode 100644 index 0000000000..a2ccbc7c03 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/poll/poll.js @@ -0,0 +1,5 @@ +window.Poll = function (el) { + RequireJS.require(['PollMain'], function (PollMain) { + new PollMain(el); + }); +}; diff --git a/common/lib/xmodule/xmodule/js/src/poll/poll_main.js b/common/lib/xmodule/xmodule/js/src/poll/poll_main.js new file mode 100644 index 0000000000..a5cec07095 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/poll/poll_main.js @@ -0,0 +1,255 @@ +(function (requirejs, require, define) { +define('PollMain', ['logme'], function (logme) { + +PollMain.prototype = { + +'showAnswerGraph': function (poll_answers, total) { + var _this, totalValue; + + totalValue = parseFloat(total); + if (isFinite(totalValue) === false) { + return; + } + + _this = this; + + $.each(poll_answers, function (index, value) { + var numValue, percentValue; + + numValue = parseFloat(value); + if (isFinite(numValue) === false) { + return; + } + + percentValue = (numValue / totalValue) * 100.0; + + _this.answersObj[index].statsEl.show(); + _this.answersObj[index].numberEl.html('' + value + ' (' + percentValue.toFixed(1) + '%)'); + _this.answersObj[index].percentEl.css({ + 'width': '' + percentValue.toFixed(1) + '%' + }); + }); +}, + +'submitAnswer': function (answer, answerObj) { + var _this; + + // Make sure that the user can answer a question only once. + if (this.questionAnswered === true) { + return; + } + this.questionAnswered = true; + + _this = this; + + answerObj.buttonEl.addClass('answered'); + + // Send the data to the server as an AJAX request. Attach a callback that will + // be fired on server's response. + $.postWithPrefix( + _this.ajax_url + '/' + answer, {}, + function (response) { + _this.showAnswerGraph(response.poll_answers, response.total); + + if (_this.wrapperSectionEl !== null) { + $(_this.wrapperSectionEl).find('.xmodule_ConditionalModule').each(function (index, value) { + new window.Conditional(value, _this.id.replace(/^poll_/, '')); + }); + } + } + ); +}, // End-of: 'submitAnswer': function (answer, answerEl) { + +'postInit': function () { + var _this; + + // Access this object inside inner functions. + _this = this; + + if ( + (this.jsonConfig.poll_answer.length > 0) && + (this.jsonConfig.answers.hasOwnProperty(this.jsonConfig.poll_answer) === false) + ) { + this.questionEl.append( + '

        Error!

        ' + + '

        XML data format changed. List of answers was modified, but poll data was not updated.

        ' + ); + + return; + } + + // Get the DOM id of the question. + this.id = this.questionEl.attr('id'); + + // Get the URL to which we will post the users answer to the question. + this.ajax_url = this.questionEl.data('ajax-url'); + + this.questionHtmlMarkup = $('
        ').html(this.jsonConfig.question).text(); + this.questionEl.append(this.questionHtmlMarkup); + + // When the user selects and answer, we will set this flag to true. + this.questionAnswered = false; + + this.answersObj = {}; + this.shortVersion = true; + + $.each(this.jsonConfig.answers, function (index, value) { + if (value.length >= 18) { + _this.shortVersion = false; + } + }); + + $.each(this.jsonConfig.answers, function (index, value) { + var answer; + + answer = {}; + + _this.answersObj[index] = answer; + + answer.el = $('
        '); + + answer.questionEl = $('
        '); + answer.buttonEl = $('
        '); + answer.textEl = $('
        '); + answer.questionEl.append(answer.buttonEl); + answer.questionEl.append(answer.textEl); + + answer.el.append(answer.questionEl); + + answer.statsEl = $('
        '); + answer.barEl = $('
        '); + answer.percentEl = $('
        '); + answer.barEl.append(answer.percentEl); + answer.numberEl = $('
        '); + answer.statsEl.append(answer.barEl); + answer.statsEl.append(answer.numberEl); + + answer.statsEl.hide(); + + answer.el.append(answer.statsEl); + + answer.textEl.html(value); + + if (_this.shortVersion === true) { + $.each(answer, function (index, value) { + if (value instanceof jQuery) { + value.addClass('short'); + } + }); + } + + answer.el.appendTo(_this.questionEl); + + answer.textEl.on('click', function () { + _this.submitAnswer(index, answer); + }); + + answer.buttonEl.on('click', function () { + _this.submitAnswer(index, answer); + }); + + if (index === _this.jsonConfig.poll_answer) { + answer.buttonEl.addClass('answered'); + _this.questionAnswered = true; + } + }); + + this.graphAnswerEl = $('
        '); + this.graphAnswerEl.hide(); + this.graphAnswerEl.appendTo(this.questionEl); + + // If it turns out that the user already answered the question, show the answers graph. + if (this.questionAnswered === true) { + this.showAnswerGraph(this.jsonConfig.poll_answers, this.jsonConfig.total); + } +} // End-of: 'postInit': function () { +}; // End-of: PollMain.prototype = { + +return PollMain; + +function PollMain(el) { + var _this; + + this.questionEl = $(el).find('.poll_question'); + if (this.questionEl.length !== 1) { + // We require one question DOM element. + logme('ERROR: PollMain constructor requires one question DOM element.'); + + return; + } + + // Just a safety precussion. If we run this code more than once, multiple 'click' callback handlers will be + // attached to the same DOM elements. We don't want this to happen. + if (this.questionEl.attr('poll_main_processed') === 'true') { + logme( + 'ERROR: PolMain JS constructor was called on a DOM element that has already been processed once.' + ); + + return; + } + + // This element was not processed earlier. + // Make sure that next time we will not process this element a second time. + this.questionEl.attr('poll_main_processed', 'true'); + + // Access this object inside inner functions. + _this = this; + + // DOM element which contains the current poll along with any conditionals. By default we assume that such + // element is not present. We will try to find it. + this.wrapperSectionEl = null; + + (function (tempEl, c1) { + while (tempEl.tagName.toLowerCase() !== 'body') { + tempEl = $(tempEl).parent()[0]; + c1 += 1; + + if ( + (tempEl.tagName.toLowerCase() === 'section') && + ($(tempEl).hasClass('xmodule_WrapperModule') === true) + ) { + _this.wrapperSectionEl = tempEl; + + break; + } else if (c1 > 50) { + // In case something breaks, and we enter an endless loop, a sane + // limit for loop iterations. + + break; + } + } + }($(el)[0], 0)); + + try { + this.jsonConfig = JSON.parse(this.questionEl.children('.poll_question_div').html()); + + $.postWithPrefix( + '' + this.questionEl.data('ajax-url') + '/' + 'get_state', {}, + function (response) { + _this.jsonConfig.poll_answer = response.poll_answer; + _this.jsonConfig.total = response.total; + + $.each(response.poll_answers, function (index, value) { + _this.jsonConfig.poll_answers[index] = value; + }); + + _this.questionEl.children('.poll_question_div').html(JSON.stringify(_this.jsonConfig)); + _this.postInit(); + } + ); + + return; + } catch (err) { + logme( + 'ERROR: Invalid JSON config for poll ID "' + this.id + '".', + 'Error messsage: "' + err.message + '".' + ); + + return; + } +} // End-of: function PollMain(el) { + +}); // End-of: define('PollMain', ['logme'], function (logme) { + +// End-of: (function (requirejs, require, define) { +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); diff --git a/common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee b/common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee new file mode 100644 index 0000000000..a13c5a8bc7 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee @@ -0,0 +1,10 @@ +class @WrapperDescriptor extends XModule.Descriptor + constructor: (@element) -> + console.log 'WrapperDescriptor' + @$items = $(@element).find(".vert-mod") + @$items.sortable( + update: (event, ui) => @update() + ) + + save: -> + children: $('.vert-mod li', @element).map((idx, el) -> $(el).data('id')).toArray() diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 5675ce1037..aec1706aad 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -74,7 +74,8 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): # VS[compat]. Take this out once course conversion is done (perhaps leave the uniqueness check) # tags that really need unique names--they store (or should store) state. - need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', 'videosequence', 'timelimit') + need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', + 'videosequence', 'poll_question', 'timelimit') attr = xml_data.attrib tag = xml_data.tag diff --git a/common/lib/xmodule/xmodule/poll_module.py b/common/lib/xmodule/xmodule/poll_module.py index eb5bef9e6d..a79781ef86 100644 --- a/common/lib/xmodule/xmodule/poll_module.py +++ b/common/lib/xmodule/xmodule/poll_module.py @@ -1,54 +1,196 @@ +"""Poll module is ungraded xmodule used by students to +to do set of polls. + +On the client side we show: +If student does not yet anwered - Question with set of choices. +If student have answered - Question with statistics for each answers. + +Student can't change his answer. +""" + +import cgi import json import logging +from copy import deepcopy +from collections import OrderedDict from lxml import etree -from pkg_resources import resource_string, resource_listdir +from pkg_resources import resource_string from xmodule.x_module import XModule -from xmodule.raw_module import RawDescriptor -from xblock.core import Integer, Scope, Boolean +from xmodule.stringify import stringify_children +from xmodule.mako_module import MakoModuleDescriptor +from xmodule.xml_module import XmlDescriptor +from xblock.core import Scope, String, Object, Boolean, List log = logging.getLogger(__name__) class PollModule(XModule): + """Poll Module""" + js = { + 'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee')], + 'js': [resource_string(__name__, 'js/src/poll/logme.js'), + resource_string(__name__, 'js/src/poll/poll.js'), + resource_string(__name__, 'js/src/poll/poll_main.js')] + } + css = {'scss': [resource_string(__name__, 'css/poll/display.scss')]} + js_module_name = "Poll" - js = {'coffee': [resource_string(__name__, 'js/src/poll/display.coffee')]} - js_module_name = "PollModule" + # Name of poll to use in links to this poll + display_name = String(help="Display name for this module", scope=Scope.settings) - upvotes = Integer(help="Number of upvotes this poll has recieved", scope=Scope.content, default=0) - downvotes = Integer(help="Number of downvotes this poll has recieved", scope=Scope.content, default=0) voted = Boolean(help="Whether this student has voted on the poll", scope=Scope.student_state, default=False) + poll_answer = String(help="Student answer", scope=Scope.student_state, default='') + poll_answers = Object(help="All possible answers for the poll fro other students", scope=Scope.content) + + answers = List(help="Poll answers from xml", scope=Scope.content, default=[]) + question = String(help="Poll question", scope=Scope.content, default='') def handle_ajax(self, dispatch, get): - ''' - Handle ajax calls to this video. - TODO (vshnayder): This is not being called right now, so the position - is not being saved. - ''' - if self.voted: - return json.dumps({'error': 'Already Voted!'}) - elif dispatch == 'upvote': - self.upvotes += 1 - self.voted = True - return json.dumps({'results': self.get_html()}) - elif dispatch == 'downvote': - self.downvotes += 1 - self.voted = True - return json.dumps({'results': self.get_html()}) + """Ajax handler. - return json.dumps({'error': 'Unknown Command!'}) + Args: + dispatch: string request slug + get: dict request get parameters + + Returns: + json string + """ + if dispatch in self.poll_answers and not self.voted: + # FIXME: fix this, when xblock will support mutable types. + # Now we use this hack. + temp_poll_answers = self.poll_answers + temp_poll_answers[dispatch] += 1 + self.poll_answers = temp_poll_answers + + self.voted = True + self.poll_answer = dispatch + return json.dumps({'poll_answers': self.poll_answers, + 'total': sum(self.poll_answers.values()), + 'callback': {'objectName': 'Conditional'} + }) + elif dispatch == 'get_state': + return json.dumps({'poll_answer': self.poll_answer, + 'poll_answers': self.poll_answers, + 'total': sum(self.poll_answers.values()) + }) + else: # return error message + return json.dumps({'error': 'Unknown Command!'}) def get_html(self): - return self.system.render_template('poll.html', { - 'upvotes': self.upvotes, - 'downvotes': self.downvotes, - 'voted': self.voted, - 'ajax_url': self.system.ajax_url, - }) + """Renders parameters to template.""" + params = { + 'element_id': self.location.html_id(), + 'element_class': self.location.category, + 'ajax_url': self.system.ajax_url, + 'configuration_json': self.dump_poll(), + } + self.content = self.system.render_template('poll.html', params) + return self.content + + def dump_poll(self): + """Dump poll information. + + Returns: + string - Serialize json. + """ + # FIXME: hack for resolving caching `default={}` during definition + # poll_answers field + if self.poll_answers is None: + self.poll_answers = {} + + answers_to_json = OrderedDict() + + # FIXME: fix this, when xblock support mutable types. + # Now we use this hack. + temp_poll_answers = self.poll_answers + + # Fill self.poll_answers, prepare data for template context. + for answer in self.answers: + # Set default count for answer = 0. + if answer['id'] not in temp_poll_answers: + temp_poll_answers[answer['id']] = 0 + answers_to_json[answer['id']] = cgi.escape(answer['text']) + self.poll_answers = temp_poll_answers + + return json.dumps({'answers': answers_to_json, + 'question': cgi.escape(self.question), + # to show answered poll after reload: + 'poll_answer': self.poll_answer, + 'poll_answers': self.poll_answers if self.voted else {}, + 'total': sum(self.poll_answers.values()) if self.voted else 0}) -class PollDescriptor(RawDescriptor): +class PollDescriptor(MakoModuleDescriptor, XmlDescriptor): + _tag_name = 'poll_question' + _child_tag_name = 'answer' + module_class = PollModule + template_dir_name = 'poll' stores_state = True - template_dir_name = "poll" \ No newline at end of file + + answers = List(help="Poll answers", scope=Scope.content, default=[]) + question = String(help="Poll question", scope=Scope.content, default='') + display_name = String(help="Display name for this module", scope=Scope.settings) + id = String(help="ID attribute for this module", scope=Scope.settings) + + @classmethod + def definition_from_xml(cls, xml_object, system): + """Pull out the data into dictionary. + + Args: + xml_object: xml from file. + system: `system` object. + + Returns: + (definition, children) - tuple + definition - dict: + { + 'answers': , + 'question': + } + """ + # Check for presense of required tags in xml. + if len(xml_object.xpath(cls._child_tag_name)) == 0: + raise ValueError("Poll_question definition must include \ + at least one 'answer' tag") + + xml_object_copy = deepcopy(xml_object) + answers = [] + for element_answer in xml_object_copy.findall(cls._child_tag_name): + answer_id = element_answer.get('id', None) + if answer_id: + answers.append({ + 'id': answer_id, + 'text': stringify_children(element_answer) + }) + xml_object_copy.remove(element_answer) + + definition = { + 'answers': answers, + 'question': stringify_children(xml_object_copy) + } + children = [] + + return (definition, children) + + def definition_to_xml(self, resource_fs): + """Return an xml element representing to this definition.""" + poll_str = '<{tag_name}>{text}'.format( + tag_name=self._tag_name, text=self.question) + xml_object = etree.fromstring(poll_str) + xml_object.set('display_name', self.display_name) + xml_object.set('id', self.id) + + def add_child(xml_obj, answer): + child_str = '<{tag_name} id="{id}">{text}'.format( + tag_name=self._child_tag_name, id=answer['id'], + text=answer['text']) + child_node = etree.fromstring(child_str) + xml_object.append(child_node) + + for answer in self.answers: + add_child(xml_object, answer) + + return xml_object diff --git a/common/lib/xmodule/xmodule/stringify.py b/common/lib/xmodule/xmodule/stringify.py index 5a640e91b1..35587d3b09 100644 --- a/common/lib/xmodule/xmodule/stringify.py +++ b/common/lib/xmodule/xmodule/stringify.py @@ -1,4 +1,5 @@ -from itertools import chain +# -*- coding: utf-8 -*- + from lxml import etree diff --git a/common/lib/xmodule/xmodule/tests/test_export.py b/common/lib/xmodule/xmodule/tests/test_export.py index 815550f5ff..5244856889 100644 --- a/common/lib/xmodule/xmodule/tests/test_export.py +++ b/common/lib/xmodule/xmodule/tests/test_export.py @@ -28,7 +28,6 @@ def strip_filenames(descriptor): strip_filenames(d) - class RoundTripTestCase(unittest.TestCase): ''' Check that our test courses roundtrip properly. Same course imported , than exported, then imported again. @@ -91,7 +90,6 @@ class RoundTripTestCase(unittest.TestCase): self.assertEquals(initial_import.modules[course_id][location], second_import.modules[course_id][location]) - def setUp(self): self.maxDiff = None @@ -104,6 +102,9 @@ class RoundTripTestCase(unittest.TestCase): def test_full_roundtrip(self): self.check_export_roundtrip(DATA_DIR, "full") + def test_conditional_and_poll_roundtrip(self): + self.check_export_roundtrip(DATA_DIR, "conditional_and_poll") + def test_selfassessment_roundtrip(self): #Test selfassessment xmodule to see if it exports correctly self.check_export_roundtrip(DATA_DIR, "self_assessment") diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index c4547241e0..8f32f9b957 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + from path import path import unittest from fs.memoryfs import MemoryFS @@ -76,7 +78,6 @@ class ImportTestCase(BaseCourseTestCase): self.assertEqual(descriptor.__class__.__name__, 'ErrorDescriptor') - def test_unique_url_names(self): '''Check that each error gets its very own url_name''' bad_xml = '''''' @@ -88,7 +89,6 @@ class ImportTestCase(BaseCourseTestCase): self.assertNotEqual(descriptor1.location, descriptor2.location) - def test_reimport(self): '''Make sure an already-exported error xml tag loads properly''' @@ -230,7 +230,6 @@ class ImportTestCase(BaseCourseTestCase): check_for_key('graceperiod', course) - def test_policy_loading(self): """Make sure that when two courses share content with the same org and course names, policy applies to the right one.""" @@ -254,7 +253,6 @@ class ImportTestCase(BaseCourseTestCase): # appropriate attribute maps -- 'graded' should be True, not 'true' self.assertEqual(toy.lms.graded, True) - def test_definition_loading(self): """When two courses share the same org and course name and both have a module with the same url_name, the definitions shouldn't clash. @@ -274,7 +272,6 @@ class ImportTestCase(BaseCourseTestCase): self.assertEqual(etree.fromstring(toy_video.data).get('youtube'), "1.0:p2Q6BrNhdh8") self.assertEqual(etree.fromstring(two_toy_video.data).get('youtube'), "1.0:p2Q6BrNhdh9") - def test_colon_in_url_name(self): """Ensure that colons in url_names convert to file paths properly""" @@ -331,6 +328,22 @@ class ImportTestCase(BaseCourseTestCase): self.assertEqual(len(video.url_name), len('video_') + 12) + def test_poll_xmodule(self): + modulestore = XMLModuleStore(DATA_DIR, course_dirs=['conditional_and_poll']) + + course = modulestore.get_courses()[0] + chapters = course.get_children() + ch1 = chapters[0] + sections = ch1.get_children() + + self.assertEqual(len(sections), 1) + + location = course.location + location = Location(location.tag, location.org, location.course, + 'sequential', 'Problem_Demos') + module = modulestore.get_instance(course.id, location) + self.assertEqual(len(module.children), 2) + def test_error_on_import(self): '''Check that when load_error_module is false, an exception is raised, rather than returning an ErrorModule''' diff --git a/common/lib/xmodule/xmodule/tests/test_logic.py b/common/lib/xmodule/xmodule/tests/test_logic.py new file mode 100644 index 0000000000..6b9a636590 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_logic.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- + +import json +import unittest + +from xmodule.poll_module import PollModule +from xmodule.conditional_module import ConditionalModule + + +class LogicTest(unittest.TestCase): + """Base class for testing xmodule logic.""" + xmodule_class = None + raw_model_data = {} + + def setUp(self): + self.system = None + self.location = None + self.descriptor = None + + self.xmodule = self.xmodule_class(self.system, self.location, + self.descriptor, self.raw_model_data) + + def ajax_request(self, dispatch, get): + return json.loads(self.xmodule.handle_ajax(dispatch, get)) + + +class PollModuleTest(LogicTest): + xmodule_class = PollModule + raw_model_data = { + 'poll_answers': {'Yes': 1, 'Dont_know': 0, 'No': 0}, + 'voted': False, + 'poll_answer': '' + } + + def test_bad_ajax_request(self): + response = self.ajax_request('bad_answer', {}) + self.assertDictEqual(response, {'error': 'Unknown Command!'}) + + def test_good_ajax_request(self): + response = self.ajax_request('No', {}) + + poll_answers = response['poll_answers'] + total = response['total'] + callback = response['callback'] + + self.assertDictEqual(poll_answers, {'Yes': 1, 'Dont_know': 0, 'No': 1}) + self.assertEqual(total, 2) + self.assertDictEqual(callback, {'objectName': 'Conditional'}) + self.assertEqual(self.xmodule.poll_answer, 'No') + + +class ConditionalModuleTest(LogicTest): + xmodule_class = ConditionalModule + raw_model_data = { + 'contents': 'Some content' + } + + def test_ajax_request(self): + # Mock is_condition_satisfied + self.xmodule.is_condition_satisfied = lambda: True + + response = self.ajax_request('No', {}) + html = response['html'] + + self.assertEqual(html, 'Some content') diff --git a/common/lib/xmodule/xmodule/wrapper_module.py b/common/lib/xmodule/xmodule/wrapper_module.py new file mode 100644 index 0000000000..d675f102bf --- /dev/null +++ b/common/lib/xmodule/xmodule/wrapper_module.py @@ -0,0 +1,26 @@ +# Same as vertical, +# But w/o css delimiters between children + +from xmodule.vertical_module import VerticalModule, VerticalDescriptor +from pkg_resources import resource_string + +# HACK: This shouldn't be hard-coded to two types +# OBSOLETE: This obsoletes 'type' +class_priority = ['video', 'problem'] + + +class WrapperModule(VerticalModule): + ''' Layout module for laying out submodules vertically w/o css delimiters''' + + has_children = True + css = {'scss': [resource_string(__name__, 'css/wrapper/display.scss')]} + + +class WrapperDescriptor(VerticalDescriptor): + module_class = WrapperModule + + has_children = True + + js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]} + js_module_name = "VerticalDescriptor" + diff --git a/common/test/data/conditional_and_poll/README b/common/test/data/conditional_and_poll/README new file mode 100644 index 0000000000..fc95a7c0c9 --- /dev/null +++ b/common/test/data/conditional_and_poll/README @@ -0,0 +1,50 @@ +Any place that says "YEAR_SEMESTER" needs to be replaced with something +in the form "2013_Spring". Take note of this name exactly, you'll need to +use it everywhere, precisely - capitalization is very important. + +See https://github.com/MITx/mitx/blob/master/doc/xml-format.md for more on all this. +----------------------- + +about/: Files that live here will be visible OUTSIDE OF COURSEWARE. + YEAR_SEMESTER/ + end_date.html: Specifies in plain-text the end date of the course + overview.html: Text of the overview of the course + short_description.html: 10-15 words about the course + prerequisites.html: Any prerequisites for the course, or None if there are none. + +course/ + YEAR_SEMESTER.xml: This is your top-level xml page that points at chapters. + Can just be for now. + +course.xml: This top level file points at a file in roots/. See creating_course.xml. + +creating_course.xml: Explains how to create course.xml + +info/: Files that live here will be visible on the COURSE LANDING PAGE + (Course Info) WITHIN THE COURSEWARE. + YEAR_SEMESTER/ + handouts.html: A list of handouts, or an empty file if there are none + (if this file doesn't exist, it displays an error) + updates.html: Course updates. + +policies/ + YEAR_SEMESTER/ + policy.json: See https://github.com/MITx/mitx/blob/master/doc/xml-format.md + for more on the fields specified by this file. + grading_policy.json: Optional -- you don't need it to get a course off the + ground but will eventually. For more info see + https://github.com/MITx/mitx/blob/master/doc/course_grading.md + +roots/ + YEAR_SEMESTER.xml: Looks something like + + where ORG in {"MITx", "HarvardX", "BerkeleyX"} + +static/ + See README. + + images/ + course_image.jpg: You MUST have an image named this to be the background + banner image on edx.org + +----------------------- \ No newline at end of file diff --git a/common/test/data/conditional_and_poll/README.md b/common/test/data/conditional_and_poll/README.md new file mode 100644 index 0000000000..7dbfa46a26 --- /dev/null +++ b/common/test/data/conditional_and_poll/README.md @@ -0,0 +1,2 @@ +content-harvard-justicex +======================== \ No newline at end of file diff --git a/common/test/data/conditional_and_poll/about/2013_Spring/overview.html b/common/test/data/conditional_and_poll/about/2013_Spring/overview.html new file mode 100644 index 0000000000..9c49899948 --- /dev/null +++ b/common/test/data/conditional_and_poll/about/2013_Spring/overview.html @@ -0,0 +1,79 @@ + + +
        +

        About ER22x

        + +

        Justice is a critical analysis of classical and contemporary theories of justice, including discussion of present-day applications. Topics include affirmative action, income distribution, same-sex marriage, the role of markets, debates about rights (human rights and property rights), arguments for and against equality, dilemmas of loyalty in public and private life. The course invites students to subject their own views on these controversies to critical examination.

        + +

        The principle readings for the course are texts by Aristotle, John Locke, Immanuel Kant, John Stuart Mill, and John Rawls. Other assigned readings include writings by contemporary philosophers, court cases, and articles about political controversies that raise philosophical questions.

        + + + +
        + + +
        +

        Course instructor

        +
        + + +
        +

        Michael J. Sandel

        +

        Michael J. Sandel is the Anne T. and Robert M. Bass Professor of Government at Harvard University, where he teaches political philosophy.  His course "Justice" has enrolled more than 15,000 Harvard students.  Sandel's writings have been published in 21 languages.  His books include What Money Can't Buy: The Moral Limits of Markets (2012); Justice: What's the Right Thing to Do? (2009); The Case against Perfection: Ethics in the Age of Genetic Engineering (2007); Public Philosophy: Essays on Morality in Politics (2005); Democracy's Discontent (1996); and Liberalism and the Limits of Justice(1982; 2nd ed., 1998). 

        +


        +
        + + +
        +
        +

        Frequently Asked Questions

        +
        +

        How much does it cost to take the course?

        +

        Nothing! The course is free.

        +
        + +
        +

        Does the course have any prerequisites?

        +

        No. Only an interest in thinking through some of the big ethical and civic questions we face in our everyday lives.

        +
        + +
        +

        Do I need any other materials to take the course?

        +

        No. As long as you’ve got a computer to access the website, you are ready to take the course.

        +
        + +
        +

        Is there a textbook for the course?

        +

        All of the course readings that are in the public domain are freely available online, at links provided on the course website. The course can be taken using these free resources alone. For those who wish to purchase a printed version of the assigned readings, an edited volume entitled, Justice: A Reader (ed., Michael Sandel) is available in paperback from Oxford University Press (in bookstores and from online booksellers). Those who would like supplementary readings on the themes of the lectures can find them in Michael Sandel's book Justice: What's the Right Thing to Do?, which is available in various languages throughout the world. This book is not required, and the course can be taken using the free online resources alone.

        +
        + +
        +

        Do I need to watch the lectures at a specific time?

        +

        No. You can watch the lectures at your leisure.

        +
        + +
        +

        Will I be able to participate in class discussions?

        +

        Yes, in several ways:

        + +
          +
        1. Each lecture invites you to respond to a poll question related to the themes of the lecture. If you respond to the question, you will be presented with a challenge to the opinion you have expressed, and invited to reply to the challenge. You can also, if you wish, comment on the opinions and responses posted by other students in the course, continuing the discussion.

        2. + +
        3. In addition to the poll question, each class contains a discussion prompt that invites you to offer your view on a controversial question related to the lecture. If you wish, you can respond to this question, and then see what other students have to say about the argument you present. You can also comment on the opinions posted by other students. One aim of the course is to promote reasoned public dialogue about hard moral and political questions.

        4. + +
        5. Each week, there will be an optional live dialogue enabling students to interact with instructors and participants from around the world.

        6. +
        +
        + +
        +

        Will certificates be awarded?

        +

        Yes. Online learners who achieve a passing grade in a course can earn a certificate of mastery. These certificates will indicate you have successfully completed the course, but will not include a specific grade. Certificates will be issued by edX under the name of HarvardX, designating the institution from which the course originated.

        +
        + +
        +
        + + \ No newline at end of file diff --git a/common/test/data/conditional_and_poll/about/2013_Spring/prerequisites.html b/common/test/data/conditional_and_poll/about/2013_Spring/prerequisites.html new file mode 100644 index 0000000000..b0047fa49f --- /dev/null +++ b/common/test/data/conditional_and_poll/about/2013_Spring/prerequisites.html @@ -0,0 +1 @@ +None diff --git a/common/test/data/conditional_and_poll/about/2013_Spring/short_description.html b/common/test/data/conditional_and_poll/about/2013_Spring/short_description.html new file mode 100644 index 0000000000..208880c842 --- /dev/null +++ b/common/test/data/conditional_and_poll/about/2013_Spring/short_description.html @@ -0,0 +1 @@ +JusticeX is an introduction to moral and political philosophy, including discussion of contemporary dilemmas and controversies. \ No newline at end of file diff --git a/common/test/data/conditional_and_poll/about/2013_Spring/video.html b/common/test/data/conditional_and_poll/about/2013_Spring/video.html new file mode 100644 index 0000000000..0cf427b16c --- /dev/null +++ b/common/test/data/conditional_and_poll/about/2013_Spring/video.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/common/test/data/conditional_and_poll/chapter/Staff.xml b/common/test/data/conditional_and_poll/chapter/Staff.xml new file mode 100644 index 0000000000..e1d5216f6d --- /dev/null +++ b/common/test/data/conditional_and_poll/chapter/Staff.xml @@ -0,0 +1,3 @@ + + + diff --git a/common/test/data/conditional_and_poll/course.xml b/common/test/data/conditional_and_poll/course.xml new file mode 120000 index 0000000000..f4f5c17b87 --- /dev/null +++ b/common/test/data/conditional_and_poll/course.xml @@ -0,0 +1 @@ +roots/2013_Spring.xml \ No newline at end of file diff --git a/common/test/data/conditional_and_poll/course/2013_Spring.xml b/common/test/data/conditional_and_poll/course/2013_Spring.xml new file mode 100644 index 0000000000..cb6e7c1217 --- /dev/null +++ b/common/test/data/conditional_and_poll/course/2013_Spring.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/common/test/data/conditional_and_poll/creating_course.xml b/common/test/data/conditional_and_poll/creating_course.xml new file mode 100644 index 0000000000..4c90f1c2ec --- /dev/null +++ b/common/test/data/conditional_and_poll/creating_course.xml @@ -0,0 +1,8 @@ + diff --git a/common/test/data/conditional_and_poll/info/2013_Spring/handouts.html b/common/test/data/conditional_and_poll/info/2013_Spring/handouts.html new file mode 100644 index 0000000000..35f2c89474 --- /dev/null +++ b/common/test/data/conditional_and_poll/info/2013_Spring/handouts.html @@ -0,0 +1,3 @@ +
          +
        1. A list of course handouts, or an empty file if there are none.
        2. +
        diff --git a/common/test/data/conditional_and_poll/info/2013_Spring/updates.html b/common/test/data/conditional_and_poll/info/2013_Spring/updates.html new file mode 100644 index 0000000000..9744c1699d --- /dev/null +++ b/common/test/data/conditional_and_poll/info/2013_Spring/updates.html @@ -0,0 +1,10 @@ + +
          + +
        1. December 9

          +
          +

          Announcement text

          +
          +
        2. + +
        diff --git a/common/test/data/conditional_and_poll/policies/2013_Spring/policy.json b/common/test/data/conditional_and_poll/policies/2013_Spring/policy.json new file mode 100644 index 0000000000..e2a204815c --- /dev/null +++ b/common/test/data/conditional_and_poll/policies/2013_Spring/policy.json @@ -0,0 +1,8 @@ +{ + "course/2013_Spring": { + "start": "2099-01-01T00:00", + "advertised_start" : "Spring 2013", + "display_name": "Justice" + } + +} diff --git a/common/test/data/conditional_and_poll/roots/2013_Spring.xml b/common/test/data/conditional_and_poll/roots/2013_Spring.xml new file mode 100644 index 0000000000..1b97a5a714 --- /dev/null +++ b/common/test/data/conditional_and_poll/roots/2013_Spring.xml @@ -0,0 +1,2 @@ + + diff --git a/common/test/data/conditional_and_poll/sequential/Problem_Demos.xml b/common/test/data/conditional_and_poll/sequential/Problem_Demos.xml new file mode 100644 index 0000000000..e10298336d --- /dev/null +++ b/common/test/data/conditional_and_poll/sequential/Problem_Demos.xml @@ -0,0 +1,31 @@ + + + +

        What's the Right Thing to Do?

        +

        Suppose four shipwrecked sailors are stranded at sea in a lifeboat, without + food or water. Would it be wrong for three of them to kill and eat the cabin + boy, in order to save their own lives?

        + Yes + No + Don't know +
        + +

        What's the Right Thing to Do?

        +

        Suppose four shipwrecked sailors are stranded at sea in a lifeboat, without + food or water. Would it be wrong for three of them to kill and eat the cabin + boy, in order to save their own lives?

        + Yes + No + Don't know +
        +
        + + + + Condition: first_poll - Yes + + In first condition. + + + +
        diff --git a/common/test/data/conditional_and_poll/static/README b/common/test/data/conditional_and_poll/static/README new file mode 100644 index 0000000000..e22f378b5e --- /dev/null +++ b/common/test/data/conditional_and_poll/static/README @@ -0,0 +1,5 @@ +Images, handouts, and other statically-served content should go ONLY +in this directory. + +Images for the front page should go in static/images. The frontpage +banner MUST be named course_image.jpg \ No newline at end of file diff --git a/common/test/data/conditional_and_poll/static/images/course_image.jpg b/common/test/data/conditional_and_poll/static/images/course_image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b6a64b939667fd6ddc44875ce72dab91818581b7 GIT binary patch literal 12626 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
        !+Z2rxWtJ%=&oe_bM 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%0AMpS#wmq-^ciH>iXv=5&z>XA7cC8 zbCGBpSeF1Q7Sh%}Uem;sXjKNMCKxVSvcdXv8`k7y% z1GyVEZ^a?oKT1pd5QL$~Q($`u=^r%cQGCZquh9$F_XJ$l7=rn1je zVY%D$y^muhA9r4Uuf zf2&`LFHM9q_|v4@B~|n{Ik+z8-xM* z#nBqHQmu-Ib3r3yC-J&QRtSe;G$->5tAanyw2`3n^*o$$MivDbuRTT=M4nIqunMn? zGxa%+-4jXMM(>CL!dzo?>2!!g3qSYXED;cI`^PyDi=<|^7J!C^met?9fkA+fI_(m@ zj6Tr@f2AorAG@W3kq!Mu8r6r7Wo9gN<(?|zJ4{CflL{_@A(7&*i633hv&5}yGM+(k zr@sVC??GBsDpIIUJ(|g64C9`MoZ`!zJR;L$`O!$Tl?l247?znSsZ!8wo~^&u?QI^) z(UQf{S3HR*Z`|r&^}b!=&AkOW84C)X411H}kG#y?INJ`cz9qW9zjiIm_x2A|G+dhR z2_;MG3AUY^$a53I`EQ-`O@?)k>OVV{xZa%#(?~4Bgg+w|Kf`;gs?DyiiQx7fr=0EOfoP@;7cTq#w1V^9wr*oQ6L5

        G(aufaK??)uH???BXq%V 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%0Doy_Y9?~SF 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�H#T9V-D9DzyJ(#%bfA})#;R^UncUy?+v*ro^$$CFB;8nD>%;}f=|;K z82b0GJ*%^1R5sg0o@9tXQUT{6W0BL7URUu;N@&P+51v$EJAe zpUbs*-^B|fJZE8b4BK~}jB*F{Kb2i6S=inY=10ii8El0A0BKML#*b;h$WzEaUNK)Q z{7Q&gUpp(661$)qGlFo$4^CV3udu!`*&I8nEMy^d$iO2R$^CyS`9tFcp6QIPcWwFl zU<1J2_~SL>S50D5Fyv8}H&e_tU`4@DLa{k~XO1~Pja4zSOKi`ypDcr(TRGro=~LkQ9x<-Ma(~jw)L;mquK- ziaAzSxZJADRdU4rI(|K>z=`5OZyU^$B$j6RKmg+z=RDPDiH_i<+T^Lj4Dr;FgWss> zRwcDYmD4LDDZ=FNr;L%Fqmf1Vh>~gCxf%y(7$B9~48XAjag3i{^nt`$m}NNmz%Ew+ zdk$(FJCcPAd)fYX;{c%}d4xjCIYPRYXnOVa7jxo>q=C&n^ZOfP1PU2XW z8-@oN{6MS2H|*sS1a3Ny2^~gO= z4l_{bmn<~6+i-bNC>?2p& zHmTZ4$zIqP{68wYVKIvI?O&QcH+2Mo&<|R`@W!ZCG8}DkK~c8=pW;$`j2!l@i&P@^ zBPufFvJyQyjC1~TQAOLDB3)v3kUB}Q3^7idRFj^(j@%#8vo!??dy$an%g)?nmgq<2 zSM;lZ)=|4DB|?BvxyCWqxi~eSth2*(tubIueGO|LdMy%_;&su-<)C2L zEz0f8KTl_rTI&1ixma#R8iSd4?yk=~_zM*%ai^4Rb< z7Ab@IgPP9@mm@=hx;ot!4C&?6mW@3|?#HS21!{MNXJO_d1qyyVJ?k#zOmamf(x@STAU0TXIO*?O;v;w(Oi7 zcvfN}1QK^-E<0n^yEtF(qA|e07(U+h!gxjG@^-NZ70z(n=jAy3h~~R19jzD=k-Y&p zaz0~~J%1YH!rB$36h#$g(7J>J0bG|Jr>=b}0T5A-CLTEZr~GPp?PNyVB9#D^$Qj+q zJ#)uet(R^@tTWqZ{{UW|E-lMKYe7k$hOo%d!roJPE!>shjiB}8pK5TOmKfEy1ZEA4 z6O4>=&ox>*sbWA2D&wGKe7g-mXXSeB1-j_UElbA&{f0>>#iQX~@ockHBK2 zi6pu$Bq*@sA#y;#C#TnnpE0MUi7sJRaPb6II965l>yiC_l+EDHAa)y0N%?vF57+tB z)5Rc2&9kY-SAl|ke!Lp3a*2?!WJemVz&eqh=e{}o=_Z&b&7odaOJcjSSY#_P=tr-w zTB&tAGX!#^7Tm;i{{TH|Tl2a_BL@M7HuHdgT8iS>#k8;tSPT$wr#&-W5zvPxs=kFY z`?d^>aM{Q|(9<3^{w$oFoN{VOQ+`SiInE07_7xd&L1h>K;F{GtoVH1{GF2;rK^gU- zlW`q5;+;1b$<7ZR^xO#;=jF~%UIksrc1E{{d|5w+Wi6&`M`4Fze(?0IJ6{yu_?J?7 z^(OP#&&-&{bC2bLT)bpE4mx8#m7(FLf9*)jAZ;Y%6OzNOKT6I@%Ik9C)TFtQz3}VA zviN((_Omi8Mv@Kec_0DWzUuf-BwE_Rs7(NpCmC!u91IL#ejwN9z4{9sRhkmZwSfV6 zC2{H7y?vGYApA*ff5Wj_&$*_7Rbvf|vD=N=KgzhVS6s4N8vg(?Z1&$9SzF1VGWlw^ z5|bp7E^s?4jD9DY;BEXq@m)aH>ZnlzYdhI?p(qzOQVeVg`x=IxGxlF3JK(sB!UhG&^vYM zUqATYQi{&~+f0fus&H@%ZpZ-f#ybAB^iReORa`k@h_2-&36QPGZ{@Up74wgeVe<7n z#Es@kaD6fB`F|?X8YIq|ac(wdN*OLxus&j|0K0SZ5symKvTfF_Zt}V=P8_b~1n1^C z?b^B7ciEO$RFzJQ25`sQ-mKb_4Z2ARmN9~aNscqujB`Ssx|Y&N&XNx=RSyFwbqq-f z$pCfd8T!?*!Z(IC2xS|W8+-fuk=OC2Dcz|vEPLYHwDJm)bAWnqIjfq4TV636Mjdhi zWBBJj)ud;4ax^IGH*AdFSp~v^q^RkDG5LzGpw99leXW0XBs<9d3=W@8YhHUZKAw!_ zHZtIRoUs_`?ax}zwt)mv1Sn+P^DamL^yf9vPF-0N)mWd#cH&8Gt}G=D8Y-(NCnS;i zcl_%QS+|$Vw`GVjJLS&Pv=iK)r}C{Wb5&@p%I%DvNvpO4?H9i0P*rGL#qVZ>Fib+Cd~C!TE;-ZK$m+KGUi1ZS0{=)o$MX`R1Z{n@J7oaKvo}3(eb5$O5w20gVkb&~9S0B%;4q&Mt#$>TuU0lU68gx0PsgR%`qT!(XIZ+)B_;{jx&G>$DpVMvPWp`8cECG?i~h4 zZ^Zspl&ybw6W3DpsCg8u;n~@NWmPV5+aBK4v1F-jAsdM#4X8%eR`dX#Ya$E9+=lOI zcvzK-Zq@)}1oijtQ)yE?_h}r8Imt}p_hKI*O9jjCTytlPG_X5KaN^!2baCtDKmt znhhW-tW}TCwObso)9?bcZA!Jc*seD>Nde>E6_IByV?ZP>dICcNKb2@%Y6;~!gDjG6 z$sKslb4oX6jU5HLsR$uoFrX~m)-o~IihIEAvf2|WBap^TxNZRH_3il78b#SAK(MPY z1TbtiaydBs1zfjJIn~MsT;XyC2ONRNsNhnIvn{69g*&S?`xzy6i-sG4Q-Hh@0PXtL z45k}tTkQ%u;IVI%cE&5VyS87mC)s0A3NHW>+;B%u4|?XjOQZ{99Dv5?H*S1oq|PvL zRWEtyLib&ayLj^p7!Vaa3@$KxbJ$l?;oF%YSkhHwX8?&Z+gRWdanO!MVOT(ColpUY z&RM^RfCmRZTD@l5Uoja(i-HF3I2bwU$4}0)yGDnp_-k2P;u`Gvg)rE_^4B~8f$DpE z*8R4kmrl}34+9t@kjEJ#jt8}QkA*MD@|H&^ti`@!Fb}E6*NW@3{cYruGOSE)Ol!v@ zInP{YJdsl>jg>2{5!vaN&0{*m$}`L5MsPh(71HTikDp|jRE!XCp-9Qc89!cs16*F8 zcJ~bF8iELAjOUyHGn1b6tqGJBW9Myrg(&AJ4mi)(0;T)f6%85NB%XT376_D`tW**Q zInTdS-nU@ggxr#ON-{z1+c@<902<=_H>tIVB4X_z%9T=1;n4N`M_%>N>6(k)Mr4)Q zR4Wh|e8U*p3Hl6Dy^Rvo-m}z13=9$Zs^==e@Ts0KKRW32Z9v?+O}NH$fQ^zsJ^23s zJXa;B%GTD3u%ZSexOyqHo5 zZZZJLVma&6+P8F13)!ubmpxRo5C|>p>T874VUq1jS&)qvAvxOEC!W8pbv^^vKGk$> zb$0-T!jReRj1SB8r(}oEC$Z>u&|2JR*2wYhUAf3_^<(K(W7VOv4QX$>NaZC_+UHpHEvCqh zzjiUSj{g8m*437=8VFf-p+mSZJdaLs$9m>;%cPp-R#ssNBYsA3dRD9!pKoZ4fmraN zah$F)bN+i)a+7+U5Q6G{f;m4?Tq}5?Me|2+Lx)himAD7%kF8#_)l3@D60qB|8*_#}hxMoJ*5#}_ zJNq5b7~**qOdvCS-Op@SS*L3)b0?P1xTpv-{5j9~SDc+$&9M{4ti?C8jt_42y`k%W zYPAg*WRQWhV<$fU0IfAwv7D)09-F3XZ4<@zr!BjVeFgFs85(UBy>Cq@=G0VJ9H_zwy7I?hB)K;*P-}FRP!ML zSQKRik5CVK+MP?Bwdm<|begQqZD3tC25?C^&ro>&b@Ru@8x!Gw7soY_0voPD9W#Oc zCccFGV7attHUYSNr1O!}Is9wI{wtvV&AGAEQxE>KCo7$U*w$6*rfrwS!e&Q>d|Yky zSfZ7~DxbaGhri=qyQAMntHo=EM|K;D?ma8!e-7)CHS``zfEyUkB=l@&^A+tL2G)by zM$r-+U=h=x$6v$stSRCX(Co)yEomdrlT(t)==rWUasVTxLv=h*%p?lKc20Kp#Uzv4 z-r9}iGwsj5ea$w`;z`s;a-3wY?xUr2duyI#V390WizSqbm?U$P-mU2D8X(g$h6kwX zKDChwww)s_&ID3@&U1tBRUz>xwA7@Aa;(7o%)fN|RH~YeQ0Ca~bi2>A-+7k=Di1+` zN3C=d+@-`Su^s4n$l(1e&*SkNO%m;V42*$+`Bsgeic%5;EE$`r;NX2TRZj24RIm(~ZQlAlgpp2m>InF_=jGgo`lpe=@6sX6p zGx}7K%>iOiki*-9T&9=en1b%PU`8?bzolwg>-M+Hgk@5C8tRO$a!L(57p`^IhT&B{ z?s{ggMXQA$IdBO4s|oFwmyOh(wL5BW1Z2)tV*@3Kt>Zq7$;SH|9&|d3{o&17nkeG7 zD;_hBFT7#XxmU9TB(NFe^rCT3R7<=^Z7mT~)vJ;5?T4EpB-1oj z;QsRml07S}j$P}O`f-QRH^4>DAJ_%4WhR!+s#eH${0@?JB4%|rKlBisP#y$T4m3-^t3n_2( zS2vEoXf3^19Pa!-D)r~@z~GO>?szTLwj?4JamOw`>G^;CRb9`Thyt?~TylQz0IYIrw`3~&ojAQv#IU_$d6{KJ>obo>(Y<{)nzUOM#hHx@6Kq{mY*8|qLkBJsY zusQPN$;6poL${A^a%;M{n95u1#0Eja;~;UyM}GCm{6t1*+0R6jE1q zHl-^wjy>*U{zR>?a{Fo+PWRfD!doN~N&;A8QxvOX|E zQYJI04*QGk9RUZo(=GT{$-fsZUNx3hj7tjSxCzG~<2ddKHRfYqv#O+XRh61Z@h966 z1(5Q)3YEg4I3w#-u0qQJQpzJ<-SeJ4ze=$*CgKpYVDF3)cIT2hj(F`=B(&XY8myQx zuGRo@Fnf;w09xI?YaKkPS)#SXP4;lo62?~E30&unl_XwZfJFp(a7OTW8Snh)YiErX zP0B(Lqyx$06sb78gJdDF#{xsqJy+g_T#YE>FH@9I4-UdSQanz7c@~Q42mQ?eAbx=A2PEQqg)JW+3 zA0o*TD=AUGj&_lf4&%YVt@pGdR8kT^#K|YhPZ;V5_w}xO!E-DnS80h{7EFS2a60`n zT};GIi|5F#xr+s5;mPUr#cPCVwup=O+BQ5tbNjY3tfPNcWhJ_O2OTR`Z{H|c_Z+s} zp+Ok`03-FRp9scoqGC~U8*94c$j5I%jw@UlCY-cL?=+FNif+|fCjRp}y#h%QR4_r$%42P!H9uawlTqsN{^?p( z18lq0oZ-b>0Sn|xS;I4OM zFUf*&>4p7j{nU&iDFU;ru0SUTrg78XKJ}Ix-IS9ZKFr?mgPCnyD+Js3!335gzqiu1 zXV}>oz>z~MbC&!oned(2c^7Pqk>n`J+RSl}oAW=Fb!rT;$K?@+jA8TNxb`(Pe|J^+ zj-HZcWyok#5C&OG22eN)oOAW&w6rWGvUx;#nK>hzwmHwIZ^+hOqbp^%x7{0gZ<;ay z$>$mATAB>$9g>N#KqQb)JCC8q`Nd_4_i9C%)K;1_QXeANj1^K2ShnmOdSks%)Mhb2 zqbPBM=55cm2jl#!LN{5hgAnAR>^qOjf5*4wS(=XMxkW@gE*qXYW7Ct*;azoQ%#CO8 z=yV<$VQ}1FkZ{>yk`4#IytpM;)Fo=SO7y9ilJob6ynaDN_^a!;9@#Tx?ZHxe)=Jvbix{#9By z=9P9%L}*7;|^+-9B}+rzjr83DyOAx%G;Zw^zINj+h9G2<(Eh)TMpbj zdJf|mro1P~i*uxIqZk26IL}ZzbgY`xwXsmXFBMct%kDU1h8WXZ#E02?pZ>2>lNY-Q~UvcL>*FF6@RohZwqhw(f(1_$_b_~d>P88>- zJa(xrH%A4kM)^~MtNcny^{C$EZ>~XzJV6E=u5eGO9lF%Eq4xxZzjlKx315?_9cYuW zoM+5QY!Ri|7FB^VImaAzAB{Xtpq57Y3n^Tak@N$mGJ1-W?CSBbM}5(SECB?Q_;eh8 zv=;J-ELj!Xa{|3d&rUK&<5;$Cwi)JcWOnlzf)WajNi?zBtX9$NJaQ0s&!Ii)Mv0gP zw|0$z1Y^G^si@_h{J{Bj%9d3JJNg28aY;MuHDp!C?_3h-2*z>2Kj$@8WG;wP8<-rf zIKb*fUy))2h{TN^?Vy*=-Z|qHR`FzwluG5!+(tjoO5QH!HqfDRLnI*ZicdM`@u+;y zH9WpTmdG7y+@K_H<^nRKlid2%G7N)pARO_}HM~q@wYb!e=I62gE-9)@KGJ=@oYR|u z>z`i6npC0!0OavnEq5?_SXN@EY-iW;t2%7r-uf9K$SWW{dH(=CYMBH8dUh1>+Zb`k zIX;Glx(+h4xzYILAZzHGPjGgrRx9h*8T@O#{g-|tOX1yhE#6jXrjU6mPI==W*YmF; zlHktl(z^`uNjc{jtNtF;A4I;6;uk40k+6=7@A!4Boj-S#mCwyPq<)ZFTAva4HO}Rl zS)<&~j!$f0a5yCA-l=>pxV!rt_Fh>LoNY!J;0F1|ec1k$h5Ih}<4@N#3tJC8WS(Wg z$;bczPt!ds(wD>EW7H;V6;@eJRO}cDfuH4Ge5cBsQKa-&IM0cCMX=H&is;VMvS2d| zsXwNAezo(bkF^Ze`TZ;g6uT=-9XBg>UQB*lYE z7VV(fqCl)4ZbvyMc02wQ=)1<{Q=6TVJnrauL8M_UZZZMS1bY7fDyyelv4}!he88ui zh65*_y?P3L{l}Sg8mn%2fh9*6>74#`PR>DRaQ;*gM#yp`4ZDo>KD9?n_lvg8w^s6? z1EF@p@SJt&R<(9m^!VkG<^fJeMG|q;`i_-a$fd>6c^v=;sOK%{bJ*6M>>p^*$hk4L z80|s;-N+;Zj(`fkBSwnUy2T(e<$&FRjEwc@ITb?8 z?T>CZ$~O<4xgGc(pN(&83HI9uP^x3V+^l_1Z@?PCxQSZIR9(dikf$K*AOd@Obo^@h z?Hvu%(4%i4ljc&$@}VjUDclc#Zj}^ICA(UQopRb-6XTq>jOpMRDYa41t9x%_GqxpI0MlSI);11h#7K+JmX&p7v~bjX(3 z5vEdOW4~_fl_iINPI#y;H={tJPb?MRyBTwg;}|@2{c6NzOfagD@tnKhWm60}86Kmh zWb{`ZDvM%GVPu;2185E$Z7Rg&G0roA>sj;4EhIo$od?N~IR~Dk{{T9*Gmx(=Y|P}~ zk-#U`vvqkINVz4M1_9u1PDdPI{xzj#G;iUwLfM)$-bg)LBz)eTI#w(gxVVpgdykZ4j@UQ z$s4dm`B{n$s)s!WeQ{5VP?X%Mn;6HIa&kHm&mQ^hS6*civ&kfAHj$7%MIE^J_onlUy$^md+O3^EZka~oCiPU^(2jG1>)x~>v`N*?wCFIKa&j1v z{V;mcrIz`lF}u6%EDH=MIUm!Wz3J&EaBosO0G4-OyLO2gfIT*myP@Nb_^quT^xWJk zw2H2al_cjXdW?GWj@5@O3S>beY=i~?vB2C2IX<<2Oubp8aAYbR9F={_#~r=+t_wlEXv}c|uon4p z!w=36JoojkvrW|2)zO(lzS0%5%MyEm^!+MRwuYSi$5*GKMFenpp-ZWZqaT?->-glJ zYkx_y%XtFh%Rr%2F*`^e`0HG4yz({UP980&fkL`vdH!81tkJL8&=Dr)P&wogk^#x| zru7_UrOuMt#9hv@GqQDk!0m0kbI)$It7mH*7EShL8}|Zs@>|pSRUHyly}4BZLjX5z zeuMh=t(_Ek(8kB+ic(krc|79-`PFierdiPSBW)yz`_ami^YGwxz&$beRm&TwVbqP) zzDYrV+2~F>`gIjkQApu_COd*{3*2?@MvpvF*bvIiAYq-~QI4PP(zWz9gp)h_FBPwAazsiClHYWnPg?U0TIKBS;Mf{q=YShKST7yEwbgt+ z)MwONV04Ji0d~*Lxcm0|wX3ueWyY1_&rJTon@UHRK4NtY?fu+$#ar;B5pd5llsmU= zZc1ly>G@TQZxTgw;fbJ^bd0IxnE(yuk=LKfsOh?Z79fRHosOUZx04Vu z#-kjcz0m$0D<-avl*(5cQyYbNrBI4@?Bi;T91pKOXnjUwu17nre7&gIJ#&uZ=xbJe zCFIiry6s@W$Q@YY2l`b@TTs^1iSVzxE4X8BIPKT9bIv`Em{ic}ZM;JsuW2!2H>6<{ z0nnf4(ye%FP!V6iVQDun4bBE|de;Metx2V7g;8*Xa6!N=?^~LGh@-f%P{tJ?W!on> z^{i!3zhkA*>>5?m$!iM(xk~TjsX4B{!gr?r+2fFMImrGXI{yGV@!tpdu-o~OMek{Oiy(uMs_ju*nPM#&@aeS01&hu5#3(>}3AXw(T{C+N|;n18F3711I@@wdJ~Z zj|*xlj`8k|Kp!yqao>^kuWb08sl}`KCT*A?Az|vxk5AIRR@Y^lN{yuw#u@l5pf@~! zT-4Q-^+#p{-lxeU+w@(1@05#al!U+o)aR)D>(jm)_?Af5$Y_f}fw=pT>t8YaGVz3$ zO}1jei5Oyfe8VRnm3=Aj+U6UW?WK*2#8E~U_|7m*Iu%^COv@WdQd*v$Zc^kZk2{Kh zqP&0hnbR&bjWFFsD-xkmiRh!JqjF3A&|aLJ69sTL%`R_C5}l;4!rt%it>G@D?XnYMM^pz&Hb|4cBYQ%NS#Q$IOD$&KEQ1A)@Nt|E z*1I85o&`!*vD)hQ?{}lCTLoZ91&{FkYtOt%tO&Iu_I4gp09b>JoCC#bXqxT9+ikf6 z$;RfxZXWf-_@e9VP)TrrFm^4o`>Ht3YHC)pxio27R%PjYQ8Y2Hl{4>rl|W8^I+okw zRN8y0+^~6YenFORrgK~~>Ww;yH@r&!05N>@Uc^;J@lBqP8eKem{pNGVGs_Zxm17)A zxz^~bTF0Y*!q~ZvP59^ggaNRAH9e=r?8=|L4)2mq(c9Y}*1U^W@su~Pw&!0k^c#WQ z&$VMGi7xFg*i0k0EHI$9N%XF&Ser5>g;Ph;nkU5n0PR8=);~25Q-XQ?1#7RwdDQuC z3ak2zaqV9?XnO9kI90Wgiy$gOF2o+>^Ia~JZLaD`=J~HGMUHR^566txd~vXfS4W#H zB%9p&4@B{jxe-L+i07t%I@gQFPo&1rD(~I*RdJ8byobTEUE0Jp&$pAhN8v}W6}fZb z_Y4M{1c3n_WP>Vw*19R;;P*J{VOy1v>2Q2cl2{-2FA)Qc$NvDWT6zoWI`n>Z+~C3w z2u=-oJld_qw*Gu8BOSRV!N=uYmxDD~ZnX52G{i8;$3SautSM~HdEwkT9ga?K+4M0DP4U*ctx-J?l5^5nG0mKHUKR^=)ZZtNoHje|;d_$G<-H ztX-YVq^yo3;}wj$hlgZK5~`8_Bw%xq=qu)59Nmo%T3GcUijVghe*iJ*oS(|RtGkV_ z%$D+SHE9r@*>*8!<%tMx5 zMr)RlkxoK$o`y5KRwaj1pYY9j^oq_HIS58Z81u(*`PZFO zMs~&BT!uy-TBq@-<7mcxiT3`r&-mgG^jxq~5qbHul6!wI%DOk0#-OIqjHHbB$9#S@ zkKzRv79_8fSq}Zjjt5__1pZZ>FNmak&+&H0eWcGbB!W0jF~J`xT%WHZzE=3XXB#}e zQy7)Evw38Jv=VxErtX5>8M$nRTSN$4vyjZXw>jLZ@; zRIGfq(SQi)?bEGxOn|8&*zY57>$f0t(~gzGcwTwF+V3EXV{XR7@}4*q-bUu?-5K9< zl>n~p_#JRRTGES9nUeU6{t(?`F+OAL898B*#s?n${`Ju)Gi-k~fVaryzVONIk(1iF zT>~Ov41o|zhpz0ee2-q$)p;A`^3jW;=cZL)owA!$Ioi9EX#ftOci@5i>zese>^;NbvKe<|bv<*_r?+a}o?Yc-4kH=B zcONSfy$ScJe6*^k+)}N}B$_%u3f&ufXp-Z6k}m*mA2)JNKD}#KQH#xi#3DeTfDShT zNf40t&|fvsDDJlMSEIef9+Ni2EBcK-lADEwate>UH~~j&el@h# z;ie4i4;*8t>(;V@e9Uor*ptK7?Qg0ohQhfd^N_dzj=lc?#=5ssEuj&UAu1S#`Bj_0 zIOB@v^w?X?h_M;r!74pR=UbtqYlcGVs>n}r6z8|;P~ziJF~>$x)VIws3|YuK_#l=U z89a~os?LFZxIMdL*nV{<+8!yCMIx06g&D&W*N@2l zbrsZ0Al~^QlqtX^#(VYSlUucTj$0OV2#Q6%AC#lXDl9g>uXDkC%XYGw4N9c97aL$1Zu>@~FWcne@&$t8&ffMH50*+`8m11RnSmzMVxE zV%5rlY6@>s3t@v}V&r52kF6G~AC?p>k|d4y$>fpuMmu%RDI$tVqm-9*iZ(b`IYapV zf1M;zNgR<2RNsS^DsZKTLOy_=DsGd|b3=o?NE+;rS3N-K#~=~ctwl7GI!7ElZ~2R3 zB!kyJ^(Ev|+$u@SuHwp1@e)AB4{oRMsO}(n2H3Zm8{}d-4o?Hy7~|feTj)-_x)pD& z9?jV+&^{{T1{I9^X~2=u9LGZ@+ekQ;Bwo}QqS$I_uew%K;z zWRcec9`s*A;}>mN628m7E6bHrc5)6+BadpF#>_wqB#3~Hp$O^6zw1!RDoY{3A2}F7 zkf39(eN9TSv~epF<%UeJ@ogT)l$n~1v_s)-k|{g+J6XxdKTK4RyTucu2}2x#lhZx> z@G<(-BxII_#^(bBVR7ntshOo%wj;+9l1U!FU+}E=Hj2H;XOu}}fbE6;?*tyilh?nc zMv%tD3w_(q#?+Va#9#VTB_YBRudsb5gd@ zPC7P0b!+x;7aJ6w6dno3T-JP&$Wv;j}V6>G;wz`D#0y zf6FPY&vjnZy0A!GvzUn)TUXX(X!h4HY4zg78`DdpT4 zAC;7J`eT7#VEk#Bm5u|O<2>yCdq ziVD%Q9v&|DJhodoqMk`rSoy(iaq|-J89b>1rq=)vI`sBD*3PkVqW=KR zMcWV?V*~Dv1_nP9T;1EtB&rYhVR+kitcTo~MfGX1|f+2!dc$d1m90fO=Ia zTXUYQbqzWul~&pXa`J%3K7qUX;-=P-jk`v}=6{qfFkIyH9lsiFs>dDKX{8_l7b8C} zL;BU-e$2x-F|ur9xw@UW$o~L8TCQuEr0;W`aU@U}rQT%N$awYf3Iz%w((*$hTLTpa$ry{g5P>s^u%vjR@k zPI3tav+u=KNttGEwnomyb_FM%Mn~7{?^4>_y@b}&j2DaoiUtM`7$erOdXkqZqiqw( zFP2Q1iHl$_Ku$(|J8@K}j24lLOvXg{kKM@HJ7czMO723MkZX9{d4LuK02#B8c|L;} zs#l64k7tw-n1J{obNF`lsB$+>$d^OZ9>)1(1>}g332~8w+>HKTjcEA3%xU(qOJqs& zj!=c`-2FRN1Pk_c2v{*uyC<$e>FrvhSsLY(G3vka$Px$~_x}J9PnyWKn`I9$n@}yX z%oO~j{Kq{=_vfu!)A2so#9K^bJGS7QdhzwF`-KFuE+b_;G5#UPVk<(za|uV821@c7 zM#4@GF^=C;N?TmsJDRb=;&`4mSrt%>5s-7~k?&K-8zBzSheY{F+mLWZ4HZ;^*k!{){~&$+5=Ghf+6va>On1_>lCLi5fuPLA!ZL~*jTp~2d7*P%T9 zYd3Q3W1`Y--|n$xV4o@D0kP8^&NKPfMlOVwr7eWJ0V4oy%W!Z{ws;>}<2mDlkTLn%^21C(GoANPh&=TCw!wAdEEbMunKDBF?i zNxO|lmFQKlxHB%<9y2m>S(I-h9sO$IV3z8^!;RS5*%`>j2N)Gn<4J2dVPa(6Aq0gS zZO0gIU-79DU6x@7c$1G5E4QTT>74P{c2L^ZMKJNq+COFV=AMN!HSsF=N{zapK7_L+_I!m zNf|5w8OH8=Dfb`NxlJnY$8w%_>3@i-3}9rC3w|T?uFpldX__RF)yoh=BW??l22UM% zKU@x#tYe{^uM@ZMOf4aalWy{)x{^T{-H;DHzom5=cxdE}5CB5(i~J+k+PR$$U1lptzu~Bc$!I91i}qKAqun61Ui+k+KRn!B7VrjE{d>ew}k1_%J$ciE?w(>OE_}(k!BzO;VCZ zo8rM{OagK3*R>}I?rV0AHrK+ETulP3%*)g>AGydM%DP=Q!)xLDGq-uc0ZOkeoc>^D zwN8LgF}B;elrC8H8+!i$^{Q*jYm2SER{sFAAmE@R7XJY2eQPR_=0>on?{0*?4byHu z#;JQE<#Je|%8+w_2T*-$r?c7i#N+pHs2qLbYdcDXtnAnUQ!V3#SU#VroLC4weKV)7_Z7&j$Y{{TvLwZyj9Z7r&P#485Lin5t66+^NQiD?sid}ugYG~HJR;6xVR%@VMbIHJC(llC8%<#dxH8tdZ&aIr&tPwZYu}xat7U>s@bvd_^34VunKls2?xh7|*YMwaW?e zI~|oFc-S*3$saILuT zMsw5fHTEaN4-wn>8hEsE3XWVM_UWI;HSkAS0||h7^qb}l+sA`a~wQpPni2N!t?#Q2;GJmzG2@!{{YIdzAV^@^c$Z! zGNv-5fuHlvDW4B@c8P7{aO4#n5x^b4oo;yU&|GL!`A2(5FhK2HlAkg@Jw?K*J`?di zg7El)TWP|@CgSmu2;8HfIQrJNgxYNxhVdk*N!mK&fxypd>i#wSIeBhNpv=e%0=Oq0 zp4G;9Z$**^MNP{houD2tMhVIMtH#7jmPgn^tZP$Ro{^(#23t=o#5WEz=~tt@k_%_@ zi@23=bIRoKYXd?PO?msk6nYMfJ!`6iOp+I7wq;ex+@$9mdQy#7E!pQ)ii;<(Gsy&= zU&y6)lb$dKKdoW-iczS<9hAlw>~2N=Mt>tYhp=24{N?vaJ!shZy=NlOUZ1|@6(lhpoI%IaG4>wv*y=0_Oi zh(9aij0&4wy?Lf)w?=%NF7Ex#e@e`?(w<@z$K)~l`-b2-{u<-sopT zh9tL|B0(wu_CN{0Ip#5pdhuOwuF)K+?sa|&)8^G;2U80Tm>agQ%2@gzTJK|XXKqEi zMIE}Y2)zmS9r{-%rB7{a6fh*P*r+p%_0Q>D9+w5Yn88!y zJ4jt=QQ7t&8<;Nx>5AIZEUoQs1Zf}IHjJj>z&`brZ*^s+uxaljUE}+}f%U~)m*Rel zrNE|Zm;eK)AZP1aDL$txskZGDbZw_XKsQbIhFO1yHEY6}p_1xbb!AvU`Ber7AO5=K zExs&h+Jv$B7YioifsR4`hx4tCBgA&UZKHt3tV!?BpmQE<1Wxsra!2X~IR$SYTHVu3d{|o*2n_W{(QJ zMo%B&e+u^S(oQFtQCXiMd~MJlNbqcNOpMON00#rEKbB2+^pMC97G2~4pH>79YWgSk znDI@$z1Y){M2&BZuoxNd{{Wz$&b%V$UA3AQTTlja7?F?w#t%=eapEGkI;h^(W=!r? zopvB7xjgplJ&z~ytnFew)>0#f*tlF{k(}iJ0G~>|JB4rEI()>RmAU@_>t?8G1j%UL zPT(0yExVz|rE$gTj*dgaek@rv#iachoRUu* zex1KM`s3o%FZOwwF(BLsQWqrT5B|sVua-V8Td|7Z22%`*&gUF~gdk_|HO-nwngv&j zO6SSGKY``eN~22UL5+w!V+b>!VUzgQ90?(5LkzJ_4`Mg~{uR{xMTO3&-)d3=3^Ikl z`<+MQ{HvP25XTN8EH*|Jxyf#a2lW-53Hyl(oZQvQepit&TX3t8I9^8}=N_d007^+C zX_QB6xX6C;lhAv5^rv}#T12fJnFJgzHv4@$)DJAu<${I^PSx4dp1;q%Z@JORXhm%= z+P-liMqj9tToE|r9IO89kWs4aEMpbdQe(=gYx^dU7Xj(kYYQ+?W zZ@NIw=bUmn{xzJD2$>>QSY(?al$JR>IUIBOnzA6Hl|n{@=kqo`c&{+Vr7uJZ$ZdVLG9b6X&ntMa_)!@^%oI^1gQZ!vp#mO7ey^}AT5TY2=j#Lbjo)6da_N{B$HB&Rbw-(?Sal6f8NhM2e&mYhl-AP>A z#`g+}sHEdLB=L=-9*3Id=Q%(_22G$V1D=F;`WCGnGW&Id;4v}hIQ}D?U}xJDu@<~! zkWM#}X2*snduzh7Nh1Qq_)l-I9Y@x?D-tf9PUCUD zsMXW%2oW8?NV!$O;Ai@J3fH%|5?RO`uuK9D?nom&exTPMYpF`l2n@#yy>rpdMmG$7 zYkJb?%VNl(QIr5mjmftMkrj7JwoaH$2SZB0Z!t?g#3N; z`cjMcO%nHRTuDQuZmP<~g0LWbpkuEeO47B94oA#YiEOSg+zxUDbH@0{+hua0o96&> zdF$`mx2$c9%8aTv?PLiX5I(236*Q!z%;N6#E&ZLo#~Vm+uBUEB<^w%A`e!xLcrM|f zm0=-Sm|$a|Ur(<}#V73Xs<~%4+wyaeI-LD8>sS0aa}+mUx*#DNi7V3{hv|xn{_f!K z7jwF^EpciODx~rbK5hqp#}wRb;7H>fVZWGTdu|uI^g5hrHK^BnQ=2?nD=8O4o^>9(n<=#(z%Q(jBP7`z`Bh6ct5RY zWG-uoGo{uKl_In%cc=kM^c{UV)mdb7Ym&j39k~7^8R|bw)r+#wMI=EpCQ5AwfN}^Q z&+@45IECYQ_Mty9Cxg)R{<*B?yB4mQR&n;Lg;iu7!u`-az50H&4DOp6Bv89Y&45lj z^~b$hn3O`w;ncRmS+Fzps_d*Sn8lRbah``C;BoKLp*3RXD>6AQ7|Z}xR|}TifTWCL zjB(bV=P)~8U@`o_fJX;Dgs9XA z5Hd-XEZfh^{J+AivIRmVbY6FSr>P$GQe~1|#zj9f1H26V4_dep8FL~Vn~~*%<$zDh zppIC3p7^SBmYZ;O3Q*;;YVOGuepY?QAmn6@oQkP(frO!#1RggIPvcz>Xy>ZsZz8b3 zB!jpD52*T6oRD%907=2d1uFcJZIeGPDFHID4Uji*I@YUmnJqLJT<+yY2T}zi;4W9G zr#=g|GsO}n=s_6cjs-jFCY_yyjrT}44WRZs(yFhR1F*+jiiudR3TGWEL=54bck$Hb zhoLc**}L%lyzdY}8W0)WFF82`^!{h?ud{v)Yxdp^lSa~E2)FJ<)xcrF01RYyuaN!; z#K+=Afh>U7~x3ydH@bF-{vyw1R9l2vzx`~Q*5-NbJf(SXuNxA@a4}qb z{%Txpak){ycsK!1BPaTOD_K2G&3ccqnQk?2-r%06BLL?deMhZy@6~;vl~zoNH*&qa zs~dl$0{F&%gtxvi^!%%SU3G7)8YxT2>`BJn+??m~{Hv4HwG$h((Z=zG0B$dwdmgo~ zW2t?nMM(zj=o@haAoMxM=}jeaB%4JAx=Ajj3hOXVSg$8NdGzU2E^iCm%CV4EHekFE z#k1>Ir%`9ANLkg3E>6+kJRIZRqP@3l%4IU)WGus7LkhNADHpS`ShrM%5APk zmKlq_0+qoX3I6~&?@yZUNm@3v1||fS=Ohw){dugKG<{PvFVtQ{JmgZ*vaiY4Jn~7Z zZJ3rA-4qjoOAfzWQTBrk(=?lyBkcg5oe%ibTDBu#_EIzLA&Ac-jz%gJ2HLSQEFiKS zphm}Yo(b=qesvm8jY1}N1rA0=a0eL3_o_=PUOHu&cbs9m^Y~V*U%XL-VJvu1qXRv< z(~{*wjQO-h>)6(7b|5HeiAdOhLHYyJ{{XL5lHbe!0FPyKPc-4Pw*+!?`Qo(JGbu>t zkN#RHJ9?folU1zZ{p+hEOY^ozMaj-Tm+4q3*&Pv;k!EQ=*F22l%-MiMgp70}G@orz zd;7TAUm?K@&h9w(6kTV@Z<;oW%HzD2xC?R?p6R}fs!$i#!W<(hbLs4N8=a2UvIVpew zfsve^zd_AVwz@LU2OBncLy5~U>NF` z50h%}?^d;;%!P1_IQkeNC57@#sTGfcdaSL@~}FMqh0Uj zH;ZioZCr*S23eQepYz3bJ_wZBTr*xos2zf$5O;x*(;YL<=~((clfEKliQ{rgkfU(N z9UF{(b=G)Ut*xfS*6esHcjp+$#&MJDT~Vc}5~+W69hQ+~B`hrBmRAQ1L}w^J&wA-B zyfGcBr`t0lXC#4~A9l`apN93#H%+;P?gGUsWmC)V>5zW4t~Ea~-vy+coUZ)sBOra= z{d-g8=whpT)<(aEG^h+=jlA);PUUs`!`C_Fab3ohtH-AXiu20>Kp;D93di~I2j^VQ zou}U3#0$$DY`|{_*(f@n!>8+8O1gQ|Bojv@rL(b!%NFC2o|P_|Y~EEa(=Ux?8>^u{ zn&~bw9XArh`quA;JVI}vWrinQ@JApFbU3ds7wMwikL>Gt9OLGdM&sAG2D@(u{7Q#S zgNPw>o=dJecNsqQoT+k}POmIhnw|co1d`hayx$RY5&WZau;Y_~{&}f%F9P5Afa64( z)@DK6yG7yu6S$5p}$L$;k7oZj@0 zNtfak?WMakGs4LT+F5zX_UEtoR%G82-%w^*-cc}ZyD^Z%(>eFAHH!M^q>we>(H+6UG5$%p@~lsuFi#^vNBn@$qBHbme7tL4^kxUI(r} zrE*n|Gr6=Xayy;XzO!s}^z*`pQdpHEBc}u&qL)qaJ89QmU>Og<+s1#xt$AhNj95%m z6B@7z#DmEoA5VI;JYn|NRT2XlkQZ-Uj=Xmji_sp1Hl(lI`YTKEn3qV9hIrlZiMfS=BoNfI%WfB<9DYUk-zHH~iH zQoY2DxF&aC0@%kuLH%l=-o_3pl$$tj7vo6r1cETJF$#9)7#IW($bPl$zq6l>MV;-a zMj>R{PY37cHQ-(-neKHbmA-QFbGU9f>41KiuR-`f;#e(axAIu4MBrnR7Y7*6ZuF}? zo9KH9DzvQ6(Qk&{DMme{0lINbQ$T2!QXSH(XMn`@&ivA2?qhG047 zyysW&s#!>Gbkve$VYsMV5PO>XN5}pd`$EjChTYRV{&nW@cwtp$id1;|g55yPd6)g?USYWZCb{{ z$*-=LpagAGj+}sc`&U&Oa;o`YjHekMy*8^3jEgJW$DOzZoGPDe9<|PD{{Xc{kKs#$ z7OyXzARBf<8y}b)_OG2hOXKel{85lS$DLyAuU8M2yZ-sx(8i*KRo<-j2mP-#9a>WchosCDf@9u{4m))j{Oec38t0FEc_;2J z+R_a27Cdv@ah{al2ly{d)BL}*?E66gV2!;7Li^{RU&6f}&*H9u@Y_aN?`O7o2Sh@o zjyVK%;=7}Q!^T=GvzE3$*~j;A)apJL{?FRKiFEas#X6NI^sScd@0wy9zZUUYqBt-pd_A8HRCIrefaXo+5qZ0 ze@eVf8)}Qg)aHDrF*Vjs8Mkf001z|iI(=#xWk}zD3|Df4u#9xU{d4$JC7&xI#nrN- zY02bw=Bvv-WK|&Gos77_Ab=Deq)JCb`IUGZG`Q_55xtOMsc82NHLec#f)UHGjB zlrB85;R+q4##D?B-(8}5PivZlrH6K#`Fm=PQh!-F}tmpA$in zM*C{Fnyro5?cejpe=76&-R~fFggGoo;9%p_p8dG5I|(b(B8*oln$jf6wIc~9924^~ z1a!yL(qCGnk0DlIu72ss+tA}5iRO}cyu=_z#Z|xzdzruI-MMbO;BY5gTw^f=pv8?t6BvTUUwI*+E$p z9C6yTtUcD;Rfal&jw&bIT&nj-w^rkOyCGwcL1Rjn$rD8ob+rlx4<3zU- zd0>}P5RCo=@G?JIm+_{|-Q^>5?C8_NcF@6T!`O78gQWVe&B{20%i7xxlVw z>J>LZRZ6IKIbcpY4Dsv9{41i;;ukjT;x=VhCu=a>!RNP1>clN0A-JM>qaSBaw8tLh z$VK5sPgBpeOQoXuC14?goR5`9KDhiTW>F?pV~EChIAXX=9thpZ_ocAd?J1OQIQ`p> z18@cptt!6)3X061Ot>)ImC7j%fUF6~1Q2)~IU^O_=<4MWE>X|}fZH>k0muG5TphSb zbxs3enM`WK2Z9G3ee1N*tjJYKlu06mY$?FwwoZL%Mct-FlWCezK+SfNM#+^vXm$lz zh{i^JFn>zv65!3Hv&;h*U8fl8NjwwJrE|6?dxVS#*+D}5Z~*K0^sW0j+xs!)Wo^*m zxd7mFJooA8+ODRJFe+9@vWwl zf-R_|nXzL2bZnNa%BzA%I0qfMqWFUtE`~%US7MBUc~{sPl^ZU&%aFe??8BZRAAJ(&FZLvEB8+qie;(bPO$2D%tKV=PX zgbGL{fCK68(zM3Kw1-wa%^jVU@rKxWi@2%JCp(Tuw@+_sllxJVjf`=|c<1!0dL_cV zD3PvNj`8yH{S8`sS7#)w!zcT-wK+aibS;tiTV)^{*_tp4V1-=mI46#It5O*e)r0Rc zhHjfi2>=j1dj54^PRf}hh^ji}gqYz;>OJcGbF-b#mPW_SMtk%goY#DpLyzJ`gD^$% z;VgzU!31&8erK8`b-9v5x>L9JYJqYOp~r5fn6XJJyHESXW0SL%2c|u_6>8QgZO#}n z6P&i+yT?q|H&%*_bwe(Z=E)Q{{U-> zWz5eK%-hLtyPkR;fq3apx{gVeT3Jw}n@>PHWFDO3(wc~6WHL#Z<@>;l4D|VU=~;5x z*D>ejD6^Rk`;z3wMsT?~9=_i7C9#x9>lh9fB?d9S)RR)&tkJMk4q_Mqy8+i3@79tD z984ZUr4$l&@H5B)wY`x^E8Q`>Olr*G%!KY3Zoj7={c5&vW4Yxc!DZZBgX#hLfAy-f z+lyvStjd{gal79iiK`JSNdvG+3aQ#a1Ppc`%b$AAO*2(1y-D}S9H}%6j2N<~%aX$c zXB7)7&axFQ=@Tye^f^682RP4hLqyT+AyqdFl5>uD0CySBYIf%c$`T+D7a$V6fq|U$ z>z-;tPUKUOU%mPu)6hbqrX6&Hx`uvb&1BF_mTIx}5(2j}=;2 zWSE~c3_0PtWPV&8DaDz_Rw~_v^U01`3Wq95Jp1!h?-P5vD}$ELT;tGwm2N_2kfUuc zxQsXO{uN30S%BMjs}crO{{S=oMRe0Js_&!NfTq>K=MD2xoRPN#eA(Pi38%{&wl<($ z#Bw|T0F6dn#DBfX1o4{I_teQ+;xH>2;EZQErD6)GL7mvnX{d6=n{m(MNW`Lz;~C@{ zeuVRA?mh()&9#9UKT1|)NVegD$^I@e`Fm4Qm^l~(@smQvR2|;kdC8-$qKnf*xA;7O zYBzd|hgH+<-a=O-sUu?J)aA2}YWgeoPiegC?ONbFv^LBlJHFt+83!Hy@7L>JIrwJv z^e+O;VRc!rW#71-LM}!>5>Mq`neZFp9M&2&>=&-q>oF|jJP5{c2c}tp{{Vp36N8kc zEBPH2F6r5yTKqV=y4LLGuuO>Jj4K8?1&$jd-#Kj88S#(8Z*yaH9FenbP2`3@ApnEV z)qwme$LyE!Iz4~J(&_d)OhP6AGXhTOn2xjPedJa2L0) z@~mpll4>Z*moy}jJ{s3;{D+b|gXO4`aSGYvuNfcin&Wk3NhDP=q>39Gvy%MhAc0=d z@qgjG{vYu)(5<;p<;X0(ewF0f)`0KUthJ)|2)h8pX>D@X?-cn0Kb)vg6l1XK*B`A_?j=2LP)R(2ytoPuTRFiw z>yJ}XTTk}ekt>x5pLrBxIqXOEq`A%I>5v_Sjjff+o;^6Itzuz1J)=h?i_0lI=f7%& zseIRAdu|Mju*-ixd-v7%+e~8AIeV z5C;Le_5}X`pXFC{xg@rk9wf#NGNh6{v)ZO==(M!Z*|P~8%KMSqs8(Z=PXieOrLc)S z)Q#B#L{9$zyjXr!QX7GIr6**kf(kN9^`k&9A>!Wi;8XPKK9&1 z&X%mLyoHdA@8hm>+mX#w)MJx-BIhw222sgBPkfGQ^~$o`l=6{7@<4Byx#KO0t$Akm zvx$7cwnf+ijF#w0{$G_;J=TqqQFjv6Ojj6?G-H6$9t!8{^sPNMK<_V}q^{z*SKxaR zdhkD5$+b}&+{NZ0`4BP5Zk_$dsar7G?B(5{{UI#w7SIv*hLCW_0f0Q`6y^T_kF^fFSu?wE`={{TH|9ZKdmUDkeT0HZB{ zLG7Pmlh5H=Mpr{_))cG~F!O@O$O#!^$Q?QDoKzNbs24^b&Xw{V-9hJ9)tQ5i(Ul zC9{En#{;R)(z+oy#rc^|nri2zX&xfftz<=gO;#3t#IuYJKZJGsaBHjZ{{V;<-;GK{ zkTVRB=acG49{s&5%Jhpk@0}Xmq-Vy`%O*b_qmOFqtY(hU1K31mZM&R|0C>kvGC}@z z(;CWK9PT}jPO#L}$>-cOsIA82-L&zMjMrn}{cYBCxRxnyK)_XI#&Lo<$JV@~!?#yf zk$JORDo3zBGQ|fxXF2DRKM%{U(fnh7XpvjT6ZqU4G zb|XGryC5SQTOS}k=daeabe(ap9U#;ZN;?MgRla2dJnrs4D)QYI#=w2>&0H|=#3lbk5;+VlHTSa9o$oeOl?f5 zQc2;n*YNbNdtbGCiQTN)IUR}njgSu<0bK5vp#~0twzXTk6&ymK6{vOuiCR0a$;9N-dgGwDu`$I-)P#hgq}%AYvu3CB}kHOKK{Yj=%H zxLyh1x66*1#(L(Zw)nHT*t3RN8~t14vigkR`*-{)N`#)4CNr|!`ZG!KxYL74CCUbK z&L124SxUN{VJBIVKXgn`YabCOd=Tw^h;uW!xq(s}22g#ggn&znoBzm|? zirTa8-`U5-lB-)ZJ-@Aboz}!qjg@=`H%*qRGJf6n0#nZYx%q?3q zj|}*sstq2>c`gc!ah%u9J~;90Z+_9UNc)_zQhM~pJ-YK=<@-QhNcwf$aR5hBPipwj z#}`*ktIEb~uUzDV^cd%YE6R9%F3+sXts|(D#5NZ36Xt?b?>^JCAF1QiV?Uj8-Y)pn zp!j;+&8Yz=k~b6w7(YYTuj^i8;_nsSK`fD2JdZn%nX*OzBPTic9A~v~TJ59wk5dbG zFdWk~JMiVsoEUTep57kHb1a*x4*w z5vdtqI(0Z7O6`s&Fnp9&rgdQ$E@sZ(!=4d}*3sO|qB$2knYqXvPY3d>?Mvc4w}`8g@YU@s zELTh4=jd|kv z=L*E_4Ub%8^T_t=RlYU;&blvv>@O^>l3RZ~kh4fKKm(S^JP$#WT<^nQ9{&KsP5VX3 z4s{C)8%uPyx0M40noYQ7l#jiQ#~+V1_I<~~PYL`(@b$FXt-@T(1UnI~8zq|_joYPq zS(Z8xl7v^gg^?+#I~})z-<{A`M&5MKr5us{tEv9V|;DUw3X9DRz^n=5W7sh4p{d*pQU?`i+^S7 zSJxI@6;>s6+(_k^%4a=d*kO+RN{94CstBI+JB@b!(o#8N!abR&b% zV?2Fp=nvVy#E)~OTie|d3`jcl1C9^ln&9-Wf{$Sc4iA}}Wxj5`2mb(Gy+7f`gC>D+ zpSU2l1NX@o>0WB2>Qq%0=S5n|G#ws?sOlDbp;>g*%!jzHm%>p=Xc9|@%!~$k;-c`# zmh#LhLKi&b@GEZD!{#U4=dWSVSFcKgPnjPzQoYwHdRaWhU8k*cUORxD#Ev%O*be5p zhJahexDG{N__d?C(&aY?2&3jb@#$X19$c*>f@_&*c!sUx!6%9u(!e;kQRSe<3m)h2 zuao}(YilL5@yDA12Yx^#bjK(4$gih-N#Q%G{AG2hBD82#V6{%16Z0R)ezo&I?PYrf z=Z${JW_Puyb#HP`GoJm;ch{)xV+=a9^gMCf{b^RqJM)eR9r5~9A8PX@M?9`Vu1C#} zL+Q>rs}e*Mx0uR|Msc2m{vGN$qjqv+P&ST-fq)9EHX4sM_A#!?TArxrn#bH zMs~V0?j&8Z<(e&pNd@`*y(+xdAt#XBOSMPLL`2|m$5G8#xl*4pt8bA)d2T@}J$if8 z4`yK|HQLM$M&5T0=l=k$U8w3!=nHZ!n9By8^kJB9`U7&z*9_NxmjO*j*`l#;&T zu;sIX&|n{0tr+v?m5jR}lzIzomk zhVw(Q02Vns1Ci@QHvuk}%xsg?6Uq+5iexeQvq;dYm*tNDV?X56go_2?MsNrl*>T7i z1JnHCv$fHT^(_+=w=NNQayV8!4`0g_+4w%`<4y6obzm44Y=;>fXWO1DjFMPGtWmGX z!--(@ByR(3x&MndHF6ym#zPfZTeTe#!22`AqG0y!M#CxP|FZFq9% zNo^v5xk8o;zvpwFas2tNdSo(4-@M#1Se$j^r&`hQv%5tu%ph&bFg|0(KTqpjHQ(;; zDaoiNbW*@w<7IYSWH#2rdI8Yq*YT&@AKr!eeBa{SxCJsYatBez=T&WHh^iC>v2CSr zMo+gs)V6o1_r6q7IwZh`MkI1Q`}NIS9Y+!W2?rU>IoU#QCAu7Bxlab!J z@5>a)SFzSxJcV}vqiY!UoE+qJKb3CyV)xC{rAbh>!?{(u4mjty>sT=}?pW2bW*d=? z3gmIrbI8vXv!RhAx-f2i@s%KB>CSkq=kLWDNm*Qx;U8$pVgit)lZETt4!+f;r8n6n z+}}FBe)jG`7{@{D_*6m{xS1M36oCjuRpUG!bNO`@p&T1hFEF~5Ax`EbjGuq4O+|R5 z#bnD9fja)}H%jhX{b4(Yu75r&JlwRg0SBB_UkW+^!<-+;){0tXcu>JI92U-30E{16 zuQU=g%+q|wkGc;|nZWvrpRy@4uJF1nutt2ekz8&AB^j1Qpa-nTSn!i;U(^MXfL`=>eVI@V6VYGuO0g^Ga~M4hQMa zQ%f{w%@Mlw0G2tzi~w*)PQw+8cOj1GgvngzA-~o2>ywf=>s`3HsY2}1q^+sw{vVhu zc~O#B5Ll3Ubm{f2WV$;CC#7?q8MuG!(9EN83xHoFXTSddTi&)>;HwZA@yA+NCaP2S zjj7E_DEuw5xEDvtSrkSP5L>Sw;%fWJhmB;~!2l=#>D=e?{VFJ7E{f8|u$cZ(*&iW3 ztKO_mvnro_=xI3*#OEQz@ z%#7*+fW602+uo?(O7o&eG;yqoyFo&KyNsVfRyO(BWfCN)xhlV|Pj1y6!?QxFtW+`Q zs~mU9#}%BKMpY$rXUgp+)V0e=xPiO2M^*ffldar5Pu83(6g zeW-6uQ0Vh>JF$Q|NFF>tVptN{_c-Z-YG;Mb!C>W(@dy z&NG~6JoEZeNOr{=CgLT_XF1wC0zVJdpLw*QLNHWyTsJxFduJboMx{M8Duo_dPIm!~ z;9wFt#!n-)HFpWyC5(@UZkqtdZ*VhL5AWc49Z>Due|Xqt8OA?Kt#XYn2vXrNcsL5b zmrk_f*_g&G&$*GLkhTDF3j+LxA6#|KRZ-^xS%5pa0YC#i{{T*vdge1dzzzu!a&XuL zanI^%wZf|AH)5bRS-UVD2>i`-$731BO|^4B&f6dnHg=rk{Pf)rr=aG#7NIm($6UZQe%_D9xjqQ~OrfK0foR9(MuNk2bZx}oe zUU{m~1)D@=K3|syKKzQ2B=VU_80L|nF659G+MJPxQH)@6a%nc0;dOf!6H=7y{o@f3 z+JK%2$vFLMTS(O;w(!l`S=3PYvpw@~T~S~ev&0yyEgC))$J zdeSmc-0EKbhlf~bGR003Vn0|T$WTI($I&pPJf z2-k9tz8$$>6cRdnS2=wgacWB(L6x5*(4{sdO=nv|7@l6N`p7dUeXw3Zmcf(k0( za52Xt^{N)|{ijI*3W6D%c1}qc4U=#Gmq2rpxZo68bfd*^Ek=w zxZ^&R&e~>;>!CAhB73yMh{p9|%nlbG{`E7kvA9z!!m+AmHV=}&haGCPGBA;`CUUz~ zgJ)-`864+|lg!JfnS#-r0G(H+dGFey!&hp|a*0HN?8*r_JhA0?>DbkQ=R!k-jpK4l zkN`R3{3@i1?|9=o2hD}R#DfE;eEm&cvM$f$fY5~-!i-~#_0M{3*=$|(*p|*oA{k=H#dS_vJXC?CsqJEH{ryyK_jYdR*1NR{pkn}|FsV|f_uj@kWdM&Czp zu@f?`_2e+XA4=tsBfR50pS%nlzsdl}VUGU*t!imjaz|txq()=|E1V~Do}(tQE+DwH znHq22#zD#5$UduDcGG_5IHk_V{o?M(3xUv%{pyr?$WBe$p|5WsyIfB5d2FRj#Y<s$7k%qbpbc{h+oakxk50px?la1G)M zsFmS_0=Wd?h-Nq)vBy(aZDPID#w}uWV%c1&B}ey<N1Fao_pX%@bL8J8-H1?gwATHR_%cyN_0|NgWtX zz-bUEz{gx3e+u*MDj6*Fv|T!7c+_nGr_2v>SoW=*D_pqKivw^%?I$V*Sn=u4Zq?^j zs&>(&DTeA2jhm3rA88Y6qwX=7UKBl9Y;NTS0_B>@S~|v_GfjXX@V_YHikj_ zz#?oeFgW02r%Ixl70!{WHvGsCoG}D#IqQ>L_LK2NwRxkTOM>ScgN5WV$?J@9_}5qB ziTqFEnd3el)7|1>lb#BFfBN{Tl`79fi|2%0k>;h%pzBhjoAdKt$TezWsA(e zla=F@XVU6~*iIA!IZNdBnUGRVHhi|J$kz7o%yPOfY=i7{Cm(?mqR<4PhAJ}KB`19eF zvp~$IB6tg*Bmyj}e81x;h*FGUgX7fCu!?DDK@-vP{a%;zYS84V+ zWq4#)3Qq3sPrtth^RGovQ`MO|(ssFe)5Ps^!*r4`U~&9I1bY7fo|U;C{}y0?3G z+NC~i$1VBy=zCY5TIl(Cdj*4<^ zquu@<_@3TbBL*$8?k78NB{&$*e)aUH!QFf8GK9Agk1&DPkGi7=j=T~p<6i^opXsnR z-MdI#;9%z-zli*+=#PSW{P&l(vLur+3?FMSIl<~jf4j%j*PSI6tCXa|eXzhROI(6e0jY1(H zlb+e>{(4u!{ygzK_qvpu$2_HE6{{WuV=2wcN7dCy~S<5I~;%xLsmK1BSWs!*@MPr=x+s-{I z#kYkLIfSb!g06C;^=9V;^*Hv;dL8G6UrT4VvxrY|l7bW^w{UsDu3Jy=-ObLoEuN^x z-)r!`UR4fp&;I~ky#D}u7q-W>8Ab`SCRq~Z^;b*U(mY^FQM2WPjAQlus>$#i^Xl-l zW#*8891IYkf%9baH0iYKZvtzk6?M3gwv{`w6M@?U73rP{NN?>3w~{1qxMIX}oM-u0 zMH(*;B7|X0p2=KPsQSpHrImKZpJnI&3hTl?yoeK)?Yw>sek9 zf-NrADC}*-jN5?V<$7}19V@faw7JAh=P=8R5u63@$8lb?B`V89;&D)nAH0e`w10-; z@n?br@*tLLYy~*U3!dJc>*o)LZ!W2=l-C1H(g6sKjzTCnEP3~@thF0+spvCWT@jY* zhbN!&iuv2e{{RQ|j|%H6b>Yb8P#X**;Cmi=_pf&kAKmh^=WEcsuM)qAJXNH8JJ%Vs zWkzU&GhhraKrnrHHKA+q+HVr*(Va5vMj}iB+~Wh2_4ltc@i&6Cj~43E8%rsqApY?p zg_r;kLGQlj^8R(*e$RiiF14uZam3I|63FAtYoC-ygP*(8p*8B#rACx}{aMdX4^mZA zK9l{4V%0t+c(J@e~9G8L;HSKzqt7oS8QPP0~P0g z0=^Q#p?H4JNYDevWo`$XF6=byI@hjGeAbAzS{MRB!Ov>osZK6VZD@NIY4XQ&lkqph z%bV-heKaUco)q;ywFZT#UHDU0jtx@X-YjIhFC2az_39oNvuA*Psx;o*92(iZ@YS`{ zc$y_`!v~7tse**{gR?8^t{&u-(amUo5-g=f5Hf>~26}##(phT4_8??_Pa_qBC&Q~X zyz?Q88+!xRwd^z{f+fcUpIXilsI+G^-96%orPLmI3{aBC+xpa6ji6fzmT;s6%LC87 zY1rw>Z86xPi+7__vceHlzE>aZV3af57#yKSC2IJ@Q#;va6?OVc6bM!gX#DR`77dAiO!kg z30C6TCs{H`*`2372j^al*D||jBrg}C0@Y(&~?G|Iq6yR*0J$mh0e=kJTQD-e`Sv9V+t8r)3nY4r>|m%H z?w+H8lb^=Cd@GZwB+hyg-LnjS`Xm#!#vC1|p7;QtrB+ksLc64309=sV5_$qa?ceKH zZ=&-i$T=uC6oz24}1jHc>3X_#X z$3jI(1n!O`iQSux+2{{`F;Tom+HIFR8+h{x4U)s_`BaSLI5P#^#1_s)}Dnc z?~umM5uCG-!~>Jqs75dPh#a%h=Ky7kVsN{;8xe> zhbax+SnGplO$0bl!>-&Oo}EwDmf4tuQ0i2ULoqn&3F(kQs!`kwK*&*v89UhSVl&gz ztvySN#d%~6B%^RI^J4_((>#yXsuQ}f?{sX44bmWnLVP8<=H&Iqmi8K7{NIM)7$l|q}z>% zcWTJ&tmcp^i-$jbN&&++1KYlTO4;z@$Ph@27{LI?*C!m3GoCO%I>dq#YL5sY6;MkK z6k`}(oqGOtx#2rmC%N4J04a{S`g3FClIPJ4`2*}nT!IVHEC@`xj- z>T!|jftu&Ea9SxAS@Y%f0lE{Pe#BP1#iWT*i7&Of9>=*oj}?{Qq+(UW0-N02xFWAGChrQ`i+ac&ASRd_Ts^^2+8-w zc8#=Kj1V%PC?36f{{Z!?5`8Hq-pE;;oDjrh@IOy#=)^+sv2_ybqKU&uN{{S&p$>;A@9*V7Eet#{(puq=#I`Qf0iqu9;gLBE8bBF%`>sDBLZj5XZ zgMzX6qq0_aYj$iY!*CCw>sD4-?OQ5|<&%+h;u3BSzEAvB3`*c?n}5bK8N=J!(;DBsUui8HvEi&OquI zkIYqh=X+Sg2HP0}7&#pA$;N-etVbj#XmZ3L!Ny2jfz;!k_~}tm>O{L0uJ_zb9$~!- zl>`O|qdbgtrd`63n7pNUK<9Q?ApAeZrxO_52*b2s92o+W_+uubd7a;6u28DBPTol4 zsKsS5^2Nca73H=jMOJ2!$jJ;wS$>@=8KOmvyu-ME2`81n0B0O}^HwKKj!xwg=Wz1I z;8czVGsRWjv7AWkRnBmr0731^`c^gvPCTnZJJwh({{V3Ak>eR=Byc@C_cb-V9&S9C zi)>T0sX6EBay!&l7eHN>Bo!&OV<&Mw`NlKPdMw`vR&0mCIWO~ja0k||*GM9glCbrR zhY0LL90JU_1QXQ#D$F6Wp-b%?mBu$`+Mq77Tb90&GN1vBjx$qlke3^oLCXSp<2(h zOjTRB-AfCQN!k<)@%VmKdhY4tnfEwCG0^bDXFt-bNhU4HbyY0elrOKht#1a=$!oim zt>9O6-mW$=$o~L8jW*&kNhL5do^TE-StO8>XG4`IIP3oa>!@d9tCD#?oot?_e(x@T z${d#a!1VQ|%)k-5BY~bf(j3M?0D!}aQ!`2cd<-1!^r|7rHReXZG0KD2lTsvmnD03R zXNpGLQvrtE+0SF2{{U4OGDxBkxE_FYrbJr4px^`m;N z!_;H~IX{hS_+vnOyw7-rZQ#QbjvF1bQsrf-tBdBkJ;&j9jx9V3@Z!%zhEFc$=3lkm z$M`n`AGd%u>Aa21+ldILk+^owdV31|?eHqy>%S1}lYiO`%*cg! zY$z-8`ti4~rfcok{w3S^E8*q-wQeJ6iSlfafrpc51+)A>eweOm@aAcys5i>BTC(Sg z{AFX{rMnZ{GcBi>`%Xa(!N3^mPfly*uZiCpBGxWNuZUAy>oT;G zk0pLzoaOLOQhmK^TgMjgi>qstS9tC+>+>8AdgmSS{&8MUYkjg=9p&0c;Pfq;@4PYM zKkcs$n>)!LHp;;XaC#BQ{{RY!#_d}}DzxJTc(Tpne`&>+C`u8=KIr^<^!;ll1c_!b z2{s6ow_yCr1_lQhJxyuc!zG@W(oMLk9p5%SWgU1QgQ=MR*GYBpUZf-eYpW!{bRcZGbW0BW+avwi2-Ots3O^f^5JYB zmv=+zY3AI{0Jua}0hn{2aDB7SgzO4^0&e1?cN_k6QBAY&L9ADw5aWf#pKyUsfF z?^f-uQcHs)0T_gZ1Tet;KcztPdAAIN?>!Pv;qBWT(~Zq8noLlJNRf$W=kA6%_T%vD zQrSDoQ~+HYIAsKp_*1PJ{?C}$0;`@*7;;DX`qQniqqmJn`PohtRGeUXjxu`C+h&o` znyD*`82o}A^S39d$8Ooj9+gyH8JLL!sU1~UCmlU_?NBY^-T7u6{%ZZw7~uzh#;4QA zmnf5Lj?w};{qB45(y`^`HeN=A46cx>GsMVt`EJ3mM@|QBYD-H(2~?1vZp8FC>Bm2h z6$3L(ati0~72co^n;%Y`RD$K9xG#D8)gLmbP+J*Xoa48pXUc4>ix$?7@;ks|X%rB0 z4hBH!$6tE0XY*VJA@*)6SZ6zjLIybd$MUG{r#A9N8Ca_V<%k=5anrB0T~gr|NmFAW z-IT!@&TvKtrDG{{6r`+694?`zy2x2TEC)F~_%$r@n`r#ISz`qM00B8-2VT8DI*!{= zj&<2Omfjm-+!s6#ZaP%IW_eH~>{oGCCAsW+5!b2fSv?-4-ELTfOESh{3%Qtp+=X5T z%bxvu)rPQlfmFG5A%;g%J*thtlGZR{$yLY-!x_dg(0yuc{$vOgxY`MfAvsWSp5Nyc zN!U);(6e`@gmWZ2PdHU4Ax;KK{JK>+=45AEcVs($V42|c&QG>QPWI%6WtpG^g2W$g zq!IpeRVUP)W12Y7TpVusyYa>`^s0=m?k3knO=G4Q?SorNW{|G!z#NcIe}8I-f~-P@ z3b+T%B!xHxka5?MQ`uh@3h3V}rgMztxcr4NFNM74u~Y};9l*v2@7}c1@BW?6)4*c@aKMNKMhv)4p)!CQ0P?!G5!+Cs>UJ0#9W z?$NSa)8#!gRBwE3tLyvX(rwn-JZ%m#&IfbH9-Mx)#rQ!Xc-ChDrfjnzY$-mQe-G4G zuXrjOJ6kxPP*?-F-H8bSN1)H)(>1&>)Zp&*E!XyF$?!jh?(Q`tPY~QoJelE6R1kZB z53sLD)BYV=!2WcoyGv!&j^Us05C?C@xqVY!ve4s3)8voLKv|?z1QYe`Tvv*IIchgH z^7#y+G0Kp@kTZ;)GtZ@UMhU-l7|$0c)cVuF+V6*a4|!`2uGZ1pvTtTwx6S;y{*}@A zhxW73{3kPwG+VMvMqA4uV0I*OJ+bf7zdYfZ-` z3txO;4)ba^1}!xNlA!A9cH`+V zzG?+bDCZ=eIrYy<_^-vE46nW(c)Cj+K+cyDxsA|ZtAIvMG0=0*_}5l1B8?lZ$U84SO5&(o!G z@c1h8O48ufY)i{&la^H*)Sme1jy)+Yej-hw#EWw6ASsiT#(C;U?a!rr#Y{V=Ef1i> z)m2k)XA^gIuXt)nBfk;Kym3SqfKgEwCP#2Q}%MnTN&D ztj_SPzr`AVA6`AawdXpwg4t!2VGLwf`GSs6bRcogGws%{lw#fOs7`G^c;&A(32xH{ zdAEky^5pG3cYiA7yhCiyE8*m3VS>OByVU!d+VL!NX|5tDcErQ4Bo4SFeQTZ7FC=S) z7P|mf+uSJ5Imdpr*C^U1OICAIJa#j(%x0C3Bo*88_XLh}TY6Q=J7RKJF(r8*Dsg}g zI`VT^t{&o0E_}e;oyfV*BbDQhan`gnYw0Dqm^p2+j3PPYf<{U7=QWL0cpVpd9*f~U zLF1W9=V@>b?tME~xcnUP5U??_Mr3gNk%J$Ub^N*OUpnZQE2hmFsC~{(GJf#mJpNVd z{{Rbh)U>;ZNOpr6MF3}RMlyNl*1TV4xwSp)9Mq$!_m9B;02R-1N=9Y zkQMhA!5A@()>D2ghz#c5N_T1LIZv9wyda z@4%L-fsW}VH~^kHj(rV%N8e_@-8>?AKlN(nKM$bS7DrzID zaCnuYvGgZ~z8$WU33R%4j|4V8Yy;`ux{GfINM(&h%abS@V{DwSxXx?AJS*efPB~H= ztG19w%L2qUK+bRo&2=mBKUKNZ1nMGb#Kh%Ns_%2$JTu^# z?Jib!2ITL?*3L8PK<)Yd71LRKB^t`fb39O?#?{VxcHsBxUJv2FjoROaZoJ99cDVUM z0!sjToc>(}b~b;sKBq033z$5!aOCg~)IXkU(OB41S7`F*lhs-)9=YL9fm(KzDy;Ul zf+E}9iiRYgd}g;l;Rn$4OZ6MXXb$2RqZ;xZ7vf&As=`wG<~U;O=Aq}WL7dm1_+LU@ zQtm&qTs_ioUEJWBs+o*h(Q0x$worGJk4exxQua2Y9SR)8zsHVirtsdD*Kg;SWtvV- z)i8pjk)@pw51l4v)E#@Sy zR-_t|$#J$mPg=OrdC~SEsBuz8dc}}OdGdmwXZg}!$RvP*a^z%CmP-7Lf;p%)8-FO} zN%?{1vRu)3EhxzG4~xDb8YPygb9@Y{n?iyzJNEvy^B2c$EgmSRw@(BBo9aZe3~N*nmp5a7Ry}s?ymsZN2vdz}!LP3}=td zy!k}cyByB3r5~_J0VPmw+vqwU$C~ipi~1j#4cE#}LuGpPBWMTl>+N4i-)XNslOfx< zu5pi3(>3CM6*Q$1$tglnFb~Xscg{Z^_0dgUEYdAWS)UkuW3-Y99I{BOw<9>{I)V5M z*ULUIw|iYqZd2z_dSsACJoY>f%D%?<&!xO^x=6xugAKHEkT7xUkO=&1`19$7)vc0__3FMcuS@WdjEubOil>8XLC5cCj_QlU&2HSjuD(g(Y`@KA6uViY6FnqH!XYVaXW)amE1RgqlQ| z;xj6V7`kmA`UuBg#;1l?DjHbAw%qxQ21&@ybI|ms&2l94&{2BsP)xW*8^|D#J^FGz ze+t#p5eln@EM#1+NdWt(#{#lpkXn*UBVg=3i0zVnD_ck|f-foIk$~7Td*u4`2l`Y( z{MJHF+nrX6F?fms>dNc_A0b+;T5h&Le+?KwFopK>$P`BpxH(icXMrU@zrKKD%E zk4oH;1aBaS0^vy=jyr>%p!$1PR8k^MHKBIWDQ0OJR|Cu7VCA#h@WyK5&xwqYE(5D7 zs3RjJ^MHL#Q_*uU+N?b7xl~bwAY-n2o=se|%y$St1q$wsl|aUDarhioO*F}enYX4S z-(^DkJlOtCq?~bqgN$>@t#1xoi0@t)w(k4e4nQN+R%V=J`#UoRE>7ZEfyw$0U(%=2 zBz->M4g(TKTP1+VcK~ z;3Lj5Lms`WoYUqokX)cFPZ`ce4^C@e!DigN-6VH+ zZ1Tq$C6qAETae@1{P(Q(%3UttzF#D`J4<>DRoHF>@vC`(M3Io7=NKmk9-S(dpxL=c z8DOimc?1FvPpS2*vUJ)sdMh1Wj^hh0$1KaV5#3%%3(w6aowur1p4B5u-yZuk)Sm5NGS|XIM7K^?dl|s7Q6)M26 z-OmJ**XjJLT15`IU>FQ@v#9Iq=tt*R+AQKGXqa&zIphz2>Fw6ElId>M9v?G!19F4% zpKs^+R8WU4XN2y^2+Zc7f&P zLk#xMsQ1Nf_-p2K4q2vL(&o9N50+y-TS$ihFdZB4^s1U@DJ+45 zwgCWb0Oy`F^dhw_t<|JazFm)>nJ^a}Fi*Az=TSjq$*DAQjF&@#4td6Nky=)lxG76Q z2;tkb;TMsGBy=6U{{Z^*`83!Iu~n1;c=>8r>`Y8dil`%8?jWCk&!t^mEm=Wppa~q* zLP}9g6uFW3iE!xlo2GVe;9-tiBk{-9riGJzyO#xk*rkBWo`4>>s&KlaBwYozY4dz5& z4VCGR;Ezs&+N((uw1Ff!60eozN1P{}64MmF`hsl$H*>anCKse_Dca%%=qVj>4vOl2#5#8*nqEgj$i7^MYlaQe zJxQ&ibG=9&BfJLaI+0Fb6pf@1dw+c>*Q%|gh2?$N$6z}1{(D!W z{8!cOwKll8-{m}#124;*=PUyH0i0JoN%GAdcqJE8o=2Kp-MN-!MpX*r1ue-Od({bc z%gI(5CvnDbII9xA>18Zcn;+c(k8gT`B~7^HSm1oW)K-p1vf5>Ph!9B3qyh#8d-2mB zm1+2LPqC=e9C`I?}t4OBNb`E^wRA z0|SnJwU2vpCO|>Ls6Qf%rbkQ?G6CnBBJZ)o%gF3E#1`V#Vj{#$SEnSmPs|+tl$sp7 z^(;-WGLWHx+l&sF$9x}Nl`Z6Nb0}sifIc|FsV5?Z{qFK zCbi~iSZ--Ac-+?ST;4B`U@)ozrI$ZWp5)eIUTq`Gl^RKqFx)sik;l@kY7;f|(;2Wv z!r;cED~x_!D<1Ow9zv1Jlv1dSdZ_Ag`2PSphVQVc+1)Y?RhY^_QSy*kdMRf53lVYMsfc zrl`-fDka@?szKUHjxpb#)|o3SJ3K5^c?!dZY~=Lq{{YsaA?8e45{$}Q2Ll=7&C0LTD`@Z@5igm>PV#XyqyuX(rdHVJG)kvaQ zir}`uK+9l$AI_fYuydV`hd+BHQfRn7=AM`YVHr={I7b5mo~P^US8PID$%uqxf_H8p zu|LpzR&~7F?iMJEmu^lofOFp!ZrU#*;ao~nA2#5;oQ}gD)stH4cGE0eyii_7+hAoW z^9cGA->9hX3N%YCxFF*s9k@*OIUhk*F1Ah+cH;=+VHhBur!^bcCEd#*$l7ufg#>o! zIjC0Mnlx!>lOyf>ScuM7drO_WhXa9(Wct?NwGSyqMGh2|$j;;3@!yeIS~IS3AR%^> zw2;Hw9CYhj4vljXMsi#N<%$fRnIQUfu1UMdn_Q_bnk`|4eBUfCNZrQl^&LL7EzQJn zTfBgjaK9sZjz9XXz}Bo5@&g@8p87;*xF2@A;Na4EWVvqN#Y)>y#%!Iy>nKOfSjmD0G(v&GLJO5kt5iC{{X2zqw%dRsSNW8 z(PVZ|OJE(max?hUSGES?HSz(LFgFKme5?rV+|dCwVx7OwW)uuCY>lHUfyZC-)~;Fj zf6czhRwahwd0*-8$KytiqRD*iE%8{DS)?I`;!bz~es!h&u5CiZcNH1O3-|(Y?rOQU z9{Mv**GWs0=FNi?IL}U=xCHZBHX58Nh7g81LXxe50UrE&*Dr51 z@%a8Vy*xT?O|s*oJFgEpzqyKIbdrE~IUuVJdSlYP6T>>;_jrrl&e6S? zeS7^mrhHiNqIg=!x7lIyQSp$#_TwCL+m75eC8iY?8`Gk&sRlp6v$5O!8 z)E}}xiZt&7e$bYYc&QfGNz|<*icr5Is8q--KLN#fcZaO>%^o;Jp_R6d3FkTL2|l3o zuSB0kgT{JEOL<-!^A-`1{_vIoTO?3lb^%WHSjm>Qy=!9#GOuUB}7cuZI_WgXuweJ^uiw zy>L;fUE-&Ac)gE94~L;n$_K6f$leuC9e77oveXe|gfWN$I)Vr|`Vetm3Hwp_0_WjQ zxJ9H2)=UD9319)v59j(<*Wa-&gw|gS?zKzEm#O#S>sqV-3)mr7Bqbo4$q(ta)5>38tQs5?a+z{wpB4^BWM9@!?n zPr~qN-Y~Uj=JJf~gSanIjO65J@~Z`q5l)pzCmYs~#r=r3h_ghQ}y2j)zWbIAknucvSI7V#FCR&+*a;|^pj4tU2>I*v!UuL1sa_80V0Fn67%&B;#nFa@AP*_gT}D0B5%dvYdgC2LAwD{*_Znh{_|D zFPMS@BC#Pxdf=Ysx4c;jE}-Hz#7E@X(el5K<<2>*eImi*lu71oa9k>!9Q5ouXO6U> z?FOXHqoP+jT`776^4#Z=zr)mw`gAqZcvn+++vFw@oaAs@ACK0#tuoXSnQxX|%)=Pt z91rvBS{iofuNhUuaW^0ycM*@vwQ$BsU7o%ge3oa_{{RKNXzgbX*#k((8JjsEZQx*f zAI`qp{hoY5J>Xv@h*t!b-S?QDp4Iua@SDX+rQfdkFzg2A3%70n0A&3u?629A;u+Ib zWS(|xrduo#e8@0 zGvZ~mi5L*;@odenNCF*zamZ!+3J5M1=?f6q1b4Xc?faT}9`$2}|0J|cKQbz6Cs6pS$hoK+}wz_9#Zdo>v$02j{>T!?G zy~E>oz}R&gJ=;pjpOhkxoxT463i*>=@D8KkeL~{iSmYvNS$2<~Xy>P;abob?p7al{ z%WKlF9#`=g+WC$383Z4|r)Vby{{Wp<(ux~|`$C^BmnDONgWCZ9HEKH-y}mLgP=UGf zr)VCT^~FJ|=yxk~D_ThV6pTis9mC)CuNJJQ7pe4dakY*89>UH^BO*X$4Td=61MB&I zmFS)l(JU{Ke$|zf`giTq73NwklSv!-L?0v&Ko}(barLgt!dkV(sw=W@i8*G*cK$z= z5X4S9qlUF7B+`-T`cHtYB)V4$k{G@~4{_Y~{#EFH9`II;EFj%k#^3+}7&!b5c}|Do zWQKGhM&<% z=S1xUIF{+)y5=LEItt+YCt)nl<`cIa*wyiV)SwcgqY82k8?>M%m+*y(-~R@J3i^D2QXw+%2y#R z517AgjV^m%D&tQcSdfKx#&V||eJkc>vsWr#a|Ro58=e5i<6mq3)?ObPmxH|52N3g~ zndx62-P#W@gi;iJ!<-Y-n(Kp7rcJGL3|bh**_WUL=H&1@)G}F?iIsL1Wn2PrjCCK% zw%*GF=ibB~4o5!bqqwzwtQ}Nj91;P?Pw=c>rfrxL>E`2nYn00GIT*uxde@SCPSVcl zBxgWEsR#xEAoXw7y~0akckX6Jd=Z{=_}7|zP_W$uiM@CSA1~hOPETwSKU&^`-5OJ8 z#eW!d;}kOl4Hyc<2N*5#smK0225aL#jCKav`^-CJjkCj_kxoaZPyYa_zVi6jrf)}Q zjaz0^l23jy`u_kb_`~Ccp^ipJ!v&9Sz=2Oh8PEI%zJC)QOjd?;ou+vW>n^}pE3&IH z4Z~nP@sZQ;{#BE5s*r<#7EBkAs00(+13sN=YVIK`gnVEEf8Zy9+v{03QYEIKVPa80 z`I(#_myVbf*tL2c@38{6ohH|Ek$u$!jm){})7Ld(eX>Xur5TTaInHzc0N4E~kjP}b zK4?;MP8%UO#z;8&Q)N#zmSr*^^9FreC$jo`=99Z3N!--7m_Zpyw-8i>#sSIhKPr5& z8Dvv9+(G~Y9!O$2!0*TDL?KbpW7{GQSxFm@Cy;w%y(>sWYkY^TqbY*sNW0a-b2OGDdxK`d3kF35`Z1 z{pzlFh2ZCoarG6)XshL4l-=c%ff+QFnM3*;yy zcdE8ikF|jlJ~rXVQ^!uVpwSouZCN94;~By2$jCKzS0ZZ;4%qf1e5V^Q+k0eI z{*28Ou&`zwL13T)IN*QZKdoT+b~xT}$V-VBWS%>Wk;hSy#dYxM{{Xn(wL+C3muwuL zLHP4o+m`5b;B^*drL=5qjd8{g-92&dRrQ$!lW$glwgU~P1E(B&;MDpQc(!3P32?_? z$N&+6z^CdKz?NjpabbWB7&#rtHBeeKTYKES;oOL#AVub2k+V4MjDBbGtv1>iD1FnE zFP2>Vp!1K+`c)4MMABOX13NcuBd{BA3HQxw&0^kZ@C;{Mu)F0Nc!7ENtW>B)Z zUd~mD>E2I~IqCrCBkSAw*0z-v8B9zJZU`Y41ob^nryTp%RF=&gxZI>|Y%25e!R>IiIJI9Dc%ci z!90Q6^!%!y*u3@IA!P#@SK}jrkUa)^R>y^Yd)JO|A&xBcJ3%=5_RVjKkM8-AwR5poz_MTn-=-F9V1Y$VGG27n*@~wE`R@m9ywl)7PKMr?iMGmgOIus2L1B1w*;A*K)#$*MO{>3Cnd^K7{?ubY8&Yu7??ifkuVul zVO!AtIjTl`Y9zS0Tr(KPaQQgN&p>;1suwnKru3As;|r0`ujN+nCx$?Fjg0D4u_v4k z--)d2q%wTFkInM{N#Jw;0P3x&nANDFuE^VLi?tPr>`CeGQ9Plzbt5g1R;IR;AtR!W zI%hQ%#uVlOCNqqh-EL&v<8m3mNsnCpb4)3?gCIER#Vo`tZ5;S$RusSZyyYWtXI z-lJJYFo2BHm5X3D{C;_%qb-&>1b3y3mLofe>7Pn276mHG208k5rE|IRNoLRE{(4hm zI~yYNQ!IMp%*ZfLkOE-2Qaf?95EQaKoON?N_g&RJvB&r<0Nw`TA21$GBWM z3z6SB^{%til`CH5bcf2hj7~&_c*c5yYTm?(P|y-`a~M53^{7!nz4HzVBV=>>R-4=I zx7^-X^1%tYK?GxQ7(7-}(dcBU+V&IAX;VH>ltedfBb6TC=e0iC;@?z>n&spYGKCv~ z^(JV0RTYnfn^<@>xelW6&SOeQF~4bw@Pe?OoSF z-`VCzZz&Ixox2Q#WCjPXra8q`@f=@lhRb^Gd>owPXu#?Zw@T89qOxMwFDfH8G38*s zanl~%38;KQ3&_f#%H#ur(f!YxDj52JFl)kl$MY=zgOS1gg$j@&~ z*F6MF7O65%6L~?2$QZ#OXBqq}Ps3M6BCy1AaC)3&k9-c@YAtrn+gI|_04llRvPOM< zxa(CIwYidvl&s8qNQL7CMQGMCe)%NwJw`pMTlgMp1bk1POQ|I8=dKUdqSNM8)Yx)F ztVrBWF`m3uh3&M_T1K+4B0rhAIXP_Y1k!6$M3d2&vMGYgb4TTsCvG{;an^`-?#{=- z1QDE;IO|htMBQ9DP%!6&T!W6>8iWYQ8$^a&@H>ynk`s?BvSV!*&N!UN7pJE^sain6 z!NDYudFlMKO_Lye<-sk;^fcJZ6(A9Uc{tDGL4OW~`(TDd3QD&O6!VkDc>~+qty_h@ z&4qZ=4yS1#e7s`-BHKBkGp6a0exGz~qnfioL06+@vZwDtQ^m_Q$P9YiBe!N%odq zss(@bXYZcAps|1gb-z$YDaP9~nr_j|~ z%NW)|Wq&Rc^5ibupTu$h0N1Ha+@!8#-OI87kPj?vKPda0`kr~J4G!5u#IrMjz-IYI zcqEK-Rwj};nUJw^jLHVZ!(;K|>rq=v8GX`n4-7#A1ab${-R##u*bL zOtQH=7bnW|L%A`=M3bTPG!eBmCpuwZ*_oO@y+>>zBi4p5$ZdD;D+zwUzgw z1@o0*$iT*113Ag8@bSF0H04a?H4hFg)Xv0@+-_WlI}UjV`P5oHf?eFk9x#YTUQ*mB z^<$1}Us}3~Ju!yx!GK2 z_vu~6foJ`gu^1d62P6!EgO2{S)!uv)kT@2^;GC#zWFM&hqO>&Mgu0cj$#^4}jNt7B zfjng69V@<^oa1M?#X1gcIGuOHPZBMpF&EqbeV}f^QP>gb*1G*d-F2&JQtTO$RU{8j zOp4%i4+`Aa>M#AH36CFi09zUF`d4+LM)&s$W>=MqVYaS(zB=QtYR$q?mgH5z^DR;0 zemC&F*MoI)JTT8KnS723WKwxtW7nLHm8+usL6%!*TV-J?uFy%|$tk`U_I< zO{a)7=xy~&bLFyt2^ehpk&OQUD#+1)V~r=odepZcYL`xjB&Y{-bK9Xf_XfU)3yHjW zS@YPOtD9te$>J?zQkz?g?72x8D?ScHodL(W^shnqZSaQc$GY5YsjbS}J7M>(-J>0c zdipcLKe45Uh4n?bypA19VZdk0M~=rh&TG@6(zJafTdT!uZM8B80DyW9E2j~QO3DbV zHUZOVvz_qogQl?X>^8PX?Dh~Y6;uGkU}GSA*O`1~_%fQ~$ZX?wlW&|GB#?I=!?y(U z?Ox@s__t4o!_RM|W9_?vC65^C!2_NN;=IdQ@g>fsa$&rg<6Ld|)D662s2%aryofGo zwtWT~pC#Rone`vp(p%f4dxW`LX&HiWak%w4#ay)gpFgoTypfml6LS|JFc|B{1mq4Y z+@!jOOL*34L~*MhojyPSJoo23S0&?*8ABLgRw$}n&Tu5n(?cjB?B z233%&zDkA!`gF(~;MbFUX4kFcv|BjQS7U}O!z6=)f`3})tAmX-ey24mvz(H-^RI|R z(`vUHK47JzI~$yX`t>LAto=U3K{Sgzf4d=$6mm};J#k${?vmSyoD~Rn3~{&)I6sv@ zbg^%MkC`JLV8P>_nf+_QuJvgi!j8?2h7e*{j$;I5VD$6^eiVwbPKaY)_kcKHbN+d! zT*fWgyre>^IBubOb@l3LraZPvDGbLMINWiOp5wi8!5!3{^*b*H-L0+qHN=3g@c`M$ z>@a)v{Ojl+gg+71*U1yG3P#r=mYe{s8<=w-Ll=k;_QfUNhVtdE>Vp_4fY&?CIi`)KMgu zkKS-O;8*0w!#@%m7)8aL>aRS3equ%l9-h2@mG?*Nt?^dqTH7?TKavXLb71uJuS*Fl zXxOtX8m&RjJD;I?1^Z8Bw>i&xW}^bg8CU|$F_J5t_+8@*ZEsJE#UO02KE}Hn`&GEx zEJNiW3hcx|HFMyf?&ZqQGx3+hh1Af#KxFxlA6oFQi~j%#WVjN~C9-^|P_bO_c>e$z z`nSslMn@ko?bfn9L*W>8dwCM+Kn?0RuREWWS?qMusVZ{jN9R7L@Z8(#Q=}0rP{;F- zsf>2%-=!LDy|gy-tdWTV0s|A5AA#v#Q~X@`4{vh}vZQPqmJFO@@~;xqeh=T-O)<6G zEK}zthXaq#^sg@yPg}(LoF+Y1R+~8w3TfJM;X=lnn+1Hy@>P`ge%D*!NrHha)6ll6%(fm##;2>$>CVk_bOs z)!v=9Jjuqh>Tl`(4ASl7RJt-d2F`QVy8S71>v)1&#CE3u_pdpc`r_*Re`P#sy-Q;p z4@&5CTlvMYYk(0*$IaL4iXn(e*&~{@8ObLp9YmVSX~mWS*Z5fRk6N*7;$OGIt!mgQ zjDmCe)?S~d$#z3bf0f&Iy3}82SCPq*3}kWNJXgb>JheJci*J?%0PkQ64CCotIA?Uxg!eh= zz>WUvbjZ&`Oq8bGyB0-p$tN3rm11H++^j;5NG+PJGVW%`hA``#o})khs%*uTyp4b= z6>I`I?OZp+n>-E5{?%*_$whqT2vXg=j7~}J*^_YcPO^8C{q#Ur*=NFOs}s6LzkDnZr0xvz|VKY|Uwc!S33b{}jmGwZ?qYsbv} z%Bim8M%?b_&)TNJBF7ACw8xdfE3tUb*SF_cH&RC(%CU^?3zYeREzUE*KKvhA)zl>O zqcUbQC>wwWfzX=Dx}1q!*&~HP4g#)nbDmB(t?b~A`AunJmGZ}N8t)8?k-3Ruyqteh zewAdRTiiN?3nV}chR#nMbNuSP!^UJ_?hCod*f<<>&OL`}TX>jTCQ07ygYx4(-(a$f|btSY7vJ(Q}OL zJ-dP2az3>?Un!f)4)SnI9AQ^IzZ{HI^07Nzv{@x$l!V@6W!xK~AdmjNTF?wk$>vO_ z%Z#8b2nA0;$6l36-K35kvEoS%N`~YW7#KfH)cQ2?8~0mrmIQ}mfC2pKRBfp~XGv)& ziIL}vX(ViIz~_yN#T9isrjy_f!h{+(1eQT?WV76Dnf2-Vh03Lu0 z{X5qydPPF)JaRg5TiR1dYy!B6;y`#E`NlsEDO9_fB63vZk!}m6wYc)` z6#TK|E_1gecJ6uUT@*_3*7WPpKh7=uFFaY z+Iw)X=4Wbx2tkEAv+KoZ;?$9x-QI^w;cEtF-E)GBe9pYLIKlMjwbn;q7%`IoZOJ^G zU=RMia~=qCj03tBkc<*|Bjp2up1l55+L%@021!&B#g%qva^s%9!mg6lsTTuh!#1&8 zTB{3atbgZ zBuLj_Va^UadgCYbsH1;q;yCFhnODO$;Vxv0AqLht?7S%-kNDR?EvrNGD0AhFxX<0= z)894B_rZ%wXI2UHl$9 zf1gTtxbjIeI*#XI9Eh>AlI-#h=OKye0qu+mv7*Qht0Jm5?*{Rn#N__~O3sew%(RW8 z3e3xwP%wJnk@Uc;8bhD$e3v<5KY5%3*kFDpx@+q}npF3oc2SmVHgGt=`j6I@`F>%RC6ReW9G*X1Qp0OO6&LOf zyogBzVL)a;S0{qZaB_b3}a_XwG{j9+jFxJ0|NZ)Bi|oS%CbJ!5c}o= z-Twg2D-12yGBJd$q4;jZEx+!Zw1q^Su9Ytg@Rb#{x!=gf8c7R{>LWh_t6#&Sk8>NxbP za4SH-sEm1Mbc7wloMVIjc&ZVCphq;(g=O3eWZ-@uUX^Yr6U=m#lWBgb^Ao`CX~Nrz zY1tE9FQ05xUur2KSP}`xJPdJ?PfBFbol*(ci=!K_+#DHUaBW|lZXBr7vx zp8Sv2k~@8iBvJhT054K_1K-fp(&l;GMiVA8z&%gBR=3ZVsKMjT;kBEI4myrKkLgM3 zwJzkcI!wcO-L-fu*%dIwm?_WYQpdj50UYlmGbuS%8v>rh)hL}< z?Yf8-BfDcGuO6ILfQV#VhS`n)Y=Ap+Q6VSIuR*qfOKrANWgC>?v&hH!%@Nx&kC>|$ zlW|Pr9S=FlAmH;(a1u$H)k7|R>m2;YuS{o(j!2Z2Q;6et`AHdN;BZf;6f0~?N?H-i zJS_lWxb9L1s*>G@dZ%{-uJo9a%g#@6&*jwB>D;V4O1N{LI0NcEsxq6z|MURIizVu)=)^#W1Im^Z{6XB*2iEE6|&V>ni@m6<%tB3 zV@n?>*~SF|Om1>MZlZy^GDv6g-Edq0*<+q-*{9VLTkutz+JeoEg;-&m<@NO9ykaNZ z?i3$0^fl@q4DKY1HpY}=MlNCX}_9Q}WtaATwHsrEZCRe2_kb6G5;1GRa>0ztzL zzlBg%4azp&dFHmYyD;}M9t!R_!tLA#e10{G!GOsGf=)-JbV6LwO!^wF?oDX}O(;nB zNYZ6g4!rSNQEZNE%43-$T!Jz|7#w>Zl}kdqTYJAUf4GAq9gn?Ont!xqED2Cs?(N#e zf3Hf3rzc}1-Sk1IO4)8Vk}#(qbZ0)FT7t&=Y@sD`cq&0W=O^^!eQDxW5WJBVJQZx7 z2>$>%s(0V5=Ojy|TL zsV=%4HDKI`E!nMnLA6FBY~ybqDI9-VwXGrY0lXl08$)WnJ&{92|Zb z^sa|boJgw+yBQ<^mLqV;Kf|B%&1GCTO=dTVS;GK!p!>tG^`~6Q!Dag*AV$lBwd=x26E#5Ir%9%+!W2nMUP6bF{WYd*`=Wn5!1O2rpDARl#02WZ-1}6&B?YmJRa} zj1R(z<}zhmt0x?NDN^BCP;9{?kTL65VO=>i$?dKTo>CPgXOadFPAgsskVGRW;lmQ# z@;+nv@%-y9)zpSCg&FIS&<{gentcBNy9>17jF3L_a5>5PR#VXIr=l%eBzyM=8IU;u z4y1dJ>sKvSDOi??*)jKk#t1y|S@6pu+%bp`oSp{+00&c#de*m+R&%%@3K@Q0066YD z^*@z!PfblUHMIGBqJ=;p6Zq$+6}x9Faz;huUnGQv;A6K3kL6tTsS9Vh*b?L(0StHp z@b#_z8Q*IORJvnn1pfe@oO4`~Z4{N!dP`+8q?TOQ#RtyF^jyyjI_!xGM?ke2h>3V&# z#TVJ4+}p~Nk@}oqjx$rm;rn~gB#K8Qxh&(O9A~F)D=9CApBqM4pa&=v5;K4}$6lw8 zde-pm*PQb)*fEyL2LR&$W3lVrhpHw~2k_90GDdPFE#;|SnTJk$j&c4)Wb1wmOX6d= z+@Hiya=iuyNav7$TJ02sZ$3a}mur3F(GPCC9Q)K7jL}T92|Tt6ssa|zm4JTl*ELXr zPT;mYzJCXmZ#vdF4%Wc~BVah~_;sWjA+lzc`q-U=OSl14w5YU`g;wziffm$gOEr&S(fxzpW;8M%sq!yc_<<)+6_0MekarxB3 zn}ypqvP|TTkuIZ&r->b7-Sa0o9AiIIT2~$rYl|d%NQwo)13mkk^v4FeEe0qBlOv-R zlyz0@GDs!n%y#D-f#L2uK6APx$t)NbpyHY!=;aUNJFdzy-G# zK7q17PAf`nC!t)l?Wye=55z44S+-WYw`X(86~Wp)NgwCwTl!Cq{4-~$1+WTD#ubB* zHshR;f&F^dk6QR7wEk_WNYReO4T45{9CRPyT|J+}7@)O{1i6+>h5>;M$3k(R#g6ci08N&j3RlwJJhB?a~2Tpyfu(9}FR#bf^KiwQK^?y!tp46R2?9(}%DJ>gi z-}qU#_i{@gg(64ech3tDxaYPYxo;lYq!!XG^Cr|sY$4^G_iuhH)x33a7>IeSo@gJz^A$Pi$Kl$%TgEpt zUPjRQQoM|X&hNNXo`dP{jZTGuWTPv2PY3<|^S^EO%g@!yULEopu_*Nt#0`N8DJjPy&It08bxN zoYy_6g?4YUd4Ms)Fg)!fa-fcS8uBFWk3tmM)tEO{&F2LO3aJBjKK4gA^r&u#jzJv7 zwzF-+09c>!>CI_eD0Oym<|xKnrUCUmkN8%7^|F(ch{#vw&m^J8Op(YrtSoe`iS5|R ztN}y{a6vfUanDZNRh>6jO)FATxONy!jEn+DJa7-aX4uR`yQTsFxM$0Ez!>SxUen=% z_D?X$8wJPkpO^**JddFKYSOms?!(O{u6x(Re~1?mNo<#DLPpBbl5z<+8Rw}PueSdH zW^ahnY8O_JJfUP&Qo}qi9sT*Q&VL16Hl2KlEQG>qSn-j!^XJ;SX-ko&dvQu|YW|1oPwellZnlJdpa*u~=Q*#XG|AHL!(%oZ zXuNYF$G06T2*;Qv+*Og|-Zl6c zW2ivN_K~tLQMUs>Oya!nTm7C;TC|#KxGBa7AEzGG^zpEc>mpH-!zUf;yxtA9k|k20 z5ORIQK=$;olj2B9`j(MLT8$8D!hjuc?@sd)H%jUVNfJq1`)~wo3UO3nE54G9y7)(;$MtVnEZWyxC(*y@_FXI*O9){V#Hu%5Kn%F zzBTyw35Vhx>bS|z9Ax6U@N#Xl3enKu*aiWLZR)`8-TG4-F67~H_lmHoWK|KiPDelo zAEiS(<}>D&46B?DbLstSc8)uhXB+XkKvDA!+*d#2bQczjfDY^``ez{5M=J%5iw&&f zYZJh%9~r0`TR7e2la2;@j1GRHs+#waq#c>^Z^vtJ(MYiI&~kWIz&pBh>t7mte6=$D zse%%Up-C%)g!kwH1#9ikj~2mg?nS=t6{B2rQH&0KbB=wh<3Eo}=bNngs|YR}9soHU zk6eWV{sZq`R&5V?G8<8;sq+@CYQAjbz?#w7M)JMrSNR+Mn%UQF zOuB@D#G7Fxk`4m5UVkiB9sHvcykvP>gJW^XC$2qcgsyj8Srl&H_wv20aj}hwG8n)s z#&OV5B)h=_VaVDDPno&o`wo>Y)y#|~0Bu}`D}jPI9C6pD)~dp!#8gP7<4`t(j!N}B zR=bay+ZuXg{{VC`c*5s~!5+kc)OD*S;S$*dh4>@^*!muw0j$+_M2RJM)HvQo2qPZ6 z3?9Ae`65S=A{&)gaxt6&Eil&j?6U<~&IzXrLnAIN;QP&Q>wz{Hx0=O-Z+fLd3EZ&M}we& z%RUs83!t%vLchYt?)R>O=on*+uE)qA9Bw@i7~|KkwR657xk$BXP8%|jhR8iI4Rp6H zx$+7w)W9qW132tE^Ha5b43yHDouS+mbQs$&6&$lQ8y zjEdr|?|hw27H#F54UA!N#t-wZwkyoAkRr$CC=7Pz8OP_}@~D-m;#8P(^3PH&QPw;x zNWqr@8BYN5{{Yvj_Obz};R49MX5rZIMt{$}T-3@fu#zSPPs&^SgRvv?toUvWccqsK zhYF{$9Ch^04l1(*lDQSEnc7&tln&r^9-O>H0**Sf*dyj3~wl{{U;R;Cg#h447g9 zNI>*XKb>U>@=8d@XJhcmA^T067*y|z18_OroOkX20M@0rcT&G;$W=VH-_(xv910#e z6?80A+YNQ|X*>)~lN8<~=kZyNG$p-dwTy4Y;WJOrFEkcd9ore&98@-MAOr ztJj{`trkW|ib*5yXTpXhvD3ak{Z&@r6-JKD!aA-pyN=P+gMrXz`P52R+|r(f3#gJ= zSzwW)c!qap?|SFg=}e9iU}l`SjmgAheeX>3$2DaV02PbO3H#)nbL-QmwLo%;-f75T zxUyvJO!WgF*0SfmhL%rKKQqmVUO?gHd}A3w*pKB-mf7WfpfWzf3o8Dcck4^`NbVzI z42rqWQUDkSjE)allp&TjSV_UYf1MH`{^pg|9q0R7rvF#wW%!Rv~#Gc30O zTNnv~2LSqX9CxaA&?IFUq-By-Ib8SkIUVX(C1YWNOR)K}NXYDQP{k>7s|vA45NV3b z8F7t_xn16%_x&j#Nh4M(oGHl2$QkW}*S$S$q>A$a$bpPOjF8OP^I_I3=8SRsFz zPEIg*$?Mbl)VXR2C9xX1+^ZRavS4M0Ky%poReM-h?6T|*HUZO(**>*$XroI2j9l(+ zSEdL*!g2b3wN>B!8+cg`%mUfROm(YInP@FurftmScZFtjZd(Hcdm4^uLvJ7iW7CSc zG(vANjKWyl;fOsBbLmv&h$}GM#alT9V6Q&?Yo<0i+G}F0iGqGif&51_$qs&0-HyCw zrdc*`R`fl))Grw^gCXj8=cQ_nT9LNG0Az2JvB#}1-WZRe9P!?VIg1KTLF9T+30B;B z!RH4Vr@o}c+d)=9iTljpj+mqknAi-EN9Rfg4`H8PX-5$p5s}xPv}{)^dW0d2MT2d| zPC@ncuH)d&s`oeYY3YwCftc~j5sp7VKNDOk#kw{aBf0he06f=Q@NWF+z9@}J8>Sl+ zVQd6%1C zVFYbIH~#>tvtwhOt^sUgtzNf|LmL)Qi~L9XwNu)~L`m6J8H2~>i3UL&1v%;}w5ucJ zg>V&e26z=V6vZiKEWnSratO~~tvte9ArG_+f*T`oAO5;Yq=?Xi$?~EfX-|`aiW?2< z$6VAAD1pPu^5>=qA0Wm(K9zpu87C+PAjRMVa}q)>7+5PNEyKco+|7*e0TbdyjC&D zsWSi(!P+_#-l<7$s?KCWn+5>`-_t$of547zH3HK4iXu{W?Iecn&~etba??Yer@Ebr zUMs$iJw`!hu>i68dB+178UB1$7NK(z+~O$}@_zF4>Fz6k#dd++p5kQ#ss$Jz{d@jZ z%RS4>84NcN+0HRe8)|0;;RKl@M&>Q~7$c`#Q(j2eg5|xyz$5%>OnH#y-HNErLGM)+ zgMv`=*E#1EO65tY{L9fg+FRy8%%_l<+J3qIm7}I!5`69s)!}5pTwvt?04m6}XN8D~ z*sB13Vn3*>R&f*{lHa^Pbyysn=hLM`(XA?t^fav*CT+5+s8PrXg$!}%GHY_e_B(50 z#!$@J+q`Fz2Yh{NDh6-01!m6a14*=#++(*Dptr4YXb~03hE2VhNgQ?@cJ1D|swi*TXSuB6{*>9 z0|Em2Hb=}ClUvquO&p>ta2*PeLm!p1>EAx}TSksFwpJj=l=*nWH{<;)OH7GmyoPrw zo=b9679QC8^ry;Z+nls6{hs0hACn5R$O8^L=LaXhUrNo^zSPW2kYYvoFc6$$2RR*c zpVGTad*OUpC1}$FVt_N2$6ndTN`iY8xF#LHF3}PO&lpk8Nc#SM)uc2McXl{Clv`P0 zi^?wYF@JPyR=$mC6xI@%#EfG`8;L(D>zwdDwWE39wYJn9*yJ_{^2h~G9Z4M1S~8aH z6~@*XJqaXWjGPRQZfi!G9n`c(C8}tvcdJF_31Hf#*|&~z2N=gry!WJnF(Rq*#Nqy6 z8F^918OOD1>UNKND_|1GCu((0LFChavt_r9zSkSb0$^n43xUYvj+M6Pi#zIN+i4O_ zcOh5`0Y=#HnwOblsFqk2J9;?4gmGX^{dvA zHTA-W4;FDE{_~K%2tMbGeGPNTG|rpoXjt5pwNMZGqms&T*8qKMp|!E|L`xb(*i{S( z!3udeJ&EG7EVM(uIMk$uAThY)k^a{lboZ{GMP<{YX2EczIdPN7$3cvao|KfVZBOCU zbTCIPz+{8YXE+6zay>cjFe|I@<+h+D{n{h+$6PNXo(KMdJM&q#`h;_;#-UN$EL*=^ z^T!=|{40M!pX_4{7_$8Ast(bP=eZx_S-Pi8V&trj>%;c9vAwx@+9=&Y*v?r-a(W#4 z`&W6R=_%z~NbViB?a1dW4r_q0@nfVo4I3T24TNmqWNpW_TVIIj5@J~zHo+h(Z7Myu z$jvWm=@t^ShYv${3ijdl_ zeD5+)2J#3bliSo*zMt_=Qq>|q*-^6)Z~*|R80gs_$kvdlBq?EemZ!fzi{8=(eM)n? zI7LyF?an}~+m9Z>IgucfbIQl&$vY3%(~9!l1+Fh5Pq!8z^W~#s=E&*jE4Z=nNWOt( zo>?Qn!B~mfPri8lYqFZ0&N`8WE!bXdyoJ8cBRui~&dfMbk)5X=)rqTkO4=*9lTJxI zxR5lB)VMk7d-U&J744)NWN^aM?1C^!Pyu!z`uo-ntKx|*o=a%>iMHUD`MTtB`PZ#U zsA|qhxjiCz?~7~>p{YY{G%>>kykUdw=1h=rkJJ2X$-H}F(dsP?fW^JA#HAB2_eTK# z06OybTAoj? zz@k@i9k?o^f0myw9FE_g*0_yc;K*W-03m_T-b@!fatD63-*}}AahTu(%5}*lxg-pM z$8Yf!#cGKfAeL7OkCG9OKtTMu^sgekxt-bQ#?d*8m)Qt1r_B5&7Cu>W)ZOfs2gwq1Eq64v^rs` zEBT^DlVcWS$YoUrmd72ChRCA3P0atL7dHItAU=p7mh=zK>>;r&KvBim{u^LbF=cc~dC2iuOd z@1F=fT-FBOM|0)~%QGVUq>-P~X|J5@^->Poc3t493Zby7#{>00t$PQ;NzTYOzwTwu z6;Is-Kso$Lt*PH;d$?-0(VwAzviHPUA6uceY3v8U1tl*WaH5{7!WZHW4mI zn!xAKSLOHY-QrUO&`7Pgk+p#2f$|^J5&75XH^5&VE&iDhhvfq*26+G)_OP>ZRT6y&1nyP7?mcLN$RNrn0G@Ns1#GF@%bLS&qY%bpjdvgz$sK61(;<7`kpi4# z_N@rv0d}B2D9(E1)_G~*y=-m!+fTJWWQbRHEKjC_LR(}G=9nxIB*-L{=nZMcHpDjl z=3mOGq&`DAmAA3&j8k;%v=Y9=6T$>9KNvvz@NiJqqgnWoF2=%Um%S5+e&GSDv z^s0=XXSLfO*XvkLpE5S3E8I^TAB+)6U!z> z<^*KdEVZ?S+D)cdDhw_L3jY9+{cGd@0NOSmBjN?gT%F@2j@>Kki#bb^775xv1I>J` z`&3zfXZX#C0|aD~_;Fo$H))*Jc6U6o;P5uE0apV65=r&?(gtt`MZ>`a_w{J6+JkFl)nenNESQPXAtBRJsy07`I@=0=d*`LE-JpPwAw zXU+2!UOJFBexRE8%j1=pD<_;7MvIm_=0I_g`F8DJZhUI8UnW@-a1XgpJs2oYkWe%N&L?!Vu+Go_@V(PTQI&qD?N@QrmsJ zh(f4gz$)1TuN?>VtC5zr%#h|q1%B#)G2fqmT4Vsn84-jjGJp#om|zU{`c%GDXn|v5 z*#MlJ1J6QzsVPTWf@vKExB5X{;}4vYMmghvMRcAOfd#}vX_w1vNN`U%=zY2TYnafj z*x0*WPK>+%0EF|L@G)Hmizqj*<`+_Nv}Xe&@aDAUNfB$UPOo#Nzua*t0A z_+*J>nbi)`cAkD!&p1ByR^Bky`?sGmbvuYqSR7#B_UI~44BRjFngr1Hxt!9H)ace3AOVnvB}-$BsQP32)!z+D(Mcc-VnVpw+QBNFb z>}6>N;8_9wCHM8|(x=gwOMkXG5t?6_aB+|^!skEa)i$L~ND|Q5&}1W4X7e$-AxBT3 z{SW6}jit`fTa0Z8>J`@@H$Xi-b6#g{Bx$OnxsU~7Tsh7U_dxahYrN2;^K|&yMBgA# z4jT%&B=zr6346*&gsmiKShQc-j{akyR69p*xFny-y6G=AMzU_&RE5aN$vp=hYllmg zwA27-kgJRzpCF9n_O8O*t<|`F#0-`SpmIU$lk^{*Tb-&V$8UJfxCP~jB;JxLo(^%G z1K*%LS6ims7PCT*s&^6r=jQbK4A&)fB5nwqnoRt}j1$wp`RiRbh9)vvvPe~i(xW^E z=cavm#%nBGxn~&@S9D*xhwpAt=4S_Q0FF9(_pEh&m%(ELJwa|*ck9#f#cBYJV(tNq z#&CXYl{|6!^{lJd0Fej{BH(TSa$Bc9qx@@ZV&{&ib(PWCcxwC1U97wxnnHT#Bc~kw zYUZU0wX`Q{ypa&WyDIZX@Ihy{CNhB# z7j496*nT@t=UP)nB)4HB5*0bY=Y!jxdj7S|g7<39K4PJ}xgLfjGtR0@WaWWFob>!V z{&gv{nnGD_8AEag4<6MHjE2`x>k;i&1LAOAx{oqoFanT96n;s_jFSkxAOe1@YLQ&x)&Y5!$EA2stVX?O+a1 z9^7Lety*uD;)$dV90mE0IVY(0sOG?x6e7Fc+>Vt*3>JhZ_SGJJ@59*#07}-)8DWFouvBGGpETJa?+*&|Jn$;f_I6$rx47 z1dq?9VA_k&abip(ueu2&5}) z2btIJoDAnAP|+!Y-eA&~a1`X{jO62{21n;lm6hMP;{fr`(~djT^A#pNZAR8ccU6>R zm1YDHj!5Iw(`L1HZR9MAfSX1~86CYor_!Z1auK@U1gJkQMp$AB>$Ka5*LmkB=j3oR_*RNm2QE`CSj3j{jj<4T8$G_GwL>kqXvvRj1V2OD zgIX7Mq9Dlrb~lrdLBY@e09vTtLf&aY*d>QwLVMQmmBB|#l&&HXm-E;L$;Ul^&lL=j zkQ6x1seMz8!v*sIf+|tI1au|}kun(;(la^Uq zKTiJuN>?&+#vd?^j^wuWT~3m|NSJ&S%vn&LHe20&F%Zsd&e zPxR?s*TQcQN2_>p<4%(f<#wVe`A&blG3{KJiS1*xo@IjsXDl0nNI2vCD~5yQlZ!r! z30IyHi!$v~&Xg*l&N$Xmj^{3oifoj1^4c!1gciW|Ax$_HU`;p1dN~KLBlB{_eEJ1kI?X?&xlgT4J z4tmzDjxHdRdIp+Pxa8qa)PMEs97OAE4p=j`c;n^A{{UL8=+mftOayDw9;5teOGR{N z8S2qydovqsbPlMDFwLJ%g0A=~W2b9ByLVXUQUG__GBNq}JbP7HN-VpCgBc8)iN0f4Tk1B^M{N?qLbm|mImbEr z4r;!irx9wd>lDx?yrReliNR?ShL`x$Fzf$1dM^w zpt3t3Gvow2@IeF~+IOiDe z)2&;#yjY(t%)7pDP`2(mo;uY?h`9>vNfEIu0)`pEAP>1r+Z@nV*?=Z zS-UiP3Q4OP%W6D`?yU#~q9C22csR#V+lr1ovM^ndITrn33mt!5J0Qx z)kXmJ5%t+^>!>MSmdFv7%b!=G&1Tfs-E>sawF1v?Y`jAP-D)gOSJo0A9DO%y(9Y$}AQ}3$&h`~wl{@Uwwc5j?qfC?Mn1gYD9~4G}!I^ApREAt!G9ay>s@E19;I-DG4q zWh8)u1dNPuYi~}{a6%No-+|6}C+G>F$ zPjh`A%JL$P0^sm{PI>(%Dbt%aievBvH56Bs${G0)-9)~T(8*Dk2z;l+7}s zgKkb6gZ}^m?0t=AUqEjxf?DC$GC>Ef(T-H(wrix9OqS~P*7R(Ld6OU#p!Uz=7MXXZ zv9VT#cDsTD4tElH!NEM7_4chaTN)?tBbn0tCbw*!Xn{#NLI%=DsjlNe@O`A4S*=4U zOflyfkG!L%as2)3H#@Ut?|$Vy z8$lA>O3}hsZ^{{zk;VrqAMEENu5%ostZ5Vw83?3p&W0uiA z7g*^}EUgLx2M!w?4?;f-el^hPRu=HzZItC=0Fm6D!211aDYZ#v4)ewyHNgGg05RvM z`887N@LO8>>nvw7Fp*CrSldeYxoMyIF(lYwJ z_h(%{X%APFAqGXgq$de<7dyt3;PuCW`){IZ^yWA1pz6x}aVeJ=k1&9dJl zIKm@fFFgrAomah@Ndr43*;JNNz_J0~r&_62a!Cg&i_p*U8T(w5MoR+6IdCal>b!0P^2>xEouqUr482V(2!g`%bKIwy~+@;gFw#tak z*8#EIet6{9Ie8?r+>nvGGJ;Dp3}db_o_Y_?w{9V6JlNC^-h9pd;Bo={KgP1nrs($t z@{aG6Fy-)|epMIKQ%GoJ-l4fdC5vuYNzWW$@$XUDxJcj6#?p5j9Or@8zvWiE&$fob zFjZ^@&O`CeDzn=gbY*l`EZ_n=4mdQT?k6I|(|JHD!n;?Ip8o*hQCStYn{-MZR8f#g z7|HB=^q{0{*_beQYycal85sty=~khGL;!IoT!X>%>Gkhcp?wOB`CY`)u3qAN?DEup z>pPF98OONIde_1&YArr8zFd2b0uT-qfOsVJ7!~LGE`&<~DjoZ{+Q2DO=zVw<>0b`) z8Y^eW8w!v~01!zT{J_9JTI#1w-0Y)?vrV5#d=Bwxl_E_&-DZ`%&PW9G0YLW)_+!0& z+wcd*=$aHSF908#o<@8AVAtn|!uTN4tzu~W!q`(AsP^NY+;e z4V(rYI)A`_pRIZ{Qc}?06F;m*$KSsWJYF>!;gTFMH~?p_y?Xgiw^}5&=PCzE`9I)) zjU~6z;9H=q>O!eK0I#Ba6XJ;`fQf!shA^j$n)H-8;(UfWlZ3Amskg(n%Y*X~$F)e7 z?;D-C&O6Xy=;V#Znwcl_56g|fSCNXL$nK7K&zQ-0ir*G|fFXs%8>+RmRWXmNDYIxjEa)r?q&*pVlkSe*HSA0ING^y zts~w%kv`lGIXS1!QiaLmWB{2CTneYET#*9=>e$63A>$)CIl(^FEzOzU3w+DhK9!8; zV)<;uw7q6^Kg0Y+tzE?yp=#g&bCFb~l_QOn7XXfNQQYcr=+=vahhfft&%JO?R=J{$ z^+k<70jEW=Knf0VOpTb`5g|K1@jud}F1HW~0qgqIS1-L!lnBViMRV>qd+J-WpEnMw z4%5=UYW=S5%4?}IjIIwPb?N^A)~}?X1rW$Hkfhg#e%Bgu&7s9U(p5;w1L^&7TVdzP zQ5>}wy4mxKktSS*E64|TC#U66Nb0ILVTU8{ayoPR)RLwfD!Xzp+~fmL$iaAhgE=_< z_tW#P`R;HKylmJ|q>vKs*2s;49(5XgOmuMk305Aq|jNoVWuUYujY@%0{3!r>C%W@A~f8SaB z2j%7RN4cdrGCLL8BRL10o}&Z4Pq^Z|oF?d^Uzj+A|_zSjHe+sag)bSQ&3yk2qN6?AuocZPVK&= z{z9h=WR66P?r_-1Ao1(#o;|81E4!52TQ*@Uh{ZowUh&d@=|Jv!Dc*_qnR-0;MK?eKvZgY#r# z1CL(yx2CzYx|e%$&D@iBT`1^0jpWNQ_M;PDVE&>_0L2*2%6$pJHUM7LLjp8EsY6nAZVtd143| z{*=ugRa>H|<$>RJb00Y1_VuW3CTFq@ycpeA2&W(njAS3jxTUjJX`Nt03$ty?;1UZC zqa=F%byTFMLqSD*i0GNXxm7{9zbnpF@%(>}*Rigz!*h?YleIP|`4lMuN9KKNjh5uW z3Ym692LNn11JjO!pVqqX4Y(H04&9{)jh7g1M;Pj8gGnJ@R9_^Ur5lw*hXW^!V2t~9 zuG2|oxPT+9Pqb}*er3qukMr8N$<-3-CY6JZR0YVv^eTFF{VTWd%SkPmGR8{?S1v#u zxg`AvqO#?bSuI*gkk7gttQ%dx!en$ja(}|NygfLMA%Po4Fww8#3H@_ex}!#t?F0r> zoy*bx03XJ#_(ts(*_u^yWBEqb?Sg-;ENe;;VOm_R8<`x;rFB=NIBVwB4w5#wD=#O1Au*O- z;Qe?t*hIU-GZ4%dV-d#HCxs*Mu3Wj^=6HE?CSCBwr_93N%FBG1$KT1xB%jK*W`#tB zrU0o>Hl8p#e=%5oACSj%VqL;B@&d=H>M(m&)!UCOw1hE`72$!w_7$Ba$zDiww2@Cs z$<&KUnGQh&^#`}|t?lym4Ok`i>aqE35IERW3=_v+%-2yCu@n(1IgFG6PbatGT=@S0 zcBY0Ew2}A;3}a*Ll&MbS;4vg{2e0E+!{rc0h9ZmsEH@}Uar&NWC?GLfx;Er~Jgzu6 zBRmZA&sw=0lLS$4+X-Epl!8|rVB@*86zttylb^GhmypR>NxhRhUP&EgST@L#9-lA<2m&0S-9(QHGOVg zMgj$nRbnIrxH#O$vu2PKn5k5ZLPP<+PK1^zBxq zFBHW|%FWQTY*SB5W_DlkCga!(!e&MIlana7r_%>MvCD#WN5;PJrc@u+R)bAm*1w1xM9 z&;mbBo$D6@d8~wcn5B#NaqSU&#N#D#fN)0~@lqASg)G2EH!E!)Cpr9kesu%yk7$xR zLaw`njh>jm&!=AfX+L>s7+2cZ2LyCu-xwU!IM}$|*tHCA9tyGfn2=Yo>-_3`mQ(hm zYy-&{!xPtzxzAs%R)b`4g+gOeysmOU>;8VU`%rGnx{(khoD83rwtMIC=CfegGQ_Mf zBa?E2v?&>GKhNb+t*@IOmm0d1{tx!B4snm`Ply>~ZH30=`9^piq>w%OQrfheA&HCa zW-Ys?Z>Kc2G*Zxw;#-K@$x{F(LD{~ayhiQ^Y;^k6O$kL`v|-(tGN2%i-3~qK%1C2n z5{CpSlWHCt2aYjNx?RW5#&;(OU*`InsQE~+NE^OpKzi3iHaR6E)sX`@ zT#?xDJN|U&-*W-GEu4doT4c_<@(`dt`KQ~?qFmr@Bz632Rd(F-T&>j)yykYF3miYNWBr-GX=(K8CK>Ypt3*f|ZrCYp-Vpx8gv@qjyf(%huBI}e3kDT_wd!Prc*{_A3Y9&yw1 zt?!8%B+=Z5x`|Rp##D9h(D&(z;yf^0OT1g9gEAvzWbNCG9Q5upUdq<*<1HG_^6og? zk|lBv5&b_tp7qI9s7sk0_$sb4vFBHI%Cf}~O~fMMK^ZJSApRIXr6dX3bS#UTRTn-Ck@${-p_#q@j-bObm{dw#3tqbW~Lzis0J5B}y@HsWCS5hXeu~PBJl>S=aslm@-RpBEbs;d^xaywJ57i_1@`GfOJw>ru=K{`J68{O~w9>?(P5H1>SDqIfQzc+nV_U=9Wc%n9%B#aOew{{T(g0-%Ux zOk-*5`s0ethUd$;Lf|+o4&Pk=0ETM7VRGAuZTtYksK#+n(~hLL-seT)8`Pe~ql^N| zrPpKUx86Ra^IUD!$Z*XgoziD^~x}~gBMg_J* zv#=wK<2+`aHm-Bkrrd0blL(@w{B$6lE4#bsSIcm`dk zj1a7TLy^-RI(Mny9$ZE+H%1#`obuV^=B7%|hVx9_Q5hisPY{D_{{rEM)@#%-CMJ=RNrC+O>4c$GL%wjFPI2r;cVi6g|<>a%7c)1W7qumuD;Um?Mvj$xlCZM-wZG~#zr&Gdh^XTZ!+PU z-vl!6Wy!;n^y!Xyt=$h^E<{ivW4mhXY;EI{^L6RcxuV{vO6{JJWu~x1dloSrrIZfK zz{efAJXcAeO8)?7*>ku`%pF`3P787X9Y#HCnD9oW=3P5nvWUP68zd;>xb^wJO6zUz zVz>;jFh&G|^nX?uAcET%rK^d8W*mef$LED4;Lz>pNwZv)mrZObl z_V?oedy3}t2QuB8qS#VQYNYa{4DKHO_0q&;zQ0qasqzwZWtK$hr{^P~80l2*d@UxQ2p1rLP8C8Os5r(*?_HmVG!pj)OM>28i~WKL_Zs=~k0TYi_K-5~x?4k3rw5s*u~6uU_VL zKfA`#r=6o4l=18Q>(lffh4(1|dr<=)EVCBQ8|i_NeW4IukA1%X z09xm9ZQR-|97dmS_N>f|BHrg{R{$~WG1s6qb4|K>8<=zG?rjTHs`<$Htjt+x3xe16D64n{OZaE?} z_ag?VL*ZziIF`;=ib)?kK|e188PDTdvwS|(6(J~9RGhq<)aTgxla910%H>*TK<2a!65x1QI~cIUcpyX&( z>sNG-3)Zu{(x@Z#g*XM;-m@%G)wc zBJGk!T(fdUTesoK&2_h0eWj(KF{jNd9uG_vBx9jC#Z*hkj-IeuByR2VZc~;x>NC&3 z6{Kjb#H&iCi4j}Q%!IgxF|ZO>8T7~oqPTUpvU3!oN8ES(+<}fV0Kn_|R07`;w3l!g znhg19NG;rqjP$N^R=)dF3CLapGvs4EF~&Z%(4xq2ZL1MqPZM0ckQT`Uc2z)PG1DX3 zxZfFEs~HISm!Fi4y%e5%4_e!?SoKT`ct-gF3LEa^pTo9)I>GV9l$Nni6{rQHaM^Q# zf;Qs^sK#n#3!6%sjgvgL#8by9b&=SjscnHsB#yzm=dExWXP2uI2Y1x1RpHb`5x$Didl26KH zETpRtpmgL4%5kvn%63`0RcOkl*872h*f|{YRc~7=?Gq}<7!pfhW4H1&)++>|v4hV9 z@T=?e=dEVmU%ZG30iAJzP5|eofBMxBW^!#BD|aTu-u`56z!u@UW1m{s(zPXv_kVYB zE&}9cxbdzdTrT`?CymjO#~k`|TY7Gz=EV?YM!>>=!-JA@k)A82F58VRbz{&piNws0 zWg0mpPSeKCxF`Jk*Qk6kv1^o$RAT*DBPi^+86Masyl=sp(6)3lU^#La9_!HU_3A$= z`ZM6w?Y-=BysI0TQ#%m--;mzgNFYQ-Uzr>M{_Y1)ui;+L z@OM}$OfsNvnUob_o(cQjpLo|9@aIgkVdc7?GBOpTW;=%<=ieKv*}NCv7!u|sjg}Im zoDbs!<1O{$itUdoHl>K=R+h)Id^Oja-4P`vRu%zrbGw0)Uq<{p@oTlbB~JMGIX!FU zPYv1~I{jiz&KQhkan*ZQu=ob~&cw4J$Q*OPz~rNPY7!+RU-;rke-|o&sy)Sb#FQqNfhJX*VeYJ1vsBOD9V(s zh9$_jfUKEaqYcMe&A8N~ie-(4)Hwip`_%gEX35D>CL9t#u4`GJ$-PN#3oLjWN%i)x z3lOD3S6dylAgUX$AZfuv!jDRZ9YQ%vU;tT&1o8S-R6Zt2kCvno=KyD=Thp%~xVS32 zOAnND-`28?d-XPz7X_*`M z?lWUpCOTQGxe=I?A$_jjGeg6W(b-^beTz0$0zizIJfW@OH*R^Q0 zl4l+7bbL!ZVC7SiM*g`O=yUlBsVrb*t9gt;1dagdSM|t*7uP7NR4Wq5c)+aL9EjAo zQIJ5#Ilw2SdUDrOhE^cC0na2L1EzDDrqb_4c?ZnIf^)`jK>l?!^5Gb;+B4J)AO5{m zn6b~AGNc@2^giDFRHGiJo_tZZX0(%LTlZy-I)ld}@dmz5_}yh0Td3E|Wf>!I<$#lb zK8m0k`u_mpQU3r(mSy=>V%RrwtKXk|lg)gk@xl$(*LYDO8Ek{eEc-`WpP|lz!ehDl9UI^*_f0cWu$B3FYn{bgl z$8r>mjB-C53>xsgOeBpbml14$8;oS)yevM6q+T9((9VroStUpdtChxFU=BIS1JDe8 zO)v~V-(_d=qaX&)-oXIp^Q&9rwz(**-HT+8n~J97D8r%4{LXh^9CO#DbgMJEEn3Bu zi4#u&Sh`5%@xWou2+!au@~n1t>STmqs8Ch;=cfdFRXE)Rv@I&3S0`!o0A!Ez=~6>+ z9K?l@vg9W@Ac9BTDqAdnWl@)6!Li39q2udTE%S3|-ckY-md5f3QGxwx ztaD3=Vz^aUr1^(WKt??(c%>3qfMF_210h!Ao}6RwtfaLQT-G)`JXx+JZzWmOf54>Y zuOG^~Xk!-+&-=5K{sV^Nf<@r>e90l#7 z^MDH==%jQbvF)6GH6_BlyQRWL=-J=AF0Yg@qgDzM8FjBQLhlh-*L zxb~{WN18E`=v7VAH7@QQi^(FX+IFiP700GK8h?f73R?uCp(L;ym!>iJ)qBT9l#)!1 zh6e-;^y8krsvi!q7_Fu2L*ZO zjP=D)yd(WwhXGt*l>>RmZg}ISD^Zhtk^-?ZWb#Hx$QVE8(z7))x_5F(Oae&gFh5G# zpS?wT8!K{H((q*1zf!?aF$ZutZ2d9F;MZ>xm~D&gAQvIXI5}^+e?$4#op@?Tdyry6 z$Ozz^6;H2jy?v{^wvnW;Qg?aS=-;XQa540*OuI?R7|`9MSka_-^@1F-vjew`<@nBh za6X-Dp}54*#`fnC1D6LG8UFwr8s*@+5o%kVikUJ=+nzw`Yow3lvJB0iDKl z{b|*Wu_$ZZsi|H`adjR?m@*gLhh9DT{VS!889cSa%hUVA{41EaxM!3J0u7sru`X%`fw{;hlh__}>yO5+&mb69 zNYRD@ox67oE=cvy^r{g^&_tm(s*n|e;Irokzw`I47%tAnV;%w?Re2k_9A~CD;=O07 z3Q2A`<@0p`_T}=Vke$eJ!efu+Rv9NS2$?tUx#ySy!j%f3vfA#y+q!Q(^V%1nhwZbs~kVZR@F`l&~j9N1uw`Mu%xj`Tm_xG&l?yHTY zrKw&^cejBSRs^ul@&Vj9&+;FoHtqylD3%7_yCs)y313|PH8hv4hLwS3ala=F#NZ5j zRA%VKAPA%@DP}G~+z((6O6PLEp>nx>$`yhD0K@GEcc%v+Q^6&KtYb`WJg8FVp*(vE zj(xLRD!@vGP{d$kgOSptwTY%@F&OYUZNJBkoqndJvupBw6-b{zJs>C z&za^DNMk75ji=c0>J3hlG>WF-Q+_u9i?@;M`P3#NI`c!J{$9(WAv5)U5BoYs&<4+SF z1P3xN$OChxe_(nVR$2`^Iqh=N4XhR0;!k?k*(x}|rsJTxif!gJm;EW&ZTf@lKQcCwK$V18t zg$@4KLMoNarDZHYWzQRWW74iqD|yiX%kzR*tnoO6%TqE<+%b52P28!v^B>Rui%r`tka z<-DNEI{-7B4EG=$S4Z&2z_MHTQhPOkywkQ3iORyeyr3GLrKdtlcC zG{?w7(T;FV1~R{1=A2{86n!lzIl&n*Ou}?|(Ig535Ln>#=kzsPsb%uj!(`);*9)FM zPtLDuHmz-NV-BG+%Hi6y##bJO|K+Pg@x#Ep_h5a$^g;Np{H za#`4Oxgko7$C4F~MHm_5vB))9rHqwW)G9G7tWV9`ABS4MBY9bhDEVB8;s>JT6&+w$q)-(A9B%?gJ2~sXSJUujjy3qilnn+4*|< z0a$x**fho_%Y@^F`=MzW_Gi+0RN?Bo07eS-HX8%{NbxCpeLCJ7n-b z&lO!h+#LS^N^eJIOg-eJhr#ATrAlrY=Zx@uOaHIW`>s2>KcD2K{6?Trz zv~mBHs7E6Y4Re$hu0PNBnOpw9=4sQpjn zT~?RkTc0T-60Z5AC5Ro5IQBgSamBu)rqs4Qj@wXAvLp){VS}?A{qIiPR_23kai+$6 zsF8y(eW8iZ1HW!F`B$5GGgdPIx7(waC78Q$pF#eAm3l9SwE%GUfC!rlD9#A={$um4 zWwwFv%art=4Qeu*bPg3=(_-Wfag)bBhPQN!hD}Noh%B5E(kQ{`F~Ry8;k-Mk8RaI) zkChL~LlOrZ4`Yqh(m+f$Qz_IKOTi@{hRpeR*sOA7*4g!+S2*3x7;DOKo0A9P_2jZ?E{OMCGC8`OK70Lv&L#9?v>C#Mv#XqOh(C{iCj7=g)U!Hj$6 zt!aKFNu-Vlh((WpILnUz07~sN_qAO=%EGUna>oEODaJX41fcV%ig=|uMEcoFLR)>qPQvp9k@UKs;6%R zYbWkfF)rMdkmRuU$@KxKhCHP(90Y_Wr?%EJcEu$^E7)Ua_`Q}O=1}q zD6O}osQ&;nHaZ@l5szxiXkJG~5k`f#V3WZZ89Dr`tk$Gk%STq>%K3$h1qTFl82Z;Q zcO32)$W?ejJ5z2L4|01~M=DV_qXcvQE;j;U`?diA3Q_U9jCQQ88vP)-jycpLJ7XoW z*9QahBD(!sN;26gx+FLR;EXOYT%4K=&2$+yx~beSxe7_h!RH-u_*PO)Ta@dkC?#X3 z)U2)N(wbwjeE9z8$N+Tf`2Ll{YFa{AIf)4>N!n8f3xR>t>s?Rw>qDlSONdIWXb`Xm zpdP2{E19`@;E`py1gG8^JQ3ULTJEfKK{&SAfj)%MS)xOvP74fRW7oOq#d6xU z>Ls%*BnbNL&Iv#Myz%c>{6%mfxs`(MS7yqV$wnU7@Aa-;_fQOWvTk5`K7@?q{70IgZ;ca(EHsWP|ExZt1r=i3IVYL`>rqKjZAAh#*h z0gj`;dd-@{%#f)va&Q3oepB~}JmR_|MO$!sqf*7uul&t`Nb^NOt zZj7nDbvk_=_W`gukq$wALWA`Kk@c@;_${c&Z_%xp5mz9qaHpJ}pFlg;hv^q`E0A~m zxys6U9B1i@^zVjKn`sxwc0gGCz~=;jMt!;cE2T}V9WjqHKE3#3;w#A~Nr#s55Tx`f z2SNPBd+);^5Iy@ zy^Z5@f}jJRz3X0&;p?d`AXzSR^EMO%UPs{_Wf4(kVp&cQH(}{sli^GCmOZS+PBNH0HLqae02TrBW1RFSy;sy= zYl2PQB|ab3jBS$8i~!wS`qY|diD9>mglr;~L-TXaf6w!+ zpNqCp*=fssv7GMO&Fx-eXRXCPq>?mj4tlcVBh%aKUng4hrJ?my6mc}J&s4D2*vNN~ z%m#Y!KN^)a>^qx=EdKy}0#-mv50|lDda-wR98%&#wN!LJ#C>a-(>0dJE}InqJRiJ$D&5`N z+?d!#Nyl@a>02tdM-!s?jmvwu=NTn)jxkdSx2NuLxD0iyeK7<`OrLc|MNem_3yZHQ z8vu+@<}YS$Lv$7u5)e)~$29S3w-(^%oaU=q+A=QO?f`O6QAuZhknU31Bp&?KTe*XM zP}!mv41C_kty$irY4Y&B0jlk4^8D+_VM_7+niSfMNI9hRCR(FL<;>7>yMgDLqo_nK zw5yoNQW`QkdsLR%v1?W`WN=1mStA!#{{SxvI6n0Glhqvdw&%n@6KwwgvOH^l6qxeM zu^AlajD368Eg^Sxz(eMJ<_DlZ%Dq4Km(dQBei^?N{~(SqL~&oMiigDxJzM zLN3kZV30bIjPcr_$-PcH;xa2~f4SvIBoaP@_2=@hmp(XI&6(L#bHtf#$DHI~{+awM z>aQEbMUuZ*h$H9p9Y^`~ubO@|*_h!bUB?RBj(H&S&)28tThv#gT%F+0i@rKSMZD$` zuF+&LJwojrJ^oN@=iAx5x%X~gmv8~PjPeFQ8v0-3mY8LRUGZUJ1)CffTpR)K(!OQa zgTrwu%^DrV3^%6-C-VZlEHlI4ZX*a) zh!`i3J+N`W6x+GCCWP_8yA((~*tTsb2-^7oj^4(nj^N0gLXi>AmJStHwoh}xsBQCX zn}*`7cWuEZZ@(W(eWFPvzX&CfoVm)LKM!nFJwsylpEJvHq+`nf?qk%RFgkr}v4O*! zN`M?5Snbal7#PQ>>s(%)DS47X^2iQwAA|I+w$+JGm5~TI!EM90BQ)Wp(NWOo{3$wl zSw&cjoCwPm;~eqrp1$>~duq%uh=*Xq5dB6t?O0wJy-&0}ffb`8l20m0$5ZwFE1J?m!tvBDuTU~g@|GcUK`N9jt9<2gi%a!*!mS`hkhNh15@ zh){ZZ6UKWQ*6@tQ=LvM`-!|Niy*M9+O=M`&8Err*C}|_!2+tq^-yD78SNt(E{j{uG zm*&6)g6AXyo}?Nr&3_{{)SicHDPe4>C_qoh#GG@Cb*1p0pwnv7Ey>HuHlOwv`?>Yv zpq_PPkz`SV*~2h9hC7c;=8p|*d+_cVim#Ta211d^Kd7px?@v)xdR*!5AawG|lMyUJ zw$NR$MsxU8Z64fzZDAcmWVrY1^5^+}6{m3%#@n5TLjM3L-GPDM@vOU2(P|-?6b~@u zbR{@B{S9e2Uk`K@BAqj``%)Dll!pUx$;i$JAEi;%T*9ndW1%HUP6{5J{&ggG9&BiS zZ=DFp80*MCtya}kDia?&&hiiUv)KBItP{N*N~G@+9Wr)}(8rkx`PjW{x#-$OPKD|w&?&EDo@|XU#4^VSAC;M3^DxblM}InE<|Vl0N4Dh z&%w0{&0NBSz4lx_a$dx4BIUOR3OZ*4zg}ysuy(k%3^qx@-L#NLJrC4_`BxO|P{t<7 z+}R7eq2T%s`9Fnq+6B*@WSLdnjtd_Ao=EG~wZ<>sMo4vEzL?1(C0ED+Sp3b-e~X-c z6{fS>Z60uxzCLfQW?z`rAY=}%qyj*|CnKk=Y1+b*+eYm?d36kO{PHVYV!tTyA7uO{ z)5n~SG9}9fDs%GZpw248S^3uuB7mTb9oYk@&mWaC$po^pNx4H10CR(kb>r~-s_p6r z*$k|?8IL=%0OPJl8O3@M(@;pY=c_@Dq9DzH6z<3!`1SXx?O$WBoC7&53IX$WJ-NZd;Mz}%Uc%;dR?Kv>aE#TmoJU`o8~`Cp?oBbBQ}4%Sjaf)#2>Hu=C01^IYDj% zVer{pHZg?%vE zAl(@o=Zq^h2Oq*cbNGslOT`zE!m+l zWfcmH5uRJVYGrScCTCE{rLm5ujz3C{p$6kSkBU=VV41$=W#}XQgM}Ifgx+TW2{S9OFF?=~{P|NoqVzec+Twq04ig%ZRGvjo(Ec0 zl;PD>ZgbzYC_?cO#^wVDH9n?~Rb(@3NSm?9INP)uxfIUvfdg=yg##J&&-w3AsR|u> zu_GRpe@%E{K$kmz;5;5i5A&#-(_p7Y?DYQtgI+G0!&uo9q%lYiOEyZ72TXf@b@b1} zj}WYp#vo{=0e50UySV6a_5ADOj}G50h5Tt{KOwNVO#Gm9IP2?P=kTw^(V`%lZ!MXW zD(}E56m=wV{{YsmIVPTqLDkW&RiUN(T=;q@HCS&nnA-w9jkg~;z{&Uf!n{`E7Py6# zNb=B-0bojSI3JNejeSA!UrVy_m%~@Q(%I#fG%5l6%%dME9gkjpg?!sCnzok`2lBkY ztW`sDjfcKK~FUZ)s1{{RZR8O5S+^)6yX3>W4N)v{@4eRJ6T$6D+{5O&X_8$T5t(LTBa@H;=~7y$ z3^9SU0J-h$`qdA#2AgXsBRR%u^_EN+Ve>Y2b-+EiAB{FlUEYM(D15&*N8J|gPH@Ao zPsr0<)5|PnB~^e`Zmvk_)Agr_j7}qyd|{Vv3g;&|>rjhu&&x22k(}hNIX}!+ESZ(% zwF$UMCd`1?KVF=4s#npUD{0xBW4Xm^+^CI6nH=$!+(F66=Od+M-ddTMKKyb>>+8^C zuS&Xo&RUe;E3r*Hc}AC8(Y@k|t0$5xa3a2^|j}->qhJIH_zt1ZX1L67rxa0Og;KPcxh1WcnnJHDu^3~4_jt!nn5k!lqnVP_k13n5Dtn^W|5Ly()b^*>7$PU`H!ph7qvefCppP`&7zK*JjIOq0uIEG2{UpV=8h^ zP6iM2{HwL__53$?fs!AW2V(RjA>g!?ZtE&B-`K{pAwOb z?!y3j4t|~Mj!w^DPm;+a)4V@^ziVYELP9gQBQ1l*Pki%TmWSdIbqPttNgz4gPh97} zW5xw}e}%6%g;tV4SDlH95?AdQb85(@Lz`PXHk_<|`u&S94l zU~R)-E8~&RUe)KCLnX%byb+*NzCuAG1aXgi{{Sk}Tb8o8cZxNaXc<=kp8YxFjB`#5 z=y6L%dcKjVI^FrLBZ*Z=c3+qS-yD&ljcytNFbB))2bO`x((F0N^dDd4USn&jLvwV$WF|S-jJE8OG5YiATekYH z`%@LmsAXffdYrfa0Is%GIZ+eCtQ}<9EJ)0h&$QLTzI`XPGT05)eLZjG&L4 zK(^zBwP zn~#tfU0PNI07g1+Ip^}lKE~QS11XJ=0v&QU7xm9?rYa=)b$gCFp>1uvkeP|H!MAN0 zUU)q{xuv<$5p6ceHcEs7pb!tspXw{6v$0sCU5F3MyY7M8k&o+G_sS%OQ4o104i4-A zzyqFrhCd3YBK@0Dxr%MtFpONOCkhF|4m;Yg9gp=<)ct*(Odwv~{O3muby$+8|yD4a~I++BG@{n>A@H6^X zdEp&GYkd8U*aF2_fca0$*Wb4_=GJmT3S=_moNp#l2`8^ z>OVU69|h|-acXd^6a3iP%d~)T$^K%xDOXpql~m-cdu4_EusZpV86<*)@I`tjgM3j2 zp>nvB=8RzT-n^MSOAXGNR_ZlL@tolF`hivaBk>4n_stw+Is$nkoY!nIQGz`6hjr6d zKB&|6aV!Yw_i=)A-mKi*G_V0{k}=JBKZCq_XPF8!7;*D2ujgKkq-!g87z>q085zZ8 zTNbJ<4jJKHCvy|xK{EI+P$5GYqb#8F&$W0ik8>8UYa>S?X+r`=c^_K$--ym5@SWg` z<_v^!$rbZM&n}s%OBI@|YUGyV=Er>gb>d{wls^DAn>5{$K-3wZXtM(Hk~Bl6}RjeCB1&Vt6Smq#-pm-O)jpaW&Z07Z9&}r zb;lUrL(r{3a`RmET_4129E^GLP<*7HzH6$pm9B8_AQ`~MI@bxLUBL{SK3M=fAsho; zc8@WV*Dh2_KsX10E21%RJX+h_-Ijkg-!Wk}oN{{95K7j!gMLtBJJf!ZCDYp|je<-; z?M#mNPF)7j!z6>y3cIFrE2F2=FT|E@=P_m@pycARC55f^pED7J^T8seQLF-vbK89(`*F3a@qCI6<x%lD_P(+g zo)z;W2lq#)9Q76Qz1;4~QwsPQ$@{~%TJ-Sx(>W{X?o+xCBxE+;4sbcg<5eYf4oNJ# zvN-H&>!@`iOq17+pOsaWaJYv*G5UX=^xozTm|iBdS*)ba)?&brc>n?b0PC-wzBK8e z#NsUCRr!yn%s)&Y*1nY0RLgGcpm0b04;kb7el_6V7_{()Q4m!xkPlo9fDh%G=%()n z6GiVt_@Co$mL-xv;3R1nX8F1Uw4Q$tPUgN^@uVMQzM16w`@rY_FNIV^Z5-1>4)74y`WC1#B&RppJ6)aJ^p^U8%& zcr3e4bK9Jc@v3n{HrI+jlVB$fxhy?FJmdcWtwrpDNnG0;u1hl>KMeIX9qPw%CfiWk z5eZ_9NjwfS*mKQwTT^OnzJ!+6sK7-EWm&KfamP{knwA9LG=Vn>Fn4Wzpn;BZLFbO1 z^;sARRZhYe3Kg3LdH`xhy7TUo2{QYzryTX#K*w&h&6{Z&5JqQmQB_&NU|fuY$G15E z994}jbdE$(I=*ws8&A3Xc>JogGXl|=!m9(qfIw{VoDtfk)8$yKl`av?yeVY^03UzG zvc7>YheJhCS{=YB3&3sMedXve^{$6Yk}(-(aICMAALYp;;~i_7OwY6IqbLbLIvn>r z_38T7){%7AaqUruiE+6=Y%%ty8ES}aq@9kFL!V{pJ?__xf=E^50C>pv{*~4|E|HM= zUBiChepeU(0DBtZY?vjhNyc~|&bL| zz7gMLKbJ0csV8a1!d&rEl5XC~G<6`5fBd6z1U&XnFn|4OGhMkhs#}PPH&gCOGz`!H$t&Iv% z9rCo>enlNyC5d6{-4M-ClYY{;8&oe$amlQ?**?*|im5*-J(E7a zl}TrDCB!=r2VXA;tiUnA$^CzwMBChyIN2uUbDx<;IuG&ou8M7Y3l)B7Sl_ad?ktot zm6IbG(yL@Z<(Cw4MDJ&yzOH8bHSQ-IPp zB$L9P1_8&QtS}8qGKn~C6GnZShq#Ov03!v!<8b%-j8|i#%q6r_a8^=VJ1`CoK_qAC zT+|VO(r)<~n|W31oQ}MDR`-YHQEa0zuHK;JvlGwt#dE4WwXSBP%Oghd7CVG^Vu`!$ zBj(01f2C~rLh0nPRLc{E83;;`mygG-WL(APM#vP(ssktk1x;M=q{1aajwI=TT!!2L z9Dz?A?%9)H-Lo~W)!HDbAdMe#lE)=aL+MRf&e4@|{_Eg=0~IyHpCs;Zz+~l@BmA0y zV^W~`S3Lbc#=9ydQW0p{qC-E;Vl%B>^{@E4kQ6y`=OJTyfQiU#t*OMO)`0`S6BNh80SY} zFBwpH^~vZz`qdTO=(C8SKogy%hb_Rt&tgw%n&~Bh89{XOM(vp;m~n&C>%~JO%5K^# z$d$0cRFTzj)O!7DWYw)j(%PNUbt1!Sg`=_qbe-GA_%3+LyoIBvBa@-ORuTCmi zWMu?PFpVMuK4(8Zf5X&M6vJBiQCqw)Tf z_@hYIWZ=H?N!;HrL4Zy=)p*of4ZU`PpsN-Oo^n+6=RcJ_=2-Xrt}tJ52N+QJz|KDk zR?|Y1@2!cVwhy%1#$*LX@D6s7!T$gnmRM9oQ)(+j2_x?kzPxwo#W0A)w4O;Y<1PK- z20cd~g-I3csTZ3PNN~IIn^fZ%{ePt#y~@9ssdW&ILqoOO>S(s}kLn`@d zMUW~_$_GGt9GvIgs9ZC(-|u|Ayz;$InU;v(Aj`aU!jvowgyBgU-fG9Ap9)Ii|!EF5DhRB+(#ML)CN01o9~PKp!_joM*jl(DUJ} zt~w$Zl6K{DlE<(4=A5t)fDlU#K>mM)LlISw;E%3xkJ6{KKQUaAGn}b7QU3tfp_HBU z5!rmQ*!f(x-gyU(dQ_TZq8XH-!;V3~BRuoXM9czhB}$X)%{mtmFxi3t$f@W!%a&;9 zEN{$n##J`5t2ky-0VH#d-N)r!Mvbqqjw32L1OxYo3zPIY$2I5B-A3$%vUc*o9A_Ve zZ|GhuhFL|m-x0{00~5mjJXS6(Y;?|1mdDbc3Vcuh00|C_c?P91@`N5;zTTsrcY2e8 zE6ctjXwRhhibu2_b-bD2j)#+ye>#WZp1&TOa-V0Bvvnr)Qpca07y$nOz4}+6{7};q zRMMh0p}0(!1OEUW$nV~;sXIvR!B_TDw?=sC+NP+8W0EcE!FI7Bm4DCjtUXadzHzx* z8Np0{Jab)j^|`n6QDKm-cy~;zwg(?hYYy{8X&K7lwu7{kUz`E=H`4i z?*TtiPmK%9wlSOo-`c0X(xiYf3RyyeTNnc-s7<+Bg4;`tficSe=3m3rN=vRa-{K*Phulixg;`1jyD6>lZ@8A zlE)U;8<-wJ9l0Gr9`(-LT@`R$vbpu*wKPp^DQIrhP)INf0!s7Nl#{UN;vvl9*|jye zSxejlhRHY_wmXkXp*&HGjnVvp$zFK`ejV!ch#zo2FwYE+o!A-tJJnZTBFS0T%OrqR zImz{J&$qo}A2XtDCt(H7@ETkltJ7~Mp7`y>XI_P69$w?Tl73zXu+3>ldFDVCRY041 zx{>;hy}vrAda5D@P0U~Jo_keCO$_B`ibyg-m*98LBCaLM2+Obsl1Ko9kITM)O3mDK+d^H&p~R7f7|Udjm5&2~ z=}d-WINVCAo?NQ77ag)Y8j|MmHpG}@jX^jZ`Gob)r)qViA8IMP9$03sp#FVObI|fH^AM3jukV*DDepR(#WsJlQ70?1Ua2SkdpQmclF-aX%tk%1pv84EdSVV4xL@_C2 zhrwb;QJz4_71!u`$@>&C+~<6N#xurxbNuVgbiGsT`x50tLygjIP0UH;4El89x{Wi$ zzI;dbARU-^$Qu^eq=yD=K+J@srWAM?DWu!xhonUwPJrus}dF z(3d9|2h+djTxNt?Xd-RV8EeoRQGylg%kB8d8i_uXC=__1P`Z`E1Td+c zi)}V~Tvu?#Vg}YYR`*WH!hm21Hc~ z7&bQX?mx$+Q`Y=PF*&sP9TabFhaR5)0FbRCD;(+$qp`86UU||FBshpLQ6EhE{-3RC zS!&C50^2v1GPo!BmmNO6Ymn0Q^L=!SIKnROH*onKez~n9Do68yvG#=l{_=%}WYiTEvocV0PH#j{32etqlSE+cPU4#2SWyz5f z$^#^A1B{V@jO32B;Xe@d(JWHQY09?Y!R3!RKakC581pR*{j29m>|sT6vu}=BpK~$8 z706t1#y+*t>GshxD#oa;S+}-6SMQ&#aef??rM<^ibRdT(9;Y7tE3L4&P{&hH&%OsAgD;oE3#S`s;gBd`?n~4D9?uxH@GBv!xK4e?~tHDvwf=_<ce&j&sIE3ELxh_-4mG%Og%+nO*=0Kw-!hoy10E}{aPka4|? zd|-ATUs|zgf8;O#6=hwyQ^qmV{{XKUizjy?Y13 ze;-@NA|&jA)pLd55HNG^o^-0j>zJNM)$7|$N{-uMsVAk!w|U8pxI zf==U{0mmOM7}UvV>~>R{o{68J9}a#beU9KnC2(7ZC2$Wzk@Xm_Zt$0i1h-Mn9gCnW ztf%SJXZ+&67Wh}=$!;$}vylNu*vv==>Ga2X`b*)LiDigDq#1W9$=|hq&o#+9u97>w zpE2~ic>UqFVJVfFhYSJhkF7_g>+OFc%V#!ZUZ9K;copY*KaL(rSAQrM04T^Lb*loEq<~&Cdy1P5#tM+c*@M!VsZ{fNWkNsoomv(BjY=e&F0GZ zL)?x>`8D&_i{eP;xnC`$+@m8bFx+B@jIOe>CVe+TXdrs&nP{sOGeX*+Pt@^xj@>^8#(sPcDLRd zv$x}PzD03YX3!{K2xF~J(;0VTrl4PgomGtqd}QaYGgRlM`UL}SZxbDg=xY-uIz z(oa3!M2$lINzY2tvGDBq^I}8ggS4sdR-)6xN)pyAx@6-y=A7v}oYJnIg^eomJH|&U z(MGG5J=pqGTJ`GNfMHBCJ_3Q3db(|X$#4wd?;Mai6X{XhDJ=9dG+L+Gb2^^8h&(^-^_=jpWPYB*7GR+w{&M{vR_=3@_b(@Hl zNMRwy(UN(uLk9Vx(aT)NlL?#wf}{q|86W<-f#WhRW8~m4$3gycR&EfhHyv>2j-N_} zk)|xkfQKVE$70Fgjqp)BH^Y8Cnm}Hu0-jmSytK&wTMAG@pBM3^2{N3{mSJ@%~lz_s0tZnX!X1FUz-(2O#IygPQpJ<5jqMWJWj+ zNLEpde8Y_XE62Tj-Y54}?9A!SbJ+6O;fa;o=ga-lylnpVN8l=>?ly0;A~Hb(Jd#)p z5<7GHRheWDcZ+~XlPb7Bdx7oss-{(Il4c?_z+j+t!RUUzzSYo?=toh7u^<^pz?VGo zPZ`HOvIiA2Km1H2m9_&J`9lHnka~_ij~?{Ppe0e~Bxe8)K4PaFQrr=9bs-s6h>x8? z8@;jk{*_G)>Rq$CbA^p~06uasKNxC1OfO~edf3;~Y3p8cw{k_cpW zj|3IQU-+^!(~O#Ztil(U?7*Zkx6H>R9Py64(4v~K`Muj0{M3R+X;rP`nC-wHGJK$a zL0i5axP^kKIWhgxhXf1^Wd8t-VZ*v+j~mI59l7pvz~uC;PYa;j#@UFFG+Oo z8(4=cs~s(nWw^=1;C+aW zIabI7agI8XP10Ze5N}2yKz*Bra7K7N{pvkWW8cQ-Bg`092M>=-eKGk~l7}=}CQj#d zqu^R6o%_<`ARYne2OxcFE0QB4?NRxp90ksC*ykO%&*M?}dUR_wX$nTFpz=xG&ISjs zIjQb`m0np(x9Yr_n;HrdDz8hz0x%DjC!;IPGLd4k;n zO8bEyC;;ObAPzd?AFX6~VT+YOptdkb;duv))~xatvnDlFz{uZ_Mmioor}M6S6ZUl_ z6ip2q5*gSyNpku7qmkJ4=ltTfG)sXH0kW%{5wNf%^ZHjMG?H80x5}}$(oRDXPp*F& z-SFn5%;U=36oLZa<(rR~k4lQo#;WXRH)*4%k+#gQZmbKVC;$DT-!^Mz!M6KVND$tRP*!1X;3<5j%&{_Z`9 zZYyj%=A{#8HOh~}mS9~`w#fYa%g6&A4`Wu}_lh?>YOV5aWg})rFhBa$M%fRP(Z#qc z4cvf39^>_^X|Ys9z#vuuk&bbmJREIRGL9X-kGlUhq0(*+=@ zb;dEl18BfG`kKu#nBp=?8C6^r8~J0#4spoND@N1(Br4n%B&o|5&&~knryU9WD?V;z zW{cI6?LTB;8phjmo$J+xG6C#5)bYC{sJsZD<=VYIpj0ti2DOZTq)LH)@H?Y@0q_SB{>!sqMnv-EEpk z`7TIqKyEna(yB)q!*3hN#z#ijqdRt=r}e9G5Q^yM8#A1*Q<45PlQUWpS|ek0;jp2J z*f4;fZv84fg(M948B@@Kob<@;(9&R~z%Vm0Ml4A@ZS?+BkjAMmn4mGi*@NvZ_cArwofI>Au4gT z0*2|pBAqpvScoD?8UfXa8R$s|+tQ2NBAU7*^5l{&j)fh8ZVw8)XRrCUw2w9z5x57>uWo*oUs7N$9i||}tMU!KM;$#XptT!{GcFV*!7i$( zgCsUV&N_ZMs+R z>xUJ1`ZQ%E9h#1;}iOdsb^9D$R_&Uxpbdev%U z$;d2_k@taZ+=3|sydNkFxlTFsr!y-YIepm3+t~dnuNZC|v3BHwDW*i0ni4EK8*36y zc_XK{^QWSDm}s3t#4xd*chjPTHMuNECpaB>_qds(7 z@f-nGO^V2%V{2y}M<>@c&d*@77>l2jhVvMCf!|k*qO}QUL(40B!BhHKD2LV%h}qWg8TR z402t#0~jAs)b^||4MO&}DDsAVpEeb-)7H8 zy~4C<6p9Fo7~liApMT1(GHgpzF5))XmC0nuJRg^jdY06P6eIA*2OWEGf1OKp5;w}Z z1&>S*$F)Ho=%-IPn52C{&QD&3t?F}Ci;S*^1^ZN^XgvteO#U6It!U9is_M-6&PyMZ z_T%249lFObMR#^2;DUG^&rhW?@sYVe-ejCjhV{9)Hgi)VRrJbt+`X&C~%}t&QxNJd2r@Sdrv*!4jx)$VU(&g~K6jbb@Anl#7;)5R zr+;eOwYb1s@uD>t;u=ZmD4=uhbN>L=sr0Mci(O`4EttfLS0x+J zbC0DbsiKv%JvT>tdEoP7WO&HPFE%neark5Kty_DvfUrpvvpL{5IUPqF;PG6(nSb_i z68zF8(xGsE?>}YwFtf*ylU<%-vK5;Ga@DepSUmtOm7;G%bij7gkc+`071+`qx9JTtjP>mJo2Xl9)1zBpZUp&@)%|&HnB$Chu9C}DSuOJ{lK@E|0tg&+7y~^; z2{v>?x#)TqhU%a>AeHUNIC$A`54b0-Coblkt)ort?k=s%vd1FHmQ)1ekLy$E*Os=W zKrL-^5y)jdJx6Z2qbC6|dG05UR=6dTYhZ)N9A~%kt~*}1ig?}yGYIj9W5ML-gWPqkT}B!9D2_=#a=9^u z1Ao8M+db=y@lD*21bN%XbK1DwWPPZ~8wNmAZ<;d8 z$iJbxUK0*&c+HBO})-{O1zOZ zHV`pxF_Jk4rU$13^sdiMoj%Guw#F$KaC7=rrS;j0*s~R3jt5`=09p2~J96$!v_&03(IZk8 zF|-gz@RQu<>GkPc{Qfi3Y#G`)U@w9*8xVc7kC@i1 zE6WCH6p%81+U@UNg&Ik%Pd>eBkdL^vJ3DWP_TnFw4a%%e;latr{{UXJEPQ!kaWMN# zyGjkJters}k5A6L_6;$hATb&1*NUFn*zWz%&th;$6{DpQQ>#_mo;seTXQ)S$j)c0O3+9XkHC*Z4EW(o1*t zd+Z05xO0pSK>MSdd*-p7H*F5;Qf;QUKB4$6x|i+I$7-rn9jmxx5y2w|p|7ew72i*HX%w8Z{G&W$ zB<&#n2RW}76He~uwM{u$$oh9m@oU@JJkiY%Xu3vR42%r+sbTo4zF<^nP=WK7Y=$4~ z)%$8;_fxtv64E>eunCQWY!0_2-`1`d2(@IV&W0P^Zl9_C4#u zK0EtV@h!Mr#{_R*xt`h2UX}J&?6cr|oj*j6Q`Qtl^W;RW)C_WeQC~a$#-Fn!`t{Ok zUMPj4v}5J~`36DX0QBf9?Og*)jM+sEp$K9dVpSLl2W)>@^zay09a-V$xrI2tVN2%D znms)y8_=fIK)#iiscEr3`CN2fPJKF6>qw2eNRqh$0n29_vF-TNFE;ABS}HL>=NTin zt$QBLBz(*&>T;T;rX({j2P1%Skysec_9>+Uyf{I`jkDy;H)TCHqVmm4RFjyxo24mRP&#Bwiy4?B6sVh0n10 zuH)zVj~V9`PVY=;CBrchr%asWpZ@?=ZObBEz;^CX2T&@F{k|go+mcjnBRS1Xo%T85 zCvy)^g7O=uFNKlG00Yh|dOWSMlHj)G7yyoHkd(%PM+Qk-sU1{Su9{<(KQ*(RxB=ka*2L*6l7W_bCV557MZG^CcoaLmU!&QEF=$dXKM zqh!+QcZOUQc-R0tSH)ivZB4I?uOnr^60rxrPR73Dx>0ebNMpzfq!C{k{Bg08%g2{O zafN3rqaNe@KU(xKPSR-Pt?wg*WZFm=+@OJv;^w1d@|lPMh6yX0u_P-Ld~(h5V~%=r znu#MZ=NTN8JdaQQwRSzuN1nlVVN?#4=f4wl;`Z|Si=J?BxFBvEd*o;7UW~a6 zcLJpH+M~2b-)!OVG!OzRP01W;B z*T!EQY#Lall0P)D9%kYPAO$%C^8Bmr&yD(f8RudFY!SSD)Z`xj07mE9zB2grVdSL1 zoDH}cJF|h6&$rBfKWh2R);yI}`;i#hq4UMWGRirTcXd!Y0tY|m-m~WOWtc4Bwij}A z+PygUtt-+#(2PuqfZ#VjHb*)9LG=7;jPMBzNokKRah1bvZ2fz5uB&pL^yPFTFpbtl z^5en7^PK$$;ZB*iOgzxVLEHv+1K%T#l-5%L9%&BD3C3}Oj-U>tcly&HQ6$Obxna8m z4nXORqydB1gPNzV#*QV6NgCCO7-nT7Ax2I|I5<6ajw)t}E>#%B#B5yPo-u%U#a4$l zg#dk`LQ59OOo5ON2j1(Nn@^EJ!yu1nEWxsU@&U~eSKQ6Fv8|>;XOSbFSx}Srmx4zl zALClu5fN$|qxoZOsUz<&>;S5IWGfOYL>0q>v#}jJ_WR$ZXlS!6cYo-kbByjFeC&AX zpQTHtluG+IH_fbpB4!H4B13|C<$L<`is?Kzbm?pZH_R6Ra6>Ol=h)XhB&e>fY)qw8 zFDIbRPVZCFwfrA=B3Cns0x`lb0F#lDsxo2bVZzkYoZCvyJDR1NHA)ej1rCBLtY@eyEBG*?@9X zpL`15O7>_Zt!7`exkzqNncX)wcCg{Vm64`6GkxlkHl`aOevQHe>|UQPbIy8a z`cpO5b0`sQQPeR5aNr(2YQKf#FKRd9MMJf*)Uo4_W7oY{)KEnvMpbuL9OPu8jW(hRU0uvC$>sPvh(ot0gXzsdqmY`OL12zZ zA&x^SAd)=~U&@>$4QyD2*|4|{PVR&9=~P6f-%(i{0zue7T!kIG`XAP`sqfKc&R2sy zB54e^;L5DZ0e_g|X#{n^`r@KTkf1(fCnNj0{0ZgMuBJx|uEm1HdU z3fsm8SC0Pxm0L04)ZJ|;G5}t@_w@X$qDh4}eG9iq92+GCi?W3SJY#@(>?-teEv}+n zP>@(*HxjLpjO2ZBS*}EOtF={xY(lZ=%`!I|y(6J55N`P^Z!5Q`bRZk>9gecj!3>9$47#ZWC z=~iwbZR;CHhdDnn92{rXsVxX_({gp)6oV_0P_O_2z~Fv3?MWz8uu3oj0u>h{c6#*S z{{W3hlFl0hD8SttV~&G4{-2Eyl8OVSB9MIC^nFsoE|vi@T{6OqaIYqr;N-{LPuPYy98%EbI;R0^$p0DQrka(Z9EKt-*>6^q$V*o z6`L{*rSbBN9AhV(`}@#i4HE89Re?}sA5qsMIrYV2^k&xP&}t*fj3tbGypVqO(m%%^ zl}ck-lV#u%F*~rUg4|;#Jv&u+t=(nXk+Cd~8#*{gW3g~^!2bXoRe>;sfh>`f1o?5WBr(as!TR*7_lnIMj7Jt2je!9K z{X15ROAhJX6z&0rb$8e#WckpNpdW9*)eS)b5vqm}T4UELkddf~n8S2g)i#Y@#PZtffIGjOX#Fk}$e&E>2g=iGVGRS846fOxBFH zEpF1q_Hw7Ioa1Tec|3EDm0hgFh2uVA5XFj-#xc+ix#?FHmCso%0t<0&q#0RcRyYKP z`G>B3eJaw~?@&|+dBImB182+zKp&2I?@-%DuW*sf(U-|AX#shh^IQAa6=ku#kO6bHw>ok)MfePRZ z9=IbN@lakODTYUhQvj}1@z?rRkkOF0C|yIcby1U^OCJ8eN(7#3Ae~DtFoY=Tan#eN zMUr4bms8UnN4WK+iV%~K#0F+OAG}6AKN{O{k%bans%}3vO9aA@MMmykZ!H*vCxemz zJH%$J{DNAP)563NA#dK__bCI0O!AG&$5Qawx65mPKhxu1RHH!}`^zVgV!F zkcb!!!{$7{*^N$&K^eHD>o$d&N_m9xb0HkP_jf)+C;7L zNhE|vxPy#0t|};lE<}0xK?OnFNcvQ!J(3`a-x4XwE1VBOQC)(wHbba9DFX!Y{{Yvk zj>fUrV#|;=7x~Eqv+<8iR;``Uk}TWIS(FpPj3*qQf4f+5;g~r;GJ<};gdgX%jxoUJ zIX>r}mDG4`?ggw8GJ+Hk;O)uIG5o(;;^JSlBx~|8$Uigf&mD;R*6)We&Ad>?`;4iV zl#mWR55HV89xK-va4o`GI`3AadClCj;x6y=cM>!UoFiB;b1U)bu?@Dqfo%QM6I%8V%$(^8&In zvkjruPU3w>>&10CEb!c5N*qYJ3`<~-PBH9j&9nrX-^*x}%#jSTDbE=7J^4He>$Ho< zn%$$086gYsK8Hqa}snt;9gP0z$FsbN(2vi$(qPOsVo< z5wvt;*FUHgmE3OGrzO#^6{BfI6%N5GF5|l)$344J+%nA4HM5Z+&J`D&0na~;H%7f% znH5>1ZcG0?wij`@Wap+vdGA~L;Aq1OmqoZ405YGS>5iGMUsIK)mOYWe(<+r+yyK@_ zj@6`Q5rxw^4REd8Z0^Y%QG=MtAK_e0^T!Y+qD5R52ozv(fPTFGwb*OXt@Y9G8Uk2= z2bg^Tu1{Bguv{|8n^HmvJxTZci5T{-h`XbnaX8NwX>qlyC9VNi2QQ3CfC2nC>F-`2 z@dLtAeWqni<@YuZIL}^3ehqug#i5?oHP}u-ZLWs|V0R#NBy(IBinNC~Zz?u4i5F)Nn?P5RKYA4EcD-1n2pR>!pfqT1c6}By)OhzOX;< z5=4#gLELv|oROZ{Cy#$>=p)o3mcR#Kskc0~3jYAL(AIvV;Xv6T84Cau$hXmcmca$R>%266;amCpCW1`X~_bKluwMrJ2y1nfNk z6|j-q3!SSVYm61nIZ`@<+o!i`lS!r#>RO!qR&1^F zD-qK<2bxsg^5%^bS2ALh)>k)^{RvQZy>5L9B-`=fT_;Niybr~$# zz*S$bQS{H}Qt?XL5qQ0auYxSuGN@y6K-{VnF~)iP>!9$j!DFoIq4dUSr*FDO&=_D2 zKb*aI;bhj+|4gopNK3<2Q74BaK{2%7r1k)J(qCy#@*|hfH@y2j?_OCj& zGE~v*VKC~nVp#Yy`z-4hdW2C(HVJYGV}|7Q=RAy_bKbtu_*MHS$)rPT_UWQ&b_`^I zfR3M2T&Ka$g+|m{&d)TLGC5M@?oons&V6{VseT^#dR<1wC~g&0LxHu|?-SqNylLX+ zUUpj^h9Z)q6w^4r4gSXW9xc?Ohga4nf+9&+5lV)~JQiPZUq}20{fvAe@cp1IX8Mij zK46+`FjLyp7Kd_5bam5XX`lp`T;tH5n5>O5`X`wTIXjml zBNeookCr^gJnh1c*{^PdIT7=9CgkqOue9lIZDNi*Zaldsr>#`6d9K=2xL+p{Gl|Eh@)Dzp@y0mLjRIE`pg4)sAJs=S63J^28CODgnS9b6N<(+6PxFB&2Tt0P9yNZ>bm<{hv5vo_z&u zX!?{)xRtjIZX~zqTo>eaX6b+#PD1mixn@~`bnXk+ogeV~aHf=R|Ye}S*4)>d@dcvI?+=zKklZ4-b{b36($t)Mp>9LKEe~DaquFV1u6Za%l<- z2)6~~=LaIIIC1k#!wx|i;GEZM+~IqXMw^;V{{VO$z?R^3r?b;tU%HCGo)e54&G;{CBfy(b3TPg|`+5h3SKjOt0%-8T@$CLJNSy zX64Jp8OYAw5FB*t?0Kx4i;dgga};n3 z`CtW6jP&c;we`zk9!jQ z2-0CF@}yEWfXj|>Pp?k3X``msv0~CkSlq$;wOo9=avQe$p1nW%)k{m-?i@Dpw2-5M$JZZ+O0e=9yRg>fa9HgM za6tUH$LmSEB4WMMq_>m{X!s-MAd}RN`91N*2f3|(56_FXQWZ$xWc=Cf#bQ|QTVQUu zz&o?k*n#e9-j_DZr`imR?T}7#8yw@-rZ1W#&0QUA*9bsmA)QnPOkn4(eXB=7xL+Vd z>^Bt*5R;5#<0sc9vz#%B2b+@W3X}J{bJO0gXfGrZ2_=iojUSfk2+tp=>0L`TfpLPkU6McE9S#jt)Z+~CZDYBx zSfdQ_$MpRw)w%L*@MJ$P7$AW7JZ;Brywn%=83eKw&mdC<~;SqXnFFtC`QxM_h0>b=lm;j>g6ABkRetl__}fWiq?6)W0qdO-mZKz za8BgGIIACr?p8^2jz<7LM(NyB%*V*b&k!tIu5s)Jb4-Cs=XOJ25<2JK^ry0UOb7vq z!+Mj=dJbIL8b%ge#m2kyCT}kx7?F}m8P5cIX0&Z~;aWVII^~-@fJX#_o!*JYj!oIkT7Vmc!os^rF`7?B=yNZ&Zhd< z%4*29ZzC-8Ng60PIKeEWbW%n=%{p%~HEp0s2pdo^&#B=3D2V-)VIu+Gkh@E6`X9=a zteYf#xpZuSxP~Jga8DeQL-$ORL=#Q^o;>7nMB*MN!+HgU<% zOt(t8c34h^Mg8Lfz^?!S#z?By9%{xIk+|{!Y>=m;W7mV$x%rK0$q-&kws03II{yFx z1Q0kpj9}Cg1$TMVaw3oQg?UnabK0z=-eWxXZjmW%`}iHOMnTCu56-H}!hf`wM$&}h zOq>og$USL2O(OKV8RwbHhYCrh1>K3p>ym|zwo z3fURL@t!%Ue92>xOkp;bI4#_LD>>qD?uUlK|406OqnJx}T|Dq|4=iP)hJ%d`@w9eVN46&=*B@svo^yB*l;fP3fg zrtXQJ%EfK%5j&uaCPJDHJ4JNW!XXWhR!Rb9BjIpqHUoK^e9 zJ2xT%IaVX){aR;{}Fy1NW+V z$%cKw9ZpCkj(T<#5po%_4jAP2&-m8sW0|2Dw;ZShuTneGBa{TDaC(lkfwss>;0DOS zJkrJ)l$gOd>T}odr(q&%7UD)hz#Q?P(xg_6el3jF?hKCy$~0swR<`k0o=)&_jNFQnae^Gf2Bx z&l^eV2UD6zKI`Orp0*aJv|6)1cuaE-w0XOi0|4YFPD%RIv#iie ze8t3W4(@jlzPbAQcB=O5^2v!7GzvKboP+fIYDc`M|RH0{a zw&m@n1@{oEyOZ~aL6Pcn`c%)hYr+D{kPBhhoxETG2d_T8Dg%07Km%)mrMt5{hVV+Y15?N|ijGxapt%wIs5a3P441j1}a8ic~G;L|7z= zp3C=t$E`}n22cqnIZ!+8^#1_s(CoD{u@XejFP0*3-AO%j>r%lym{StU44{$;&mNT= zq{Pa|NOdX>@O#d-%e|x)2|$$X_W$|9aWFYdFS6h z;arvDa_?iNw3F=NLbI!2vEUNDFn`IdQFIpS0VI++VV%qObIw5@h^~7|yelz@#}hd? zE1nN={uRCC`Ew~&T!DuCpLC8~oUTJ)1CV=m{OaUaOwp2nFm)qfT;y?rdCBAh_*QLQ z-4^2Qc4%1H#UjTV#L4ook1xt}_V4{F%nsIYCz`AVb`mr44*dJ^P)$GjCLSaVm>n=d z^&fz!Ccb%XSoyOs!8uhV3}gI7J#JiCsC-BSVJ$a%Yd!X;Q2m3voKzed=xW_^HS2diQI#WBF7e==(kh9Gi|*_vbwRHKV89NtZDw z+RL|cHz0A_vF%*;oohdsS)`4l122p)Z(e;z6}@Svqg?ryNMm*+1JilOP&3aJl%#Y; z^)z(*z0Dh3463to!zTxfeq*g}SzJoD@tau;YQU*R8*(x0_zIyNk`>u(*~jqb1d)O1 zkJp;FXDotc7sv#xc7{BK^vE4PxvqK3Q+Uez8@6)Ce{dpuR*q)@cS+C^M?;SJt8!Z% zIpp&|iBl`IzI*kmT10DcAz5~xGa+PVES)evK5IgF)uv^hZ!u5+;fDx7#|Q8=pD|K; znA*xbQReNPn2Z-@BmwMk?_5WU?mU^`bIfZ~N9VVRnQHcq1GU#zk}fFS2>G!0;707$~O)p7`tA9eJ$`EZd0U~Y?O7{Ja0Hyv}6+OX~P z(J{ooIv`?EPa~fBuD?~YfZWfyT`;X6XmUKldS@K*S$Fz#N59E)BVe7R9I)W$B=s5T zTJq_k&pO(en!cMA^_4g^Xm35PZHmF5yyW{)z3J|$=KRFsib-T z0E9jqyzy*hDggzY$&Kd&?t#zaUqXBi@MISdrL6N0IULI5wg~hDj=d`b!QLHTJyA+T z90UX|=U-04gN$aq`{1|38$Ax*?6BQ(es3{XJf3!e`1P!-MK!7E;p*}$r?KiE3N-jF zqP4K|Ol(vf5I$UY{D7~dejn%`*zFS}s_Mgd-_Cy?{cFU$2jJUyZz64Be71k}dk0cL z;QD60&%m0*Q%M!1dzG!mM)B>B#%bfI$1lrC@k^^)14HrH})bJet3#$k!GKVfmTcXg%?foYz{F zk;gTu(b!!q_OTgQgx&Ld^Zx+Wqgb;^SWJxBA2$cTy<`9;ju$(W!ZH*$Ut?Mj$_2A5 zgq9qTIr>nl5TZ9!fpt4+KGZ@Jk`8|FO3SpJ?Z`<^*z<%Pa6RhuNi^{}W5kV+4`Jy5 zAVJ^0So-z)*}=7dX$NoRikVeS*rb|z6?{qI*zUZ!BKg9uNGF`;vm(*9yUj4fFdk2K zKIXUVZkFobef#ZfZrXo@R2tReJX=*z+slBX?(X$I{i`P>(AF)PmN$oNlV3)_50MTy zKJ@71Q*;WJ2y#FbXI4@Qlwh#!0araq>^~}}EMg@q8xR#s5>N6!g?ZSRUt`e2RM6-x zHFIkadC4RWgQ)c4wk$6E$bgg;4l+lra9$kOVtq~7-^^(6pdNQuV{XRc%ub82kUmg( zHI!YIi>Vz+FDzE-;rx?~i~*XvrP?-~8#0#RdC#pf+91(0xWQgM39TDw!^7miBzLXi z&T6`7h^?;oL{;;izgm+|xeapaasV9QQ|%Q$c!v*;;-px3I~Q*L2Rzj?0(jyK{0xfl zAKNMeCDp0uF~RitPj2<^u;v#TCy}1D;h(lFL+qBXCssUQ*4S5SiJ4T``QBWTaNCA> z81L&)%NUJF;1l)hgZWg=81g`k$K4qij+$K7uR7bjj0-5*K*Jr~zYKnL#hue~k2S|y{L071o;>p))$W`} zj4sjv8@R?Z+mVy_*Du0zvfUdgdUs@zu`!X*Bhjj7lHDT$p?X5OJiFQAeKo%EL(5)c-_u_ z3et~k3Vi3B#N+OU$sYdP{{Sk@j2P!XXxhZD%oiZzf^pNHm2xpGFmDVT9(J+8L&h`5 zBif;vRNWi;dYHDf?#FWFFnP}dIR5}WY35~;>=t)otI2MygN?W!TB&6$lBqdTWF>Gq zlgAkC{VMcIts@Bnhsi22_W;5DO%&DcX708+4IOa@?@`=>N-H-D^d6OaN*+{)2+JUR zh9~9V9tM7EYa~Gojm@0#Bg-06f+EX{Foc;0z7HbI3e)IrTK@M#{{nzGY*s z(_Bjmv%4W^#s~L?K;ZM!`qhgdsWI~8X)zw)pd2SWlaBm~rDW@CY)ns-PR+p>#{hmm zja;;8jQ&>AnPDkk;{*UP$6n`}-FzZ%-8SwgXyi{dT0G~q&ZD$^qX4)L2p|EFGxf*gT@37!jAe?d{GfGS_!;Zieih1iVawkJF`dL@vyOi+ zeg$0mz;6ao;^qMt~lF?mN%3;DN(>+6;C++Rnlo2 zg{kw@hhdG(cmRXjw@Rw(nxc?fQr!1GWFjIQ_Era+MN&BU6OK=BV`sdS{-SEPBsj--rh?HqFz{Yq#txh_lT&)hSjT_eC)g^0k?5&U- z^aJVnieXIJs`HE$KRM~teLK{)sv6ZpE0VcxyaAGb9-^}^uB3^bWh=RUYzE5vFW0Z< zThCYyqxVgJ3AB%Le4&<1btmrqd!O*GqALj}iC-~|;5IfMGhC;KCj>;~e5EqXr?1R7 z#yI@zq)Wo60!DBK2k$GF4=!b?ogHJT_=Fe?dAoxVgO5s-$_6~FhwKMj4n}Gx2-#O+ z;A00k2mJF-M2twO^I?exKPd!^0tI@JT}`D?t&8^1s+N4H^4yP@l`rmb_32kEPfQwyt*MPgeT!mKY=g=TCO?ZK)Mps!^rIR#TyHqci(>V1#I{MSnL^wNKt9S`!TjoS6 zp+?cpJLlAU)GH`qhh=l_Xz0?efV0k@Dvm{5x?_{hB|r`H0)Y zy-jPv8_R^GerDGJ$IX$0ob~Dd0M@9ZV7X{xG5Li; zws1#YGyeeApvGc~Nr;GtbuO#a0gykf4{NqzJmQA#?g?LTiHb;qCt&2A z$Dqf28iw{n;a!8KK`2-ej(MvRGh4vw$8?I>;d=Mr6IFErn;B*(pc4Cnfx+$d;C^(I zvn}}<>l|=^Wh|hQag3_t@v5(HxiGQ@Bb5WG@1J_PdcPn|+kj9QjAMRkrTQpVSkah9 zIU|g>IsSFi0b0nZI~+*SlslUwAG{B7Q7{umT;!41w@O==DsdWME(s_6`&4CuqhUdP z!0tW0tD!5Sl1}eI@yF(EXjC~I@_)~zH5Hj$sUrt1&jN;!Z3iWNPj;lX07wkHspkj1 zK7^}ii85Im%q4O=8d&5{B!<8z?-o)>^ds}9OoMp}pb^s?QY*VYR4Gx0An}t^xul}j zmmFDD^6$BTBaWPZ`sy%p=xzi03>**6p9&*4&&O5CY zB!oS%yyxDfjlxfpw{h&lDn67cUo5(O%;24>FjxKsLqx(g5-@C>?*os{f?~DMY27@m zaT@j^Q~ZCWET9w%7;VenZ%lhFJeke0aB_mz;$EOkTiar^?Baqsi9jdhK@pGl}RTbC?f#z-mBa? zNUU-Rh+$gcXX7IZ88gpu*Xc%p(j_V8D;Z-BfkxIR8$Ag1?^LdBoJxme zg?5)?jz_*fD$zxbR%ehSC?N>J0g(5{ZgKh2YHA=JWGq@`8DwwMRCjRE5!! z%0h-Gz~PQZDOcNJ6EaB z2lT639E_-L6wk3k1jiib7!>POaq}vmC^3>c=e}xrA!wg2AA4kD7z4L|#+wu%TNu&K z;B$uPJx9GZ&U-tLWLa(4pd0`X8OioNsz_mtR}q{Q9B02uZLDmtA1@3;WU1ie*i?-i zQWP(^pvs;xlaWcZY^87M=l=k&(u9NBj-&9Zlk5eZJ~R%3&f%4vcM>_|0&$+drD|DB z79@$8Qb_S4xKp^VQUE>kn#q@BlLk<&z9Lz1&(LwjT+*-J*+U)502m=h%uh~xbgU{S z^%SU3C5#0q6GvjWZzFnxII>06q7sEfJgiBW&n90Eu^Mt>^j>@`q|5wYf|KPn8D z&NGA49@VoAyaGG}@)8x9#s^MA1d6~qDZ+ubkAAq#TG8%4%`9mu zF>-eNz>*sS^sK0|Cr3^1y-xK#NCS+YO1onLwz-kyXLEtI2+3zS>FhZC=|SC_xv47~ z7qDOzR7Qvlf*TG;IURcSsX> zZr_b?=_TB{j1CD|f>0PBPA$2HVxTDePkR#o|nZgbplKkur`pQAOthiju;fv{>W zvRolkyP;1^V~_s;Rdsqb`bz?vfw=C@@z{_sPjmQJ5#gwzzK{VXSyP2m%O2wg@chkp zz8cdO3nV^RiPSQ>G0zysI6Zn1T)gSFj)==+pwKmzOLZ#cI0OdH7fv-IiSyFEG)_H4_7Bgq4*;A5V={{Z#s zW|C&vr5jaF6z9mDW+6kNhW&gYm2x=9BD9QoG7~ZYHmSiQ1RVCRbHsYfTSoU$d2pS)Hby=3o~P(5I437! z-0!(p#TwGyOu-X!;YR}pI6eOW8s#Uul1q@GRw*zC9IB3)`tT`VUW(_QkQm>g;#0zt0EUwJ1z!0r~18_;mC!U|J zaT=bYxA)GbM?;;?dMf(l`VQYyUX6Wk<{i_>fqre7IqSv;p*(f4`J4^;;_ZT+uN##CnR@Kp1B;HS5;+d*0QLXOpM8ls3Ty`eR_NQRCl@* zLg>6o!5Cz43lL9SgPPXYMpwJJfd-K@C78-UQM{0yj|2?z2TH32x0`-eXY%pE&Ou%d z8odhd!%$t?iB&_$Kv9Mzv%$uDj%%5_(<0KZW_!ZYGr3Z%4oeQhJqIF%7R2QCAEIb$ zsG*$1B0F)7*k2P7Ukd)9}Aw2NIu%LXIKP)vCYazW?$ zioL4nNuo;=LgUC{9n)w5MmiIoy#D|?9Q zBikmkacStyDW%Mjt?>Tw4WweIk2Dh7jUSJm( z7|$N{;NJ`MXxi}|pu>LLZC#TJAr_30LuRWQSK{>wMOO2 zdd>KZ|1UuBal@_sEkdJ2!HqdVXY9tKE)U zj)3vc^A+gc9o58k+gPf`bDW--{{Z#Zg#0DdA6mBv%&j3_7dUQ7{y+-!SS_a0(b=8W z-*3uE{{UD?0=nr{zKrp4RQ>B7i*2i|waNe_l(5LyK2h~GcHdc=JH}GV&;gtdqPedO zPPWP!Pwy2nbJOyzOS_d8-hH_kFGw3U6Rprp*_HCM4mZv49oNZ#jmKe_N2R$nF zro2)FX^SH_2cA#4u6}Ju-}zf1LXm^>bQ$BdO?YK{a>}X+PzN=IO3o`oNKwA$Luabu z-9B;9`ZXoZCZ18zWp;Arzdv=`eKW@H?m7|$7yZ`m<*PGENZ7_-ZDt8iAhw6l{?dsQ^F!)d{%QBZCoe! zi6_6OU(&r}!|aoJD9pGYc>DTSgm`1bzi8I{mBUJ8+Ii<_9e-cay_3TmuOW-=2g{ED zFRO8%>sUrMXLWs*&WhsDNUo6*LFj#bYHcwkw4H$&LC~6Qwa1cDIYA6~9q=mbtUl1k za)bb9@vUPMhEnJdqT7aOz{>-kI@EUs%f?rd4?uY}VmU;ZNO{QXRjwouO^$aCgi~=d zK=HddEN}q82fcXL?Rf(K0AO1=1P}&K<}qH)EavXv5D*mxPJMp0+nfQ{>t2&MudRq5aU{Y6W0SaoNAjpH7yTXo0F4BDn*cC9@%Un_f*#zd zKsfF)4MT9kINt_B_$RMHkMsG^w+Ew4@vn>40j;HECt<)m<#B)jK7ax%m;(cmSo&ZbQ~jg>Hh%N?OwU@ z(nW^nVG7%oS;-s{0bDQTUR9{W_FHC(6>X=Ic_3ha_3OgGMf($a5w@x5R*9Q(fw=R= z(T+M~^HKT!Lph8rk({rXRBdb=;Ag!}dvaRZBt~e;Z3??{v>XG1I{q~SffSM%N^qg9;2_)lUkEeYK=WA;o5mx0)!8~4;c2x z;A=^)41g3x^8WybJ%Jd;eR)5XW@+-Q5|Jq<&nF{xNI6{gtJip%0fQ&ayCCt59CrLT zr5DR95dIq){v97?MUZ9MF}oWxo;nQo6|rS*w+>AB63K#vmnZ9ySXNg7;4E2l7~qvZ zcj?>nt*s2M@@5||1$?~W0PCM_N9##TQc}?9^vS19HJf=;k_!L<2aFsM)2&`YA_d~w z=^4o^K_?@rIQ=Us+Tm?lM&b93pf*pYHv4n!R|!`;A|>SKC^3=v=DQw>V zZcg_NLN;Nf%J=f4=P z2F~ls)DT-p#!r;2MM&F>4*hzX?=E>|BNZcH zI4hhsbN)Z26(P#hjP0$-BSE!s5GbUu$m6FY^{uZC$j=eOIlu%f1B?(x4m$L#a}viA z`HrZ80694sJx+7QZ0N_xw{>hPxfmlk1K%IYrmpFXnrU4fF}0bS1nl5r7zB@@tzQRR zM6t4_!}p(Q7!99X)_N3=_iMD0f7UY<&$UgUVk`A7jfppIA#>A@UvWzi)Zl`zL#(r1 zjw6hZ-7y1eWxD?W^{YEv#PXxco42P?}kHUdUWsZgYq!OnZu*wow%FPcF5)&_vV?O)3$ z54@HB>FwIKE$>xr&^)6dKs#+#fUBZ$ViEldl8+-wM%gkX_T_a(v}Of zkDGzgk0b!*Hrtd8pm1D!`gEjw(Ia_+M-7djFKqU~{{R~H6Sm`RJqvbo!s zoOK=X+OERlaO%hj!N?gKxCfK|dQ~`L`)n5JwN!*BaUAjMS{8DNZy*zGfmm_7<=_vz zc<6dmUe3ovWua_@5=L!Z_Bp{-;g}Aj9>W!3Y}N=A$_WXDizntxo`>nrHD1?j2NxRy zl`G|*NKueS>v&b}in%V_0?fy*dk%T010zPy zAXyqj-G)+m_2Z%EtuoPfT*l#%x4z~euMtrT+j&2?MZc0pX5v2|a~6;^H}B{rb7!f~pF9-h|Rz;Zas1Q9>@m zj$7B7nKjfru09|wFYZuD(7)H^XMe@lRQr~zFe`SEV>q-KBJ;mMGA-| zL^xkhtxX!YnCk4FTA|p?`6>7T?@a+t3WKx_qyy2tf$N%n-Kyp&)d~TI0Frp;^5U|V zXDxr<@)pXRvtv7GSe96s6~S?oI5;0uO)$PoK0MbU zNF(@VpUK5!ma}W`4 zkoC?9BRQ(ZF>Z04r3NKm;yLfnZnWZbFLo%WbC}FwgkY~#I0rq8eR-_w$=#%P8(K~K zu)KrEVffYCup(2)Di6x13_k7_O9Z-t8hb+BOlK5=qZLg*mx} z8L$UHbImCVH+dxRx#V=DRU$UeC2)BaMD20wx@9CBg&^{9O*k=iJr3?NNBQ)oDvgA~ zrJ1{uIQ%JN+UFav4;;6CdsMVvKGzm6Gim!et`z4T`KC#hDi{z44UfI})YkBAg_?9A za6!*JdQ!|cHu*ksFnZ(gqUdTZp|GJF(MCoI$L0PNBq45NZTVtP{qupx9`wLA7b;{f z4?JXjJ-X9Sv@97}P+(-QOy|8PscTKvg86c7JD*_tx#&Oo-D%Lo$^doT0r}XrbM&UP z&nEMBN~32u9dk)33A6zp+`w_)pVZScZ7C-mhqNr)fFe$C2yAoor(#5qyx%ecMpbjp zKbU(dY1yxo>qj&UyiZN=uj2%B_A_>dZ|Cz`BQs z>?6BdMz^+&N(q<{I&>NP#{_Almw~0|s zU*%F5kPZjb;<$ZJK})#hNT34XZU_qn4n2B%_0MYOl&UwT$0DsNi1EwM4=tSWl>=+Y zOM;*fF~>iJVczM9lXkw}WhJne6nkM??X>S@!fJ>z+?Zrg&JdY(U8mKa?`ZE_i% z7a%AjCp~l10*N~nEA%ydKX}%tkj9GbAtW~?P7X3gKDE^8*J~t=6DCLAKtXOW3G3;f z*0{|+VJz`^hBqh(MpMFk+~f7F?H2jsl2LN_5-~8HyzSw7k8$4>$5t+HLQ`n#^x0BX zVK2=f$;j)DI0SU7wvzcuh9CrvTlbBf%aS^A&mT&_vJG;LQRXK zcK-kn=t#i#>05egJkx{ZjjW&o!wsG}$9}bur$^+o2qRpK<#Cop`F*Q*Oj0A>7zdCA z4iKN0f!J_=!llORQ=zq^I}Z*=3r8IAAp-z_xGcCFk~;O`y6Y&`cZv7M z@yBcf=A;SdZ>8w|F4 z40QMFT{Wb3*0Ghgjl|KW#5<1SN1^u3XD-a>y{vQ=+Mt3gY77B61%df~zm9#nRQ6hr z*_t`+WGW2_`AeF%#c5qC{B)X*B^A`dm75q@4m@r zCB%!EhF=Z9ZJ_WneQ{jNYYnbmnYALD8Hq+=z#M_V?s?$V(S_Z`Dz;?0rl8W@Bq{)3 zF%~q80;A>xu{i!!O6?tPOo?o&(By;JbKGOsk6x8t&gi4a(#N%91Vz9li0VgNbsv>l zu`4x`NRhhWY&%qAJPeWQaqnJ(i%9dOSDsfSYqMe*qnG!JrbYw;3C2b{bsw!#mE^mJ z&AIbdWy9`#WR9oUXEjS#xglIL{^%X9g}(Qv^{V=3LfAs4eA&&UcB!-7@^Dt@Abj z;Bky{MmzgdD>K}dUnH*?Ob{?nU@Fd|rnaA?!F8s)x+*g{Q}b|7&F#Tjhf1?+%}Oo) zT9s08+?O7}9=-Egt0kyLO`Tqo1d9YFGKcKNivmB@^%(yEKhmjb`aD{NwDMi67aWY_ z=NqtbpIVa87?`D*ee;tZal)LFk%Rr#J!@XyNqcP}U8Qjnu>wV3nUpExJ^GK%p~$Id zi2OI9OC$-DdC^HnD}X`AKr@_r{uR?{IznnF!drhjXWfKun?L|?M_+o+)3qU`$Y!^6 zMPS}y<0Xd~=hXbat#vjRaKMSO+_a~RAwMZNCnWtvV=1I4rgQcl5sunsRgF-y4dfi> z2RIl5lf`v@AJH1?Qy*xByr2$uD9Uuse=$pD7R6<0L$gVi8Bz0l1MQx**!WKHTX>Gr zTLp$(EvcsiE6OjpU?t{s+*c(F{_gLKKq&=Fjf&+wt|UX3{jrxze3( zHw2YK9P~gz{-7G=yc!|bng)#-o+1mz{N$624{kq|dR^Qr{h2J5k@-?6Bg!189Ws6T z)-=~UI%>(qSm7>G+4b;cQZ7r7=8Nk8k+og9#jiTEOJRIH6qP_;} zt$6W*8VM1y-d`JrM*wvlde^<^aLIfCx@W;5+Z_{;jz1A!E&L6*xQSSzfK-#__fA1P z``}m8KMi#xeHA6S7Rz?Imcm6 zm1IjL5TRC+054!YKl=5dpx;6))VqcOLWSxFzvr57h;;#JDhRfGqj5gn>v>M<=aU#V z>{!xt*v3WA34Bun#j>SOKugJ7#*bNC%shEJ|)^qI6(PgN@G7y z!}X}=O%?2=b5C5-0@x2XI|=9gukfxj#`Xe3=4qu$qWsu6`u%I6@lV8!I?lxm_W^-j zrx-Z=GhR#MACJ0Cf=ahH5h6x70fyoSwQ^Lc6`{QvRV41BSeEQX9BRm_Pa`Lm8SE=h z!+M4l-Dc)S;D2+}{{SlTD}Rm}Pl@$c@~(#E8&e#BJ$qM4p!oXQB>^RmVuO}EWY!Uy zPWL*i(UfDVJu2&1iDQym>0|>TLg#`%&TB%`Q~OjX_UfvbE3|r7n(BTxit^M53NUrX zLFxEb=Z5@1&}rcL>RHeabpzY;uSSJr(dAX8%C0Ykb+K#l3hAz*2o!EpkKpJl-MlHU z2`pfe+in#=0lRUE`Nu=l*IDsJ&Gw!iL^63xyP;9(Ufbad^}o{*q{;;Of^=i`ABA;A z*_Vm;lCkPmkr|dJiw*MRlU1%9-aM*H2FLQK{4;vZ7&9>gpv`P~G0VN-P;a{*Y%?~GEF*m;#EoVDMV z5G65^K<{2D`(VzneTFFaF2D-oxvy@K78C_@yS_Wu&;J0mmaLaL^}HD*st^x+@mt|n zzKkj!F(3glpoQImlartFO+|u)VNU~Y(bQw>^{13j4&cOt*Er^*B%`sBinz$>l6|Yu zcDcs)Ba?6emnASpG0*8y%&!}lC`z*Mxa56mSxOkl+(BS+PBOpe6$lC$q*E#RcW}LW zaqm;qnQD3O#hY)mM{py+^9}$}fgqnQ@f>ew{{swc^(n@}<81LXZ`l9J%Sh_TbmQ{C2VOXPse3Ss9${1Fi^CJ@c_^ z!nJ5xAXaUv<+lxTwyKDZtdW-_k;vEp&OhF&Eti};tf85( zz;z!_Ph4|XWLLHX#-4O;GUc<5gm81mUMif{{!~t5f>ttTu6aF=`Sh;Fta@>2L=4Ww zkC?JZMtrcXmE+%x_4TKS&yxE>?F0;N!zjl>G4<(7aKdLEdX+IZ3Q1goJJA$sk{4VV z*>Dax1HXEfu3+TbF6pS#BCuuMgCc@9{Gg89{{Z#zT2``_x;}C^amXvi?!zGVMatv(K#R{$9F@58JD2!IQ(kOy~NW+jmQ*&f-%*OdgmXdRgT@#-*98{{G*I3 z9GrLSQrf6%p^&Y>EEk4W!`W&7y)^W7o=M*bJ>^LBPHQ5kGxkuHV!fES{g1* zxJQ5&j zxW^~8SJc9+1SwOsKqMYlpzDu*Dtiy#M~PT$bzGbg&JTQ4H|yp`$_f+45<~tSdREwH z&rB)Qyh@%MhEKMbiIHD-Ae>--UI6C0810M3q!_>>IKVip4IM?b(g7yi22cpe9rMp_ z-;H#3sYbJD$*u5OxpliGtFvoRiFEsPKjbJK&=b*nZu zb;w%c-v2?#?l1bxle8RPNq z=~>Rn=vrolvNV?hONMbE0dU_j#(hWE^r==)C6@|zhQkGXmnNW{gzBazj@-6J2RoZ+ z>_=L$Z@wsl#Tk-EB%#3rsNm+NOHvc&dX_>DwFR3a3@~<+oa58_)ww``obCa>QdNHF zI3VEuHC`h3LEbSM5`JO^F^p$})~p3ctv3jn@Bgx!vT7zk2&fOsmE%TL=7-I)%JAUK;V#e^!DPL97?UaB^g|vQ=ecx z{{T8#RN0CS%&B=N%EITKKp!p!4l|SAkSY%#$tn9@)l>4Ga0xvQI*x{=d&z`krWu*C zqC=bm)Hk81yqqDDUy;~#Cw4LDao<0sbICJRB`2u`()p-jh1Fs*9G$oXamIeQrir)6 zOpe}2knnij#~2@g{OU;}XklXQyA9W~j-U^EjdzC4oM?^7RdbvSj!ztnbg5U^)(Kd4 z8Ym@ewFTG(W7U}F?w?L7!$5)-nkI3zv4PMpao4s*R*g)uG2695o$a23JZ}F0Yp*pp zV+F7hkt8feS$W1kuOgyb=u7U2Zf1>GEKeGiA(&%0&U5`K-g?Np)yz(0$nUq)Be#Ff zqcSAHVz)(XI6Uw}ZOP~H6zSAkF-(VIcjbA=1pbvR5cL`M-O|q@9Iz)~W1dFQjAO4_ zOTXVo=1Rs{3Hh2_00HM8=hB^b1W=b34#ieY*i(i(fZ9(Scl@d+K?A&pVd$_JxhFcgUsJdy(xs=J2cpO^v&?bH*VwA8g`iMAcBz}R|$>+M^wGZ!Z0nGCRPjoDl(=Kvn2 zmLhHumm@t-J-ujfK0^p0aCsv?T4ZwsL*;?Iu>Sx*tx~&0Nvn_Ce4V^yPf_XXQo|Zc z9@0;o!zZv68Ww06d3f_1f;t>^1M?r1D#0L8xl1YjCH^dbPw=C0({^LU$CN@dU^&P+ z#}wHvB${Bd{HU$a@yBYCCddWT3%fXB-1>f19E7R|!xF^&#OLS;{OWczeA}m?A(f-J zkbp89fPE>lHu#7b^2z5UaDNVz*<}F;1*8mf{?#CIP~~!-fR4WWP?1uScR~eV3%P7B z7zghPY-Ah~07BUc4>Z{}Y-HNyy5~3_l}ii+%B>QDdE-55wmJE=K{K71lO(CY^v_&> zDv`&WeAP)dhTvc>ILYr#lQN(ZWlrp|>ItMqb2_%t&AWHwj)s-ADq2g(ni$#gPjD21 zK;y3!Y8Ej@o0kM+axe%d80pPKgsO&k*tDm&7&#u^->pbtJEPnJ>~eP&EDjIpS-o^R zscoTiO~0E@xQ(reR{M&ma9f}A`PXCN{{V_+-p%drk>%V^EIw>@?0=nbaa>085)S7K zNCzM;dK$d~Ga(`=7aw;EkUu)kq*66iHPG+ceFs;M7Lh5Fc5YV@ZjrM{fe2>@3$$m0{{Ysk zZBtXmn6cRx2@b_U(Cl0T)70eE4KnTb4a}_Wz^TS|XTC`19C1+}dFpe?$?DHu(tJk* zH_$M;cZER9V4URVBh%8I;|&5$Ch{BTHbWi93`firP7fcQaGGPu8b-x~M9dg~IVYTx z`B$m*3hd3p6@;^cO(IsP8U9*(&2ZwDn$b`CK4>ABFY!VM<;Cke9 z?Ot=@FA7aH|byl(A`n(VJha?l_4X5S$WBHn`acuh&?v13dInGa|bpABb z{{XV@rf3&(z6MV`k};8vJJ&k)Z6S{(mvLg*VnUF5fm_p(l1%7?lNhjh(4q!Ar8ryx zjo$v1GQ%I)rDoc)BWz=g{NGw0;g(UA!fnRTqXcIeC)d7di#S%;gOq*14(dDdai4l^ z-(laPEj$kr2<`(jV1+M@z~J;hjU~J<1cjo^n}AkDY>|!*Pi~o~t=?_pVIxXOW^9r} zWk}9_y(n2{h%C0R5`&UcIR~fs)@tF|h+s&jXDm_okR0LFN7RnAsO;A3E2^D@u>^h5 zkdf;Z7t+<4=B~$^sGJVi+dSje(T0Ko9@3CgyRY^C^xsLs+ ztI+j1#(E5XwW^ax4Y8GW_TR9BmM5MML(pT~Rz!wXia8@vL|H0W4^BICob{^~vr96^ zxRA0Dx!)L5_zrpgRh*YnRIGLy8hLiVx&=yr0o)aejB-bOe_Gnpt)#t?%}?3O<;mj`wZJ8_V5IvoC$(D+K|TBFRM@iE2b!Y}-gh zy{uy9NP!Y(kaz@hkIW98sC!XuEF{XL09bA4R4zL9^9(o1Z53M)_7gm>f5zJ^I$}j5BQBSyV`f z547*(9tir5DwXw$x=0j6&zvk zBpa=jt;1~q0&sZ*^*>72((L7zRAYP>$Om#dDaknFuk);`HPqTNxn*?ZytGV~RuP6( z3)B6l?#$i-T*)66_FyC_bs41 zBUUOuJmWq3R&iX~3A35hucU@MmGat6glBuH4eSS~z~|q+b2_H5wi1MvCz+i?`Gbs% zbBuBATmB={rGnCVY_l9uOm~cO`2=(N{WDx$!^#sjpX zgPg7h2P3XdE1}T!xwOlNZz;@J$-)*b?ZCk6`d0FkSnG~w`5hRfR0IZCVt+OMh5BSy9hDmQ52@I;woG8kij=+6?O2x?<#vJPA<;q&k z^H@G3`C0d}wmJ@exas~CvvVrNrz6@ttLL^3*9D07^Z<&)(ku>}rm#ptqqgxQC?Spk z18zGJ>stC4Fg`DOB;sN8KhVj%ZrR2NFUd&dLP5f zG}N@?CD>^}P$DdP53d=)uRYQxf@@`oN`mrXdF2P*aCD)$7 z4o*#YXN0~f>AoF{Wz5QwO2JStBLf|O`qj`{{C=?1?O8nY%!NiU2~po3_^GEhx*b!& zO01V-&^$rm`yF#cyOsCvnZQNPKr>vvo#2@C_}OfN%JKpmc*EfKUU}ocy)(nV z7A-YvZz9T4(VUHebFiOZ$kwv!*3w)=)(Ar|!#4H+0r_+l*6KH+E*LjUa(@S0CxhT+ zm7|sn=K;55cK-lB;ak?bJPGmoc!~r;~y@=K~_VUdKU=?-5(ic?dW=Hm}o;;~o8Lw(t+ZtwUG2S7b<5 zX4ttMSJOB@rFt|n%F|mBEItyBtsP81v<1zS1y^=lVl)}a>;8GH9c$uGh&9PWX%I?KDnOi8P@4iM>+DCZBzYg(lk$iws2V+aS^k0ICuX5o~In= z@~=joNhZDH$E}%RY5Wn?`2)tDANaYiYCDqdA!^O@c@2`D!1t=Qf3XLNe193a)iqo7 zmBv}K&U5eA@UOl63GjN_!V?2Pn;7yj_r3CKtkeD-E|DRE7?hzHQb%gmt^$<>?yXK4 zXHs756_0>45BMkb){k<5wM)-8KR;}H<2bIvLj9Ldhh$T!-rfZ2FhJdpTvywl@Por= zx!NRY58gd;E0ystg6!^#ZHyx@AfDfqc=fQ5ZL<&UI<;M!K41RFi$s&jX&Plr;0{P1 zTH4dKf;1a}X6Me?wKpC|r}eFWh#w3_kEqP{z@5iGA;%r-nb9wyxGM+%$resA&U4?_ z82xJq$Jsp%bgGfL;2CDptzm^0AuBr;JF}6-e;W0l25X|q@+Z5PF)TnHy)&BdeG9~r zF10*bnG3m(mK87$Jn{Kgs(3#7Jx0ng_H{*!oE0ZI<29nG=4D!$@17slE%hWvXU^t0 z#~3xaWqXpz9s=?J&wl2(UjRn3X~^<0+(7_zKj(p64wZ6dyue~ad{_{(~4{CR%h{HnzUdwbVj4RcMiEJc>b z9dhbammez*c<6m<8PhSg&8fVj^EyW{l!b@H#r`xE5JgpC9Ixo&okKs|PWUs`y=GS+H%B=QuFy)pFeKN|Uu zB*^EGI`e_gdWUoc=v?`( zvat#n8>yB+)kPa85llk9-1Zp2mjZOkzcC#oru-{^+Z&lEw&-Mz70_ zjqS-J9eRq-mh2n3)akb>wu;;Q#fvyBR1!1C8L9OD04Z1#nI2{(k79j)mlc(uPM_KT z03mYioQE0UoQ}VhY78>0Yyy&UcAmKJ{Ofo}OEOld>U6S2s)-$&%xn?><0SF{=sBx8 zbZ=uki4r#L`4|`~I%nz4Q_`c3TWE}|&Y8wno_@8ZX#}5Xa^Y|oxH$xlm>vBqT5H@g zlIDq{ExT+9aQE6VB-utC7=IS2m$9Tgf01*Sj+oM4gw+YLUl-#o^XE9~th-3TACF3Q=7E;WQ=!3YsSIhon1;zeF&q<)*#7_u?)(s@9VJmz zkVhc%lb++$;PYHWFqCsC`Gb%E=%8Tdj=q)G_zrBLR@}rXWCMT~p~va@)YVIpEL+7% zolL6HDBQ}SM$oLI=NSjC4O{Tgb8#S;25>%2tZ~UaNel?|7^TpM zovHrqiHm6py$d!w`d0P*>Q_Zn*xpNb7UYwhgS!osW6pn&01H9%zT%fp9&Vu<$Mdt2 za7i0D#sK^{`cyJ8h7w@v=OK4wV-#r9AMHp~oG%O?A9UdL_w7?Fx;G;J>Ct%s3}?(@ zc=>Wzo-hyd;-ZZ|(pE=rEt$qtsrj&dy+EsP6EFcsmF9DS{9F(-%~RB;7SqHd4#9vw zDLes=e-WR~ojCK*xo=u}9e$q&+2caF1g7r&*F1lo^lB&!{VUDH$-=9#$5Qc|KN*qb zxrtb|=NSm343Xar-=OP8#+J+n`4S_XoZxZL`qX9(psk@B08(&pUp|@qeJaG3>2g)! zY^WF!$<=xf=b#36OlW;2-$-o_b>oBez zWe*%{5g#^qe!t;bO*GbxY!_i!oWAY3AmC*C4oBr#O}5P()OSUwhn}k^6?DiN%LUFQ8-oFY*&_tiSmk!xAWh|dR@$5n zG05-rsX!6is?FzNGNkSKiQ}iR$UlW(*1CqsTc=%{rP!JNG6p?Ar(b%GY&Dn?PyVw3 zaB@gKnD3A&^KG;NFBf{Y(m`(A92|Ehqga`vP|Ut&#>;1fVt5$!9CY;es^#TYLXP(# zyNE!{&;)NV^dB})PsgP}DK8f|K!*$)jwvVHVA%x78XN|i)%8(SwU z%|x~}f?bBZUUh^e-(+E!;hSj;F~A^qs?RZ4_e46jSw~!Y44#!N^Lf^>qIqkvj}F|E z?fFw~SU>=9Ngy(B#|OP(CuVf%&~!iO=vL-Pww#t3`@_>XRU3h zNPH83sAW;T*eFutw@%cF5r*7@DL~RFW?n$Y9Fs~VYk=u0My4Z{+(vqJ=}})Qq%uP^ zk~sO4oPFRrR*{h6l(itZw!s4_5`sf2^S_ghbC1%pFRpjt6K4;4xC=Pa>Nj{_6tF!Nrn8r4d!j8OGM10N)noj6$-N!L7UyO z|{I%LPhHgOos$I*4q%3K&CTS#mD`4}CejVyLHmo2! zI4W>)>rZE5${YZs<3GdoAC*RgBLgAH?hiRW-&&Mhq_!MbvLh03=a(EFDI-&kK>2~r zexj58T%kggX2;6Mr@c$&v$+bREJ!5w>C%D0A`8{r$DmG`9mOAKc5={@fN_v|`f=K( zGAJxlX#n5?JJEPdAU0VU^Oe{@BOdhA=tQZ*nYJ+$6n{H79S5P$CZvfLR}C>Z4f2pN zpL$};fI-cu7jtCg%8TY2g3{c%g=PSj4*kw5&=O2wdZk$3;D|tZWKwK93{{Wu# zoTYscqDe@-Iz(DbGmX4pai4#F)ppec(ZX&Wz`#3LjiIy99FA)@3FR@Fa(E>{%PR75 zR;{H(Y%=cvk_OOJbHT~!(y?+bZs^>#l-x#4t@Ekcs>2=0IP2&uPe&_qh-GGZSc2Ig za65L-Ls+&lEY{FS&E={&13Z}t^gR#OwX8J-KAwER06bPtGxe)N$9fX$8zne62QXe$XBmk_)8PG=^j&1%MdGL-};9+Z(as zKMW+3{L@6C7_ZHeTb|kDIpV!0^4UCDp-FFZ8oUrky91KPfChcB&3M(Wt2cu@Skc^Z z8|Mn6!vhBfTVe%7>f30NwppYI$c@zS`0vBoIU=HVs)v~>e zNXk|~%Al)s+A;GJ$J2^aRuq~?L#Ap5blVGWgn{>uDsX>KZYxtsVyuD*k$63S0!Q+! z8(V`Mz`~Io%bcj%9D&ApBU5stafKhn99YF9(%N;)0IjL_a& zIAUY-BHA|`2G3EC!1M1`cSdeuSlB3w3f%sK)O4;>OTF^#wg}_~KsJ};uRM{@xUKs) z5?w~oG0ObgjxomvApSHpealYh+P$|12?S)e?~@_8Qb@q$a(eW~dbgxS40l%2Ig%j} z+(|rv=s(YT&1;CID#k(tL4xPMxd*RW(a{p9F5pXsBak@YZjVg`uoB+ zcDK?Cn2Ml%JQ~%doZy3yZ2noGTIo{{T+)%W57Wu(M$;ywI0Jh6>z_djrpE z(^s)^YRMf%rQh~#yZPG+gvelWgq-j{{Z$2-7W2Ks6COcuH{qCL951DFmp>D&+T3~5 zLQo7oB>7t%yPwcjLwseou`ZUfsauSasCRMclY(ncV>X@9w4$Y{?0PScA-1syUujX6 z!VmSB2SX$*)~3P&@KK^1`3qA3{G9U0nVLzr1K0LWN~x@-%KfR_V?F zz&sxHQNgzC*}NkS4@95F5hecsn`JwOdskAGMsMOJ0J0`+BttV#<0dkUhiX-@Gpq| z#~5q5zIzI_HdwE;bc)$CKb?`ZWRdOZT`rZT z{{U%S$8)BkSI3nqh2-O@HP=T0P8!(qDr4$YPWl|3zwGyA;rEK;Ojw%IM%q~vD}6~^ z6J5@c@Wvf7OIP_eAl$u9e=}W{pQ1cgUR3dZ>Hgpx^{p){NJ|U2?X?9C6b-rL55~Ja z$R2H6Y~>p_a=xFV{jLbfQVe**9&=jo_-U3c#Ia^Qam{T*Vl9;f3Z7B#j`gD(uFNO| z0Be&GicwzcJh@58!h=h}UBOmBaqhyk5(2W1G!6$n>O1>(LMBeM_quO7F)lE}t$1~@ z9PG56v?|_65!*Ne)4gJ9dURI-!^Uz(dRIlL!VvGk$m~T?ZMO=}!!6#qWYgH_r)wTr zchab%q4Vy#Ep+XF?GGE4P^t;}ut@;_03lwN;fuev>Cg@PP>^uF zM*xc9yl z>}_q@&7UL__ydaVd^akKP4aT!@;m0ZYdfi}^oRpTz&lCqYo_qt{f29X4qTjLx7NEU z&dBnrtLk;P_kgZq$s^vb+mU;wv6GRGYa$@e3Z_A908L$jQ3N|9%J%x!sp?|T-9}`w z(&ur21WegJ%4_5g7F>O=U%Zq!aMCvizXPRxKk-LZIzNH$<&pNYCcu5a$*+{JZUZrF z<)q7walyt;eYmev1+!-*c2t*fOn=KlILP9dKy}x2t_c zSCQG{AZI+DzosZ&WF}_#ng+Pj`R9^K^~pScU)H{K_~mG0GMQ0R87sf|RdJu?Ur6h5 zeWJvxhEunn@M_d<_xH~~m3)Ka zi(w9_Hu6-k=nm1z9Fh2%`8Z3GvyXCURJ6HHZvN6YfHRk2o#Hlb6#HYZ<5lh=jE6Uv zL@Ei$`={%X(~r)s>Q?!H$cmm=1RU~Q4!_ce97>{hzAkcAiRf=iBK}_b;C3L!&8MKud&3@w<1|kTKJo zR>4IUcvcbAUW+QQI?vKAXBRKZ1hrvw4Qjj7dGXDU4pSrjwpZ>LCTZH>B?&BH80an0| z@U0&PUaUH)d(@DsgNAN1*!S;|`BIbTpj6sWM{y!~a1t}Q0A+B)Zp;Dc$*A;cJgbCY zM&a|W^UDnP_M-0Dt}D<$UEs5579%`F?e;HRv%)&KEkX zre&EG2J9((i~+QsS3Z?q%5{fWRto-D9IkW5bI3h0(0xrZ?oYMbZXvwffs#sqGn2+U zW{4#}+Og~o!FLrt;~2>F{Cm{XmpvblL0%U-S&#axu}~Bbo2GiMN9S4kjL#zcj1_@9 zanVN{epsuFf=ftbl(TLdKvUPB;vT(!I;p7g;bh&FJ8-}#0OSndpGq+Dy(SS}>ov3*8r+vf{4(=4=9D4NzwC8-l;jjX8fC=1q=l=k$T=#}&SkNa@xx#~j4my4~t%h*6 z;HLA8XAK#%PnE^R3>5TTP5b2DT zEYt52u`i5d6YIxsYNr&TR0nX362UQ&M$k)-e|ohrZ#QkRMp4UxnGeu`QA+@hGOECZIowVN z9fwYOQz9s*smjG=$mNDqbuKvI@z3W~WDR9FX#%<1hD`DXI3)Hw{*|FD?Hmpesxcx( z3^E5i{N4LiJEE;{eEj)|ouy9YZ1v9{Po-y3E~fh}3X>*X79fbJovwKVW3TB@l4p)L zNBP)=JBQxj^Hw8sJ-lM$X++()sLn=kG5CW~TuJ7T0FFo3~HeHEOH3RliV7RTCiqryLk}| z22tJ=0q^OI{}MOb9EDLbM+ZHsQLL1uZo{5Js*1!hImsjD2h%<2861=3 zleLE?pEt-ujlVI-Jx9Ovrv}VKz5#5SFJkR`qP&ZMcMOOfMNfn_g5y#gaxTyupMQj!1Ss8~z zo_Q4sVLP_XyCXZ)FH(I!O1(TdZ!$?X#*&pNMpztrW7@K5UdGT|?UFDjdaO#ZvtVo) zCm;epTC}PnjYJXoVVjOKx0Av8{c3`{0Krm7M-7$F%zD+$JPD(8xGUy!g5z+(=bZi= z)-z1GU21IUv%G4X;bIe#tOf%Q>HamRWd3|xvLFD0O2#=k&N6ydCZ5vIZ^gVZs5vAa zNcGQfYhu@kw!CsAPQ{rdP*q19A8IXQIlHr^xARo&Nb;m*KtFqqaz;I?b4H#>79ZU@ zHbbv$bB}-WE1iZ`nK!H|V*>*UF^q5*k80k~A&^{exG^g?%ey!o2RsggHKa5mN2>fa zyvC;`rQX1jjmK+o%M9T6_Z(LP`%HK-^zC&nt&j|;ASVMWf<}ESU&C6-u)U5@O0;Jz zdlAr%26_5dSK~`a{D0t6=LLD=LWoJrU@m%l;f-3}=8%0XdB=o&R~EIRM{j!ZnaDAm z46w&PiTP`h@wSH~w=6=d85p#Lwnlq^e+t8E$-Eungf7He$VS{Bnmx=ge_!WbqpE1J z_=iiDdw(qgAT7_!oB_A`n!>d|WYmtQ8|cp!)^wNB?jv~=n8-c&3!dlk^sK9dnq

        ' ); - targetEl.appendTo(state.baseImageEl.parent()); + + if (fromTargetField === true) { + targetEl.appendTo(draggableObj.iconEl); + } else { + targetEl.appendTo(state.baseImageEl.parent()); + } + targetEl.mousedown(function (event) { event.preventDefault(); }); @@ -68,8 +124,13 @@ define(['logme'], function (logme) { } targetObj = { + 'uniqueId': state.getUniqueId(), + 'id': obj.id, + 'x': obj.x, + 'y': obj.y, + 'w': obj.w, 'h': obj.h, @@ -86,9 +147,21 @@ define(['logme'], function (logme) { 'updateNumTextEl': updateNumTextEl, 'removeDraggable': removeDraggable, - 'addDraggable': addDraggable + 'addDraggable': addDraggable, + + 'type': 'base', + 'draggableObj': null }; + if (fromTargetField === true) { + targetObj.offset = draggableObj.iconEl.position(); + targetObj.offset.top += obj.y; + targetObj.offset.left += obj.x; + + targetObj.type = 'on_drag'; + targetObj.draggableObj = draggableObj; + } + if (state.config.onePerTarget === false) { numTextEl.appendTo(state.baseImageEl.parent()); numTextEl.mousedown(function (event) { @@ -99,7 +172,11 @@ define(['logme'], function (logme) { }); } - state.targets.push(targetObj); + targetObj.indexInStateArray = state.targets.push(targetObj) - 1; + + if (fromTargetField === true) { + draggableObj.targetField.push(targetObj); + } } function removeDraggable(draggable) { @@ -121,6 +198,10 @@ define(['logme'], function (logme) { draggable.onTarget = null; draggable.onTargetIndex = null; + if (this.type === 'on_drag') { + this.draggableObj.numDraggablesOnMe -= 1; + } + this.updateNumTextEl(); } @@ -128,6 +209,10 @@ define(['logme'], function (logme) { draggable.onTarget = this; draggable.onTargetIndex = this.draggableList.push(draggable) - 1; + if (this.type === 'on_drag') { + this.draggableObj.numDraggablesOnMe += 1; + } + this.updateNumTextEl(); } @@ -183,10 +268,5 @@ define(['logme'], function (logme) { this.numTextEl.html(this.draggableList.length); } } -}); - -// End of wrapper for RequireJS. As you can see, we are passing -// namespaced Require JS variables to an anonymous function. Within -// it, you can use the standard requirejs(), require(), and define() -// functions as if they were in the global namespace. -}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) +}); // End-of: define(['logme'], function (logme) { +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) { diff --git a/common/static/js/capa/drag_and_drop/update_input.js b/common/static/js/capa/drag_and_drop/update_input.js index 04715a3ecf..804b0bed97 100644 --- a/common/static/js/capa/drag_and_drop/update_input.js +++ b/common/static/js/capa/drag_and_drop/update_input.js @@ -1,9 +1,4 @@ -// Wrapper for RequireJS. It will make the standard requirejs(), require(), and -// define() functions from Require JS available inside the anonymous function. -// -// See https://edx-wiki.atlassian.net/wiki/display/LMS/Integration+of+Require+JS+into+the+system (function (requirejs, require, define) { - define(['logme'], function (logme) { return { 'check': check, @@ -37,7 +32,12 @@ define(['logme'], function (logme) { (function (c2) { while (c2 < state.targets[c1].draggableList.length) { tempObj = {}; - tempObj[state.targets[c1].draggableList[c2].id] = state.targets[c1].id; + + if (state.targets[c1].type === 'base') { + tempObj[state.targets[c1].draggableList[c2].id] = state.targets[c1].id; + } else { + addTargetRecursively(tempObj, state.targets[c1].draggableList[c2], state.targets[c1]); + } draggables.push(tempObj); tempObj = null; @@ -50,7 +50,18 @@ define(['logme'], function (logme) { }(0)); } - $('#input_' + state.problemId).val(JSON.stringify({'draggables': draggables})); + $('#input_' + state.problemId).val(JSON.stringify(draggables)); + } + + function addTargetRecursively(tempObj, draggable, target) { + if (target.type === 'base') { + tempObj[draggable.id] = target.id; + } else { + tempObj[draggable.id] = {}; + tempObj[draggable.id][target.id] = {}; + + addTargetRecursively(tempObj[draggable.id][target.id], target.draggableObj, target.draggableObj.onTarget); + } } // Check if input has an answer from server. If yes, then position @@ -59,6 +70,7 @@ define(['logme'], function (logme) { var inputElVal; inputElVal = $('#input_' + state.problemId).val(); + if (inputElVal.length === 0) { return false; } @@ -68,95 +80,147 @@ define(['logme'], function (logme) { return true; } - function getUseTargets(answer) { - if ($.isArray(answer.draggables) === false) { - logme('ERROR: answer.draggables is not an array.'); + function processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i) { + var baseDraggableId, baseDraggable, baseTargetId, baseTarget, + layeredDraggableId, layeredDraggable, layeredTargetId, layeredTarget, + chain; - return; - } else if (answer.draggables.length === 0) { - return; - } - - if ($.isPlainObject(answer.draggables[0]) === false) { - logme('ERROR: answer.draggables array does not contain objects.'); + if (depth === 0) { + // We are at the lowest depth? The end. return; } - for (c1 in answer.draggables[0]) { - if (answer.draggables[0].hasOwnProperty(c1) === false) { - continue; - } + if (answerSortedByDepth.hasOwnProperty(depth) === false) { + // We have a depth that ts not valid, we decrease the depth by one. + processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth - 1, 0); - if (typeof answer.draggables[0][c1] === 'string') { - // use_targets = true; - - return true; - } else if ( - ($.isArray(answer.draggables[0][c1]) === true) && - (answer.draggables[0][c1].length === 2) - ) { - // use_targets = false; - - return false; - } else { - logme('ERROR: answer.draggables[0] is inconsidtent.'); - - return; - } + return; } - logme('ERROR: answer.draggables[0] is an empty object.'); + if (answerSortedByDepth[depth].length <= i) { + // We ran out of answers at this depth, go to the next depth down. + processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth - 1, 0); + + return; + } + + chain = answerSortedByDepth[depth][i]; + + baseDraggableId = Object.keys(chain)[0]; + + // This is a hack. For now we will work with depths 1 and 3. + if (depth === 1) { + baseTargetId = chain[baseDraggableId]; + + layeredTargetId = null; + layeredDraggableId = null; + + // createBaseDraggableOnTarget(state, baseDraggableId, baseTargetId); + } else if (depth === 3) { + layeredDraggableId = baseDraggableId; + + layeredTargetId = Object.keys(chain[layeredDraggableId])[0]; + + baseDraggableId = Object.keys(chain[layeredDraggableId][layeredTargetId])[0]; + + baseTargetId = chain[layeredDraggableId][layeredTargetId][baseDraggableId]; + } + + checkBaseDraggable(); return; + + function checkBaseDraggable() { + if ((baseDraggable = getById(state, 'draggables', baseDraggableId, null, false, baseTargetId)) === null) { + createBaseDraggableOnTarget(state, baseDraggableId, baseTargetId, true, function () { + if ((baseDraggable = getById(state, 'draggables', baseDraggableId, null, false, baseTargetId)) === null) { + console.log('ERROR: Could not successfully create a base draggable on a base target.'); + } else { + baseTarget = baseDraggable.onTarget; + + if ((layeredTargetId === null) || (layeredDraggableId === null)) { + processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1); + } else { + checklayeredDraggable(); + } + } + }); + } else { + baseTarget = baseDraggable.onTarget; + + if ((layeredTargetId === null) || (layeredDraggableId === null)) { + processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1); + } else { + checklayeredDraggable(); + } + } + } + + function checklayeredDraggable() { + if ((layeredDraggable = getById(state, 'draggables', layeredDraggableId, null, false, layeredTargetId, baseDraggableId, baseTargetId)) === null) { + layeredDraggable = getById(state, 'draggables', layeredDraggableId); + layeredTarget = null; + baseDraggable.targetField.every(function (target) { + if (target.id === layeredTargetId) { + layeredTarget = target; + } + + return true; + }); + + if ((layeredDraggable !== null) && (layeredTarget !== null)) { + layeredDraggable.moveDraggableTo('target', layeredTarget, function () { + processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1); + }); + } else { + processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1); + } + } else { + processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, depth, i + 1); + } + } } - function processAnswerTargets(state, answer) { - var draggableId, draggable, targetId, target; + function createBaseDraggableOnTarget(state, draggableId, targetId, reportError, funcCallback) { + var draggable, target; - (function (c1) { - while (c1 < answer.draggables.length) { - for (draggableId in answer.draggables[c1]) { - if (answer.draggables[c1].hasOwnProperty(draggableId) === false) { - continue; - } - - if ((draggable = getById(state, 'draggables', draggableId)) === null) { - logme( - 'ERROR: In answer there exists a ' + - 'draggable ID "' + draggableId + '". No ' + - 'draggable with this ID could be found.' - ); - - continue; - } - - targetId = answer.draggables[c1][draggableId]; - if ((target = getById(state, 'targets', targetId)) === null) { - logme( - 'ERROR: In answer there exists a target ' + - 'ID "' + targetId + '". No target with this ' + - 'ID could be found.' - ); - - continue; - } - - draggable.moveDraggableTo('target', target); - } - - c1 += 1; + if ((draggable = getById(state, 'draggables', draggableId)) === null) { + if (reportError !== false) { + logme( + 'ERROR: In answer there exists a ' + + 'draggable ID "' + draggableId + '". No ' + + 'draggable with this ID could be found.' + ); } - }(0)); + + return false; + } + + if ((target = getById(state, 'targets', targetId)) === null) { + if (reportError !== false) { + logme( + 'ERROR: In answer there exists a target ' + + 'ID "' + targetId + '". No target with this ' + + 'ID could be found.' + ); + } + + return false; + } + + draggable.moveDraggableTo('target', target, funcCallback); + + return true; } function processAnswerPositions(state, answer) { var draggableId, draggable; (function (c1) { - while (c1 < answer.draggables.length) { - for (draggableId in answer.draggables[c1]) { - if (answer.draggables[c1].hasOwnProperty(draggableId) === false) { + while (c1 < answer.length) { + for (draggableId in answer[c1]) { + if (answer[c1].hasOwnProperty(draggableId) === false) { continue; } @@ -171,8 +235,8 @@ define(['logme'], function (logme) { } draggable.moveDraggableTo('XY', { - 'x': answer.draggables[c1][draggableId][0], - 'y': answer.draggables[c1][draggableId][1] + 'x': answer[c1][draggableId][0], + 'y': answer[c1][draggableId][1] }); } @@ -182,33 +246,110 @@ define(['logme'], function (logme) { } function repositionDraggables(state, answer) { - if (answer.draggables.length === 0) { + var answerSortedByDepth, minDepth, maxDepth; + + answerSortedByDepth = {}; + minDepth = 1000; + maxDepth = 0; + + answer.every(function (chain) { + var depth; + + depth = findDepth(chain, 0); + + if (depth < minDepth) { + minDepth = depth; + } + if (depth > maxDepth) { + maxDepth = depth; + } + + if (answerSortedByDepth.hasOwnProperty(depth) === false) { + answerSortedByDepth[depth] = []; + } + + answerSortedByDepth[depth].push(chain); + + return true; + }); + + if (answer.length === 0) { return; } - if (state.config.individualTargets !== getUseTargets(answer)) { - logme('ERROR: JSON config is not consistent with server response.'); - + // For now we support only one case. + if ((minDepth < 1) || (maxDepth > 3)) { return; } if (state.config.individualTargets === true) { - processAnswerTargets(state, answer); + processAnswerTargets(state, answerSortedByDepth, minDepth, maxDepth, maxDepth, 0); } else if (state.config.individualTargets === false) { processAnswerPositions(state, answer); } } - function getById(state, type, id) { + function findDepth(tempObj, depth) { + var i; + + if ($.isPlainObject(tempObj) === false) { + return depth; + } + + depth += 1; + + for (i in tempObj) { + if (tempObj.hasOwnProperty(i) === true) { + depth = findDepth(tempObj[i], depth); + } + } + + return depth; + } + + function getById(state, type, id, fromTargetField, inContainer, targetId, baseDraggableId, baseTargetId) { return (function (c1) { while (c1 < state[type].length) { if (type === 'draggables') { - if ((state[type][c1].id === id) && (state[type][c1].isOriginal === true)) { - return state[type][c1]; + if ((targetId !== undefined) && (inContainer === false) && (baseDraggableId !== undefined) && (baseTargetId !== undefined)) { + if ( + (state[type][c1].id === id) && + (state[type][c1].inContainer === false) && + (state[type][c1].onTarget.id === targetId) && + (state[type][c1].onTarget.type === 'on_drag') && + (state[type][c1].onTarget.draggableObj.id === baseDraggableId) && + (state[type][c1].onTarget.draggableObj.onTarget.id === baseTargetId) + ) { + return state[type][c1]; + } + } else if ((targetId !== undefined) && (inContainer === false)) { + if ( + (state[type][c1].id === id) && + (state[type][c1].inContainer === false) && + (state[type][c1].onTarget.id === targetId) + ) { + return state[type][c1]; + } + } else { + if (inContainer === false) { + if ((state[type][c1].id === id) && (state[type][c1].inContainer === false)) { + return state[type][c1]; + } + } else { + if ((state[type][c1].id === id) && (state[type][c1].inContainer === true)) { + return state[type][c1]; + } + } } } else { // 'targets' - if (state[type][c1].id === id) { - return state[type][c1]; + if (fromTargetField === true) { + if ((state[type][c1].id === id) && (state[type][c1].type === 'on_drag')) { + return state[type][c1]; + } + } else { + if ((state[type][c1].id === id) && (state[type][c1].type === 'base')) { + return state[type][c1]; + } } } @@ -218,10 +359,5 @@ define(['logme'], function (logme) { return null; }(0)); } -}); - -// End of wrapper for RequireJS. As you can see, we are passing -// namespaced Require JS variables to an anonymous function. Within -// it, you can use the standard requirejs(), require(), and define() -// functions as if they were in the global namespace. -}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) +}); // End-of: define(['logme'], function (logme) { +}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) { From b7680f3157d51c7daa439590aad949952299d2ae Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 5 Mar 2013 14:52:20 -0500 Subject: [PATCH 145/501] Fix tests except for conditional module and open ended grading --- .../contentstore/tests/test_contentstore.py | 24 +- .../tests/test_course_settings.py | 28 +-- cms/djangoapps/contentstore/views.py | 5 +- .../models/settings/course_metadata.py | 56 +++-- common/lib/xmodule/xmodule/course_module.py | 8 +- common/lib/xmodule/xmodule/mako_module.py | 6 +- .../xmodule/modulestore/inheritance.py | 27 ++- .../lib/xmodule/xmodule/modulestore/mongo.py | 16 +- .../xmodule/modulestore/xml_exporter.py | 9 +- .../xmodule/xmodule/tests/test_capa_module.py | 2 + lms/djangoapps/courseware/model_data.py | 19 ++ .../courseware/tests/test_model_data.py | 225 ++++++------------ local-requirements.txt | 2 +- 13 files changed, 199 insertions(+), 228 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 798990467e..da7b1ed682 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -6,6 +6,7 @@ from django.conf import settings from django.core.urlresolvers import reverse from path import path from tempdir import mkdtemp_clean +from datetime import timedelta import json from fs.osfs import OSFS import copy @@ -27,6 +28,7 @@ from xmodule.contentstore.django import contentstore from xmodule.templates import update_templates from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.inheritance import own_metadata from xmodule.templates import update_templates from xmodule.capa_module import CapaDescriptor @@ -218,7 +220,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # compare what's on disk compared to what we have in our course with fs.open('grading_policy.json','r') as grading_policy: on_disk = loads(grading_policy.read()) - self.assertEqual(on_disk, course.definition['data']['grading_policy']) + self.assertEqual(on_disk, course.grading_policy) #check for policy.json self.assertTrue(fs.exists('policy.json')) @@ -227,7 +229,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): with fs.open('policy.json','r') as course_policy: on_disk = loads(course_policy.read()) self.assertIn('course/6.002_Spring_2012', on_disk) - self.assertEqual(on_disk['course/6.002_Spring_2012'], course.metadata) + self.assertEqual(on_disk['course/6.002_Spring_2012'], own_metadata(course)) # remove old course delete_course(ms, cs, location) @@ -444,8 +446,7 @@ class ContentStoreTest(ModuleStoreTestCase): # let's assert on the metadata_inheritance on an existing vertical for vertical in verticals: - self.assertIn('xqa_key', vertical.metadata) - self.assertEqual(course.metadata['xqa_key'], vertical.metadata['xqa_key']) + self.assertEqual(course.lms.xqa_key, vertical.lms.xqa_key) self.assertGreater(len(verticals), 0) @@ -455,31 +456,28 @@ class ContentStoreTest(ModuleStoreTestCase): # crate a new module and add it as a child to a vertical ms.clone_item(source_template_location, new_component_location) parent = verticals[0] - ms.update_children(parent.location, parent.definition.get('children', []) + [new_component_location.url()]) + ms.update_children(parent.location, parent.children + [new_component_location.url()]) # flush the cache ms.get_cached_metadata_inheritance_tree(new_component_location, -1) new_module = ms.get_item(new_component_location) # check for grace period definition which should be defined at the course level - self.assertIn('graceperiod', new_module.metadata) + self.assertEqual(parent.lms.graceperiod, new_module.lms.graceperiod) - self.assertEqual(parent.metadata['graceperiod'], new_module.metadata['graceperiod']) - - self.assertEqual(course.metadata['xqa_key'], new_module.metadata['xqa_key']) + self.assertEqual(course.lms.xqa_key, new_module.lms.xqa_key) # # now let's define an override at the leaf node level # - new_module.metadata['graceperiod'] = '1 day' - ms.update_metadata(new_module.location, new_module.metadata) + new_module.lms.graceperiod = timedelta(1) + ms.update_metadata(new_module.location, own_metadata(new_module)) # flush the cache and refetch ms.get_cached_metadata_inheritance_tree(new_component_location, -1) new_module = ms.get_item(new_component_location) - self.assertIn('graceperiod', new_module.metadata) - self.assertEqual('1 day', new_module.metadata['graceperiod']) + self.assertEqual(timedelta(1), new_module.lms.graceperiod) class TemplateTestCase(ModuleStoreTestCase): diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 4a09064709..136af04190 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -287,31 +287,31 @@ class CourseMetadataEditingTest(CourseTestCase): def test_update_from_json(self): test_model = CourseMetadata.update_from_json(self.course_location, - { "a" : 1, - "b_a_c_h" : { "c" : "test" }, - "test_text" : "a text string"}) + { "advertised_start" : "start A", + "testcenter_info" : { "c" : "test" }, + "days_early_for_beta" : 2}) self.update_check(test_model) # try fresh fetch to ensure persistence test_model = CourseMetadata.fetch(self.course_location) self.update_check(test_model) # now change some of the existing metadata test_model = CourseMetadata.update_from_json(self.course_location, - { "a" : 2, + { "advertised_start" : "start B", "display_name" : "jolly roger"}) self.assertIn('display_name', test_model, 'Missing editable metadata field') self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value") - self.assertIn('a', test_model, 'Missing revised a metadata field') - self.assertEqual(test_model['a'], 2, "a not expected value") + self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field') + self.assertEqual(test_model['advertised_start'], 'start B', "advertised_start not expected value") def update_check(self, test_model): self.assertIn('display_name', test_model, 'Missing editable metadata field') self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") - self.assertIn('a', test_model, 'Missing new a metadata field') - self.assertEqual(test_model['a'], 1, "a not expected value") - self.assertIn('b_a_c_h', test_model, 'Missing b_a_c_h metadata field') - self.assertDictEqual(test_model['b_a_c_h'], { "c" : "test" }, "b_a_c_h not expected value") - self.assertIn('test_text', test_model, 'Missing test_text metadata field') - self.assertEqual(test_model['test_text'], "a text string", "test_text not expected value") + self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field') + self.assertEqual(test_model['advertised_start'], 'start A', "advertised_start not expected value") + self.assertIn('testcenter_info', test_model, 'Missing testcenter_info metadata field') + self.assertDictEqual(test_model['testcenter_info'], { "c" : "test" }, "testcenter_info not expected value") + self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field') + self.assertEqual(test_model['days_early_for_beta'], 2, "days_early_for_beta not expected value") def test_delete_key(self): @@ -322,5 +322,5 @@ class CourseMetadataEditingTest(CourseTestCase): self.assertEqual(test_model['display_name'], 'Testing', "not expected value") self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') # check for deletion effectiveness - self.assertNotIn('showanswer', test_model, 'showanswer field still in') - self.assertNotIn('xqa_key', test_model, 'xqa_key field still in') + self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in') + self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in') diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 3a0d549a5b..8522366b2e 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -464,6 +464,9 @@ class SessionKeyValueStore(KeyValueStore): except (KeyError, InvalidScopeError): del self._session[tuple(key)] + def has(self, key): + return key in self._model_data or key in self._session + def preview_module_system(request, preview_id, descriptor): """ @@ -662,7 +665,7 @@ def save_item(request): # commit to datastore # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata - store.update_metadata(item_location, existing_item._model_data._kvs._metadata) + store.update_metadata(item_location, own_metadat(existing_item)) return HttpResponse() diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index d088d75665..d836b8302f 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -1,6 +1,7 @@ from xmodule.modulestore import Location from contentstore.utils import get_modulestore from xmodule.x_module import XModuleDescriptor +from xmodule.modulestore.inheritance import own_metadata class CourseMetadata(object): @@ -10,7 +11,7 @@ class CourseMetadata(object): ''' # __new_advanced_key__ is used by client not server; so, could argue against it being here FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end', 'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', '__new_advanced_key__'] - + @classmethod def fetch(cls, course_location): """ @@ -18,53 +19,60 @@ class CourseMetadata(object): """ if not isinstance(course_location, Location): course_location = Location(course_location) - + course = {} - + descriptor = get_modulestore(course_location).get_item(course_location) - - for k, v in descriptor.metadata.iteritems(): - if k not in cls.FILTERED_LIST: - course[k] = v - + + for field in descriptor.fields + descriptor.lms.fields: + if field.name not in cls.FILTERED_LIST: + course[field.name] = field.read_from(descriptor) + return course - + @classmethod def update_from_json(cls, course_location, jsondict): """ Decode the json into CourseMetadata and save any changed attrs to the db. - + Ensures none of the fields are in the blacklist. """ descriptor = get_modulestore(course_location).get_item(course_location) - + dirty = False for k, v in jsondict.iteritems(): # should it be an error if one of the filtered list items is in the payload? - if k not in cls.FILTERED_LIST and (k not in descriptor.metadata or descriptor.metadata[k] != v): + if k in cls.FILTERED_LIST: + continue + + if hasattr(descriptor, k) and getattr(descriptor, k) != v: dirty = True - descriptor.metadata[k] = v - + setattr(descriptor, k, v) + elif hasattr(descriptor.lms, k) and getattr(descriptor.lms, k) != k: + dirty = True + setattr(descriptor.lms, k, v) + if dirty: - get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) - + get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor)) + # Could just generate and return a course obj w/o doing any db reads, but I put the reads in as a means to confirm # it persisted correctly return cls.fetch(course_location) - + @classmethod def delete_key(cls, course_location, payload): ''' Remove the given metadata key(s) from the course. payload can be a single key or [key..] ''' descriptor = get_modulestore(course_location).get_item(course_location) - + for key in payload['deleteKeys']: - if key in descriptor.metadata: - del descriptor.metadata[key] - - get_modulestore(course_location).update_metadata(course_location, descriptor.metadata) - + if hasattr(descriptor, key): + delattr(descriptor, key) + elif hasattr(descriptor.lms, key): + delattr(descriptor.lms, key) + + get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor)) + return cls.fetch(course_location) - \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index a627ed6429..68331dde74 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -50,7 +50,7 @@ class StringOrDate(Date): try: return time.strftime(self.time_format, value) - except ValueError: + except (ValueError, TypeError): return value @@ -449,8 +449,10 @@ class CourseDescriptor(SequenceDescriptor): specified. Returns specified list even if is_cohorted and/or auto_cohort are false. """ - return self.metadata.get("cohort_config", {}).get( - "auto_cohort_groups", []) + if self.cohort_config is None: + return [] + else: + return self.cohort_config.get("auto_cohort_groups", []) @property diff --git a/common/lib/xmodule/xmodule/mako_module.py b/common/lib/xmodule/xmodule/mako_module.py index 8ec1c4a078..84db6ad779 100644 --- a/common/lib/xmodule/xmodule/mako_module.py +++ b/common/lib/xmodule/xmodule/mako_module.py @@ -45,9 +45,9 @@ class MakoModuleDescriptor(XModuleDescriptor): @property def editable_metadata_fields(self): fields = {} - for field, value in own_metadata(self): - if field.name in self.system_metadata_fields: + for field, value in own_metadata(self).items(): + if field in self.system_metadata_fields: continue - fields[field.name] = value + fields[field] = value return fields diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index fb94ec8b09..947c6a2985 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -5,9 +5,6 @@ INHERITABLE_METADATA = ( 'graded', 'start', 'due', 'graceperiod', 'showanswer', 'rerandomize', # TODO (ichuang): used for Fall 2012 xqa server access 'xqa_key', - # TODO: This is used by the XMLModuleStore to provide for locations for - # static files, and will need to be removed when that code is removed - 'data_dir' # How many days early to show a course element to beta testers (float) # intended to be set per-course, but can be overridden in for specific # elements. Can be a float. @@ -33,13 +30,13 @@ def inherit_metadata(descriptor, model_data): be inherited """ if not hasattr(descriptor, '_inherited_metadata'): - setattr(descriptor, '_inherited_metadata', set()) + setattr(descriptor, '_inherited_metadata', {}) # Set all inheritable metadata from kwargs that are # in self.inheritable_metadata and aren't already set in metadata for attr in INHERITABLE_METADATA: if attr not in descriptor._model_data and attr in model_data: - descriptor._inherited_metadata.add(attr) + descriptor._inherited_metadata[attr] = model_data[attr] descriptor._model_data[attr] = model_data[attr] @@ -52,15 +49,19 @@ def own_metadata(module): metadata = {} for field in module.fields + module.lms.fields: # Only save metadata that wasn't inherited - if (field.scope == Scope.settings and - field.name not in inherited_metadata and - field.name in module._model_data): + if field.scope != Scope.settings: + continue - try: - metadata[field.name] = field.read_from(module) - except KeyError: - # Ignore any missing keys in _model_data - pass + if field.name in inherited_metadata and module._model_data[field.name] == inherited_metadata[field.name]: + continue + if field.name not in module._model_data: + continue + + try: + metadata[field.name] = module._model_data[field.name] + except KeyError: + # Ignore any missing keys in _model_data + pass return metadata diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 20cfc715bb..0103f7ff6a 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -22,7 +22,7 @@ from . import ModuleStoreBase, Location from .draft import DraftModuleStore from .exceptions import (ItemNotFoundError, DuplicateItemError) -from .inheritance import own_metadata, INHERITABLE_METADATA +from .inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata log = logging.getLogger(__name__) @@ -84,6 +84,18 @@ class MongoKeyValueStore(KeyValueStore): else: raise InvalidScopeError(key.scope) + def has(self, key): + if key.scope in (Scope.children, Scope.parent): + return True + elif key.scope == Scope.settings: + return key.field_name in self._metadata + elif key.scope == Scope.content: + if key.field_name == 'data' and not isinstance(self._data, dict): + return True + else: + return key.field_name in self._data + else: + raise InvalidScopeError(key.scope) MongoUsage = namedtuple('MongoUsage', 'id, def_id') @@ -146,7 +158,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): module = class_(self, location, model_data) if self.metadata_inheritance_tree is not None: metadata_to_inherit = self.metadata_inheritance_tree.get('parent_metadata', {}).get(location.url(), {}) - module.inherit_metadata(metadata_to_inherit) + inherit_metadata(module, metadata_to_inherit) return module except: log.debug("Failed to load descriptor", exc_info=True) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py index 4b9c747079..0724211ed3 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py @@ -1,6 +1,7 @@ import logging from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore +from xmodule.modulestore.inheritance import own_metadata from fs.osfs import OSFS from json import dumps @@ -31,14 +32,12 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d # export the grading policy policies_dir = export_fs.makeopendir('policies') course_run_policy_dir = policies_dir.makeopendir(course.location.name) - if 'grading_policy' in course.definition['data']: - with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy: - grading_policy.write(dumps(course.grading_policy)) + with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy: + grading_policy.write(dumps(course.grading_policy)) # export all of the course metadata in policy.json with course_run_policy_dir.open('policy.json', 'w') as course_policy: - policy = {} - policy = {'course/' + course.location.name: course.metadata} + policy = {'course/' + course.location.name: own_metadata(course)} course_policy.write(dumps(policy)) diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 73da9a4d2c..dbcee08c0c 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -98,6 +98,8 @@ class CapaFactory(object): if correct: # TODO: probably better to actually set the internal state properly, but... module.get_score = lambda: {'score': 1, 'total': 1} + else: + module.get_score = lambda: {'score': 0, 'total': 1} return module diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index e183b6ca93..35deda5d6b 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -336,6 +336,25 @@ class LmsKeyValueStore(KeyValueStore): else: field_object.delete() + def has(self, key): + if key.field_name in self._descriptor_model_data: + return key.field_name in self._descriptor_model_data + + if key.scope == Scope.parent: + return True + + if key.scope not in self._allowed_scopes: + raise InvalidScopeError(key.scope) + + field_object = self._model_data_cache.find(key) + if field_object is None: + return False + + if key.scope == Scope.student_state: + return key.field_name in json.loads(field_object.state) + else: + return True + LmsUsage = namedtuple('LmsUsage', 'id, def_id') diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py index 33a14f3c61..7f4727cf15 100644 --- a/lms/djangoapps/courseware/tests/test_model_data.py +++ b/lms/djangoapps/courseware/tests/test_model_data.py @@ -56,24 +56,24 @@ class StudentModuleFactory(factory.Factory): class ContentFactory(factory.Factory): FACTORY_FOR = XModuleContentField - field_name = 'content_field' - value = json.dumps('content_value') + field_name = 'existing_field' + value = json.dumps('old_value') definition_id = location('def_id').url() class SettingsFactory(factory.Factory): FACTORY_FOR = XModuleSettingsField - field_name = 'settings_field' - value = json.dumps('settings_value') + field_name = 'existing_field' + value = json.dumps('old_value') usage_id = '%s-%s' % (course_id, location('def_id').url()) class StudentPrefsFactory(factory.Factory): FACTORY_FOR = XModuleStudentPrefsField - field_name = 'student_pref_field' - value = json.dumps('student_pref_value') + field_name = 'existing_field' + value = json.dumps('old_value') student = factory.SubFactory(UserFactory) module_type = 'problem' @@ -81,8 +81,8 @@ class StudentPrefsFactory(factory.Factory): class StudentInfoFactory(factory.Factory): FACTORY_FOR = XModuleStudentInfoField - field_name = 'student_info_field' - value = json.dumps('student_info_value') + field_name = 'existing_field' + value = json.dumps('old_value') student = factory.SubFactory(UserFactory) @@ -125,6 +125,7 @@ class TestInvalidScopes(TestCase): self.assertRaises(InvalidScopeError, self.kvs.get, LmsKeyValueStore.Key(scope, None, None, 'field')) self.assertRaises(InvalidScopeError, self.kvs.set, LmsKeyValueStore.Key(scope, None, None, 'field'), 'value') self.assertRaises(InvalidScopeError, self.kvs.delete, LmsKeyValueStore.Key(scope, None, None, 'field')) + self.assertRaises(InvalidScopeError, self.kvs.has, LmsKeyValueStore.Key(scope, None, None, 'field')) class TestStudentModuleStorage(TestCase): @@ -168,6 +169,14 @@ class TestStudentModuleStorage(TestCase): self.assertEquals(1, StudentModule.objects.all().count()) self.assertEquals({'a_field': 'a_value'}, json.loads(StudentModule.objects.all()[0].state)) + def test_has_existing_field(self): + "Test that `has` returns True for existing fields in StudentModules" + self.assertTrue(self.kvs.has(student_state_key('a_field'))) + + def test_has_missing_field(self): + "Test that `has` returns False for missing fields in StudentModule" + self.assertFalse(self.kvs.has(student_state_key('not_a_field'))) + class TestMissingStudentModule(TestCase): def setUp(self): @@ -200,172 +209,90 @@ class TestMissingStudentModule(TestCase): "Test that deleting a field from a missing StudentModule raises a KeyError" self.assertRaises(KeyError, self.kvs.delete, student_state_key('a_field')) + def test_has_field_for_missing_student_module(self): + "Test that `has` returns False for missing StudentModules" + self.assertFalse(self.kvs.has(student_state_key('a_field'))) -class TestSettingsStorage(TestCase): + +class StorageTestBase(object): + factory = None + scope = None + key_factory = None + storage_class = None def setUp(self): - settings = SettingsFactory.create() - self.user = UserFactory.create() + field_storage = self.factory.create() + if hasattr(field_storage, 'student'): + self.user = field_storage.student + else: + self.user = UserFactory.create() self.desc_md = {} - self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.settings, 'settings_field')])], course_id, self.user) + self.mdc = ModelDataCache([mock_descriptor([mock_field(self.scope, 'existing_field')])], course_id, self.user) self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) def test_get_existing_field(self): - "Test that getting an existing field in an existing SettingsField works" - self.assertEquals('settings_value', self.kvs.get(settings_key('settings_field'))) + "Test that getting an existing field in an existing Storage Field works" + self.assertEquals('old_value', self.kvs.get(self.key_factory('existing_field'))) def test_get_missing_field(self): - "Test that getting a missing field from an existing SettingsField raises a KeyError" - self.assertRaises(KeyError, self.kvs.get, settings_key('not_settings_field')) + "Test that getting a missing field from an existing Storage Field raises a KeyError" + self.assertRaises(KeyError, self.kvs.get, self.key_factory('missing_field')) def test_set_existing_field(self): "Test that setting an existing field changes the value" - self.kvs.set(settings_key('settings_field'), 'new_value') - self.assertEquals(1, XModuleSettingsField.objects.all().count()) - self.assertEquals('new_value', json.loads(XModuleSettingsField.objects.all()[0].value)) + self.kvs.set(self.key_factory('existing_field'), 'new_value') + self.assertEquals(1, self.storage_class.objects.all().count()) + self.assertEquals('new_value', json.loads(self.storage_class.objects.all()[0].value)) def test_set_missing_field(self): "Test that setting a new field changes the value" - self.kvs.set(settings_key('not_settings_field'), 'new_value') - self.assertEquals(2, XModuleSettingsField.objects.all().count()) - self.assertEquals('settings_value', json.loads(XModuleSettingsField.objects.get(field_name='settings_field').value)) - self.assertEquals('new_value', json.loads(XModuleSettingsField.objects.get(field_name='not_settings_field').value)) + self.kvs.set(self.key_factory('missing_field'), 'new_value') + self.assertEquals(2, self.storage_class.objects.all().count()) + self.assertEquals('old_value', json.loads(self.storage_class.objects.get(field_name='existing_field').value)) + self.assertEquals('new_value', json.loads(self.storage_class.objects.get(field_name='missing_field').value)) def test_delete_existing_field(self): "Test that deleting an existing field removes it" - self.kvs.delete(settings_key('settings_field')) - self.assertEquals(0, XModuleSettingsField.objects.all().count()) + self.kvs.delete(self.key_factory('existing_field')) + self.assertEquals(0, self.storage_class.objects.all().count()) def test_delete_missing_field(self): - "Test that deleting a missing field from an existing SettingsField raises a KeyError" - self.assertRaises(KeyError, self.kvs.delete, settings_key('not_settings_field')) - self.assertEquals(1, XModuleSettingsField.objects.all().count()) + "Test that deleting a missing field from an existing Storage Field raises a KeyError" + self.assertRaises(KeyError, self.kvs.delete, self.key_factory('missing_field')) + self.assertEquals(1, self.storage_class.objects.all().count()) + + def test_has_existing_field(self): + "Test that `has` returns True for an existing Storage Field" + self.assertTrue(self.kvs.has(self.key_factory('existing_field'))) + + def test_has_missing_field(self): + "Test that `has` return False for an existing Storage Field" + self.assertFalse(self.kvs.has(self.key_factory('missing_field'))) -class TestContentStorage(TestCase): - - def setUp(self): - content = ContentFactory.create() - self.user = UserFactory.create() - self.desc_md = {} - self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.content, 'content_field')])], course_id, self.user) - self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) - - def test_get_existing_field(self): - "Test that getting an existing field in an existing ContentField works" - self.assertEquals('content_value', self.kvs.get(content_key('content_field'))) - - def test_get_missing_field(self): - "Test that getting a missing field from an existing ContentField raises a KeyError" - self.assertRaises(KeyError, self.kvs.get, content_key('not_content_field')) - - def test_set_existing_field(self): - "Test that setting an existing field changes the value" - self.kvs.set(content_key('content_field'), 'new_value') - self.assertEquals(1, XModuleContentField.objects.all().count()) - self.assertEquals('new_value', json.loads(XModuleContentField.objects.all()[0].value)) - - def test_set_missing_field(self): - "Test that setting a new field changes the value" - self.kvs.set(content_key('not_content_field'), 'new_value') - self.assertEquals(2, XModuleContentField.objects.all().count()) - self.assertEquals('content_value', json.loads(XModuleContentField.objects.get(field_name='content_field').value)) - self.assertEquals('new_value', json.loads(XModuleContentField.objects.get(field_name='not_content_field').value)) - - def test_delete_existing_field(self): - "Test that deleting an existing field removes it" - self.kvs.delete(content_key('content_field')) - self.assertEquals(0, XModuleContentField.objects.all().count()) - - def test_delete_missing_field(self): - "Test that deleting a missing field from an existing ContentField raises a KeyError" - self.assertRaises(KeyError, self.kvs.delete, content_key('not_content_field')) - self.assertEquals(1, XModuleContentField.objects.all().count()) +class TestSettingsStorage(StorageTestBase, TestCase): + factory = SettingsFactory + scope = Scope.settings + key_factory = settings_key + storage_class = XModuleSettingsField -class TestStudentPrefsStorage(TestCase): - - def setUp(self): - student_pref = StudentPrefsFactory.create() - self.user = student_pref.student - self.desc_md = {} - self.mdc = ModelDataCache([mock_descriptor([ - mock_field(Scope.student_preferences, 'student_pref_field'), - mock_field(Scope.student_preferences, 'not_student_pref_field'), - ])], course_id, self.user) - self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) - - def test_get_existing_field(self): - "Test that getting an existing field in an existing StudentPrefsField works" - self.assertEquals('student_pref_value', self.kvs.get(student_prefs_key('student_pref_field'))) - - def test_get_missing_field(self): - "Test that getting a missing field from an existing StudentPrefsField raises a KeyError" - self.assertRaises(KeyError, self.kvs.get, student_prefs_key('not_student_pref_field')) - - def test_set_existing_field(self): - "Test that setting an existing field changes the value" - self.kvs.set(student_prefs_key('student_pref_field'), 'new_value') - self.assertEquals(1, XModuleStudentPrefsField.objects.all().count()) - self.assertEquals('new_value', json.loads(XModuleStudentPrefsField.objects.all()[0].value)) - - def test_set_missing_field(self): - "Test that setting a new field changes the value" - self.kvs.set(student_prefs_key('not_student_pref_field'), 'new_value') - self.assertEquals(2, XModuleStudentPrefsField.objects.all().count()) - self.assertEquals('student_pref_value', json.loads(XModuleStudentPrefsField.objects.get(field_name='student_pref_field').value)) - self.assertEquals('new_value', json.loads(XModuleStudentPrefsField.objects.get(field_name='not_student_pref_field').value)) - - def test_delete_existing_field(self): - "Test that deleting an existing field removes it" - self.kvs.delete(student_prefs_key('student_pref_field')) - self.assertEquals(0, XModuleStudentPrefsField.objects.all().count()) - - def test_delete_missing_field(self): - "Test that deleting a missing field from an existing StudentPrefsField raises a KeyError" - self.assertRaises(KeyError, self.kvs.delete, student_prefs_key('not_student_pref_field')) - self.assertEquals(1, XModuleStudentPrefsField.objects.all().count()) +class TestContentStorage(StorageTestBase, TestCase): + factory = ContentFactory + scope = Scope.content + key_factory = content_key + storage_class = XModuleContentField -class TestStudentInfoStorage(TestCase): +class TestStudentPrefsStorage(StorageTestBase, TestCase): + factory = StudentPrefsFactory + scope = Scope.student_preferences + key_factory = student_prefs_key + storage_class = XModuleStudentPrefsField - def setUp(self): - student_info = StudentInfoFactory.create() - self.user = student_info.student - self.desc_md = {} - self.mdc = ModelDataCache([mock_descriptor([ - mock_field(Scope.student_info, 'student_info_field'), - mock_field(Scope.student_info, 'not_student_info_field'), - ])], course_id, self.user) - self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) - def test_get_existing_field(self): - "Test that getting an existing field in an existing StudentInfoField works" - self.assertEquals('student_info_value', self.kvs.get(student_info_key('student_info_field'))) - - def test_get_missing_field(self): - "Test that getting a missing field from an existing StudentInfoField raises a KeyError" - self.assertRaises(KeyError, self.kvs.get, student_info_key('not_student_info_field')) - - def test_set_existing_field(self): - "Test that setting an existing field changes the value" - self.kvs.set(student_info_key('student_info_field'), 'new_value') - self.assertEquals(1, XModuleStudentInfoField.objects.all().count()) - self.assertEquals('new_value', json.loads(XModuleStudentInfoField.objects.all()[0].value)) - - def test_set_missing_field(self): - "Test that setting a new field changes the value" - self.kvs.set(student_info_key('not_student_info_field'), 'new_value') - self.assertEquals(2, XModuleStudentInfoField.objects.all().count()) - self.assertEquals('student_info_value', json.loads(XModuleStudentInfoField.objects.get(field_name='student_info_field').value)) - self.assertEquals('new_value', json.loads(XModuleStudentInfoField.objects.get(field_name='not_student_info_field').value)) - - def test_delete_existing_field(self): - "Test that deleting an existing field removes it" - self.kvs.delete(student_info_key('student_info_field')) - self.assertEquals(0, XModuleStudentInfoField.objects.all().count()) - - def test_delete_missing_field(self): - "Test that deleting a missing field from an existing StudentInfoField raises a KeyError" - self.assertRaises(KeyError, self.kvs.delete, student_info_key('not_student_info_field')) - self.assertEquals(1, XModuleStudentInfoField.objects.all().count()) +class TestStudentInfoStorage(StorageTestBase, TestCase): + factory = StudentInfoFactory + scope = Scope.student_info + key_factory = student_info_key + storage_class = XModuleStudentInfoField diff --git a/local-requirements.txt b/local-requirements.txt index 1248ecf77d..5c7715c444 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -6,4 +6,4 @@ # XBlock: # Might change frequently, so put it in local-requirements.txt, # but conceptually is an external package, so it is in a separate repo. --e git+ssh://git@github.com/MITx/xmodule-debugger@8f82a3b7fc#egg=XBlock +-e git+ssh://git@github.com/MITx/xmodule-debugger@e3c4bc#egg=XBlock From 697e426ce65af5cdb25dec0ef1498657ada94063 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 5 Mar 2013 16:58:59 -0500 Subject: [PATCH 146/501] Switch to passing through entire task state --- .../xmodule/combined_open_ended_module.py | 6 +++--- .../combined_open_ended_modulev1.py | 12 +++++++----- .../combined_open_ended_rubric.py | 2 +- .../open_ended_grading_classes/openendedchild.py | 16 +++++++++++----- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 3ea5dc3339..32fcc267f0 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -142,7 +142,7 @@ class CombinedOpenEndedModule(XModule): self.child_descriptor = descriptors[version_index](self.system) self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(self.data), self.system) self.child_module = modules[version_index](self.system, location, self.child_definition, self.child_descriptor, - instance_state = instance_state, static_data= static_data) + instance_state = instance_state, static_data= static_data, model_data=model_data) def get_html(self): return self.child_module.get_html() @@ -156,8 +156,8 @@ class CombinedOpenEndedModule(XModule): def get_score(self): return self.child_module.get_score() - def max_score(self): - return self.child_module.max_score() + #def max_score(self): + # return self.child_module.max_score() def get_progress(self): return self.child_module.get_progress() diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 55d8a2a009..d51129152a 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -78,7 +78,7 @@ class CombinedOpenEndedV1Module(): DONE = 'done' def __init__(self, system, location, definition, descriptor, - instance_state=None, shared_state=None, metadata = None, static_data = None, **kwargs): + instance_state=None, shared_state=None, metadata = None, static_data = None, model_data=None,**kwargs): """ Definition file should have one or many task blocks, a rubric block, and a prompt block: @@ -115,8 +115,8 @@ class CombinedOpenEndedV1Module(): """ + self._model_data = model_data self.instance_state = instance_state - log.debug(instance_state) self.display_name = instance_state.get('display_name', "Open Ended") self.rewrite_content_links = static_data.get('rewrite_content_links',"") @@ -233,7 +233,9 @@ class CombinedOpenEndedV1Module(): current_task_state = None if len(self.task_states) > self.current_task_number: current_task_state = self.task_states[self.current_task_number] + model_data = self._model_data['task_states'][self.current_task_number] + log.debug(model_data) self.current_task_xml = self.task_xml[self.current_task_number] if self.current_task_number > 0: @@ -272,7 +274,7 @@ class CombinedOpenEndedV1Module(): }) self.current_task = child_task_module(self.system, self.location, self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, - instance_state=current_task_state) + instance_state=self.instance_state, model_data = self._model_data, task_number = self.current_task_number) self.task_states.append(self.current_task.get_instance_state()) self.state = self.ASSESSING else: @@ -280,7 +282,7 @@ class CombinedOpenEndedV1Module(): current_task_state = self.overwrite_state(current_task_state) self.current_task = child_task_module(self.system, self.location, self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, - instance_state=current_task_state) + instance_state=self.instance_state, model_data = self._model_data, task_number = self.current_task_number) return True @@ -393,7 +395,7 @@ class CombinedOpenEndedV1Module(): task_parsed_xml = task_descriptor.definition_from_xml(etree_xml, self.system) task = children['modules'][task_type](self.system, self.location, task_parsed_xml, task_descriptor, - self.static_data, instance_state=task_state) + self.static_data, instance_state=task_state, model_data = self._model_data) last_response = task.latest_answer() last_score = task.latest_score() last_post_assessment = task.latest_post_assessment(self.system) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py index f756b2b853..e2bafa7a10 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py @@ -96,7 +96,7 @@ class CombinedOpenEndedRubric(object): log.error(error_message) raise RubricParsingError(error_message) - if total != max_score: + if int(total) != int(max_score): #This is a staff_facing_error error_msg = "The max score {0} for problem {1} does not match the total number of points in the rubric {2}. Contact the learning sciences group for assistance.".format( max_score, location, total) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 2edc81e722..7134269774 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -65,7 +65,7 @@ class OpenEndedChild(object): } def __init__(self, system, location, definition, descriptor, static_data, - instance_state=None, shared_state=None, **kwargs): + instance_state=None, shared_state=None, model_data=None, task_number = None, **kwargs): # Load instance state if instance_state is not None: instance_state = json.loads(instance_state) @@ -76,13 +76,19 @@ class OpenEndedChild(object): # None for any element, and score and hint can be None for the last (current) # element. # Scores are on scale from 0 to max_score - self.history = instance_state.get('history', []) + self._model_data = model_data + task_state = {} + if task_number is not None: + self.task_number = task_number + if instance_state is not None: + task_state = - self.state = instance_state.get('state', self.INITIAL) + instance_state['task_states'][task_number]['history'] + instance_state['task_states'][task_number]['state']', self.INITIAL) - self.created = instance_state.get('created', False) + self.created = task_state.get('created', False) - self.attempts = instance_state.get('attempts', 0) + self.attempts = task_state.get('attempts', 0) self.max_attempts = static_data['max_attempts'] self.prompt = static_data['prompt'] From 904ed6c80ff72e9c8b9b0a3acbad15da9856e455 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 5 Mar 2013 17:48:39 -0500 Subject: [PATCH 147/501] Combined open ended renders correctly, but internal state tracking broken --- .../xmodule/combined_open_ended_module.py | 2 +- .../combined_open_ended_modulev1.py | 23 +++--- .../open_ended_module.py | 44 +++++------ .../openendedchild.py | 79 +++++++++++-------- .../self_assessment_module.py | 54 +++++-------- 5 files changed, 97 insertions(+), 105 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 32fcc267f0..e30cb51dca 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -58,7 +58,7 @@ class CombinedOpenEndedModule(XModule): display_name = String(help="Display name for this module", scope=Scope.settings) current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.student_state) - task_states = String(help="State dictionaries of each task within this module.", default=json.dumps("[]"), scope=Scope.student_state) + task_states = Object(help="State dictionaries of each task within this module.", default=[], scope=Scope.student_state) state = String(help="Which step within the current task that the student is on.", default="initial", scope=Scope.student_state) attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.student_state) ready_to_reset = Boolean(help="If the problem is ready to be reset or not.", default=False, scope=Scope.student_state) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index d51129152a..7ca6d2029e 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -195,10 +195,10 @@ class CombinedOpenEndedV1Module(): last_response = last_response_data['response'] loaded_task_state = json.loads(current_task_state) - if loaded_task_state['state'] == self.INITIAL: - loaded_task_state['state'] = self.ASSESSING - loaded_task_state['created'] = True - loaded_task_state['history'].append({'answer': last_response}) + if loaded_task_state['child_state'] == self.INITIAL: + loaded_task_state['child_state'] = self.ASSESSING + loaded_task_state['child_created'] = True + loaded_task_state['child_history'].append({'answer': last_response}) current_task_state = json.dumps(loaded_task_state) return current_task_state @@ -233,9 +233,7 @@ class CombinedOpenEndedV1Module(): current_task_state = None if len(self.task_states) > self.current_task_number: current_task_state = self.task_states[self.current_task_number] - model_data = self._model_data['task_states'][self.current_task_number] - log.debug(model_data) self.current_task_xml = self.task_xml[self.current_task_number] if self.current_task_number > 0: @@ -255,6 +253,7 @@ class CombinedOpenEndedV1Module(): #This sends the etree_xml object through the descriptor module of the current task, and #returns the xml parsed by the descriptor + log.debug(current_task_state) self.current_task_parsed_xml = self.current_task_descriptor.definition_from_xml(etree_xml, self.system) if current_task_state is None and self.current_task_number == 0: self.current_task = child_task_module(self.system, self.location, @@ -268,9 +267,9 @@ class CombinedOpenEndedV1Module(): 'state': self.ASSESSING, 'version': self.STATE_VERSION, 'max_score': self._max_score, - 'attempts': 0, - 'created': True, - 'history': [{'answer': last_response}], + 'child_attempts': 0, + 'child_created': True, + 'child_history': [{'answer': last_response}], }) self.current_task = child_task_module(self.system, self.location, self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, @@ -395,7 +394,7 @@ class CombinedOpenEndedV1Module(): task_parsed_xml = task_descriptor.definition_from_xml(etree_xml, self.system) task = children['modules'][task_type](self.system, self.location, task_parsed_xml, task_descriptor, - self.static_data, instance_state=task_state, model_data = self._model_data) + self.static_data, instance_state=self.instance_state, model_data = self._model_data) last_response = task.latest_answer() last_score = task.latest_score() last_post_assessment = task.latest_post_assessment(self.system) @@ -413,7 +412,7 @@ class CombinedOpenEndedV1Module(): else: last_post_evaluation = task.format_feedback_with_evaluation(self.system, last_post_assessment) last_post_assessment = last_post_evaluation - rubric_data = task._parse_score_msg(task.history[-1].get('post_assessment', ""), self.system) + rubric_data = task._parse_score_msg(task.child_history[-1].get('post_assessment', ""), self.system) rubric_scores = rubric_data['rubric_scores'] grader_types = rubric_data['grader_types'] feedback_items = rubric_data['feedback_items'] @@ -427,7 +426,7 @@ class CombinedOpenEndedV1Module(): last_post_assessment = "" last_correctness = task.is_last_response_correct() max_score = task.max_score() - state = task.state + state = task.child_state if task_type in HUMAN_TASK_TYPE: human_task_name = HUMAN_TASK_TYPE[task_type] else: diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index 7395a93a0b..022509b659 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -63,17 +63,17 @@ class OpenEndedModule(openendedchild.OpenEndedChild): if oeparam is None: #This is a staff_facing_error raise ValueError(error_message.format('oeparam')) - if self.prompt is None: + if self.child_prompt is None: raise ValueError(error_message.format('prompt')) - if self.rubric is None: + if self.child_rubric is None: raise ValueError(error_message.format('rubric')) - self._parse(oeparam, self.prompt, self.rubric, system) + self._parse(oeparam, self.child_prompt, self.child_rubric, system) - if self.created == True and self.state == self.ASSESSING: - self.created = False + if self.child_created == True and self.child_state == self.ASSESSING: + self.child_created = False self.send_to_grader(self.latest_answer(), system) - self.created = False + self.child_created = False def _parse(self, oeparam, prompt, rubric, system): @@ -88,8 +88,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild): # Note that OpenEndedResponse is agnostic to the specific contents of grader_payload prompt_string = stringify_children(prompt) rubric_string = stringify_children(rubric) - self.prompt = prompt_string - self.rubric = rubric_string + self.child_prompt = prompt_string + self.child_rubric = rubric_string grader_payload = oeparam.find('grader_payload') grader_payload = grader_payload.text if grader_payload is not None else '' @@ -128,7 +128,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): @param system: ModuleSystem @return: Success indicator """ - self.state = self.DONE + self.child_state = self.DONE return {'success': True} def message_post(self, get, system): @@ -166,7 +166,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): anonymous_student_id = system.anonymous_student_id queuekey = xqueue_interface.make_hashkey(str(system.seed) + qtime + anonymous_student_id + - str(len(self.history))) + str(len(self.child_history))) xheader = xqueue_interface.make_xheader( lms_callback_url=system.xqueue['callback_url'], @@ -193,7 +193,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): if error: success = False - self.state = self.DONE + self.child_state = self.DONE #This is a student_facing_message return {'success': success, 'msg': "Successfully submitted your feedback."} @@ -217,7 +217,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): # Generate header queuekey = xqueue_interface.make_hashkey(str(system.seed) + qtime + anonymous_student_id + - str(len(self.history))) + str(len(self.child_history))) xheader = xqueue_interface.make_xheader(lms_callback_url=system.xqueue['callback_url'], lms_key=queuekey, @@ -260,7 +260,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): self.record_latest_score(new_score_msg['score']) self.record_latest_post_assessment(score_msg) - self.state = self.POST_ASSESSMENT + self.child_state = self.POST_ASSESSMENT return True @@ -539,16 +539,16 @@ class OpenEndedModule(openendedchild.OpenEndedChild): @param short_feedback: If the long feedback is wanted or not @return: Returns formatted feedback """ - if not self.history: + if not self.child_history: return "" - feedback_dict = self._parse_score_msg(self.history[-1].get('post_assessment', ""), system, + feedback_dict = self._parse_score_msg(self.child_history[-1].get('post_assessment', ""), system, join_feedback=join_feedback) if not short_feedback: return feedback_dict['feedback'] if feedback_dict['valid'] else '' if feedback_dict['valid']: short_feedback = self._convert_longform_feedback_to_html( - json.loads(self.history[-1].get('post_assessment', ""))) + json.loads(self.child_history[-1].get('post_assessment', ""))) return short_feedback if feedback_dict['valid'] else '' def format_feedback_with_evaluation(self, system, feedback): @@ -601,7 +601,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): @param system: Modulesystem (needed to align with other ajax functions) @return: Returns the current state """ - state = self.state + state = self.child_state return {'state': state} def save_answer(self, get, system): @@ -617,7 +617,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): if closed: return msg - if self.state != self.INITIAL: + if self.child_state != self.INITIAL: return self.out_of_sync_error(get) # add new history element with answer and empty score and hint. @@ -664,13 +664,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild): """ #set context variables and render template eta_string = None - if self.state != self.INITIAL: + if self.child_state != self.INITIAL: latest = self.latest_answer() previous_answer = latest if latest is not None else self.initial_display post_assessment = self.latest_post_assessment(system) score = self.latest_score() correct = 'correct' if self.is_submission_correct(score) else 'incorrect' - if self.state == self.ASSESSING: + if self.child_state == self.ASSESSING: eta_string = self.get_eta() else: post_assessment = "" @@ -679,9 +679,9 @@ class OpenEndedModule(openendedchild.OpenEndedChild): context = { - 'prompt': self.prompt, + 'prompt': self.child_prompt, 'previous_answer': previous_answer, - 'state': self.state, + 'state': self.child_state, 'allow_reset': self._allow_reset(), 'rows': 30, 'cols': 80, diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 7134269774..844d0279c8 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -67,8 +67,12 @@ class OpenEndedChild(object): def __init__(self, system, location, definition, descriptor, static_data, instance_state=None, shared_state=None, model_data=None, task_number = None, **kwargs): # Load instance state + if instance_state is not None: - instance_state = json.loads(instance_state) + try: + instance_state = json.loads(instance_state) + except: + pass else: instance_state = {} @@ -78,30 +82,37 @@ class OpenEndedChild(object): # Scores are on scale from 0 to max_score self._model_data = model_data task_state = {} - if task_number is not None: - self.task_number = task_number - if instance_state is not None: - task_state = - instance_state['task_states'][task_number]['history'] - instance_state['task_states'][task_number]['state']', self.INITIAL) + try: + self.child_history=instance_state['task_states'][task_number]['history'] + except: + self.child_history = [] + try: + self.child_state=instance_state['task_states'][task_number]['state'] + except: + self.child_state = self.INITIAL - self.created = task_state.get('created', False) + try: + self.child_created = instance_state['task_states'][task_number]['created'] + except: + self.child_created = False - self.attempts = task_state.get('attempts', 0) - self.max_attempts = static_data['max_attempts'] + try: + self.child_attempts = instance_state['task_states'][task_number]['attempts'] + except: + self.child_attempts = 0 - self.prompt = static_data['prompt'] - self.rubric = static_data['rubric'] + self.child_prompt = static_data['prompt'] + self.child_rubric = static_data['rubric'] self.display_name = static_data['display_name'] self.accept_file_upload = static_data['accept_file_upload'] self.close_date = static_data['close_date'] self.s3_interface = static_data['s3_interface'] self.skip_basic_checks = static_data['skip_basic_checks'] + self._max_score = static_data['max_score'] # Used for progress / grading. Currently get credit just for # completion (doesn't matter if you self-assessed correct/incorrect). - self._max_score = static_data['max_score'] if system.open_ended_grading_interface: self.peer_gs = PeerGradingService(system.open_ended_grading_interface, system) self.controller_qs = controller_query_service.ControllerQueryService(system.open_ended_grading_interface,system) @@ -109,8 +120,6 @@ class OpenEndedChild(object): self.peer_gs = MockPeerGradingService() self.controller_qs = None - - self.system = system self.location_string = location @@ -144,32 +153,32 @@ class OpenEndedChild(object): #This is a student_facing_error 'error': 'The problem close date has passed, and this problem is now closed.' } - elif self.attempts > self.max_attempts: + elif self.child_attempts > self.max_attempts: return True, { 'success': False, #This is a student_facing_error - 'error': 'You have attempted this problem {0} times. You are allowed {1} attempts.'.format(self.attempts, self.max_attempts) + 'error': 'You have attempted this problem {0} times. You are allowed {1} attempts.'.format(self.child_attempts, self.max_attempts) } else: return False, {} def latest_answer(self): """Empty string if not available""" - if not self.history: + if not self.child_history: return "" - return self.history[-1].get('answer', "") + return self.child_history[-1].get('answer', "") def latest_score(self): """None if not available""" - if not self.history: + if not self.child_history: return None - return self.history[-1].get('score') + return self.child_history[-1].get('score') def latest_post_assessment(self, system): """Empty string if not available""" - if not self.history: + if not self.child_history: return "" - return self.history[-1].get('post_assessment', "") + return self.child_history[-1].get('post_assessment', "") @staticmethod def sanitize_html(answer): @@ -191,30 +200,30 @@ class OpenEndedChild(object): @return: None """ answer = OpenEndedChild.sanitize_html(answer) - self.history.append({'answer': answer}) + self.child_history.append({'answer': answer}) def record_latest_score(self, score): """Assumes that state is right, so we're adding a score to the latest history element""" - self.history[-1]['score'] = score + self.child_history[-1]['score'] = score def record_latest_post_assessment(self, post_assessment): """Assumes that state is right, so we're adding a score to the latest history element""" - self.history[-1]['post_assessment'] = post_assessment + self.child_history[-1]['post_assessment'] = post_assessment def change_state(self, new_state): """ A centralized place for state changes--allows for hooks. If the current state matches the old state, don't run any hooks. """ - if self.state == new_state: + if self.child_state == new_state: return - self.state = new_state + self.child_state = new_state - if self.state == self.DONE: - self.attempts += 1 + if self.child_state == self.DONE: + self.child_attempts += 1 def get_instance_state(self): """ @@ -223,17 +232,17 @@ class OpenEndedChild(object): state = { 'version': self.STATE_VERSION, - 'history': self.history, - 'state': self.state, + 'history': self.child_history, + 'state': self.child_state, 'max_score': self._max_score, - 'attempts': self.attempts, + 'attempts': self.child_attempts, 'created': False, } return json.dumps(state) def _allow_reset(self): """Can the module be reset?""" - return (self.state == self.DONE and self.attempts < self.max_attempts) + return (self.child_state == self.DONE and self.child_attempts < self.max_attempts) def max_score(self): """ @@ -278,7 +287,7 @@ class OpenEndedChild(object): """ #This is a dev_facing_error log.warning("Open ended child state out sync. state: %r, get: %r. %s", - self.state, get, msg) + self.child_state, get, msg) #This is a student_facing_error return {'success': False, 'error': 'The problem state got out-of-sync. Please try reloading the page.'} diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py index dac0747095..6ffe08fb02 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py @@ -39,22 +39,6 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): REQUEST_HINT = 'request_hint' DONE = 'done' - student_answers = List(scope=Scope.student_state, default=[]) - scores = List(scope=Scope.student_state, default=[]) - hints = List(scope=Scope.student_state, default=[]) - state = String(scope=Scope.student_state, default=INITIAL) - - # Used for progress / grading. Currently get credit just for - # completion (doesn't matter if you self-assessed correct/incorrect). - max_score = Integer(scope=Scope.settings, default=openendedchild.MAX_SCORE) - max_attempts = Integer(scope=Scope.settings, default=openendedchild.MAX_ATTEMPTS) - - attempts = Integer(scope=Scope.student_state, default=0) - rubric = String(scope=Scope.content) - prompt = String(scope=Scope.content) - submitmessage = String(scope=Scope.content) - hintprompt = String(scope=Scope.content) - def setup_response(self, system, location, definition, descriptor): """ Sets up the module @@ -64,8 +48,8 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): @param descriptor: SelfAssessmentDescriptor @return: None """ - self.prompt = stringify_children(self.prompt) - self.rubric = stringify_children(self.rubric) + self.child_prompt = stringify_children(self.child_prompt) + self.child_rubric = stringify_children(self.child_rubric) def get_html(self, system): """ @@ -74,18 +58,18 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): @return: Rendered HTML """ #set context variables and render template - if self.state != self.INITIAL: + if self.child_state != self.INITIAL: latest = self.latest_answer() previous_answer = latest if latest is not None else '' else: previous_answer = '' context = { - 'prompt': self.prompt, + 'prompt': self.child_prompt, 'previous_answer': previous_answer, 'ajax_url': system.ajax_url, 'initial_rubric': self.get_rubric_html(system), - 'state': self.state, + 'state': self.child_state, 'allow_reset': self._allow_reset(), 'child_type': 'selfassessment', 'accept_file_upload': self.accept_file_upload, @@ -131,11 +115,11 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): """ Return the appropriate version of the rubric, based on the state. """ - if self.state == self.INITIAL: + if self.child_state == self.INITIAL: return '' rubric_renderer = CombinedOpenEndedRubric(system, False) - rubric_dict = rubric_renderer.render_rubric(self.rubric) + rubric_dict = rubric_renderer.render_rubric(self.child_rubric) success = rubric_dict['success'] rubric_html = rubric_dict['html'] @@ -144,13 +128,13 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): 'max_score': self._max_score, } - if self.state == self.ASSESSING: + if self.child_state == self.ASSESSING: context['read_only'] = False - elif self.state in (self.POST_ASSESSMENT, self.DONE): + elif self.child_state in (self.POST_ASSESSMENT, self.DONE): context['read_only'] = True else: #This is a dev_facing_error - raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.state)) + raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.child_state)) return system.render_template('self_assessment_rubric.html', context) @@ -158,10 +142,10 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): """ Return the appropriate version of the hint view, based on state. """ - if self.state in (self.INITIAL, self.ASSESSING): + if self.child_state in (self.INITIAL, self.ASSESSING): return '' - if self.state == self.DONE: + if self.child_state == self.DONE: # display the previous hint latest = self.latest_post_assessment(system) hint = latest if latest is not None else '' @@ -170,13 +154,13 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): context = {'hint': hint} - if self.state == self.POST_ASSESSMENT: + if self.child_state == self.POST_ASSESSMENT: context['read_only'] = False - elif self.state == self.DONE: + elif self.child_state == self.DONE: context['read_only'] = True else: #This is a dev_facing_error - raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.state)) + raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.child_state)) return system.render_template('self_assessment_hint.html', context) @@ -198,7 +182,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): if closed: return msg - if self.state != self.INITIAL: + if self.child_state != self.INITIAL: return self.out_of_sync_error(get) error_message = "" @@ -239,7 +223,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): 'message_html' only if success is true """ - if self.state != self.ASSESSING: + if self.child_state != self.ASSESSING: return self.out_of_sync_error(get) try: @@ -262,7 +246,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): self.change_state(self.DONE) d['allow_reset'] = self._allow_reset() - d['state'] = self.state + d['state'] = self.child_state return d def save_hint(self, get, system): @@ -276,7 +260,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): with the error key only present if success is False and message_html only if True. ''' - if self.state != self.POST_ASSESSMENT: + if self.child_state != self.POST_ASSESSMENT: # Note: because we only ask for hints on wrong answers, may not have # the same number of hints and answers. return self.out_of_sync_error(get) From 0d777a7a0e770d1bda0238b5e20977bdd7f84f74 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 5 Mar 2013 17:53:06 -0500 Subject: [PATCH 148/501] Convert to passing in current task state again --- .../combined_open_ended_modulev1.py | 4 ++-- .../xmodule/open_ended_grading_classes/openendedchild.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 7ca6d2029e..2149729605 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -273,7 +273,7 @@ class CombinedOpenEndedV1Module(): }) self.current_task = child_task_module(self.system, self.location, self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, - instance_state=self.instance_state, model_data = self._model_data, task_number = self.current_task_number) + instance_state=current_task_state) self.task_states.append(self.current_task.get_instance_state()) self.state = self.ASSESSING else: @@ -281,7 +281,7 @@ class CombinedOpenEndedV1Module(): current_task_state = self.overwrite_state(current_task_state) self.current_task = child_task_module(self.system, self.location, self.current_task_parsed_xml, self.current_task_descriptor, self.static_data, - instance_state=self.instance_state, model_data = self._model_data, task_number = self.current_task_number) + instance_state=current_task_state) return True diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 844d0279c8..8cdfc8f322 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -102,6 +102,7 @@ class OpenEndedChild(object): except: self.child_attempts = 0 + self.max_attempts = static_data['max_attempts'] self.child_prompt = static_data['prompt'] self.child_rubric = static_data['rubric'] self.display_name = static_data['display_name'] @@ -277,7 +278,7 @@ class OpenEndedChild(object): return Progress(self.get_score()['score'], self._max_score) except Exception as err: #This is a dev_facing_error - log.exception("Got bad progress from open ended child module. Max Score: {1}".format(self._max_score)) + log.exception("Got bad progress from open ended child module. Max Score: {0}".format(self._max_score)) return None return None From b7c6f7ca1a9575f39bdc465dc3178b5483429325 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 5 Mar 2013 19:16:02 -0500 Subject: [PATCH 149/501] Mostly working state --- .../combined_open_ended_modulev1.py | 9 ++--- .../openendedchild.py | 36 ++++++------------- 2 files changed, 15 insertions(+), 30 deletions(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 2149729605..8ad53f3db7 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -195,6 +195,7 @@ class CombinedOpenEndedV1Module(): last_response = last_response_data['response'] loaded_task_state = json.loads(current_task_state) + log.debug(loaded_task_state) if loaded_task_state['child_state'] == self.INITIAL: loaded_task_state['child_state'] = self.ASSESSING loaded_task_state['child_created'] = True @@ -253,7 +254,6 @@ class CombinedOpenEndedV1Module(): #This sends the etree_xml object through the descriptor module of the current task, and #returns the xml parsed by the descriptor - log.debug(current_task_state) self.current_task_parsed_xml = self.current_task_descriptor.definition_from_xml(etree_xml, self.system) if current_task_state is None and self.current_task_number == 0: self.current_task = child_task_module(self.system, self.location, @@ -264,7 +264,7 @@ class CombinedOpenEndedV1Module(): last_response_data = self.get_last_response(self.current_task_number - 1) last_response = last_response_data['response'] current_task_state = json.dumps({ - 'state': self.ASSESSING, + 'child_state': self.ASSESSING, 'version': self.STATE_VERSION, 'max_score': self._max_score, 'child_attempts': 0, @@ -276,6 +276,7 @@ class CombinedOpenEndedV1Module(): instance_state=current_task_state) self.task_states.append(self.current_task.get_instance_state()) self.state = self.ASSESSING + log.debug(self.task_states) else: if self.current_task_number > 0 and not reset: current_task_state = self.overwrite_state(current_task_state) @@ -394,7 +395,7 @@ class CombinedOpenEndedV1Module(): task_parsed_xml = task_descriptor.definition_from_xml(etree_xml, self.system) task = children['modules'][task_type](self.system, self.location, task_parsed_xml, task_descriptor, - self.static_data, instance_state=self.instance_state, model_data = self._model_data) + self.static_data, instance_state=task_state, model_data = self._model_data) last_response = task.latest_answer() last_score = task.latest_score() last_post_assessment = task.latest_post_assessment(self.system) @@ -479,7 +480,7 @@ class CombinedOpenEndedV1Module(): if not self.allow_reset: self.task_states[self.current_task_number] = self.current_task.get_instance_state() current_task_state = json.loads(self.task_states[self.current_task_number]) - if current_task_state['state'] == self.DONE: + if current_task_state['child_state'] == self.DONE: self.current_task_number += 1 if self.current_task_number >= (len(self.task_xml)): self.state = self.DONE diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 8cdfc8f322..9a9446b6cf 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -65,7 +65,7 @@ class OpenEndedChild(object): } def __init__(self, system, location, definition, descriptor, static_data, - instance_state=None, shared_state=None, model_data=None, task_number = None, **kwargs): + instance_state=None, shared_state=None, **kwargs): # Load instance state if instance_state is not None: @@ -80,27 +80,11 @@ class OpenEndedChild(object): # None for any element, and score and hint can be None for the last (current) # element. # Scores are on scale from 0 to max_score - self._model_data = model_data - task_state = {} - try: - self.child_history=instance_state['task_states'][task_number]['history'] - except: - self.child_history = [] - try: - self.child_state=instance_state['task_states'][task_number]['state'] - except: - self.child_state = self.INITIAL - - try: - self.child_created = instance_state['task_states'][task_number]['created'] - except: - self.child_created = False - - try: - self.child_attempts = instance_state['task_states'][task_number]['attempts'] - except: - self.child_attempts = 0 + self.child_history=instance_state.get('child_history',[]) + self.child_state=instance_state.get('child_state', self.INITIAL) + self.child_created = instance_state.get('child_created', False) + self.child_attempts = instance_state.get('child_attempts', 0) self.max_attempts = static_data['max_attempts'] self.child_prompt = static_data['prompt'] @@ -233,11 +217,11 @@ class OpenEndedChild(object): state = { 'version': self.STATE_VERSION, - 'history': self.child_history, - 'state': self.child_state, + 'child_history': self.child_history, + 'child_state': self.child_state, 'max_score': self._max_score, - 'attempts': self.child_attempts, - 'created': False, + 'child_attempts': self.child_attempts, + 'child_created': False, } return json.dumps(state) @@ -275,7 +259,7 @@ class OpenEndedChild(object): ''' if self._max_score > 0: try: - return Progress(self.get_score()['score'], self._max_score) + return Progress(int(self.get_score()['score']), int(self._max_score)) except Exception as err: #This is a dev_facing_error log.exception("Got bad progress from open ended child module. Max Score: {0}".format(self._max_score)) From f71c43f77bbd66074751b6e1dda44a23eae50491 Mon Sep 17 00:00:00 2001 From: Vasyl Nakvasiuk Date: Wed, 6 Mar 2013 14:28:10 +0200 Subject: [PATCH 150/501] fix self.excess_draggables --- common/lib/capa/capa/verifiers/draganddrop.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/capa/capa/verifiers/draganddrop.py b/common/lib/capa/capa/verifiers/draganddrop.py index 5d7e4770cf..cdfa163f33 100644 --- a/common/lib/capa/capa/verifiers/draganddrop.py +++ b/common/lib/capa/capa/verifiers/draganddrop.py @@ -352,8 +352,8 @@ class DragAndDrop(object): # correct_answer entries. If the draggable is mentioned in at least one # correct_answer entry, the value is False. # default to consider every user answer excess until proven otherwise. - self.excess_draggables = dict((users_draggable.keys()[0],True) - for users_draggable in user_answer['draggables']) + self.excess_draggables = dict((users_draggable.keys()[0],True) + for users_draggable in user_answer) # Convert nested `user_answer` to flat format. user_answer = flat_user_answer(user_answer) From bb69e9fab68c2e7bd3104baca4262d1e5b8697c7 Mon Sep 17 00:00:00 2001 From: Vasyl Nakvasiuk Date: Wed, 6 Mar 2013 14:30:29 +0200 Subject: [PATCH 151/501] fix dnd tests --- common/lib/capa/capa/verifiers/tests_draganddrop.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/verifiers/tests_draganddrop.py b/common/lib/capa/capa/verifiers/tests_draganddrop.py index b70c6f1553..52dcaa0008 100644 --- a/common/lib/capa/capa/verifiers/tests_draganddrop.py +++ b/common/lib/capa/capa/verifiers/tests_draganddrop.py @@ -280,13 +280,13 @@ class Test_DragAndDrop_Grade(unittest.TestCase): self.assertTrue(draganddrop.grade(user_input, correct_answer)) def test_expect_no_actions_wrong(self): - user_input = '{"draggables": [{"1": "t1"}, \ - {"name_with_icon": "t2"}]}' + user_input = '[{"1": "t1"}, \ + {"name_with_icon": "t2"}]' correct_answer = [] self.assertFalse(draganddrop.grade(user_input, correct_answer)) def test_expect_no_actions_right(self): - user_input = '{"draggables": []}' + user_input = '[]' correct_answer = [] self.assertTrue(draganddrop.grade(user_input, correct_answer)) From c6545eb092d7bcbe2d934ac2753d6fb8113f0468 Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Wed, 6 Mar 2013 06:21:08 -0700 Subject: [PATCH 152/501] Begin to document symmath as we go --- .../js/capa/symbolic_mathjax_preprocessor.js | 22 +++++++ .../course_data_formats/symbolic_response.rst | 26 ++++++++ lms/lib/symmath/formula.py | 59 +++++++++++++++++-- 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 common/static/js/capa/symbolic_mathjax_preprocessor.js create mode 100644 doc/public/course_data_formats/symbolic_response.rst diff --git a/common/static/js/capa/symbolic_mathjax_preprocessor.js b/common/static/js/capa/symbolic_mathjax_preprocessor.js new file mode 100644 index 0000000000..19104553dc --- /dev/null +++ b/common/static/js/capa/symbolic_mathjax_preprocessor.js @@ -0,0 +1,22 @@ +window.SymbolicMathjaxPreprocessor = function () { + this.fn = function (eqn) { + // flags and config + var superscriptsOn = true; + + if (superscriptsOn) { + // find instances of "__" and make them superscripts ("^") and tag them + // as such. Specifcally replace instances of "__X" or "__{XYZ}" with + // "^{CHAR$1}", marking superscripts as different from powers + + // a zero width space--this is an invisible character that no one would + // use, that gets passed through MathJax and to the server + var c = "\u200b"; + eqn = eqn.replace(/__(?:([^\{])|\{([^\}]+)\})/g, '^{' + c + '$1$2}'); + + // NOTE: MathJax supports '\class{name}{mathcode}' but not for asciimath + // input, which is too bad. This would be preferable to the char tag + } + + return eqn; + }; +}; diff --git a/doc/public/course_data_formats/symbolic_response.rst b/doc/public/course_data_formats/symbolic_response.rst new file mode 100644 index 0000000000..773821766e --- /dev/null +++ b/doc/public/course_data_formats/symbolic_response.rst @@ -0,0 +1,26 @@ +################# +Symbolic Response +################# + +This document plans to document features that the current symbolic response +supports. In general it allows the input and validation of math expressions, +up to commutativity and some identities. + + +******** +Features +******** + +This is a partial list of features, to be revised as we go along: + * sub and superscripts: an expression following the ``^`` character + indicates exponentiation. To use superscripts in variables, the syntax + is ``b_x__d`` for the variable ``b`` with subscript ``x`` and super + ``d``. + + An example of a problem:: + + + + + + It's a bit of a pain to enter that. diff --git a/lms/lib/symmath/formula.py b/lms/lib/symmath/formula.py index 7c4ea084d6..914a65d1b0 100644 --- a/lms/lib/symmath/formula.py +++ b/lms/lib/symmath/formula.py @@ -248,14 +248,21 @@ class formula(object): fix_hat(xml) def flatten_pmathml(xml): - ''' - Give the text version of PMathML elements + ''' Give the text version of certain PMathML elements + + Sometimes MathML will be given with each letter separated (it + doesn't know if its implicit multiplication or what). From an xml + node, find the (text only) variable name it represents. So it takes + + m + a + x + + and returns 'max', for easier use later on. ''' tag = gettag(xml) if tag == 'mn': return xml.text elif tag == 'mi': return xml.text - # elif tag == 'msub': return '_'.join([flatten_pmathml(y) for y in xml]) - # elif tag == 'msup': return '^'.join([flatten_pmathml(y) for y in xml]) elif tag == 'mrow': return ''.join([flatten_pmathml(y) for y in xml]) raise Exception, '[flatten_pmathml] unknown tag %s' % tag @@ -263,23 +270,63 @@ class formula(object): # they have the character \u200b in the superscript # replace them with a__b so snuggle doesn't get confused def fix_superscripts(xml): + ''' Look for and replace sup elements with 'X__Y' or 'X_Y__Z' + + In the javascript, variables with '__X' in them had an invisible + character inserted into the sup (to distinguish from powers) + E.g. normal: + + a + b + c + + to be interpreted '(a_b)^c' (nothing done by this method) + + And modified: + + b + x + + + d + + + to be interpreted 'a_b__c' + + also: + + x + + + B + + + to be 'x__B' + ''' for k in xml: tag = gettag(k) - # match node to a superscript + # match things like the last example-- + # the second item in msub is an mrow with the first + # character equal to \u200b if (tag == 'msup' and len(k) == 2 and gettag(k[1]) == 'mrow' and gettag(k[1][0]) == 'mo' and k[1][0].text == u'\u200b'): # whew + # replace the msup with 'X__Y' k[1].remove(k[1][0]) newk = etree.Element('mi') newk.text = '%s__%s' % (flatten_pmathml(k[0]), flatten_pmathml(k[1])) xml.replace(k, newk) + # match things like the middle example- + # the third item in msubsup is an mrow with the first + # character equal to \u200b if (tag == 'msubsup' and len(k) == 3 and gettag(k[2]) == 'mrow' and gettag(k[2][0]) == 'mo' and k[2][0].text == u'\u200b'): # whew + # replace the msubsup with 'X_Y__Z' k[2].remove(k[2][0]) newk = etree.Element('mi') newk.text = '%s_%s__%s' % (flatten_pmathml(k[0]), flatten_pmathml(k[1]), flatten_pmathml(k[2])) @@ -316,7 +363,7 @@ class formula(object): try: xml = self.preprocess_pmathml(self.expr) except Exception, err: - # print 'Err %s while preprocessing; expr=%s' % (err, self.expr) + log.warning('Err %s while preprocessing; expr=%s' % (err, self.expr)) return "Error! Cannot process pmathml" pmathml = etree.tostring(xml, pretty_print=True) self.the_pmathml = pmathml From 14873e6465c2bfd3c9652581b41c1924dad0703b Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 6 Mar 2013 08:45:32 -0500 Subject: [PATCH 153/501] Reorderd xmodule storage migrations --- .../{0005_add_xmodule_storage.py => 0008_add_xmodule_storage.py} | 0 .../{0006_add_field_default.py => 0009_add_field_default.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename lms/djangoapps/courseware/migrations/{0005_add_xmodule_storage.py => 0008_add_xmodule_storage.py} (100%) rename lms/djangoapps/courseware/migrations/{0006_add_field_default.py => 0009_add_field_default.py} (100%) diff --git a/lms/djangoapps/courseware/migrations/0005_add_xmodule_storage.py b/lms/djangoapps/courseware/migrations/0008_add_xmodule_storage.py similarity index 100% rename from lms/djangoapps/courseware/migrations/0005_add_xmodule_storage.py rename to lms/djangoapps/courseware/migrations/0008_add_xmodule_storage.py diff --git a/lms/djangoapps/courseware/migrations/0006_add_field_default.py b/lms/djangoapps/courseware/migrations/0009_add_field_default.py similarity index 100% rename from lms/djangoapps/courseware/migrations/0006_add_field_default.py rename to lms/djangoapps/courseware/migrations/0009_add_field_default.py From 06001c8c1ecf970565788dba8957a835b3b5f4b7 Mon Sep 17 00:00:00 2001 From: Vasyl Nakvasiuk Date: Wed, 6 Mar 2013 17:10:15 +0200 Subject: [PATCH 154/501] fix test_logic --- .../lib/xmodule/xmodule/tests/test_logic.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_logic.py b/common/lib/xmodule/xmodule/tests/test_logic.py index 6b9a636590..018b40427e 100644 --- a/common/lib/xmodule/xmodule/tests/test_logic.py +++ b/common/lib/xmodule/xmodule/tests/test_logic.py @@ -3,20 +3,23 @@ import json import unittest -from xmodule.poll_module import PollModule -from xmodule.conditional_module import ConditionalModule +from xmodule.poll_module import PollDescriptor +from xmodule.conditional_module import ConditionalDescriptor class LogicTest(unittest.TestCase): """Base class for testing xmodule logic.""" - xmodule_class = None + descriptor_class = None raw_model_data = {} def setUp(self): + class EmptyClass: pass + self.system = None self.location = None - self.descriptor = None + self.descriptor = EmptyClass() + self.xmodule_class = self.descriptor_class.module_class self.xmodule = self.xmodule_class(self.system, self.location, self.descriptor, self.raw_model_data) @@ -25,7 +28,7 @@ class LogicTest(unittest.TestCase): class PollModuleTest(LogicTest): - xmodule_class = PollModule + descriptor_class = PollDescriptor raw_model_data = { 'poll_answers': {'Yes': 1, 'Dont_know': 0, 'No': 0}, 'voted': False, @@ -50,16 +53,14 @@ class PollModuleTest(LogicTest): class ConditionalModuleTest(LogicTest): - xmodule_class = ConditionalModule - raw_model_data = { - 'contents': 'Some content' - } + descriptor_class = ConditionalDescriptor def test_ajax_request(self): # Mock is_condition_satisfied self.xmodule.is_condition_satisfied = lambda: True + setattr(self.xmodule.descriptor, 'get_children', lambda: []) response = self.ajax_request('No', {}) html = response['html'] - self.assertEqual(html, 'Some content') + self.assertEqual(html, []) From 7ea3d70c2c9a97e7e9fc4c2607cc132cbe3a5802 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 6 Mar 2013 11:21:42 -0500 Subject: [PATCH 155/501] Caching attribute values from child module --- .../xmodule/combined_open_ended_module.py | 35 +++++++++++++------ .../combined_open_ended_modulev1.py | 14 ++++---- .../open_ended_module.py | 1 + .../xmodule/xmodule/peer_grading_module.py | 5 ++- 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index e30cb51dca..2a3931dffb 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -12,12 +12,16 @@ from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import Comb log = logging.getLogger("mitx.courseware") -V1_ATTRIBUTES = ["display_name", "current_task_number", "task_states", "state", - "attempts", "ready_to_reset", "max_attempts", "is_graded", "accept_file_upload", +V1_SETTINGS_ATTRIBUTES = ["display_name", "attempts", "is_graded", "accept_file_upload", "skip_spelling_checks", "due", "graceperiod", "max_score"] +V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state", + "student_attempts", "ready_to_reset"] + +V1_ATTRIBUTES = V1_SETTINGS_ATTRIBUTES + V1_STUDENT_ATTRIBUTES + VERSION_TUPLES = ( - ('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module, V1_ATTRIBUTES), + ('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module, V1_SETTINGS_ATTRIBUTES, V1_STUDENT_ATTRIBUTES), ) DEFAULT_VERSION = 1 @@ -56,13 +60,13 @@ class CombinedOpenEndedModule(XModule): icon_class = 'problem' - display_name = String(help="Display name for this module", scope=Scope.settings) + display_name = String(help="Display name for this module", default="Open Ended Grading", scope=Scope.settings) current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.student_state) task_states = Object(help="State dictionaries of each task within this module.", default=[], scope=Scope.student_state) state = String(help="Which step within the current task that the student is on.", default="initial", scope=Scope.student_state) - attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.student_state) + student_attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.student_state) ready_to_reset = Boolean(help="If the problem is ready to be reset or not.", default=False, scope=Scope.student_state) - max_attempts = Integer(help="Maximum number of attempts that a student is allowed.", default=1, scope=Scope.settings) + attempts = Integer(help="Maximum number of attempts that a student is allowed.", default=1, scope=Scope.settings) is_graded = Boolean(help="Whether or not the problem is graded.", default=False, scope=Scope.settings) accept_file_upload = Boolean(help="Whether or not the problem accepts file uploads.", default=False, scope=Scope.settings) skip_spelling_checks = Boolean(help="Whether or not to skip initial spelling checks.", default=True, scope=Scope.settings) @@ -124,7 +128,8 @@ class CombinedOpenEndedModule(XModule): versions = [i[0] for i in VERSION_TUPLES] descriptors = [i[1] for i in VERSION_TUPLES] modules = [i[2] for i in VERSION_TUPLES] - attributes = [i[3] for i in VERSION_TUPLES] + settings_attributes = [i[3] for i in VERSION_TUPLES] + student_attributes = [i[4] for i in VERSION_TUPLES] try: version_index = versions.index(self.version) @@ -134,20 +139,26 @@ class CombinedOpenEndedModule(XModule): self.version = DEFAULT_VERSION version_index = versions.index(self.version) + self.student_attributes = student_attributes[version_index] + self.settings_attributes = settings_attributes[version_index] + + attributes = self.student_attributes + self.settings_attributes + static_data = { 'rewrite_content_links' : self.rewrite_content_links, } - instance_state = { k: getattr(self,k) for k in attributes[version_index]} - instance_state.update({'data' : self.data}) + instance_state = { k: getattr(self,k) for k in attributes} self.child_descriptor = descriptors[version_index](self.system) self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(self.data), self.system) self.child_module = modules[version_index](self.system, location, self.child_definition, self.child_descriptor, - instance_state = instance_state, static_data= static_data, model_data=model_data) + instance_state = instance_state, static_data= static_data, model_data=model_data, attributes=attributes) def get_html(self): + self.save_instance_data() return self.child_module.get_html() def handle_ajax(self, dispatch, get): + self.save_instance_data() return self.child_module.handle_ajax(dispatch, get) def get_instance_state(self): @@ -166,6 +177,10 @@ class CombinedOpenEndedModule(XModule): def due_date(self): return self.child_module.due_date + def save_instance_date(self): + for attribute in self.student_attributes: + setattr(self,k, getattr(self.child_module,k)) + class CombinedOpenEndedDescriptor(RawDescriptor): """ diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 8ad53f3db7..4f304b1a0e 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -131,11 +131,11 @@ class CombinedOpenEndedV1Module(): #Overall state of the combined open ended module self.state = instance_state.get('state', self.INITIAL) - self.attempts = instance_state.get('attempts', 0) + self.student_attempts = instance_state.get('student_attempts', 0) #Allow reset is true if student has failed the criteria to move to the next child task self.allow_reset = instance_state.get('ready_to_reset', False) - self.max_attempts = self.instance_state.get('attempts', MAX_ATTEMPTS) + self.attempts = self.instance_state.get('attempts', MAX_ATTEMPTS) self.is_scored = self.instance_state.get('is_graded', IS_SCORED) in TRUE_DICT self.accept_file_upload = self.instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT self.skip_basic_checks = self.instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) @@ -161,7 +161,7 @@ class CombinedOpenEndedV1Module(): #Static data is passed to the child modules to render self.static_data = { 'max_score': self._max_score, - 'max_attempts': self.max_attempts, + 'max_attempts': self.attempts, 'prompt': definition['prompt'], 'rubric': definition['rubric'], 'display_name': self.display_name, @@ -195,7 +195,6 @@ class CombinedOpenEndedV1Module(): last_response = last_response_data['response'] loaded_task_state = json.loads(current_task_state) - log.debug(loaded_task_state) if loaded_task_state['child_state'] == self.INITIAL: loaded_task_state['child_state'] = self.ASSESSING loaded_task_state['child_created'] = True @@ -276,7 +275,6 @@ class CombinedOpenEndedV1Module(): instance_state=current_task_state) self.task_states.append(self.current_task.get_instance_state()) self.state = self.ASSESSING - log.debug(self.task_states) else: if self.current_task_number > 0 and not reset: current_task_state = self.overwrite_state(current_task_state) @@ -638,13 +636,13 @@ class CombinedOpenEndedV1Module(): if not self.allow_reset: return self.out_of_sync_error(get) - if self.attempts > self.max_attempts: + if self.student_attempts > self.attempts: return { 'success': False, #This is a student_facing_error 'error': ('You have attempted this question {0} times. ' 'You are only allowed to attempt it {1} times.').format( - self.attempts, self.max_attempts) + self.student_attempts, self.attempts) } self.state = self.INITIAL self.allow_reset = False @@ -670,7 +668,7 @@ class CombinedOpenEndedV1Module(): 'current_task_number': self.current_task_number, 'state': self.state, 'task_states': self.task_states, - 'attempts': self.attempts, + 'student_attempts': self.student_attempts, 'ready_to_reset': self.allow_reset, } diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index 022509b659..a7b3f8f9ed 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -579,6 +579,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): 'check_for_score': self.check_for_score, } + log.debug(dispatch) if dispatch not in handlers: #This is a dev_facing_error log.error("Cannot find {0} in handlers in handle_ajax function for open_ended_module.py".format(dispatch)) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index b469fd9656..e6651ed85e 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -454,11 +454,13 @@ class PeerGradingModule(XModule): except GradingServiceError: #This is a student_facing_error error_text = EXTERNAL_GRADER_NO_CONTACT_ERROR + log.error(error_text) success = False # catch error if if the json loads fails except ValueError: #This is a student_facing_error error_text = "Could not get list of problems to peer grade. Please notify course staff." + log.error(error_text) success = False @@ -553,7 +555,7 @@ class PeerGradingModule(XModule): class PeerGradingDescriptor(RawDescriptor): """ - Module for adding peer grading questions + Module for adding combined open ended questions """ mako_template = "widgets/raw-edit.html" module_class = PeerGradingModule @@ -562,3 +564,4 @@ class PeerGradingDescriptor(RawDescriptor): stores_state = True has_score = True template_dir_name = "peer_grading" + From 3329af077ae31e359fd3e09341f64e686606ffa9 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 6 Mar 2013 11:29:25 -0500 Subject: [PATCH 156/501] Force instance state to save periodically --- .../xmodule/combined_open_ended_module.py | 11 +++++--- .../combined_open_ended_modulev1.py | 28 +++++++++---------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 2a3931dffb..4f806d6b78 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -155,11 +155,14 @@ class CombinedOpenEndedModule(XModule): def get_html(self): self.save_instance_data() - return self.child_module.get_html() + return_value = self.child_module.get_html() + return return_value def handle_ajax(self, dispatch, get): self.save_instance_data() - return self.child_module.handle_ajax(dispatch, get) + return_value = self.child_module.handle_ajax(dispatch, get) + self.save_instance_data() + return return_value def get_instance_state(self): return self.child_module.get_instance_state() @@ -177,9 +180,9 @@ class CombinedOpenEndedModule(XModule): def due_date(self): return self.child_module.due_date - def save_instance_date(self): + def save_instance_data(self): for attribute in self.student_attributes: - setattr(self,k, getattr(self.child_module,k)) + setattr(self,attribute, getattr(self.child_module,attribute)) class CombinedOpenEndedDescriptor(RawDescriptor): diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 4f304b1a0e..7b768457ba 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -134,7 +134,7 @@ class CombinedOpenEndedV1Module(): self.student_attempts = instance_state.get('student_attempts', 0) #Allow reset is true if student has failed the criteria to move to the next child task - self.allow_reset = instance_state.get('ready_to_reset', False) + self.ready_to_reset = instance_state.get('ready_to_reset', False) self.attempts = self.instance_state.get('attempts', MAX_ATTEMPTS) self.is_scored = self.instance_state.get('is_graded', IS_SCORED) in TRUE_DICT self.accept_file_upload = self.instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT @@ -237,8 +237,8 @@ class CombinedOpenEndedV1Module(): self.current_task_xml = self.task_xml[self.current_task_number] if self.current_task_number > 0: - self.allow_reset = self.check_allow_reset() - if self.allow_reset: + self.ready_to_reset = self.check_allow_reset() + if self.ready_to_reset: self.current_task_number = self.current_task_number - 1 current_task_type = self.get_tag_name(self.current_task_xml) @@ -291,7 +291,7 @@ class CombinedOpenEndedV1Module(): Input: None Output: the allow_reset attribute of the current module. """ - if not self.allow_reset: + if not self.ready_to_reset: if self.current_task_number > 0: last_response_data = self.get_last_response(self.current_task_number - 1) current_response_data = self.get_current_attributes(self.current_task_number) @@ -299,9 +299,9 @@ class CombinedOpenEndedV1Module(): if(current_response_data['min_score_to_attempt'] > last_response_data['score'] or current_response_data['max_score_to_attempt'] < last_response_data['score']): self.state = self.DONE - self.allow_reset = True + self.ready_to_reset = True - return self.allow_reset + return self.ready_to_reset def get_context(self): """ @@ -315,7 +315,7 @@ class CombinedOpenEndedV1Module(): context = { 'items': [{'content': task_html}], 'ajax_url': self.system.ajax_url, - 'allow_reset': self.allow_reset, + 'allow_reset': self.ready_to_reset, 'state': self.state, 'task_count': len(self.task_xml), 'task_number': self.current_task_number + 1, @@ -475,7 +475,7 @@ class CombinedOpenEndedV1Module(): Output: boolean indicating whether or not the task state changed. """ changed = False - if not self.allow_reset: + if not self.ready_to_reset: self.task_states[self.current_task_number] = self.current_task.get_instance_state() current_task_state = json.loads(self.task_states[self.current_task_number]) if current_task_state['child_state'] == self.DONE: @@ -624,7 +624,7 @@ class CombinedOpenEndedV1Module(): Output: Dictionary to be rendered """ self.update_task_states() - return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.allow_reset} + return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.ready_to_reset} def reset(self, get): """ @@ -633,7 +633,7 @@ class CombinedOpenEndedV1Module(): Output: AJAX dictionary to tbe rendered """ if self.state != self.DONE: - if not self.allow_reset: + if not self.ready_to_reset: return self.out_of_sync_error(get) if self.student_attempts > self.attempts: @@ -645,14 +645,14 @@ class CombinedOpenEndedV1Module(): self.student_attempts, self.attempts) } self.state = self.INITIAL - self.allow_reset = False + self.ready_to_reset = False for i in xrange(0, len(self.task_xml)): self.current_task_number = i self.setup_next_task(reset=True) self.current_task.reset(self.system) self.task_states[self.current_task_number] = self.current_task.get_instance_state() self.current_task_number = 0 - self.allow_reset = False + self.ready_to_reset = False self.setup_next_task() return {'success': True, 'html': self.get_html_nonsystem()} @@ -669,7 +669,7 @@ class CombinedOpenEndedV1Module(): 'state': self.state, 'task_states': self.task_states, 'student_attempts': self.student_attempts, - 'ready_to_reset': self.allow_reset, + 'ready_to_reset': self.ready_to_reset, } return json.dumps(state) @@ -703,7 +703,7 @@ class CombinedOpenEndedV1Module(): entirely, in which case they will be in the self.DONE state), and if it is scored or not. @return: Boolean corresponding to the above. """ - return (self.state == self.DONE or self.allow_reset) and self.is_scored + return (self.state == self.DONE or self.ready_to_reset) and self.is_scored def get_score(self): """ From d20fc439ecfb310724d02ce37ecf9df86ebf363b Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 6 Mar 2013 11:47:48 -0500 Subject: [PATCH 157/501] Fix peer grading boolean parsing --- .../combined_open_ended_modulev1.py | 2 +- .../xmodule/xmodule/peer_grading_module.py | 23 +++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 7b768457ba..c94f5abd80 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -138,7 +138,7 @@ class CombinedOpenEndedV1Module(): self.attempts = self.instance_state.get('attempts', MAX_ATTEMPTS) self.is_scored = self.instance_state.get('is_graded', IS_SCORED) in TRUE_DICT self.accept_file_upload = self.instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT - self.skip_basic_checks = self.instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) + self.skip_basic_checks = self.instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) in TRUE_DICT display_due_date_string = self.instance_state.get('due', None) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index e6651ed85e..005bd015b5 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -58,16 +58,19 @@ class PeerGradingModule(XModule): else: self.peer_gs = MockPeerGradingService() - if self.use_for_single_location == True: + log.debug(self.use_for_single_location) + log.debug(type(self.use_for_single_location)) + log.debug(self.use_for_single_location==True) + if self.use_for_single_location in TRUE_DICT: try: self.linked_problem = modulestore().get_instance(self.system.course_id, self.link_to_location) except: log.error("Linked location {0} for peer grading module {1} does not exist".format( self.link_to_location, self.location)) raise - due_date = self.linked_problem.metadata.get('peer_grading_due', None) + due_date = self.linked_problem._model_data.get('peer_grading_due', None) if due_date: - self.metadata['due'] = due_date + self._model_data['due'] = due_date try: self.timeinfo = TimeInfo(self.display_due_date_string, self.grace_period_string) @@ -120,7 +123,7 @@ class PeerGradingModule(XModule): """ if self.closed(): return self.peer_grading_closed() - if not self.use_for_single_location: + if self.use_for_single_location not in TRUE_DICT: return self.peer_grading() else: return self.peer_grading_problem({'location': self.link_to_location})['html'] @@ -171,7 +174,7 @@ class PeerGradingModule(XModule): pass def get_score(self): - if not self.use_for_single_location or not self.is_graded: + if self.use_for_single_location not in TRUE_DICT or self.is_graded not in TRUE_DICT: return None try: @@ -204,7 +207,7 @@ class PeerGradingModule(XModule): randomization, and 5/7 on another ''' max_grade = None - if self.use_for_single_location and self.is_graded: + if self.use_for_single_location in TRUE_DICT and self.is_graded in TRUE_DICT: max_grade = self.max_grade return max_grade @@ -450,7 +453,6 @@ class PeerGradingModule(XModule): error_text = problem_list_dict['error'] problem_list = problem_list_dict['problem_list'] - except GradingServiceError: #This is a student_facing_error error_text = EXTERNAL_GRADER_NO_CONTACT_ERROR @@ -480,8 +482,9 @@ class PeerGradingModule(XModule): problem_location = problem['location'] descriptor = _find_corresponding_module_for_location(problem_location) if descriptor: - problem['due'] = descriptor.metadata.get('peer_grading_due', None) - grace_period_string = descriptor.metadata.get('graceperiod', None) + log.debug(descriptor.__dict__) + problem['due'] = descriptor._model_data.get('peer_grading_due', None) + grace_period_string = descriptor._model_data.get('graceperiod', None) try: problem_timeinfo = TimeInfo(problem['due'], grace_period_string) except: @@ -516,7 +519,7 @@ class PeerGradingModule(XModule): Show individual problem interface ''' if get is None or get.get('location') is None: - if not self.use_for_single_location: + if self.use_for_single_location not in TRUE_DICT: #This is an error case, because it must be set to use a single location to be called without get parameters #This is a dev_facing_error log.error("Peer grading problem in peer_grading_module called with no get parameters, but use_for_single_location is False.") From f73979d03b8ea04f02f035b22d080a1edf6022e6 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 6 Mar 2013 11:57:14 -0500 Subject: [PATCH 158/501] Generate less db inserts --- common/lib/xmodule/xmodule/combined_open_ended_module.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 4f806d6b78..ee1f42e585 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -182,7 +182,9 @@ class CombinedOpenEndedModule(XModule): def save_instance_data(self): for attribute in self.student_attributes: - setattr(self,attribute, getattr(self.child_module,attribute)) + child_attr = getattr(self.child_module,attribute) + if child_attr != getattr(self, attribute): + setattr(self,attribute, getattr(self.child_module,attribute)) class CombinedOpenEndedDescriptor(RawDescriptor): From c1218e8c084047547abbf41c477564a0c9c65a9e Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 6 Mar 2013 12:26:23 -0500 Subject: [PATCH 159/501] Remove debug statements --- common/lib/xmodule/xmodule/peer_grading_module.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 005bd015b5..5fc325b632 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -57,10 +57,7 @@ class PeerGradingModule(XModule): self.peer_gs = PeerGradingService(self.system.open_ended_grading_interface, self.system) else: self.peer_gs = MockPeerGradingService() - - log.debug(self.use_for_single_location) - log.debug(type(self.use_for_single_location)) - log.debug(self.use_for_single_location==True) + if self.use_for_single_location in TRUE_DICT: try: self.linked_problem = modulestore().get_instance(self.system.course_id, self.link_to_location) From b75b091581e8ab172a5154d649a0d0506472109d Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Wed, 6 Mar 2013 12:27:22 -0500 Subject: [PATCH 160/501] Remove 2 more debug statements --- .../xmodule/open_ended_grading_classes/open_ended_module.py | 1 - common/lib/xmodule/xmodule/peer_grading_module.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index a7b3f8f9ed..022509b659 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -579,7 +579,6 @@ class OpenEndedModule(openendedchild.OpenEndedChild): 'check_for_score': self.check_for_score, } - log.debug(dispatch) if dispatch not in handlers: #This is a dev_facing_error log.error("Cannot find {0} in handlers in handle_ajax function for open_ended_module.py".format(dispatch)) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 5fc325b632..2208d074a2 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -57,7 +57,7 @@ class PeerGradingModule(XModule): self.peer_gs = PeerGradingService(self.system.open_ended_grading_interface, self.system) else: self.peer_gs = MockPeerGradingService() - + if self.use_for_single_location in TRUE_DICT: try: self.linked_problem = modulestore().get_instance(self.system.course_id, self.link_to_location) @@ -479,7 +479,6 @@ class PeerGradingModule(XModule): problem_location = problem['location'] descriptor = _find_corresponding_module_for_location(problem_location) if descriptor: - log.debug(descriptor.__dict__) problem['due'] = descriptor._model_data.get('peer_grading_due', None) grace_period_string = descriptor._model_data.get('graceperiod', None) try: From 493421c1ec0f78f9a0793a6fd76b302445c4e064 Mon Sep 17 00:00:00 2001 From: Vasyl Nakvasiuk Date: Wed, 6 Mar 2013 19:52:18 +0200 Subject: [PATCH 161/501] fix `test_conditional`, add new conditional to "conditional_and_poll" test course --- .../xmodule/xmodule/tests/test_conditional.py | 18 +++++++++------ .../conditional/condone.xml | 3 +++ .../course/2013_Spring.xml | 6 +++++ .../conditional_and_poll/html/secret_page.xml | 4 ++++ .../problem/choiceprob.xml | 22 +++++++++++++++++++ 5 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 common/test/data/conditional_and_poll/conditional/condone.xml create mode 100644 common/test/data/conditional_and_poll/html/secret_page.xml create mode 100644 common/test/data/conditional_and_poll/problem/choiceprob.xml diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py index d4d5a1d2e1..1d63577073 100644 --- a/common/lib/xmodule/xmodule/tests/test_conditional.py +++ b/common/lib/xmodule/xmodule/tests/test_conditional.py @@ -70,7 +70,7 @@ class ConditionalModuleTest(unittest.TestCase): """Make sure that conditional module works""" print "Starting import" - course = self.get_course('conditional') + course = self.get_course('conditional_and_poll') print "Course: ", course print "id: ", course.id @@ -82,7 +82,9 @@ class ConditionalModuleTest(unittest.TestCase): location = descriptor.location return descriptor.xmodule(test_system) - location = Location(["i4x", "edX", "cond_test", "conditional", "condone"]) + # edx - HarvardX + # cond_test - ER22x + location = Location(["i4x", "HarvardX", "ER22x", "conditional", "condone"]) def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None): return text @@ -91,14 +93,14 @@ class ConditionalModuleTest(unittest.TestCase): module = inner_get_module(location) print "module: ", module - print "module.condition: ", module.condition + print "module.conditions_map: ", module.conditions_map print "module children: ", module.get_children() print "module display items (children): ", module.get_display_items() html = module.get_html() print "html type: ", type(html) print "html: ", html - html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-edX-cond_test-conditional-condone', 'id': 'i4x://edX/cond_test/conditional/condone'}" + html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-HarvardX-ER22x-conditional-condone', 'id': 'i4x://HarvardX/ER22x/conditional/condone', 'depends': 'i4x-HarvardX-ER22x-problem-choiceprob'}" self.assertEqual(html, html_expect) gdi = module.get_display_items() @@ -106,11 +108,13 @@ class ConditionalModuleTest(unittest.TestCase): ajax = json.loads(module.handle_ajax('', '')) print "ajax: ", ajax - self.assertTrue('ConditionalModule' in ajax['html']) + html = ajax['html'] + self.assertFalse(any(['This is a secret' in item for item in html])) # now change state of the capa problem to make it completed - inner_get_module(Location('i4x://edX/cond_test/problem/choiceprob')).attempts = 1 + inner_get_module(Location('i4x://HarvardX/ER22x/problem/choiceprob')).attempts = 1 ajax = json.loads(module.handle_ajax('', '')) print "post-attempt ajax: ", ajax - self.assertTrue('This is a secret' in ajax['html']) + html = ajax['html'] + self.assertTrue(any(['This is a secret' in item for item in html])) diff --git a/common/test/data/conditional_and_poll/conditional/condone.xml b/common/test/data/conditional_and_poll/conditional/condone.xml new file mode 100644 index 0000000000..80b061e244 --- /dev/null +++ b/common/test/data/conditional_and_poll/conditional/condone.xml @@ -0,0 +1,3 @@ + + + diff --git a/common/test/data/conditional_and_poll/course/2013_Spring.xml b/common/test/data/conditional_and_poll/course/2013_Spring.xml index cb6e7c1217..2eea422a2f 100644 --- a/common/test/data/conditional_and_poll/course/2013_Spring.xml +++ b/common/test/data/conditional_and_poll/course/2013_Spring.xml @@ -2,5 +2,11 @@ Take note of this name exactly, you'll need to use it everywhere. --> + + + + + + diff --git a/common/test/data/conditional_and_poll/html/secret_page.xml b/common/test/data/conditional_and_poll/html/secret_page.xml new file mode 100644 index 0000000000..63be3cfa8d --- /dev/null +++ b/common/test/data/conditional_and_poll/html/secret_page.xml @@ -0,0 +1,4 @@ + +

        This is a secret!

        + + diff --git a/common/test/data/conditional_and_poll/problem/choiceprob.xml b/common/test/data/conditional_and_poll/problem/choiceprob.xml new file mode 100644 index 0000000000..fa91954977 --- /dev/null +++ b/common/test/data/conditional_and_poll/problem/choiceprob.xml @@ -0,0 +1,22 @@ + + + +

        Consider a hypothetical magnetic field pointing out of your computer screen. Now imagine an electron traveling from right to left in the plane of your screen. A diagram of this situation is show below…

        +
        + +

        a. The magnitude of the force experienced by the electron is proportional the product of which of the following? (Select all that apply.)

        + + + + +Magnetic field strength… +Electric field strength… +Electric charge of the electron… +Radius of the electron… +Mass of the electron… +Velocity of the electron… + + + + +
        From e7900859b6d1a39a949362a5650127f4fe3de121 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 6 Mar 2013 15:11:08 -0500 Subject: [PATCH 162/501] Remove the 'title' alias for lms.display_name --- cms/djangoapps/contentstore/views.py | 2 +- common/lib/xmodule/xmodule/course_module.py | 4 - lms/djangoapps/course_wiki/views.py | 2 +- lms/djangoapps/courseware/views.py | 2 +- lms/templates/dashboard.html | 18 +-- lms/templates/discussion/index.html | 2 +- lms/templates/navigation.html | 2 +- lms/templates/test_center_register.html | 154 ++++++++++---------- 8 files changed, 91 insertions(+), 95 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 1da1069676..bc1a9a19eb 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -135,7 +135,7 @@ def index(request): return render_to_response('index.html', { 'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'), - 'courses': [(course.title, + 'courses': [(course.lms.display_name, reverse('course_index', args=[ course.location.org, course.location.course, diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index b9d3b34cb3..bd76edf540 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -741,10 +741,6 @@ class CourseDescriptor(SequenceDescriptor): exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code] return exams[0] if len(exams) == 1 else None - @property - def title(self): - return self.display_name - @property def number(self): return self.location.course diff --git a/lms/djangoapps/course_wiki/views.py b/lms/djangoapps/course_wiki/views.py index 6e9f2e38de..febe101071 100644 --- a/lms/djangoapps/course_wiki/views.py +++ b/lms/djangoapps/course_wiki/views.py @@ -95,7 +95,7 @@ def course_wiki_redirect(request, course_id): root, course_slug, title=course_slug, - content="This is the wiki for **{0}**'s _{1}_.".format(course.org, course.title), + content="This is the wiki for **{0}**'s _{1}_.".format(course.org, course.lms.display_name), user_message="Course page automatically created.", user=None, ip_address=None, diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 7930c9de19..c851628c1d 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -282,7 +282,7 @@ def index(request, course_id, chapter=None, section=None, context = { 'csrf': csrf(request)['csrf_token'], 'accordion': render_accordion(request, course, chapter, section, model_data_cache), - 'COURSE_TITLE': course.title, + 'COURSE_TITLE': course.lms.display_name, 'course': course, 'init': '', 'content': '', diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 18275636ac..18a9cac892 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -199,7 +199,7 @@ %> - +
        @@ -216,7 +216,7 @@ % endif

        ${get_course_about_section(course, 'university')}

        -

        ${course.number} ${course.title}

        +

        ${course.number} ${course.lms.display_name}

        <% @@ -226,7 +226,7 @@ %> % if testcenter_exam_info is not None: - % if registration is None and testcenter_exam_info.is_registering(): + % if registration is None and testcenter_exam_info.is_registering():
        Register for Pearson exam

        Registration for the Pearson exam is now open and will close on ${testcenter_exam_info.registration_end_date_text}

        @@ -234,25 +234,25 @@ % endif % if registration is not None: - % if registration.is_accepted: + % if registration.is_accepted:
        Schedule Pearson exam

        Registration number: ${registration.client_candidate_id}

        Write this down! You’ll need it to schedule your exam.

        % endif - % if registration.is_rejected: + % if registration.is_rejected:

        Your registration for the Pearson exam has been rejected. Please see your registration status details. Otherwise contact edX at exam-help@edx.org for further help.

        % endif - % if not registration.is_accepted and not registration.is_rejected: + % if not registration.is_accepted and not registration.is_rejected:

        Your registration for the Pearson exam is pending. Within a few days, you should see a confirmation number here, which can be used to schedule your exam.

        % endif % endif - % endif + % endif <% cert_status = cert_statuses.get(course.id) @@ -314,13 +314,13 @@ View Archived Course % else: View Course - % endif + % endif % endif Unregister
        - + % endfor % else: diff --git a/lms/templates/discussion/index.html b/lms/templates/discussion/index.html index e30ee4a2db..25d68bf85e 100644 --- a/lms/templates/discussion/index.html +++ b/lms/templates/discussion/index.html @@ -26,7 +26,7 @@
        -

        ${course.title} Discussion

        +

        ${course.lms.display_name} Discussion

        diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index d574bc3f6e..60988d7068 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -41,7 +41,7 @@ site_status_msg = get_site_status_msg(course_id)

        % if course: -

        ${course.org}: ${course.number} ${course.title}

        +

        ${course.org}: ${course.number} ${course.lms.display_name}

        % endif
          diff --git a/lms/templates/test_center_register.html b/lms/templates/test_center_register.html index 6b87860fad..ba72bf3186 100644 --- a/lms/templates/test_center_register.html +++ b/lms/templates/test_center_register.html @@ -31,8 +31,8 @@ // when a form is successfully filled out, return back to the dashboard. location.href="${reverse('dashboard')}"; } else { - // This is performed by the following code that parses the errors returned as json by the - // registration form validation. + // This is performed by the following code that parses the errors returned as json by the + // registration form validation. var field_errors = json.field_errors; var non_field_errors = json.non_field_errors; var fieldname; @@ -55,7 +55,7 @@ $("[id='"+field_id+"']").addClass('error'); - field_errorlist = field_errors[fieldname]; + field_errorlist = field_errors[fieldname]; for (i=0; i < field_errorlist.length; i+= 1) { field_error = field_errorlist[i]; error_msg = '' + field_label + ':' + '' + field_error + ''; @@ -88,24 +88,24 @@ -
          - +
          +
          -

          ${get_course_about_section(course, 'university')} ${course.number} ${course.title}

          - +

          ${get_course_about_section(course, 'university')} ${course.number} ${course.lms.display_name}

          + % if registration:

          Your Pearson VUE Proctored Exam Registration

          - % else: + % else:

          Register for a Pearson VUE Proctored Exam

          % endif
          - - <% - exam_help_href = "mailto:exam-help@edx.org?subject=Pearson VUE Exam - " + get_course_about_section(course, 'university') + " - " + course.number + + <% + exam_help_href = "mailto:exam-help@edx.org?subject=Pearson VUE Exam - " + get_course_about_section(course, 'university') + " - " + course.number %> % if registration: @@ -118,14 +118,14 @@
          % endif - % if registration.demographics_is_rejected: + % if registration.demographics_is_rejected:

          Your demographic information contained an error and was rejected

          Please check the information you provided, and correct the errors noted below.

          % endif - - % if registration.registration_is_rejected: + + % if registration.registration_is_rejected:

          Your registration for the Pearson exam has been rejected

          Please see your registration status details for more information.

          @@ -140,14 +140,14 @@ % endif % endif - +
          - % if exam_info.is_registering(): + % if exam_info.is_registering():
          - % else: + % else:
          @@ -155,12 +155,12 @@

          Your previous information is available below, however you may not edit any of the information.

          % endif - + % if registration:

          - Please use the following form if you need to update your demographic information used in your Pearson VUE Proctored Exam. Required fields are noted by bold text and an asterisk (*). + Please use the following form if you need to update your demographic information used in your Pearson VUE Proctored Exam. Required fields are noted by bold text and an asterisk (*).

          - % else: + % else:

          Please provide the following demographic information to register for a Pearson VUE Proctored Exam. Required fields are noted by bold text and an asterisk (*).

          @@ -175,11 +175,11 @@
          - +
          1. - +
          2. @@ -199,14 +199,14 @@
          - +
          - +
          1. - +
          2. @@ -221,7 +221,7 @@
          3. -
          4. +
          5. @@ -238,15 +238,15 @@
          - +
          - +
          1. - +
            @@ -274,12 +274,12 @@
          - + % if registration: % if registration.accommodation_request and len(registration.accommodation_request) > 0:
          % endif - % else: + % else:
          % endif @@ -287,13 +287,13 @@ % if registration.accommodation_request and len(registration.accommodation_request) > 0:

          Note: Your previous accommodation request below needs to be reviewed in detail and will add a significant delay to your registration process.

          % endif - % else: + % else:

          Note: Accommodation requests are not part of your demographic information, and cannot be changed once submitted. Accommodation requests, which are reviewed on a case-by-case basis, will add significant delay to the registration process.

          % endif - +
          - +
            % if registration: % if registration.accommodation_request and len(registration.accommodation_request) > 0: @@ -302,21 +302,21 @@

            ${registration.accommodation_request}

            % endif - % else: + % else:
          1. - +
          2. % endif
          - +
          % if registration: Cancel Update - % else: + % else: Cancel Registration % endif @@ -325,68 +325,68 @@

            -
            +
          % if registration: % if registration.accommodation_request and len(registration.accommodation_request) > 0: % endif - % else: + % else: Special (ADA) Accommodations % endif - +