diff --git a/brew-formulas.txt b/brew-formulas.txt
index 0aed9645d0..b5b555e2a0 100644
--- a/brew-formulas.txt
+++ b/brew-formulas.txt
@@ -7,3 +7,4 @@ python
yuicompressor
node
graphviz
+mysql
diff --git a/cms/.coveragerc b/cms/.coveragerc
new file mode 100644
index 0000000000..42638feb8f
--- /dev/null
+++ b/cms/.coveragerc
@@ -0,0 +1,13 @@
+# .coveragerc for cms
+[run]
+data_file = reports/cms/.coverage
+source = cms
+
+[report]
+ignore_errors = True
+
+[html]
+directory = reports/cms/cover
+
+[xml]
+output = reports/cms/coverage.xml
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 7dcd32caab..d55c309827 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -14,9 +14,7 @@ from path import path
# Nose Test Runner
INSTALLED_APPS += ('django_nose',)
-NOSE_ARGS = ['--cover-erase', '--with-xunit', '--with-xcoverage', '--cover-html', '--cover-inclusive']
-for app in os.listdir(PROJECT_ROOT / 'djangoapps'):
- NOSE_ARGS += ['--cover-package', app]
+NOSE_ARGS = ['--with-xunit']
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
TEST_ROOT = path('test_root')
diff --git a/common/djangoapps/pipeline_mako/templates/static_content.html b/common/djangoapps/pipeline_mako/templates/static_content.html
index c153da22fe..302d4d7aa5 100644
--- a/common/djangoapps/pipeline_mako/templates/static_content.html
+++ b/common/djangoapps/pipeline_mako/templates/static_content.html
@@ -3,8 +3,7 @@ from staticfiles.storage import staticfiles_storage
from pipeline_mako import compressed_css, compressed_js
%>
-<%def name='url(file)'>
-<%
+<%def name='url(file)'><%
try:
url = staticfiles_storage.url(file)
except:
diff --git a/common/djangoapps/student/management/commands/pearson_export_cdd.py b/common/djangoapps/student/management/commands/pearson_export_cdd.py
new file mode 100644
index 0000000000..b10e92d92d
--- /dev/null
+++ b/common/djangoapps/student/management/commands/pearson_export_cdd.py
@@ -0,0 +1,156 @@
+import csv
+import uuid
+from collections import defaultdict, OrderedDict
+from datetime import datetime
+
+from django.core.management.base import BaseCommand, CommandError
+
+from student.models import TestCenterUser
+
+class Command(BaseCommand):
+ CSV_TO_MODEL_FIELDS = OrderedDict([
+ ("ClientCandidateID", "client_candidate_id"),
+ ("FirstName", "first_name"),
+ ("LastName", "last_name"),
+ ("MiddleName", "middle_name"),
+ ("Suffix", "suffix"),
+ ("Salutation", "salutation"),
+ ("Email", "email"),
+ # Skipping optional fields Username and Password
+ ("Address1", "address_1"),
+ ("Address2", "address_2"),
+ ("Address3", "address_3"),
+ ("City", "city"),
+ ("State", "state"),
+ ("PostalCode", "postal_code"),
+ ("Country", "country"),
+ ("Phone", "phone"),
+ ("Extension", "extension"),
+ ("PhoneCountryCode", "phone_country_code"),
+ ("FAX", "fax"),
+ ("FAXCountryCode", "fax_country_code"),
+ ("CompanyName", "company_name"),
+ # Skipping optional field CustomQuestion
+ ("LastUpdate", "user_updated_at"), # in UTC, so same as what we store
+ ])
+
+ args = ''
+ help = """
+ Export user information from TestCenterUser model into a tab delimited
+ text file with a format that Pearson expects.
+ """
+ def handle(self, *args, **kwargs):
+ if len(args) < 1:
+ print Command.help
+ return
+
+ self.reset_sample_data()
+
+ with open(args[0], "wb") as outfile:
+ writer = csv.DictWriter(outfile,
+ Command.CSV_TO_MODEL_FIELDS,
+ delimiter="\t",
+ quoting=csv.QUOTE_MINIMAL,
+ extrasaction='ignore')
+ writer.writeheader()
+ for tcu in TestCenterUser.objects.order_by('id'):
+ record = dict((csv_field, getattr(tcu, model_field))
+ for csv_field, model_field
+ in Command.CSV_TO_MODEL_FIELDS.items())
+ record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S")
+ writer.writerow(record)
+
+ def reset_sample_data(self):
+ def make_sample(**kwargs):
+ data = dict((model_field, kwargs.get(model_field, ""))
+ for model_field in Command.CSV_TO_MODEL_FIELDS.values())
+ return TestCenterUser(**data)
+
+ def generate_id():
+ return "edX{:012}".format(uuid.uuid4().int % (10**12))
+
+ # TestCenterUser.objects.all().delete()
+
+ samples = [
+ make_sample(
+ client_candidate_id=generate_id(),
+ first_name="Jack",
+ last_name="Doe",
+ middle_name="C",
+ address_1="11 Cambridge Center",
+ address_2="Suite 101",
+ city="Cambridge",
+ state="MA",
+ postal_code="02140",
+ country="USA",
+ phone="(617)555-5555",
+ phone_country_code="1",
+ user_updated_at=datetime.utcnow()
+ ),
+ make_sample(
+ client_candidate_id=generate_id(),
+ first_name="Clyde",
+ last_name="Smith",
+ middle_name="J",
+ suffix="Jr.",
+ salutation="Mr.",
+ address_1="1 Penny Lane",
+ city="Honolulu",
+ state="HI",
+ postal_code="96792",
+ country="USA",
+ phone="555-555-5555",
+ phone_country_code="1",
+ user_updated_at=datetime.utcnow()
+ ),
+ make_sample(
+ client_candidate_id=generate_id(),
+ first_name="Patty",
+ last_name="Lee",
+ salutation="Dr.",
+ address_1="P.O. Box 555",
+ city="Honolulu",
+ state="HI",
+ postal_code="96792",
+ country="USA",
+ phone="808-555-5555",
+ phone_country_code="1",
+ user_updated_at=datetime.utcnow()
+ ),
+ make_sample(
+ client_candidate_id=generate_id(),
+ first_name="Jimmy",
+ last_name="James",
+ address_1="2020 Palmer Blvd.",
+ city="Springfield",
+ state="MA",
+ postal_code="96792",
+ country="USA",
+ phone="917-555-5555",
+ phone_country_code="1",
+ extension="2039",
+ fax="917-555-5556",
+ fax_country_code="1",
+ company_name="ACME Traps",
+ user_updated_at=datetime.utcnow()
+ ),
+ make_sample(
+ client_candidate_id=generate_id(),
+ first_name="Yeong-Un",
+ last_name="Seo",
+ address_1="Duryu, Lotte 101",
+ address_2="Apt 55",
+ city="Daegu",
+ country="KOR",
+ phone="917-555-5555",
+ phone_country_code="011",
+ user_updated_at=datetime.utcnow()
+ ),
+
+ ]
+
+ for tcu in samples:
+ tcu.save()
+
+
+
\ No newline at end of file
diff --git a/common/djangoapps/student/management/commands/pearson_export_ead.py b/common/djangoapps/student/management/commands/pearson_export_ead.py
new file mode 100644
index 0000000000..415f0812ae
--- /dev/null
+++ b/common/djangoapps/student/management/commands/pearson_export_ead.py
@@ -0,0 +1,150 @@
+import csv
+import uuid
+from collections import defaultdict, OrderedDict
+from datetime import datetime
+
+from django.core.management.base import BaseCommand, CommandError
+
+from student.models import TestCenterUser
+
+def generate_id():
+ return "{:012}".format(uuid.uuid4().int % (10**12))
+
+class Command(BaseCommand):
+ args = ''
+ help = """
+ Export user information from TestCenterUser model into a tab delimited
+ text file with a format that Pearson expects.
+ """
+ FIELDS = [
+ 'AuthorizationTransactionType',
+ 'AuthorizationID',
+ 'ClientAuthorizationID',
+ 'ClientCandidateID',
+ 'ExamAuthorizationCount',
+ 'ExamSeriesCode',
+ 'EligibilityApptDateFirst',
+ 'EligibilityApptDateLast',
+ 'LastUpdate',
+ ]
+
+ def handle(self, *args, **kwargs):
+ if len(args) < 1:
+ print Command.help
+ return
+
+ # self.reset_sample_data()
+
+ with open(args[0], "wb") as outfile:
+ writer = csv.DictWriter(outfile,
+ Command.FIELDS,
+ delimiter="\t",
+ quoting=csv.QUOTE_MINIMAL,
+ extrasaction='ignore')
+ writer.writeheader()
+ for tcu in TestCenterUser.objects.order_by('id')[:5]:
+ record = defaultdict(
+ lambda: "",
+ AuthorizationTransactionType="Add",
+ ClientAuthorizationID=generate_id(),
+ ClientCandidateID=tcu.client_candidate_id,
+ ExamAuthorizationCount="1",
+ ExamSeriesCode="6002x001",
+ EligibilityApptDateFirst="2012/12/15",
+ EligibilityApptDateLast="2012/12/30",
+ LastUpdate=datetime.utcnow().strftime("%Y/%m/%d %H:%M:%S")
+ )
+ writer.writerow(record)
+
+
+ def reset_sample_data(self):
+ def make_sample(**kwargs):
+ data = dict((model_field, kwargs.get(model_field, ""))
+ for model_field in Command.CSV_TO_MODEL_FIELDS.values())
+ return TestCenterUser(**data)
+
+ # TestCenterUser.objects.all().delete()
+
+ samples = [
+ make_sample(
+ client_candidate_id=generate_id(),
+ first_name="Jack",
+ last_name="Doe",
+ middle_name="C",
+ address_1="11 Cambridge Center",
+ address_2="Suite 101",
+ city="Cambridge",
+ state="MA",
+ postal_code="02140",
+ country="USA",
+ phone="(617)555-5555",
+ phone_country_code="1",
+ user_updated_at=datetime.utcnow()
+ ),
+ make_sample(
+ client_candidate_id=generate_id(),
+ first_name="Clyde",
+ last_name="Smith",
+ middle_name="J",
+ suffix="Jr.",
+ salutation="Mr.",
+ address_1="1 Penny Lane",
+ city="Honolulu",
+ state="HI",
+ postal_code="96792",
+ country="USA",
+ phone="555-555-5555",
+ phone_country_code="1",
+ user_updated_at=datetime.utcnow()
+ ),
+ make_sample(
+ client_candidate_id=generate_id(),
+ first_name="Patty",
+ last_name="Lee",
+ salutation="Dr.",
+ address_1="P.O. Box 555",
+ city="Honolulu",
+ state="HI",
+ postal_code="96792",
+ country="USA",
+ phone="808-555-5555",
+ phone_country_code="1",
+ user_updated_at=datetime.utcnow()
+ ),
+ make_sample(
+ client_candidate_id=generate_id(),
+ first_name="Jimmy",
+ last_name="James",
+ address_1="2020 Palmer Blvd.",
+ city="Springfield",
+ state="MA",
+ postal_code="96792",
+ country="USA",
+ phone="917-555-5555",
+ phone_country_code="1",
+ extension="2039",
+ fax="917-555-5556",
+ fax_country_code="1",
+ company_name="ACME Traps",
+ user_updated_at=datetime.utcnow()
+ ),
+ make_sample(
+ client_candidate_id=generate_id(),
+ first_name="Yeong-Un",
+ last_name="Seo",
+ address_1="Duryu, Lotte 101",
+ address_2="Apt 55",
+ city="Daegu",
+ country="KOR",
+ phone="917-555-5555",
+ phone_country_code="011",
+ user_updated_at=datetime.utcnow()
+ ),
+
+ ]
+
+ for tcu in samples:
+ tcu.save()
+
+
+
\ No newline at end of file
diff --git a/common/djangoapps/student/management/commands/pearson_make_tc_user.py b/common/djangoapps/student/management/commands/pearson_make_tc_user.py
new file mode 100644
index 0000000000..d974c25b6b
--- /dev/null
+++ b/common/djangoapps/student/management/commands/pearson_make_tc_user.py
@@ -0,0 +1,85 @@
+import uuid
+from datetime import datetime
+from optparse import make_option
+
+from django.contrib.auth.models import User
+from django.core.management.base import BaseCommand, CommandError
+
+from student.models import TestCenterUser
+
+class Command(BaseCommand):
+ option_list = BaseCommand.option_list + (
+ make_option(
+ '--client_candidate_id',
+ action='store',
+ dest='client_candidate_id',
+ help='ID we assign a user to identify them to Pearson'
+ ),
+ make_option(
+ '--first_name',
+ action='store',
+ dest='first_name',
+ ),
+ make_option(
+ '--last_name',
+ action='store',
+ dest='last_name',
+ ),
+ make_option(
+ '--address_1',
+ action='store',
+ dest='address_1',
+ ),
+ make_option(
+ '--city',
+ action='store',
+ dest='city',
+ ),
+ make_option(
+ '--state',
+ action='store',
+ dest='state',
+ help='Two letter code (e.g. MA)'
+ ),
+ make_option(
+ '--postal_code',
+ action='store',
+ dest='postal_code',
+ ),
+ make_option(
+ '--country',
+ action='store',
+ dest='country',
+ help='Three letter country code (ISO 3166-1 alpha-3), like USA'
+ ),
+ make_option(
+ '--phone',
+ action='store',
+ dest='phone',
+ help='Pretty free-form (parens, spaces, dashes), but no country code'
+ ),
+ make_option(
+ '--phone_country_code',
+ action='store',
+ dest='phone_country_code',
+ help='Phone country code, just "1" for the USA'
+ ),
+ )
+ args = ""
+ help = "Create a TestCenterUser entry for a given Student"
+
+ @staticmethod
+ def is_valid_option(option_name):
+ base_options = set(option.dest for option in BaseCommand.option_list)
+ return option_name not in base_options
+
+
+ def handle(self, *args, **options):
+ username = args[0]
+ print username
+
+ our_options = dict((k, v) for k, v in options.items()
+ if Command.is_valid_option(k))
+ student = User.objects.get(username=username)
+ student.test_center_user = TestCenterUser(**our_options)
+ student.test_center_user.save()
diff --git a/common/djangoapps/student/migrations/0020_add_test_center_user.py b/common/djangoapps/student/migrations/0020_add_test_center_user.py
new file mode 100644
index 0000000000..e308e2d7e0
--- /dev/null
+++ b/common/djangoapps/student/migrations/0020_add_test_center_user.py
@@ -0,0 +1,188 @@
+# -*- 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 'TestCenterUser'
+ db.create_table('student_testcenteruser', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['auth.User'], unique=True)),
+ ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)),
+ ('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)),
+ ('user_updated_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)),
+ ('candidate_id', self.gf('django.db.models.fields.IntegerField')(null=True, db_index=True)),
+ ('client_candidate_id', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
+ ('first_name', self.gf('django.db.models.fields.CharField')(max_length=30, db_index=True)),
+ ('last_name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
+ ('middle_name', self.gf('django.db.models.fields.CharField')(max_length=30, blank=True)),
+ ('suffix', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)),
+ ('salutation', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)),
+ ('address_1', self.gf('django.db.models.fields.CharField')(max_length=40)),
+ ('address_2', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)),
+ ('address_3', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)),
+ ('city', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)),
+ ('state', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=20, blank=True)),
+ ('postal_code', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=16, blank=True)),
+ ('country', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)),
+ ('phone', self.gf('django.db.models.fields.CharField')(max_length=35)),
+ ('extension', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=8, blank=True)),
+ ('phone_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)),
+ ('fax', self.gf('django.db.models.fields.CharField')(max_length=35, blank=True)),
+ ('fax_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, blank=True)),
+ ('company_name', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)),
+ ))
+ db.send_create_signal('student', ['TestCenterUser'])
+
+
+ def backwards(self, orm):
+ # Deleting model 'TestCenterUser'
+ db.delete_table('student_testcenteruser')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'about': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'avatar_type': ('django.db.models.fields.CharField', [], {'default': "'n'", 'max_length': '1'}),
+ 'bronze': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
+ 'consecutive_days_visit_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+ 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}),
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'date_of_birth': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
+ 'display_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'email_isvalid': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'email_key': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}),
+ 'email_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'gold': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
+ 'gravatar': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'ignored_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'interesting_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'last_seen': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'location': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
+ 'new_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'questions_per_page': ('django.db.models.fields.SmallIntegerField', [], {'default': '10'}),
+ 'real_name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
+ 'reputation': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1'}),
+ 'seen_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+ 'show_country': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'silver': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
+ 'status': ('django.db.models.fields.CharField', [], {'default': "'w'", 'max_length': '2'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}),
+ 'website': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'student.courseenrollment': {
+ 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
+ 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+ },
+ 'student.pendingemailchange': {
+ 'Meta': {'object_name': 'PendingEmailChange'},
+ 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.pendingnamechange': {
+ 'Meta': {'object_name': 'PendingNameChange'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+ 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.registration': {
+ 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
+ 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'student.testcenteruser': {
+ 'Meta': {'object_name': 'TestCenterUser'},
+ 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+ 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
+ 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
+ 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
+ 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+ 'client_candidate_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
+ 'company_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
+ 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
+ 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
+ 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
+ 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
+ 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
+ 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
+ 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
+ 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
+ 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
+ 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+ 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
+ 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
+ },
+ 'student.userprofile': {
+ 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
+ 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
+ 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
+ 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
+ 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
+ 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
+ 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
+ },
+ 'student.usertestgroup': {
+ 'Meta': {'object_name': 'UserTestGroup'},
+ 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
+ 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
+ }
+ }
+
+ complete_apps = ['student']
\ No newline at end of file
diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index 7e8658045e..61c2537399 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -8,7 +8,7 @@ Portal servers that hold the canoncial user information and that user
information is replicated to slave Course server pools. Each Course has a set of
servers that serves only its content and has users that are relevant only to it.
-We replicate the following tables into the Course DBs where the user is
+We replicate the following tables into the Course DBs where the user is
enrolled. Only the Portal servers should ever write to these models.
* UserProfile
* CourseEnrollment
@@ -41,33 +41,22 @@ import uuid
from django.conf import settings
from django.contrib.auth.models import User
from django.db import models
-from django.db.models.signals import post_delete, post_save
-from django.dispatch import receiver
-from django_countries import CountryField
-
from django.db.models.signals import post_save
from django.dispatch import receiver
-from functools import partial
-
import comment_client as cc
-from django_comment_client.models import Role, Permission
+from django_comment_client.models import Role
-import logging
-
-from xmodule.modulestore.django import modulestore
-
-#from cache_toolbox import cache_model, cache_relation
log = logging.getLogger(__name__)
class UserProfile(models.Model):
- """This is where we store all the user demographic fields. We have a
+ """This is where we store all the user demographic fields. We have a
separate table for this rather than extending the built-in Django auth_user.
Notes:
- * Some fields are legacy ones from the first run of 6.002, from which
+ * Some fields are legacy ones from the first run of 6.002, from which
we imported many users.
* Fields like name and address are intentionally open ended, to account
for international variations. An unfortunate side-effect is that we
@@ -133,6 +122,72 @@ class UserProfile(models.Model):
def set_meta(self, js):
self.meta = json.dumps(js)
+class TestCenterUser(models.Model):
+ """This is our representation of the User for in-person testing, and
+ specifically for Pearson at this point. A few things to note:
+
+ * Pearson only supports Latin-1, so we have to make sure that the data we
+ capture here will work with that encoding.
+ * While we have a lot of this demographic data in UserProfile, it's much
+ more free-structured there. We'll try to pre-pop the form with data from
+ UserProfile, but we'll need to have a step where people who are signing
+ up re-enter their demographic data into the fields we specify.
+ * Users are only created here if they register to take an exam in person.
+
+ The field names and lengths are modeled on the conventions and constraints
+ of Pearson's data import system, including oddities such as suffix having
+ a limit of 255 while last_name only gets 50.
+ """
+ # Our own record keeping...
+ user = models.ForeignKey(User, unique=True, default=None)
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
+ updated_at = models.DateTimeField(auto_now=True, db_index=True)
+ # user_updated_at happens only when the user makes a change to their data,
+ # and is something Pearson needs to know to manage updates. Unlike
+ # updated_at, this will not get incremented when we do a batch data import.
+ user_updated_at = models.DateTimeField(db_index=True)
+
+ # Unique ID given to us for this User by the Testing Center. It's null when
+ # we first create the User entry, and is assigned by Pearson later.
+ candidate_id = models.IntegerField(null=True, db_index=True)
+
+ # Unique ID we assign our user for a the Test Center.
+ client_candidate_id = models.CharField(max_length=50, db_index=True)
+
+ # Name
+ first_name = models.CharField(max_length=30, db_index=True)
+ last_name = models.CharField(max_length=50, db_index=True)
+ middle_name = models.CharField(max_length=30, blank=True)
+ suffix = models.CharField(max_length=255, blank=True)
+ salutation = models.CharField(max_length=50, blank=True)
+
+ # Address
+ address_1 = models.CharField(max_length=40)
+ address_2 = models.CharField(max_length=40, blank=True)
+ address_3 = models.CharField(max_length=40, blank=True)
+ city = models.CharField(max_length=32, db_index=True)
+ # state example: HI -- they have an acceptable list that we'll just plug in
+ # state is required if you're in the US or Canada, but otherwise not.
+ state = models.CharField(max_length=20, blank=True, db_index=True)
+ # postal_code required if you're in the US or Canada
+ postal_code = models.CharField(max_length=16, blank=True, db_index=True)
+ # country is a ISO 3166-1 alpha-3 country code (e.g. "USA", "CAN", "MNG")
+ country = models.CharField(max_length=3, db_index=True)
+
+ # Phone
+ phone = models.CharField(max_length=35)
+ extension = models.CharField(max_length=8, blank=True, db_index=True)
+ phone_country_code = models.CharField(max_length=3, db_index=True)
+ fax = models.CharField(max_length=35, blank=True)
+ # fax_country_code required *if* fax is present.
+ fax_country_code = models.CharField(max_length=3, blank=True)
+
+ # Company
+ company_name = models.CharField(max_length=50, blank=True)
+
+ @property
+ def email(self):
+ return self.user.email
## TODO: Should be renamed to generic UserGroup, and possibly
# Given an optional field for type of group
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 2b940fe982..78ade4152c 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -19,16 +19,19 @@ from django.core.context_processors import csrf
from django.core.mail import send_mail
from django.core.validators import validate_email, validate_slug, ValidationError
from django.db import IntegrityError
-from django.http import HttpResponse, Http404
+from django.http import HttpResponse, HttpResponseForbidden, Http404
from django.shortcuts import redirect
from mitxmako.shortcuts import render_to_response, render_to_string
from bs4 import BeautifulSoup
from django.core.cache import cache
-from django_future.csrf import ensure_csrf_cookie
+from django_future.csrf import ensure_csrf_cookie, csrf_exempt
from student.models import (Registration, UserProfile,
PendingNameChange, PendingEmailChange,
CourseEnrollment)
+
+from certificates.models import CertificateStatuses, certificate_status_for_student
+
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.django import modulestore
@@ -143,11 +146,20 @@ def dashboard(request):
show_courseware_links_for = frozenset(course.id for course in courses
if has_access(request.user, course, 'load'))
+ # TODO: workaround to not have to zip courses and certificates in the template
+ # since before there is a migration to certificates
+ if settings.MITX_FEATURES.get('CERTIFICATES_ENABLED'):
+ cert_statuses = { course.id: certificate_status_for_student(request.user, course.id) for course in courses}
+ else:
+ cert_statuses = {}
+
context = {'courses': courses,
'message': message,
'staff_access': staff_access,
'errored_courses': errored_courses,
- 'show_courseware_links_for' : show_courseware_links_for}
+ 'show_courseware_links_for' : show_courseware_links_for,
+ 'cert_statuses': cert_statuses,
+ }
return render_to_response('dashboard.html', context)
@@ -206,13 +218,13 @@ def change_enrollment(request):
return {'success': False,
'error': 'enrollment in {} not allowed at this time'
.format(course.display_name)}
-
- org, course_num, run=course_id.split("/")
+
+ org, course_num, run=course_id.split("/")
statsd.increment("common.student.enrollment",
tags=["org:{0}".format(org),
"course:{0}".format(course_num),
"run:{0}".format(run)])
-
+
enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id)
return {'success': True}
@@ -220,13 +232,13 @@ def change_enrollment(request):
try:
enrollment = CourseEnrollment.objects.get(user=user, course_id=course_id)
enrollment.delete()
-
- org, course_num, run=course_id.split("/")
+
+ org, course_num, run=course_id.split("/")
statsd.increment("common.student.unenrollment",
tags=["org:{0}".format(org),
"course:{0}".format(course_num),
"run:{0}".format(run)])
-
+
return {'success': True}
except CourseEnrollment.DoesNotExist:
return {'success': False, 'error': 'You are not enrolled for this course.'}
@@ -275,13 +287,13 @@ def login_user(request, error=""):
log.info("Login success - {0} ({1})".format(username, email))
try_change_enrollment(request)
-
+
statsd.increment("common.student.successful_login")
-
+
return HttpResponse(json.dumps({'success': True}))
-
+
log.warning("Login failed - Account not active for user {0}, resending activation".format(username))
-
+
reactivation_email_for_user(user)
not_activated_msg = "This account has not been activated. We have " + \
"sent another activation message. Please check your " + \
@@ -483,9 +495,9 @@ def create_account(request, post_override=None):
log.debug('bypassing activation email')
login_user.is_active = True
login_user.save()
-
+
statsd.increment("common.student.account_created")
-
+
js = {'success': True}
return HttpResponse(json.dumps(js), mimetype="application/json")
@@ -541,9 +553,9 @@ def password_reset(request):
''' Attempts to send a password reset e-mail. '''
if request.method != "POST":
raise Http404
-
+
# By default, Django doesn't allow Users with is_active = False to reset their passwords,
- # but this bites people who signed up a long time ago, never activated, and forgot their
+ # but this bites people who signed up a long time ago, never activated, and forgot their
# password. So for their sake, we'll auto-activate a user for whome password_reset is called.
try:
user = User.objects.get(email=request.POST['email'])
@@ -551,7 +563,7 @@ def password_reset(request):
user.save()
except:
log.exception("Tried to auto-activate user to enable password reset, but failed.")
-
+
form = PasswordResetForm(request.POST)
if form.is_valid():
form.save(use_https = request.is_secure(),
@@ -589,7 +601,7 @@ def reactivation_email_for_user(user):
res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
return HttpResponse(json.dumps({'success': True}))
-
+
@ensure_csrf_cookie
def change_email_request(request):
@@ -764,8 +776,8 @@ def accept_name_change_by_id(id):
@ensure_csrf_cookie
def accept_name_change(request):
- ''' JSON: Name change process. Course staff clicks 'accept' on a given name change
-
+ ''' JSON: Name change process. Course staff clicks 'accept' on a given name change
+
We used this during the prototype but now we simply record name changes instead
of manually approving them. Still keeping this around in case we want to go
back to this approval method.
@@ -774,3 +786,23 @@ def accept_name_change(request):
raise Http404
return accept_name_change_by_id(int(request.POST['id']))
+
+# TODO: This is a giant kludge to give Pearson something to test against ASAP.
+# Will need to get replaced by something that actually ties into TestCenterUser
+@csrf_exempt
+def test_center_login(request):
+ if not settings.MITX_FEATURES.get('ENABLE_PEARSON_HACK_TEST'):
+ raise Http404
+
+ client_candidate_id = request.POST.get("clientCandidateID")
+ # registration_id = request.POST.get("registrationID")
+ exit_url = request.POST.get("exitURL")
+ error_url = request.POST.get("errorURL")
+
+ if client_candidate_id == "edX003671291147":
+ user = authenticate(username=settings.PEARSON_TEST_USER,
+ password=settings.PEARSON_TEST_PASSWORD)
+ login(request, user)
+ return redirect('/courses/MITx/6.002x/2012_Fall/courseware/Final_Exam/Final_Exam_Fall_2012/')
+ else:
+ return HttpResponseForbidden()
diff --git a/common/lib/capa/.coveragerc b/common/lib/capa/.coveragerc
new file mode 100644
index 0000000000..6af3218f75
--- /dev/null
+++ b/common/lib/capa/.coveragerc
@@ -0,0 +1,13 @@
+# .coveragerc for common/lib/capa
+[run]
+data_file = reports/common/lib/capa/.coverage
+source = common/lib/capa
+
+[report]
+ignore_errors = True
+
+[html]
+directory = reports/common/lib/capa/cover
+
+[xml]
+output = reports/common/lib/capa/coverage.xml
diff --git a/common/lib/capa/capa/chem/chemtools.py b/common/lib/capa/capa/chem/chemtools.py
index e5a8e1a6f6..ad6633fc00 100644
--- a/common/lib/capa/capa/chem/chemtools.py
+++ b/common/lib/capa/capa/chem/chemtools.py
@@ -1,63 +1,143 @@
-from collections import OrderedDict
+"""This module originally includes functions for grading Vsepr problems.
+
+Also, may be this module is the place for other chemistry-related grade functions. TODO: discuss it.
+"""
+
import json
import unittest
+import itertools
def vsepr_parse_user_answer(user_input):
- d = OrderedDict(json.loads(user_input))
- d['atoms'] = OrderedDict(sorted(d['atoms'].items()))
- return d
+ """
+ user_input is json generated by vsepr.js from dictionary.
+ There are must be only two keys in original user_input dictionary: "geometry" and "atoms".
+ Format: u'{"geometry": "AX3E0","atoms":{"c0": "B","p0": "F","p1": "B","p2": "F"}}'
+ Order of elements inside "atoms" subdict does not matters.
+ Return dict from parsed json.
+
+ "Atoms" subdict stores positions of atoms in molecule.
+ General types of positions:
+ c0 - central atom
+ p0..pN - peripheral atoms
+ a0..aN - axial atoms
+ e0..eN - equatorial atoms
+
+ Each position is dictionary key, i.e. user_input["atoms"]["c0"] is central atom, user_input["atoms"]["a0"] is one of axial atoms.
+
+ Special position only for AX6 (Octahedral) geometry:
+ e10, e12 - atom pairs opposite the central atom,
+ e20, e22 - atom pairs opposite the central atom,
+ e1 and e2 pairs lying crosswise in equatorial plane.
+
+ In user_input["atoms"] may be only 3 set of keys:
+ (c0,p0..pN),
+ (c0, a0..aN, e0..eN),
+ (c0, a0, a1, e10,e11,e20,e21) - if geometry is AX6.
+ """
+ return json.loads(user_input)
def vsepr_build_correct_answer(geometry, atoms):
- correct_answer = OrderedDict()
- correct_answer['geometry'] = geometry
- correct_answer['atoms'] = OrderedDict(sorted(atoms.items()))
- return correct_answer
-
-
-def vsepr_grade(user_input, correct_answer, ignore_p_order=False, ignore_a_order=False, ignore_e_order=False):
- """ Flags ignore_(a,p,e)_order are for checking order in axial, perepherial or equatorial positions.
- Allowed cases:
- c0, a, e
- c0, p
- Not implemented and not tested cases when p with a or e (no need for now)
"""
- # print user_input, type(user_input)
- # print correct_answer, type(correct_answer)
+ geometry is string.
+ atoms is dict of atoms with proper positions.
+ Example:
+
+ correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"})
+
+ returns a dictionary composed from input values:
+ {'geometry': geometry, 'atoms': atoms}
+ """
+ return {'geometry': geometry, 'atoms': atoms}
+
+
+def vsepr_grade(user_input, correct_answer, convert_to_peripheral=False):
+ """
+ This function does comparison between user_input and correct_answer.
+
+ Comparison is successful if all steps are successful:
+
+ 1) geometries are equal
+ 2) central atoms (index in dictionary 'c0') are equal
+ 3):
+ In next steps there is comparing of corresponding subsets of atom positions: equatorial (e0..eN), axial (a0..aN) or peripheral (p0..pN)
+
+ If convert_to_peripheral is True, then axial and equatorial positions are converted to peripheral.
+ This means that user_input from:
+ "atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}' after parsing to json
+ is converted to:
+ {"c0": "Br", "p0": "(ep)", "p1": "test", "p2": "H", "p3": "H", "p4": "(ep)", "p6": "(ep)"}
+ i.e. aX and eX -> pX
+
+ So if converted, p subsets are compared,
+ if not a and e subsets are compared
+ If all subsets are equal, grade succeeds.
+
+ There is also one special case for AX6 geometry.
+ In this case user_input["atoms"] contains special 3 symbol keys: e10, e12, e20, and e21.
+ Correct answer for this geometry can be of 3 types:
+ 1) c0 and peripheral
+ 2) c0 and axial and equatorial
+ 3) c0 and axial and equatorial-subset-1 (e1X) and equatorial-subset-2 (e2X)
+
+ If correct answer is type 1 or 2, then user_input is converted from type 3 to type 2 (or to type 1 if convert_to_peripheral is True)
+
+ If correct_answer is type 3, then we done special case comparison. We have 3 sets of atoms positions both in user_input and correct_answer: axial, eq-1 and eq-2.
+ Answer will be correct if these sets are equals for one of permutations. For example, if :
+ user_axial = correct_eq-1
+ user_eq-1 = correct-axial
+ user_eq-2 = correct-eq-2
+
+ """
if user_input['geometry'] != correct_answer['geometry']:
return False
if user_input['atoms']['c0'] != correct_answer['atoms']['c0']:
return False
- # not order-aware comparisons
- for ignore in [(ignore_p_order, 'p'), (ignore_e_order, 'e'), (ignore_a_order, 'a')]:
- if ignore[0]:
+ if convert_to_peripheral:
+ # convert user_input from (a,e,e1,e2) to (p)
+ # correct_answer must be set in (p) using this flag
+ c0 = user_input['atoms'].pop('c0')
+ user_input['atoms'] = {'p' + str(i): v for i, v in enumerate(user_input['atoms'].values())}
+ user_input['atoms']['c0'] = c0
+
+ # special case for AX6
+ if 'e10' in correct_answer['atoms']: # need check e1x, e2x symmetry for AX6..
+ a_user = {}
+ a_correct = {}
+ for ea_position in ['a', 'e1', 'e2']: # collecting positions:
+ a_user[ea_position] = [v for k, v in user_input['atoms'].items() if k.startswith(ea_position)]
+ a_correct[ea_position] = [v for k, v in correct_answer['atoms'].items() if k.startswith(ea_position)]
+
+ correct = [sorted(a_correct['a'])] + [sorted(a_correct['e1'])] + [sorted(a_correct['e2'])]
+ for permutation in itertools.permutations(['a', 'e1', 'e2']):
+ if correct == [sorted(a_user[permutation[0]])] + [sorted(a_user[permutation[1]])] + [sorted(a_user[permutation[2]])]:
+ return True
+ return False
+
+ else: # no need to check e1x,e2x symmetry - convert them to ex
+ if 'e10' in user_input['atoms']: # e1x exists, it is AX6.. case
+ e_index = 0
+ for k, v in user_input['atoms'].items():
+ if len(k) == 3: # e1x
+ del user_input['atoms'][k]
+ user_input['atoms']['e' + str(e_index)] = v
+ e_index += 1
+
+ # common case
+ for ea_position in ['p', 'a', 'e']:
# collecting atoms:
- a_user = [v for k, v in user_input['atoms'].items() if k.startswith(ignore[1])]
- a_correct = [v for k, v in correct_answer['atoms'].items() if k.startswith(ignore[1])]
- # print ignore[0], ignore[1], a_user, a_correct
+ a_user = [v for k, v in user_input['atoms'].items() if k.startswith(ea_position)]
+ a_correct = [v for k, v in correct_answer['atoms'].items() if k.startswith(ea_position)]
+ # print a_user, a_correct
if len(a_user) != len(a_correct):
return False
if sorted(a_user) != sorted(a_correct):
return False
- # order-aware comparisons
- for ignore in [(ignore_p_order, 'p'), (ignore_e_order, 'e'), (ignore_a_order, 'a')]:
- if not ignore[0]:
- # collecting atoms:
- a_user = [v for k, v in user_input['atoms'].items() if k.startswith(ignore[1])]
- a_correct = [v for k, v in correct_answer['atoms'].items() if k.startswith(ignore[1])]
- # print '2nd', ignore[0], ignore[1], a_user, a_correct
- if len(a_user) != len(a_correct):
- return False
- if len(a_correct) == 0:
- continue
- if a_user != a_correct:
- return False
-
- return True
+ return True
class Test_Grade(unittest.TestCase):
@@ -65,63 +145,53 @@ class Test_Grade(unittest.TestCase):
def test_incorrect_geometry(self):
correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"})
- user_answer = vsepr_parse_user_answer(u'{"geometry":"AX3E0","atoms":{"c0":"B","p0":"F","p1":"B","p2":"F"}}')
+ user_answer = vsepr_parse_user_answer(u'{"geometry": "AX3E0","atoms":{"c0": "B","p0": "F","p1": "B","p2": "F"}}')
self.assertFalse(vsepr_grade(user_answer, correct_answer))
- def test_incorrect_positions(self):
+ def test_correct_answer_p(self):
correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"})
- user_answer = vsepr_parse_user_answer(u'{"geometry":"AX4E0","atoms":{"c0":"B","p0":"F","p1":"B","p2":"F"}}')
- self.assertFalse(vsepr_grade(user_answer, correct_answer))
-
- def test_correct_answer(self):
- correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"})
- user_answer = vsepr_parse_user_answer(u'{"geometry":"AX4E0","atoms":{"c0":"N","p0":"H","p1":"(ep)","p2":"H", "p3":"H"}}')
+ user_answer = vsepr_parse_user_answer(u'{"geometry": "AX4E0","atoms":{"c0": "N","p0": "H","p1": "(ep)","p2": "H", "p3": "H"}}')
self.assertTrue(vsepr_grade(user_answer, correct_answer))
- def test_incorrect_position_order_p(self):
- correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"})
- user_answer = vsepr_parse_user_answer(u'{"geometry":"AX4E0","atoms":{"c0":"N","p0":"H","p1":"H","p2":"(ep)", "p3":"H"}}')
- self.assertFalse(vsepr_grade(user_answer, correct_answer))
-
- def test_correct_position_order_with_ignore_p_order(self):
- correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"})
- user_answer = vsepr_parse_user_answer(u'{"geometry":"AX4E0","atoms":{"c0":"N","p0":"H","p1":"H","p2":"(ep)", "p3":"H"}}')
- self.assertTrue(vsepr_grade(user_answer, correct_answer, ignore_p_order=True))
-
- def test_incorrect_position_order_ae(self):
+ def test_correct_answer_ae(self):
correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "test", "a1": "(ep)", "e0": "H", "e1": "H", "e2": "(ep)", "e3": "(ep)"})
- user_answer = vsepr_parse_user_answer(u'{"geometry":"AX6E0","atoms":{"c0":"Br","a0":"test","a1":"(ep)","e0":"H","e1":"(ep)","e2":"(ep)","e3":"(ep)"}}')
- self.assertFalse(vsepr_grade(user_answer, correct_answer))
+ user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "H","e20": "(ep)","e21": "(ep)"}}')
+ self.assertTrue(vsepr_grade(user_answer, correct_answer))
- def test_correct_position_order_with_ignore_a_order_not_e(self):
+ def test_correct_answer_ae_convert_to_p_but_input_not_in_p(self):
correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "test", "e0": "H", "e1": "H", "e2": "(ep)", "e3": "(ep)"})
- user_answer = vsepr_parse_user_answer(u'{"geometry":"AX6E0","atoms":{"c0":"Br","a0":"test","a1":"(ep)","e0":"H","e1":"H","e2":"(ep)","e3":"(ep)"}}')
- self.assertTrue(vsepr_grade(user_answer, correct_answer, ignore_a_order=True))
+ user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}')
+ self.assertFalse(vsepr_grade(user_answer, correct_answer, convert_to_peripheral=True))
- def test_incorrect_position_order_with_ignore_a_order_not_e(self):
- correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "test", "e0": "H", "e1": "H", "e2": "H", "e3": "(ep)"})
- user_answer = vsepr_parse_user_answer(u'{"geometry":"AX6E0","atoms":{"c0":"Br","a0":"test","a1":"(ep)","e0":"H","e1":"H","e2":"(ep)","e3":"H"}}')
- self.assertFalse(vsepr_grade(user_answer, correct_answer, ignore_a_order=True))
+ def test_correct_answer_ae_convert_to_p(self):
+ correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "p0": "(ep)", "p1": "test", "p2": "H", "p3": "H", "p4": "(ep)", "p6": "(ep)"})
+ user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}')
+ self.assertTrue(vsepr_grade(user_answer, correct_answer, convert_to_peripheral=True))
- def test_correct_position_order_with_ignore_e_order_not_a(self):
- correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "test", "e0": "H", "e1": "H", "e2": "H", "e3": "(ep)"})
- user_answer = vsepr_parse_user_answer(u'{"geometry":"AX6E0","atoms":{"c0":"Br","a0":"(ep)","a1":"test","e0":"H","e1":"H","e2":"(ep)","e3":"H"}}')
- self.assertTrue(vsepr_grade(user_answer, correct_answer, ignore_e_order=True))
+ def test_correct_answer_e1e2_in_a(self):
+ correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"})
+ user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "(ep)","a1": "(ep)","e10": "H","e11": "H","e20": "H","e21": "H"}}')
+ self.assertTrue(vsepr_grade(user_answer, correct_answer))
- def test_incorrect_position_order_with_ignore_e_order__not_a(self):
- correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "test", "e0": "H", "e1": "H", "e2": "H", "e3": "(ep)"})
- user_answer = vsepr_parse_user_answer(u'{"geometry":"AX6E0","atoms":{"c0":"Br","a0":"test","a1":"(ep)","e0":"H","e1":"H","e2":"(ep)","e3":"H"}}')
- self.assertFalse(vsepr_grade(user_answer, correct_answer, ignore_e_order=True))
+ def test_correct_answer_e1e2_in_e1(self):
+ correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"})
+ user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "H","a1": "H","e10": "(ep)","e11": "(ep)","e20": "H","e21": "H"}}')
+ self.assertTrue(vsepr_grade(user_answer, correct_answer))
- def test_correct_position_order_with_ignore_ae_order(self):
- correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "test", "e0": "H", "e1": "H", "e2": "H", "e3": "(ep)"})
- user_answer = vsepr_parse_user_answer(u'{"geometry":"AX6E0","atoms":{"c0":"Br","a0":"test","a1":"(ep)","e0":"H","e1":"H","e2":"(ep)","e3":"H"}}')
- self.assertTrue(vsepr_grade(user_answer, correct_answer, ignore_e_order=True, ignore_a_order=True))
+ def test_correct_answer_e1e2_in_e2(self):
+ correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"})
+ user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "H","a1": "H","e10": "H","e11": "H","e20": "(ep)","e21": "(ep)"}}')
+ self.assertTrue(vsepr_grade(user_answer, correct_answer))
+
+ def test_incorrect_answer_e1e2(self):
+ correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"})
+ user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "H","a1": "H","e10": "(ep)","e11": "H","e20": "H","e21": "(ep)"}}')
+ self.assertFalse(vsepr_grade(user_answer, correct_answer))
def test_incorrect_c0(self):
correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "test", "e0": "H", "e1": "H", "e2": "H", "e3": "(ep)"})
- user_answer = vsepr_parse_user_answer(u'{"geometry":"AX6E0","atoms":{"c0":"H","a0":"test","a1":"(ep)","e0":"H","e1":"H","e2":"(ep)","e3":"H"}}')
- self.assertFalse(vsepr_grade(user_answer, correct_answer, ignore_e_order=True, ignore_a_order=True))
+ user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "H","a0": "test","a1": "(ep)","e0": "H","e1": "H","e2": "(ep)","e3": "H"}}')
+ self.assertFalse(vsepr_grade(user_answer, correct_answer))
def suite():
diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py
index 3c4f43a1d6..c7386219b1 100644
--- a/common/lib/capa/capa/correctmap.py
+++ b/common/lib/capa/capa/correctmap.py
@@ -68,7 +68,7 @@ class CorrectMap(object):
correct_map is saved by LMS as a plaintext JSON dump of the correctmap dict. This
means that when the definition of CorrectMap (e.g. its properties) are altered,
- an existing correct_map dict not coincide with the newest CorrectMap format as
+ an existing correct_map dict will not coincide with the newest CorrectMap format as
defined by self.set.
For graceful migration, feed the contents of each correct map to self.set, rather than
diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py
index d47c5a3006..0b2250f98d 100644
--- a/common/lib/capa/capa/inputtypes.py
+++ b/common/lib/capa/capa/inputtypes.py
@@ -21,26 +21,26 @@ Each input type takes the xml tree as 'element', the previous answer as 'value',
graded status as'status'
"""
-# TODO: there is a lot of repetitive "grab these elements from xml attributes, with these defaults,
-# put them in the context" code. Refactor so class just specifies required and optional attrs (with
-# defaults for latter), and InputTypeBase does the right thing.
+# TODO: make hints do something
+
+# TODO: make all inputtypes actually render msg
+
+# TODO: remove unused fields (e.g. 'hidden' in a few places)
+
+# TODO: add validators so that content folks get better error messages.
-# TODO: Quoting and unquoting is handled in a pretty ad-hoc way. Also something that could be done
-# properly once in InputTypeBase.
# Possible todo: make inline the default for textlines and other "one-line" inputs. It probably
# makes sense, but a bunch of problems have markup that assumes block. Bigger TODO: figure out a
# general css and layout strategy for capa, document it, then implement it.
-
-
+from collections import namedtuple
import json
import logging
from lxml import etree
import re
import shlex # for splitting quoted strings
import sys
-import xml.sax.saxutils as saxutils
from registry import TagRegistry
@@ -50,6 +50,61 @@ log = logging.getLogger('mitx.' + __name__)
registry = TagRegistry()
+class Attribute(object):
+ """
+ Allows specifying required and optional attributes for input types.
+ """
+
+ # want to allow default to be None, but also allow required objects
+ _sentinel = object()
+
+ def __init__(self, name, default=_sentinel, transform=None, validate=None, render=True):
+ """
+ Define an attribute
+
+ name (str): then name of the attribute--should be alphanumeric (valid for an XML attribute)
+
+ default (any type): If not specified, this attribute is required. If specified, use this as the default value
+ if the attribute is not specified. Note that this value will not be transformed or validated.
+
+ transform (function str -> any type): If not None, will be called to transform the parsed value into an internal
+ representation.
+
+ validate (function str-or-return-type-of-tranform -> unit or exception): If not None, called to validate the
+ (possibly transformed) value of the attribute. Should raise ValueError with a helpful message if
+ the value is invalid.
+
+ render (bool): if False, don't include this attribute in the template context.
+ """
+ self.name = name
+ self.default = default
+ self.validate = validate
+ self.transform = transform
+ self.render = render
+
+ def parse_from_xml(self, element):
+ """
+ Given an etree xml element that should have this attribute, do the obvious thing:
+ - look for it. raise ValueError if not found and required.
+ - transform and validate. pass through any exceptions from transform or validate.
+ """
+ val = element.get(self.name)
+ if self.default == self._sentinel and val is None:
+ raise ValueError('Missing required attribute {0}.'.format(self.name))
+
+ if val is None:
+ # not required, so return default
+ return self.default
+
+ if self.transform is not None:
+ val = self.transform(val)
+
+ if self.validate is not None:
+ self.validate(val)
+
+ return val
+
+
class InputTypeBase(object):
"""
Abstract base class for input types.
@@ -102,9 +157,12 @@ class InputTypeBase(object):
self.status = state.get('status', 'unanswered')
- # Call subclass "constructor" -- means they don't have to worry about calling
- # super().__init__, and are isolated from changes to the input constructor interface.
try:
+ # Pre-parse and propcess all the declared requirements.
+ self.process_requirements()
+
+ # Call subclass "constructor" -- means they don't have to worry about calling
+ # super().__init__, and are isolated from changes to the input constructor interface.
self.setup()
except Exception as err:
# Something went wrong: add xml to message, but keep the traceback
@@ -112,6 +170,36 @@ class InputTypeBase(object):
raise Exception, msg, sys.exc_info()[2]
+ @classmethod
+ def get_attributes(cls):
+ """
+ Should return a list of Attribute objects (see docstring there for details). Subclasses should override. e.g.
+
+ return [Attribute('unicorn', True), Attribute('num_dragons', 12, transform=int), ...]
+ """
+ return []
+
+
+ def process_requirements(self):
+ """
+ Subclasses can declare lists of required and optional attributes. This
+ function parses the input xml and pulls out those attributes. This
+ isolates most simple input types from needing to deal with xml parsing at all.
+
+ Processes attributes, putting the results in the self.loaded_attributes dictionary. Also creates a set
+ self.to_render, containing the names of attributes that should be included in the context by default.
+ """
+ # Use local dicts and sets so that if there are exceptions, we don't end up in a partially-initialized state.
+ loaded = {}
+ to_render = set()
+ for a in self.get_attributes():
+ loaded[a.name] = a.parse_from_xml(self.xml)
+ if a.render:
+ to_render.add(a.name)
+
+ self.loaded_attributes = loaded
+ self.to_render = to_render
+
def setup(self):
"""
InputTypes should override this to do any needed initialization. It is called after the
@@ -122,14 +210,36 @@ class InputTypeBase(object):
"""
pass
+
def _get_render_context(self):
"""
- Abstract method. Subclasses should implement to return the dictionary
- of keys needed to render their template.
+ Should return a dictionary of keys needed to render the template for the input type.
(Separate from get_html to faciliate testing of logic separately from the rendering)
+
+ The default implementation gets the following rendering context: basic things like value, id, status, and msg,
+ as well as everything in self.loaded_attributes, and everything returned by self._extra_context().
+
+ This means that input types that only parse attributes and pass them to the template get everything they need,
+ and don't need to override this method.
"""
- raise NotImplementedError
+ context = {
+ 'id': self.id,
+ 'value': self.value,
+ 'status': self.status,
+ 'msg': self.msg,
+ }
+ context.update((a, v) for (a, v) in self.loaded_attributes.iteritems() if a in self.to_render)
+ context.update(self._extra_context())
+ return context
+
+ def _extra_context(self):
+ """
+ Subclasses can override this to return extra context that should be passed to their templates for rendering.
+
+ This is useful when the input type requires computing new template variables from the parsed attributes.
+ """
+ return {}
def get_html(self):
"""
@@ -139,7 +249,9 @@ class InputTypeBase(object):
raise NotImplementedError("no rendering template specified for class {0}"
.format(self.__class__))
- html = self.system.render_template(self.template, self._get_render_context())
+ context = self._get_render_context()
+
+ html = self.system.render_template(self.template, context)
return etree.XML(html)
@@ -153,38 +265,38 @@ class OptionInput(InputTypeBase):
Example:
The location of the sky
+
+ # TODO: allow ordering to be randomized
"""
template = "optioninput.html"
tags = ['optioninput']
- def setup(self):
- # Extract the options...
- options = self.xml.get('options')
- if not options:
- raise ValueError("optioninput: Missing 'options' specification.")
-
+ @staticmethod
+ def parse_options(options):
+ """
+ Given options string, convert it into an ordered list of (option_id, option_description) tuples, where
+ id==description for now. TODO: make it possible to specify different id and descriptions.
+ """
# parse the set of possible options
- oset = shlex.shlex(options[1:-1])
- oset.quotes = "'"
- oset.whitespace = ","
- oset = [x[1:-1] for x in list(oset)]
+ lexer = shlex.shlex(options[1:-1])
+ lexer.quotes = "'"
+ # Allow options to be separated by whitespace as well as commas
+ lexer.whitespace = ", "
- # make ordered list with (key, value) same
- self.osetdict = [(oset[x], oset[x]) for x in range(len(oset))]
- # TODO: allow ordering to be randomized
+ # remove quotes
+ tokens = [x[1:-1] for x in list(lexer)]
- def _get_render_context(self):
+ # make list of (option_id, option_description), with description=id
+ return [(t, t) for t in tokens]
- context = {
- 'id': self.id,
- 'value': self.value,
- 'status': self.status,
- 'msg': self.msg,
- 'options': self.osetdict,
- 'inline': self.xml.get('inline',''),
- }
- return context
+ @classmethod
+ def get_attributes(cls):
+ """
+ Convert options to a convenient format.
+ """
+ return [Attribute('options', transform=cls.parse_options),
+ Attribute('inline', '')]
registry.register(OptionInput)
@@ -223,53 +335,50 @@ class ChoiceGroup(InputTypeBase):
# value. (VS: would be nice to make this less hackish).
if self.tag == 'choicegroup':
self.suffix = ''
- self.element_type = "radio"
+ self.html_input_type = "radio"
elif self.tag == 'radiogroup':
- self.element_type = "radio"
+ self.html_input_type = "radio"
self.suffix = '[]'
elif self.tag == 'checkboxgroup':
- self.element_type = "checkbox"
+ self.html_input_type = "checkbox"
self.suffix = '[]'
else:
raise Exception("ChoiceGroup: unexpected tag {0}".format(self.tag))
- self.choices = extract_choices(self.xml)
+ self.choices = self.extract_choices(self.xml)
- def _get_render_context(self):
- context = {'id': self.id,
- 'value': self.value,
- 'status': self.status,
- 'input_type': self.element_type,
- 'choices': self.choices,
- 'name_array_suffix': self.suffix}
- return context
+ def _extra_context(self):
+ return {'input_type': self.html_input_type,
+ 'choices': self.choices,
+ 'name_array_suffix': self.suffix}
-def extract_choices(element):
- '''
- Extracts choices for a few input types, such as ChoiceGroup, RadioGroup and
- CheckboxGroup.
+ @staticmethod
+ def extract_choices(element):
+ '''
+ Extracts choices for a few input types, such as ChoiceGroup, RadioGroup and
+ CheckboxGroup.
- returns list of (choice_name, choice_text) tuples
+ returns list of (choice_name, choice_text) tuples
- TODO: allow order of choices to be randomized, following lon-capa spec. Use
- "location" attribute, ie random, top, bottom.
- '''
+ TODO: allow order of choices to be randomized, following lon-capa spec. Use
+ "location" attribute, ie random, top, bottom.
+ '''
- choices = []
+ choices = []
- for choice in element:
- if choice.tag != 'choice':
- raise Exception(
- "[capa.inputtypes.extract_choices] Expected a tag; got %s instead"
- % choice.tag)
- choice_text = ''.join([etree.tostring(x) for x in choice])
- if choice.text is not None:
- # TODO: fix order?
- choice_text += choice.text
+ for choice in element:
+ if choice.tag != 'choice':
+ raise Exception(
+ "[capa.inputtypes.extract_choices] Expected a tag; got %s instead"
+ % choice.tag)
+ choice_text = ''.join([etree.tostring(x) for x in choice])
+ if choice.text is not None:
+ # TODO: fix order?
+ choice_text += choice.text
- choices.append((choice.get("name"), choice_text))
+ choices.append((choice.get("name"), choice_text))
- return choices
+ return choices
registry.register(ChoiceGroup)
@@ -292,33 +401,23 @@ class JavascriptInput(InputTypeBase):
template = "javascriptinput.html"
tags = ['javascriptinput']
+ @classmethod
+ def get_attributes(cls):
+ """
+ Register the attributes.
+ """
+ return [Attribute('params', None),
+ Attribute('problem_state', None),
+ Attribute('display_class', None),
+ Attribute('display_file', None),]
+
+
def setup(self):
# Need to provide a value that JSON can parse if there is no
# student-supplied value yet.
if self.value == "":
self.value = 'null'
- self.params = self.xml.get('params')
- self.problem_state = self.xml.get('problem_state')
- self.display_class = self.xml.get('display_class')
- self.display_file = self.xml.get('display_file')
-
-
- def _get_render_context(self):
- escapedict = {'"': '"'}
- value = saxutils.escape(self.value, escapedict)
- msg = saxutils.escape(self.msg, escapedict)
-
- context = {'id': self.id,
- 'params': self.params,
- 'display_file': self.display_file,
- 'display_class': self.display_class,
- 'problem_state': self.problem_state,
- 'value': value,
- 'evaluation': msg,
- }
- return context
-
registry.register(JavascriptInput)
@@ -326,51 +425,55 @@ registry.register(JavascriptInput)
class TextLine(InputTypeBase):
"""
+ A text line input. Can do math preview if "math"="1" is specified.
+ If the hidden attribute is specified, the textline is hidden and the input id is stored in a div with name equal
+ to the value of the hidden attribute. This is used e.g. for embedding simulations turned into questions.
"""
template = "textline.html"
tags = ['textline']
+
+ @classmethod
+ def get_attributes(cls):
+ """
+ Register the attributes.
+ """
+ return [
+ Attribute('size', None),
+
+
+ Attribute('hidden', False),
+ Attribute('inline', False),
+
+ # Attributes below used in setup(), not rendered directly.
+ Attribute('math', None, render=False),
+ # TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x
+ Attribute('dojs', None, render=False),
+ Attribute('preprocessorClassName', None, render=False),
+ Attribute('preprocessorSrc', None, render=False),
+ ]
+
+
def setup(self):
- self.size = self.xml.get('size')
+ self.do_math = bool(self.loaded_attributes['math'] or
+ self.loaded_attributes['dojs'])
- # if specified, then textline is hidden and input id is stored
- # in div with name=self.hidden.
- self.hidden = self.xml.get('hidden', False)
-
- self.inline = self.xml.get('inline', False)
-
- # TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x
- self.do_math = bool(self.xml.get('math') or self.xml.get('dojs'))
# TODO: do math checking using ajax instead of using js, so
# that we only have one math parser.
self.preprocessor = None
if self.do_math:
# Preprocessor to insert between raw input and Mathjax
- self.preprocessor = {'class_name': self.xml.get('preprocessorClassName',''),
- 'script_src': self.xml.get('preprocessorSrc','')}
- if '' in self.preprocessor.values():
+ self.preprocessor = {'class_name': self.loaded_attributes['preprocessorClassName'],
+ 'script_src': self.loaded_attributes['preprocessorSrc']}
+ if None in self.preprocessor.values():
self.preprocessor = None
-
- def _get_render_context(self):
- # Escape answers with quotes, so they don't crash the system!
- escapedict = {'"': '"'}
- value = saxutils.escape(self.value, escapedict)
-
- context = {'id': self.id,
- 'value': value,
- 'status': self.status,
- 'size': self.size,
- 'msg': self.msg,
- 'hidden': self.hidden,
- 'inline': self.inline,
- 'do_math': self.do_math,
- 'preprocessor': self.preprocessor,
- }
- return context
+ def _extra_context(self):
+ return {'do_math': self.do_math,
+ 'preprocessor': self.preprocessor,}
registry.register(TextLine)
@@ -388,30 +491,36 @@ class FileSubmission(InputTypeBase):
submitted_msg = ("Your file(s) have been submitted; as soon as your submission is"
" graded, this message will be replaced with the grader's feedback.")
- def setup(self):
- escapedict = {'"': '"'}
- self.allowed_files = json.dumps(self.xml.get('allowed_files', '').split())
- self.allowed_files = saxutils.escape(self.allowed_files, escapedict)
- self.required_files = json.dumps(self.xml.get('required_files', '').split())
- self.required_files = saxutils.escape(self.required_files, escapedict)
+ @staticmethod
+ def parse_files(files):
+ """
+ Given a string like 'a.py b.py c.out', split on whitespace and return as a json list.
+ """
+ return json.dumps(files.split())
+ @classmethod
+ def get_attributes(cls):
+ """
+ Convert the list of allowed files to a convenient format.
+ """
+ return [Attribute('allowed_files', '[]', transform=cls.parse_files),
+ Attribute('required_files', '[]', transform=cls.parse_files),]
+
+ def setup(self):
+ """
+ Do some magic to handle queueing status (render as "queued" instead of "incomplete"),
+ pull queue_len from the msg field. (TODO: get rid of the queue_len hack).
+ """
# Check if problem has been queued
- queue_len = 0
+ self.queue_len = 0
# Flag indicating that the problem has been queued, 'msg' is length of queue
if self.status == 'incomplete':
self.status = 'queued'
self.queue_len = self.msg
self.msg = FileSubmission.submitted_msg
- def _get_render_context(self):
-
- context = {'id': self.id,
- 'status': self.status,
- 'msg': self.msg,
- 'value': self.value,
- 'queue_len': self.queue_len,
- 'allowed_files': self.allowed_files,
- 'required_files': self.required_files,}
+ def _extra_context(self):
+ return {'queue_len': self.queue_len,}
return context
registry.register(FileSubmission)
@@ -431,13 +540,30 @@ class CodeInput(InputTypeBase):
# non-codemirror editor.
]
+ # pulled out for testing
+ submitted_msg = ("Submitted. As soon as your submission is"
+ " graded, this message will be replaced with the grader's feedback.")
+
+ @classmethod
+ def get_attributes(cls):
+ """
+ Convert options to a convenient format.
+ """
+ return [Attribute('rows', '30'),
+ Attribute('cols', '80'),
+ Attribute('hidden', ''),
+
+ # For CodeMirror
+ Attribute('mode', 'python'),
+ Attribute('linenumbers', 'true'),
+ # Template expects tabsize to be an int it can do math with
+ Attribute('tabsize', 4, transform=int),
+ ]
def setup(self):
- self.rows = self.xml.get('rows') or '30'
- self.cols = self.xml.get('cols') or '80'
- # if specified, then textline is hidden and id is stored in div of name given by hidden
- self.hidden = self.xml.get('hidden', '')
-
+ """
+ Implement special logic: handle queueing state, and default input.
+ """
# if no student input yet, then use the default input given by the problem
if not self.value:
self.value = self.xml.text
@@ -448,28 +574,11 @@ class CodeInput(InputTypeBase):
if self.status == 'incomplete':
self.status = 'queued'
self.queue_len = self.msg
- self.msg = 'Submitted to grader.'
+ self.msg = self.submitted_msg
- # For CodeMirror
- self.mode = self.xml.get('mode', 'python')
- self.linenumbers = self.xml.get('linenumbers', 'true')
- self.tabsize = int(self.xml.get('tabsize', '4'))
-
- def _get_render_context(self):
-
- context = {'id': self.id,
- 'value': self.value,
- 'status': self.status,
- 'msg': self.msg,
- 'mode': self.mode,
- 'linenumbers': self.linenumbers,
- 'rows': self.rows,
- 'cols': self.cols,
- 'hidden': self.hidden,
- 'tabsize': self.tabsize,
- 'queue_len': self.queue_len,
- }
- return context
+ def _extra_context(self):
+ """Defined queue_len, add it """
+ return {'queue_len': self.queue_len,}
registry.register(CodeInput)
@@ -482,26 +591,19 @@ class Schematic(InputTypeBase):
template = "schematicinput.html"
tags = ['schematic']
- def setup(self):
- self.height = self.xml.get('height')
- self.width = self.xml.get('width')
- self.parts = self.xml.get('parts')
- self.analyses = self.xml.get('analyses')
- self.initial_value = self.xml.get('initial_value')
- self.submit_analyses = self.xml.get('submit_analyses')
+ @classmethod
+ def get_attributes(cls):
+ """
+ Convert options to a convenient format.
+ """
+ return [
+ Attribute('height', None),
+ Attribute('width', None),
+ Attribute('parts', None),
+ Attribute('analyses', None),
+ Attribute('initial_value', None),
+ Attribute('submit_analyses', None),]
-
- def _get_render_context(self):
-
- context = {'id': self.id,
- 'value': self.value,
- 'initial_value': self.initial_value,
- 'status': self.status,
- 'width': self.width,
- 'height': self.height,
- 'parts': self.parts,
- 'analyses': self.analyses,
- 'submit_analyses': self.submit_analyses,}
return context
registry.register(Schematic)
@@ -522,12 +624,20 @@ class ImageInput(InputTypeBase):
template = "imageinput.html"
tags = ['imageinput']
- def setup(self):
- self.src = self.xml.get('src')
- self.height = self.xml.get('height')
- self.width = self.xml.get('width')
+ @classmethod
+ def get_attributes(cls):
+ """
+ Note: src, height, and width are all required.
+ """
+ return [Attribute('src'),
+ Attribute('height'),
+ Attribute('width'),]
- # if value is of the form [x,y] then parse it and send along coordinates of previous answer
+
+ def setup(self):
+ """
+ if value is of the form [x,y] then parse it and send along coordinates of previous answer
+ """
m = re.match('\[([0-9]+),([0-9]+)]', self.value.strip().replace(' ', ''))
if m:
# Note: we subtract 15 to compensate for the size of the dot on the screen.
@@ -537,19 +647,10 @@ class ImageInput(InputTypeBase):
(self.gx, self.gy) = (0, 0)
- def _get_render_context(self):
+ def _extra_context(self):
- context = {'id': self.id,
- 'value': self.value,
- 'height': self.height,
- 'width': self.width,
- 'src': self.src,
- 'gx': self.gx,
- 'gy': self.gy,
- 'status': self.status,
- 'msg': self.msg,
- }
- return context
+ return {'gx': self.gx,
+ 'gy': self.gy}
registry.register(ImageInput)
@@ -565,30 +666,18 @@ class Crystallography(InputTypeBase):
template = "crystallography.html"
tags = ['crystallography']
+ @classmethod
+ def get_attributes(cls):
+ """
+ Note: height, width are required.
+ """
+ return [Attribute('size', None),
+ Attribute('height'),
+ Attribute('width'),
- def setup(self):
- self.height = self.xml.get('height')
- self.width = self.xml.get('width')
- self.size = self.xml.get('size')
-
- # if specified, then textline is hidden and id is stored in div of name given by hidden
- self.hidden = self.xml.get('hidden', '')
-
- # Escape answers with quotes, so they don't crash the system!
- escapedict = {'"': '"'}
- self.value = saxutils.escape(self.value, escapedict)
-
- def _get_render_context(self):
- context = {'id': self.id,
- 'value': self.value,
- 'status': self.status,
- 'size': self.size,
- 'msg': self.msg,
- 'hidden': self.hidden,
- 'width': self.width,
- 'height': self.height,
- }
- return context
+ # can probably be removed (textline should prob be always-hidden)
+ Attribute('hidden', ''),
+ ]
registry.register(Crystallography)
@@ -603,29 +692,16 @@ class VseprInput(InputTypeBase):
template = 'vsepr_input.html'
tags = ['vsepr_input']
- def setup(self):
- self.height = self.xml.get('height')
- self.width = self.xml.get('width')
-
- # Escape answers with quotes, so they don't crash the system!
- escapedict = {'"': '"'}
- self.value = saxutils.escape(self.value, escapedict)
-
- self.molecules = self.xml.get('molecules')
- self.geometries = self.xml.get('geometries')
-
- def _get_render_context(self):
-
- context = {'id': self.id,
- 'value': self.value,
- 'status': self.status,
- 'msg': self.msg,
- 'width': self.width,
- 'height': self.height,
- 'molecules': self.molecules,
- 'geometries': self.geometries,
- }
- return context
+ @classmethod
+ def get_attributes(cls):
+ """
+ Note: height, width are required.
+ """
+ return [Attribute('height'),
+ Attribute('width'),
+ Attribute('molecules'),
+ Attribute('geometries'),
+ ]
registry.register(VseprInput)
@@ -646,17 +722,17 @@ class ChemicalEquationInput(InputTypeBase):
template = "chemicalequationinput.html"
tags = ['chemicalequationinput']
- def setup(self):
- self.size = self.xml.get('size', '20')
+ @classmethod
+ def get_attributes(cls):
+ """
+ Can set size of text field.
+ """
+ return [Attribute('size', '20'),]
- def _get_render_context(self):
- context = {
- 'id': self.id,
- 'value': self.value,
- 'status': self.status,
- 'size': self.size,
- 'previewer': '/static/js/capa/chemical_equation_preview.js',
- }
- return context
+ def _extra_context(self):
+ """
+ TODO (vshnayder): Get rid of this once we have a standard way of requiring js to be loaded.
+ """
+ return {'previewer': '/static/js/capa/chemical_equation_preview.js',}
registry.register(ChemicalEquationInput)
diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py
index 648fc9e861..b990c489b3 100644
--- a/common/lib/capa/capa/responsetypes.py
+++ b/common/lib/capa/capa/responsetypes.py
@@ -81,7 +81,7 @@ class LoncapaResponse(object):
by __init__
- check_hint_condition : check to see if the student's answers satisfy a particular
- condition for a hint to be displayed
+ condition for a hint to be displayed
- render_html : render this Response as HTML (must return XHTML-compliant string)
- __unicode__ : unicode representation of this Response
@@ -149,6 +149,7 @@ class LoncapaResponse(object):
# for convenience
self.answer_id = self.answer_ids[0]
+ # map input_id -> maxpoints
self.maxpoints = dict()
for inputfield in self.inputfields:
# By default, each answerfield is worth 1 point
@@ -280,17 +281,14 @@ class LoncapaResponse(object):
(correctness, npoints, msg) for each answer_id.
Arguments:
- - student_answers : dict of (answer_id,answer) where answer = student input (string)
-
- - old_cmap : previous CorrectMap (may be empty); useful for analyzing or
- recording history of responses
+ - student_answers : dict of (answer_id, answer) where answer = student input (string)
'''
pass
@abc.abstractmethod
def get_answers(self):
'''
- Return a dict of (answer_id,answer_text) for each answer for this question.
+ Return a dict of (answer_id, answer_text) for each answer for this question.
'''
pass
diff --git a/common/lib/capa/capa/templates/crystallography.html b/common/lib/capa/capa/templates/crystallography.html
index f46e2f753a..2370f59dd2 100644
--- a/common/lib/capa/capa/templates/crystallography.html
+++ b/common/lib/capa/capa/templates/crystallography.html
@@ -19,7 +19,7 @@
% endif
- ${status}
-
+
${msg|n}
diff --git a/common/lib/capa/capa/templates/javascriptinput.html b/common/lib/capa/capa/templates/javascriptinput.html
index 8b4c8f7115..b4d007e4d8 100644
--- a/common/lib/capa/capa/templates/javascriptinput.html
+++ b/common/lib/capa/capa/templates/javascriptinput.html
@@ -2,7 +2,7 @@
+ data-submission="${value|h}" data-evaluation="${msg|h}">
diff --git a/common/lib/capa/capa/templates/textline.html b/common/lib/capa/capa/templates/textline.html
index 97c512fc00..fbb5467b67 100644
--- a/common/lib/capa/capa/templates/textline.html
+++ b/common/lib/capa/capa/templates/textline.html
@@ -20,7 +20,7 @@
% endif
-
% endif
-
diff --git a/common/lib/capa/capa/tests/test_inputtypes.py b/common/lib/capa/capa/tests/test_inputtypes.py
index b3d7702246..826d304717 100644
--- a/common/lib/capa/capa/tests/test_inputtypes.py
+++ b/common/lib/capa/capa/tests/test_inputtypes.py
@@ -2,9 +2,18 @@
Tests of input types.
TODO:
+- refactor: so much repetive code (have factory methods that build xml elements directly, etc)
+
+- test error cases
+
+- check rendering -- e.g. msg should appear in the rendered output. If possible, test that
+ templates are escaping things properly.
+
+
- test unicode in values, parameters, etc.
- test various html escapes
- test funny xml chars -- should never get xml parse error if things are escaped properly.
+
"""
from lxml import etree
@@ -46,6 +55,19 @@ class OptionInputTest(unittest.TestCase):
self.assertEqual(context, expected)
+ def test_option_parsing(self):
+ f = inputtypes.OptionInput.parse_options
+ def check(input, options):
+ """Take list of options, confirm that output is in the silly doubled format"""
+ expected = [(o, o) for o in options]
+ self.assertEqual(f(input), expected)
+
+ check("('a','b')", ['a', 'b'])
+ check("('a', 'b')", ['a', 'b'])
+ check("('a b','b')", ['a b', 'b'])
+ check("('My \"quoted\"place','b')", ['My \"quoted\"place', 'b'])
+
+
class ChoiceGroupTest(unittest.TestCase):
'''
Test choice groups, radio groups, and checkbox groups
@@ -73,6 +95,7 @@ class ChoiceGroupTest(unittest.TestCase):
expected = {'id': 'sky_input',
'value': 'foil3',
'status': 'answered',
+ 'msg': '',
'input_type': expected_input_type,
'choices': [('foil1', 'This is foil One.'),
('foil2', 'This is foil Two.'),
@@ -119,12 +142,13 @@ class JavascriptInputTest(unittest.TestCase):
context = the_input._get_render_context()
expected = {'id': 'prob_1_2',
+ 'status': 'unanswered',
+ 'msg': '',
+ 'value': '3',
'params': params,
'display_file': display_file,
'display_class': display_class,
- 'problem_state': problem_state,
- 'value': '3',
- 'evaluation': '',}
+ 'problem_state': problem_state,}
self.assertEqual(context, expected)
@@ -204,9 +228,6 @@ class FileSubmissionTest(unittest.TestCase):
element = etree.fromstring(xml_str)
- escapedict = {'"': '"'}
- esc = lambda s: saxutils.escape(s, escapedict)
-
state = {'value': 'BumbleBee.py',
'status': 'incomplete',
'feedback' : {'message': '3'}, }
@@ -220,8 +241,8 @@ class FileSubmissionTest(unittest.TestCase):
'msg': input_class.submitted_msg,
'value': 'BumbleBee.py',
'queue_len': '3',
- 'allowed_files': esc('["runme.py", "nooooo.rb", "ohai.java"]'),
- 'required_files': esc('["cookies.py"]')}
+ 'allowed_files': '["runme.py", "nooooo.rb", "ohai.java"]',
+ 'required_files': '["cookies.py"]'}
self.assertEqual(context, expected)
@@ -255,14 +276,15 @@ class CodeInputTest(unittest.TestCase):
'status': 'incomplete',
'feedback' : {'message': '3'}, }
- the_input = lookup_tag('codeinput')(test_system, element, state)
+ input_class = lookup_tag('codeinput')
+ the_input = input_class(test_system, element, state)
context = the_input._get_render_context()
expected = {'id': 'prob_1_2',
'value': 'print "good evening"',
'status': 'queued',
- 'msg': 'Submitted to grader.',
+ 'msg': input_class.submitted_msg,
'mode': mode,
'linenumbers': linenumbers,
'rows': rows,
@@ -311,8 +333,9 @@ class SchematicTest(unittest.TestCase):
expected = {'id': 'prob_1_2',
'value': value,
- 'initial_value': initial_value,
'status': 'unsubmitted',
+ 'msg': '',
+ 'initial_value': initial_value,
'width': width,
'height': height,
'parts': parts,
@@ -476,6 +499,7 @@ class ChemicalEquationTest(unittest.TestCase):
expected = {'id': 'prob_1_2',
'value': 'H2OYeah',
'status': 'unanswered',
+ 'msg': '',
'size': size,
'previewer': '/static/js/capa/chemical_equation_preview.js',
}
diff --git a/common/lib/xmodule/.coveragerc b/common/lib/xmodule/.coveragerc
new file mode 100644
index 0000000000..310c8e778b
--- /dev/null
+++ b/common/lib/xmodule/.coveragerc
@@ -0,0 +1,13 @@
+# .coveragerc for common/lib/xmodule
+[run]
+data_file = reports/common/lib/xmodule/.coverage
+source = common/lib/xmodule
+
+[report]
+ignore_errors = True
+
+[html]
+directory = reports/common/lib/xmodule/cover
+
+[xml]
+output = reports/common/lib/xmodule/coverage.xml
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index 6e3b7a0492..47d5d5c423 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -540,15 +540,9 @@ class CapaModule(XModule):
lcp_id = self.lcp.problem_id
correct_map = self.lcp.grade_answers(answers)
except StudentInputError as inst:
- # TODO (vshnayder): why is this line here?
- #self.lcp = LoncapaProblem(self.definition['data'],
- # id=lcp_id, state=old_state, system=self.system)
log.exception("StudentInputError in capa_module:problem_check")
return {'success': inst.message}
except Exception, err:
- # TODO: why is this line here?
- #self.lcp = LoncapaProblem(self.definition['data'],
- # id=lcp_id, state=old_state, system=self.system)
if self.system.DEBUG:
msg = "Error checking problem: " + str(err)
msg += '\nTraceback:\n' + traceback.format_exc()
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index de8eddd0b8..2b6232d366 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -133,7 +133,7 @@ class CourseDescriptor(SequenceDescriptor):
Returns True if the current time is after the specified course end date.
Returns False if there is no end date specified.
"""
- if self.end_date is None:
+ if self.end is None:
return False
return time.gmtime() > self.end
@@ -250,6 +250,10 @@ class CourseDescriptor(SequenceDescriptor):
displayed_start = self._try_parse_time('advertised_start') or self.start
return time.strftime("%b %d, %Y", displayed_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
@@ -276,6 +280,21 @@ class CourseDescriptor(SequenceDescriptor):
more sensible framework later."""
return self.metadata.get('discussion_link', None)
+ @property
+ def forum_posts_allowed(self):
+ try:
+ blackout_periods = [(parse_time(start), parse_time(end))
+ for start, end
+ in self.metadata.get('discussion_blackouts', [])]
+ now = time.gmtime()
+ for start, end in blackout_periods:
+ if start <= now <= end:
+ return False
+ except:
+ log.exception("Error parsing discussion_blackouts for course {0}".format(self.id))
+
+ return True
+
@property
def hide_progress_tab(self):
"""TODO: same as above, intended to let internal CS50 hide the progress tab
@@ -283,6 +302,16 @@ class CourseDescriptor(SequenceDescriptor):
# 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):
+ """
+ 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/css/video/display.scss b/common/lib/xmodule/xmodule/css/video/display.scss
index e8aba4d671..43b024ec32 100644
--- a/common/lib/xmodule/xmodule/css/video/display.scss
+++ b/common/lib/xmodule/xmodule/css/video/display.scss
@@ -355,6 +355,34 @@ div.video {
}
}
+ a.quality_control {
+ background: url(../images/hd.png) center no-repeat;
+ border-right: 1px solid #000;
+ @include box-shadow(1px 0 0 #555, inset 1px 0 0 #555);
+ color: #797979;
+ display: block;
+ float: left;
+ line-height: 46px; //height of play pause buttons
+ margin-left: 0;
+ padding: 0 lh(.5);
+ text-indent: -9999px;
+ @include transition();
+ width: 30px;
+
+ &:hover {
+ background-color: #444;
+ color: #fff;
+ text-decoration: none;
+ }
+
+ &.active {
+ background-color: #F44;
+ color: #0ff;
+ text-decoration: none;
+ }
+ }
+
+
a.hide-subtitles {
background: url('../images/cc.png') center no-repeat;
color: #797979;
diff --git a/common/lib/xmodule/xmodule/js/src/capa/schematic.js b/common/lib/xmodule/xmodule/js/src/capa/schematic.js
index b01f6e12e8..b033dbaf46 100644
--- a/common/lib/xmodule/xmodule/js/src/capa/schematic.js
+++ b/common/lib/xmodule/xmodule/js/src/capa/schematic.js
@@ -1995,7 +1995,7 @@ cktsim = (function() {
// set up each schematic entry widget
function update_schematics() {
// set up each schematic on the page
- var schematics = document.getElementsByClassName('schematic');
+ var schematics = $('.schematic');
for (var i = 0; i < schematics.length; ++i)
if (schematics[i].getAttribute("loaded") != "true") {
try {
@@ -2036,7 +2036,7 @@ function add_schematic_handler(other_onload) {
// ask each schematic input widget to update its value field for submission
function prepare_schematics() {
- var schematics = document.getElementsByClassName('schematic');
+ var schematics = $('.schematic');
for (var i = schematics.length - 1; i >= 0; i--)
schematics[i].schematic.update_value();
}
@@ -3339,23 +3339,28 @@ schematic = (function() {
}
// add method to canvas to compute relative coords for event
- HTMLCanvasElement.prototype.relMouseCoords = function(event){
- // run up the DOM tree to figure out coords for top,left of canvas
- var totalOffsetX = 0;
- var totalOffsetY = 0;
- var currentElement = this;
- do {
- totalOffsetX += currentElement.offsetLeft;
- totalOffsetY += currentElement.offsetTop;
- }
- while (currentElement = currentElement.offsetParent);
-
- // now compute relative position of click within the canvas
- this.mouse_x = event.pageX - totalOffsetX;
- this.mouse_y = event.pageY - totalOffsetY;
-
- this.page_x = event.pageX;
- this.page_y = event.pageY;
+ try {
+ if (HTMLCanvasElement)
+ HTMLCanvasElement.prototype.relMouseCoords = function(event){
+ // run up the DOM tree to figure out coords for top,left of canvas
+ var totalOffsetX = 0;
+ var totalOffsetY = 0;
+ var currentElement = this;
+ do {
+ totalOffsetX += currentElement.offsetLeft;
+ totalOffsetY += currentElement.offsetTop;
+ }
+ while (currentElement = currentElement.offsetParent);
+
+ // now compute relative position of click within the canvas
+ this.mouse_x = event.pageX - totalOffsetX;
+ this.mouse_y = event.pageY - totalOffsetY;
+
+ this.page_x = event.pageX;
+ this.page_y = event.pageY;
+ }
+ }
+ catch (err) { // ignore
}
///////////////////////////////////////////////////////////////////////////////
@@ -4091,48 +4096,52 @@ schematic = (function() {
// add dashed lines!
// from http://davidowens.wordpress.com/2010/09/07/html-5-canvas-and-dashed-lines/
- CanvasRenderingContext2D.prototype.dashedLineTo = function(fromX, fromY, toX, toY, pattern) {
- // Our growth rate for our line can be one of the following:
- // (+,+), (+,-), (-,+), (-,-)
- // Because of this, our algorithm needs to understand if the x-coord and
- // y-coord should be getting smaller or larger and properly cap the values
- // based on (x,y).
- var lt = function (a, b) { return a <= b; };
- var gt = function (a, b) { return a >= b; };
- var capmin = function (a, b) { return Math.min(a, b); };
- var capmax = function (a, b) { return Math.max(a, b); };
-
- var checkX = { thereYet: gt, cap: capmin };
- var checkY = { thereYet: gt, cap: capmin };
-
- if (fromY - toY > 0) {
- checkY.thereYet = lt;
- checkY.cap = capmax;
- }
- if (fromX - toX > 0) {
- checkX.thereYet = lt;
- checkX.cap = capmax;
- }
-
- this.moveTo(fromX, fromY);
- var offsetX = fromX;
- var offsetY = fromY;
- var idx = 0, dash = true;
- while (!(checkX.thereYet(offsetX, toX) && checkY.thereYet(offsetY, toY))) {
- var ang = Math.atan2(toY - fromY, toX - fromX);
- var len = pattern[idx];
-
- offsetX = checkX.cap(toX, offsetX + (Math.cos(ang) * len));
- offsetY = checkY.cap(toY, offsetY + (Math.sin(ang) * len));
-
- if (dash) this.lineTo(offsetX, offsetY);
- else this.moveTo(offsetX, offsetY);
-
- idx = (idx + 1) % pattern.length;
- dash = !dash;
- }
- };
-
+ try {
+ if (CanvasRenderingContext2D)
+ CanvasRenderingContext2D.prototype.dashedLineTo = function(fromX, fromY, toX, toY, pattern) {
+ // Our growth rate for our line can be one of the following:
+ // (+,+), (+,-), (-,+), (-,-)
+ // Because of this, our algorithm needs to understand if the x-coord and
+ // y-coord should be getting smaller or larger and properly cap the values
+ // based on (x,y).
+ var lt = function (a, b) { return a <= b; };
+ var gt = function (a, b) { return a >= b; };
+ var capmin = function (a, b) { return Math.min(a, b); };
+ var capmax = function (a, b) { return Math.max(a, b); };
+
+ var checkX = { thereYet: gt, cap: capmin };
+ var checkY = { thereYet: gt, cap: capmin };
+
+ if (fromY - toY > 0) {
+ checkY.thereYet = lt;
+ checkY.cap = capmax;
+ }
+ if (fromX - toX > 0) {
+ checkX.thereYet = lt;
+ checkX.cap = capmax;
+ }
+
+ this.moveTo(fromX, fromY);
+ var offsetX = fromX;
+ var offsetY = fromY;
+ var idx = 0, dash = true;
+ while (!(checkX.thereYet(offsetX, toX) && checkY.thereYet(offsetY, toY))) {
+ var ang = Math.atan2(toY - fromY, toX - fromX);
+ var len = pattern[idx];
+
+ offsetX = checkX.cap(toX, offsetX + (Math.cos(ang) * len));
+ offsetY = checkY.cap(toY, offsetY + (Math.sin(ang) * len));
+
+ if (dash) this.lineTo(offsetX, offsetY);
+ else this.moveTo(offsetX, offsetY);
+
+ idx = (idx + 1) % pattern.length;
+ dash = !dash;
+ }
+ };
+ }
+ catch (err) { //noop
+ }
// given a range of values, return a new range [vmin',vmax'] where the limits
// have been chosen "nicely". Taken from matplotlib.ticker.LinearLocator
function view_limits(vmin,vmax) {
diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee
index b1e41afc3c..cdd74c5d07 100644
--- a/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee
+++ b/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee
@@ -22,7 +22,7 @@ class @VideoCaption extends Subview
"""
@$('.video-controls .secondary-controls').append """
Captions
- """
+ """#"
@$('.subtitles').css maxHeight: @$('.video-wrapper').height() - 5
@fetchCaption()
@@ -144,7 +144,7 @@ class @VideoCaption extends Subview
@el.removeClass('closed')
@scrollCaption()
$.cookie('hide_captions', hide_captions, expires: 3650, path: '/')
-
+
captionHeight: ->
if @el.hasClass('fullscreen')
$(window).height() - @$('.video-controls').height()
diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_control.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_control.coffee
index 5053f1dcb1..856549c3e2 100644
--- a/common/lib/xmodule/xmodule/js/src/video/display/video_control.coffee
+++ b/common/lib/xmodule/xmodule/js/src/video/display/video_control.coffee
@@ -16,7 +16,7 @@ class @VideoControl extends Subview
Fill Browser
- """
+ """#"
unless onTouchBasedDevice()
@$('.video_control').addClass('play').html('Play')
diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee
index bb89def63d..8829e25dac 100644
--- a/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee
+++ b/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee
@@ -9,6 +9,7 @@ class @VideoPlayer extends Subview
bind: ->
$(@control).bind('play', @play)
.bind('pause', @pause)
+ $(@qualityControl).bind('changeQuality', @handlePlaybackQualityChange)
$(@caption).bind('seek', @onSeek)
$(@speedControl).bind('speedChange', @onSpeedChange)
$(@progressSlider).bind('seek', @onSeek)
@@ -25,6 +26,7 @@ class @VideoPlayer extends Subview
render: ->
@control = new VideoControl el: @$('.video-controls')
+ @qualityControl = new VideoQualityControl el: @$('.secondary-controls')
@caption = new VideoCaption
el: @el
youtubeId: @video.youtubeId('1.0')
@@ -41,10 +43,12 @@ class @VideoPlayer extends Subview
rel: 0
showinfo: 0
enablejsapi: 1
+ modestbranding: 1
videoId: @video.youtubeId()
events:
onReady: @onReady
onStateChange: @onStateChange
+ onPlaybackQualityChange: @onPlaybackQualityChange
@caption.hideCaptions(@['video'].hide_captions)
addToolTip: ->
@@ -53,7 +57,7 @@ class @VideoPlayer extends Subview
my: 'top right'
at: 'top center'
- onReady: =>
+ onReady: (event) =>
unless onTouchBasedDevice()
$('.video-load-complete:first').data('video').player.play()
@@ -68,6 +72,13 @@ class @VideoPlayer extends Subview
when YT.PlayerState.ENDED
@onEnded()
+ onPlaybackQualityChange: (event, value) =>
+ quality = @player.getPlaybackQuality()
+ @qualityControl.onQualityChange(quality)
+
+ handlePlaybackQualityChange: (event, value) =>
+ @player.setPlaybackQuality(value)
+
onUnstarted: =>
@control.pause()
@caption.pause()
diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_quality_control.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_quality_control.coffee
new file mode 100644
index 0000000000..f8f6167075
--- /dev/null
+++ b/common/lib/xmodule/xmodule/js/src/video/display/video_quality_control.coffee
@@ -0,0 +1,26 @@
+class @VideoQualityControl extends Subview
+ initialize: ->
+ @quality = null;
+
+ bind: ->
+ @$('.quality_control').click @toggleQuality
+
+ render: ->
+ @el.append """
+ HD
+ """#"
+
+ onQualityChange: (value) ->
+ @quality = value
+ if @quality in ['hd720', 'hd1080', 'highres']
+ @el.addClass('active')
+ else
+ @el.removeClass('active')
+
+ toggleQuality: (event) =>
+ event.preventDefault()
+ if @quality in ['hd720', 'hd1080', 'highres']
+ newQuality = 'large'
+ else
+ newQuality = 'hd720'
+ $(@).trigger('changeQuality', newQuality)
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_volume_control.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_volume_control.coffee
index 10d3f6b044..096b50042d 100644
--- a/common/lib/xmodule/xmodule/js/src/video/display/video_volume_control.coffee
+++ b/common/lib/xmodule/xmodule/js/src/video/display/video_volume_control.coffee
@@ -17,7 +17,7 @@ class @VideoVolumeControl extends Subview
- """
+ """#"
@slider = @$('.volume-slider').slider
orientation: "vertical"
range: "min"
diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py
index 9e8199629a..77532959d7 100644
--- a/common/lib/xmodule/xmodule/tests/test_import.py
+++ b/common/lib/xmodule/xmodule/tests/test_import.py
@@ -176,6 +176,33 @@ class ImportTestCase(unittest.TestCase):
self.assertEqual(chapter_xml.tag, 'chapter')
self.assertFalse('graceperiod' in chapter_xml.attrib)
+ def test_is_pointer_tag(self):
+ """
+ Check that is_pointer_tag works properly.
+ """
+
+ yes = ["""