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/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py
index 5963814918..f680dd7262 100644
--- a/cms/djangoapps/contentstore/views.py
+++ b/cms/djangoapps/contentstore/views.py
@@ -66,7 +66,10 @@ log = logging.getLogger(__name__)
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
-DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential']
+DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
+
+# cdodge: these are categories which should not be parented, they are detached from the hierarchy
+DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
def _modulestore(location):
@@ -692,7 +695,9 @@ def clone_item(request):
new_item.metadata['display_name'] = display_name
_modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata)
- _modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
+
+ if new_item.location.category not in DETACHED_CATEGORIES:
+ _modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
return HttpResponse(json.dumps({'id': dest_location.url()}))
@@ -873,6 +878,25 @@ def edit_static(request, org, course, coursename):
return render_to_response('edit-static-page.html', {})
+def edit_tabs(request, org, course, coursename):
+ location = ['i4x', org, course, 'course', coursename]
+ course_item = modulestore().get_item(location)
+ static_tabs_loc = Location('i4x', org, course, 'static_tab', None)
+
+ static_tabs = modulestore('direct').get_items(static_tabs_loc)
+
+ components = [
+ static_tab.location.url()
+ for static_tab
+ in static_tabs
+ ]
+
+ return render_to_response('edit-tabs.html', {
+ 'active_tab': 'pages',
+ 'context_course':course_item,
+ 'components': components
+ })
+
def not_found(request):
return render_to_response('error.html', {'error': '404'})
@@ -977,6 +1001,17 @@ def create_new_course(request):
# set a default start date to now
new_course.metadata['start'] = stringify_time(time.gmtime())
+ # set up the default tabs
+ # I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
+ # at least a list populated with the minimal times
+ # @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
+ # place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
+ new_course.tabs = [{"type": "courseware"},
+ {"type": "course_info", "name": "Course Info"},
+ {"type": "discussion", "name": "Discussion"},
+ {"type": "wiki", "name": "Wiki"},
+ {"type": "progress", "name": "Progress"}]
+
modulestore('direct').update_metadata(new_course.location.url(), new_course.own_metadata)
create_all_course_groups(request.user, new_course.location)
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 235993f5ac..0f69ab682e 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/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee
new file mode 100644
index 0000000000..34d86a3051
--- /dev/null
+++ b/cms/static/coffee/src/views/tabs.coffee
@@ -0,0 +1,54 @@
+class CMS.Views.TabsEdit extends Backbone.View
+ events:
+ 'click .new-tab': 'addNewTab'
+
+ initialize: =>
+ @$('.component').each((idx, element) =>
+ new CMS.Views.ModuleEdit(
+ el: element,
+ onDelete: @deleteTab,
+ model: new CMS.Models.Module(
+ id: $(element).data('id'),
+ )
+ )
+ )
+
+ @$('.components').sortable(
+ handle: '.drag-handle'
+ update: (event, ui) => alert 'not yet implemented!'
+ helper: 'clone'
+ opacity: '0.5'
+ placeholder: 'component-placeholder'
+ forcePlaceholderSize: true
+ axis: 'y'
+ items: '> .component'
+ )
+
+ addNewTab: (event) =>
+ event.preventDefault()
+
+ editor = new CMS.Views.ModuleEdit(
+ onDelete: @deleteTab
+ model: new CMS.Models.Module()
+ )
+
+ $('.new-component-item').before(editor.$el)
+
+ editor.cloneTemplate(
+ @model.get('id'),
+ 'i4x://edx/templates/static_tab/Empty'
+ )
+
+ deleteTab: (event) =>
+ if not confirm 'Are you sure you want to delete this component? This action cannot be undone.'
+ return
+ $component = $(event.currentTarget).parents('.component')
+ $.post('/delete_item', {
+ id: $component.data('id')
+ }, =>
+ $component.remove()
+ )
+
+
+
+
diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html
new file mode 100644
index 0000000000..94c5e38260
--- /dev/null
+++ b/cms/templates/edit-tabs.html
@@ -0,0 +1,42 @@
+<%inherit file="base.html" />
+<%! from django.core.urlresolvers import reverse %>
+<%block name="title">Tabs%block>
+<%block name="bodyclass">static-pages%block>
+
+<%block name="jsextra">
+
+%block>
+
+<%block name="content">
+
+
+
+
Static Tabs
+
+
+
+
+
+ % for id in components:
+
+ % endfor
+
+ -
+
+ New Tab
+
+
+
+
+
+
+
+
+%block>
\ No newline at end of file
diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html
index 2b9b2c7884..0f5780a5d2 100644
--- a/cms/templates/widgets/header.html
+++ b/cms/templates/widgets/header.html
@@ -10,7 +10,7 @@
${context_course.display_name}
- Courseware
- - Pages
+ - Tabs
- Assets
- Users
- Import
diff --git a/cms/urls.py b/cms/urls.py
index 8ff4e67a46..e0dbc68129 100644
--- a/cms/urls.py
+++ b/cms/urls.py
@@ -36,6 +36,7 @@ urlpatterns = ('',
'contentstore.views.remove_user', name='remove_user'),
url(r'^pages/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.static_pages', name='static_pages'),
url(r'^edit_static/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_static', name='edit_static'),
+ url(r'^edit_tabs/(?P[^/]+)/(?P[^/]+)/course/(?P[^/]+)$', 'contentstore.views.edit_tabs', name='edit_tabs'),
url(r'^(?P[^/]+)/(?P[^/]+)/assets/(?P[^/]+)$', 'contentstore.views.asset_index', name='asset_index'),
# temporary landing page for a course
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..8810c8609b 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -19,13 +19,13 @@ 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)
@@ -774,3 +774,26 @@ 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/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/setup.py b/common/lib/xmodule/setup.py
index ba5bcd872f..74fa418c91 100644
--- a/common/lib/xmodule/setup.py
+++ b/common/lib/xmodule/setup.py
@@ -35,10 +35,10 @@ setup(
"videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"videosequence = xmodule.seq_module:SequenceDescriptor",
"discussion = xmodule.discussion_module:DiscussionDescriptor",
- "course_info = xmodule.html_module:HtmlDescriptor",
- "static_tab = xmodule.html_module:HtmlDescriptor",
+ "course_info = xmodule.html_module:CourseInfoDescriptor",
+ "static_tab = xmodule.html_module:StaticTabDescriptor",
"custom_tag_template = xmodule.raw_module:RawDescriptor",
- "about = xmodule.html_module:HtmlDescriptor"
+ "about = xmodule.html_module:AboutDescriptor"
]
}
)
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index 6e3f450324..512247a429 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -266,6 +266,10 @@ class CourseDescriptor(SequenceDescriptor):
"""
return self.metadata.get('tabs')
+ @tabs.setter
+ def tabs(self, value):
+ self.metadata['tabs'] = value
+
@property
def show_calculator(self):
return self.metadata.get("show_calculator", None) == "Yes"
diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py
index f6dddfdd4c..cae099845a 100644
--- a/common/lib/xmodule/xmodule/html_module.py
+++ b/common/lib/xmodule/xmodule/html_module.py
@@ -170,3 +170,25 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
elt = etree.Element('html')
elt.set("filename", relname)
return elt
+
+
+class AboutDescriptor(HtmlDescriptor):
+ """
+ These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
+ in order to be able to create new ones
+ """
+ template_dir_name = "about"
+
+class StaticTabDescriptor(HtmlDescriptor):
+ """
+ These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
+ in order to be able to create new ones
+ """
+ template_dir_name = "statictab"
+
+class CourseInfoDescriptor(HtmlDescriptor):
+ """
+ These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
+ in order to be able to create new ones
+ """
+ template_dir_name = "courseinfo"
diff --git a/common/lib/xmodule/xmodule/js/src/capa/schematic.js b/common/lib/xmodule/xmodule/js/src/capa/schematic.js
index e65b0ecad6..b033dbaf46 100644
--- a/common/lib/xmodule/xmodule/js/src/capa/schematic.js
+++ b/common/lib/xmodule/xmodule/js/src/capa/schematic.js
@@ -3723,7 +3723,7 @@ schematic = (function() {
// look for property input fields in the content and give
// them a keypress listener that interprets ENTER as
// clicking OK.
- var plist = content.$('.property');
+ var plist = content.getElementsByClassName('property');
for (var i = plist.length - 1; i >= 0; --i) {
var field = plist[i];
field.dialog = dialog; // help event handler find us...
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py
index 550e6570ac..19f506906c 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo.py
@@ -276,10 +276,49 @@ class MongoModuleStore(ModuleStoreBase):
source_item = self.collection.find_one(location_to_query(source))
source_item['_id'] = Location(location).dict()
self.collection.insert(source_item)
- return self._load_items([source_item])[0]
+ item = self._load_items([source_item])[0]
+
+ # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
+ # if we add one then we need to also add it to the policy information (i.e. metadata)
+ # we should remove this once we can break this reference from the course to static tabs
+ if location.category == 'static_tab':
+ course = self.get_course_for_item(item.location)
+ existing_tabs = course.tabs or []
+ existing_tabs.append({'type':'static_tab', 'name' : item.metadata.get('display_name'), 'url_slug' : item.location.name})
+ course.tabs = existing_tabs
+ self.update_metadata(course.location, course.metadata)
+
+ return item
except pymongo.errors.DuplicateKeyError:
raise DuplicateItemError(location)
+
+ def get_course_for_item(self, location):
+ '''
+ VS[compat]
+ cdodge: for a given Xmodule, return the course that it belongs to
+ NOTE: This makes a lot of assumptions about the format of the course location
+ Also we have to assert that this module maps to only one course item - it'll throw an
+ assert if not
+ This is only used to support static_tabs as we need to be course module aware
+ '''
+
+ # @hack! We need to find the course location however, we don't
+ # know the 'name' parameter in this context, so we have
+ # to assume there's only one item in this query even though we are not specifying a name
+ course_search_location = ['i4x', location.org, location.course, 'course', None]
+ courses = self.get_items(course_search_location)
+
+ # make sure we found exactly one match on this above course search
+ found_cnt = len(courses)
+ if found_cnt == 0:
+ raise BaseException('Could not find course at {0}'.format(course_search_location))
+
+ if found_cnt > 1:
+ raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
+
+ return courses[0]
+
def _update_single_item(self, location, update):
"""
Set update on the specified item, and raises ItemNotFoundError
@@ -327,6 +366,19 @@ class MongoModuleStore(ModuleStoreBase):
location: Something that can be passed to Location
metadata: A nested dictionary of module metadata
"""
+ # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
+ # if we add one then we need to also add it to the policy information (i.e. metadata)
+ # we should remove this once we can break this reference from the course to static tabs
+ loc = Location(location)
+ if loc.category == 'static_tab':
+ course = self.get_course_for_item(loc)
+ existing_tabs = course.tabs or []
+ for tab in existing_tabs:
+ if tab.get('url_slug') == loc.name:
+ tab['name'] = metadata.get('display_name')
+ break
+ course.tabs = existing_tabs
+ self.update_metadata(course.location, course.metadata)
self._update_single_item(location, {'metadata': metadata})
@@ -336,6 +388,16 @@ class MongoModuleStore(ModuleStoreBase):
location: Something that can be passed to Location
"""
+ # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
+ # if we add one then we need to also add it to the policy information (i.e. metadata)
+ # we should remove this once we can break this reference from the course to static tabs
+ if location.category == 'static_tab':
+ item = self.get_item(location)
+ course = self.get_course_for_item(item.location)
+ existing_tabs = course.tabs or []
+ course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name]
+ self.update_metadata(course.location, course.metadata)
+
self.collection.remove({'_id': Location(location).dict()})
def get_parent_locations(self, location):
diff --git a/common/lib/xmodule/xmodule/templates/about/empty.yaml b/common/lib/xmodule/xmodule/templates/about/empty.yaml
new file mode 100644
index 0000000000..fa3ed606bd
--- /dev/null
+++ b/common/lib/xmodule/xmodule/templates/about/empty.yaml
@@ -0,0 +1,5 @@
+---
+metadata:
+ display_name: Empty
+data: "This is where you can add additional information about your course.
"
+children: []
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml b/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml
new file mode 100644
index 0000000000..fa3ed606bd
--- /dev/null
+++ b/common/lib/xmodule/xmodule/templates/courseinfo/empty.yaml
@@ -0,0 +1,5 @@
+---
+metadata:
+ display_name: Empty
+data: "This is where you can add additional information about your course.
"
+children: []
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/templates/statictab/empty.yaml b/common/lib/xmodule/xmodule/templates/statictab/empty.yaml
new file mode 100644
index 0000000000..410e1496c2
--- /dev/null
+++ b/common/lib/xmodule/xmodule/templates/statictab/empty.yaml
@@ -0,0 +1,5 @@
+---
+metadata:
+ display_name: Empty
+data: "This is where you can add additional pages to your courseware. Click the 'edit' button to begin editing.
"
+children: []
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py
index 11c90f45ef..9a22950ca8 100644
--- a/common/lib/xmodule/xmodule/video_module.py
+++ b/common/lib/xmodule/xmodule/video_module.py
@@ -32,6 +32,7 @@ class VideoModule(XModule):
self.position = 0
self.show_captions = xmltree.get('show_captions', 'true')
self.source = self._get_source(xmltree)
+ self.track = self._get_track(xmltree)
if instance_state is not None:
state = json.loads(instance_state)
@@ -40,13 +41,25 @@ class VideoModule(XModule):
def _get_source(self, xmltree):
# find the first valid source
- source = None
- for element in xmltree.findall('source'):
+ return self._get_first_external(xmltree, 'source')
+
+ def _get_track(self, xmltree):
+ # find the first valid track
+ return self._get_first_external(xmltree, 'track')
+
+ def _get_first_external(self, xmltree, tag):
+ """
+ Will return the first valid element
+ of the given tag.
+ 'valid' means has a non-empty 'src' attribute
+ """
+ result = None
+ for element in xmltree.findall(tag):
src = element.get('src')
if src:
- source = src
+ result = src
break
- return source
+ return result
def handle_ajax(self, dispatch, get):
'''
@@ -85,6 +98,7 @@ class VideoModule(XModule):
'id': self.location.html_id(),
'position': self.position,
'source': self.source,
+ 'track' : self.track,
'display_name': self.display_name,
# TODO (cpennington): This won't work when we move to data that isn't on the filesystem
'data_dir': self.metadata['data_dir'],
diff --git a/github-requirements.txt b/github-requirements.txt
new file mode 100644
index 0000000000..468d55ce65
--- /dev/null
+++ b/github-requirements.txt
@@ -0,0 +1,5 @@
+# Python libraries to install directly from github
+-e git://github.com/MITx/django-staticfiles.git@6d2504e5c8#egg=django-staticfiles
+-e git://github.com/MITx/django-pipeline.git#egg=django-pipeline
+-e git://github.com/MITx/django-wiki.git@e2e84558#egg=django-wiki
+-e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev
diff --git a/jenkins/quality.sh b/jenkins/quality.sh
new file mode 100755
index 0000000000..4cf26d76bf
--- /dev/null
+++ b/jenkins/quality.sh
@@ -0,0 +1,14 @@
+#! /bin/bash
+
+set -e
+set -x
+
+# Reset the submodule, in case it changed
+git submodule foreach 'git reset --hard HEAD'
+
+# Set the IO encoding to UTF-8 so that askbot will start
+export PYTHONIOENCODING=UTF-8
+
+rake clobber
+rake pep8 || echo "pep8 failed, continuing"
+rake pylint || echo "pylint failed, continuing"
diff --git a/jenkins/test_edge.sh b/jenkins/test_edge.sh
new file mode 100755
index 0000000000..7b58b481f6
--- /dev/null
+++ b/jenkins/test_edge.sh
@@ -0,0 +1,29 @@
+#! /bin/bash
+
+set -e
+set -x
+
+# Reset the submodule, in case it changed
+git submodule foreach 'git reset --hard HEAD'
+
+# Set the IO encoding to UTF-8 so that askbot will start
+export PYTHONIOENCODING=UTF-8
+
+GIT_BRANCH=${GIT_BRANCH/HEAD/master}
+
+pip install -q -r pre-requirements.txt
+yes w | pip install -q -r requirements.txt
+[ ! -d askbot ] || pip install -q -r askbot/askbot_requirements.txt
+
+rake clobber
+TESTS_FAILED=0
+rake test_cms[false] || TESTS_FAILED=1
+rake test_lms[false] || TESTS_FAILED=1
+rake test_common/lib/capa || TESTS_FAILED=1
+rake test_common/lib/xmodule || TESTS_FAILED=1
+rake phantomjs_jasmine_lms || true
+rake phantomjs_jasmine_cms || true
+rake coverage:xml coverage:html
+
+[ $TESTS_FAILED == '0' ]
+rake autodeploy_properties
\ No newline at end of file
diff --git a/jenkins/test_lms.sh b/jenkins/test_lms.sh
new file mode 100755
index 0000000000..98640c2b5b
--- /dev/null
+++ b/jenkins/test_lms.sh
@@ -0,0 +1,27 @@
+#! /bin/bash
+
+set -e
+set -x
+
+# Reset the submodule, in case it changed
+git submodule foreach 'git reset --hard HEAD'
+
+# Set the IO encoding to UTF-8 so that askbot will start
+export PYTHONIOENCODING=UTF-8
+
+GIT_BRANCH=${GIT_BRANCH/HEAD/master}
+
+pip install -q -r pre-requirements.txt
+yes w | pip install -q -r requirements.txt
+[ ! -d askbot ] || pip install -q -r askbot/askbot_requirements.txt
+
+rake clobber
+TESTS_FAILED=0
+rake test_lms[false] || TESTS_FAILED=1
+rake test_common/lib/capa || TESTS_FAILED=1
+rake test_common/lib/xmodule || TESTS_FAILED=1
+rake phantomjs_jasmine_lms || true
+rake coverage:xml coverage:html
+
+[ $TESTS_FAILED == '0' ]
+rake autodeploy_properties
\ No newline at end of file
diff --git a/lms/.coveragerc b/lms/.coveragerc
new file mode 100644
index 0000000000..acac3ed4f2
--- /dev/null
+++ b/lms/.coveragerc
@@ -0,0 +1,13 @@
+# .coveragerc for lms
+[run]
+data_file = reports/lms/.coverage
+source = lms
+
+[report]
+ignore_errors = True
+
+[html]
+directory = reports/lms/cover
+
+[xml]
+output = reports/lms/coverage.xml
diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py
index 393cb0918b..ffc7c929de 100644
--- a/lms/djangoapps/courseware/models.py
+++ b/lms/djangoapps/courseware/models.py
@@ -108,7 +108,6 @@ class StudentModuleCache(object):
else:
self.cache = []
-
@classmethod
def cache_for_descriptor_descendents(cls, course_id, user, descriptor, depth=None,
descriptor_filter=lambda descriptor: True,
@@ -138,7 +137,6 @@ class StudentModuleCache(object):
return descriptors
-
descriptors = get_child_descriptors(descriptor, depth, descriptor_filter)
return StudentModuleCache(course_id, user, descriptors, select_for_update)
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index eb8533395e..42c6139245 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -85,3 +85,5 @@ CONTENTSTORE = AUTH_TOKENS.get('CONTENTSTORE', CONTENTSTORE)
if 'COURSE_ID' in ENV_TOKENS:
ASKBOT_URL = "courses/{0}/discussions/".format(ENV_TOKENS['COURSE_ID'])
+PEARSON_TEST_USER = "pearsontest"
+PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD")
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index 1cc9ee4b88..e88420a49e 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -177,3 +177,8 @@ FILE_UPLOAD_HANDLERS = (
########################### PIPELINE #################################
PIPELINE_SASS_ARGUMENTS = '-r {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT)
+
+########################## PEARSON TESTING ###########################
+MITX_FEATURES['ENABLE_PEARSON_HACK_TEST'] = True
+PEARSON_TEST_USER = "pearsontest"
+PEARSON_TEST_PASSWORD = "12345"
diff --git a/lms/envs/test.py b/lms/envs/test.py
index e87b276695..a6b89bd7f4 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -27,18 +27,11 @@ SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
# Nose Test Runner
INSTALLED_APPS += ('django_nose',)
-NOSE_ARGS = []
-# Turning off coverage speeds up tests dramatically... until we have better config,
-# leave it here for manual fiddling.
-_coverage = True
-if _coverage:
- NOSE_ARGS = ['--cover-erase', '--with-xunit', '--with-xcoverage', '--cover-html',
- # '-v', '--pdb', # When really stuck, uncomment to start debugger on error
- '--cover-inclusive', '--cover-html-dir',
- os.environ.get('NOSE_COVER_HTML_DIR', 'cover_html')]
- for app in os.listdir(PROJECT_ROOT / 'djangoapps'):
- NOSE_ARGS += ['--cover-package', app]
+NOSE_ARGS = [
+ '--with-xunit',
+ # '-v', '--pdb', # When really stuck, uncomment to start debugger on error
+]
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
# Local Directories
diff --git a/lms/templates/video.html b/lms/templates/video.html
index 47556095cb..5c041d5c70 100644
--- a/lms/templates/video.html
+++ b/lms/templates/video.html
@@ -18,3 +18,9 @@
Download video here.
% endif
+
+% if track:
+
+
Download subtitles here.
+
+% endif
diff --git a/lms/urls.py b/lms/urls.py
index d8a80a370f..94b8c971a2 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -26,6 +26,11 @@ urlpatterns = ('',
url(r'^reject_name_change$', 'student.views.reject_name_change'),
url(r'^pending_name_changes$', 'student.views.pending_name_changes'),
+ url(r'^testcenter/login$', 'student.views.test_center_login'),
+
+ # url(r'^testcenter/login$', 'student.test_center_views.login'),
+ # url(r'^testcenter/logout$', 'student.test_center_views.logout'),
+
url(r'^event$', 'track.views.user_track'),
url(r'^t/(?P[^/]*)$', 'static_template_view.views.index'), # TODO: Is this used anymore? What is STATIC_GRAB?
diff --git a/local-requirements.txt b/local-requirements.txt
new file mode 100644
index 0000000000..a4d153dd36
--- /dev/null
+++ b/local-requirements.txt
@@ -0,0 +1,3 @@
+# Python libraries to install that are local to the mitx repo
+-e common/lib/capa
+-e common/lib/xmodule
diff --git a/rakefile b/rakefile
index 6f125e353f..4f1c15321f 100644
--- a/rakefile
+++ b/rakefile
@@ -29,7 +29,7 @@ PACKAGE_REPO = "packages@gp.mitx.mit.edu:/opt/pkgrepo.incoming"
NORMALIZED_DEPLOY_NAME = DEPLOY_NAME.downcase().gsub(/[_\/]/, '-')
INSTALL_DIR_PATH = File.join(DEPLOY_DIR, NORMALIZED_DEPLOY_NAME)
# Set up the clean and clobber tasks
-CLOBBER.include(BUILD_DIR, REPORT_DIR, 'cover*', '.coverage', 'test_root/*_repo', 'test_root/staticfiles')
+CLOBBER.include(BUILD_DIR, REPORT_DIR, 'test_root/*_repo', 'test_root/staticfiles')
CLEAN.include("#{BUILD_DIR}/*.deb", "#{BUILD_DIR}/util")
def select_executable(*cmds)
@@ -78,6 +78,11 @@ def django_for_jasmine(system, django_reload)
Process.wait(django_pid)
end
end
+
+def report_dir_path(dir)
+ return File.join(REPORT_DIR, dir.to_s)
+end
+
task :default => [:test, :pep8, :pylint]
directory REPORT_DIR
@@ -89,16 +94,16 @@ default_options = {
task :predjango do
sh("find . -type f -name *.pyc -delete")
- sh('pip install -e common/lib/xmodule -e common/lib/capa')
+ sh('pip install -q --upgrade -r local-requirements.txt')
sh('git submodule update --init')
end
task :clean_test_files do
- sh("git clean -fdx test_root")
+ sh("git clean -fqdx test_root")
end
[:lms, :cms, :common].each do |system|
- report_dir = File.join(REPORT_DIR, system.to_s)
+ report_dir = report_dir_path(system)
directory report_dir
desc "Run pep8 on all #{system} code"
@@ -141,11 +146,19 @@ end
$failed_tests = 0
+def run_under_coverage(cmd, root)
+ cmd0, cmd_rest = cmd.split(" ", 2)
+ # We use "python -m coverage" so that the proper python will run the importable coverage
+ # rather than the coverage that OS path finds.
+ cmd = "python -m coverage run --rcfile=#{root}/.coveragerc `which #{cmd0}` #{cmd_rest}"
+ return cmd
+end
+
def run_tests(system, report_dir, stop_on_failure=true)
ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml")
- ENV['NOSE_COVER_HTML_DIR'] = File.join(report_dir, "cover")
dirs = Dir["common/djangoapps/*"] + Dir["#{system}/djangoapps/*"]
- sh(django_admin(system, :test, 'test', *dirs.each)) do |ok, res|
+ cmd = django_admin(system, :test, 'test', '--logging-clear-handlers', *dirs.each)
+ sh(run_under_coverage(cmd, system)) do |ok, res|
if !ok and stop_on_failure
abort "Test failed!"
end
@@ -153,11 +166,10 @@ def run_tests(system, report_dir, stop_on_failure=true)
end
end
-TEST_TASKS = []
+TEST_TASK_DIRS = []
[:lms, :cms].each do |system|
- report_dir = File.join(REPORT_DIR, system.to_s)
- directory report_dir
+ report_dir = report_dir_path(system)
# Per System tasks
desc "Run all django tests on our djangoapps for the #{system}"
@@ -170,7 +182,7 @@ TEST_TASKS = []
run_tests(system, report_dir, args.stop_on_failure)
end
- TEST_TASKS << "test_#{system}"
+ TEST_TASK_DIRS << system
desc <<-desc
Start the #{system} locally with the specified environment (defaults to dev).
@@ -191,12 +203,15 @@ TEST_TASKS = []
desc "Run collectstatic in the specified environment"
task "#{system}:collectstatic:#{env}" => :predjango do
- sh("#{django_admin(system, env, 'collectstatic', '--noinput')}")
+ sh("#{django_admin(system, env, 'collectstatic', '--noinput')} > /tmp/collectstatic.out") do |ok, status|
+ if !ok
+ abort "collectstatic failed!"
+ end
+ end
end
end
end
-
desc "Reset the relational database used by django. WARNING: this will delete all of your existing users"
task :resetdb, [:env] do |t, args|
args.with_defaults(:env => 'dev')
@@ -210,30 +225,39 @@ task :migrate, [:env] do |t, args|
sh(django_admin(:lms, args.env, 'migrate'))
end
-Dir["common/lib/*"].each do |lib|
+Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib|
task_name = "test_#{lib}"
- report_dir = File.join(REPORT_DIR, task_name.gsub('/', '_'))
- directory report_dir
+ report_dir = report_dir_path(lib)
desc "Run tests for common lib #{lib}"
task task_name => report_dir do
ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml")
- sh("nosetests #{lib} --cover-erase --with-xunit --with-xcoverage --cover-html --cover-inclusive --cover-package #{File.basename(lib)} --cover-html-dir #{File.join(report_dir, "cover")}")
+ cmd = "nosetests #{lib} --logging-clear-handlers --with-xunit"
+ sh(run_under_coverage(cmd, lib)) do |ok, res|
+ $failed_tests += 1 unless ok
+ end
end
- TEST_TASKS << task_name
+ TEST_TASK_DIRS << lib
desc "Run tests for common lib #{lib} (without coverage)"
task "fasttest_#{lib}" do
sh("nosetests #{lib}")
end
+end
+task :report_dirs
+
+TEST_TASK_DIRS.each do |dir|
+ report_dir = report_dir_path(dir)
+ directory report_dir
+ task :report_dirs => [REPORT_DIR, report_dir]
end
task :test do
- TEST_TASKS.each do |task|
- Rake::Task[task].invoke(false)
+ TEST_TASK_DIRS.each do |dir|
+ Rake::Task["test_#{dir}"].invoke(false)
end
if $failed_tests > 0
@@ -241,6 +265,34 @@ task :test do
end
end
+namespace :coverage do
+ desc "Build the html coverage reports"
+ task :html => :report_dirs do
+ TEST_TASK_DIRS.each do |dir|
+ report_dir = report_dir_path(dir)
+
+ if !File.file?("#{report_dir}/.coverage")
+ next
+ end
+
+ sh("coverage html --rcfile=#{dir}/.coveragerc")
+ end
+ end
+
+ desc "Build the xml coverage reports"
+ task :xml => :report_dirs do
+ TEST_TASK_DIRS.each do |dir|
+ report_dir = report_dir_path(dir)
+
+ if !File.file?("#{report_dir}/.coverage")
+ next
+ end
+ # Why doesn't the rcfile control the xml output file properly??
+ sh("coverage xml -o #{report_dir}/coverage.xml --rcfile=#{dir}/.coveragerc")
+ end
+ end
+end
+
task :runserver => :lms
desc "Run django-admin against the specified system and environment"
diff --git a/repo-requirements.txt b/repo-requirements.txt
index f98d05ffc9..aa503e9779 100644
--- a/repo-requirements.txt
+++ b/repo-requirements.txt
@@ -1,6 +1,2 @@
--e git://github.com/MITx/django-staticfiles.git@6d2504e5c8#egg=django-staticfiles
--e git://github.com/MITx/django-pipeline.git#egg=django-pipeline
--e git://github.com/MITx/django-wiki.git@e2e84558#egg=django-wiki
--e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev
--e common/lib/capa
--e common/lib/xmodule
+-r github-requirements.txt
+-r local-requirements.txt
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index a8f064927d..5ccb7a8eb9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -52,5 +52,6 @@ pil
nltk
django-debug-toolbar-mongo
dogstatsd-python
-MySQL-python
+# Taking out MySQL-python for now because it requires mysql to be installed, so breaks updates on content folks' envs.
+# MySQL-python
sphinx