From 9dd182800b46f781ef56a0a15ca620021f0f385d Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Fri, 8 Nov 2013 11:56:12 -0500 Subject: [PATCH 001/110] Set empty aws credentials to None. --- cms/envs/aws.py | 7 ++++++- lms/envs/aws.py | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 2e53a555a5..9cdfd79b34 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -156,9 +156,14 @@ SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY') if SEGMENT_IO_KEY: MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False) - AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] +if AWS_ACCESS_KEY_ID == "": + AWS_ACCESS_KEY_ID = None + AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] +if AWS_SECRET_ACCESS_KEY == "": + AWS_SECRET_ACCESS_KEY = None + DATABASES = AUTH_TOKENS['DATABASES'] MODULESTORE = AUTH_TOKENS['MODULESTORE'] CONTENTSTORE = AUTH_TOKENS['CONTENTSTORE'] diff --git a/lms/envs/aws.py b/lms/envs/aws.py index d524474d5b..0262c9a5d4 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -243,7 +243,13 @@ CC_PROCESSOR = AUTH_TOKENS.get('CC_PROCESSOR', CC_PROCESSOR) SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] +if AWS_ACCESS_KEY_ID == "": + AWS_ACCESS_KEY_ID = None + AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] +if AWS_SECRET_ACCESS_KEY == "": + AWS_SECRET_ACCESS_KEY = None + AWS_STORAGE_BUCKET_NAME = AUTH_TOKENS.get('AWS_STORAGE_BUCKET_NAME', 'edxuploads') DATABASES = AUTH_TOKENS['DATABASES'] From 2a31e3567e623feffd74cb861d40c792d5909344 Mon Sep 17 00:00:00 2001 From: John Jarvis Date: Wed, 6 Nov 2013 17:07:02 -0500 Subject: [PATCH 002/110] sending template pdf with certificate request --- .../management/commands/ungenerated_certs.py | 1 + .../0015_adding_mode_for_verified_certs.py | 86 +++++++++++++++++++ lms/djangoapps/certificates/models.py | 5 ++ lms/djangoapps/certificates/queue.py | 75 +++++++++------- 4 files changed, 136 insertions(+), 31 deletions(-) create mode 100644 lms/djangoapps/certificates/migrations/0015_adding_mode_for_verified_certs.py diff --git a/lms/djangoapps/certificates/management/commands/ungenerated_certs.py b/lms/djangoapps/certificates/management/commands/ungenerated_certs.py index 5fb9c53718..5aa223acab 100644 --- a/lms/djangoapps/certificates/management/commands/ungenerated_certs.py +++ b/lms/djangoapps/certificates/management/commands/ungenerated_certs.py @@ -93,6 +93,7 @@ class Command(BaseCommand): total = enrolled_students.count() count = 0 start = datetime.datetime.now(UTC) + for student in enrolled_students: count += 1 if count % STATUS_INTERVAL == 0: diff --git a/lms/djangoapps/certificates/migrations/0015_adding_mode_for_verified_certs.py b/lms/djangoapps/certificates/migrations/0015_adding_mode_for_verified_certs.py new file mode 100644 index 0000000000..c16d51b8ee --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0015_adding_mode_for_verified_certs.py @@ -0,0 +1,86 @@ +# -*- 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 field 'GeneratedCertificate.mode' + db.add_column('certificates_generatedcertificate', 'mode', + self.gf('django.db.models.fields.CharField')(default='honor', max_length=32), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'GeneratedCertificate.mode' + db.delete_column('certificates_generatedcertificate', 'mode') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'certificates.certificatewhitelist': { + 'Meta': {'object_name': 'CertificateWhitelist'}, + 'course_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'whitelist': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'certificates.generatedcertificate': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'GeneratedCertificate'}, + 'course_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'blank': 'True'}), + 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now_add': 'True', 'blank': 'True'}), + 'distinction': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '128', 'blank': 'True'}), + 'download_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'error_reason': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}), + 'grade': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '5', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '32'}), + 'modified_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'unavailable'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'verify_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', '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'}) + } + } + + complete_apps = ['certificates'] \ No newline at end of file diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 8cd1a292c4..36ff18618e 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -62,6 +62,10 @@ class CertificateStatuses(object): restricted = 'restricted' unavailable = 'unavailable' +class CertificateModes(object): + verified = 'verified' + honor = 'honor' + audit = 'audit' class CertificateWhitelist(models.Model): """ @@ -86,6 +90,7 @@ class GeneratedCertificate(models.Model): key = models.CharField(max_length=32, blank=True, default='') distinction = models.BooleanField(default=False) status = models.CharField(max_length=32, default='unavailable') + mode = models.CharField(max_length=32, default=CertificateModes.honor) name = models.CharField(blank=True, max_length=255) created_date = models.DateTimeField( auto_now_add=True, default=datetime.now) diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 5f63bbf1e2..33db940c44 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -9,7 +9,7 @@ from capa.xqueue_interface import XQueueInterface from capa.xqueue_interface import make_xheader, make_hashkey from django.conf import settings from requests.auth import HTTPBasicAuth -from student.models import UserProfile +from student.models import UserProfile, CourseEnrollment import json import random @@ -57,7 +57,7 @@ class XQueueCertInterface(object): if settings.XQUEUE_INTERFACE.get('basic_auth') is not None: requests_auth = HTTPBasicAuth( - *settings.XQUEUE_INTERFACE['basic_auth']) + *settings.XQUEUE_INTERFACE['basic_auth']) else: requests_auth = None @@ -68,10 +68,10 @@ class XQueueCertInterface(object): self.request = request self.xqueue_interface = XQueueInterface( - settings.XQUEUE_INTERFACE['url'], - settings.XQUEUE_INTERFACE['django_auth'], - requests_auth, - ) + settings.XQUEUE_INTERFACE['url'], + settings.XQUEUE_INTERFACE['django_auth'], + requests_auth, + ) self.whitelist = CertificateWhitelist.objects.all() self.restricted = UserProfile.objects.filter(allow_certificate=False) self.use_https = True @@ -84,7 +84,7 @@ class XQueueCertInterface(object): course_id - courseenrollment.course_id (string) WARNING: this command will leave the old certificate, if one exists, - laying around in AWS taking up space. If this is a problem, + laying around in AWS taking up space. If this is a problem, take pains to clean up storage before running this command. Change the certificate status to unavailable (if it exists) and request @@ -92,7 +92,7 @@ class XQueueCertInterface(object): Return the status object. """ - # TODO: when del_cert is implemented and plumbed through certificates + # TODO: when del_cert is implemented and plumbed through certificates # repo also, do a deletion followed by a creation r/t a simple # recreation. XXX: this leaves orphan cert files laying around in # AWS. See note in the docstring too. @@ -149,13 +149,15 @@ class XQueueCertInterface(object): """ VALID_STATUSES = [status.generating, - status.unavailable, - status.deleted, + status.unavailable, + status.deleted, status.error, status.notpassing] cert_status = certificate_status_for_student(student, course_id)['status'] + new_status = cert_status + if cert_status in VALID_STATUSES: # grade the student @@ -165,9 +167,6 @@ class XQueueCertInterface(object): course = courses.get_course_by_id(course_id) profile = UserProfile.objects.get(user=student) - cert, created = GeneratedCertificate.objects.get_or_create( - user=student, course_id=course_id) - # Needed self.request.user = student self.request.session = {} @@ -175,45 +174,59 @@ class XQueueCertInterface(object): grade = grades.grade(student, self.request, course) is_whitelisted = self.whitelist.filter( user=student, course_id=course_id, whitelist=True).exists() + enrollment = CourseEnrollment.objects.get(user=student) + org = course_id.split('/')[0] + course_num = course_id.split('/')[1] + if enrolment.mode == CertificateModes.verified: + template_pdf = "certificate-template-{0}-{1}-verified.pdf".format( + org, course_num) + else: + # honor code and audit students + template_pdf = "certificate-template-{0}-{1}.pdf".format( + org, course_num) + + cert, created = GeneratedCertificate.objects.get_or_create( + user=student, course_id=course_id) + + cert.mode = enrollment.mode + + cert.user = student + cert.grade = grade['percent'] + cert.course_id = course_id + cert.name = profile.name if is_whitelisted or grade['grade'] is not None: - key = make_hashkey(random.random()) - - cert.grade = grade['percent'] - cert.user = student - cert.course_id = course_id - cert.key = key - cert.name = profile.name - # check to see whether the student is on the # the embargoed country restricted list # otherwise, put a new certificate request # on the queue + if self.restricted.filter(user=student).exists(): - cert.status = status.restricted + new_status = status.restricted + cert.status = new_status cert.save() else: + key = make_hashkey(random.random()) + cert.key = key contents = { 'action': 'create', 'username': student.username, 'course_id': course_id, 'name': profile.name, 'grade': grade['grade'], + 'template_pdf': template_pdf, } - cert.status = status.generating + new_status = status.generating + cert.status = new_status cert.save() self._send_to_xqueue(contents, key) else: - cert_status = status.notpassing - cert.grade = grade['percent'] - cert.user = student - cert.course_id = course_id - cert.name = profile.name - cert.status = cert_status + new_status = status.notpassing + cert.status = new_status cert.save() - return cert_status + return new_status def _send_to_xqueue(self, contents, key): @@ -227,7 +240,7 @@ class XQueueCertInterface(object): proto, settings.SITE_NAME, key), key, settings.CERT_QUEUE) (error, msg) = self.xqueue_interface.send_to_queue( - header=xheader, body=json.dumps(contents)) + header=xheader, body=json.dumps(contents)) if error: logger.critical('Unable to add a request to the queue: {} {}'.format(error, msg)) raise Exception('Unable to send queue message') From 2fed61814a4ee63e2753933e816b8680647608b9 Mon Sep 17 00:00:00 2001 From: John Jarvis Date: Wed, 6 Nov 2013 17:32:57 -0500 Subject: [PATCH 003/110] fixing some typos --- lms/djangoapps/certificates/queue.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 33db940c44..8dffa7ee24 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -1,7 +1,7 @@ from certificates.models import GeneratedCertificate from certificates.models import certificate_status_for_student from certificates.models import CertificateStatuses as status -from certificates.models import CertificateWhitelist +from certificates.models import CertificateWhitelist, CertificateModes from courseware import grades, courses from django.test.client import RequestFactory @@ -177,12 +177,12 @@ class XQueueCertInterface(object): enrollment = CourseEnrollment.objects.get(user=student) org = course_id.split('/')[0] course_num = course_id.split('/')[1] - if enrolment.mode == CertificateModes.verified: - template_pdf = "certificate-template-{0}-{1}-verified.pdf".format( + if enrollment.mode == CertificateModes.verified: + template_pdf = "certificate-template-{0}-{1}-verified.pdf".format( org, course_num) else: # honor code and audit students - template_pdf = "certificate-template-{0}-{1}.pdf".format( + template_pdf = "certificate-template-{0}-{1}.pdf".format( org, course_num) cert, created = GeneratedCertificate.objects.get_or_create( From 1b7a871926848beecd88c86db55c7e2d36296fe9 Mon Sep 17 00:00:00 2001 From: Julia Hansbrough Date: Fri, 15 Nov 2013 22:18:31 +0000 Subject: [PATCH 004/110] Fixed password reset message LMS-1507 --- common/djangoapps/student/views.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index f92ffe9d3e..d4a03dca37 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1229,11 +1229,8 @@ def password_reset(request): from_email=settings.DEFAULT_FROM_EMAIL, request=request, domain_override=request.get_host()) - return HttpResponse(json.dumps({'success': True, + return HttpResponse(json.dumps({'success': True, 'value': render_to_string('registration/password_reset_done.html', {})})) - else: - return HttpResponse(json.dumps({'success': False, - 'error': _('Invalid e-mail or user')})) def password_reset_confirm_wrapper( @@ -1515,4 +1512,4 @@ def change_email_settings(request): log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(user.username, user.email, course_id)) track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard') - return HttpResponse(json.dumps({'success': True})) \ No newline at end of file + return HttpResponse(json.dumps({'success': True})) \ No newline at end of file From c8a98051dd99b68da27d4e276b964b1c85beee04 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Fri, 15 Nov 2013 21:28:03 -0800 Subject: [PATCH 005/110] CSV Reporting of Shopping Cart Purchases, with tests squashing to one commit to make cherry-picking by feature possible --- lms/djangoapps/shoppingcart/admin.py | 7 + ...nannotation__add_field_orderitem_report.py | 132 +++++++++++++++ lms/djangoapps/shoppingcart/models.py | 90 ++++++++++- .../shoppingcart/tests/test_models.py | 85 +++++++++- .../shoppingcart/tests/test_views.py | 150 +++++++++++++++++- lms/djangoapps/shoppingcart/urls.py | 1 + lms/djangoapps/shoppingcart/views.py | 74 +++++++++ lms/envs/aws.py | 4 + lms/envs/common.py | 8 + lms/static/sass/views/_shoppingcart.scss | 8 + .../shoppingcart/download_report.html | 29 ++++ requirements/edx/base.txt | 1 + 12 files changed, 582 insertions(+), 7 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/admin.py create mode 100644 lms/djangoapps/shoppingcart/migrations/0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report.py create mode 100644 lms/templates/shoppingcart/download_report.html diff --git a/lms/djangoapps/shoppingcart/admin.py b/lms/djangoapps/shoppingcart/admin.py new file mode 100644 index 0000000000..199a39d7c0 --- /dev/null +++ b/lms/djangoapps/shoppingcart/admin.py @@ -0,0 +1,7 @@ +""" +Allows django admin site to add PaidCourseRegistrationAnnotations +""" +from ratelimitbackend import admin +from shoppingcart.models import PaidCourseRegistrationAnnotation + +admin.site.register(PaidCourseRegistrationAnnotation) diff --git a/lms/djangoapps/shoppingcart/migrations/0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report.py b/lms/djangoapps/shoppingcart/migrations/0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report.py new file mode 100644 index 0000000000..04d37c730a --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report.py @@ -0,0 +1,132 @@ +# -*- 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 'PaidCourseRegistrationAnnotation' + db.create_table('shoppingcart_paidcourseregistrationannotation', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(unique=True, max_length=128, db_index=True)), + ('annotation', self.gf('django.db.models.fields.TextField')(null=True)), + )) + db.send_create_signal('shoppingcart', ['PaidCourseRegistrationAnnotation']) + + # Adding field 'OrderItem.report_comments' + db.add_column('shoppingcart_orderitem', 'report_comments', + self.gf('django.db.models.fields.TextField')(default=''), + keep_default=False) + + + def backwards(self, orm): + # Deleting model 'PaidCourseRegistrationAnnotation' + db.delete_table('shoppingcart_paidcourseregistrationannotation') + + # Deleting field 'OrderItem.report_comments' + db.delete_column('shoppingcart_orderitem', 'report_comments') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'report_comments': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.paidcourseregistrationannotation': { + 'Meta': {'object_name': 'PaidCourseRegistrationAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", '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'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 03be80861a..f8422dbefc 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -2,6 +2,7 @@ from datetime import datetime import pytz import logging import smtplib +import unicodecsv from model_utils.managers import InheritanceManager from collections import namedtuple @@ -207,6 +208,8 @@ class OrderItem(models.Model): line_desc = models.CharField(default="Misc. Item", max_length=1024) currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes fulfilled_time = models.DateTimeField(null=True) + # general purpose field, not user-visible. Used for reporting + report_comments = models.TextField(default="") @property def line_cost(self): @@ -254,6 +257,66 @@ class OrderItem(models.Model): """ return self.pk_with_subclass, set([]) + @classmethod + def purchased_items_btw_dates(cls, start_date, end_date): + """ + Returns a QuerySet of the purchased items between start_date and end_date inclusive. + """ + return cls.objects.filter( + status="purchased", + fulfilled_time__gte=start_date, + fulfilled_time__lt=end_date, + ) + + @classmethod + def csv_purchase_report_btw_dates(cls, filelike, start_date, end_date): + """ + Outputs a CSV report into "filelike" (a file-like python object, such as an actual file, an HttpRequest, + or sys.stdout) of purchased items between start_date and end_date inclusive. + Opening and closing filelike (if applicable) should be taken care of by the caller + """ + items = cls.purchased_items_btw_dates(start_date, end_date).order_by("fulfilled_time") + + writer = unicodecsv.writer(filelike, encoding="utf-8") + writer.writerow(OrderItem.csv_report_header_row()) + + for item in items: + writer.writerow(item.csv_report_row) + + @classmethod + def csv_report_header_row(cls): + """ + Returns the "header" row for a csv report of purchases + """ + return [ + "Purchase Time", + "Order ID", + "Status", + "Quantity", + "Unit Cost", + "Total Cost", + "Currency", + "Description", + "Comments" + ] + + @property + def csv_report_row(self): + """ + Returns an array which can be fed into csv.writer to write out one csv row + """ + return [ + self.fulfilled_time, + self.order_id, # pylint: disable=no-member + self.status, + self.qty, + self.unit_cost, + self.line_cost, + self.currency, + self.line_desc, + self.report_comments, + ] + @property def pk_with_subclass(self): """ @@ -345,13 +408,13 @@ class PaidCourseRegistration(OrderItem): item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) item.status = order.status - item.mode = course_mode.slug item.qty = 1 item.unit_cost = cost item.line_desc = 'Registration for Course: {0}'.format(course.display_name_with_default) item.currency = currency order.currency = currency + item.report_comments = item.csv_report_comments order.save() item.save() log.info("User {} added course registration {} to cart: order {}" @@ -391,6 +454,31 @@ class PaidCourseRegistration(OrderItem): return self.pk_with_subclass, set([notification]) + @property + def csv_report_comments(self): + """ + Tries to fetch an annotation associated with the course_id from the database. If not found, returns u"". + Otherwise returns the annotation + """ + try: + return PaidCourseRegistrationAnnotation.objects.get(course_id=self.course_id).annotation + except PaidCourseRegistrationAnnotation.DoesNotExist: + return u"" + + +class PaidCourseRegistrationAnnotation(models.Model): + """ + A model that maps course_id to an additional annotation. This is specifically needed because when Stanford + generates report for the paid courses, each report item must contain the payment account associated with a course. + And unfortunately we didn't have the concept of a "SKU" or stock item where we could keep this association, + so this is to retrofit it. + """ + course_id = models.CharField(unique=True, max_length=128, db_index=True) + annotation = models.TextField(null=True) + + def __unicode__(self): + return u"{} : {}".format(self.course_id, self.annotation) + class CertificateItem(OrderItem): """ diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index ecb76ac941..a7196aa2a1 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -2,6 +2,8 @@ Tests for the Shopping Cart Models """ import smtplib +import StringIO +from textwrap import dedent from boto.exception import BotoServerError # this is a super-class of SESError and catches connection errors from mock import patch, MagicMock @@ -15,7 +17,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from shoppingcart.models import (Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration, - OrderItemSubclassPK) + OrderItemSubclassPK, PaidCourseRegistrationAnnotation) from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode @@ -321,6 +323,87 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class PurchaseReportTest(ModuleStoreTestCase): + + FIVE_MINS = datetime.timedelta(minutes=5) + TEST_ANNOTATION = u'Ba\xfc\u5305' + + def setUp(self): + self.user = UserFactory.create() + self.course_id = "MITx/999/Robot_Super_Course" + self.cost = 40 + self.course = CourseFactory.create(org='MITx', number='999', display_name=u'Robot Super Course') + course_mode = CourseMode(course_id=self.course_id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + course_mode.save() + course_mode2 = CourseMode(course_id=self.course_id, + mode_slug="verified", + mode_display_name="verified cert", + min_price=self.cost) + course_mode2.save() + self.annotation = PaidCourseRegistrationAnnotation(course_id=self.course_id, annotation=self.TEST_ANNOTATION) + self.annotation.save() + self.cart = Order.get_cart_for_user(self.user) + self.reg = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + self.cert_item = CertificateItem.add_to_order(self.cart, self.course_id, self.cost, 'verified') + self.cart.purchase() + self.now = datetime.datetime.now(pytz.UTC) + + def test_purchased_items_btw_dates(self): + purchases = OrderItem.purchased_items_btw_dates(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS) + self.assertEqual(len(purchases), 2) + self.assertIn(self.reg.orderitem_ptr, purchases) + self.assertIn(self.cert_item.orderitem_ptr, purchases) + no_purchases = OrderItem.purchased_items_btw_dates(self.now + self.FIVE_MINS, + self.now + self.FIVE_MINS + self.FIVE_MINS) + self.assertFalse(no_purchases) + + test_time = datetime.datetime.now(pytz.UTC) + + CORRECT_CSV = dedent(""" + Purchase Time,Order ID,Status,Quantity,Unit Cost,Total Cost,Currency,Description,Comments + {time_str},1,purchased,1,40,40,usd,Registration for Course: Robot Super Course,Ba\xc3\xbc\xe5\x8c\x85 + {time_str},1,purchased,1,40,40,usd,"Certificate of Achievement, verified cert for course Robot Super Course", + """.format(time_str=str(test_time))) + + def test_purchased_csv(self): + """ + Tests that a generated purchase report CSV is as we expect + """ + # coerce the purchase times to self.test_time so that the test can match. + # It's pretty hard to patch datetime.datetime b/c it's a python built-in, which is immutable, so we + # make the times match this way + for item in OrderItem.purchased_items_btw_dates(self.now - self.FIVE_MINS, self.now + self.FIVE_MINS): + item.fulfilled_time = self.test_time + item.save() + + # add annotation to the + csv_file = StringIO.StringIO() + OrderItem.csv_purchase_report_btw_dates(csv_file, self.now - self.FIVE_MINS, self.now + self.FIVE_MINS) + csv = csv_file.getvalue() + csv_file.close() + # Using excel mode csv, which automatically ends lines with \r\n, so need to convert to \n + self.assertEqual(csv.replace('\r\n', '\n').strip(), self.CORRECT_CSV.strip()) + + def test_csv_report_no_annotation(self): + """ + Fill in gap in test coverage. csv_report_comments for PaidCourseRegistration instance with no + matching annotation + """ + # delete the matching annotation + self.annotation.delete() + self.assertEqual(u"", self.reg.csv_report_comments) + + def test_paidcourseregistrationannotation_unicode(self): + """ + Fill in gap in test coverage. __unicode__ method of PaidCourseRegistrationAnnotation + """ + self.assertEqual(unicode(self.annotation), u'{} : {}'.format(self.course_id, self.TEST_ANNOTATION)) + + @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class CertificateItemTest(ModuleStoreTestCase): """ diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index d60cab78d9..0451277ce2 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -3,23 +3,23 @@ Tests for Shopping Cart views """ from urlparse import urlparse +from django.conf import settings from django.test import TestCase from django.test.utils import override_settings from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ +from django.contrib.auth.models import Group from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from xmodule.modulestore.exceptions import ItemNotFoundError from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE -from shoppingcart.views import add_course_to_cart -from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration +from shoppingcart.views import _can_download_report, _get_date_from_str +from shoppingcart.models import Order, CertificateItem, PaidCourseRegistration, OrderItem from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode -from ..exceptions import PurchasedCallbackException from mitxmako.shortcuts import render_to_response -from shoppingcart.processors import render_purchase_form_html, process_postpay_callback +from shoppingcart.processors import render_purchase_form_html from mock import patch, Mock @@ -232,3 +232,143 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.assertEqual(resp.status_code, 200) ((template, _context), _tmp) = render_mock.call_args self.assertEqual(template, cert_item.single_item_receipt_template) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class CSVReportViewsTest(ModuleStoreTestCase): + """ + Test suite for CSV Purchase Reporting + """ + def setUp(self): + self.user = UserFactory.create() + self.user.set_password('password') + self.user.save() + self.course_id = "MITx/999/Robot_Super_Course" + self.cost = 40 + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + self.course_mode = CourseMode(course_id=self.course_id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + self.course_mode.save() + self.verified_course_id = 'org/test/Test_Course' + CourseFactory.create(org='org', number='test', run='course1', display_name='Test Course') + self.cart = Order.get_cart_for_user(self.user) + self.dl_grp = Group(name=settings.PAYMENT_REPORT_GENERATOR_GROUP) + self.dl_grp.save() + + def login_user(self): + """ + Helper fn to login self.user + """ + self.client.login(username=self.user.username, password="password") + + def add_to_download_group(self, user): + """ + Helper fn to add self.user to group that's allowed to download report CSV + """ + user.groups.add(self.dl_grp) + + def test_report_csv_no_access(self): + self.login_user() + response = self.client.get(reverse('payment_csv_report')) + self.assertEqual(response.status_code, 403) + + def test_report_csv_bad_method(self): + self.login_user() + self.add_to_download_group(self.user) + response = self.client.put(reverse('payment_csv_report')) + self.assertEqual(response.status_code, 400) + + @patch('shoppingcart.views.render_to_response', render_mock) + def test_report_csv_get(self): + self.login_user() + self.add_to_download_group(self.user) + response = self.client.get(reverse('payment_csv_report')) + + ((template, context), unused_kwargs) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/download_report.html') + self.assertFalse(context['total_count_error']) + self.assertFalse(context['date_fmt_error']) + self.assertIn(_("Download Purchase Report"), response.content) + + @patch('shoppingcart.views.render_to_response', render_mock) + def test_report_csv_bad_date(self): + self.login_user() + self.add_to_download_group(self.user) + response = self.client.post(reverse('payment_csv_report'), {'start_date': 'BAD', 'end_date': 'BAD'}) + + ((template, context), unused_kwargs) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/download_report.html') + self.assertFalse(context['total_count_error']) + self.assertTrue(context['date_fmt_error']) + self.assertIn(_("There was an error in your date input. It should be formatted as YYYY-MM-DD"), + response.content) + + @patch('shoppingcart.views.render_to_response', render_mock) + @override_settings(PAYMENT_REPORT_MAX_ITEMS=0) + def test_report_csv_too_long(self): + PaidCourseRegistration.add_to_order(self.cart, self.course_id) + self.cart.purchase() + self.login_user() + self.add_to_download_group(self.user) + response = self.client.post(reverse('payment_csv_report'), {'start_date': '1970-01-01', + 'end_date': '2100-01-01'}) + + ((template, context), unused_kwargs) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/download_report.html') + self.assertTrue(context['total_count_error']) + self.assertFalse(context['date_fmt_error']) + self.assertIn(_("There are too many results in your report.") + " (>0)", response.content) + + # just going to ignored the date in this test, since we already deal with date testing + # in test_models.py + CORRECT_CSV_NO_DATE = ",1,purchased,1,40,40,usd,Registration for Course: Robot Super Course," + + def test_report_csv(self): + PaidCourseRegistration.add_to_order(self.cart, self.course_id) + self.cart.purchase() + self.login_user() + self.add_to_download_group(self.user) + response = self.client.post(reverse('payment_csv_report'), {'start_date': '1970-01-01', + 'end_date': '2100-01-01'}) + self.assertEqual(response['Content-Type'], 'text/csv') + self.assertIn(",".join(OrderItem.csv_report_header_row()), response.content) + self.assertIn(self.CORRECT_CSV_NO_DATE, response.content) + + +class UtilFnsTest(TestCase): + """ + Tests for utility functions in views.py + """ + def setUp(self): + self.user = UserFactory.create() + + def test_can_download_report_no_group(self): + """ + Group controlling perms is not present + """ + self.assertFalse(_can_download_report(self.user)) + + def test_can_download_report_not_member(self): + """ + User is not part of group controlling perms + """ + Group(name=settings.PAYMENT_REPORT_GENERATOR_GROUP).save() + self.assertFalse(_can_download_report(self.user)) + + def test_can_download_report(self): + """ + User is part of group controlling perms + """ + grp = Group(name=settings.PAYMENT_REPORT_GENERATOR_GROUP) + grp.save() + self.user.groups.add(grp) + self.assertTrue(_can_download_report(self.user)) + + def test_get_date_from_str(self): + test_str = "2013-10-01" + date = _get_date_from_str(test_str) + self.assertEqual(2013, date.year) + self.assertEqual(10, date.month) + self.assertEqual(1, date.day) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 9522d15298..3653c91524 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -12,6 +12,7 @@ if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: url(r'^clear/$', 'clear_cart'), url(r'^remove_item/$', 'remove_item'), url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'), + url(r'^csv_report/$', 'csv_report', name='payment_csv_report'), ) if settings.MITX_FEATURES.get('ENABLE_PAYMENT_FAKE'): diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 8c6d61d532..ad7ef6b080 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -1,4 +1,8 @@ import logging +import datetime +import pytz +from django.conf import settings +from django.contrib.auth.models import Group from django.http import (HttpResponse, HttpResponseRedirect, HttpResponseNotFound, HttpResponseBadRequest, HttpResponseForbidden, Http404) from django.utils.translation import ugettext as _ @@ -121,3 +125,73 @@ def show_receipt(request, ordernum): context.update(order_items[0].single_item_receipt_context) return render_to_response(receipt_template, context) + + +def _can_download_report(user): + """ + Tests if the user can download the payments report, based on membership in a group whose name is determined + in settings. If the group does not exist, denies all access + """ + try: + access_group = Group.objects.get(name=settings.PAYMENT_REPORT_GENERATOR_GROUP) + except Group.DoesNotExist: + return False + return access_group in user.groups.all() + + +def _get_date_from_str(date_input): + """ + Gets date from the date input string. Lets the ValueError raised by invalid strings be processed by the caller + """ + return datetime.datetime.strptime(date_input.strip(), "%Y-%m-%d").replace(tzinfo=pytz.UTC) + + +def _render_report_form(start_str, end_str, total_count_error=False, date_fmt_error=False): + """ + Helper function that renders the purchase form. Reduces repetition + """ + context = { + 'total_count_error': total_count_error, + 'date_fmt_error': date_fmt_error, + 'start_date': start_str, + 'end_date': end_str, + } + return render_to_response('shoppingcart/download_report.html', context) + + +@login_required +def csv_report(request): + """ + Downloads csv reporting of orderitems + """ + if not _can_download_report(request.user): + return HttpResponseForbidden(_('You do not have permission to view this page.')) + + if request.method == 'POST': + start_str = request.POST.get('start_date', '') + end_str = request.POST.get('end_date', '') + try: + start_date = _get_date_from_str(start_str) + end_date = _get_date_from_str(end_str) + datetime.timedelta(days=1) + except ValueError: + # Error case: there was a badly formatted user-input date string + return _render_report_form(start_str, end_str, date_fmt_error=True) + + items = OrderItem.purchased_items_btw_dates(start_date, end_date) + if items.count() > settings.PAYMENT_REPORT_MAX_ITEMS: + # Error case: too many items would be generated in the report and we're at risk of timeout + return _render_report_form(start_str, end_str, total_count_error=True) + + response = HttpResponse(mimetype='text/csv') + filename = "purchases_report_{}.csv".format(datetime.datetime.now(pytz.UTC).strftime("%Y-%m-%d-%H-%M-%S")) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + OrderItem.csv_purchase_report_btw_dates(response, start_date, end_date) + return response + + elif request.method == 'GET': + end_date = datetime.datetime.now(pytz.UTC) + start_date = end_date - datetime.timedelta(days=30) + return _render_report_form(start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d")) + + else: + return HttpResponseBadRequest("HTTP Method Not Supported") diff --git a/lms/envs/aws.py b/lms/envs/aws.py index d524474d5b..204f317d8e 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -148,6 +148,10 @@ PAYMENT_SUPPORT_EMAIL = ENV_TOKENS.get('PAYMENT_SUPPORT_EMAIL', PAYMENT_SUPPORT_ PAID_COURSE_REGISTRATION_CURRENCY = ENV_TOKENS.get('PAID_COURSE_REGISTRATION_CURRENCY', PAID_COURSE_REGISTRATION_CURRENCY) +# Payment Report Settings +PAYMENT_REPORT_GENERATOR_GROUP = ENV_TOKENS.get('PAYMENT_REPORT_GENERATOR_GROUP', PAYMENT_REPORT_GENERATOR_GROUP) +PAYMENT_REPORT_MAX_ITEMS = ENV_TOKENS.get('PAYMENT_REPORT_MAX_ITEMS', PAYMENT_REPORT_MAX_ITEMS) + # Bulk Email overrides BULK_EMAIL_DEFAULT_FROM_EMAIL = ENV_TOKENS.get('BULK_EMAIL_DEFAULT_FROM_EMAIL', BULK_EMAIL_DEFAULT_FROM_EMAIL) BULK_EMAIL_EMAILS_PER_TASK = ENV_TOKENS.get('BULK_EMAIL_EMAILS_PER_TASK', BULK_EMAIL_EMAILS_PER_TASK) diff --git a/lms/envs/common.py b/lms/envs/common.py index c698ce24be..376fdb2405 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -542,6 +542,12 @@ CC_PROCESSOR = { } # Setting for PAID_COURSE_REGISTRATION, DOES NOT AFFECT VERIFIED STUDENTS PAID_COURSE_REGISTRATION_CURRENCY = ['usd', '$'] + +# Members of this group are allowed to generate payment reports +PAYMENT_REPORT_GENERATOR_GROUP = 'shoppingcart_report_access' +# Maximum number of rows the report can contain +PAYMENT_REPORT_MAX_ITEMS = 10000 + ################################# open ended grading config ##################### #By setting up the default settings with an incorrect user name and password, @@ -899,6 +905,8 @@ BULK_EMAIL_LOG_SENT_EMAILS = False # parallel, and what the SES rate is. BULK_EMAIL_RETRY_DELAY_BETWEEN_SENDS = 0.02 + + ################################### APPS ###################################### INSTALLED_APPS = ( # Standard ones that are always installed... diff --git a/lms/static/sass/views/_shoppingcart.scss b/lms/static/sass/views/_shoppingcart.scss index d6861fb456..1b3da66893 100644 --- a/lms/static/sass/views/_shoppingcart.scss +++ b/lms/static/sass/views/_shoppingcart.scss @@ -5,6 +5,14 @@ padding: 30px 30px 0 30px; } +.error_msg { + margin: 20px; + padding: 5px; + color: $red; + border: 1px solid $red; + +} + .cart-list { padding: 30px; margin-top: 40px; diff --git a/lms/templates/shoppingcart/download_report.html b/lms/templates/shoppingcart/download_report.html new file mode 100644 index 0000000000..838b07f145 --- /dev/null +++ b/lms/templates/shoppingcart/download_report.html @@ -0,0 +1,29 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> + +<%block name="title">${_("Download Purchase Report")} + + +
+

${_("Download CSV of purchase data")}

+ % if date_fmt_error: +
+ ${_("There was an error in your date input. It should be formatted as YYYY-MM-DD")} +
+ % endif + % if total_count_error: +
+ ${_("There are too many results in your report.")} (>${settings.PAYMENT_REPORT_MAX_ITEMS}). + ${_("Try making the date range smaller.")} +
+ % endif +
+ + + + + + +
+
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index a0199378e7..f198b3ae10 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -70,6 +70,7 @@ South==0.7.6 sympy==0.7.1 xmltodict==0.4.1 django-ratelimit-backend==0.6 +unicodecsv==0.9.4 # Used for debugging ipython==0.13.1 From 41b73d8f482ae218bb1c1795b3879541de835e40 Mon Sep 17 00:00:00 2001 From: Julia Hansbrough Date: Mon, 18 Nov 2013 20:03:01 +0000 Subject: [PATCH 006/110] Basic test fix --- common/djangoapps/student/tests/tests.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index 06d61c0425..b90ba3a165 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -59,23 +59,28 @@ class ResetPasswordTests(TestCase): self.user_bad_passwd.password = UNUSABLE_PASSWORD self.user_bad_passwd.save() + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_user_bad_password_reset(self): """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD""" bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) bad_pwd_resp = password_reset(bad_pwd_req) + # If they've got an unusable password, fine, we should let them reset it self.assertEquals(bad_pwd_resp.status_code, 200) - self.assertEquals(bad_pwd_resp.content, json.dumps({'success': False, - 'error': 'Invalid e-mail or user'})) + self.assertEquals(bad_pwd_resp.content, json.dumps({'success': True, + 'value': "('registration/password_reset_done.html', [])"})) + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) def test_nonexist_email_password_reset(self): """Now test the exception cases with of reset_password called with invalid email.""" bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"}) bad_email_resp = password_reset(bad_email_req) + # Note: even if the email is bad, we return a successful response code + # This prevents someone potentially trying to "brute-force" find out which emails are and aren't registered with edX self.assertEquals(bad_email_resp.status_code, 200) - self.assertEquals(bad_email_resp.content, json.dumps({'success': False, - 'error': 'Invalid e-mail or user'})) + self.assertEquals(bad_email_resp.content, json.dumps({'success': True, + 'value': "('registration/password_reset_done.html', [])"})) @unittest.skipUnless(not settings.MITX_FEATURES.get('DISABLE_PASSWORD_RESET_EMAIL_TEST', False), dedent("""Skipping Test because CMS has not provided necessary templates for password reset. From 2e31ff8c35e75489cf89fa653af154052eac2fc8 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 14 Nov 2013 17:25:34 -0500 Subject: [PATCH 007/110] Recover from error loading forum thread list When a user attempts to load more threads in the forum navigation sidebar, reset the state of the world so the user can retry, and alert the user appropriately. --- CHANGELOG.rst | 2 ++ common/static/coffee/src/discussion/discussion.coffee | 6 +++--- .../src/discussion/views/discussion_thread_list_view.coffee | 6 +++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 69522b0584..46fc3f9cdf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +LMS: Add error recovery when a user loads more threads in the forum sidebar. + LMS: Add a user-visible alert modal when a forums AJAX request fails. Blades: Add template for checkboxes response to studio. BLD-193. diff --git a/common/static/coffee/src/discussion/discussion.coffee b/common/static/coffee/src/discussion/discussion.coffee index 5a52cd4de0..954dd80129 100644 --- a/common/static/coffee/src/discussion/discussion.coffee +++ b/common/static/coffee/src/discussion/discussion.coffee @@ -25,9 +25,8 @@ if Backbone? @add model model - retrieveAnotherPage: (mode, options={}, sort_options={})-> - @current_page += 1 - data = { page: @current_page } + retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)-> + data = { page: @current_page + 1 } switch mode when 'search' url = DiscussionUtil.urlFor 'search' @@ -59,6 +58,7 @@ if Backbone? @reset new_collection @pages = response.num_pages @current_page = response.page + error: error sortByDate: (thread) -> # diff --git a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee index 5a1e3fa9ae..ad527cf20b 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee @@ -156,7 +156,11 @@ if Backbone? $(".post-list a").first()?.focus() ) - @collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy}) + error = => + @renderThreads() + DiscussionUtil.discussionAlert("Sorry", "We had some trouble loading more threads. Please try again.") + + @collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy}, error) renderThread: (thread) => content = $(_.template($("#thread-list-item-template").html())(thread.toJSON())) From 95932610a7fe02fd416385314cfe23d4fbfacd9a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 14 Nov 2013 17:03:30 -0500 Subject: [PATCH 008/110] Add focus trap on forum navigation thread loading For accessibility purposes, it is bad to allow a user to initiate loading of additional threads in the navigation sidebar and then shift focus away from the sidebar, only to have focus snap back when the additional threads are loaded. Now, we trap focus on the loading element as recommended by our accessibility consultant. JIRA: FOR-238 --- CHANGELOG.rst | 3 +++ common/static/coffee/src/discussion/utils.coffee | 14 ++++++++------ .../views/discussion_thread_list_view.coffee | 5 ++++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 46fc3f9cdf..91eac8f611 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +LMS: Trap focus on the loading element when a user loads more threads +in the forum sidebar to improve accessibility. + LMS: Add error recovery when a user loads more threads in the forum sidebar. LMS: Add a user-visible alert modal when a forums AJAX request fails. diff --git a/common/static/coffee/src/discussion/utils.coffee b/common/static/coffee/src/discussion/utils.coffee index 73cfde8a06..89014c5f57 100644 --- a/common/static/coffee/src/discussion/utils.coffee +++ b/common/static/coffee/src/discussion/utils.coffee @@ -87,6 +87,13 @@ class @DiscussionUtil "notifications_status" : "/notification_prefs/status" }[name] + @makeFocusTrap: (elem) -> + elem.keydown( + (event) -> + if event.which == 9 # Tab + event.preventDefault() + ) + @discussionAlert: (header, body) -> if $("#discussion-alert").length == 0 alertDiv = $("" ) - # Capture focus - alertDiv.find("button").keydown( - (event) -> - if event.which == 9 # Tab - event.preventDefault() - ) + @makeFocusTrap(alertDiv.find("button")) alertTrigger = $("").css("display", "none") alertTrigger.leanModal({closeButton: "#discussion-alert .dismiss", overlay: 1, top: 200}) $("body").append(alertDiv).append(alertTrigger) diff --git a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee index ad527cf20b..57385c15bd 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee @@ -124,8 +124,11 @@ if Backbone? loadMorePages: (event) -> if event event.preventDefault() - @$(".more-pages").html('
Loading more threads
') + @$(".more-pages").html('
Loading more threads
') @$(".more-pages").addClass("loading") + loadingDiv = @$(".more-pages .loading-animation") + DiscussionUtil.makeFocusTrap(loadingDiv) + loadingDiv.focus() options = {} switch @mode when 'search' From e3b8ce708f6fd2431a5cd5e412f508a676585e9d Mon Sep 17 00:00:00 2001 From: RobertMarks Date: Mon, 18 Nov 2013 09:20:00 -0800 Subject: [PATCH 009/110] changes to allow multiple choicetextresponses in one problem --- common/lib/capa/capa/templates/choicetext.html | 2 +- common/static/js/capa/choicetextinput.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/common/lib/capa/capa/templates/choicetext.html b/common/lib/capa/capa/templates/choicetext.html index 5f587e214a..e74e9f71e5 100644 --- a/common/lib/capa/capa/templates/choicetext.html +++ b/common/lib/capa/capa/templates/choicetext.html @@ -55,7 +55,7 @@ % else: <% my_id = content_node.get('contents','') %> <% my_val = value.get(my_id,'') %> - + %endif ${content_node['tail_text']} diff --git a/common/static/js/capa/choicetextinput.js b/common/static/js/capa/choicetextinput.js index 4d7540f938..514e3f67f5 100644 --- a/common/static/js/capa/choicetextinput.js +++ b/common/static/js/capa/choicetextinput.js @@ -1,13 +1,13 @@ (function () { var update = function () { // Whenever a value changes create a new serialized version of this - // problem's inputs and set the hidden input fields value to equal it. - var parent = $(this).closest('.problems-wrapper'); + // problem's inputs and set the hidden input field's value to equal it. + var parent = $(this).closest('section.choicetextinput'); // find the closest parent problems-wrapper and use that as the problem // grab the input id from the input // real_input is the hidden input field var real_input = $('input.choicetextvalue', parent); - var all_inputs = $('.choicetextinput .ctinput', parent); + var all_inputs = $('input.ctinput', parent); var user_inputs = {}; $(all_inputs).each(function (index, elt) { var node = $(elt); From fc46efb6c77033d1af0ec06f2dadfb625783ec0c Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 19 Nov 2013 14:03:05 -0500 Subject: [PATCH 010/110] Fix bug in grabbing course enrollments. LMS-1475 --- lms/djangoapps/certificates/queue.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 8dffa7ee24..8ab9759b8c 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -10,6 +10,7 @@ from capa.xqueue_interface import make_xheader, make_hashkey from django.conf import settings from requests.auth import HTTPBasicAuth from student.models import UserProfile, CourseEnrollment +from verify_student.models import SoftwareSecurePhotoVerification import json import random @@ -174,7 +175,7 @@ class XQueueCertInterface(object): grade = grades.grade(student, self.request, course) is_whitelisted = self.whitelist.filter( user=student, course_id=course_id, whitelist=True).exists() - enrollment = CourseEnrollment.objects.get(user=student) + enrollment = CourseEnrollment.objects.get(user=student, course_id=course_id) org = course_id.split('/')[0] course_num = course_id.split('/')[1] if enrollment.mode == CertificateModes.verified: From 954ca83c9000327a15b99f05466d0e0322148cf9 Mon Sep 17 00:00:00 2001 From: danielcebrian Date: Tue, 19 Nov 2013 14:37:07 -0500 Subject: [PATCH 011/110] Update AUTHORS --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 9326b6781a..60ac912d49 100644 --- a/AUTHORS +++ b/AUTHORS @@ -97,3 +97,4 @@ Iain Dunning Olivier Marquez Florian Dufour Manuel Freire +Daniel Cebrián Robles From 87238e6d938e3b972b3404cfe3c00b2c74793e9f Mon Sep 17 00:00:00 2001 From: Julia Hansbrough Date: Tue, 19 Nov 2013 15:54:20 +0000 Subject: [PATCH 012/110] Removed null bits --- common/djangoapps/student/tests/tests.py | 2 +- common/djangoapps/student/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index b90ba3a165..9aa5ad8279 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -65,7 +65,7 @@ class ResetPasswordTests(TestCase): bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) bad_pwd_resp = password_reset(bad_pwd_req) - # If they've got an unusable password, fine, we should let them reset it + # If they've got an unusable password, we return a successful response code self.assertEquals(bad_pwd_resp.status_code, 200) self.assertEquals(bad_pwd_resp.content, json.dumps({'success': True, 'value': "('registration/password_reset_done.html', [])"})) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index d4a03dca37..1702d7145e 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1512,4 +1512,4 @@ def change_email_settings(request): log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(user.username, user.email, course_id)) track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard') - return HttpResponse(json.dumps({'success': True})) \ No newline at end of file + return HttpResponse(json.dumps({'success': True})) \ No newline at end of file From bcb5f1b3689bbf9b76e23997caa4f713cda8aea1 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 19 Nov 2013 16:34:01 -0500 Subject: [PATCH 013/110] Increased timeout for element count in HTML test --- .../features/component_settings_editor_helpers.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 5473438571..d3293c474e 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -6,14 +6,6 @@ from nose.tools import assert_equal, assert_in # pylint: disable=E0611 from terrain.steps import reload_the_page -def _is_expected_element_count(css, expected_number): - """ - Returns whether the number of elements found on the page by css locator - the same number that you expected. - """ - return len(world.css_find(css)) == expected_number - - @world.absorb def create_component_instance(step, category, component_type=None, is_advanced=False): """ @@ -47,8 +39,11 @@ def create_component_instance(step, category, component_type=None, is_advanced=F world.wait_for_invisible(component_button_css) click_component_from_menu(category, component_type, is_advanced) - world.wait_for(lambda _: _is_expected_element_count(module_css, - module_count_before + 1)) + expected_count = module_count_before + 1 + world.wait_for( + lambda _: len(world.css_find(module_css)) == expected_count, + timeout=20 + ) @world.absorb From 6e0140b65a91f30096c60f3870a455ab41a85260 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 19 Nov 2013 16:28:46 -0500 Subject: [PATCH 014/110] revises .gitignore file to include static css directories and remnants of devstack setup --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index b3f7473dc0..d864ecf7d0 100644 --- a/.gitignore +++ b/.gitignore @@ -48,14 +48,18 @@ reports/ .prereqs_cache .vagrant/ node_modules +.bundle/ +bin/ ### Static assets pipeline artifacts *.scssc +lms/static/css/ lms/static/sass/*.css lms/static/sass/application.scss lms/static/sass/application-extend1.scss lms/static/sass/application-extend2.scss lms/static/sass/course.scss +cms/static/css/ cms/static/sass/*.css ### Logging artifacts From e7c06e3ab17b77e1be57ede079835130db9c21bf Mon Sep 17 00:00:00 2001 From: cahrens Date: Fri, 15 Nov 2013 11:20:12 -0500 Subject: [PATCH 015/110] Change preview view method to use RESTful URL. STUD-848 --- .../contentstore/tests/test_contentstore.py | 39 +++++++++------- .../contentstore/views/component.py | 9 ++-- cms/djangoapps/contentstore/views/item.py | 28 +++++++++++- cms/djangoapps/contentstore/views/preview.py | 45 ++++--------------- cms/djangoapps/contentstore/views/tabs.py | 9 ++-- .../coffee/spec/views/module_edit_spec.coffee | 11 ++--- .../coffee/src/views/module_edit.coffee | 6 +-- cms/static/coffee/src/views/tabs.coffee | 3 +- cms/static/coffee/src/views/unit.coffee | 1 - cms/templates/edit-tabs.html | 4 +- cms/templates/unit.html | 4 +- cms/urls.py | 1 - 12 files changed, 74 insertions(+), 86 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 58cf3be70b..2b773e991d 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -454,31 +454,36 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): @override_settings(COURSES_WITH_UNSAFE_CODE=['edX/toy/.*']) def test_module_preview_in_whitelist(self): - ''' + """ Tests the ajax callback to render an XModule - ''' - direct_store = modulestore('direct') - import_from_xml(direct_store, 'common/test/data/', ['toy']) - - # also try a custom response which will trigger the 'is this course in whitelist' logic - problem_module_location = Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]) - url = reverse('preview_component', kwargs={'location': problem_module_location.url()}) - resp = self.client.get_html(url) - self.assertEqual(resp.status_code, 200) + """ + resp = self._test_preview(Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None])) + # These are the data-ids of the xblocks contained in the vertical. + # Ultimately, these must be converted to new locators. + self.assertContains(resp, 'i4x://edX/toy/video/sample_video') + self.assertContains(resp, 'i4x://edX/toy/video/separate_file_video') + self.assertContains(resp, 'i4x://edX/toy/video/video_with_end_time') + self.assertContains(resp, 'i4x://edX/toy/poll_question/T1_changemind_poll_foo_2') def test_video_module_caption_asset_path(self): - ''' + """ This verifies that a video caption url is as we expect it to be - ''' + """ + resp = self._test_preview(Location(['i4x', 'edX', 'toy', 'video', 'sample_video', None])) + self.assertContains(resp, 'data-caption-asset-path="/c4x/edX/toy/asset/subs_"') + + def _test_preview(self, location): + """ Preview test case. """ direct_store = modulestore('direct') - import_from_xml(direct_store, 'common/test/data/', ['toy']) + _, course_items = import_from_xml(direct_store, 'common/test/data/', ['toy']) # also try a custom response which will trigger the 'is this course in whitelist' logic - video_module_location = Location(['i4x', 'edX', 'toy', 'video', 'sample_video', None]) - url = reverse('preview_component', kwargs={'location': video_module_location.url()}) - resp = self.client.get_html(url) + locator = loc_mapper().translate_location( + course_items[0].location.course_id, location, False, True + ) + resp = self.client.get_html(locator.url_reverse('xblock')) self.assertEqual(resp.status_code, 200) - self.assertContains(resp, 'data-caption-asset-path="/c4x/edX/toy/asset/subs_"') + return resp def test_delete(self): direct_store = modulestore('direct') diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 327e75c7f4..70d7bef7ce 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -249,12 +249,9 @@ def edit_unit(request, location): ) components = [ - [ - component.location.url(), - loc_mapper().translate_location( - course.location.course_id, component.location, False, True - ) - ] + loc_mapper().translate_location( + course.location.course_id, component.location, False, True + ) for component in item.get_children() ] diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 97bfde3b82..d33d00377b 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -3,7 +3,9 @@ import logging from uuid import uuid4 +from functools import partial from static_replace import replace_static_urls +from xmodule_modifiers import wrap_xblock from django.core.exceptions import PermissionDenied from django.contrib.auth.decorators import login_required @@ -27,6 +29,8 @@ from xmodule.modulestore.locator import BlockUsageLocator from student.models import CourseEnrollment from django.http import HttpResponseBadRequest from xblock.fields import Scope +from preview import handler_prefix, get_preview_html +from mitxmako.shortcuts import render_to_response, render_to_string __all__ = ['orphan_handler', 'xblock_handler'] @@ -51,6 +55,7 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= all children and "all_versions" to delete from all (mongo) versions. GET json: returns representation of the xblock (locator id, data, and metadata). + html: returns HTML for rendering the xblock (which includes both the "preview" view and the "editor" view) PUT or POST json: if xblock location is specified, update the xblock instance. The json payload can contain these fields, all optional: @@ -76,8 +81,27 @@ def xblock_handler(request, tag=None, course_id=None, branch=None, version_guid= old_location = loc_mapper().translate_locator_to_location(location) if request.method == 'GET': - rsp = _get_module_info(location) - return JsonResponse(rsp) + if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): + rsp = _get_module_info(location) + return JsonResponse(rsp) + else: + component = modulestore().get_item(old_location) + # Wrap the generated fragment in the xmodule_editor div so that the javascript + # can bind to it correctly + component.runtime.wrappers.append(partial(wrap_xblock, handler_prefix)) + + try: + content = component.render('studio_view').content + # catch exceptions indiscriminately, since after this point they escape the + # dungeon and surface as uneditable, unsaveable, and undeletable + # component-goblins. + except Exception as exc: # pylint: disable=W0703 + content = render_to_string('html_error.html', {'message': str(exc)}) + + return render_to_response('component.html', { + 'preview': get_preview_html(request, component), + 'editor': content + }) elif request.method == 'DELETE': delete_children = str_to_bool(request.REQUEST.get('recurse', 'False')) delete_all_versions = str_to_bool(request.REQUEST.get('all_versions', 'False')) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 6e86a6485b..3b2ec85326 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -3,7 +3,7 @@ from functools import partial from django.conf import settings from django.core.urlresolvers import reverse -from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden +from django.http import Http404, HttpResponseBadRequest from django.contrib.auth.decorators import login_required from mitxmako.shortcuts import render_to_response, render_to_string @@ -24,10 +24,9 @@ from util.sandboxing import can_execute_unsafe_code import static_replace from .session_kv_store import SessionKeyValueStore from .helpers import render_from_lms -from .access import has_access from ..utils import get_course_for_item -__all__ = ['preview_handler', 'preview_component'] +__all__ = ['preview_handler'] log = logging.getLogger(__name__) @@ -53,13 +52,13 @@ def preview_handler(request, usage_id, handler, suffix=''): usage_id: The usage-id of the block to dispatch to, passed through `quote_slashes` handler: The handler to execute - suffix: The remaineder of the url to be passed to the handler + suffix: The remainder of the url to be passed to the handler """ location = unquote_slashes(usage_id) descriptor = modulestore().get_item(location) - instance = load_preview_module(request, descriptor) + instance = _load_preview_module(request, descriptor) # Let the module handle the AJAX req = django_to_webob_request(request) try: @@ -85,32 +84,6 @@ def preview_handler(request, usage_id, handler, suffix=''): return webob_to_django_response(resp) -@login_required -def preview_component(request, location): - "Return the HTML preview of a component" - # TODO (vshnayder): change name from id to location in coffee+html as well. - if not has_access(request.user, location): - return HttpResponseForbidden() - - component = modulestore().get_item(location) - # Wrap the generated fragment in the xmodule_editor div so that the javascript - # can bind to it correctly - component.runtime.wrappers.append(partial(wrap_xblock, handler_prefix)) - - try: - content = component.render('studio_view').content - # catch exceptions indiscriminately, since after this point they escape the - # dungeon and surface as uneditable, unsaveable, and undeletable - # component-goblins. - except Exception as exc: # pylint: disable=W0703 - content = render_to_string('html_error.html', {'message': str(exc)}) - - return render_to_response('component.html', { - 'preview': get_preview_html(request, component), - 'editor': content - }) - - class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method """ An XModule ModuleSystem for use in Studio previews @@ -119,7 +92,7 @@ class PreviewModuleSystem(ModuleSystem): # pylint: disable=abstract-method return handler_prefix(block, handler_name, suffix) + '?' + query -def preview_module_system(request, descriptor): +def _preview_module_system(request, descriptor): """ Returns a ModuleSystem for the specified descriptor that is specialized for rendering module previews. @@ -135,7 +108,7 @@ def preview_module_system(request, descriptor): # TODO (cpennington): Do we want to track how instructors are using the preview problems? track_function=lambda event_type, event: None, filestore=descriptor.runtime.resources_fs, - get_module=partial(load_preview_module, request), + get_module=partial(_load_preview_module, request), render_template=render_from_lms, debug=True, replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id), @@ -162,7 +135,7 @@ def preview_module_system(request, descriptor): ) -def load_preview_module(request, descriptor): +def _load_preview_module(request, descriptor): """ Return a preview XModule instantiated from the supplied descriptor. @@ -171,7 +144,7 @@ def load_preview_module(request, descriptor): """ student_data = DbModel(SessionKeyValueStore(request)) descriptor.bind_for_student( - preview_module_system(request, descriptor), + _preview_module_system(request, descriptor), LmsFieldData(descriptor._field_data, student_data), # pylint: disable=protected-access ) return descriptor @@ -182,7 +155,7 @@ def get_preview_html(request, descriptor): Returns the HTML returned by the XModule's student_view, specified by the descriptor and idx. """ - module = load_preview_module(request, descriptor) + module = _load_preview_module(request, descriptor) try: content = module.render("student_view").content except Exception as exc: # pylint: disable=W0703 diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index 277445e3b9..40ea9bfd6b 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -125,12 +125,9 @@ def edit_tabs(request, org, course, coursename): static_tabs.append(modulestore('direct').get_item(static_tab_loc)) components = [ - [ - static_tab.location.url(), - loc_mapper().translate_location( - course_item.location.course_id, static_tab.location, False, True - ) - ] + loc_mapper().translate_location( + course_item.location.course_id, static_tab.location, False, True + ) for static_tab in static_tabs ] diff --git a/cms/static/coffee/spec/views/module_edit_spec.coffee b/cms/static/coffee/spec/views/module_edit_spec.coffee index 22d1052fa3..36716668d3 100644 --- a/cms/static/coffee/spec/views/module_edit_spec.coffee +++ b/cms/static/coffee/spec/views/module_edit_spec.coffee @@ -1,12 +1,9 @@ -define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) -> +define ["coffee/src/views/module_edit", "js/models/module_info", "xmodule"], (ModuleEdit, ModuleModel) -> describe "ModuleEdit", -> beforeEach -> - @stubModule = jasmine.createSpy("Module") - @stubModule.id = 'stub-id' - @stubModule.get = (param)-> - if param == 'old_id' - return 'stub-old-id' + @stubModule = new ModuleModel + id: "stub-id" setFixtures """
  • @@ -62,7 +59,7 @@ define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) -> @moduleEdit.render() it "loads the module preview and editor via ajax on the view element", -> - expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/preview_component/#{@moduleEdit.model.get('old_id')}", jasmine.any(Function)) + expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/xblock/#{@moduleEdit.model.id}", jasmine.any(Function)) @moduleEdit.$el.load.mostRecentCall.args[1]() expect(@moduleEdit.loadDisplay).toHaveBeenCalled() expect(@moduleEdit.delegateEvents).toHaveBeenCalled() diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index a13e572887..729a17615e 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -69,15 +69,13 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1", payload (data) => @model.set(id: data.locator) - @model.set(old_id: data.id) - @$el.data('id', data.id) @$el.data('locator', data.locator) @render() ) render: -> - if @model.get('old_id') - @$el.load("/preview_component/#{@model.get('old_id')}", => + if @model.id + @$el.load(@model.url(), => @loadDisplay() @delegateEvents() ) diff --git a/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee index 0f72e8bddb..a85b3b7863 100644 --- a/cms/static/coffee/src/views/tabs.coffee +++ b/cms/static/coffee/src/views/tabs.coffee @@ -6,8 +6,7 @@ define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views initialize: => @$('.component').each((idx, element) => model = new ModuleModel({ - id: $(element).data('locator'), - old_id:$(element).data('id') + id: $(element).data('locator') }) new ModuleEditView( diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index 075b56d1b0..e6ba0e1382 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -63,7 +63,6 @@ define ["jquery", "jquery.ui", "gettext", "backbone", @$('.component').each (idx, element) => model = new ModuleModel id: $(element).data('locator') - old_id: $(element).data('id') new ModuleEditView el: element, onDelete: @deleteComponent, diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html index f6b8e7b77a..54a30217ea 100644 --- a/cms/templates/edit-tabs.html +++ b/cms/templates/edit-tabs.html @@ -61,8 +61,8 @@ require(["backbone", "coffee/src/views/tabs"], function(Backbone, TabsEditView)
      - % for id, locator in components: -
    1. + % for locator in components: +
    2. % endfor
    3. diff --git a/cms/templates/unit.html b/cms/templates/unit.html index cc7827c7d3..e83dd45da0 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -48,8 +48,8 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"

        - % for id, locator in components: -
      1. + % for locator in components: +
      2. % endfor
      3. diff --git a/cms/urls.py b/cms/urls.py index 99e9cbfaba..343e9f4d04 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -14,7 +14,6 @@ urlpatterns = patterns('', # nopep8 url(r'^$', 'contentstore.views.howitworks', name='homepage'), url(r'^edit/(?P.*?)$', 'contentstore.views.edit_unit', name='edit_unit'), url(r'^subsection/(?P.*?)$', 'contentstore.views.edit_subsection', name='edit_subsection'), - url(r'^preview_component/(?P.*?)$', 'contentstore.views.preview_component', name='preview_component'), url(r'^transcripts/upload$', 'contentstore.views.upload_transcripts', name='upload_transcripts'), url(r'^transcripts/download$', 'contentstore.views.download_transcripts', name='download_transcripts'), From 16568766994bc77b0f543dfdf4cbed5e39a51ecc Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 19 Nov 2013 17:12:49 -0500 Subject: [PATCH 016/110] If student has not passed verification, issue an honor code cert. Also, display a message on their dashboard. --- lms/djangoapps/certificates/models.py | 9 +++++---- lms/djangoapps/certificates/queue.py | 11 ++++++++--- .../dashboard/_dashboard_certificate_information.html | 8 +++++++- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 36ff18618e..4e948d4b06 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -93,9 +93,9 @@ class GeneratedCertificate(models.Model): mode = models.CharField(max_length=32, default=CertificateModes.honor) name = models.CharField(blank=True, max_length=255) created_date = models.DateTimeField( - auto_now_add=True, default=datetime.now) + auto_now_add=True, default=datetime.now) modified_date = models.DateTimeField( - auto_now=True, default=datetime.now) + auto_now=True, default=datetime.now) error_reason = models.CharField(max_length=512, blank=True, default='') class Meta: @@ -133,8 +133,9 @@ def certificate_status_for_student(student, course_id): try: generated_certificate = GeneratedCertificate.objects.get( - user=student, course_id=course_id) - d = {'status': generated_certificate.status} + user=student, course_id=course_id) + d = {'status': generated_certificate.status, + 'mode': generated_certificate.mode} if generated_certificate.grade: d['grade'] = generated_certificate.grade if generated_certificate.status == CertificateStatuses.downloadable: diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 8ab9759b8c..03a0947aa9 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -178,9 +178,15 @@ class XQueueCertInterface(object): enrollment = CourseEnrollment.objects.get(user=student, course_id=course_id) org = course_id.split('/')[0] course_num = course_id.split('/')[1] - if enrollment.mode == CertificateModes.verified: + cert_mode = enrollment.mode + if enrollment.mode == CertificateModes.verified and SoftwareSecurePhotoVerification.user_is_verified(student): template_pdf = "certificate-template-{0}-{1}-verified.pdf".format( org, course_num) + elif (enrollment.mode == CertificateModes.verified and not + SoftwareSecurePhotoVerification.user_is_verified(student)): + template_pdf = "certificate-template-{0}-{1}.pdf".format( + org, course_num) + cert_mode = CertificateModes.honor else: # honor code and audit students template_pdf = "certificate-template-{0}-{1}.pdf".format( @@ -189,8 +195,7 @@ class XQueueCertInterface(object): cert, created = GeneratedCertificate.objects.get_or_create( user=student, course_id=course_id) - cert.mode = enrollment.mode - + cert.mode = cert_mode cert.user = student cert.grade = grade['percent'] cert.course_id = course_id diff --git a/lms/templates/dashboard/_dashboard_certificate_information.html b/lms/templates/dashboard/_dashboard_certificate_information.html index ea5171c0ef..3222b6aae8 100644 --- a/lms/templates/dashboard/_dashboard_certificate_information.html +++ b/lms/templates/dashboard/_dashboard_certificate_information.html @@ -19,7 +19,7 @@ else: % elif cert_status['status'] in ('generating', 'ready', 'notpassing', 'restricted'):

        ${_("Your final grade:")} ${"{0:.0f}%".format(float(cert_status['grade'])*100)}. - % if cert_status['status'] == 'notpassing': + % if cert_status['status'] == 'notpassing' and enrollment.mode != 'audit': ${_("Grade required for a certificate:")} ${"{0:.0f}%".format(float(course.lowest_passing_grade)*100)}. % elif cert_status['status'] == 'restricted' and enrollment.mode == 'verified': @@ -44,6 +44,12 @@ else: ${_("Download Your Certificate (PDF)")}

      4. + % elif cert_status['show_download_url'] and enrollment.mode == 'verified' and cert_status['mode'] == 'honor': +
      5. +

        ${_('Since we did not have a valid set of verification photos from you when certificates were generated, we could not grant you a verified certificate. An honor code certificate has been granted instead.')}

        +
        + ${_("Download Your Certificate (PDF)")}
      6. % elif cert_status['show_download_url'] and enrollment.mode == 'verified':
      7. Date: Tue, 19 Nov 2013 17:30:27 -0500 Subject: [PATCH 017/110] Use class methods to find the enrollment mode. --- lms/djangoapps/certificates/queue.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 03a0947aa9..0c07ed6e6f 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -175,14 +175,14 @@ class XQueueCertInterface(object): grade = grades.grade(student, self.request, course) is_whitelisted = self.whitelist.filter( user=student, course_id=course_id, whitelist=True).exists() - enrollment = CourseEnrollment.objects.get(user=student, course_id=course_id) + enrollment_mode = CourseEnrollment.enrollment_mode_for_user(student, course_id) org = course_id.split('/')[0] course_num = course_id.split('/')[1] - cert_mode = enrollment.mode - if enrollment.mode == CertificateModes.verified and SoftwareSecurePhotoVerification.user_is_verified(student): + cert_mode = enrollment_mode + if enrollment_mode == CertificateModes.verified and SoftwareSecurePhotoVerification.user_is_verified(student): template_pdf = "certificate-template-{0}-{1}-verified.pdf".format( org, course_num) - elif (enrollment.mode == CertificateModes.verified and not + elif (enrollment_mode == CertificateModes.verified and not SoftwareSecurePhotoVerification.user_is_verified(student)): template_pdf = "certificate-template-{0}-{1}.pdf".format( org, course_num) From cb113deade37141e65c2bceac2ddf3fff5ca10e0 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Fri, 11 Oct 2013 17:37:04 -0400 Subject: [PATCH 018/110] Separate all db ops from modulestore ops --- .../xmodule/modulestore/split_migrator.py | 2 +- .../split_mongo/definition_lazy_loader.py | 3 +- .../split_mongo/mongo_connection.py | 116 +++++++++++++++++ .../xmodule/modulestore/split_mongo/split.py | 122 ++++++------------ .../modulestore/tests/test_split_migrator.py | 6 +- .../tests/test_split_modulestore.py | 34 ++--- 6 files changed, 170 insertions(+), 113 deletions(-) create mode 100644 common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py diff --git a/common/lib/xmodule/xmodule/modulestore/split_migrator.py b/common/lib/xmodule/xmodule/modulestore/split_migrator.py index 27a758c083..355f5bfa62 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_migrator.py +++ b/common/lib/xmodule/xmodule/modulestore/split_migrator.py @@ -88,7 +88,7 @@ class SplitMigrator(object): index_info = self.split_modulestore.get_course_index_info(course_version_locator) versions = index_info['versions'] versions['draft'] = versions['published'] - self.split_modulestore.update_course_index(course_version_locator, {'versions': versions}, update_versions=True) + self.split_modulestore.update_course_index(index_info) # clean up orphans in published version: in old mongo, parents pointed to the union of their published and draft # children which meant some pointers were to non-existent locations in 'direct' diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py index 9b2a652a95..ded67104b4 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/definition_lazy_loader.py @@ -22,5 +22,4 @@ class DefinitionLazyLoader(object): Fetch the definition. Note, the caller should replace this lazy loader pointer with the result so as not to fetch more than once """ - return self.modulestore.definitions.find_one( - {'_id': self.definition_locator.definition_id}) + return self.modulestore.db_connection.get_definition(self.definition_locator.definition_id) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py new file mode 100644 index 0000000000..510c100048 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/mongo_connection.py @@ -0,0 +1,116 @@ +""" +Segregation of pymongo functions from the data modeling mechanisms for split modulestore. +""" +import pymongo + +class MongoConnection(object): + """ + Segregation of pymongo functions from the data modeling mechanisms for split modulestore. + """ + def __init__( + self, db, collection, host, port=27017, tz_aware=True, user=None, password=None, **kwargs + ): + """ + Create & open the connection, authenticate, and provide pointers to the collections + """ + self.database = pymongo.database.Database( + pymongo.MongoClient( + host=host, + port=port, + tz_aware=tz_aware, + **kwargs + ), + db + ) + + if user is not None and password is not None: + self.database.authenticate(user, password) + + self.course_index = self.database[collection + '.active_versions'] + self.structures = self.database[collection + '.structures'] + self.definitions = self.database[collection + '.definitions'] + + # every app has write access to the db (v having a flag to indicate r/o v write) + # Force mongo to report errors, at the expense of performance + # pymongo docs suck but explanation: + # http://api.mongodb.org/java/2.10.1/com/mongodb/WriteConcern.html + self.course_index.write_concern = {'w': 1} + self.structures.write_concern = {'w': 1} + self.definitions.write_concern = {'w': 1} + + def get_structure(self, key): + """ + Get the structure from the persistence mechanism whose id is the given key + """ + return self.structures.find_one({'_id': key}) + + def find_matching_structures(self, query): + """ + Find the structure matching the query. Right now the query must be a legal mongo query + :param query: a mongo-style query of {key: [value|{$in ..}|..], ..} + """ + return self.structures.find(query) + + def insert_structure(self, structure): + """ + Create the structure in the db + """ + self.structures.insert(structure) + + def update_structure(self, structure): + """ + Update the db record for structure + """ + self.structures.update({'_id': structure['_id']}, structure) + + def get_course_index(self, key): + """ + Get the course_index from the persistence mechanism whose id is the given key + """ + return self.course_index.find_one({'_id': key}) + + def find_matching_course_indexes(self, query): + """ + Find the course_index matching the query. Right now the query must be a legal mongo query + :param query: a mongo-style query of {key: [value|{$in ..}|..], ..} + """ + return self.course_index.find(query) + + def insert_course_index(self, course_index): + """ + Create the course_index in the db + """ + self.course_index.insert(course_index) + + def update_course_index(self, course_index): + """ + Update the db record for course_index + """ + self.course_index.update({'_id': course_index['_id']}, course_index) + + def delete_course_index(self, key): + """ + Delete the course_index from the persistence mechanism whose id is the given key + """ + return self.course_index.remove({'_id': key}) + + def get_definition(self, key): + """ + Get the definition from the persistence mechanism whose id is the given key + """ + return self.definitions.find_one({'_id': key}) + + def find_matching_definitions(self, query): + """ + Find the definitions matching the query. Right now the query must be a legal mongo query + :param query: a mongo-style query of {key: [value|{$in ..}|..], ..} + """ + return self.definitions.find(query) + + def insert_definition(self, definition): + """ + Create the definition in the db + """ + self.definitions.insert(definition) + + diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index 6152416596..a6349e6113 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -1,7 +1,6 @@ import threading import datetime import logging -import pymongo import re from importlib import import_module from path import path @@ -21,6 +20,7 @@ from .caching_descriptor_system import CachingDescriptorSystem from xblock.fields import Scope from xblock.runtime import Mixologist from bson.objectid import ObjectId +from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection log = logging.getLogger(__name__) #============================================================================== @@ -49,7 +49,6 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): A Mongodb backed ModuleStore supporting versions, inheritance, and sharing. """ - # pylint: disable=W0201 def __init__(self, doc_store_config, fs_root, render_template, default_class=None, error_tracker=null_error_tracker, @@ -62,44 +61,13 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): super(SplitMongoModuleStore, self).__init__(**kwargs) self.loc_mapper = loc_mapper - def do_connection( - db, collection, host, port=27017, tz_aware=True, user=None, password=None, **kwargs - ): - """ - Create & open the connection, authenticate, and provide pointers to the collections - """ - self.db = pymongo.database.Database( - pymongo.MongoClient( - host=host, - port=port, - tz_aware=tz_aware, - **kwargs - ), - db - ) - - if user is not None and password is not None: - self.db.authenticate(user, password) - - self.course_index = self.db[collection + '.active_versions'] - self.structures = self.db[collection + '.structures'] - self.definitions = self.db[collection + '.definitions'] - - do_connection(**doc_store_config) + self.db_connection = MongoConnection(**doc_store_config) + self.db = self.db_connection.database # Code review question: How should I expire entries? # _add_cache could use a lru mechanism to control the cache size? self.thread_cache = threading.local() - - # every app has write access to the db (v having a flag to indicate r/o v write) - # Force mongo to report errors, at the expense of performance - # pymongo docs suck but explanation: - # http://api.mongodb.org/java/2.10.1/com/mongodb/WriteConcern.html - self.course_index.write_concern = {'w': 1} - self.structures.write_concern = {'w': 1} - self.definitions.write_concern = {'w': 1} - if default_class is not None: module_path, _, class_name = default_class.rpartition('.') class_ = getattr(import_module(module_path), class_name) @@ -138,7 +106,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): block['definition'] = DefinitionLazyLoader(self, block['definition']) else: # Load all descendants by id - descendent_definitions = self.definitions.find({ + descendent_definitions = self.db_connection.find_matching_definitions({ '_id': {'$in': [block['definition'] for block in new_module_data.itervalues()]}}) # turn into a map @@ -226,7 +194,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): if course_locator.course_id is not None and course_locator.branch is not None: # use the course_id - index = self.course_index.find_one({'_id': course_locator.course_id}) + index = self.db_connection.get_course_index(course_locator.course_id) if index is None: raise ItemNotFoundError(course_locator) if course_locator.branch not in index['versions']: @@ -241,7 +209,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): # cast string to ObjectId if necessary version_guid = course_locator.as_object_id(version_guid) - entry = self.structures.find_one({'_id': version_guid}) + entry = self.db_connection.get_structure(version_guid) # b/c more than one course can use same structure, the 'course_id' and 'branch' are not intrinsic to structure # and the one assoc'd w/ it by another fetch may not be the one relevant to this fetch; so, @@ -269,7 +237,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): if qualifiers is None: qualifiers = {} qualifiers.update({"versions.{}".format(branch): {"$exists": True}}) - matching = self.course_index.find(qualifiers) + matching = self.db_connection.find_matching_course_indexes(qualifiers) # collect ids and then query for those version_guids = [] @@ -279,7 +247,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): version_guids.append(version_guid) id_version_map[version_guid] = structure['_id'] - course_entries = self.structures.find({'_id': {'$in': version_guids}}) + course_entries = self.db_connection.find_matching_structures({'_id': {'$in': version_guids}}) # get the block for the course element (s/b the root) result = [] @@ -455,7 +423,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): """ if course_locator.course_id is None: return None - index = self.course_index.find_one({'_id': course_locator.course_id}) + index = self.db_connection.get_course_index(course_locator.course_id) return index # TODO figure out a way to make this info accessible from the course descriptor @@ -487,7 +455,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): 'edited_on': when the change was made } """ - definition = self.definitions.find_one({'_id': definition_locator.definition_id}) + definition = self.db_connection.get_definition(definition_locator.definition_id) if definition is None: return None return definition['edit_info'] @@ -509,14 +477,14 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): # TODO if depth is significant, it may make sense to get all that have the same original_version # and reconstruct the subtree from version_guid - next_entries = self.structures.find({'previous_version' : version_guid}) + next_entries = self.db_connection.find_matching_structures({'previous_version' : version_guid}) # must only scan cursor's once next_versions = [struct for struct in next_entries] result = {version_guid: [CourseLocator(version_guid=struct['_id']) for struct in next_versions]} depth = 1 while depth < version_history_depth and len(next_versions) > 0: depth += 1 - next_entries = self.structures.find({'previous_version': + next_entries = self.db_connection.find_matching_structures({'previous_version': {'$in': [struct['_id'] for struct in next_versions]}}) next_versions = [struct for struct in next_entries] for course_structure in next_versions: @@ -537,7 +505,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): course_struct = self._lookup_course(block_locator.version_agnostic())['structure'] usage_id = block_locator.usage_id update_version_field = 'blocks.{}.edit_info.update_version'.format(usage_id) - all_versions_with_block = self.structures.find({'original_version': course_struct['original_version'], + all_versions_with_block = self.db_connection.find_matching_structures({'original_version': course_struct['original_version'], update_version_field: {'$exists': True}}) # find (all) root versions and build map previous: [successors] possible_roots = [] @@ -596,7 +564,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): "original_version": new_id, } } - self.definitions.insert(document) + self.db_connection.insert_definition(document) definition_locator = DefinitionLocator(new_id) return definition_locator @@ -618,7 +586,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): # if this looks in cache rather than fresh fetches, then it will probably not detect # actual change b/c the descriptor and cache probably point to the same objects - old_definition = self.definitions.find_one({'_id': definition_locator.definition_id}) + old_definition = self.db_connection.get_definition(definition_locator.definition_id) if old_definition is None: raise ItemNotFoundError(definition_locator.url()) @@ -630,7 +598,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): old_definition['edit_info']['edited_on'] = datetime.datetime.now(UTC) # previous version id old_definition['edit_info']['previous_version'] = definition_locator.definition_id - self.definitions.insert(old_definition) + self.db_connection.insert_definition(old_definition) return DefinitionLocator(old_definition['_id']), True else: return definition_locator, False @@ -657,7 +625,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): :param course_blocks: the current list of blocks. :param category: """ - existing_uses = self.course_index.find({"_id": {"$regex": id_root}}) + existing_uses = self.db_connection.find_matching_course_indexes({"_id": {"$regex": id_root}}) if existing_uses.count() > 0: max_found = 0 matcher = re.compile(id_root + r'(\d+)') @@ -779,11 +747,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): parent['edit_info']['update_version'] = new_id if continue_version: # db update - self.structures.update({'_id': new_id}, new_structure) + self.db_connection.update_structure(new_structure) # clear cache so things get refetched and inheritance recomputed self._clear_cache(new_id) else: - self.structures.insert(new_structure) + self.db_connection.insert_structure(new_structure) # update the index entry if appropriate if index_entry is not None: @@ -856,7 +824,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): 'original_version': definition_id, } } - self.definitions.insert(definition_entry) + self.db_connection.insert_definition(definition_entry) new_id = ObjectId() draft_structure = { @@ -880,7 +848,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): } } } - self.structures.insert(draft_structure) + self.db_connection.insert_structure(draft_structure) if versions_dict is None: versions_dict = {master_branch: new_id} @@ -898,20 +866,20 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): if block_fields is not None: root_block['fields'].update(block_fields) if definition_fields is not None: - definition = self.definitions.find_one({'_id': root_block['definition']}) + definition = self.db_connection.get_definition(root_block['definition']) definition['fields'].update(definition_fields) definition['edit_info']['previous_version'] = definition['_id'] definition['edit_info']['edited_by'] = user_id definition['edit_info']['edited_on'] = datetime.datetime.now(UTC) definition['_id'] = ObjectId() - self.definitions.insert(definition) + self.db_connection.insert_definition(definition) root_block['definition'] = definition['_id'] root_block['edit_info']['edited_on'] = datetime.datetime.now(UTC) root_block['edit_info']['edited_by'] = user_id root_block['edit_info']['previous_version'] = root_block['edit_info'].get('update_version') root_block['edit_info']['update_version'] = new_id - self.structures.insert(draft_structure) + self.db_connection.insert_structure(draft_structure) versions_dict[master_branch] = new_id # create the index entry @@ -926,7 +894,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): 'edited_by': user_id, 'edited_on': datetime.datetime.now(UTC), 'versions': versions_dict} - self.course_index.insert(index_entry) + self.db_connection.insert_course_index(index_entry) return self.get_course(CourseLocator(course_id=new_id, branch=master_branch)) def update_item(self, descriptor, user_id, force=False): @@ -978,7 +946,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): 'previous_version': block_data['edit_info']['update_version'], 'update_version': new_id, } - self.structures.insert(new_structure) + self.db_connection.insert_structure(new_structure) # update the index entry if appropriate if index_entry is not None: self._update_head(index_entry, descriptor.location.branch, new_id) @@ -1016,7 +984,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): is_updated = self._persist_subdag(xblock, user_id, new_structure['blocks'], new_id) if is_updated: - self.structures.insert(new_structure) + self.db_connection.insert_structure(new_structure) # update the index entry if appropriate if index_entry is not None: @@ -1115,31 +1083,18 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): '''Deprecated, use update_item.''' raise NotImplementedError('use update_item') - def update_course_index(self, course_locator, new_values_dict, update_versions=False): + def update_course_index(self, updated_index_entry): """ - Change the given course's index entry for the given fields. new_values_dict - should be a subset of the dict returned by get_course_index_info. - It cannot include '_id' (will raise IllegalArgument). - Provide update_versions=True if you intend this to replace the versions hash. + Change the given course's index entry. + Note, this operation can be dangerous and break running courses. - If the dict includes versions and not update_versions, it will raise an exception. - - If the dict includes edited_on or edited_by, it will raise an exception - Does not return anything useful. """ # TODO how should this log the change? edited_on and edited_by for this entry # has the semantic of who created the course and when; so, changing those will lose # that information. - if '_id' in new_values_dict: - raise ValueError("Cannot override _id") - if 'edited_on' in new_values_dict or 'edited_by' in new_values_dict: - raise ValueError("Cannot set edited_on or edited_by") - if not update_versions and 'versions' in new_values_dict: - raise ValueError("Cannot override versions without setting update_versions") - self.course_index.update({'_id': course_locator.course_id}, - {'$set': new_values_dict}) + self.db_connection.update_course_index(updated_index_entry) def delete_item(self, usage_locator, user_id, delete_children=False, force=False): """ @@ -1182,7 +1137,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): remove_subtree(usage_locator.usage_id) # update index if appropriate and structures - self.structures.insert(new_structure) + self.db_connection.insert_structure(new_structure) result = CourseLocator(version_guid=new_id) @@ -1204,11 +1159,11 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): :param course_id: uses course_id rather than locator to emphasize its global effect """ - index = self.course_index.find_one({'_id': course_id}) + index = self.db_connection.get_course_index(course_id) if index is None: raise ItemNotFoundError(course_id) # this is the only real delete in the system. should it do something else? - self.course_index.remove(index['_id']) + self.db_connection.delete_course_index(index['_id']) def get_errored_courses(self): """ @@ -1296,7 +1251,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): block['fields']["children"] = [ usage_id for usage_id in block['fields']["children"] if usage_id in original_structure['blocks'] ] - self.structures.update({'_id': original_structure['_id']}, original_structure) + self.db_connection.update_structure(original_structure) # clear cache again b/c inheritance may be wrong over orphans self._clear_cache(original_structure['_id']) @@ -1379,7 +1334,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): else: return None else: - index_entry = self.course_index.find_one({'_id': locator.course_id}) + index_entry = self.db_connection.get_course_index(locator.course_id) is_head = ( locator.version_guid is None or index_entry['versions'][locator.branch] == locator.version_guid @@ -1424,9 +1379,8 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): :param course_locator: :param new_id: """ - self.course_index.update( - {"_id": index_entry["_id"]}, - {"$set": {"versions.{}".format(branch): new_id}}) + index_entry['versions'][branch] = new_id + self.db_connection.update_course_index(index_entry) def _partition_fields_by_scope(self, category, fields): """ diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py index 049fbd2ef8..74975f2896 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py @@ -59,9 +59,9 @@ class TestMigration(unittest.TestCase): dbref = self.loc_mapper.db dbref.drop_collection(self.loc_mapper.location_map) split_db = self.split_mongo.db - split_db.drop_collection(split_db.course_index) - split_db.drop_collection(split_db.structures) - split_db.drop_collection(split_db.definitions) + split_db.drop_collection(self.split_mongo.db_connection.course_index) + split_db.drop_collection(self.split_mongo.db_connection.structures) + split_db.drop_collection(self.split_mongo.db_connection.definitions) # old_mongo doesn't give a db attr, but all of the dbs are the same dbref.drop_collection(self.old_mongo.collection) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py index aa095d8e4c..92de35e39f 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py @@ -1018,41 +1018,29 @@ class TestCourseCreation(SplitModuleTest): Test changing the org, pretty id, etc of a course. Test that it doesn't allow changing the id, etc. """ locator = CourseLocator(course_id="GreekHero", branch='draft') - modulestore().update_course_index(locator, {'org': 'funkyU'}) + course_info = modulestore().get_course_index_info(locator) + course_info['org'] = 'funkyU' + modulestore().update_course_index(course_info) course_info = modulestore().get_course_index_info(locator) self.assertEqual(course_info['org'], 'funkyU') - modulestore().update_course_index(locator, {'org': 'moreFunky', 'prettyid': 'Ancient Greek Demagods'}) + course_info['org'] = 'moreFunky' + course_info['prettyid'] = 'Ancient Greek Demagods' + modulestore().update_course_index(course_info) course_info = modulestore().get_course_index_info(locator) self.assertEqual(course_info['org'], 'moreFunky') self.assertEqual(course_info['prettyid'], 'Ancient Greek Demagods') - self.assertRaises(ValueError, modulestore().update_course_index, locator, {'_id': 'funkygreeks'}) - - with self.assertRaises(ValueError): - modulestore().update_course_index( - locator, - {'edited_on': datetime.datetime.now(UTC)} - ) - with self.assertRaises(ValueError): - modulestore().update_course_index( - locator, - {'edited_by': 'sneak'} - ) - - self.assertRaises(ValueError, modulestore().update_course_index, locator, - {'versions': {'draft': self.GUID_D1}}) - # an allowed but not necessarily recommended way to revert the draft version versions = course_info['versions'] versions['draft'] = self.GUID_D1 - modulestore().update_course_index(locator, {'versions': versions}, update_versions=True) + modulestore().update_course_index(course_info) course = modulestore().get_course(locator) self.assertEqual(str(course.location.version_guid), self.GUID_D1) # an allowed but not recommended way to publish a course versions['published'] = self.GUID_D1 - modulestore().update_course_index(locator, {'versions': versions}, update_versions=True) + modulestore().update_course_index(course_info) course = modulestore().get_course(CourseLocator(course_id=locator.course_id, branch="published")) self.assertEqual(str(course.location.version_guid), self.GUID_D1) @@ -1068,9 +1056,9 @@ class TestCourseCreation(SplitModuleTest): self.assertEqual(new_course.location.usage_id, 'top') self.assertEqual(new_course.category, 'chapter') # look at db to verify - db_structure = modulestore().structures.find_one({ - '_id': new_course.location.as_object_id(new_course.location.version_guid) - }) + db_structure = modulestore().db_connection.get_structure( + new_course.location.as_object_id(new_course.location.version_guid) + ) self.assertIsNotNone(db_structure, "Didn't find course") self.assertNotIn('course', db_structure['blocks']) self.assertIn('top', db_structure['blocks']) From 783e4b223fbd8c9837e39d16085b18b05bd6fb2c Mon Sep 17 00:00:00 2001 From: Zubair Afzal Date: Thu, 7 Nov 2013 17:08:58 +0500 Subject: [PATCH 019/110] Show error on invalid html in course handout edit + Added tests STUD-293 --- .../features/course-updates.feature | 14 ++++++ .../contentstore/features/course-updates.py | 29 ++++++++++++ .../coffee/spec/views/course_info_spec.coffee | 19 ++++++++ cms/static/js/views/course_info_handout.js | 46 ++++++++++++------- cms/static/js/views/course_info_helper.js | 5 +- .../js/course_info_handouts.underscore | 3 +- 6 files changed, 97 insertions(+), 19 deletions(-) diff --git a/cms/djangoapps/contentstore/features/course-updates.feature b/cms/djangoapps/contentstore/features/course-updates.feature index 6f24fba68c..152da9c349 100644 --- a/cms/djangoapps/contentstore/features/course-updates.feature +++ b/cms/djangoapps/contentstore/features/course-updates.feature @@ -76,3 +76,17 @@ Feature: CMS.Course updates Then I see the handout "/c4x/MITx/999/asset/modified.jpg" And when I reload the page Then I see the handout "/c4x/MITx/999/asset/modified.jpg" + + Scenario: Users cannot save handouts with bad html until edit or update it properly + Given I have opened a new course in Studio + And I go to the course updates page + When I modify the handout to "

        [LINK TEXT]

        " + Then I see the handout error text + And I see handout save button disabled + When I edit the handout to "

        home

        " + Then I see handout save button re-enabled + When I save handout edit + # Can only do partial text matches because of the quotes with in quotes (and regexp step matching). + Then I see the handout "https://www.google.com.pk/" + And when I reload the page + Then I see the handout "https://www.google.com.pk/" diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py index da74f5aa4b..b41578c907 100644 --- a/cms/djangoapps/contentstore/features/course-updates.py +++ b/cms/djangoapps/contentstore/features/course-updates.py @@ -90,6 +90,35 @@ def check_handout(_step, handout): assert handout in world.css_html(handout_css) +@step(u'I see the handout error text') +def check_handout_error(_step): + handout_error_css = 'div#handout_error' + assert world.css_has_class(handout_error_css, 'is-shown') + + +@step(u'I see handout save button disabled') +def check_handout_error(_step): + handout_save_button = 'form.edit-handouts-form a.save-button' + assert world.css_has_class(handout_save_button, 'is-disabled') + + +@step(u'I edit the handout to "([^"]*)"$') +def edit_handouts(_step, text): + type_in_codemirror(0, text) + + +@step(u'I see handout save button re-enabled') +def check_handout_error(_step): + handout_save_button = 'form.edit-handouts-form a.save-button' + assert not world.css_has_class(handout_save_button, 'is-disabled') + + +@step(u'I save handout edit') +def check_handout_error(_step): + save_css = 'a.save-button' + world.css_click(save_css) + + def change_text(text): type_in_codemirror(0, text) save_css = 'a.save-button' diff --git a/cms/static/coffee/spec/views/course_info_spec.coffee b/cms/static/coffee/spec/views/course_info_spec.coffee index 3c388fa593..1e843d59fb 100644 --- a/cms/static/coffee/spec/views/course_info_spec.coffee +++ b/cms/static/coffee/spec/views/course_info_spec.coffee @@ -196,3 +196,22 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model @handoutsEdit.$el.find('.edit-button').click() expect(@handoutsEdit.$codeMirror.getValue().trim()).toEqual('/static/fromServer.jpg') + it "can open course handouts with bad html on edit", -> + # Enter some bad html in handouts section, verifying that the + # model/handoutform opens when "Edit" is clicked + + @model = new ModuleInfo({ + id: 'handouts-id', + data: '

        [LINK TEXT]

        ') + expect($('.edit-handouts-form').is(':hidden')).toEqual(false) \ No newline at end of file diff --git a/cms/static/js/views/course_info_handout.js b/cms/static/js/views/course_info_handout.js index f9804d03a4..9309deda1b 100644 --- a/cms/static/js/views/course_info_handout.js +++ b/cms/static/js/views/course_info_handout.js @@ -30,6 +30,7 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification" model: this.model })) ); + $('.handouts-content').html(this.model.get('data')); this.$preview = this.$el.find('.handouts-content'); this.$form = this.$el.find(".edit-handouts-form"); this.$editor = this.$form.find('.handouts-content-editor'); @@ -50,32 +51,43 @@ define(["backbone", "underscore", "codemirror", "js/views/feedback_notification" }, onSave: function(event) { - this.model.set('data', this.$codeMirror.getValue()); - var saving = new NotificationView.Mini({ - title: gettext('Saving…') - }); - saving.show(); - this.model.save({}, { - success: function() { - saving.hide(); - } - }); - this.render(); - this.$form.hide(); - this.closeEditor(); - - analytics.track('Saved Course Handouts', { - 'course': course_location_analytics - }); + $('#handout_error').removeClass('is-shown'); + $('.save-button').removeClass('is-disabled'); + if ($('.CodeMirror-lines').find('.cm-error').length == 0){ + this.model.set('data', this.$codeMirror.getValue()); + var saving = new NotificationView.Mini({ + title: gettext('Saving…') + }); + saving.show(); + this.model.save({}, { + success: function() { + saving.hide(); + } + }); + this.render(); + this.$form.hide(); + this.closeEditor(); + analytics.track('Saved Course Handouts', { + 'course': course_location_analytics + }); + }else{ + $('#handout_error').addClass('is-shown'); + $('.save-button').addClass('is-disabled'); + event.preventDefault(); + } }, onCancel: function(event) { + $('#handout_error').removeClass('is-shown'); + $('.save-button').removeClass('is-disabled'); this.$form.hide(); this.closeEditor(); }, closeEditor: function() { + $('#handout_error').removeClass('is-shown'); + $('.save-button').removeClass('is-disabled'); this.$form.hide(); ModalUtils.hideModalCover(); this.$form.find('.CodeMirror').remove(); diff --git a/cms/static/js/views/course_info_helper.js b/cms/static/js/views/course_info_helper.js index ec4a6ba550..fb3474cdb0 100644 --- a/cms/static/js/views/course_info_helper.js +++ b/cms/static/js/views/course_info_helper.js @@ -6,7 +6,10 @@ define(["codemirror", "utility"], var $codeMirror = CodeMirror.fromTextArea(textArea, { mode: "text/html", lineNumbers: true, - lineWrapping: true + lineWrapping: true, + onChange: function () { + $('.save-button').removeClass('is-disabled'); + } }); $codeMirror.setValue(content); $codeMirror.clearHistory(); diff --git a/cms/templates/js/course_info_handouts.underscore b/cms/templates/js/course_info_handouts.underscore index 7fbbe9bc33..6ce8518a32 100644 --- a/cms/templates/js/course_info_handouts.underscore +++ b/cms/templates/js/course_info_handouts.underscore @@ -3,12 +3,13 @@

        Course Handouts

        <%if (model.get('data') != null) { %>
        - <%= model.get('data') %> +
        <% } else {%>

        ${_("You have no handouts defined")}

        <% } %>
        +
        <%=gettext("There is invalid code in your content. Please check to make sure it is valid HTML.")%>
        From cc2f1e73bfd754cba2c6b875ce93df281d9d9780 Mon Sep 17 00:00:00 2001 From: polesye Date: Wed, 20 Nov 2013 15:48:15 +0200 Subject: [PATCH 020/110] BLD-524: Add missing assert in video test. --- cms/djangoapps/contentstore/features/video.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index 5408c48290..c97dba10b9 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -181,7 +181,7 @@ def click_on_the_caption(_step, index): @step('I see caption line with data-index "([^"]*)" has class "([^"]*)"$') def caption_line_has_class(_step, index, className): SELECTOR = ".subtitles > li[data-index='{index}']".format(index=int(index.strip())) - world.css_has_class(SELECTOR, className.strip()) + assert world.css_has_class(SELECTOR, className.strip()) @step('I see a range on slider$') From f01b36b5d42923155a15ed7a86e78e125a4c4191 Mon Sep 17 00:00:00 2001 From: cahrens Date: Mon, 18 Nov 2013 14:48:11 -0500 Subject: [PATCH 021/110] Test for i4x on returned pages. STUD-941 --- .../contentstore/tests/test_contentstore.py | 146 ++++++++++++++---- cms/static/js/models/course_info.js | 5 +- cms/templates/asset_index.html | 2 +- cms/templates/course_info.html | 1 - cms/templates/settings.html | 2 +- cms/templates/widgets/segment-io.html | 15 +- 6 files changed, 131 insertions(+), 40 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 2b773e991d..34f15b6db8 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1,6 +1,5 @@ #pylint: disable=E1101 -import json import shutil import mock @@ -15,6 +14,7 @@ from fs.osfs import OSFS import copy from json import loads from datetime import timedelta +from django.test import TestCase from django.contrib.auth.models import User from django.dispatch import Signal @@ -53,6 +53,7 @@ from pytz import UTC from uuid import uuid4 from pymongo import MongoClient from student.models import CourseEnrollment +import re from contentstore.utils import delete_course_and_groups from xmodule.modulestore.django import loc_mapper @@ -135,6 +136,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) self.assertEqual(resp.status_code, 200) + # TODO: uncomment after edit_unit no longer using locations. + # _test_no_locations(self, resp) for expected in expected_types: self.assertIn(expected, resp.content) @@ -160,15 +163,21 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): resp = self.client.get_html(reverse('edit_unit', kwargs={'location': location.url()})) self.assertEqual(resp.status_code, 400) + _test_no_locations(self, resp, status_code=400) def check_edit_unit(self, test_course_name): import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name]) - for descriptor in modulestore().get_items(Location(None, None, 'vertical', None, None)): + items = modulestore().get_items(Location('i4x', 'edX', test_course_name, 'vertical', None, None)) + # Assert is here to make sure that the course being tested actually has verticals. + self.assertGreater(len(items), 0) + for descriptor in items: print "Checking ", descriptor.location.url() print descriptor.__class__, descriptor.location resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) self.assertEqual(resp.status_code, 200) + # TODO: uncomment after edit_unit not using locations. + # _test_no_locations(self, resp) def lockAnAsset(self, content_store, course_location): """ @@ -483,6 +492,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ) resp = self.client.get_html(locator.url_reverse('xblock')) self.assertEqual(resp.status_code, 200) + # TODO: uncomment when preview no longer has locations being returned. + # _test_no_locations(self, resp) return resp def test_delete(self): @@ -841,6 +852,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_bad_contentstore_request(self): resp = self.client.get_html('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') self.assertEqual(resp.status_code, 400) + _test_no_locations(self, resp, 400) def test_rewrite_nonportable_links_on_import(self): module_store = modulestore('direct') @@ -1026,12 +1038,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): items = module_store.get_items(stub_location.replace(category='vertical', name=None)) self.assertGreater(len(items), 0) for descriptor in items: - # don't try to look at private verticals. Right now we're running - # the service in non-draft aware - if getattr(descriptor, 'is_draft', False): - print "Checking {0}....".format(descriptor.location.url()) - resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) - self.assertEqual(resp.status_code, 200) + print "Checking {0}....".format(descriptor.location.url()) + resp = self.client.get_html(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) + self.assertEqual(resp.status_code, 200) + # TODO: uncomment when edit_unit no longer has locations. + # _test_no_locations(self, resp) # verify that we have the content in the draft store as well vertical = draft_store.get_item( @@ -1508,6 +1519,7 @@ class ContentStoreTest(ModuleStoreTestCase): status_code=200, html=True ) + _test_no_locations(self, resp) def test_course_factory(self): """Test that the course factory works correctly.""" @@ -1530,6 +1542,8 @@ class ContentStoreTest(ModuleStoreTestCase): status_code=200, html=True ) + # TODO: uncomment when course index no longer has locations being returned. + # _test_no_locations(self, resp) def test_course_overview_view_with_course(self): """Test viewing the course overview page with an existing course""" @@ -1589,6 +1603,13 @@ class ContentStoreTest(ModuleStoreTestCase): Import and walk through some common URL endpoints. This just verifies non-500 and no other correct behavior, so it is not a deep test """ + def test_get_html(page): + # Helper function for getting HTML for a page in Studio and + # checking that it does not error. + resp = self.client.get_html(new_location.url_reverse(page)) + self.assertEqual(resp.status_code, 200) + _test_no_locations(self, resp) + import_from_xml(modulestore('direct'), 'common/test/data/', ['simple']) loc = Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]) new_location = loc_mapper().translate_location(loc.course_id, loc, False, True) @@ -1598,42 +1619,46 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertContains(resp, 'Chapter 2') # go to various pages - - # import page - resp = self.client.get_html(new_location.url_reverse('import/', '')) - self.assertEqual(resp.status_code, 200) - - # export page - resp = self.client.get_html(new_location.url_reverse('export/', '')) - self.assertEqual(resp.status_code, 200) - - # course team - url = new_location.url_reverse('course_team/', '') - resp = self.client.get_html(url) - self.assertEqual(resp.status_code, 200) - - # course info - resp = self.client.get(new_location.url_reverse('course_info')) - self.assertEqual(resp.status_code, 200) + test_get_html('import') + test_get_html('export') + test_get_html('course_team') + test_get_html('course_info') + test_get_html('checklists') + test_get_html('assets') # settings_details - resp = self.client.get(reverse('settings_details', + resp = self.client.get_html(reverse('settings_details', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) self.assertEqual(resp.status_code, 200) + _test_no_locations(self, resp) # settings_details - resp = self.client.get(reverse('settings_grading', + resp = self.client.get_html(reverse('settings_grading', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) self.assertEqual(resp.status_code, 200) + # TODO: uncomment when grading is not using old locations. + # _test_no_locations(self, resp) - # assets_handler (HTML for full page content) - url = new_location.url_reverse('assets/', '') - resp = self.client.get_html(url) + # advanced settings + resp = self.client.get_html(reverse('course_advanced_settings', + kwargs={'org': loc.org, + 'course': loc.course, + 'name': loc.name})) self.assertEqual(resp.status_code, 200) + # TODO: uncomment when advanced settings not using old locations. + # _test_no_locations(self, resp) + + # textbook index + resp = self.client.get_html(reverse('textbook_index', + kwargs={'org': loc.org, + 'course': loc.course, + 'name': loc.name})) + self.assertEqual(resp.status_code, 200) + _test_no_locations(self, resp) # go look at a subsection page subsection_location = loc.replace(category='sequential', name='test_sequence') @@ -1641,12 +1666,23 @@ class ContentStoreTest(ModuleStoreTestCase): reverse('edit_subsection', kwargs={'location': subsection_location.url()}) ) self.assertEqual(resp.status_code, 200) + # TODO: uncomment when grading and outline not using old locations. + # _test_no_locations(self, resp) # go look at the Edit page unit_location = loc.replace(category='vertical', name='test_vertical') resp = self.client.get_html( reverse('edit_unit', kwargs={'location': unit_location.url()})) self.assertEqual(resp.status_code, 200) + # TODO: uncomment when edit_unit not using old locations. + # _test_no_locations(self, resp) + + resp = self.client.get_html(reverse('edit_tabs', + kwargs={'org': loc.org, + 'course': loc.course, + 'coursename': loc.name})) + self.assertEqual(resp.status_code, 200) + _test_no_locations(self, resp) def delete_item(category, name): """ Helper method for testing the deletion of an xblock item. """ @@ -1654,6 +1690,7 @@ class ContentStoreTest(ModuleStoreTestCase): del_location = loc_mapper().translate_location(loc.course_id, del_loc, False, True) resp = self.client.delete(del_location.url_reverse('xblock')) self.assertEqual(resp.status_code, 204) + _test_no_locations(self, resp, status_code=204, html=False) # delete a component delete_item(category='html', name='test_html') @@ -1853,7 +1890,10 @@ class ContentStoreTest(ModuleStoreTestCase): Show the course overview page. """ new_location = loc_mapper().translate_location(location.course_id, location, False, True) - return self.client.get_html(new_location.url_reverse('course/', '')) + resp = self.client.get_html(new_location.url_reverse('course/', '')) + # TODO: uncomment when i4x no longer in overview. + # _test_no_locations(self, resp) + return resp @override_settings(MODULESTORE=TEST_MODULESTORE) @@ -1920,6 +1960,32 @@ class MetadataSaveTestCase(ModuleStoreTestCase): pass +class EntryPageTestCase(TestCase): + """ + Tests entry pages that aren't specific to a course. + """ + def setUp(self): + self.client = AjaxEnabledTestClient() + + def _test_page(self, page, status_code=200): + resp = self.client.get_html(reverse(page)) + self.assertEqual(resp.status_code, status_code) + _test_no_locations(self, resp, status_code) + + def test_how_it_works(self): + self._test_page("howitworks") + + def test_signup(self): + self._test_page("signup") + + def test_login(self): + self._test_page("login") + + def test_logout(self): + # Logout redirects. + self._test_page("logout", 302) + + def _create_course(test, course_data): """ Creates a course via an AJAX request and verifies the URL returned in the response. @@ -1945,3 +2011,21 @@ def _course_factory_create_course(): def _get_course_id(test_course_data): """Returns the course ID (org/number/run).""" return "{org}/{number}/{run}".format(**test_course_data) + + +def _test_no_locations(test, resp, status_code=200, html=True): + """ + Verifies that "i4x", which appears in old locations, but not + new locators, does not appear in the HTML response output. + Used to verify that database refactoring is complete. + """ + test.assertNotContains(resp, 'i4x', status_code=status_code, html=html) + if html: + # For HTML pages, it is nice to call the method with html=True because + # it checks that the HTML properly parses. However, it won't find i4x usages + # in JavaScript blocks. + content = resp.content + num_jump_to = len(re.findall(r"8000(\S)*jump_to/i4x", content)) + total_i4x = len(re.findall(r"i4x", content)) + + test.assertEqual(total_i4x - num_jump_to, 0, "i4x found outside of LMS jump-to links") diff --git a/cms/static/js/models/course_info.js b/cms/static/js/models/course_info.js index e5a6114dff..e4c816ccf3 100644 --- a/cms/static/js/models/course_info.js +++ b/cms/static/js/models/course_info.js @@ -5,12 +5,9 @@ define(["backbone"], function(Backbone) { url: '', defaults: { - "courseId": "", // the location url "updates" : null, // UpdateCollection "handouts": null // HandoutCollection - }, - - idAttribute : "courseId" + } }); return CourseInfo; }); diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 5576664df7..4f6f14a466 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -187,7 +187,7 @@ require(["domReady", "jquery", "gettext", "js/models/asset", "js/collections/ass ${_('close')} - diff --git a/cms/templates/unit.html b/cms/templates/unit.html index 9f3daf0b20..2318e9cd97 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -48,8 +48,8 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"

          - % for id, locator in components: -
        1. + % for locator in components: +
        2. % endfor
        3. From 341875bb1841ff90ad86c7449876a23d21854e6d Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Thu, 10 Oct 2013 10:26:48 -0400 Subject: [PATCH 073/110] Remove code related to Pearson Testing Centers --- .../tests/test_course_settings.py | 3 - common/djangoapps/external_auth/views.py | 141 +--- .../management/commands/pearson_dump.py | 77 -- .../management/commands/pearson_export_cdd.py | 111 --- .../management/commands/pearson_export_ead.py | 103 --- .../commands/pearson_import_conf_zip.py | 119 --- .../commands/pearson_make_tc_registration.py | 206 ----- .../commands/pearson_make_tc_user.py | 190 ----- .../management/commands/pearson_transfer.py | 167 ---- .../management/commands/tests/__init__.py | 0 .../management/commands/tests/test_pearson.py | 380 --------- common/djangoapps/student/models.py | 477 ----------- common/djangoapps/student/views.py | 171 +--- common/lib/xmodule/setup.py | 1 - common/lib/xmodule/xmodule/course_module.py | 102 --- common/lib/xmodule/xmodule/modulestore/xml.py | 2 +- .../xmodule/tests/rendering/__init__.py | 1 - .../tests/rendering/xmodule_asserts.py | 31 - .../lib/xmodule/xmodule/timelimit_module.py | 136 --- common/lib/xmodule/xmodule/xml_module.py | 6 +- .../policies/2012_Fall.json | 15 - .../internal_data_formats/sql_schema.rst | 75 +- lms/djangoapps/courseware/models.py | 1 - .../courseware/tests/test_timelimit_module.py | 29 - lms/djangoapps/courseware/views.py | 81 +- lms/envs/aws.py | 6 - lms/envs/common.py | 5 - lms/envs/dev.py | 3 - .../multicourse/_testcenter-register.scss | 790 ------------------ lms/templates/test_center_register.html | 482 ----------- lms/urls.py | 6 - 31 files changed, 29 insertions(+), 3888 deletions(-) delete mode 100644 common/djangoapps/student/management/commands/pearson_dump.py delete mode 100644 common/djangoapps/student/management/commands/pearson_export_cdd.py delete mode 100644 common/djangoapps/student/management/commands/pearson_export_ead.py delete mode 100644 common/djangoapps/student/management/commands/pearson_import_conf_zip.py delete mode 100644 common/djangoapps/student/management/commands/pearson_make_tc_registration.py delete mode 100644 common/djangoapps/student/management/commands/pearson_make_tc_user.py delete mode 100644 common/djangoapps/student/management/commands/pearson_transfer.py delete mode 100644 common/djangoapps/student/management/commands/tests/__init__.py delete mode 100644 common/djangoapps/student/management/commands/tests/test_pearson.py delete mode 100644 common/lib/xmodule/xmodule/tests/rendering/xmodule_asserts.py delete mode 100644 common/lib/xmodule/xmodule/timelimit_module.py delete mode 100644 lms/djangoapps/courseware/tests/test_timelimit_module.py delete mode 100644 lms/static/sass/multicourse/_testcenter-register.scss delete mode 100644 lms/templates/test_center_register.html diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 0e24dd497a..792b28fe4d 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -441,7 +441,6 @@ class CourseMetadataEditingTest(CourseTestCase): def test_update_from_json(self): test_model = CourseMetadata.update_from_json(self.course, { "advertised_start": "start A", - "testcenter_info": {"c": "test"}, "days_early_for_beta": 2 }) self.update_check(test_model) @@ -464,8 +463,6 @@ class CourseMetadataEditingTest(CourseTestCase): self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field') self.assertEqual(test_model['advertised_start'], 'start A', "advertised_start not expected value") - self.assertIn('testcenter_info', test_model, 'Missing testcenter_info metadata field') - self.assertDictEqual(test_model['testcenter_info'], {"c": "test"}, "testcenter_info not expected value") self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field') self.assertEqual(test_model['days_early_for_beta'], 2, "days_early_for_beta not expected value") diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index 5872955780..a995dff22b 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -21,7 +21,7 @@ from django.core.exceptions import ValidationError if settings.MITX_FEATURES.get('AUTH_USE_CAS'): from django_cas.views import login as django_cas_login -from student.models import UserProfile, TestCenterUser, TestCenterRegistration +from student.models import UserProfile from django.http import HttpResponse, HttpResponseRedirect, HttpRequest, HttpResponseForbidden from django.utils.http import urlquote, is_safe_url @@ -880,146 +880,7 @@ def provider_xrds(request): return response -#------------------- -# Pearson -#------------------- def course_from_id(course_id): """Return the CourseDescriptor corresponding to this course_id""" course_loc = CourseDescriptor.id_to_location(course_id) return modulestore().get_instance(course_id, course_loc) - - -@csrf_exempt -def test_center_login(request): - ''' Log in students taking exams via Pearson - - Takes a POST request that contains the following keys: - - code - a security code provided by Pearson - - clientCandidateID - - registrationID - - exitURL - the url that we redirect to once we're done - - vueExamSeriesCode - a code that indicates the exam that we're using - ''' - # Imports from lms/djangoapps/courseware -- these should not be - # in a common djangoapps. - from courseware.views import get_module_for_descriptor, jump_to - from courseware.model_data import FieldDataCache - - # errors are returned by navigating to the error_url, adding a query parameter named "code" - # which contains the error code describing the exceptional condition. - def makeErrorURL(error_url, error_code): - log.error("generating error URL with error code {}".format(error_code)) - return "{}?code={}".format(error_url, error_code) - - # get provided error URL, which will be used as a known prefix for returning error messages to the - # Pearson shell. - error_url = request.POST.get("errorURL") - - # TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson - # with the code we calculate for the same parameters. - if 'code' not in request.POST: - return HttpResponseRedirect(makeErrorURL(error_url, "missingSecurityCode")) - code = request.POST.get("code") - - # calculate SHA for query string - # TODO: figure out how to get the original query string, so we can hash it and compare. - - if 'clientCandidateID' not in request.POST: - return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID")) - client_candidate_id = request.POST.get("clientCandidateID") - - # TODO: check remaining parameters, and maybe at least log if they're not matching - # expected values.... - # registration_id = request.POST.get("registrationID") - # exit_url = request.POST.get("exitURL") - - # find testcenter_user that matches the provided ID: - try: - testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id) - except TestCenterUser.DoesNotExist: - AUDIT_LOG.error("not able to find demographics for cand ID {}".format(client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID")) - - AUDIT_LOG.info("Attempting to log in test-center user '{}' for test of cand {}".format(testcenteruser.user.username, client_candidate_id)) - - # find testcenter_registration that matches the provided exam code: - # Note that we could rely in future on either the registrationId or the exam code, - # or possibly both. But for now we know what to do with an ExamSeriesCode, - # while we currently have no record of RegistrationID values at all. - if 'vueExamSeriesCode' not in request.POST: - # we are not allowed to make up a new error code, according to Pearson, - # so instead of "missingExamSeriesCode", we use a valid one that is - # inaccurate but at least distinct. (Sigh.) - AUDIT_LOG.error("missing exam series code for cand ID {}".format(client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID")) - exam_series_code = request.POST.get('vueExamSeriesCode') - - registrations = TestCenterRegistration.objects.filter(testcenter_user=testcenteruser, exam_series_code=exam_series_code) - if not registrations: - AUDIT_LOG.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned")) - - # TODO: figure out what to do if there are more than one registrations.... - # for now, just take the first... - registration = registrations[0] - - course_id = registration.course_id - course = course_from_id(course_id) # assume it will be found.... - if not course: - AUDIT_LOG.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")) - exam = course.get_test_center_exam(exam_series_code) - if not exam: - AUDIT_LOG.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")) - location = exam.exam_url - log.info("Proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location)) - - # check if the test has already been taken - timelimit_descriptor = modulestore().get_instance(course_id, Location(location)) - if not timelimit_descriptor: - log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location)) - return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")) - - timelimit_module_cache = FieldDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user, - timelimit_descriptor, depth=None) - timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, - timelimit_module_cache, course_id, position=None) - if not timelimit_module.category == 'timelimit': - log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location)) - return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")) - - if timelimit_module and timelimit_module.has_ended: - AUDIT_LOG.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at)) - return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken")) - - # check if we need to provide an accommodation: - time_accommodation_mapping = {'ET12ET': 'ADDHALFTIME', - 'ET30MN': 'ADD30MIN', - 'ETDBTM': 'ADDDOUBLE', } - - time_accommodation_code = None - for code in registration.get_accommodation_codes(): - if code in time_accommodation_mapping: - time_accommodation_code = time_accommodation_mapping[code] - - if time_accommodation_code: - timelimit_module.accommodation_code = time_accommodation_code - AUDIT_LOG.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code)) - - # UGLY HACK!!! - # Login assumes that authentication has occurred, and that there is a - # backend annotation on the user object, indicating which backend - # against which the user was authenticated. We're authenticating here - # against the registration entry, and assuming that the request given - # this information is correct, we allow the user to be logged in - # without a password. This could all be formalized in a backend object - # that does the above checking. - # TODO: (brian) create a backend class to do this. - # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) - testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass") - login(request, testcenteruser.user) - AUDIT_LOG.info("Logged in user '{}' for test of cand {} on exam {} for course {}: URL = {}".format(testcenteruser.user.username, client_candidate_id, exam_series_code, course_id, location)) - - # And start the test: - return jump_to(request, course_id, location) diff --git a/common/djangoapps/student/management/commands/pearson_dump.py b/common/djangoapps/student/management/commands/pearson_dump.py deleted file mode 100644 index 0c9e215f77..0000000000 --- a/common/djangoapps/student/management/commands/pearson_dump.py +++ /dev/null @@ -1,77 +0,0 @@ -from optparse import make_option -from json import dump -from datetime import datetime - -from django.core.management.base import BaseCommand - -from student.models import TestCenterRegistration - - -class Command(BaseCommand): - - args = '' - help = """ - Dump information as JSON from TestCenterRegistration tables, including username and status. - """ - - option_list = BaseCommand.option_list + ( - make_option('--course_id', - action='store', - dest='course_id', - help='Specify a particular course.'), - make_option('--exam_series_code', - action='store', - dest='exam_series_code', - default=None, - help='Specify a particular exam, using the Pearson code'), - make_option('--accommodation_pending', - action='store_true', - dest='accommodation_pending', - default=False, - ), - ) - - def handle(self, *args, **options): - if len(args) < 1: - outputfile = datetime.utcnow().strftime("pearson-dump-%Y%m%d-%H%M%S.json") - else: - outputfile = args[0] - - # construct the query object to dump: - registrations = TestCenterRegistration.objects.all() - if 'course_id' in options and options['course_id']: - registrations = registrations.filter(course_id=options['course_id']) - if 'exam_series_code' in options and options['exam_series_code']: - registrations = registrations.filter(exam_series_code=options['exam_series_code']) - - # collect output: - output = [] - for registration in registrations: - if 'accommodation_pending' in options and options['accommodation_pending'] and not registration.accommodation_is_pending: - continue - record = {'username': registration.testcenter_user.user.username, - 'email': registration.testcenter_user.email, - 'first_name': registration.testcenter_user.first_name, - 'last_name': registration.testcenter_user.last_name, - 'client_candidate_id': registration.client_candidate_id, - 'client_authorization_id': registration.client_authorization_id, - 'course_id': registration.course_id, - 'exam_series_code': registration.exam_series_code, - 'accommodation_request': registration.accommodation_request, - 'accommodation_code': registration.accommodation_code, - 'registration_status': registration.registration_status(), - 'demographics_status': registration.demographics_status(), - 'accommodation_status': registration.accommodation_status(), - } - if len(registration.upload_error_message) > 0: - record['registration_error'] = registration.upload_error_message - if len(registration.testcenter_user.upload_error_message) > 0: - record['demographics_error'] = registration.testcenter_user.upload_error_message - if registration.needs_uploading: - record['needs_uploading'] = True - - output.append(record) - - # dump output: - with open(outputfile, 'w') as outfile: - dump(output, outfile, indent=2) diff --git a/common/djangoapps/student/management/commands/pearson_export_cdd.py b/common/djangoapps/student/management/commands/pearson_export_cdd.py deleted file mode 100644 index efb4a55387..0000000000 --- a/common/djangoapps/student/management/commands/pearson_export_cdd.py +++ /dev/null @@ -1,111 +0,0 @@ -import csv -import os -from collections import OrderedDict -from datetime import datetime -from optparse import make_option - -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError - -from student.models import TestCenterUser -from pytz import UTC - - -class Command(BaseCommand): - - CSV_TO_MODEL_FIELDS = OrderedDict([ - # Skipping optional field CandidateID - ("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 - ]) - - # define defaults, even thought 'store_true' shouldn't need them. - # (call_command will set None as default value for all options that don't have one, - # so one cannot rely on presence/absence of flags in that world.) - option_list = BaseCommand.option_list + ( - make_option('--dest-from-settings', - action='store_true', - dest='dest-from-settings', - default=False, - help='Retrieve the destination to export to from django.'), - make_option('--destination', - action='store', - dest='destination', - default=None, - help='Where to store the exported files') - ) - - def handle(self, **options): - # update time should use UTC in order to be comparable to the user_updated_at - # field - uploaded_at = datetime.now(UTC) - - # if specified destination is an existing directory, then - # create a filename for it automatically. If it doesn't exist, - # then we will create the directory. - # Name will use timestamp -- this is UTC, so it will look funny, - # but it should at least be consistent with the other timestamps - # used in the system. - if 'dest-from-settings' in options and options['dest-from-settings']: - if 'LOCAL_EXPORT' in settings.PEARSON: - dest = settings.PEARSON['LOCAL_EXPORT'] - else: - raise CommandError('--dest-from-settings was enabled but the' - 'PEARSON[LOCAL_EXPORT] setting was not set.') - elif 'destination' in options and options['destination']: - dest = options['destination'] - else: - raise CommandError('--destination or --dest-from-settings must be used') - - if not os.path.isdir(dest): - os.makedirs(dest) - - destfile = os.path.join(dest, uploaded_at.strftime("cdd-%Y%m%d-%H%M%S.dat")) - - # strings must be in latin-1 format. CSV parser will - # otherwise convert unicode objects to ascii. - def ensure_encoding(value): - if isinstance(value, unicode): - return value.encode('iso-8859-1') - else: - return value - -# dump_all = options['dump_all'] - - with open(destfile, "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'): - if tcu.needs_uploading: # or dump_all - record = dict((csv_field, ensure_encoding(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) - tcu.uploaded_at = uploaded_at - tcu.save() diff --git a/common/djangoapps/student/management/commands/pearson_export_ead.py b/common/djangoapps/student/management/commands/pearson_export_ead.py deleted file mode 100644 index ec10ab1599..0000000000 --- a/common/djangoapps/student/management/commands/pearson_export_ead.py +++ /dev/null @@ -1,103 +0,0 @@ -import csv -import os -from collections import OrderedDict -from datetime import datetime -from optparse import make_option - -from django.conf import settings -from django.core.management.base import BaseCommand, CommandError - -from student.models import TestCenterRegistration, ACCOMMODATION_REJECTED_CODE -from pytz import UTC - - -class Command(BaseCommand): - - CSV_TO_MODEL_FIELDS = OrderedDict([ - ('AuthorizationTransactionType', 'authorization_transaction_type'), - ('AuthorizationID', 'authorization_id'), - ('ClientAuthorizationID', 'client_authorization_id'), - ('ClientCandidateID', 'client_candidate_id'), - ('ExamAuthorizationCount', 'exam_authorization_count'), - ('ExamSeriesCode', 'exam_series_code'), - ('Accommodations', 'accommodation_code'), - ('EligibilityApptDateFirst', 'eligibility_appointment_date_first'), - ('EligibilityApptDateLast', 'eligibility_appointment_date_last'), - ("LastUpdate", "user_updated_at"), # in UTC, so same as what we store - ]) - - option_list = BaseCommand.option_list + ( - make_option('--dest-from-settings', - action='store_true', - dest='dest-from-settings', - default=False, - help='Retrieve the destination to export to from django.'), - make_option('--destination', - action='store', - dest='destination', - default=None, - help='Where to store the exported files'), - make_option('--dump_all', - action='store_true', - dest='dump_all', - default=False, - ), - make_option('--force_add', - action='store_true', - dest='force_add', - default=False, - ), - ) - - def handle(self, **options): - # update time should use UTC in order to be comparable to the user_updated_at - # field - uploaded_at = datetime.now(UTC) - - # if specified destination is an existing directory, then - # create a filename for it automatically. If it doesn't exist, - # then we will create the directory. - # Name will use timestamp -- this is UTC, so it will look funny, - # but it should at least be consistent with the other timestamps - # used in the system. - if 'dest-from-settings' in options and options['dest-from-settings']: - if 'LOCAL_EXPORT' in settings.PEARSON: - dest = settings.PEARSON['LOCAL_EXPORT'] - else: - raise CommandError('--dest-from-settings was enabled but the' - 'PEARSON[LOCAL_EXPORT] setting was not set.') - elif 'destination' in options and options['destination']: - dest = options['destination'] - else: - raise CommandError('--destination or --dest-from-settings must be used') - - if not os.path.isdir(dest): - os.makedirs(dest) - - destfile = os.path.join(dest, uploaded_at.strftime("ead-%Y%m%d-%H%M%S.dat")) - - dump_all = options['dump_all'] - - with open(destfile, "wb") as outfile: - writer = csv.DictWriter(outfile, - Command.CSV_TO_MODEL_FIELDS, - delimiter="\t", - quoting=csv.QUOTE_MINIMAL, - extrasaction='ignore') - writer.writeheader() - for tcr in TestCenterRegistration.objects.order_by('id'): - if dump_all or tcr.needs_uploading: - record = dict((csv_field, getattr(tcr, 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") - record["EligibilityApptDateFirst"] = record["EligibilityApptDateFirst"].strftime("%Y/%m/%d") - record["EligibilityApptDateLast"] = record["EligibilityApptDateLast"].strftime("%Y/%m/%d") - if record["Accommodations"] == ACCOMMODATION_REJECTED_CODE: - record["Accommodations"] = "" - if options['force_add']: - record['AuthorizationTransactionType'] = 'Add' - - writer.writerow(record) - tcr.uploaded_at = uploaded_at - tcr.save() diff --git a/common/djangoapps/student/management/commands/pearson_import_conf_zip.py b/common/djangoapps/student/management/commands/pearson_import_conf_zip.py deleted file mode 100644 index 3edb3a76d7..0000000000 --- a/common/djangoapps/student/management/commands/pearson_import_conf_zip.py +++ /dev/null @@ -1,119 +0,0 @@ -import csv -from time import strptime, strftime -from datetime import datetime -from zipfile import ZipFile, is_zipfile - -from dogapi import dog_http_api -from pytz import UTC - -from django.core.management.base import BaseCommand, CommandError -from django.conf import settings - -import django_startup - -from student.models import TestCenterUser, TestCenterRegistration - - -django_startup.autostartup() - - -class Command(BaseCommand): - - args = '' - help = """ - Import Pearson confirmation files and update TestCenterUser - and TestCenterRegistration tables with status. - """ - - @staticmethod - def datadog_error(string, tags): - dog_http_api.event("Pearson Import", string, alert_type='error', tags=[tags]) - - def handle(self, *args, **kwargs): - if len(args) < 1: - print Command.help - return - - source_zip = args[0] - if not is_zipfile(source_zip): - error = "Input file is not a zipfile: \"{}\"".format(source_zip) - Command.datadog_error(error, source_zip) - raise CommandError(error) - - # loop through all files in zip, and process them based on filename prefix: - with ZipFile(source_zip, 'r') as zipfile: - for fileinfo in zipfile.infolist(): - with zipfile.open(fileinfo) as zipentry: - if fileinfo.filename.startswith("eac-"): - self.process_eac(zipentry) - elif fileinfo.filename.startswith("vcdc-"): - self.process_vcdc(zipentry) - else: - error = "Unrecognized confirmation file type\"{}\" in confirmation zip file \"{}\"".format(fileinfo.filename, zipfile) - Command.datadog_error(error, source_zip) - raise CommandError(error) - - def process_eac(self, eacfile): - print "processing eac" - reader = csv.DictReader(eacfile, delimiter="\t") - for row in reader: - client_authorization_id = row['ClientAuthorizationID'] - if not client_authorization_id: - if row['Status'] == 'Error': - Command.datadog_error("Error in EAD file processing ({}): {}".format(row['Date'], row['Message']), eacfile.name) - else: - Command.datadog_error("Encountered bad record: {}".format(row), eacfile.name) - else: - try: - registration = TestCenterRegistration.objects.get(client_authorization_id=client_authorization_id) - Command.datadog_error("Found authorization record for user {}".format(registration.testcenter_user.user.username), eacfile.name) - # now update the record: - registration.upload_status = row['Status'] - registration.upload_error_message = row['Message'] - try: - registration.processed_at = strftime('%Y-%m-%d %H:%M:%S', strptime(row['Date'], '%Y/%m/%d %H:%M:%S')) - except ValueError as ve: - Command.datadog_error("Bad Date value found for {}: message {}".format(client_authorization_id, ve), eacfile.name) - # store the authorization Id if one is provided. (For debugging) - if row['AuthorizationID']: - try: - registration.authorization_id = int(row['AuthorizationID']) - except ValueError as ve: - Command.datadog_error("Bad AuthorizationID value found for {}: message {}".format(client_authorization_id, ve), eacfile.name) - - registration.confirmed_at = datetime.now(UTC) - registration.save() - except TestCenterRegistration.DoesNotExist: - Command.datadog_error("Failed to find record for client_auth_id {}".format(client_authorization_id), eacfile.name) - - def process_vcdc(self, vcdcfile): - print "processing vcdc" - reader = csv.DictReader(vcdcfile, delimiter="\t") - for row in reader: - client_candidate_id = row['ClientCandidateID'] - if not client_candidate_id: - if row['Status'] == 'Error': - Command.datadog_error("Error in CDD file processing ({}): {}".format(row['Date'], row['Message']), vcdcfile.name) - else: - Command.datadog_error("Encountered bad record: {}".format(row), vcdcfile.name) - else: - try: - tcuser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id) - Command.datadog_error("Found demographics record for user {}".format(tcuser.user.username), vcdcfile.name) - # now update the record: - tcuser.upload_status = row['Status'] - tcuser.upload_error_message = row['Message'] - try: - tcuser.processed_at = strftime('%Y-%m-%d %H:%M:%S', strptime(row['Date'], '%Y/%m/%d %H:%M:%S')) - except ValueError as ve: - Command.datadog_error("Bad Date value found for {}: message {}".format(client_candidate_id, ve), vcdcfile.name) - # store the candidate Id if one is provided. (For debugging) - if row['CandidateID']: - try: - tcuser.candidate_id = int(row['CandidateID']) - except ValueError as ve: - Command.datadog_error("Bad CandidateID value found for {}: message {}".format(client_candidate_id, ve), vcdcfile.name) - tcuser.confirmed_at = datetime.utcnow() - tcuser.save() - except TestCenterUser.DoesNotExist: - Command.datadog_error(" Failed to find record for client_candidate_id {}".format(client_candidate_id), vcdcfile.name) diff --git a/common/djangoapps/student/management/commands/pearson_make_tc_registration.py b/common/djangoapps/student/management/commands/pearson_make_tc_registration.py deleted file mode 100644 index 50e56bb4be..0000000000 --- a/common/djangoapps/student/management/commands/pearson_make_tc_registration.py +++ /dev/null @@ -1,206 +0,0 @@ -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, TestCenterRegistration, TestCenterRegistrationForm, get_testcenter_registration -from student.views import course_from_id -from xmodule.course_module import CourseDescriptor -from xmodule.modulestore.exceptions import ItemNotFoundError - - -class Command(BaseCommand): - option_list = BaseCommand.option_list + ( - # registration info: - make_option( - '--accommodation_request', - action='store', - dest='accommodation_request', - ), - make_option( - '--accommodation_code', - action='store', - dest='accommodation_code', - ), - make_option( - '--client_authorization_id', - action='store', - dest='client_authorization_id', - ), - # exam info: - make_option( - '--exam_series_code', - action='store', - dest='exam_series_code', - ), - make_option( - '--eligibility_appointment_date_first', - action='store', - dest='eligibility_appointment_date_first', - help='use YYYY-MM-DD format if overriding existing course values, or YYYY-MM-DDTHH:MM if not using an existing course.' - ), - make_option( - '--eligibility_appointment_date_last', - action='store', - dest='eligibility_appointment_date_last', - help='use YYYY-MM-DD format if overriding existing course values, or YYYY-MM-DDTHH:MM if not using an existing course.' - ), - # internal values: - make_option( - '--authorization_id', - action='store', - dest='authorization_id', - help='ID we receive from Pearson for a particular authorization' - ), - make_option( - '--upload_status', - action='store', - dest='upload_status', - help='status value assigned by Pearson' - ), - make_option( - '--upload_error_message', - action='store', - dest='upload_error_message', - help='error message provided by Pearson on a failure.' - ), - # control values: - make_option( - '--ignore_registration_dates', - action='store_true', - dest='ignore_registration_dates', - help='find exam info for course based on exam_series_code, even if the exam is not active.' - ), - make_option( - '--create_dummy_exam', - action='store_true', - dest='create_dummy_exam', - help='create dummy exam info for course, even if course exists' - ), - ) - args = "" - help = "Create or modify a TestCenterRegistration 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] - course_id = args[1] - print username, course_id - - our_options = dict((k, v) for k, v in options.items() - if Command.is_valid_option(k) and v is not None) - try: - student = User.objects.get(username=username) - except User.DoesNotExist: - raise CommandError("User \"{}\" does not exist".format(username)) - - try: - testcenter_user = TestCenterUser.objects.get(user=student) - except TestCenterUser.DoesNotExist: - raise CommandError("User \"{}\" does not have an existing demographics record".format(username)) - - # get an "exam" object. Check to see if a course_id was specified, and use information from that: - exam = None - create_dummy_exam = 'create_dummy_exam' in our_options and our_options['create_dummy_exam'] - if not create_dummy_exam: - try: - course = course_from_id(course_id) - if 'ignore_registration_dates' in our_options: - examlist = [exam for exam in course.test_center_exams if exam.exam_series_code == our_options.get('exam_series_code')] - exam = examlist[0] if len(examlist) > 0 else None - else: - exam = course.current_test_center_exam - except ItemNotFoundError: - pass - else: - # otherwise use explicit values (so we don't have to define a course): - exam_name = "Dummy Placeholder Name" - exam_info = {'Exam_Series_Code': our_options['exam_series_code'], - 'First_Eligible_Appointment_Date': our_options['eligibility_appointment_date_first'], - 'Last_Eligible_Appointment_Date': our_options['eligibility_appointment_date_last'], - } - exam = CourseDescriptor.TestCenterExam(course_id, exam_name, exam_info) - # update option values for date_first and date_last to use YYYY-MM-DD format - # instead of YYYY-MM-DDTHH:MM - our_options['eligibility_appointment_date_first'] = exam.first_eligible_appointment_date.strftime("%Y-%m-%d") - our_options['eligibility_appointment_date_last'] = exam.last_eligible_appointment_date.strftime("%Y-%m-%d") - - if exam is None: - raise CommandError("Exam for course_id {} does not exist".format(course_id)) - - exam_code = exam.exam_series_code - - UPDATE_FIELDS = ('accommodation_request', - 'accommodation_code', - 'client_authorization_id', - 'exam_series_code', - 'eligibility_appointment_date_first', - 'eligibility_appointment_date_last', - ) - - # create and save the registration: - needs_updating = False - registrations = get_testcenter_registration(student, course_id, exam_code) - if len(registrations) > 0: - registration = registrations[0] - for fieldname in UPDATE_FIELDS: - if fieldname in our_options and registration.__getattribute__(fieldname) != our_options[fieldname]: - needs_updating = True; - else: - accommodation_request = our_options.get('accommodation_request', '') - registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request) - needs_updating = True - - - if needs_updating: - # first update the record with the new values, if any: - for fieldname in UPDATE_FIELDS: - if fieldname in our_options and fieldname not in TestCenterRegistrationForm.Meta.fields: - registration.__setattr__(fieldname, our_options[fieldname]) - - # the registration form normally populates the data dict with - # the accommodation request (if any). But here we want to - # specify only those values that might change, so update the dict with existing - # values. - form_options = dict(our_options) - for propname in TestCenterRegistrationForm.Meta.fields: - if propname not in form_options: - form_options[propname] = registration.__getattribute__(propname) - form = TestCenterRegistrationForm(instance=registration, data=form_options) - if form.is_valid(): - form.update_and_save() - print "Updated registration information for user's registration: username \"{}\" course \"{}\", examcode \"{}\"".format(student.username, course_id, exam_code) - else: - if (len(form.errors) > 0): - print "Field Form errors encountered:" - for fielderror in form.errors: - for msg in form.errors[fielderror]: - print "Field Form Error: {} -- {}".format(fielderror, msg) - if (len(form.non_field_errors()) > 0): - print "Non-field Form errors encountered:" - for nonfielderror in form.non_field_errors: - print "Non-field Form Error: %s" % nonfielderror - - else: - print "No changes necessary to make to existing user's registration." - - # override internal values: - change_internal = False - if 'exam_series_code' in our_options: - exam_code = our_options['exam_series_code'] - registration = get_testcenter_registration(student, course_id, exam_code)[0] - for internal_field in ['upload_error_message', 'upload_status', 'authorization_id']: - if internal_field in our_options: - registration.__setattr__(internal_field, our_options[internal_field]) - change_internal = True - - if change_internal: - print "Updated confirmation information in existing user's registration." - registration.save() - else: - print "No changes necessary to make to confirmation information in existing user's registration." diff --git a/common/djangoapps/student/management/commands/pearson_make_tc_user.py b/common/djangoapps/student/management/commands/pearson_make_tc_user.py deleted file mode 100644 index 10ef0bd067..0000000000 --- a/common/djangoapps/student/management/commands/pearson_make_tc_user.py +++ /dev/null @@ -1,190 +0,0 @@ -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, TestCenterUserForm - - -class Command(BaseCommand): - option_list = BaseCommand.option_list + ( - # demographics: - make_option( - '--first_name', - action='store', - dest='first_name', - ), - make_option( - '--middle_name', - action='store', - dest='middle_name', - ), - make_option( - '--last_name', - action='store', - dest='last_name', - ), - make_option( - '--suffix', - action='store', - dest='suffix', - ), - make_option( - '--salutation', - action='store', - dest='salutation', - ), - make_option( - '--address_1', - action='store', - dest='address_1', - ), - make_option( - '--address_2', - action='store', - dest='address_2', - ), - make_option( - '--address_3', - action='store', - dest='address_3', - ), - 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( - '--extension', - action='store', - dest='extension', - ), - make_option( - '--phone_country_code', - action='store', - dest='phone_country_code', - help='Phone country code, just "1" for the USA' - ), - make_option( - '--fax', - action='store', - dest='fax', - help='Pretty free-form (parens, spaces, dashes), but no country code' - ), - make_option( - '--fax_country_code', - action='store', - dest='fax_country_code', - help='Fax country code, just "1" for the USA' - ), - make_option( - '--company_name', - action='store', - dest='company_name', - ), - # internal values: - make_option( - '--client_candidate_id', - action='store', - dest='client_candidate_id', - help='ID we assign a user to identify them to Pearson' - ), - make_option( - '--upload_status', - action='store', - dest='upload_status', - help='status value assigned by Pearson' - ), - make_option( - '--upload_error_message', - action='store', - dest='upload_error_message', - help='error message provided by Pearson on a failure.' - ), - ) - args = "" - help = "Create or modify 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) and v is not None) - student = User.objects.get(username=username) - try: - testcenter_user = TestCenterUser.objects.get(user=student) - needs_updating = testcenter_user.needs_update(our_options) - except TestCenterUser.DoesNotExist: - # do additional initialization here: - testcenter_user = TestCenterUser.create(student) - needs_updating = True - - if needs_updating: - # the registration form normally populates the data dict with - # all values from the testcenter_user. But here we only want to - # specify those values that change, so update the dict with existing - # values. - form_options = dict(our_options) - for propname in TestCenterUser.user_provided_fields(): - if propname not in form_options: - form_options[propname] = testcenter_user.__getattribute__(propname) - form = TestCenterUserForm(instance=testcenter_user, data=form_options) - if form.is_valid(): - form.update_and_save() - else: - errorlist = [] - if (len(form.errors) > 0): - errorlist.append("Field Form errors encountered:") - for fielderror in form.errors: - errorlist.append("Field Form Error: {}".format(fielderror)) - if (len(form.non_field_errors()) > 0): - errorlist.append("Non-field Form errors encountered:") - for nonfielderror in form.non_field_errors: - errorlist.append("Non-field Form Error: {}".format(nonfielderror)) - raise CommandError("\n".join(errorlist)) - else: - print "No changes necessary to make to existing user's demographics." - - # override internal values: - change_internal = False - testcenter_user = TestCenterUser.objects.get(user=student) - for internal_field in ['upload_error_message', 'upload_status', 'client_candidate_id']: - if internal_field in our_options: - testcenter_user.__setattr__(internal_field, our_options[internal_field]) - change_internal = True - - if change_internal: - testcenter_user.save() - print "Updated confirmation information in existing user's demographics." - else: - print "No changes necessary to make to confirmation information in existing user's demographics." diff --git a/common/djangoapps/student/management/commands/pearson_transfer.py b/common/djangoapps/student/management/commands/pearson_transfer.py deleted file mode 100644 index b00cf27ffb..0000000000 --- a/common/djangoapps/student/management/commands/pearson_transfer.py +++ /dev/null @@ -1,167 +0,0 @@ -from optparse import make_option -import os -from stat import S_ISDIR - -import boto -from dogapi import dog_http_api, dog_stats_api -import paramiko - -from django.conf import settings -from django.core.management import call_command -from django.core.management.base import BaseCommand, CommandError - -import django_startup - - -django_startup.autostartup() - - -class Command(BaseCommand): - help = """ - This command handles the importing and exporting of student records for - Pearson. It uses some other Django commands to export and import the - files and then uploads over SFTP to Pearson and stuffs the entry in an - S3 bucket for archive purposes. - - Usage: ./manage.py pearson-transfer --mode [import|export|both] - """ - - option_list = BaseCommand.option_list + ( - make_option('--mode', - action='store', - dest='mode', - default='both', - choices=('import', 'export', 'both'), - help='mode is import, export, or both'), - ) - - def handle(self, **options): - - if not hasattr(settings, 'PEARSON'): - raise CommandError('No PEARSON entries in auth/env.json.') - - # check settings needed for either import or export: - for value in ['SFTP_HOSTNAME', 'SFTP_USERNAME', 'SFTP_PASSWORD', 'S3_BUCKET']: - if value not in settings.PEARSON: - raise CommandError('No entry in the PEARSON settings' - '(env/auth.json) for {0}'.format(value)) - - for value in ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY']: - if not hasattr(settings, value): - raise CommandError('No entry in the AWS settings' - '(env/auth.json) for {0}'.format(value)) - - # check additional required settings for import and export: - if options['mode'] in ('export', 'both'): - for value in ['LOCAL_EXPORT', 'SFTP_EXPORT']: - if value not in settings.PEARSON: - raise CommandError('No entry in the PEARSON settings' - '(env/auth.json) for {0}'.format(value)) - # make sure that the import directory exists or can be created: - source_dir = settings.PEARSON['LOCAL_EXPORT'] - if not os.path.isdir(source_dir): - os.makedirs(source_dir) - - if options['mode'] in ('import', 'both'): - for value in ['LOCAL_IMPORT', 'SFTP_IMPORT']: - if value not in settings.PEARSON: - raise CommandError('No entry in the PEARSON settings' - '(env/auth.json) for {0}'.format(value)) - # make sure that the import directory exists or can be created: - dest_dir = settings.PEARSON['LOCAL_IMPORT'] - if not os.path.isdir(dest_dir): - os.makedirs(dest_dir) - - - def sftp(files_from, files_to, mode, deleteAfterCopy=False): - with dog_stats_api.timer('pearson.{0}'.format(mode), tags='sftp'): - try: - t = paramiko.Transport((settings.PEARSON['SFTP_HOSTNAME'], 22)) - t.connect(username=settings.PEARSON['SFTP_USERNAME'], - password=settings.PEARSON['SFTP_PASSWORD']) - sftp = paramiko.SFTPClient.from_transport(t) - - if mode == 'export': - try: - sftp.chdir(files_to) - except IOError: - raise CommandError('SFTP destination path does not exist: {}'.format(files_to)) - for filename in os.listdir(files_from): - sftp.put(files_from + '/' + filename, filename) - if deleteAfterCopy: - os.remove(os.path.join(files_from, filename)) - else: - try: - sftp.chdir(files_from) - except IOError: - raise CommandError('SFTP source path does not exist: {}'.format(files_from)) - for filename in sftp.listdir('.'): - # skip subdirectories - if not S_ISDIR(sftp.stat(filename).st_mode): - sftp.get(filename, files_to + '/' + filename) - # delete files from sftp server once they are successfully pulled off: - if deleteAfterCopy: - sftp.remove(filename) - except: - dog_http_api.event('pearson {0}'.format(mode), - 'sftp uploading failed', - alert_type='error') - raise - finally: - sftp.close() - t.close() - - def s3(files_from, bucket, mode, deleteAfterCopy=False): - with dog_stats_api.timer('pearson.{0}'.format(mode), tags='s3'): - try: - for filename in os.listdir(files_from): - source_file = os.path.join(files_from, filename) - # use mode as name of directory into which to write files - dest_file = os.path.join(mode, filename) - upload_file_to_s3(bucket, source_file, dest_file) - if deleteAfterCopy: - os.remove(files_from + '/' + filename) - except: - dog_http_api.event('pearson {0}'.format(mode), - 's3 archiving failed') - raise - - def upload_file_to_s3(bucket, source_file, dest_file): - """ - Upload file to S3 - """ - s3 = boto.connect_s3(settings.AWS_ACCESS_KEY_ID, - settings.AWS_SECRET_ACCESS_KEY) - from boto.s3.key import Key - b = s3.get_bucket(bucket) - k = Key(b) - k.key = "{filename}".format(filename=dest_file) - k.set_contents_from_filename(source_file) - - def export_pearson(): - options = {'dest-from-settings': True} - call_command('pearson_export_cdd', **options) - call_command('pearson_export_ead', **options) - mode = 'export' - sftp(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['SFTP_EXPORT'], mode, deleteAfterCopy=False) - s3(settings.PEARSON['LOCAL_EXPORT'], settings.PEARSON['S3_BUCKET'], mode, deleteAfterCopy=True) - - def import_pearson(): - mode = 'import' - try: - sftp(settings.PEARSON['SFTP_IMPORT'], settings.PEARSON['LOCAL_IMPORT'], mode, deleteAfterCopy=True) - s3(settings.PEARSON['LOCAL_IMPORT'], settings.PEARSON['S3_BUCKET'], mode, deleteAfterCopy=False) - except Exception as e: - dog_http_api.event('Pearson Import failure', str(e)) - raise e - else: - for filename in os.listdir(settings.PEARSON['LOCAL_IMPORT']): - filepath = os.path.join(settings.PEARSON['LOCAL_IMPORT'], filename) - call_command('pearson_import_conf_zip', filepath) - os.remove(filepath) - - # actually do the work! - if options['mode'] in ('export', 'both'): - export_pearson() - if options['mode'] in ('import', 'both'): - import_pearson() diff --git a/common/djangoapps/student/management/commands/tests/__init__.py b/common/djangoapps/student/management/commands/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/common/djangoapps/student/management/commands/tests/test_pearson.py b/common/djangoapps/student/management/commands/tests/test_pearson.py deleted file mode 100644 index 68fa10eaaa..0000000000 --- a/common/djangoapps/student/management/commands/tests/test_pearson.py +++ /dev/null @@ -1,380 +0,0 @@ -''' -Created on Jan 17, 2013 - -@author: brian -''' -import logging -import os -from tempfile import mkdtemp -import cStringIO -import shutil -import sys - -from django.test import TestCase -from django.core.management import call_command -from nose.plugins.skip import SkipTest - -from student.models import User, TestCenterUser, get_testcenter_registration - -log = logging.getLogger(__name__) - - -def create_tc_user(username): - user = User.objects.create_user(username, '{}@edx.org'.format(username), 'fakepass') - options = { - 'first_name': 'TestFirst', - 'last_name': 'TestLast', - 'address_1': 'Test Address', - 'city': 'TestCity', - 'state': 'Alberta', - 'postal_code': 'A0B 1C2', - 'country': 'CAN', - 'phone': '252-1866', - 'phone_country_code': '1', - } - call_command('pearson_make_tc_user', username, **options) - return TestCenterUser.objects.get(user=user) - - -def create_tc_registration(username, course_id='org1/course1/term1', exam_code='exam1', accommodation_code=None): - - options = {'exam_series_code': exam_code, - 'eligibility_appointment_date_first': '2013-01-01T00:00', - 'eligibility_appointment_date_last': '2013-12-31T23:59', - 'accommodation_code': accommodation_code, - 'create_dummy_exam': True, - } - - call_command('pearson_make_tc_registration', username, course_id, **options) - user = User.objects.get(username=username) - registrations = get_testcenter_registration(user, course_id, exam_code) - return registrations[0] - - -def create_multiple_registrations(prefix='test'): - username1 = '{}_multiple1'.format(prefix) - create_tc_user(username1) - create_tc_registration(username1) - create_tc_registration(username1, course_id='org1/course2/term1') - create_tc_registration(username1, exam_code='exam2') - username2 = '{}_multiple2'.format(prefix) - create_tc_user(username2) - create_tc_registration(username2) - username3 = '{}_multiple3'.format(prefix) - create_tc_user(username3) - create_tc_registration(username3, course_id='org1/course2/term1') - username4 = '{}_multiple4'.format(prefix) - create_tc_user(username4) - create_tc_registration(username4, exam_code='exam2') - - -def get_command_error_text(*args, **options): - stderr_string = None - old_stderr = sys.stderr - sys.stderr = cStringIO.StringIO() - try: - call_command(*args, **options) - except SystemExit, why1: - # The goal here is to catch CommandError calls. - # But these are actually translated into nice messages, - # and sys.exit(1) is then called. For testing, we - # want to catch what sys.exit throws, and get the - # relevant text either from stdout or stderr. - if (why1.message > 0): - stderr_string = sys.stderr.getvalue() - else: - raise why1 - except Exception, why: - raise why - - finally: - sys.stderr = old_stderr - - if stderr_string is None: - raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0])) - return stderr_string - - -def get_error_string_for_management_call(*args, **options): - stdout_string = None - old_stdout = sys.stdout - old_stderr = sys.stderr - sys.stdout = cStringIO.StringIO() - sys.stderr = cStringIO.StringIO() - try: - call_command(*args, **options) - except SystemExit, why1: - # The goal here is to catch CommandError calls. - # But these are actually translated into nice messages, - # and sys.exit(1) is then called. For testing, we - # want to catch what sys.exit throws, and get the - # relevant text either from stdout or stderr. - if (why1.message == 1): - stdout_string = sys.stdout.getvalue() - stderr_string = sys.stderr.getvalue() - else: - raise why1 - except Exception, why: - raise why - - finally: - sys.stdout = old_stdout - sys.stderr = old_stderr - - if stdout_string is None: - raise Exception("Expected call to {} to fail, but it succeeded!".format(args[0])) - return stdout_string, stderr_string - - -def get_file_info(dirpath): - filelist = os.listdir(dirpath) - print 'Files found: {}'.format(filelist) - numfiles = len(filelist) - if numfiles == 1: - filepath = os.path.join(dirpath, filelist[0]) - with open(filepath, 'r') as cddfile: - filecontents = cddfile.readlines() - numlines = len(filecontents) - return filepath, numlines - else: - raise Exception("Expected to find a single file in {}, but found {}".format(dirpath, filelist)) - - -class PearsonTestCase(TestCase): - ''' - Base class for tests running Pearson-related commands - ''' - - def assertErrorContains(self, error_message, expected): - self.assertTrue(error_message.find(expected) >= 0, 'error message "{}" did not contain "{}"'.format(error_message, expected)) - - def setUp(self): - self.import_dir = mkdtemp(prefix="import") - self.addCleanup(shutil.rmtree, self.import_dir) - self.export_dir = mkdtemp(prefix="export") - self.addCleanup(shutil.rmtree, self.export_dir) - - def tearDown(self): - pass - # and clean up the database: -# TestCenterUser.objects.all().delete() -# TestCenterRegistration.objects.all().delete() - - -class PearsonCommandTestCase(PearsonTestCase): - - def test_missing_demographic_fields(self): - # We won't bother to test all details of form validation here. - # It is enough to show that it works here, but deal with test cases for the form - # validation in the student tests, not these management tests. - username = 'baduser' - User.objects.create_user(username, '{}@edx.org'.format(username), 'fakepass') - options = {} - error_string = get_command_error_text('pearson_make_tc_user', username, **options) - self.assertTrue(error_string.find('Field Form errors encountered:') >= 0) - self.assertTrue(error_string.find('Field Form Error: city') >= 0) - self.assertTrue(error_string.find('Field Form Error: first_name') >= 0) - self.assertTrue(error_string.find('Field Form Error: last_name') >= 0) - self.assertTrue(error_string.find('Field Form Error: country') >= 0) - self.assertTrue(error_string.find('Field Form Error: phone_country_code') >= 0) - self.assertTrue(error_string.find('Field Form Error: phone') >= 0) - self.assertTrue(error_string.find('Field Form Error: address_1') >= 0) - self.assertErrorContains(error_string, 'Field Form Error: address_1') - - def test_create_good_testcenter_user(self): - testcenter_user = create_tc_user("test_good_user") - self.assertIsNotNone(testcenter_user) - - def test_create_good_testcenter_registration(self): - username = 'test_good_registration' - create_tc_user(username) - registration = create_tc_registration(username) - self.assertIsNotNone(registration) - - def test_cdd_missing_option(self): - error_string = get_command_error_text('pearson_export_cdd', **{}) - self.assertErrorContains(error_string, 'Error: --destination or --dest-from-settings must be used') - - def test_ead_missing_option(self): - error_string = get_command_error_text('pearson_export_ead', **{}) - self.assertErrorContains(error_string, 'Error: --destination or --dest-from-settings must be used') - - def test_export_single_cdd(self): - # before we generate any tc_users, we expect there to be nothing to output: - options = {'dest-from-settings': True} - with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}): - call_command('pearson_export_cdd', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 1, "Expect cdd file to have no non-header lines") - os.remove(filepath) - - # generating a tc_user should result in a line in the output - username = 'test_single_cdd' - create_tc_user(username) - call_command('pearson_export_cdd', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 2, "Expect cdd file to have one non-header line") - os.remove(filepath) - - # output after registration should not have any entries again. - call_command('pearson_export_cdd', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 1, "Expect cdd file to have no non-header lines") - os.remove(filepath) - - # if we modify the record, then it should be output again: - user_options = {'first_name': 'NewTestFirst', } - call_command('pearson_make_tc_user', username, **user_options) - call_command('pearson_export_cdd', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 2, "Expect cdd file to have one non-header line") - os.remove(filepath) - - def test_export_single_ead(self): - # before we generate any registrations, we expect there to be nothing to output: - options = {'dest-from-settings': True} - with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}): - call_command('pearson_export_ead', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 1, "Expect ead file to have no non-header lines") - os.remove(filepath) - - # generating a registration should result in a line in the output - username = 'test_single_ead' - create_tc_user(username) - create_tc_registration(username) - call_command('pearson_export_ead', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 2, "Expect ead file to have one non-header line") - os.remove(filepath) - - # output after registration should not have any entries again. - call_command('pearson_export_ead', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 1, "Expect ead file to have no non-header lines") - os.remove(filepath) - - # if we modify the record, then it should be output again: - create_tc_registration(username, accommodation_code='EQPMNT') - call_command('pearson_export_ead', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 2, "Expect ead file to have one non-header line") - os.remove(filepath) - - def test_export_multiple(self): - create_multiple_registrations("export") - with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir}): - options = {'dest-from-settings': True} - call_command('pearson_export_cdd', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 5, "Expect cdd file to have four non-header lines: total was {}".format(numlines)) - os.remove(filepath) - - call_command('pearson_export_ead', **options) - (filepath, numlines) = get_file_info(self.export_dir) - self.assertEquals(numlines, 7, "Expect ead file to have six non-header lines: total was {}".format(numlines)) - os.remove(filepath) - - -# def test_bad_demographic_option(self): -# username = 'nonuser' -# output_string, stderrmsg = get_error_string_for_management_call('pearson_make_tc_user', username, **{'--garbage' : None }) -# print stderrmsg -# self.assertErrorContains(stderrmsg, 'Unexpected option') -# -# def test_missing_demographic_user(self): -# username = 'nonuser' -# output_string, error_string = get_error_string_for_management_call('pearson_make_tc_user', username, **{}) -# self.assertErrorContains(error_string, 'User matching query does not exist') - -# credentials for a test SFTP site: -SFTP_HOSTNAME = 'ec2-23-20-150-101.compute-1.amazonaws.com' -SFTP_USERNAME = 'pearsontest' -SFTP_PASSWORD = 'password goes here' - -S3_BUCKET = 'edx-pearson-archive' -AWS_ACCESS_KEY_ID = 'put yours here' -AWS_SECRET_ACCESS_KEY = 'put yours here' - - -class PearsonTransferTestCase(PearsonTestCase): - ''' - Class for tests running Pearson transfers - ''' - - def test_transfer_config(self): - stderrmsg = get_command_error_text('pearson_transfer', **{'mode': 'garbage'}) - self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries') - - stderrmsg = get_command_error_text('pearson_transfer') - self.assertErrorContains(stderrmsg, 'Error: No PEARSON entries') - - with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir, - 'LOCAL_IMPORT': self.import_dir}): - stderrmsg = get_command_error_text('pearson_transfer') - self.assertErrorContains(stderrmsg, 'Error: No entry in the PEARSON settings') - - def test_transfer_export_missing_dest_dir(self): - raise SkipTest() - create_multiple_registrations('export_missing_dest') - with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir, - 'SFTP_EXPORT': 'this/does/not/exist', - 'SFTP_HOSTNAME': SFTP_HOSTNAME, - 'SFTP_USERNAME': SFTP_USERNAME, - 'SFTP_PASSWORD': SFTP_PASSWORD, - 'S3_BUCKET': S3_BUCKET, - }, - AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY): - options = {'mode': 'export'} - stderrmsg = get_command_error_text('pearson_transfer', **options) - self.assertErrorContains(stderrmsg, 'Error: SFTP destination path does not exist') - - def test_transfer_export(self): - raise SkipTest() - create_multiple_registrations("transfer_export") - with self.settings(PEARSON={'LOCAL_EXPORT': self.export_dir, - 'SFTP_EXPORT': 'results/topvue', - 'SFTP_HOSTNAME': SFTP_HOSTNAME, - 'SFTP_USERNAME': SFTP_USERNAME, - 'SFTP_PASSWORD': SFTP_PASSWORD, - 'S3_BUCKET': S3_BUCKET, - }, - AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY): - options = {'mode': 'export'} -# call_command('pearson_transfer', **options) -# # confirm that the export directory is still empty: -# self.assertEqual(len(os.listdir(self.export_dir)), 0, "expected export directory to be empty") - - def test_transfer_import_missing_source_dir(self): - raise SkipTest() - create_multiple_registrations('import_missing_src') - with self.settings(PEARSON={'LOCAL_IMPORT': self.import_dir, - 'SFTP_IMPORT': 'this/does/not/exist', - 'SFTP_HOSTNAME': SFTP_HOSTNAME, - 'SFTP_USERNAME': SFTP_USERNAME, - 'SFTP_PASSWORD': SFTP_PASSWORD, - 'S3_BUCKET': S3_BUCKET, - }, - AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY): - options = {'mode': 'import'} - stderrmsg = get_command_error_text('pearson_transfer', **options) - self.assertErrorContains(stderrmsg, 'Error: SFTP source path does not exist') - - def test_transfer_import(self): - raise SkipTest() - create_multiple_registrations('import_missing_src') - with self.settings(PEARSON={'LOCAL_IMPORT': self.import_dir, - 'SFTP_IMPORT': 'results', - 'SFTP_HOSTNAME': SFTP_HOSTNAME, - 'SFTP_USERNAME': SFTP_USERNAME, - 'SFTP_PASSWORD': SFTP_PASSWORD, - 'S3_BUCKET': S3_BUCKET, - }, - AWS_ACCESS_KEY_ID=AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY=AWS_SECRET_ACCESS_KEY): - options = {'mode': 'import'} - call_command('pearson_transfer', **options) - self.assertEqual(len(os.listdir(self.import_dir)), 0, "expected import directory to be empty") diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 5ab15057ee..3f0c1c507f 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -11,7 +11,6 @@ file and check it in at the same time as your model changes. To do that, 3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/ """ from datetime import datetime -from random import randint import hashlib import json import logging @@ -23,8 +22,6 @@ from django.contrib.auth.signals import user_logged_in, user_logged_out from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver -import django.dispatch -from django.forms import ModelForm, forms from course_modes.models import CourseMode import lms.lib.comment_client as cc @@ -144,480 +141,6 @@ class UserProfile(models.Model): def set_meta(self, js): self.meta = json.dumps(js) -TEST_CENTER_STATUS_ACCEPTED = "Accepted" -TEST_CENTER_STATUS_ERROR = "Error" - - -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. - - Also storing here the confirmation information received from Pearson (if any) - as to the success or failure of the upload. (VCDC file) - """ - # 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 we assign our user for the Test Center. - client_candidate_id = models.CharField(unique=True, 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, db_index=True) - - # time at which edX sent the registration to the test center - uploaded_at = models.DateTimeField(null=True, blank=True, db_index=True) - - # confirmation back from the test center, as well as timestamps - # on when they processed the request, and when we received - # confirmation back. - processed_at = models.DateTimeField(null=True, db_index=True) - upload_status = models.CharField(max_length=20, blank=True, db_index=True) # 'Error' or 'Accepted' - upload_error_message = models.CharField(max_length=512, blank=True) - # Unique ID given to us for this User by the Testing Center. It's null when - # we first create the User entry, and may be assigned by Pearson later. - # (However, it may never be set if we are always initiating such candidate creation.) - candidate_id = models.IntegerField(null=True, db_index=True) - confirmed_at = models.DateTimeField(null=True, db_index=True) - - @property - def needs_uploading(self): - return self.uploaded_at is None or self.uploaded_at < self.user_updated_at - - @staticmethod - def user_provided_fields(): - return ['first_name', 'middle_name', 'last_name', 'suffix', 'salutation', - 'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country', - 'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name'] - - @property - def email(self): - return self.user.email - - def needs_update(self, fields): - for fieldname in TestCenterUser.user_provided_fields(): - if fieldname in fields and getattr(self, fieldname) != fields[fieldname]: - return True - - return False - - @staticmethod - def _generate_edx_id(prefix): - NUM_DIGITS = 12 - return u"{}{:012}".format(prefix, randint(1, 10 ** NUM_DIGITS - 1)) - - @staticmethod - def _generate_candidate_id(): - return TestCenterUser._generate_edx_id("edX") - - @classmethod - def create(cls, user): - testcenter_user = cls(user=user) - # testcenter_user.candidate_id remains unset - # assign an ID of our own: - cand_id = cls._generate_candidate_id() - while TestCenterUser.objects.filter(client_candidate_id=cand_id).exists(): - cand_id = cls._generate_candidate_id() - testcenter_user.client_candidate_id = cand_id - return testcenter_user - - @property - def is_accepted(self): - return self.upload_status == TEST_CENTER_STATUS_ACCEPTED - - @property - def is_rejected(self): - return self.upload_status == TEST_CENTER_STATUS_ERROR - - @property - def is_pending(self): - return not self.is_accepted and not self.is_rejected - - -class TestCenterUserForm(ModelForm): - class Meta: - model = TestCenterUser - fields = ('first_name', 'middle_name', 'last_name', 'suffix', 'salutation', - 'address_1', 'address_2', 'address_3', 'city', 'state', 'postal_code', 'country', - 'phone', 'extension', 'phone_country_code', 'fax', 'fax_country_code', 'company_name') - - def update_and_save(self): - new_user = self.save(commit=False) - # create additional values here: - new_user.user_updated_at = datetime.now(UTC) - new_user.upload_status = '' - new_user.save() - log.info("Updated demographic information for user's test center exam registration: username \"{}\" ".format(new_user.user.username)) - - # add validation: - - def clean_country(self): - code = self.cleaned_data['country'] - if code and (len(code) != 3 or not code.isalpha()): - raise forms.ValidationError(u'Must be three characters (ISO 3166-1): e.g. USA, CAN, MNG') - return code.upper() - - def clean(self): - def _can_encode_as_latin(fieldvalue): - try: - fieldvalue.encode('iso-8859-1') - except UnicodeEncodeError: - return False - return True - - cleaned_data = super(TestCenterUserForm, self).clean() - - # check for interactions between fields: - if 'country' in cleaned_data: - country = cleaned_data.get('country') - if country == 'USA' or country == 'CAN': - if 'state' in cleaned_data and len(cleaned_data['state']) == 0: - self._errors['state'] = self.error_class([u'Required if country is USA or CAN.']) - del cleaned_data['state'] - - if 'postal_code' in cleaned_data and len(cleaned_data['postal_code']) == 0: - self._errors['postal_code'] = self.error_class([u'Required if country is USA or CAN.']) - del cleaned_data['postal_code'] - - if 'fax' in cleaned_data and len(cleaned_data['fax']) > 0 and 'fax_country_code' in cleaned_data and len(cleaned_data['fax_country_code']) == 0: - self._errors['fax_country_code'] = self.error_class([u'Required if fax is specified.']) - del cleaned_data['fax_country_code'] - - # check encoding for all fields: - cleaned_data_fields = [fieldname for fieldname in cleaned_data] - for fieldname in cleaned_data_fields: - if not _can_encode_as_latin(cleaned_data[fieldname]): - self._errors[fieldname] = self.error_class([u'Must only use characters in Latin-1 (iso-8859-1) encoding']) - del cleaned_data[fieldname] - - # Always return the full collection of cleaned data. - return cleaned_data - -# our own code to indicate that a request has been rejected. -ACCOMMODATION_REJECTED_CODE = 'NONE' - -ACCOMMODATION_CODES = ( - (ACCOMMODATION_REJECTED_CODE, 'No Accommodation Granted'), - ('EQPMNT', 'Equipment'), - ('ET12ET', 'Extra Time - 1/2 Exam Time'), - ('ET30MN', 'Extra Time - 30 Minutes'), - ('ETDBTM', 'Extra Time - Double Time'), - ('SEPRMM', 'Separate Room'), - ('SRREAD', 'Separate Room and Reader'), - ('SRRERC', 'Separate Room and Reader/Recorder'), - ('SRRECR', 'Separate Room and Recorder'), - ('SRSEAN', 'Separate Room and Service Animal'), - ('SRSGNR', 'Separate Room and Sign Language Interpreter'), -) - -ACCOMMODATION_CODE_DICT = {code: name for (code, name) in ACCOMMODATION_CODES} - - -class TestCenterRegistration(models.Model): - """ - This is our representation of a user's registration 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. This is less of an issue - than for the TestCenterUser. - * Registrations are only created here when a user registers to take an exam in person. - - The field names and lengths are modeled on the conventions and constraints - of Pearson's data import system. - """ - # to find an exam registration, we key off of the user and course_id. - # If multiple exams per course are possible, we would also need to add the - # exam_series_code. - testcenter_user = models.ForeignKey(TestCenterUser, default=None) - course_id = models.CharField(max_length=128, db_index=True) - - 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. - # The appointment dates, the exam count, and the accommodation codes can be updated, - # but hopefully this won't happen often. - user_updated_at = models.DateTimeField(db_index=True) - # "client_authorization_id" is our unique identifier for the authorization. - # This must be present for an update or delete to be sent to Pearson. - client_authorization_id = models.CharField(max_length=20, unique=True, db_index=True) - - # information about the test, from the course policy: - exam_series_code = models.CharField(max_length=15, db_index=True) - eligibility_appointment_date_first = models.DateField(db_index=True) - eligibility_appointment_date_last = models.DateField(db_index=True) - - # this is really a list of codes, using an '*' as a delimiter. - # So it's not a choice list. We use the special value of ACCOMMODATION_REJECTED_CODE - # to indicate the rejection of an accommodation request. - accommodation_code = models.CharField(max_length=64, blank=True) - - # store the original text of the accommodation request. - accommodation_request = models.CharField(max_length=1024, blank=True, db_index=False) - - # time at which edX sent the registration to the test center - uploaded_at = models.DateTimeField(null=True, db_index=True) - - # confirmation back from the test center, as well as timestamps - # on when they processed the request, and when we received - # confirmation back. - processed_at = models.DateTimeField(null=True, db_index=True) - upload_status = models.CharField(max_length=20, blank=True, db_index=True) # 'Error' or 'Accepted' - upload_error_message = models.CharField(max_length=512, blank=True) - # Unique ID given to us for this registration by the Testing Center. It's null when - # we first create the registration entry, and may be assigned by Pearson later. - # (However, it may never be set if we are always initiating such candidate creation.) - authorization_id = models.IntegerField(null=True, db_index=True) - confirmed_at = models.DateTimeField(null=True, db_index=True) - - @property - def candidate_id(self): - return self.testcenter_user.candidate_id - - @property - def client_candidate_id(self): - return self.testcenter_user.client_candidate_id - - @property - def authorization_transaction_type(self): - if self.authorization_id is not None: - return 'Update' - elif self.uploaded_at is None: - return 'Add' - elif self.registration_is_rejected: - # Assume that if the registration was rejected before, - # it is more likely this is the (first) correction - # than a second correction in flight before the first was - # processed. - return 'Add' - else: - # TODO: decide what to send when we have uploaded an initial version, - # but have not received confirmation back from that upload. If the - # registration here has been changed, then we don't know if this changed - # registration should be submitted as an 'add' or an 'update'. - # - # If the first registration were lost or in error (e.g. bad code), - # the second should be an "Add". If the first were processed successfully, - # then the second should be an "Update". We just don't know.... - return 'Update' - - @property - def exam_authorization_count(self): - # Someday this could go in the database (with a default value). But at present, - # we do not expect anyone to be authorized to take an exam more than once. - return 1 - - @property - def needs_uploading(self): - return self.uploaded_at is None or self.uploaded_at < self.user_updated_at - - @classmethod - def create(cls, testcenter_user, exam, accommodation_request): - registration = cls(testcenter_user=testcenter_user) - registration.course_id = exam.course_id - registration.accommodation_request = accommodation_request.strip() - registration.exam_series_code = exam.exam_series_code - registration.eligibility_appointment_date_first = exam.first_eligible_appointment_date.strftime("%Y-%m-%d") - registration.eligibility_appointment_date_last = exam.last_eligible_appointment_date.strftime("%Y-%m-%d") - registration.client_authorization_id = cls._create_client_authorization_id() - # accommodation_code remains blank for now, along with Pearson confirmation information - return registration - - @staticmethod - def _generate_authorization_id(): - return TestCenterUser._generate_edx_id("edXexam") - - @staticmethod - def _create_client_authorization_id(): - """ - Return a unique id for a registration, suitable for using as an authorization code - for Pearson. It must fit within 20 characters. - """ - # generate a random value, and check to see if it already is in use here - auth_id = TestCenterRegistration._generate_authorization_id() - while TestCenterRegistration.objects.filter(client_authorization_id=auth_id).exists(): - auth_id = TestCenterRegistration._generate_authorization_id() - return auth_id - - # methods for providing registration status details on registration page: - @property - def demographics_is_accepted(self): - return self.testcenter_user.is_accepted - - @property - def demographics_is_rejected(self): - return self.testcenter_user.is_rejected - - @property - def demographics_is_pending(self): - return self.testcenter_user.is_pending - - @property - def accommodation_is_accepted(self): - return len(self.accommodation_request) > 0 and len(self.accommodation_code) > 0 and self.accommodation_code != ACCOMMODATION_REJECTED_CODE - - @property - def accommodation_is_rejected(self): - return len(self.accommodation_request) > 0 and self.accommodation_code == ACCOMMODATION_REJECTED_CODE - - @property - def accommodation_is_pending(self): - return len(self.accommodation_request) > 0 and len(self.accommodation_code) == 0 - - @property - def accommodation_is_skipped(self): - return len(self.accommodation_request) == 0 - - @property - def registration_is_accepted(self): - return self.upload_status == TEST_CENTER_STATUS_ACCEPTED - - @property - def registration_is_rejected(self): - return self.upload_status == TEST_CENTER_STATUS_ERROR - - @property - def registration_is_pending(self): - return not self.registration_is_accepted and not self.registration_is_rejected - - # methods for providing registration status summary on dashboard page: - @property - def is_accepted(self): - return self.registration_is_accepted and self.demographics_is_accepted - - @property - def is_rejected(self): - return self.registration_is_rejected or self.demographics_is_rejected - - @property - def is_pending(self): - return not self.is_accepted and not self.is_rejected - - def get_accommodation_codes(self): - return self.accommodation_code.split('*') - - def get_accommodation_names(self): - return [ACCOMMODATION_CODE_DICT.get(code, "Unknown code " + code) for code in self.get_accommodation_codes()] - - @property - def registration_signup_url(self): - return settings.PEARSONVUE_SIGNINPAGE_URL - - def demographics_status(self): - if self.demographics_is_accepted: - return "Accepted" - elif self.demographics_is_rejected: - return "Rejected" - else: - return "Pending" - - def accommodation_status(self): - if self.accommodation_is_skipped: - return "Skipped" - elif self.accommodation_is_accepted: - return "Accepted" - elif self.accommodation_is_rejected: - return "Rejected" - else: - return "Pending" - - def registration_status(self): - if self.registration_is_accepted: - return "Accepted" - elif self.registration_is_rejected: - return "Rejected" - else: - return "Pending" - - -class TestCenterRegistrationForm(ModelForm): - class Meta: - model = TestCenterRegistration - fields = ('accommodation_request', 'accommodation_code') - - def clean_accommodation_request(self): - code = self.cleaned_data['accommodation_request'] - if code and len(code) > 0: - return code.strip() - return code - - def update_and_save(self): - registration = self.save(commit=False) - # create additional values here: - registration.user_updated_at = datetime.now(UTC) - registration.upload_status = '' - registration.save() - log.info("Updated registration information for user's test center exam registration: username \"{}\" course \"{}\", examcode \"{}\"".format(registration.testcenter_user.user.username, registration.course_id, registration.exam_series_code)) - - def clean_accommodation_code(self): - code = self.cleaned_data['accommodation_code'] - if code: - code = code.upper() - codes = code.split('*') - for codeval in codes: - if codeval not in ACCOMMODATION_CODE_DICT: - raise forms.ValidationError(u'Invalid accommodation code specified: "{}"'.format(codeval)) - return code - - -def get_testcenter_registration(user, course_id, exam_series_code): - try: - tcu = TestCenterUser.objects.get(user=user) - except TestCenterUser.DoesNotExist: - return [] - return TestCenterRegistration.objects.filter(testcenter_user=tcu, course_id=course_id, exam_series_code=exam_series_code) - -# nosetests thinks that anything with _test_ in the name is a test. -# Correct this (https://nose.readthedocs.org/en/latest/finding_tests.html) -get_testcenter_registration.__test__ = False - def unique_id_for_user(user): """ diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 81aa859563..2c3099a672 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -39,10 +39,9 @@ from mitxmako.shortcuts import render_to_response, render_to_string from course_modes.models import CourseMode from student.models import ( - Registration, UserProfile, TestCenterUser, TestCenterUserForm, - TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange, + Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, - get_testcenter_registration, CourseEnrollmentAllowed, UserStanding, + CourseEnrollmentAllowed, UserStanding, ) from student.forms import PasswordResetFormNoActive @@ -966,172 +965,6 @@ def create_account(request, post_override=None): return response -def exam_registration_info(user, course): - """ Returns a Registration object if the user is currently registered for a current - exam of the course. Returns None if the user is not registered, or if there is no - current exam for the course. - """ - exam_info = course.current_test_center_exam - if exam_info is None: - return None - - exam_code = exam_info.exam_series_code - registrations = get_testcenter_registration(user, course.id, exam_code) - if registrations: - registration = registrations[0] - else: - registration = None - return registration - - -@login_required -@ensure_csrf_cookie -def begin_exam_registration(request, course_id): - """ Handles request to register the user for the current - test center exam of the specified course. Called by form - in dashboard.html. - """ - user = request.user - - try: - course = course_from_id(course_id) - except ItemNotFoundError: - log.error("User {0} enrolled in non-existent course {1}".format(user.username, course_id)) - raise Http404 - - # get the exam to be registered for: - # (For now, we just assume there is one at most.) - # if there is no exam now (because someone bookmarked this stupid page), - # then return a 404: - exam_info = course.current_test_center_exam - if exam_info is None: - raise Http404 - - # determine if the user is registered for this course: - registration = exam_registration_info(user, course) - - # we want to populate the registration page with the relevant information, - # if it already exists. Create an empty object otherwise. - try: - testcenteruser = TestCenterUser.objects.get(user=user) - except TestCenterUser.DoesNotExist: - testcenteruser = TestCenterUser() - testcenteruser.user = user - - context = {'course': course, - 'user': user, - 'testcenteruser': testcenteruser, - 'registration': registration, - 'exam_info': exam_info, - } - - return render_to_response('test_center_register.html', context) - - -@ensure_csrf_cookie -def create_exam_registration(request, post_override=None): - """ - JSON call to create a test center exam registration. - Called by form in test_center_register.html - """ - post_vars = post_override if post_override else request.POST - - # first determine if we need to create a new TestCenterUser, or if we are making any update - # to an existing TestCenterUser. - username = post_vars['username'] - user = User.objects.get(username=username) - course_id = post_vars['course_id'] - course = course_from_id(course_id) # assume it will be found.... - - # make sure that any demographic data values received from the page have been stripped. - # Whitespace is not an acceptable response for any of these values - demographic_data = {} - for fieldname in TestCenterUser.user_provided_fields(): - if fieldname in post_vars: - demographic_data[fieldname] = (post_vars[fieldname]).strip() - try: - testcenter_user = TestCenterUser.objects.get(user=user) - needs_updating = testcenter_user.needs_update(demographic_data) - log.info("User {0} enrolled in course {1} {2}updating demographic info for exam registration".format(user.username, course_id, "" if needs_updating else "not ")) - except TestCenterUser.DoesNotExist: - # do additional initialization here: - testcenter_user = TestCenterUser.create(user) - needs_updating = True - log.info("User {0} enrolled in course {1} creating demographic info for exam registration".format(user.username, course_id)) - - # perform validation: - if needs_updating: - # first perform validation on the user information - # using a Django Form. - form = TestCenterUserForm(instance=testcenter_user, data=demographic_data) - if form.is_valid(): - form.update_and_save() - else: - response_data = {'success': False} - # return a list of errors... - response_data['field_errors'] = form.errors - response_data['non_field_errors'] = form.non_field_errors() - return HttpResponse(json.dumps(response_data), mimetype="application/json") - - # create and save the registration: - needs_saving = False - exam = course.current_test_center_exam - exam_code = exam.exam_series_code - registrations = get_testcenter_registration(user, course_id, exam_code) - if registrations: - registration = registrations[0] - # NOTE: we do not bother to check here to see if the registration has changed, - # because at the moment there is no way for a user to change anything about their - # registration. They only provide an optional accommodation request once, and - # cannot make changes to it thereafter. - # It is possible that the exam_info content has been changed, such as the - # scheduled exam dates, but those kinds of changes should not be handled through - # this registration screen. - - else: - accommodation_request = post_vars.get('accommodation_request', '') - registration = TestCenterRegistration.create(testcenter_user, exam, accommodation_request) - needs_saving = True - log.info("User {0} enrolled in course {1} creating new exam registration".format(user.username, course_id)) - - if needs_saving: - # do validation of registration. (Mainly whether an accommodation request is too long.) - form = TestCenterRegistrationForm(instance=registration, data=post_vars) - if form.is_valid(): - form.update_and_save() - else: - response_data = {'success': False} - # return a list of errors... - response_data['field_errors'] = form.errors - response_data['non_field_errors'] = form.non_field_errors() - return HttpResponse(json.dumps(response_data), mimetype="application/json") - - # only do the following if there is accommodation text to send, - # and a destination to which to send it. - # TODO: still need to create the accommodation email templates -# if 'accommodation_request' in post_vars and 'TESTCENTER_ACCOMMODATION_REQUEST_EMAIL' in settings: -# d = {'accommodation_request': post_vars['accommodation_request'] } -# -# # composes accommodation email -# subject = render_to_string('emails/accommodation_email_subject.txt', d) -# # Email subject *must not* contain newlines -# subject = ''.join(subject.splitlines()) -# message = render_to_string('emails/accommodation_email.txt', d) -# -# try: -# dest_addr = settings['TESTCENTER_ACCOMMODATION_REQUEST_EMAIL'] -# from_addr = user.email -# send_mail(subject, message, from_addr, [dest_addr], fail_silently=False) -# except: -# log.exception(sys.exc_info()) -# response_data = {'success': False} -# response_data['non_field_errors'] = [ 'Could not send accommodation e-mail.', ] -# return HttpResponse(json.dumps(response_data), mimetype="application/json") - - js = {'success': True} - return HttpResponse(json.dumps(js), mimetype="application/json") - - def auto_auth(request): """ Automatically logs the user in with a generated random credentials diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index c55ff4a348..2404f1dc0f 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -20,7 +20,6 @@ XMODULES = [ "section = xmodule.backcompat_module:SemanticSectionDescriptor", "sequential = xmodule.seq_module:SequenceDescriptor", "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "timelimit = xmodule.timelimit_module:TimeLimitDescriptor", "vertical = xmodule.vertical_module:VerticalDescriptor", "video = xmodule.video_module:VideoDescriptor", "videoalpha = xmodule.video_module:VideoDescriptor", diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 26fb1faa91..0ddd27d202 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -213,7 +213,6 @@ class CourseFields(object): discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings) discussion_topics = Dict(help="Map of topics names to ids", scope=Scope.settings) discussion_sort_alpha = Boolean(scope=Scope.settings, default=False, help="Sort forum categories and subcategories alphabetically.") - testcenter_info = Dict(help="Dictionary of Test Center info", scope=Scope.settings) announcement = Date(help="Date this course is announced", scope=Scope.settings) cohort_config = Dict(help="Dictionary defining cohort configuration", scope=Scope.settings) is_new = Boolean(help="Whether this course should be flagged as new", scope=Scope.settings) @@ -426,20 +425,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): if self.discussion_topics == {}: self.discussion_topics = {'General': {'id': self.location.html_id()}} - self.test_center_exams = [] - test_center_info = self.testcenter_info - if test_center_info is not None: - for exam_name in test_center_info: - try: - exam_info = test_center_info[exam_name] - self.test_center_exams.append(self.TestCenterExam(self.id, exam_name, exam_info)) - except Exception as err: - # If we can't parse the test center exam info, don't break - # the rest of the courseware. - msg = 'Error %s: Unable to load test-center exam info for exam "%s" of course "%s"' % (err, exam_name, self.id) - log.error(msg) - continue - # TODO check that this is still needed here and can't be by defaults. if not self.tabs: # When calling the various _tab methods, can omit the 'type':'blah' from the @@ -876,93 +861,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): return True - class TestCenterExam(object): - def __init__(self, course_id, exam_name, exam_info): - self.course_id = course_id - self.exam_name = exam_name - self.exam_info = exam_info - self.exam_series_code = exam_info.get('Exam_Series_Code') or exam_name - self.display_name = exam_info.get('Exam_Display_Name') or self.exam_series_code - self.first_eligible_appointment_date = self._try_parse_time('First_Eligible_Appointment_Date') - if self.first_eligible_appointment_date is None: - raise ValueError("First appointment date must be specified") - # TODO: If defaulting the last appointment date, it should be the - # *end* of the same day, not the same time. It's going to be used as the - # end of the exam overall, so we don't want the exam to disappear too soon. - # It's also used optionally as the registration end date, so time matters there too. - self.last_eligible_appointment_date = self._try_parse_time('Last_Eligible_Appointment_Date') # or self.first_eligible_appointment_date - if self.last_eligible_appointment_date is None: - raise ValueError("Last appointment date must be specified") - self.registration_start_date = (self._try_parse_time('Registration_Start_Date') or - datetime.fromtimestamp(0, UTC())) - self.registration_end_date = self._try_parse_time('Registration_End_Date') or self.last_eligible_appointment_date - # do validation within the exam info: - if self.registration_start_date > self.registration_end_date: - raise ValueError("Registration start date must be before registration end date") - if self.first_eligible_appointment_date > self.last_eligible_appointment_date: - raise ValueError("First appointment date must be before last appointment date") - if self.registration_end_date > self.last_eligible_appointment_date: - raise ValueError("Registration end date must be before last appointment date") - self.exam_url = exam_info.get('Exam_URL') - - def _try_parse_time(self, key): - """ - Parse an optional metadata key containing a time: if present, complain - if it doesn't parse. - Return None if not present or invalid. - """ - if key in self.exam_info: - try: - return Date().from_json(self.exam_info[key]) - except ValueError as e: - msg = "Exam {0} in course {1} loaded with a bad exam_info key '{2}': '{3}'".format(self.exam_name, self.course_id, self.exam_info[key], e) - log.warning(msg) - return None - - def has_started(self): - return datetime.now(UTC()) > self.first_eligible_appointment_date - - def has_ended(self): - return datetime.now(UTC()) > self.last_eligible_appointment_date - - def has_started_registration(self): - return datetime.now(UTC()) > self.registration_start_date - - def has_ended_registration(self): - return datetime.now(UTC()) > self.registration_end_date - - def is_registering(self): - now = datetime.now(UTC()) - return now >= self.registration_start_date and now <= self.registration_end_date - - @property - def first_eligible_appointment_date_text(self): - return self.first_eligible_appointment_date.strftime("%b %d, %Y") - - @property - def last_eligible_appointment_date_text(self): - return self.last_eligible_appointment_date.strftime("%b %d, %Y") - - @property - def registration_end_date_text(self): - return date_utils.get_default_time_display(self.registration_end_date) - - @property - def current_test_center_exam(self): - exams = [exam for exam in self.test_center_exams if exam.has_started_registration() and not exam.has_ended()] - if len(exams) > 1: - # TODO: output some kind of warning. This should already be - # caught if we decide to do validation at load time. - return exams[0] - elif len(exams) == 1: - return exams[0] - else: - return None - - def get_test_center_exam(self, exam_series_code): - exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code] - return exams[0] if len(exams) == 1 else None - @property def number(self): return self.location.course diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 90e633d8a1..9d055356fc 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -79,7 +79,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): # tags that really need unique names--they store (or should store) state. need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', - 'videosequence', 'poll_question', 'timelimit', 'vertical') + 'videosequence', 'poll_question', 'vertical') attr = xml_data.attrib tag = xml_data.tag diff --git a/common/lib/xmodule/xmodule/tests/rendering/__init__.py b/common/lib/xmodule/xmodule/tests/rendering/__init__.py index 9a3a52262e..d93c168c8c 100644 --- a/common/lib/xmodule/xmodule/tests/rendering/__init__.py +++ b/common/lib/xmodule/xmodule/tests/rendering/__init__.py @@ -1,2 +1 @@ import core -import xmodule_asserts \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/tests/rendering/xmodule_asserts.py b/common/lib/xmodule/xmodule/tests/rendering/xmodule_asserts.py deleted file mode 100644 index fa4dd66b06..0000000000 --- a/common/lib/xmodule/xmodule/tests/rendering/xmodule_asserts.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -View assertion functions for XModules -""" - -from __future__ import absolute_import - -from nose.tools import assert_equals, assert_not_equals # pylint: disable=no-name-in-module - -from xmodule.timelimit_module import TimeLimitModule, TimeLimitDescriptor - -from xmodule.tests.rendering.core import assert_student_view_valid_html, assert_student_view_invalid_html - - -@assert_student_view_valid_html.register(TimeLimitModule) -@assert_student_view_valid_html.register(TimeLimitDescriptor) -def _(block, html): - """ - Assert that a TimeLimitModule renders student_view html correctly - """ - assert_not_equals(0, block.get_display_items()) - assert_student_view_valid_html(block.get_children()[0], html) - - -@assert_student_view_invalid_html.register(TimeLimitModule) -@assert_student_view_invalid_html.register(TimeLimitDescriptor) -def _(block, html): - """ - Assert that a TimeLimitModule renders student_view html correctly - """ - assert_equals(0, len(block.get_display_items())) - assert_equals(u"", html) diff --git a/common/lib/xmodule/xmodule/timelimit_module.py b/common/lib/xmodule/xmodule/timelimit_module.py deleted file mode 100644 index 73744b5e8b..0000000000 --- a/common/lib/xmodule/xmodule/timelimit_module.py +++ /dev/null @@ -1,136 +0,0 @@ -import logging - -from lxml import etree -from time import time - -from xmodule.editing_module import XMLEditingDescriptor -from xmodule.xml_module import XmlDescriptor -from xmodule.x_module import XModule -from xmodule.progress import Progress -from xmodule.exceptions import NotFoundError -from xblock.fields import Float, String, Boolean, Scope -from xblock.fragment import Fragment - - -log = logging.getLogger(__name__) - - -class TimeLimitFields(object): - has_children = True - - beginning_at = Float(help="The time this timer was started", scope=Scope.user_state) - ending_at = Float(help="The time this timer will end", scope=Scope.user_state) - accomodation_code = String(help="A code indicating accommodations to be given the student", scope=Scope.user_state) - time_expired_redirect_url = String(help="Url to redirect users to after the timelimit has expired", scope=Scope.settings) - duration = Float(help="The length of this timer", scope=Scope.settings) - suppress_toplevel_navigation = Boolean(help="Whether the toplevel navigation should be suppressed when viewing this module", scope=Scope.settings) - - -class TimeLimitModule(TimeLimitFields, XModule): - ''' - Wrapper module which imposes a time constraint for the completion of its child. - ''' - - # For a timed activity, we are only interested here - # in time-related accommodations, and these should be disjoint. - # (For proctored exams, it is possible to have multiple accommodations - # apply to an exam, so they require accommodating a multi-choice.) - TIME_ACCOMMODATION_CODES = (('NONE', 'No Time Accommodation'), - ('ADDHALFTIME', 'Extra Time - 1 1/2 Time'), - ('ADD30MIN', 'Extra Time - 30 Minutes'), - ('DOUBLE', 'Extra Time - Double Time'), - ('TESTING', 'Extra Time -- Large amount for testing purposes') - ) - - def _get_accommodated_duration(self, duration): - ''' - Get duration for activity, as adjusted for accommodations. - Input and output are expressed in seconds. - ''' - if self.accommodation_code is None or self.accommodation_code == 'NONE': - return duration - elif self.accommodation_code == 'ADDHALFTIME': - # TODO: determine what type to return - return int(duration * 1.5) - elif self.accommodation_code == 'ADD30MIN': - return (duration + (30 * 60)) - elif self.accommodation_code == 'DOUBLE': - return (duration * 2) - elif self.accommodation_code == 'TESTING': - # when testing, set timer to run for a week at a time. - return 3600 * 24 * 7 - - @property - def has_begun(self): - return self.beginning_at is not None - - @property - def has_ended(self): - if not self.ending_at: - return False - return self.ending_at < time() - - def begin(self, duration): - ''' - Sets the starting time and ending time for the activity, - based on the duration provided (in seconds). - ''' - self.beginning_at = time() - modified_duration = self._get_accommodated_duration(duration) - self.ending_at = self.beginning_at + modified_duration - - def get_remaining_time_in_ms(self): - return int((self.ending_at - time()) * 1000) - - def student_view(self, context): - # assumes there is one and only one child, so it only renders the first child - children = self.get_display_items() - if children: - child = children[0] - return child.render('student_view', context) - else: - return Fragment() - - def get_progress(self): - ''' Return the total progress, adding total done and total available. - (assumes that each submodule uses the same "units" for progress.) - ''' - # TODO: Cache progress or children array? - children = self.get_children() - progresses = [child.get_progress() for child in children] - progress = reduce(Progress.add_counts, progresses) - return progress - - def handle_ajax(self, _dispatch, _data): - raise NotFoundError('Unexpected dispatch type') - - def get_icon_class(self): - children = self.get_children() - if children: - return children[0].get_icon_class() - else: - return "other" - -class TimeLimitDescriptor(TimeLimitFields, XMLEditingDescriptor, XmlDescriptor): - - module_class = TimeLimitModule - - @classmethod - def definition_from_xml(cls, xml_object, system): - children = [] - for child in xml_object: - try: - children.append(system.process_xml(etree.tostring(child, encoding='unicode')).location.url()) - except Exception as e: - log.exception("Unable to load child when parsing TimeLimit wrapper. Continuing...") - if system.error_tracker is not None: - system.error_tracker("ERROR: " + str(e)) - continue - return {}, children - - def definition_to_xml(self, resource_fs): - xml_object = etree.Element('timelimit') - for child in self.get_children(): - xml_object.append( - etree.fromstring(child.export_to_xml(resource_fs))) - return xml_object diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index c64f1e0be3..e692dfb21c 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -3,7 +3,6 @@ import copy import logging import os import sys -from collections import namedtuple from lxml import etree from xblock.fields import Dict, Scope, ScopeIds @@ -133,15 +132,12 @@ class XmlDescriptor(XModuleDescriptor): 'ispublic', # if True, then course is listed for all users; see 'xqa_key', # for xqaa server access 'giturl', # url of git server for origin of file - # information about testcenter exams is a dict (of dicts), not a string, - # so it cannot be easily exportable as a course element's attribute. - 'testcenter_info', # VS[compat] Remove once unused. 'name', 'slug') metadata_to_strip = ('data_dir', 'tabs', 'grading_policy', 'published_by', 'published_date', - 'discussion_blackouts', 'testcenter_info', + 'discussion_blackouts', # VS[compat] -- remove the below attrs once everything is in the CMS 'course', 'org', 'url_name', 'filename', # Used for storing xml attributes between import and export, for roundtrips diff --git a/common/test/data/test_exam_registration/policies/2012_Fall.json b/common/test/data/test_exam_registration/policies/2012_Fall.json index 49af7d1527..c7e1e3fffe 100644 --- a/common/test/data/test_exam_registration/policies/2012_Fall.json +++ b/common/test/data/test_exam_registration/policies/2012_Fall.json @@ -3,21 +3,6 @@ "graceperiod": "2 days 5 hours 59 minutes 59 seconds", "start": "2011-07-17T12:00", "display_name": "Toy Course", - "testcenter_info": { - "Midterm_Exam": { - "Exam_Series_Code": "Midterm_Exam", - "First_Eligible_Appointment_Date": "2012-11-09T00:00", - "Last_Eligible_Appointment_Date": "2012-11-09T23:59" - }, - "Final_Exam": { - "Exam_Series_Code": "mit6002xfall12a", - "Exam_Display_Name": "Final Exam", - "First_Eligible_Appointment_Date": "2013-01-25T00:00", - "Last_Eligible_Appointment_Date": "2013-01-25T23:59", - "Registration_Start_Date": "2013-01-01T00:00", - "Registration_End_Date": "2013-01-21T23:59" - } - } }, "chapter/Overview": { "display_name": "Overview" diff --git a/docs/data/source/internal_data_formats/sql_schema.rst b/docs/data/source/internal_data_formats/sql_schema.rst index 078928c21b..6cfeec3179 100644 --- a/docs/data/source/internal_data_formats/sql_schema.rst +++ b/docs/data/source/internal_data_formats/sql_schema.rst @@ -19,7 +19,7 @@ All of our tables will be described below, first in summary form with field type .. list-table:: :widths: 10 80 :header-rows: 1 - + * - Value - Meaning * - `int` @@ -36,13 +36,13 @@ All of our tables will be described below, first in summary form with field type - Date * - `datetime` - Datetime in UTC, precision in seconds. - + `Null` .. list-table:: :widths: 10 80 :header-rows: 1 - + * - Value - Meaning * - `YES` @@ -57,7 +57,7 @@ All of our tables will be described below, first in summary form with field type .. list-table:: :widths: 10 80 :header-rows: 1 - + * - Value - Meaning * - `PRI` @@ -252,19 +252,19 @@ There is an important split in demographic data gathered for the students who si `old_names` A list of the previous names this user had, and the timestamps at which they submitted a request to change those names. These name change request submissions used to require a staff member to approve it before the name change took effect. This is no longer the case, though we still record their previous names. - + Note that the value stored for each entry is the name they had, not the name they requested to get changed to. People often changed their names as the time for certificate generation approached, to replace nicknames with their actual names or correct spelling/punctuation errors. - + The timestamps are UTC, like all datetimes stored in our system. - + `old_emails` A list of previous emails this user had, with timestamps of when they changed them, in a format similar to `old_names`. There was never an approval process for this. - + The timestamps are UTC, like all datetimes stored in our system. - + `6002x_exit_response` Answers to a survey that was sent to students after the prototype 6.002x course in the Spring of 2012. The questions and number of questions were randomly selected to measure how much survey length affected response rate. Only students from this course have this field. - + `courseware` ------------ @@ -277,7 +277,7 @@ There is an important split in demographic data gathered for the students who si .. list-table:: :widths: 10 80 :header-rows: 1 - + * - Value - Meaning * - `NULL` @@ -306,10 +306,10 @@ There is an important split in demographic data gathered for the students who si .. list-table:: :widths: 10 80 :header-rows: 1 - + * - Value - Meaning - * - `NULL` + * - `NULL` - This student signed up before this information was collected * - `''` (blank) - User did not specify level of education. @@ -335,7 +335,7 @@ There is an important split in demographic data gathered for the students who si - None * - `'other'` - Other - + `goals` ------- Text field collected during student signup in response to the prompt, "Goals in signing up for edX". We only started collecting this information after the transition from MITx to edX, so prototype course students will have `NULL` for this field. Students who elected not to enter anything will have a blank string. @@ -382,7 +382,7 @@ Any piece of content in the courseware can store state and score in the `coursew .. warning:: **Modules might not be what you expect!** - + It's important to understand what "modules" are in the context of our system, as the terminology can be confusing. For the conventions of this table and many parts of our code, a "module" is a content piece that appears in the courseware. This can be nearly anything that appears when users are in the courseware tab: a video, a piece of HTML, a problem, etc. Modules can also be collections of other modules, such as sequences, verticals (modules stacked together on the same page), weeks, chapters, etc. In fact, the course itself is a top level module that contains all the other contents of the course as children. You can imagine the entire course as a tree with modules at every node. Modules can store state, but whether and how they do so is up to the implemenation for that particular kind of module. When a user loads page, we look up all the modules they need to render in order to display it, and then we ask the database to look up state for those modules for that user. If there is corresponding entry for that user for a given module, we create a new row and set the state to an empty JSON dictionary. @@ -420,7 +420,7 @@ The `courseware_studentmodule` table holds all courseware state for a given user .. list-table:: :widths: 10 80 :header-rows: 0 - + * - `chapter` - The top level categories for a course. Each of these is usually labeled as a Week in the courseware, but this is just convention. * - `combinedopenended` @@ -437,8 +437,6 @@ The `courseware_studentmodule` table holds all courseware state for a given user - Self assessment problems. An early test of the open ended grading system that is not in widespread use yet. Recently deprecated in favor of `combinedopenended`. * - `sequential` - A collection of videos, problems, and other materials, rendered as a horizontal icon bar in the courseware. - * - `timelimit` - - A special module that records the time you start working on a piece of courseware and enforces time limits, used for Pearson exams. This hasn't been completely generalized yet, so is not available for widespread use. * - `videosequence` - A collection of videos, exercise problems, and other materials, rendered as a horizontal icon bar in the courseware. Use is inconsistent, and some courses use a `sequential` instead. @@ -451,20 +449,20 @@ The `courseware_studentmodule` table holds all courseware state for a given user .. list-table:: Breakdown of example `module_id`: `i4x://MITx/3.091x/problemset/Sample_Problems` :widths: 10 20 70 :header-rows: 1 - + * - Part - Example - Definition * - `i4x://` - - + - - Just a convention we ran with. We had plans for the domain `i4x.org` at one point. * - `org` - `MITx` - The organization part of the ID, indicating what organization created this piece of content. - * - `course_num` + * - `course_num` - `3.091x` - The course number this content was created for. Note that there is no run information here, so you can't know what runs of the course this content is being used for from the `module_id` alone; you have to look at the `courseware_studentmodule.course_id` field. - * - `module_type` + * - `module_type` - `problemset` - The module type, same value as what's in the `courseware_studentmodule.module_type` field. * - `module_name` @@ -501,33 +499,6 @@ The `courseware_studentmodule` table holds all courseware state for a given user `selfassessment` TODO: More details to come. - `timelimit` - This very uncommon type was only used in one Pearson exam for one course, and the format may change significantly in the future. It is currently a JSON dictionary with fields: - - .. list-table:: - :widths: 10 20 70 - :header-rows: 1 - - * - JSON field - - Example - - Definition - * - `beginning_at` - - `1360590255.488154` - - UTC time as measured in seconds since UNIX epoch representing when the exam was started. - * - `ending_at` - - `1360596632.559758` - - UTC time as measured in seconds since UNIX epoch representing the time the exam will close. - * - `accomodation_codes` - - `DOUBLE` - - (optional) Sometimes students are given more time for accessibility reasons. Possible values are: - - * `NONE`: no time accommodation - * `ADDHALFTIME`: 1.5X normal time allowance - * `ADD30MIN`: normal time allowance + 30 minutes - * `DOUBLE`: 2X normal time allowance - * `TESTING`: extra long period (for testing/debugging) - - `grade` ------- Floating point value indicating the total unweighted grade for this problem that the student has scored. Basically how many responses they got right within the problem. @@ -608,13 +579,13 @@ The generatedcertificate table tracks certificate state for students who have be * `notpassing` * `restricted` * `error` - + After a course has been graded and certificates have been issued status will be one of: - + * `downloadable` * `notpassing` * `restricted` - + If the status is `downloadable` then the student passed the course and there will be a certificate available for download. `download_url` diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index a164543930..068c2e95cd 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -27,7 +27,6 @@ class StudentModule(models.Model): MODULE_TYPES = (('problem', 'problem'), ('video', 'video'), ('html', 'html'), - ('timelimit', 'timelimit'), ) ## These three are the key for the object module_type = models.CharField(max_length=32, choices=MODULE_TYPES, default='problem', db_index=True) diff --git a/lms/djangoapps/courseware/tests/test_timelimit_module.py b/lms/djangoapps/courseware/tests/test_timelimit_module.py deleted file mode 100644 index 3e3e0943f3..0000000000 --- a/lms/djangoapps/courseware/tests/test_timelimit_module.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Tests of the TimeLimitModule - -TODO: This should be a test in common/lib/xmodule. However, -actually rendering HTML templates for XModules at this point requires -Django (which is storing the templates), so the test can't run in isolation -""" -from xmodule.modulestore.tests.factories import ItemFactory -from xmodule.tests.rendering.core import assert_student_view - -from . import XModuleRenderingTestBase - - -class TestTimeLimitModuleRendering(XModuleRenderingTestBase): - """ - Tests of TimeLimitModule html rendering - """ - def test_with_children(self): - block = ItemFactory.create(category='timelimit') - block.xmodule_runtime = self.new_module_runtime() - ItemFactory.create(category='html', data='This is just text', parent=block) - - assert_student_view(block, block.render('student_view')) - - def test_without_children(self): - block = ItemFactory.create(category='timelimit') - block.xmodule_runtime = self.new_module_runtime() - - assert_student_view(block, block.render('student_view')) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 6646ea1e63..69fd33f417 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -172,71 +172,6 @@ def save_child_position(seq_module, child_name): seq_module.save() -def check_for_active_timelimit_module(request, course_id, course): - """ - Looks for a timing module for the given user and course that is currently active. - If found, returns a context dict with timer-related values to enable display of time remaining. - """ - context = {} - - # TODO (cpennington): Once we can query the course structure, replace this with such a query - timelimit_student_modules = StudentModule.objects.filter(student=request.user, course_id=course_id, module_type='timelimit') - if timelimit_student_modules: - for timelimit_student_module in timelimit_student_modules: - # get the corresponding section_descriptor for the given StudentModel entry: - module_state_key = timelimit_student_module.module_state_key - timelimit_descriptor = modulestore().get_instance(course_id, Location(module_state_key)) - timelimit_module_cache = FieldDataCache.cache_for_descriptor_descendents(course.id, request.user, - timelimit_descriptor, depth=None) - timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, - timelimit_module_cache, course.id, position=None) - if timelimit_module is not None and timelimit_module.category == 'timelimit' and \ - timelimit_module.has_begun and not timelimit_module.has_ended: - location = timelimit_module.location - # determine where to go when the timer expires: - if timelimit_descriptor.time_expired_redirect_url is None: - raise Http404("no time_expired_redirect_url specified at this location: {} ".format(timelimit_module.location)) - context['time_expired_redirect_url'] = timelimit_descriptor.time_expired_redirect_url - # Fetch the remaining time relative to the end time as stored in the module when it was started. - # This value should be in milliseconds. - remaining_time = timelimit_module.get_remaining_time_in_ms() - context['timer_expiration_duration'] = remaining_time - context['suppress_toplevel_navigation'] = timelimit_descriptor.suppress_toplevel_navigation - return_url = reverse('jump_to', kwargs={'course_id': course_id, 'location': location}) - context['timer_navigation_return_url'] = return_url - return context - - -def update_timelimit_module(user, course_id, field_data_cache, timelimit_descriptor, timelimit_module): - """ - Updates the state of the provided timing module, starting it if it hasn't begun. - Returns dict with timer-related values to enable display of time remaining. - Returns 'timer_expiration_duration' in dict if timer is still active, and not if timer has expired. - """ - context = {} - # determine where to go when the exam ends: - if timelimit_descriptor.time_expired_redirect_url is None: - raise Http404("No time_expired_redirect_url specified at this location: {} ".format(timelimit_module.location)) - context['time_expired_redirect_url'] = timelimit_descriptor.time_expired_redirect_url - - if not timelimit_module.has_ended: - if not timelimit_module.has_begun: - # user has not started the exam, so start it now. - if timelimit_descriptor.duration is None: - raise Http404("No duration specified at this location: {} ".format(timelimit_module.location)) - # The user may have an accommodation that has been granted to them. - # This accommodation information should already be stored in the module's state. - timelimit_module.begin(timelimit_descriptor.duration) - - # the exam has been started, either because the student is returning to the - # exam page, or because they have just visited it. Fetch the remaining time relative to the - # end time as stored in the module when it was started. - context['timer_expiration_duration'] = timelimit_module.get_remaining_time_in_ms() - # also use the timed module to determine whether top-level navigation is visible: - context['suppress_toplevel_navigation'] = timelimit_descriptor.suppress_toplevel_navigation - return context - - def chat_settings(course, user): """ Returns a dict containing the settings required to connect to a @@ -390,22 +325,8 @@ def index(request, course_id, chapter=None, section=None, # Save where we are in the chapter save_child_position(chapter_module, section) - - # check here if this section *is* a timed module. - if section_module.category == 'timelimit': - timer_context = update_timelimit_module(user, course_id, section_field_data_cache, - section_descriptor, section_module) - if 'timer_expiration_duration' in timer_context: - context.update(timer_context) - else: - # if there is no expiration defined, then we know the timer has expired: - return HttpResponseRedirect(timer_context['time_expired_redirect_url']) - else: - # check here if this page is within a course that has an active timed module running. If so, then - # add in the appropriate timer information to the rendering context: - context.update(check_for_active_timelimit_module(request, course_id, course)) - context['fragment'] = section_module.render('student_view') + else: # section is none, so display a message prev_section = get_current_child(chapter_module) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index f6a171c3fc..2e19887bf9 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -290,12 +290,6 @@ OPEN_ENDED_GRADING_INTERFACE = AUTH_TOKENS.get('OPEN_ENDED_GRADING_INTERFACE', EMAIL_HOST_USER = AUTH_TOKENS.get('EMAIL_HOST_USER', '') # django default is '' EMAIL_HOST_PASSWORD = AUTH_TOKENS.get('EMAIL_HOST_PASSWORD', '') # django default is '' -PEARSON_TEST_USER = "pearsontest" -PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD") - -# Pearson hash for import/export -PEARSON = AUTH_TOKENS.get("PEARSON") - # Datadog for events! DATADOG = AUTH_TOKENS.get("DATADOG", {}) DATADOG.update(ENV_TOKENS.get("DATADOG", {})) diff --git a/lms/envs/common.py b/lms/envs/common.py index 8f921480f3..46c9df1a6e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -523,11 +523,6 @@ WIKI_USE_BOOTSTRAP_SELECT_WIDGET = False WIKI_LINK_LIVE_LOOKUPS = False WIKI_LINK_DEFAULT_LEVEL = 2 -################################# Pearson TestCenter config ################ - -PEARSONVUE_SIGNINPAGE_URL = "https://www1.pearsonvue.com/testtaker/signin/SignInPage/EDX" -# TESTCENTER_ACCOMMODATION_REQUEST_EMAIL = "exam-help@example.com" - ##### Feedback submission mechanism ##### FEEDBACK_SUBMISSION_EMAIL = None diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 8fe5e7a19c..7ab58b008f 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -254,9 +254,6 @@ MITX_FEATURES['RESTRICT_ENROLL_BY_REG_METHOD'] = True PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT) -########################## PEARSON TESTING ########################### -MITX_FEATURES['ENABLE_PEARSON_LOGIN'] = False - ########################## ANALYTICS TESTING ######################## ANALYTICS_SERVER_URL = "http://127.0.0.1:9000/" diff --git a/lms/static/sass/multicourse/_testcenter-register.scss b/lms/static/sass/multicourse/_testcenter-register.scss deleted file mode 100644 index e39c25b387..0000000000 --- a/lms/static/sass/multicourse/_testcenter-register.scss +++ /dev/null @@ -1,790 +0,0 @@ -// Pearson VUE Test Center Registration -// ===== - -.testcenter-register { - @include clearfix; - padding: 60px 0px 120px; - - // reset - horrible, but necessary - p, a, h1, h2, h3, h4, h5, h6 { - font-family: $sans-serif !important; - } - - // basic layout - .introduction { - width: flex-grid(12); - } - - .message-status-registration { - width: flex-grid(12); - } - - .content, aside { - @include box-sizing(border-box); - } - - .content { - margin-right: flex-gutter(); - width: flex-grid(8); - float: left; - } - - aside { - margin: 0; - width: flex-grid(4); - float: left; - } - - // introduction - .introduction { - - header { - - h2 { - margin: 0; - font-family: $sans-serif; - font-size: 16px; - color: $lighter-base-font-color; - } - - h1 { - font-family: $sans-serif; - font-size: 34px; - text-align: left; - } - } - } - - // content - .content { - background: rgb(255,255,255); - } - - // form - .form-fields-primary, .form-fields-secondary { - border-bottom: 1px solid rgba(0,0,0,0.25); - box-shadow: 0 1px 2px 0 rgba(0,0,0, 0.1); - } - - form { - border: 1px solid rgb(216, 223, 230); - border-radius: 3px; - box-shadow: 0 1px 2px 0 rgba(0,0,0, 0.2); - - .instructions, .note { - margin: 0; - padding: ($baseline*1.5) ($baseline*1.5) 0 ($baseline*1.5); - font-size: 14px; - color: tint($base-font-color, 20%); - - strong { - font-weight: normal; - } - - .title, .indicator { - color: $base-font-color; - font-weight: 700; - } - } - - fieldset { - border-bottom: 1px solid rgba(216, 223, 230, 0.50); - padding: ($baseline*1.5); - } - - .form-actions { - @include clearfix(); - padding: ($baseline*1.5); - - button[type="submit"] { - display: block; - @include button(simple, $blue); - @include box-sizing(border-box); - border-radius: 3px; - font: bold 15px/1.6rem $sans-serif; - letter-spacing: 0; - padding: ($baseline*0.75) $baseline; - text-align: center; - - - &:disabled { - opacity: 0.3; - } - } - - .action-primary { - float: left; - width: flex-grid(5,8); - margin-right: flex-gutter(2); - } - - .action-secondary { - display: block; - float: left; - width: flex-grid(2,8); - margin-top: $baseline; - padding: ($baseline/4); - font-size: 13px; - text-align: right; - text-transform: uppercase; - } - - &.error { - - } - } - - .list-input { - margin: 0; - padding: 0; - list-style: none; - - .field { - border-bottom: 1px dotted rgba(216, 223, 230, 0.5); - margin: 0 0 $baseline 0; - padding: 0 0 $baseline 0; - - &:last-child { - border: none; - margin-bottom: 0; - padding-bottom: 0; - } - - &.disabled, &.submitted { - color: rgba(0,0,0,.25); - - label { - cursor: text; - - &:after { - margin-left: ($baseline/4); - } - } - - textarea, input { - background: rgb(255,255,255); - color: rgba(0,0,0,.25); - } - } - - &.disabled { - label:after { - color: rgba(0,0,0,.35); - content: "(Disabled Currently)"; - } - } - - &.submitted { - - label:after { - content: "(Previously Submitted and Not Editable)"; - } - - .value { - border-radius: 3px; - border: 1px solid #C8C8C8; - padding: $baseline ($baseline*0.75); - background: #FAFAFA; - } - } - - &.error { - - label { - color: $red; - } - - input, textarea { - border-color: tint($red,50%); - } - } - - &.required { - - label { - font-weight: bold; - } - - label:after { - margin-left: ($baseline/4); - content: "*"; - } - } - - label, input, textarea { - display: block; - font-family: $sans-serif; - font-style: normal; - } - - label { - margin: 0 0 ($baseline/4) 0; - @include transition(color 0.15s ease-in-out 0s); - - &.is-focused { - color: $blue; - } - } - - input, textarea { - height: 100%; - width: 100%; - padding: ($baseline/2); - - &.long { - width: 100%; - } - - &.short { - width: 25%; - } - } - - textarea.long { - height: ($baseline*5); - } - } - - .field-group { - @include clearfix(); - border-bottom: 1px dotted rgba(216, 223, 230, 0.5); - margin: 0 0 $baseline 0; - padding: 0 0 $baseline 0; - - .field { - display: block; - float: left; - border-bottom: none; - margin: 0 $baseline ($baseline/2) 0; - padding-bottom: 0; - - input, textarea { - width: 100%; - } - } - - &.addresses { - - .field { - width: 45%; - } - } - - &.postal-2 { - border-bottom: none; - margin-bottom: 0; - padding-bottom: 0; - - } - - &.phoneinfo { - - } - } - } - - &.disabled { - - > .instructions { - display: none; - } - - .field { - opacity: 0.6; - - .label, label { - cursor: auto; - } - } - - .form-actions { - display: none; - } - } - } - - // form - specifics - .form-fields-secondary { - display: none; - - &.is-shown { - display: block; - } - - &.disabled { - - fieldset { - opacity: 0.5; - } - } - } - - .form-fields-secondary-visibility { - display: block; - margin: 0; - padding: $baseline ($baseline*1.5) 0 ($baseline*1.5); - font-size: 13px; - - &.is-hidden { - display: none; - } - } - - - // aside - aside { - padding-left: $baseline; - - .message-status { - border-radius: 3px; - margin: 0 0 ($baseline*2) 0; - border: 1px solid #ccc; - padding: 0; - background: tint($yellow,90%); - - > * { - padding: $baseline; - } - - p { - margin: 0 0 ($baseline/4) 0; - padding: 0; - font-size: 13px; - } - - .label, .value { - display: block; - } - - h3, h4, h5 { - font-family: $sans-serif; - } - - h3 { - border-bottom: 1px solid tint(rgb(0,0,0), 90%); - padding-bottom: ($baseline*0.75); - } - - h4 { - margin-bottom: ($baseline/4); - } - - .status-list { - list-style: none; - margin: 0; - padding: $baseline; - - > .item { - @include clearfix(); - margin: 0 0 ($baseline*0.75) 0; - border-bottom: 1px solid tint(rgb(0,0,0), 95%); - padding: 0 0 ($baseline/2) 0; - - &:last-child { - margin-bottom: 0; - border-bottom: none; - padding-bottom: 0; - } - - .title { - margin-bottom: ($baseline/4); - position: relative; - font-weight: bold; - font-size: 14px; - - &:after { - position: absolute; - top: 0; - right: $baseline; - margin-left: $baseline; - content: "not started"; - text-transform: uppercase; - font-size: 11px; - font-weight: normal; - opacity: 0.5; - } - } - - .details, .item, .instructions { - @include transition(opacity 0.10s ease-in-out 0s); - font-size: 13px; - opacity: 0.65; - } - - &:before { - border-radius: $baseline; - position: relative; - top: 3px; - display: block; - float: left; - width: ($baseline/2); - height: ($baseline/2); - margin: 0 ($baseline/2) 0 0; - background: $dark-gray; - content: ""; - } - - // specific states - &.status-processed { - - &:before { - background: green; - } - - .title:after { - color: green; - content: "processed"; - } - - &.status-registration { - .exam-link { - font-weight: 600 !important; - } - } - } - - &.status-pending { - - &:before { - background: transparent; - border: 1px dotted gray; - } - - .title:after { - color: gray; - content: "pending"; - } - } - - &.status-rejected { - - &:before { - background: $red; - } - - .title:after { - color: red; - content: "rejected"; - } - - .call-link { - font-weight: bold; - } - } - - &.status-initial { - - &:before { - background: transparent; - border: 1px dotted gray; - } - - .title:after { - color: gray; - } - } - - &:hover, &:focus { - - .details, .item, .instructions { - opacity: 1.0; - } - } - } - - // sub menus - .accommodations-list, .error-list { - list-style: none; - margin: ($baseline/2) 0; - padding: 0; - font-size: 13px; - - .item { - margin: 0 0 ($baseline/4) 0; - padding: 0; - } - } - } - - // actions - .contact-link { - font-weight: 600; - } - - .actions { - box-shadow: inset 0 1px 1px 0px rgba(0,0,0,0.2); - border-top: 1px solid tint(rgb(0,0,0), 90%); - padding-top: ($baseline*0.75); - background: tint($yellow,70%); - font-size: 14px; - - .title { - font-size: 14px; - } - - .label, .value { - display: inline-block; - } - - .label { - margin-right: ($baseline/4); - } - - .value { - font-weight: bold; - } - - .message-copy { - font-size: 13px; - } - - .exam-button { - @include button(simple, $pink); - display: block; - margin: ($baseline/2) 0 0 0; - padding: ($baseline/2) $baseline; - font-size: 13px; - font-weight: bold; - - &:hover, &:focus { - text-decoration: none; - } - } - } - - .registration-number { - - .label { - text-transform: none; - letter-spacing: 0; - } - - - } - - .registration-processed { - - .message-copy { - margin: 0 0 ($baseline/2) 0; - } - } - } - - > .details { - border-bottom: 1px solid rgba(216, 223, 230, 0.5); - margin: 0 0 $baseline 0; - padding: 0 $baseline $baseline $baseline; - font-family: $sans-serif; - font-size: 14px; - - &:last-child { - border: none; - margin-bottom: 0; - padding-bottom: 0; - } - - h4 { - margin: 0 0 ($baseline/2) 0; - font-family: $sans-serif; - font-size: 14px; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #ccc; - } - - .label, .value { - display: inline-block; - } - - .label { - color: rgba(0,0,0,.65); - margin-right: ($baseline/2); - } - - .value { - color: rgb(0,0,0); - font-weight: 600; - } - } - - .details-course { - - } - - .details-registration { - - ul { - margin: 0; - padding: 0; - list-style: none; - - li { - margin: 0 0 ($baseline/4) 0; - } - } - } - } - - // status messages - .message { - border-radius: 3px; - display: none; - margin: $baseline 0; - padding: ($baseline/2) $baseline; - - &.is-shown { - display: block; - } - - .message-copy { - font-size: 14px; - } - - // registration status - &.message-flash { - border-radius: 3px; - position: relative; - margin: 0 0 ($baseline*2) 0; - border: 1px solid #ccc; - padding-top: ($baseline*0.75); - background: tint($yellow,70%); - font-size: 14px; - - .message-title, .message-copy { - } - - .message-title { - font-weight: bold; - font-size: 16px; - margin: 0 0 ($baseline/4) 0; - } - - .message-copy { - font-size: 14px; - } - - .contact-button { - @include button(simple, $blue); - } - - .exam-button { - @include button(simple, $pink); - } - - .button { - position: absolute; - top: ($baseline/4); - right: $baseline; - margin: ($baseline/2) 0 0 0; - padding: ($baseline/2) $baseline; - font-size: 13px; - font-weight: bold; - letter-spacing: 0; - - &:hover, &:focus { - text-decoration: none; - } - } - - &.message-action { - - .message-title, .message-copy { - width: 65%; - } - } - } - - // submission error - &.submission-error { - @include box-sizing(border-box); - float: left; - width: flex-grid(8,8); - border: 1px solid tint($red,85%); - background: tint($red,95%); - font-size: 14px; - - #submission-error-heading { - margin-bottom: ($baseline/2); - border-bottom: 1px solid tint($red, 85%); - padding-bottom: ($baseline/2); - font-weight: bold; - } - - .field-name, .field-error { - display: inline-block; - } - - .field-name { - margin-right: ($baseline/4); - } - - .field-error { - color: tint($red, 55%); - } - - p { - color: $red; - } - - ul { - margin: 0 0 ($baseline/2) 0; - padding: 0; - list-style: none; - - li { - margin-bottom: ($baseline/2); - padding: 0; - - span { - color: $red; - } - - a { - color: $red; - text-decoration: none; - - &:hover, &:active, &:focus { - text-decoration: underline; - } - } - } - } - } - - // submission success - &.submission-saved { - border: 1px solid tint($blue,85%); - background: tint($blue,95%); - - .message-copy { - color: $blue; - } - } - - // specific - registration closed - &.registration-closed { - @include border-bottom-radius(0); - margin-top: 0; - border-bottom: 1px solid $light-gray; - padding: $baseline; - background: tint($light-gray,50%); - - .message-title { - font-weight: bold; - } - - .message-copy { - - } - } - } - - .is-shown { - display: block; - } - - // hidden - .is-hidden { - display: none; - } -} diff --git a/lms/templates/test_center_register.html b/lms/templates/test_center_register.html deleted file mode 100644 index c35acf9914..0000000000 --- a/lms/templates/test_center_register.html +++ /dev/null @@ -1,482 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> - -<%! - from django.core.urlresolvers import reverse - from courseware.courses import course_image_url, get_course_about_section - from courseware.access import has_access - from certificates.models import CertificateStatuses -%> -<%inherit file="main.html" /> - -<%namespace name='static' file='static_content.html'/> - -<%block name="title">${_('Pearson VUE Test Center Proctoring - Registration')} - -<%block name="js_extra"> - - - -
          - -
          -
          -
          -

          ${get_course_about_section(course, 'university')} ${course.display_number_with_default | h} ${course.display_name_with_default | h}

          - - % if registration: -

          ${_('Your Pearson VUE Proctored Exam Registration')}

          - % else: -

          ${_('Register for a Pearson VUE Proctored Exam')}

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

          ${_('Your registration for the Pearson exam has been processed')}

          -

          ${_("Your registration number is {reg_number}. " - "(Write this down! You\'ll need it to schedule your exam.)").format(reg_number=registration.client_candidate_id)}

          - ${_('Schedule Pearson exam')} -
          - % endif - - % if registration.demographics_is_rejected: -
          -

          ${_('Your demographic information contained an error and was rejected')}

          -

          ${_('Please check the information you provided, and correct the errors noted below.')} -

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

          ${_('Your registration for the Pearson exam has been rejected')}

          -

          ${_('Please see your registration status details for more information.')}

          -
          - % endif - - % if registration.is_pending: -
          -

          ${_('Your registration for the Pearson exam is pending')}

          -

          ${_('Once your information is processed, it will be forwarded to Pearson and you will be able to schedule an exam.')}

          -
          - % endif - - % endif - -
          -
          - -
          - % if exam_info.is_registering(): -
          - % else: - - -
          -

          ${_('Registration for this Pearson exam is closed')}

          -

          ${_('Your previous information is available below, however you may not edit any of the information.')} -

          - % endif - - % if registration: -

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

          - % else: -

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

          - % endif - - - - - - - -
          -
          - - -
            -
          1. - - -
          2. -
          3. - - -
          4. -
          5. - - -
          6. -
          7. - - -
          8. -
          9. - - -
          10. -
          -
          - -
          - - -
            -
          1. - - -
          2. -
          3. -
            - - -
            -
            - - -
            -
          4. -
          5. - - -
          6. -
          7. -
            - - -
            -
            - - -
            -
            - - -
            -
          8. -
          -
          - -
          - - -
            -
          1. -
            - - -
            -
            - - -
            -
            - - -
            -
          2. -
          3. -
            - - -
            -
            - - -
            -
          4. -
          5. - - -
          6. -
          -
          -
          - - % if registration: - % if registration.accommodation_request and len(registration.accommodation_request) > 0: -
          - % endif - % else: -
          - % endif - - % if registration: - % if registration.accommodation_request and len(registration.accommodation_request) > 0: -

          ${_('Note: Your previous accommodation request below needs to be reviewed in detail and will add a significant delay to your registration process.')}

          - % endif - % else: -

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

          - % endif - -
          - - -
            - % if registration: - % if registration.accommodation_request and len(registration.accommodation_request) > 0: - - % endif - % else: -
          1. - - -
          2. - % endif -
          -
          -
          - -
          - % if registration: - - ${_('Cancel Update')} - % else: - - ${_('Cancel Registration')} - % endif - -
          -

          -
            -
            -
            - - - % if registration: - % if registration.accommodation_request and len(registration.accommodation_request) > 0: - - % endif - % else: - ${_('Special (ADA) Accommodations')} - % endif -
            - - -
            diff --git a/lms/urls.py b/lms/urls.py index 4aec95d01f..a3e8f86557 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -41,9 +41,6 @@ urlpatterns = ('', # nopep8 url(r'^create_account$', 'student.views.create_account', name='create_account'), url(r'^activate/(?P[^/]*)$', 'student.views.activate_account', name="activate"), - url(r'^begin_exam_registration/(?P[^/]+/[^/]+/[^/]+)$', 'student.views.begin_exam_registration', name="begin_exam_registration"), - url(r'^create_exam_registration$', 'student.views.create_exam_registration'), - url(r'^password_reset/$', 'student.views.password_reset', name='password_reset'), ## Obsolete Django views for password resets ## TODO: Replace with Mako-ized views @@ -402,9 +399,6 @@ if settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): url(r'^openid/provider/xrds/$', 'external_auth.views.provider_xrds', name='openid-provider-xrds') ) -if settings.MITX_FEATURES.get('ENABLE_PEARSON_LOGIN', False): - urlpatterns += url(r'^testcenter/login$', 'external_auth.views.test_center_login'), - if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'): urlpatterns += ( url(r'^migrate/modules$', 'lms_migration.migrate.manage_modulestores'), From a56b9457a048ccb32ed327f66e2001fdaf0b395e Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Mon, 25 Nov 2013 15:01:27 -0500 Subject: [PATCH 074/110] Rebase to re-prepare PR --- common/djangoapps/student/models.py | 4 ++-- lms/static/sass/application-extend1.scss.mako | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 3f0c1c507f..9bf1b2d7f6 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -21,7 +21,7 @@ from django.contrib.auth.models import User from django.contrib.auth.signals import user_logged_in, user_logged_out from django.db import models from django.db.models.signals import post_save -from django.dispatch import receiver +from django.dispatch import receiver, Signal from course_modes.models import CourseMode import lms.lib.comment_client as cc @@ -33,7 +33,7 @@ from track.views import server_track from eventtracking import tracker -unenroll_done = django.dispatch.Signal(providing_args=["course_enrollment"]) +unenroll_done = Signal(providing_args=["course_enrollment"]) log = logging.getLogger(__name__) AUDIT_LOG = logging.getLogger("audit") diff --git a/lms/static/sass/application-extend1.scss.mako b/lms/static/sass/application-extend1.scss.mako index 86a442301d..4ffdd972ba 100644 --- a/lms/static/sass/application-extend1.scss.mako +++ b/lms/static/sass/application-extend1.scss.mako @@ -46,7 +46,6 @@ @import 'multicourse/home'; @import 'multicourse/dashboard'; @import 'multicourse/account'; -@import 'multicourse/testcenter-register'; @import 'multicourse/courses'; @import 'multicourse/course_about'; @import 'multicourse/jobs'; From 6614d7e9bbec2e23d6b1a2a956d52f5848561274 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 26 Nov 2013 16:52:06 -0500 Subject: [PATCH 075/110] Add migration to remove pearson tables --- .../student/migrations/0029_remove_pearson.py | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 common/djangoapps/student/migrations/0029_remove_pearson.py diff --git a/common/djangoapps/student/migrations/0029_remove_pearson.py b/common/djangoapps/student/migrations/0029_remove_pearson.py new file mode 100644 index 0000000000..b92b27017f --- /dev/null +++ b/common/djangoapps/student/migrations/0029_remove_pearson.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Deleting model 'TestCenterUser' + db.delete_table('student_testcenteruser') + + # Deleting model 'TestCenterRegistration' + db.delete_table('student_testcenterregistration') + + + def backwards(self, orm): + # Adding model 'TestCenterUser' + db.create_table('student_testcenteruser', ( + ('last_name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)), + ('suffix', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)), + ('confirmed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True, db_index=True)), + ('salutation', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)), + ('postal_code', self.gf('django.db.models.fields.CharField')(blank=True, max_length=16, db_index=True)), + ('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('city', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)), + ('first_name', self.gf('django.db.models.fields.CharField')(max_length=30, db_index=True)), + ('middle_name', self.gf('django.db.models.fields.CharField')(max_length=30, blank=True)), + ('phone_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)), + ('upload_status', self.gf('django.db.models.fields.CharField')(blank=True, max_length=20, db_index=True)), + ('state', self.gf('django.db.models.fields.CharField')(blank=True, max_length=20, db_index=True)), + ('upload_error_message', self.gf('django.db.models.fields.CharField')(max_length=512, blank=True)), + ('company_name', self.gf('django.db.models.fields.CharField')(blank=True, max_length=50, db_index=True)), + ('candidate_id', self.gf('django.db.models.fields.IntegerField')(null=True, db_index=True)), + ('fax', self.gf('django.db.models.fields.CharField')(max_length=35, blank=True)), + ('user_updated_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)), + ('phone', self.gf('django.db.models.fields.CharField')(max_length=35)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['auth.User'], unique=True)), + ('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(blank=True, null=True, db_index=True)), + ('extension', self.gf('django.db.models.fields.CharField')(blank=True, max_length=8, db_index=True)), + ('fax_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, blank=True)), + ('country', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)), + ('client_candidate_id', self.gf('django.db.models.fields.CharField')(max_length=50, unique=True, db_index=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)), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True, db_index=True)), + )) + db.send_create_signal('student', ['TestCenterUser']) + + # Adding model 'TestCenterRegistration' + db.create_table('student_testcenterregistration', ( + ('client_authorization_id', self.gf('django.db.models.fields.CharField')(max_length=20, unique=True, db_index=True)), + ('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('user_updated_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)), + ('authorization_id', self.gf('django.db.models.fields.IntegerField')(null=True, db_index=True)), + ('upload_status', self.gf('django.db.models.fields.CharField')(blank=True, max_length=20, db_index=True)), + ('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True, db_index=True)), + ('confirmed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True, db_index=True)), + ('accommodation_request', self.gf('django.db.models.fields.CharField')(max_length=1024, blank=True)), + ('eligibility_appointment_date_first', self.gf('django.db.models.fields.DateField')(db_index=True)), + ('exam_series_code', self.gf('django.db.models.fields.CharField')(max_length=15, db_index=True)), + ('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), + ('upload_error_message', self.gf('django.db.models.fields.CharField')(max_length=512, blank=True)), + ('accommodation_code', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), + ('testcenter_user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['student.TestCenterUser'])), + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('eligibility_appointment_date_last', self.gf('django.db.models.fields.DateField')(db_index=True)), + )) + db.send_create_signal('student', ['TestCenterRegistration']) + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", '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'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + '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'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + '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.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + '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.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + '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 From 006b8f829891a4edb5efd7d6ce8ca781f7b4b34a Mon Sep 17 00:00:00 2001 From: vagrant Date: Wed, 27 Nov 2013 14:38:59 +0000 Subject: [PATCH 076/110] Removed old migration --- ...time__add_field_orderitem_refund_reques.py | 124 ------------------ 1 file changed, 124 deletions(-) delete mode 100644 lms/djangoapps/shoppingcart/migrations/0005_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques.py diff --git a/lms/djangoapps/shoppingcart/migrations/0005_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques.py b/lms/djangoapps/shoppingcart/migrations/0005_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques.py deleted file mode 100644 index 9a96cbd2ec..0000000000 --- a/lms/djangoapps/shoppingcart/migrations/0005_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques.py +++ /dev/null @@ -1,124 +0,0 @@ -# -*- 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 field 'Order.refunded_time' - db.add_column('shoppingcart_order', 'refunded_time', - self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True), - keep_default=False) - - # Adding field 'OrderItem.refund_requested_time' - db.add_column('shoppingcart_orderitem', 'refund_requested_time', - self.gf('django.db.models.fields.DateTimeField')(null=True), - keep_default=False) - - - def backwards(self, orm): - # Deleting field 'Order.refunded_time' - db.delete_column('shoppingcart_order', 'refunded_time') - - # Deleting field 'OrderItem.refund_requested_time' - db.delete_column('shoppingcart_orderitem', 'refund_requested_time') - - - models = { - 'auth.group': { - 'Meta': {'object_name': 'Group'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - 'auth.permission': { - 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - 'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'shoppingcart.certificateitem': { - 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, - 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), - 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), - 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) - }, - 'shoppingcart.order': { - 'Meta': {'object_name': 'Order'}, - 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), - 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), - 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), - 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), - 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), - 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), - 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), - 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), - 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), - 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), - 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), - 'refunded_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.orderitem': { - 'Meta': {'object_name': 'OrderItem'}, - 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), - 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), - 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), - 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), - 'refund_requested_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.paidcourseregistration': { - 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), - 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), - 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) - }, - 'student.courseenrollment': { - 'Meta': {'ordering': "('user', 'course_id')", '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'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - } - } - - complete_apps = ['shoppingcart'] \ No newline at end of file From 30cd57f53908ad7310324cf92c598b598b5cdbd1 Mon Sep 17 00:00:00 2001 From: Julia Hansbrough Date: Wed, 27 Nov 2013 15:16:25 +0000 Subject: [PATCH 077/110] New migrations file --- ...time__add_field_orderitem_refund_reques.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 lms/djangoapps/shoppingcart/migrations/0006_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques.py diff --git a/lms/djangoapps/shoppingcart/migrations/0006_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques.py b/lms/djangoapps/shoppingcart/migrations/0006_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques.py new file mode 100644 index 0000000000..13e001cfd0 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0006_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques.py @@ -0,0 +1,131 @@ +# -*- 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 field 'Order.refunded_time' + db.add_column('shoppingcart_order', 'refunded_time', + self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True), + keep_default=False) + + # Adding field 'OrderItem.refund_requested_time' + db.add_column('shoppingcart_orderitem', 'refund_requested_time', + self.gf('django.db.models.fields.DateTimeField')(null=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'Order.refunded_time' + db.delete_column('shoppingcart_order', 'refunded_time') + + # Deleting field 'OrderItem.refund_requested_time' + db.delete_column('shoppingcart_orderitem', 'refund_requested_time') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'refunded_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'refund_requested_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'report_comments': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.paidcourseregistrationannotation': { + 'Meta': {'object_name': 'PaidCourseRegistrationAnnotation'}, + 'annotation': ('django.db.models.fields.TextField', [], {'null': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '128', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", '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'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file From d75b580f8f85b909f03ba1378ccf0a52bf5c4507 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Wed, 27 Nov 2013 13:17:00 +0200 Subject: [PATCH 078/110] Enabled back turned off Video acceptance tests. Updated comments. BLD-537 --- .../contentstore/features/video.feature | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index 9f08e98f0d..b126652ecc 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -53,33 +53,36 @@ Feature: CMS.Video Component Then Captions become "invisible" # 8 - # Disabled 11/26 due to flakiness in master - #Scenario: Open captions never become invisible - # Given I have created a Video component with subtitles - # And Make sure captions are open - # Then Captions are "visible" - # And I hover over button "CC" - # Then Captions are "visible" - # And I hover over button "volume" - # Then Captions are "visible" + # Disabled 11/26 due to flakiness in master. + # Enabled back on 11/29. + Scenario: Open captions never become invisible + Given I have created a Video component with subtitles + And Make sure captions are open + Then Captions are "visible" + And I hover over button "CC" + Then Captions are "visible" + And I hover over button "volume" + Then Captions are "visible" # 9 - # Disabled 11/26 due to flakiness in master - #Scenario: Closed captions are invisible when mouse doesn't hover on CC button - # Given I have created a Video component with subtitles - # And Make sure captions are closed - # Then Captions become "invisible" - # And I hover over button "volume" - # Then Captions are "invisible" + # Disabled 11/26 due to flakiness in master. + # Enabled back on 11/29. + Scenario: Closed captions are invisible when mouse doesn't hover on CC button + Given I have created a Video component with subtitles + And Make sure captions are closed + Then Captions become "invisible" + And I hover over button "volume" + Then Captions are "invisible" # 10 - # Disabled 11/26 due to flakiness in master - #Scenario: When enter key is pressed on a caption shows an outline around it - # Given I have created a Video component with subtitles - # And Make sure captions are opened - # Then I focus on caption line with data-index "0" - # Then I press "enter" button on caption line with data-index "0" - # And I see caption line with data-index "0" has class "focused" + # Disabled 11/26 due to flakiness in master. + # Enabled back on 11/29. + Scenario: When enter key is pressed on a caption shows an outline around it + Given I have created a Video component with subtitles + And Make sure captions are opened + Then I focus on caption line with data-index "0" + Then I press "enter" button on caption line with data-index "0" + And I see caption line with data-index "0" has class "focused" # 11 Scenario: When start end end times are specified, a range on slider is shown From 0dbb7603fb73533c10f34d80623d2f17eda95dcd Mon Sep 17 00:00:00 2001 From: polesye Date: Mon, 25 Nov 2013 11:15:56 +0200 Subject: [PATCH 079/110] BLD-533: Improve calculator's tooltip accessibility. --- CHANGELOG.rst | 3 + lms/static/coffee/fixtures/calculator.html | 7 +- lms/static/coffee/spec/calculator_spec.coffee | 280 ++++++++++++++++-- lms/static/coffee/src/calculator.coffee | 165 +++++++++-- .../sass/course/layout/_calculator.scss | 12 +- lms/templates/courseware/courseware.html | 103 +++---- 6 files changed, 466 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9680759f8b..e2ebb76e53 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Blades: Improve calculator's tooltip accessibility. Add possibility to navigate + through the hints via arrow keys. BLD-533. + LMS: Add feature for providing background grade report generation via Celery instructor task, with reports uploaded to S3. Feature is visible on the beta instructor dashboard. LMS-58 diff --git a/lms/static/coffee/fixtures/calculator.html b/lms/static/coffee/fixtures/calculator.html index 17d163eb67..638dcc0b1f 100644 --- a/lms/static/coffee/fixtures/calculator.html +++ b/lms/static/coffee/fixtures/calculator.html @@ -6,8 +6,11 @@
            - - + Hints +
            diff --git a/lms/static/coffee/spec/calculator_spec.coffee b/lms/static/coffee/spec/calculator_spec.coffee index 8e41ebcb3b..ed10bb26a6 100644 --- a/lms/static/coffee/spec/calculator_spec.coffee +++ b/lms/static/coffee/spec/calculator_spec.coffee @@ -1,4 +1,16 @@ describe 'Calculator', -> + + KEY = + TAB : 9 + ENTER : 13 + ALT : 18 + ESC : 27 + SPACE : 32 + LEFT : 37 + UP : 38 + RIGHT : 39 + DOWN : 40 + beforeEach -> loadFixtures 'coffee/fixtures/calculator.html' @calculator = new Calculator @@ -9,15 +21,14 @@ describe 'Calculator', -> it 'bind the help button', -> # These events are bind by $.hover() - expect($('div.help-wrapper a')).toHandle 'mouseover' - expect($('div.help-wrapper a')).toHandle 'mouseout' - expect($('div.help-wrapper')).toHandle 'focusin' - expect($('div.help-wrapper')).toHandle 'focusout' + expect($('#calculator_hint')).toHandle 'mouseover' + expect($('#calculator_hint')).toHandle 'mouseout' + expect($('#calculator_hint')).toHandle 'keydown' it 'prevent default behavior on help button', -> - $('div.help-wrapper a').click (e) -> + $('#calculator_hint').click (e) -> expect(e.isDefaultPrevented()).toBeTruthy() - $('div.help-wrapper a').click() + $('#calculator_hint').click() it 'bind the calculator submit', -> expect($('form#calculator')).toHandleWith 'submit', @calculator.calculate @@ -51,30 +62,261 @@ describe 'Calculator', -> @calculator.toggle(jQuery.Event("click")) expect($('.calc')).not.toHaveClass('closed') - describe 'helpShow', -> + describe 'showHint', -> it 'show the help overlay', -> - @calculator.helpShow() + @calculator.showHint() expect($('.help')).toHaveClass('shown') expect($('.help')).toHaveAttr('aria-hidden', 'false') - describe 'helpHide', -> + + describe 'hideHint', -> it 'show the help overlay', -> - @calculator.helpHide() + @calculator.hideHint() expect($('.help')).not.toHaveClass('shown') expect($('.help')).toHaveAttr('aria-hidden', 'true') - describe 'handleKeyDown', -> - it 'on pressing Esc the hint becomes hidden', -> - @calculator.helpShow() - e = jQuery.Event('keydown', { which: 27 } ); + describe 'handleClickOnDocument', -> + it 'on click out of the hint popup it becomes hidden', -> + @calculator.showHint() + e = jQuery.Event('click'); $(document).trigger(e); expect($('.help')).not.toHaveClass 'shown' - it 'On pressing other buttons the hint continue to show', -> - @calculator.helpShow() - e = jQuery.Event('keydown', { which: 32 } ); - $(document).trigger(e); - expect($('.help')).toHaveClass 'shown' + describe 'selectHint', -> + it 'select correct hint item', -> + spyOn($.fn, 'focus') + element = $('.hint-item').eq(1) + @calculator.selectHint(element) + + expect(element.focus).toHaveBeenCalled() + expect(@calculator.activeHint).toEqual(element) + expect(@calculator.hintPopup).toHaveAttr('aria-activedescendant', element.attr('id')) + + it 'select the first hint if argument element is not passed', -> + @calculator.selectHint() + expect(@calculator.activeHint.attr('id')).toEqual($('.hint-item').first().attr('id')) + + it 'select the first hint if argument element is empty', -> + @calculator.selectHint([]) + expect(@calculator.activeHint.attr('id')).toBe($('.hint-item').first().attr('id')) + + describe 'prevHint', -> + + it 'Prev hint item is selected', -> + @calculator.activeHint = $('.hint-item').eq(1) + @calculator.prevHint() + + expect(@calculator.activeHint.attr('id')).toBe($('.hint-item').eq(0).attr('id')) + + it 'Prev hint item is selected', -> + @calculator.activeHint = $('.hint-item').eq(1) + @calculator.prevHint() + + expect(@calculator.activeHint.attr('id')).toBe($('.hint-item').eq(0).attr('id')) + + it 'if this was the first item, select the last one', -> + @calculator.activeHint = $('.hint-item').eq(0) + @calculator.prevHint() + + expect(@calculator.activeHint.attr('id')).toBe($('.hint-item').eq(1).attr('id')) + + describe 'nextHint', -> + + it 'Next hint item is selected', -> + @calculator.activeHint = $('.hint-item').eq(0) + @calculator.nextHint() + + expect(@calculator.activeHint.attr('id')).toBe($('.hint-item').eq(1).attr('id')) + + it 'If this was the last item, select the first one', -> + @calculator.activeHint = $('.hint-item').eq(1) + @calculator.nextHint() + + expect(@calculator.activeHint.attr('id')).toBe($('.hint-item').eq(0).attr('id')) + + describe 'handleKeyDown', -> + assertHintIsHidden = (calc, key) -> + spyOn(calc, 'hideHint') + calc.showHint() + e = jQuery.Event('keydown', { keyCode: key }); + value = calc.handleKeyDown(e) + + expect(calc.hideHint).toHaveBeenCalled + expect(value).toBeFalsy() + expect(e.isDefaultPrevented()).toBeTruthy() + + assertHintIsVisible = (calc, key) -> + spyOn(calc, 'showHint') + spyOn($.fn, 'focus') + e = jQuery.Event('keydown', { keyCode: key }); + value = calc.handleKeyDown(e) + + expect(calc.showHint).toHaveBeenCalled + expect(value).toBeFalsy() + expect(e.isDefaultPrevented()).toBeTruthy() + expect(calc.activeHint.focus).toHaveBeenCalled() + + assertNothingHappens = (calc, key) -> + spyOn(calc, 'showHint') + e = jQuery.Event('keydown', { keyCode: key }); + value = calc.handleKeyDown(e) + + expect(calc.showHint).not.toHaveBeenCalled + expect(value).toBeTruthy() + expect(e.isDefaultPrevented()).toBeFalsy() + + it 'hint popup becomes hidden on press ENTER', -> + assertHintIsHidden(@calculator, KEY.ENTER) + + it 'hint popup becomes visible on press ENTER', -> + assertHintIsVisible(@calculator, KEY.ENTER) + + it 'hint popup becomes hidden on press SPACE', -> + assertHintIsHidden(@calculator, KEY.SPACE) + + it 'hint popup becomes visible on press SPACE', -> + assertHintIsVisible(@calculator, KEY.SPACE) + + it 'Nothing happens on press ALT', -> + assertNothingHappens(@calculator, KEY.ALT) + + it 'Nothing happens on press any other button', -> + assertNothingHappens(@calculator, KEY.DOWN) + + describe 'handleKeyDownOnHint', -> + it 'Navigation works in proper way', -> + calc = @calculator + + eventToShowHint = jQuery.Event('keydown', { keyCode: KEY.ENTER } ); + $('#calculator_hint').trigger(eventToShowHint); + + spyOn(calc, 'hideHint') + spyOn(calc, 'prevHint') + spyOn(calc, 'nextHint') + spyOn($.fn, 'focus') + + cases = + left: + event: + keyCode: KEY.LEFT + shiftKey: false + returnedValue: false + called: + 'prevHint': calc + isPropagationStopped: true + + leftWithShift: + returnedValue: true + event: + keyCode: KEY.LEFT + shiftKey: true + not_called: + 'prevHint': calc + + up: + event: + keyCode: KEY.UP + shiftKey: false + returnedValue: false + called: + 'prevHint': calc + isPropagationStopped: true + + upWithShift: + returnedValue: true + event: + keyCode: KEY.UP + shiftKey: true + not_called: + 'prevHint': calc + + right: + event: + keyCode: KEY.RIGHT + shiftKey: false + returnedValue: false + called: + 'nextHint': calc + isPropagationStopped: true + + rightWithShift: + returnedValue: true + event: + keyCode: KEY.RIGHT + shiftKey: true + not_called: + 'nextHint': calc + + down: + event: + keyCode: KEY.DOWN + shiftKey: false + returnedValue: false + called: + 'nextHint': calc + isPropagationStopped: true + + downWithShift: + returnedValue: true + event: + keyCode: KEY.DOWN + shiftKey: true + not_called: + 'nextHint': calc + + tab: + returnedValue: true + event: + keyCode: KEY.TAB + shiftKey: false + called: + 'hideHint': calc + + esc: + returnedValue: false + event: + keyCode: KEY.ESC + shiftKey: false + called: + 'hideHint': calc + 'focus': $.fn + isPropagationStopped: true + + alt: + returnedValue: true + event: + which: KEY.ALT + not_called: + 'hideHint': calc + 'nextHint': calc + 'prevHint': calc + + $.each(cases, (key, data) -> + calc.hideHint.reset() + calc.prevHint.reset() + calc.nextHint.reset() + $.fn.focus.reset() + + e = jQuery.Event('keydown', data.event or {}); + value = calc.handleKeyDownOnHint(e) + + if data.called + $.each(data.called, (method, obj) -> + expect(obj[method]).toHaveBeenCalled() + ) + + if data.not_called + $.each(data.not_called, (method, obj) -> + expect(obj[method]).not.toHaveBeenCalled() + ) + + if data.isPropagationStopped + expect(e.isPropagationStopped()).toBeTruthy() + else + expect(e.isPropagationStopped()).toBeFalsy() + + expect(value).toBe(data.returnedValue) + ) describe 'calculate', -> beforeEach -> diff --git a/lms/static/coffee/src/calculator.coffee b/lms/static/coffee/src/calculator.coffee index c54a235581..230ff5e922 100644 --- a/lms/static/coffee/src/calculator.coffee +++ b/lms/static/coffee/src/calculator.coffee @@ -1,21 +1,48 @@ +# Keyboard Support + +# If focus is on the hint button: +# * Enter: Open or close hint popup. Select last focused hint item if opening +# * Space: Open or close hint popup. Select last focused hint item if opening + +# If focus is on a hint item: +# * Left arrow: Select previous hint item +# * Up arrow: Select previous hint item +# * Right arrow: Select next hint item +# * Down arrow: Select next hint item + + class @Calculator constructor: -> + @hintButton = $('#calculator_hint') + @hintPopup = $('.help') + @hintsList = @hintPopup.find('.hint-item') + @selectHint($('#' + @hintPopup.attr('aria-activedescendant'))); + $('.calc').click @toggle $('form#calculator').submit(@calculate).submit (e) -> e.preventDefault() - $('div.help-wrapper a') + @hintButton .hover( - $.proxy(@helpShow, @), - $.proxy(@helpHide, @) + $.proxy(@showHint, @), + $.proxy(@hideHint, @) ) - .click (e) -> - e.preventDefault() + .keydown($.proxy(@handleKeyDown, @)) + .click (e) -> e.preventDefault() - $(document).keydown $.proxy(@handleKeyDown, @) + @hintPopup + .keydown($.proxy(@handleKeyDownOnHint, @)) - $('div.help-wrapper') - .focusin($.proxy @helpOnFocus, @) - .focusout($.proxy @helpOnBlur, @) + @handleClickOnDocument = $.proxy(@handleClickOnDocument, @) + + KEY: + TAB : 9 + ENTER : 13 + ESC : 27 + SPACE : 32 + LEFT : 37 + UP : 38 + RIGHT : 39 + DOWN : 40 toggle: (event) -> event.preventDefault() @@ -49,32 +76,110 @@ class @Calculator $calc.toggleClass 'closed' - helpOnFocus: (e) -> - e.preventDefault() - @isFocusedHelp = true - @helpShow() - - helpOnBlur: (e) -> - e.preventDefault() - @isFocusedHelp = false - @helpHide() - - helpShow: -> - $('.help') + showHint: -> + @hintPopup .addClass('shown') .attr('aria-hidden', false) - helpHide: -> - if not @isFocusedHelp - $('.help') - .removeClass('shown') - .attr('aria-hidden', true) + $(document).on('click', @handleClickOnDocument) + + hideHint: -> + @hintPopup + .removeClass('shown') + .attr('aria-hidden', true) + + $(document).off('click', @handleClickOnDocument) + + selectHint: (element) -> + if not element or (element and element.length == 0) + element = @hintsList.first() + + @activeHint = element; + @activeHint.focus(); + @hintPopup.attr('aria-activedescendant', element.attr('id')); + + prevHint: () -> + prev = @activeHint.prev(); # the previous hint + # if this was the first item + # select the last one in the group. + if @activeHint.index() == 0 + prev = @hintsList.last() + # select the previous hint + @selectHint(prev) + + nextHint: () -> + next = @activeHint.next(); # the next hint + # if this was the last item, + # select the first one in the group. + if @activeHint.index() == @hintsList.length - 1 + next = @hintsList.first() + # give the next hint focus + @selectHint(next) handleKeyDown: (e) -> - ESC = 27 - if e.which is ESC and $('.help').hasClass 'shown' - @isFocusedHelp = false - @helpHide() + if e.altKey + # do nothing + return true + + if e.keyCode == @KEY.ENTER or e.keyCode == @KEY.SPACE + if @hintPopup.hasClass 'shown' + @hideHint() + else + @showHint() + @activeHint.focus() + + e.preventDefault() + return false + + # allow the event to propagate + return true + + handleKeyDownOnHint: (e) -> + if e.altKey + # do nothing + return true + + switch e.keyCode + when @KEY.TAB + # hide popup with hints + @hideHint() + + when @KEY.ESC + # hide popup with hints + @hideHint() + @hintButton.focus() + + e.stopPropagation() + return false + + when @KEY.LEFT, @KEY.UP + if e.shiftKey + # do nothing + return true + + @prevHint() + + e.stopPropagation() + return false + + when @KEY.RIGHT, @KEY.DOWN + if e.shiftKey + # do nothing + return true + + @nextHint() + + e.stopPropagation() + return false + + # allow the event to propagate + return true + + handleClickOnDocument: (e) -> + @hideHint() + + # allow the event to propagate + return true; calculate: -> $.getWithPrefix '/calculate', { equation: $('#calculator_input').val() }, (data) -> diff --git a/lms/static/sass/course/layout/_calculator.scss b/lms/static/sass/course/layout/_calculator.scss index 274d8a00c6..b9a5d286ef 100644 --- a/lms/static/sass/course/layout/_calculator.scss +++ b/lms/static/sass/course/layout/_calculator.scss @@ -112,15 +112,20 @@ div.calc-main { right: 0; top: 0; - a { + #calculator_hint { background: url("../images/info-icon.png") center center no-repeat; height: 35px; @include hide-text; width: 35px; - display: block; + display: block; + + &:focus { + outline: 5px auto #5b9dd9; + } } .help { + @include transition(none); background: #fff; border-radius: 3px; box-shadow: 0 0 3px #999; @@ -129,11 +134,12 @@ div.calc-main { position: absolute; right: -40px; bottom: 57px; - @include transition(none); width: 600px; overflow: hidden; pointer-events: none; display: none; + margin: 0; + list-style: none; &.shown { display: block; diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 80b52b9d36..9a6a197de0 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -218,13 +218,14 @@ ${fragment.foot_html()}
            - ${_("Hints")} - +
          • +
          • ${_("Operators")}: + - * / ^ and || (${_("parallel resistors function")})

          • +
          • ${_("Functions")}: sin, cos, tan, sqrt, log10, log2, ln, arccos, arcsin, arctan, abs, fact/factorial

          • +
          • ${_("Constants")}:

            + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
            j=sqrt(-1)
            e=${_("Euler's number")}
            pi=${_("ratio of a circle's circumference to it's diameter")}
            k=${_("Boltzmann constant")}
            c=${_("speed of light")}
            T=${_("freezing point of water in degrees Kelvin")}
            q=${_("fundamental charge")}
            +
          • +
      From 8f013871788097d3e93d763f92331f6163499b62 Mon Sep 17 00:00:00 2001 From: polesye Date: Mon, 2 Dec 2013 10:25:43 +0200 Subject: [PATCH 080/110] BLD-525: Fix Numerical input to support mathematical operations. --- CHANGELOG.rst | 2 ++ .../lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee | 4 ++-- common/lib/xmodule/xmodule/js/src/problem/edit.coffee | 8 +++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e2ebb76e53..e654098fb1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Blades: Fix Numerical input to support mathematical operations. BLD-525. + Blades: Improve calculator's tooltip accessibility. Add possibility to navigate through the hints via arrow keys. BLD-533. diff --git a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee index ea1341cc23..d7f163dd02 100644 --- a/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/problem/edit_spec.coffee @@ -100,7 +100,7 @@ describe 'MarkdownEditingDescriptor', -> = 3.14159 +- .02 Enter the approximate value of 502*9: - = 4518 +- 15% + = 502*9 +- 15% Enter the number of fingers on a human hand: = 5 @@ -125,7 +125,7 @@ describe 'MarkdownEditingDescriptor', ->

      Enter the approximate value of 502*9:

      - + diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee index 9f5d361009..3115a46a9a 100644 --- a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee @@ -234,12 +234,14 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor floatValue = parseFloat(answersList[0]); if(!isNaN(floatValue)) { - var params = /(.*?)\+\-\s*(.*?$)/.exec(answersList[0]); + var params = /(.*?)\+\-\s*(.*?$)/.exec(answersList[0]), + answer = answersList[0].replace(/\s+/g, ''); if(params) { - string = '\n'; + answer = params[1].replace(/\s+/g, ''); + string = '\n'; string += ' \n'; } else { - string = '\n'; + string = '\n'; } string += ' \n'; string += '\n\n'; From 9dc68b03a02578767c55686f371129c00f078883 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Wed, 27 Nov 2013 10:01:10 -0500 Subject: [PATCH 081/110] Improve auth handling of Locators Ensure user admin screen gets the union of all possibly matching group names. Smarter default group naming. STUD-1003 --- cms/djangoapps/auth/authz.py | 130 ++++++++++++------ .../contentstore/tests/test_permissions.py | 127 +++++++++++++++++ .../contentstore/tests/test_users.py | 16 +-- cms/djangoapps/contentstore/views/course.py | 4 +- cms/djangoapps/contentstore/views/user.py | 63 ++++----- 5 files changed, 246 insertions(+), 94 deletions(-) create mode 100644 cms/djangoapps/contentstore/tests/test_permissions.py diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index c4d1a9ddff..1a1f138cb5 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -1,3 +1,6 @@ +""" +Studio authorization functions primarily for course creators, instructors, and staff +""" #======================================================================================================================= # # This code is somewhat duplicative of access.py in the LMS. We will unify the code as a separate story @@ -11,7 +14,8 @@ from django.conf import settings from xmodule.modulestore import Location from xmodule.modulestore.locator import CourseLocator, Locator from xmodule.modulestore.django import loc_mapper -from xmodule.modulestore.exceptions import InvalidLocationError +from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError +import itertools # define a couple of simple roles, we just need ADMIN and EDITOR now for our purposes @@ -26,7 +30,11 @@ COURSE_CREATOR_GROUP_NAME = "course_creator_group" # of those two variables -def get_course_groupname_for_role(location, role): +def get_all_course_role_groupnames(location, role, use_filter=True): + ''' + Get all of the possible groupnames for this role location pair. If use_filter==True, + only return the ones defined in the groups collection. + ''' location = Locator.to_locator_or_location(location) # hack: check for existence of a group name in the legacy LMS format _ @@ -38,22 +46,46 @@ def get_course_groupname_for_role(location, role): except InvalidLocationError: # will occur on old locations where location is not of category course pass if isinstance(location, Location): + # least preferred role_course format groupnames.append('{0}_{1}'.format(role, location.course)) + try: + locator = loc_mapper().translate_location(location.course_id, location, False, False) + groupnames.append('{0}_{1}'.format(role, locator.course_id)) + except (InvalidLocationError, ItemNotFoundError): + pass elif isinstance(location, CourseLocator): old_location = loc_mapper().translate_locator_to_location(location, get_course=True) if old_location: + # the slashified version of the course_id (myu/mycourse/myrun) groupnames.append('{0}_{1}'.format(role, old_location.course_id)) - - for groupname in groupnames: - if Group.objects.filter(name=groupname).exists(): - return groupname - return groupnames[0] + # add the least desirable but sometimes occurring format. + groupnames.append('{0}_{1}'.format(role, old_location.course)) + # filter to the ones which exist + default = groupnames[0] + if use_filter: + groupnames = [group for group in groupnames if Group.objects.filter(name=group).exists()] + return groupnames, default -def get_users_in_course_group_by_role(location, role): - groupname = get_course_groupname_for_role(location, role) - (group, _created) = Group.objects.get_or_create(name=groupname) - return group.user_set.all() +def get_course_groupname_for_role(location, role): + ''' + Get the preferred used groupname for this role, location combo. + Preference order: + * role_course_id (e.g., staff_myu.mycourse.myrun) + * role_old_course_id (e.g., staff_myu/mycourse/myrun) + * role_old_course (e.g., staff_mycourse) + ''' + groupnames, default = get_all_course_role_groupnames(location, role) + return groupnames[0] if groupnames else default + + +def get_course_role_users(course_locator, role): + ''' + Get all of the users with the given role in the given course. + ''' + groupnames, _ = get_all_course_role_groupnames(course_locator, role) + groups = [Group.objects.get(name=groupname) for groupname in groupnames] + return list(itertools.chain.from_iterable(group.user_set.all() for group in groups)) def create_all_course_groups(creator, location): @@ -65,11 +97,11 @@ def create_all_course_groups(creator, location): def create_new_course_group(creator, location, role): - groupname = get_course_groupname_for_role(location, role) - (group, created) = Group.objects.get_or_create(name=groupname) - if created: - group.save() - + ''' + Create the new course group always using the preferred name even if another form already exists. + ''' + groupnames, __ = get_all_course_role_groupnames(location, role, use_filter=False) + group, __ = Group.objects.get_or_create(name=groupnames[0]) creator.groups.add(group) creator.save() @@ -82,15 +114,13 @@ def _delete_course_group(location): asserted permissions """ # remove all memberships - instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME)) - for user in instructors.user_set.all(): - user.groups.remove(instructors) - user.save() - - staff = Group.objects.get(name=get_course_groupname_for_role(location, STAFF_ROLE_NAME)) - for user in staff.user_set.all(): - user.groups.remove(staff) - user.save() + for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]: + groupnames, _ = get_all_course_role_groupnames(location, role) + for groupname in groupnames: + group = Group.objects.get(name=groupname) + for user in group.user_set.all(): + user.groups.remove(group) + user.save() def _copy_course_group(source, dest): @@ -98,25 +128,25 @@ def _copy_course_group(source, dest): This is to be called only by either a command line code path or through an app which has already asserted permissions to do this action """ - instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME)) - new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME)) - for user in instructors.user_set.all(): - user.groups.add(new_instructors_group) - user.save() - - staff = Group.objects.get(name=get_course_groupname_for_role(source, STAFF_ROLE_NAME)) - new_staff_group = Group.objects.get(name=get_course_groupname_for_role(dest, STAFF_ROLE_NAME)) - for user in staff.user_set.all(): - user.groups.add(new_staff_group) - user.save() + for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]: + groupnames, _ = get_all_course_role_groupnames(source, role) + for groupname in groupnames: + group = Group.objects.get(name=groupname) + new_group, _ = Group.objects.get_or_create(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME)) + for user in group.user_set.all(): + user.groups.add(new_group) + user.save() def add_user_to_course_group(caller, user, location, role): + """ + If caller is authorized, add the given user to the given course's role + """ # only admins can add/remove other users if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME): raise PermissionDenied - group = Group.objects.get(name=get_course_groupname_for_role(location, role)) + group, _ = Group.objects.get_or_create(name=get_course_groupname_for_role(location, role)) return _add_user_to_group(user, group) @@ -132,9 +162,7 @@ def add_user_to_creator_group(caller, user): if not caller.is_active or not caller.is_authenticated or not caller.is_staff: raise PermissionDenied - (group, created) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME) - if created: - group.save() + (group, _) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME) return _add_user_to_group(user, group) @@ -152,6 +180,9 @@ def _add_user_to_group(user, group): def get_user_by_email(email): + """ + Get the user whose email is the arg. Return None if no such user exists. + """ user = None # try to look up user, return None if not found try: @@ -163,13 +194,21 @@ def get_user_by_email(email): def remove_user_from_course_group(caller, user, location, role): + """ + If caller is authorized, remove the given course x role authorization for user + """ # only admins can add/remove other users if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME): raise PermissionDenied # see if the user is actually in that role, if not then we don't have to do anything - if is_user_in_course_group_role(user, location, role): - _remove_user_from_group(user, get_course_groupname_for_role(location, role)) + groupnames, _ = get_all_course_role_groupnames(location, role) + for groupname in groupnames: + groups = user.groups.filter(name=groupname) + if groups: + # will only be one with that name + user.groups.remove(groups[0]) + user.save() def remove_user_from_creator_group(caller, user): @@ -195,11 +234,16 @@ def _remove_user_from_group(user, group_name): def is_user_in_course_group_role(user, location, role, check_staff=True): + """ + Check whether the given user has the given role in this course. If check_staff + then give permission if the user is staff without doing a course-role query. + """ if user.is_active and user.is_authenticated: # all "is_staff" flagged accounts belong to all groups if check_staff and user.is_staff: return True - return user.groups.filter(name=get_course_groupname_for_role(location, role)).exists() + groupnames, _ = get_all_course_role_groupnames(location, role) + return any(user.groups.filter(name=groupname).exists() for groupname in groupnames) return False diff --git a/cms/djangoapps/contentstore/tests/test_permissions.py b/cms/djangoapps/contentstore/tests/test_permissions.py new file mode 100644 index 0000000000..6dae9d8831 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_permissions.py @@ -0,0 +1,127 @@ +""" +Test CRUD for authorization. +""" +from django.test.utils import override_settings +from django.contrib.auth.models import User, Group + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from contentstore.tests.modulestore_config import TEST_MODULESTORE +from contentstore.tests.utils import AjaxEnabledTestClient +from xmodule.modulestore.django import loc_mapper +from xmodule.modulestore import Location +from auth.authz import INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME +from auth import authz +import copy +from contentstore.views.access import has_access + + +@override_settings(MODULESTORE=TEST_MODULESTORE) +class TestCourseAccess(ModuleStoreTestCase): + """ + Course-based access (as opposed to access of a non-course xblock) + """ + def setUp(self): + """ + Create a staff user and log them in (creating the client). + + Create a pool of users w/o granting them any permissions + """ + super(TestCourseAccess, self).setUp() + uname = 'testuser' + email = 'test+courses@edx.org' + password = 'foo' + + # Create the use so we can log them in. + self.user = User.objects.create_user(uname, email, password) + + # Note that we do not actually need to do anything + # for registration if we directly mark them active. + self.user.is_active = True + # Staff has access to view all courses + self.user.is_staff = True + self.user.save() + + self.client = AjaxEnabledTestClient() + self.client.login(username=uname, password=password) + + # create a course via the view handler which has a different strategy for permissions than the factory + self.course_location = Location(['i4x', 'myu', 'mydept.mycourse', 'course', 'myrun']) + self.course_locator = loc_mapper().translate_location( + self.course_location.course_id, self.course_location, False, True + ) + self.client.ajax_post( + self.course_locator.url_reverse('course'), + { + 'org': self.course_location.org, + 'number': self.course_location.course, + 'display_name': 'My favorite course', + 'run': self.course_location.name, + } + ) + + self.users = self._create_users() + + def _create_users(self): + """ + Create 8 users and return them + """ + users = [] + for i in range(8): + username = "user{}".format(i) + email = "test+user{}@edx.org".format(i) + user = User.objects.create_user(username, email, 'foo') + user.is_active = True + user.save() + users.append(user) + return users + + def tearDown(self): + """ + Reverse the setup + """ + self.client.logout() + ModuleStoreTestCase.tearDown(self) + + def test_get_all_users(self): + """ + Test getting all authors for a course where their permissions run the gamut of allowed group + types. + """ + # first check the groupname for the course creator. + self.assertTrue( + self.user.groups.filter( + name="{}_{}".format(INSTRUCTOR_ROLE_NAME, self.course_locator.course_id) + ).exists(), + "Didn't add creator as instructor." + ) + users = copy.copy(self.users) + user_by_role = {} + # add the misc users to the course in different groups + for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]: + user_by_role[role] = [] + groupnames, _ = authz.get_all_course_role_groupnames(self.course_locator, role) + for groupname in groupnames: + group, _ = Group.objects.get_or_create(name=groupname) + user = users.pop() + user_by_role[role].append(user) + user.groups.add(group) + user.save() + self.assertTrue(has_access(user, self.course_locator), "{} does not have access".format(user)) + self.assertTrue(has_access(user, self.course_location), "{} does not have access".format(user)) + + response = self.client.get_html(self.course_locator.url_reverse('course_team')) + for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]: + for user in user_by_role[role]: + self.assertContains(response, user.email) + + # test copying course permissions + copy_course_location = Location(['i4x', 'copyu', 'copydept.mycourse', 'course', 'myrun']) + copy_course_locator = loc_mapper().translate_location( + copy_course_location.course_id, copy_course_location, False, True + ) + # pylint: disable=protected-access + authz._copy_course_group(self.course_locator, copy_course_locator) + for role in [INSTRUCTOR_ROLE_NAME, STAFF_ROLE_NAME]: + for user in user_by_role[role]: + self.assertTrue(has_access(user, copy_course_locator), "{} no copy access".format(user)) + self.assertTrue(has_access(user, copy_course_location), "{} no copy access".format(user)) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py index c10dec61bd..ec8b5570a5 100644 --- a/cms/djangoapps/contentstore/tests/test_users.py +++ b/cms/djangoapps/contentstore/tests/test_users.py @@ -29,8 +29,8 @@ class UsersTestCase(CourseTestCase): self.detail_url = self.location.url_reverse('course_team', self.ext_user.email) self.inactive_detail_url = self.location.url_reverse('course_team', self.inactive_user.email) self.invalid_detail_url = self.location.url_reverse('course_team', "nonexistent@user.com") - self.staff_groupname = get_course_groupname_for_role(self.course.location, "staff") - self.inst_groupname = get_course_groupname_for_role(self.course.location, "instructor") + self.staff_groupname = get_course_groupname_for_role(self.course_locator, "staff") + self.inst_groupname = get_course_groupname_for_role(self.course_locator, "instructor") def test_index(self): resp = self.client.get(self.index_url, HTTP_ACCEPT='text/html') @@ -145,18 +145,6 @@ class UsersTestCase(CourseTestCase): self.assertIn("error", result) self.assert_not_enrolled() - def test_detail_post_bad_json(self): - resp = self.client.post( - self.detail_url, - data="{foo}", - content_type="application/json", - HTTP_ACCEPT="application/json", - ) - self.assertEqual(resp.status_code, 400) - result = json.loads(resp.content) - self.assertIn("error", result) - self.assert_not_enrolled() - def test_detail_post_no_json(self): resp = self.client.post( self.detail_url, diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 044ef79473..c7e379869b 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -292,7 +292,8 @@ def create_new_course(request): initialize_course_tabs(new_course) - create_all_course_groups(request.user, new_course.location) + new_location = loc_mapper().translate_location(new_course.location.course_id, new_course.location, False, True) + create_all_course_groups(request.user, new_location) # seed the forums seed_permissions_roles(new_course.location.course_id) @@ -301,7 +302,6 @@ def create_new_course(request): # work. CourseEnrollment.enroll(request.user, new_course.location.course_id) - new_location = loc_mapper().translate_location(new_course.location.course_id, new_course.location, False, True) return JsonResponse({'url': new_location.url_reverse("course/", "")}) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index 6f2a2fbdec..d68aaeff7b 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -1,5 +1,4 @@ import json -from django.conf import settings from django.core.exceptions import PermissionDenied from django.contrib.auth.models import User, Group from django.contrib.auth.decorators import login_required @@ -10,9 +9,11 @@ from django_future.csrf import ensure_csrf_cookie from mitxmako.shortcuts import render_to_response from xmodule.modulestore.django import modulestore, loc_mapper -from util.json_request import JsonResponse +from util.json_request import JsonResponse, expect_json from auth.authz import ( - STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_course_groupname_for_role) + STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME, get_course_groupname_for_role, + get_course_role_users +) from course_creators.views import user_requested_access from .access import has_access @@ -35,6 +36,7 @@ def request_course_creator(request): return JsonResponse({"Status": "OK"}) +# pylint: disable=unused-argument @login_required @ensure_csrf_cookie @require_http_methods(("GET", "POST", "PUT", "DELETE")) @@ -62,38 +64,39 @@ def course_team_handler(request, tag=None, course_id=None, branch=None, version_ return HttpResponseNotFound() -def _manage_users(request, location): +def _manage_users(request, locator): """ This view will return all CMS users who are editors for the specified course """ - old_location = loc_mapper().translate_locator_to_location(location) + old_location = loc_mapper().translate_locator_to_location(locator) # check that logged in user has permissions to this item - if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME): + if not has_access(request.user, locator): raise PermissionDenied() course_module = modulestore().get_item(old_location) - - staff_groupname = get_course_groupname_for_role(location, "staff") - staff_group, __ = Group.objects.get_or_create(name=staff_groupname) - inst_groupname = get_course_groupname_for_role(location, "instructor") - inst_group, __ = Group.objects.get_or_create(name=inst_groupname) + instructors = get_course_role_users(locator, INSTRUCTOR_ROLE_NAME) + # the page only lists staff and assumes they're a superset of instructors. Do a union to ensure. + staff = set(get_course_role_users(locator, STAFF_ROLE_NAME)).union(instructors) return render_to_response('manage_users.html', { 'context_course': course_module, - 'staff': staff_group.user_set.all(), - 'instructors': inst_group.user_set.all(), - 'allow_actions': has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME), + 'staff': staff, + 'instructors': instructors, + 'allow_actions': has_access(request.user, locator, role=INSTRUCTOR_ROLE_NAME), }) -def _course_team_user(request, location, email): - old_location = loc_mapper().translate_locator_to_location(location) +@expect_json +def _course_team_user(request, locator, email): + """ + Handle the add, remove, promote, demote requests ensuring the requester has authority + """ # check that logged in user has permissions to this item - if has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): + if has_access(request.user, locator, role=INSTRUCTOR_ROLE_NAME): # instructors have full permissions pass - elif has_access(request.user, location, role=STAFF_ROLE_NAME) and email == request.user.email: + elif has_access(request.user, locator, role=STAFF_ROLE_NAME) and email == request.user.email: # staff can only affect themselves pass else: @@ -123,7 +126,7 @@ def _course_team_user(request, location, email): # what's the highest role that this user has? groupnames = set(g.name for g in user.groups.all()) for role in roles: - role_groupname = get_course_groupname_for_role(old_location, role) + role_groupname = get_course_groupname_for_role(locator, role) if role_groupname in groupnames: msg["role"] = role break @@ -139,7 +142,7 @@ def _course_team_user(request, location, email): # make sure that the role groups exist groups = {} for role in roles: - groupname = get_course_groupname_for_role(old_location, role) + groupname = get_course_groupname_for_role(locator, role) group, __ = Group.objects.get_or_create(name=groupname) groups[role] = group @@ -162,22 +165,13 @@ def _course_team_user(request, location, email): return JsonResponse() # all other operations require the requesting user to specify a role - if request.META.get("CONTENT_TYPE", "").startswith("application/json") and request.body: - try: - payload = json.loads(request.body) - except: - return JsonResponse({"error": _("malformed JSON")}, 400) - try: - role = payload["role"] - except KeyError: - return JsonResponse({"error": _("`role` is required")}, 400) - else: - if not "role" in request.POST: - return JsonResponse({"error": _("`role` is required")}, 400) - role = request.POST["role"] + role = request.json.get("role", request.POST.get("role")) + if role is None: + return JsonResponse({"error": _("`role` is required")}, 400) + old_location = loc_mapper().translate_locator_to_location(locator) if role == "instructor": - if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME): + if not has_access(request.user, locator, role=INSTRUCTOR_ROLE_NAME): msg = { "error": _("Only instructors may create other instructors") } @@ -203,4 +197,3 @@ def _course_team_user(request, location, email): CourseEnrollment.enroll(user, old_location.course_id) return JsonResponse() - From 78149d0ae42332367774fd9209804ba3f2f9145d Mon Sep 17 00:00:00 2001 From: polesye Date: Tue, 3 Dec 2013 09:08:44 +0200 Subject: [PATCH 082/110] Add comment. --- common/lib/xmodule/xmodule/js/src/problem/edit.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee index 3115a46a9a..d50cbae865 100644 --- a/common/lib/xmodule/xmodule/js/src/problem/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/problem/edit.coffee @@ -234,6 +234,7 @@ class @MarkdownEditingDescriptor extends XModule.Descriptor floatValue = parseFloat(answersList[0]); if(!isNaN(floatValue)) { + // Tries to extract parameters from string like 'expr +- tolerance' var params = /(.*?)\+\-\s*(.*?$)/.exec(answersList[0]), answer = answersList[0].replace(/\s+/g, ''); if(params) { From 8a180744edead97b553ac13f43704c177e701ffc Mon Sep 17 00:00:00 2001 From: polesye Date: Tue, 3 Dec 2013 12:50:16 +0200 Subject: [PATCH 083/110] Fix LTI max_score method. --- common/lib/xmodule/xmodule/lti_module.py | 2 +- common/lib/xmodule/xmodule/tests/test_lti_unit.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 12bca979db..abc2c7084e 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -375,7 +375,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} return params def max_score(self): - return self.weight + return self.weight if self.graded else 0 @XBlock.handler diff --git a/common/lib/xmodule/xmodule/tests/test_lti_unit.py b/common/lib/xmodule/xmodule/tests/test_lti_unit.py index b79bec16cc..2049366930 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti_unit.py +++ b/common/lib/xmodule/xmodule/tests/test_lti_unit.py @@ -249,3 +249,13 @@ class LTIModuleTest(LogicTest): def test_client_key_secret(self): pass + def test_max_score(self): + self.xmodule.weight = 100.0 + + self.xmodule.graded = True + self.assertEqual(self.xmodule.max_score(), 100) + + self.xmodule.graded = False + self.assertEqual(self.xmodule.max_score(), 0) + + From 2088a2159e8bc94a627e7547500952f308b75619 Mon Sep 17 00:00:00 2001 From: polesye Date: Tue, 3 Dec 2013 13:41:51 +0200 Subject: [PATCH 084/110] Add min value. --- common/lib/xmodule/xmodule/lti_module.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index abc2c7084e..96694491ac 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -88,7 +88,12 @@ class LTIFields(object): custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings) open_in_a_new_page = Boolean(help="Should LTI be opened in new page?", default=True, scope=Scope.settings) graded = Boolean(help="Grades will be considered in overall score.", default=False, scope=Scope.settings) - weight = Float(help="Weight for student grades.", default=1.0, scope=Scope.settings) + weight = Float( + help="Weight for student grades.", + default=1.0, + scope=Scope.settings, + values={"min": 0}, + ) class LTIModule(LTIFields, XModule): From 41d82dfcc1a444ad3b9e8c4219c4268f0b0e3270 Mon Sep 17 00:00:00 2001 From: polesye Date: Tue, 3 Dec 2013 17:21:04 +0200 Subject: [PATCH 085/110] BLD-542: Add display name. --- common/lib/xmodule/xmodule/lti_module.py | 1 + 1 file changed, 1 insertion(+) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 12bca979db..c499f38af9 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -83,6 +83,7 @@ class LTIFields(object): https://github.com/idan/oauthlib/blob/master/oauthlib/oauth1/rfc5849/signature.py#L136 """ + display_name = String(display_name="Display Name", help="Display name for this module", scope=Scope.settings, default="LTI") lti_id = String(help="Id of the tool", default='', scope=Scope.settings) launch_url = String(help="URL of the tool", default='http://www.example.com', scope=Scope.settings) custom_parameters = List(help="Custom parameters (vbid, book_location, etc..)", scope=Scope.settings) From 932d13ede883801fe60f1adce3f1eb20b7671f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Tue, 3 Dec 2013 17:48:27 +0200 Subject: [PATCH 086/110] Make has_score to be XField insted of decriptor property. --- common/lib/xmodule/xmodule/lti_module.py | 4 ++-- common/lib/xmodule/xmodule/tests/test_lti_unit.py | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 96694491ac..df826eed12 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -94,6 +94,7 @@ class LTIFields(object): scope=Scope.settings, values={"min": 0}, ) + has_score = Boolean(help="Does this LTI module have score?", default=False, scope=Scope.settings) class LTIModule(LTIFields, XModule): @@ -380,7 +381,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} return params def max_score(self): - return self.weight if self.graded else 0 + return self.weight if self.has_score else None @XBlock.handler @@ -586,6 +587,5 @@ class LTIDescriptor(LTIFields, MetadataOnlyEditingDescriptor, EmptyDataRawDescri """ Descriptor for LTI Xmodule. """ - has_score = True module_class = LTIModule grade_handler = module_attr('grade_handler') diff --git a/common/lib/xmodule/xmodule/tests/test_lti_unit.py b/common/lib/xmodule/xmodule/tests/test_lti_unit.py index 2049366930..427a3c3ff2 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti_unit.py +++ b/common/lib/xmodule/xmodule/tests/test_lti_unit.py @@ -253,9 +253,12 @@ class LTIModuleTest(LogicTest): self.xmodule.weight = 100.0 self.xmodule.graded = True - self.assertEqual(self.xmodule.max_score(), 100) + self.assertEqual(self.xmodule.max_score(), None) + + self.xmodule.has_score = True + self.assertEqual(self.xmodule.max_score(), 100.0) self.xmodule.graded = False - self.assertEqual(self.xmodule.max_score(), 0) + self.assertEqual(self.xmodule.max_score(), 100.0) From 1cd9325ef5df633656fd0294ff2e7bc44f7efa51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80?= Date: Tue, 3 Dec 2013 19:04:21 +0200 Subject: [PATCH 087/110] Fix LTI tests. --- common/lib/xmodule/xmodule/tests/test_lti_unit.py | 1 + lms/djangoapps/courseware/features/lti.feature | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_lti_unit.py b/common/lib/xmodule/xmodule/tests/test_lti_unit.py index 427a3c3ff2..bb04b20868 100644 --- a/common/lib/xmodule/xmodule/tests/test_lti_unit.py +++ b/common/lib/xmodule/xmodule/tests/test_lti_unit.py @@ -194,6 +194,7 @@ class LTIModuleTest(LogicTest): Response from Tool Provider is correct. """ self.xmodule.verify_oauth_body_sign = Mock() + self.xmodule.has_score = True request = Request(self.environ) request.body = self.get_request_body() response = self.xmodule.grade_handler(request, '') diff --git a/lms/djangoapps/courseware/features/lti.feature b/lms/djangoapps/courseware/features/lti.feature index 7f27a65b5a..eb2da7436b 100644 --- a/lms/djangoapps/courseware/features/lti.feature +++ b/lms/djangoapps/courseware/features/lti.feature @@ -44,8 +44,8 @@ Feature: LMS.LTI component Scenario: Graded LTI component in LMS is correctly works Given the course has correct LTI credentials And the course has an LTI component with correct fields: - | open_in_a_new_page | weight | is_graded | - | False | 10 | True | + | open_in_a_new_page | weight | is_graded | has_score | + | False | 10 | True | True | And I submit answer to LTI question And I click on the "Progress" tab Then I see text "Problem Scores: 5/10" From 599bdbb1023534ceddb815f6334fc447d2419d10 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 3 Dec 2013 14:05:33 -0500 Subject: [PATCH 088/110] Show full diffs in ResponseType tests. --- common/lib/capa/capa/tests/test_responsetypes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 99b3f1d52c..6b400dc653 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -29,6 +29,9 @@ class ResponseTest(unittest.TestCase): xml_factory_class = None + # If something is wrong, show it to us. + maxDiff = None + def setUp(self): if self.xml_factory_class: self.xml_factory = self.xml_factory_class() From 149c3fd05fefb512a15294c31b053f6f75a23636 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 3 Dec 2013 14:13:54 -0500 Subject: [PATCH 089/110] Make runone work with more test output. --- scripts/runone.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/runone.py b/scripts/runone.py index 19b5f7195b..3c2b312723 100755 --- a/scripts/runone.py +++ b/scripts/runone.py @@ -32,8 +32,12 @@ def main(argv): if words[0].endswith(':'): del words[0] - test_method = words[0] - test_path = words[1].split('.') + if len(words) == 1: + test_path, test_method = words[0].rsplit('.', 1) + test_path = test_path.split('.') + else: + test_method = words[0] + test_path = words[1].split('.') if test_path[0] == 'mitx': del test_path[0] test_class = test_path[-1] From 8eff442752b6ca6720b8d97373102140427f3486 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Mon, 2 Dec 2013 11:37:07 -0500 Subject: [PATCH 090/110] mitxmako => edxmako --- cms/djangoapps/contentstore/views/assets.py | 2 +- cms/djangoapps/contentstore/views/checklist.py | 2 +- cms/djangoapps/contentstore/views/component.py | 2 +- cms/djangoapps/contentstore/views/course.py | 2 +- cms/djangoapps/contentstore/views/dev.py | 2 +- cms/djangoapps/contentstore/views/error.py | 2 +- cms/djangoapps/contentstore/views/helpers.py | 2 +- cms/djangoapps/contentstore/views/import_export.py | 2 +- cms/djangoapps/contentstore/views/item.py | 2 +- cms/djangoapps/contentstore/views/preview.py | 2 +- cms/djangoapps/contentstore/views/public.py | 2 +- cms/djangoapps/contentstore/views/tabs.py | 2 +- cms/djangoapps/contentstore/views/user.py | 2 +- cms/djangoapps/course_creators/admin.py | 2 +- cms/envs/acceptance.py | 2 +- cms/envs/common.py | 4 ++-- cms/envs/dev.py | 2 +- cms/envs/test.py | 2 +- common/djangoapps/course_groups/views.py | 2 +- common/djangoapps/course_modes/views.py | 2 +- common/djangoapps/{mitxmako => edxmako}/README | 0 common/djangoapps/{mitxmako => edxmako}/__init__.py | 0 .../djangoapps/{mitxmako => edxmako}/makoloader.py | 2 +- .../{mitxmako => edxmako}/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/preprocess_assets.py | 0 .../djangoapps/{mitxmako => edxmako}/middleware.py | 0 common/djangoapps/{mitxmako => edxmako}/shortcuts.py | 10 +++++----- common/djangoapps/{mitxmako => edxmako}/startup.py | 4 ++-- common/djangoapps/{mitxmako => edxmako}/template.py | 12 ++++++------ .../{mitxmako => edxmako}/templatetag_helpers.py | 0 common/djangoapps/{mitxmako => edxmako}/tests.py | 4 ++-- common/djangoapps/external_auth/views.py | 2 +- common/djangoapps/pipeline_js/views.py | 2 +- common/djangoapps/pipeline_mako/__init__.py | 2 +- .../student/management/commands/massemail.py | 6 +++--- .../student/management/commands/massemailtxt.py | 6 +++--- common/djangoapps/student/views.py | 2 +- common/djangoapps/track/views.py | 2 +- common/djangoapps/util/views.py | 2 +- common/djangoapps/xmodule_modifiers.py | 2 +- .../xmodule/modulestore/tests/django_utils.py | 6 +++--- docs/internal/overview.md | 2 +- lms/djangoapps/branding/views.py | 4 ++-- lms/djangoapps/circuit/views.py | 2 +- lms/djangoapps/courseware/module_render.py | 2 +- lms/djangoapps/courseware/tests/__init__.py | 2 +- lms/djangoapps/courseware/tests/test_views.py | 2 +- lms/djangoapps/courseware/views.py | 2 +- lms/djangoapps/dashboard/views.py | 2 +- lms/djangoapps/debug/views.py | 2 +- lms/djangoapps/django_comment_client/base/views.py | 2 +- lms/djangoapps/django_comment_client/forum/views.py | 2 +- lms/djangoapps/django_comment_client/utils.py | 4 ++-- lms/djangoapps/instructor/enrollment.py | 2 +- lms/djangoapps/instructor/hint_manager.py | 2 +- .../instructor/views/instructor_dashboard.py | 2 +- lms/djangoapps/instructor/views/legacy.py | 2 +- lms/djangoapps/licenses/views.py | 2 +- lms/djangoapps/multicourse/views.py | 2 +- lms/djangoapps/notes/views.py | 2 +- lms/djangoapps/notification_prefs/views.py | 2 +- .../open_ended_grading/open_ended_notifications.py | 2 +- .../open_ended_grading/staff_grading_service.py | 2 +- lms/djangoapps/open_ended_grading/tests.py | 2 +- lms/djangoapps/open_ended_grading/utils.py | 2 +- lms/djangoapps/open_ended_grading/views.py | 4 ++-- lms/djangoapps/shoppingcart/models.py | 2 +- .../shoppingcart/processors/CyberSource.py | 2 +- lms/djangoapps/shoppingcart/tests/payment_fake.py | 2 +- lms/djangoapps/shoppingcart/tests/test_views.py | 2 +- lms/djangoapps/shoppingcart/views.py | 2 +- lms/djangoapps/static_template_view/views.py | 2 +- lms/djangoapps/staticbook/views.py | 2 +- lms/djangoapps/verify_student/views.py | 2 +- lms/envs/acceptance.py | 2 +- lms/envs/cms/dev.py | 2 +- lms/envs/cms/mixed_dev.py | 2 +- lms/envs/common.py | 10 +++++----- lms/envs/dev_ike.py | 2 +- lms/envs/dev_mongo.py | 2 +- 81 files changed, 99 insertions(+), 99 deletions(-) rename common/djangoapps/{mitxmako => edxmako}/README (100%) rename common/djangoapps/{mitxmako => edxmako}/__init__.py (100%) rename common/djangoapps/{mitxmako => edxmako}/makoloader.py (98%) rename common/djangoapps/{mitxmako => edxmako}/management/__init__.py (100%) rename common/djangoapps/{mitxmako => edxmako}/management/commands/__init__.py (100%) rename common/djangoapps/{mitxmako => edxmako}/management/commands/preprocess_assets.py (100%) rename common/djangoapps/{mitxmako => edxmako}/middleware.py (100%) rename common/djangoapps/{mitxmako => edxmako}/shortcuts.py (94%) rename common/djangoapps/{mitxmako => edxmako}/startup.py (94%) rename common/djangoapps/{mitxmako => edxmako}/template.py (88%) rename common/djangoapps/{mitxmako => edxmako}/templatetag_helpers.py (100%) rename common/djangoapps/{mitxmako => edxmako}/tests.py (92%) diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 7f2fab96f3..ed3588d895 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -7,7 +7,7 @@ from django.views.decorators.http import require_http_methods from django_future.csrf import ensure_csrf_cookie from django.views.decorators.http import require_POST -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from cache_toolbox.core import del_cached_content from xmodule.contentstore.django import contentstore diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py index 61c6c672a7..7326c3d16b 100644 --- a/cms/djangoapps/contentstore/views/checklist.py +++ b/cms/djangoapps/contentstore/views/checklist.py @@ -6,7 +6,7 @@ from django.http import HttpResponseBadRequest from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods from django_future.csrf import ensure_csrf_cookie -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from django.http import HttpResponseNotFound from django.core.exceptions import PermissionDenied from xmodule.modulestore.django import loc_mapper diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 3742c7af20..269b603eff 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -9,7 +9,7 @@ from django.core.exceptions import PermissionDenied from django_future.csrf import ensure_csrf_cookie from django.conf import settings from xmodule.modulestore.exceptions import ItemNotFoundError -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from xmodule.modulestore.django import modulestore from xmodule.util.date_utils import get_default_time_display diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 044ef79473..a92708bd69 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -16,7 +16,7 @@ from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.http import HttpResponseBadRequest, HttpResponseNotFound from util.json_request import JsonResponse -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from xmodule.error_module import ErrorDescriptor from xmodule.modulestore.django import modulestore, loc_mapper diff --git a/cms/djangoapps/contentstore/views/dev.py b/cms/djangoapps/contentstore/views/dev.py index 0fcc355c11..6bda59abdd 100644 --- a/cms/djangoapps/contentstore/views/dev.py +++ b/cms/djangoapps/contentstore/views/dev.py @@ -4,7 +4,7 @@ These views will NOT be shown on production: trying to access them will result in a 404 error. """ # pylint: disable=W0613 -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response def dev_mode(request): diff --git a/cms/djangoapps/contentstore/views/error.py b/cms/djangoapps/contentstore/views/error.py index 56499a69ac..9983987d29 100644 --- a/cms/djangoapps/contentstore/views/error.py +++ b/cms/djangoapps/contentstore/views/error.py @@ -2,7 +2,7 @@ from django.http import (HttpResponse, HttpResponseServerError, HttpResponseNotFound) -from mitxmako.shortcuts import render_to_string, render_to_response +from edxmako.shortcuts import render_to_string, render_to_response import functools import json diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py index abbf84755e..a10a489c9a 100644 --- a/cms/djangoapps/contentstore/views/helpers.py +++ b/cms/djangoapps/contentstore/views/helpers.py @@ -1,6 +1,6 @@ from django.http import HttpResponse from django.shortcuts import redirect -from mitxmako.shortcuts import render_to_string, render_to_response +from edxmako.shortcuts import render_to_string, render_to_response __all__ = ['edge', 'event', 'landing'] diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py index f740d10707..c36d30a34d 100644 --- a/cms/djangoapps/contentstore/views/import_export.py +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -21,7 +21,7 @@ from django.http import HttpResponseNotFound from django.views.decorators.http import require_http_methods, require_GET from django.utils.translation import ugettext as _ -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from auth.authz import create_all_course_groups from xmodule.modulestore.xml_importer import import_from_xml diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 220da038a7..0a85eb7765 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -30,7 +30,7 @@ from student.models import CourseEnrollment from django.http import HttpResponseBadRequest from xblock.fields import Scope from preview import handler_prefix, get_preview_html -from mitxmako.shortcuts import render_to_response, render_to_string +from edxmako.shortcuts import render_to_response, render_to_string from models.settings.course_grading import CourseGradingModel __all__ = ['orphan_handler', 'xblock_handler'] diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 123d7fbadb..3f5bfbfa60 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -5,7 +5,7 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.http import Http404, HttpResponseBadRequest from django.contrib.auth.decorators import login_required -from mitxmako.shortcuts import render_to_response, render_to_string +from edxmako.shortcuts import render_to_response, render_to_string from xmodule_modifiers import replace_static_urls, wrap_xblock from xmodule.error_module import ErrorDescriptor diff --git a/cms/djangoapps/contentstore/views/public.py b/cms/djangoapps/contentstore/views/public.py index 9ab03a4093..c7857af0c0 100644 --- a/cms/djangoapps/contentstore/views/public.py +++ b/cms/djangoapps/contentstore/views/public.py @@ -6,7 +6,7 @@ from django.core.context_processors import csrf from django.shortcuts import redirect from django.conf import settings -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from external_auth.views import ssl_login_shortcut diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index 46791ddc26..cf0ab42ffb 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -9,7 +9,7 @@ from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django_future.csrf import ensure_csrf_cookie from django.views.decorators.http import require_http_methods -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from xmodule.modulestore import Location from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.django import modulestore diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index 6f2a2fbdec..50120bad38 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -7,7 +7,7 @@ from django.views.decorators.http import require_http_methods from django.utils.translation import ugettext as _ from django.views.decorators.http import require_POST from django_future.csrf import ensure_csrf_cookie -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from xmodule.modulestore.django import modulestore, loc_mapper from util.json_request import JsonResponse diff --git a/cms/djangoapps/course_creators/admin.py b/cms/djangoapps/course_creators/admin.py index df2baa1aa2..87e17fabfa 100644 --- a/cms/djangoapps/course_creators/admin.py +++ b/cms/djangoapps/course_creators/admin.py @@ -8,7 +8,7 @@ from course_creators.views import update_course_creator_group from ratelimitbackend import admin from django.conf import settings from django.dispatch import receiver -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from django.core.mail import send_mail from smtplib import SMTPException diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index 1a9621a221..afbdff7d3f 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -34,7 +34,7 @@ DOC_STORE_CONFIG = { MODULESTORE_OPTIONS = { 'default_class': 'xmodule.raw_module.RawDescriptor', 'fs_root': TEST_ROOT / "data", - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'render_template': 'edxmako.shortcuts.render_to_string', } MODULESTORE = { diff --git a/cms/envs/common.py b/cms/envs/common.py index 8e2788a86a..961a089a14 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -161,7 +161,7 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', 'track.middleware.TrackMiddleware', - 'mitxmako.middleware.MakoMiddleware', + 'edxmako.middleware.MakoMiddleware', # Detects user-requested locale from 'accept-language' header in http request 'django.middleware.locale.LocaleMiddleware', @@ -393,7 +393,7 @@ INSTALLED_APPS = ( 'datadog', # For asset pipelining - 'mitxmako', + 'edxmako', 'pipeline', 'staticfiles', 'static_replace', diff --git a/cms/envs/dev.py b/cms/envs/dev.py index ea66688a8a..ddf1708c87 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -30,7 +30,7 @@ DOC_STORE_CONFIG = { modulestore_options = { 'default_class': 'xmodule.raw_module.RawDescriptor', 'fs_root': GITHUB_REPO_ROOT, - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'render_template': 'edxmako.shortcuts.render_to_string', } MODULESTORE = { diff --git a/cms/envs/test.py b/cms/envs/test.py index c64e0ef1e8..ef7133e0bd 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -60,7 +60,7 @@ DOC_STORE_CONFIG = { MODULESTORE_OPTIONS = { 'default_class': 'xmodule.raw_module.RawDescriptor', 'fs_root': TEST_ROOT / "data", - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'render_template': 'edxmako.shortcuts.render_to_string', } MODULESTORE = { diff --git a/common/djangoapps/course_groups/views.py b/common/djangoapps/course_groups/views.py index 764f6c301d..fb1a99a6aa 100644 --- a/common/djangoapps/course_groups/views.py +++ b/common/djangoapps/course_groups/views.py @@ -9,7 +9,7 @@ import logging import re from courseware.courses import get_course_with_access -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from . import cohorts diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 7c4bb3a1f7..f3dca5d08f 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -13,7 +13,7 @@ from django.utils.translation import ugettext as _ from django.contrib.auth.decorators import login_required from django.utils.decorators import method_decorator -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from course_modes.models import CourseMode from courseware.access import has_access diff --git a/common/djangoapps/mitxmako/README b/common/djangoapps/edxmako/README similarity index 100% rename from common/djangoapps/mitxmako/README rename to common/djangoapps/edxmako/README diff --git a/common/djangoapps/mitxmako/__init__.py b/common/djangoapps/edxmako/__init__.py similarity index 100% rename from common/djangoapps/mitxmako/__init__.py rename to common/djangoapps/edxmako/__init__.py diff --git a/common/djangoapps/mitxmako/makoloader.py b/common/djangoapps/edxmako/makoloader.py similarity index 98% rename from common/djangoapps/mitxmako/makoloader.py rename to common/djangoapps/edxmako/makoloader.py index 06ae2219e6..8e741f9946 100644 --- a/common/djangoapps/mitxmako/makoloader.py +++ b/common/djangoapps/edxmako/makoloader.py @@ -6,7 +6,7 @@ from django.template.loader import make_origin, get_template_from_string from django.template.loaders.filesystem import Loader as FilesystemLoader from django.template.loaders.app_directories import Loader as AppDirectoriesLoader -from mitxmako.template import Template +from edxmako.template import Template import tempdir diff --git a/common/djangoapps/mitxmako/management/__init__.py b/common/djangoapps/edxmako/management/__init__.py similarity index 100% rename from common/djangoapps/mitxmako/management/__init__.py rename to common/djangoapps/edxmako/management/__init__.py diff --git a/common/djangoapps/mitxmako/management/commands/__init__.py b/common/djangoapps/edxmako/management/commands/__init__.py similarity index 100% rename from common/djangoapps/mitxmako/management/commands/__init__.py rename to common/djangoapps/edxmako/management/commands/__init__.py diff --git a/common/djangoapps/mitxmako/management/commands/preprocess_assets.py b/common/djangoapps/edxmako/management/commands/preprocess_assets.py similarity index 100% rename from common/djangoapps/mitxmako/management/commands/preprocess_assets.py rename to common/djangoapps/edxmako/management/commands/preprocess_assets.py diff --git a/common/djangoapps/mitxmako/middleware.py b/common/djangoapps/edxmako/middleware.py similarity index 100% rename from common/djangoapps/mitxmako/middleware.py rename to common/djangoapps/edxmako/middleware.py diff --git a/common/djangoapps/mitxmako/shortcuts.py b/common/djangoapps/edxmako/shortcuts.py similarity index 94% rename from common/djangoapps/mitxmako/shortcuts.py rename to common/djangoapps/edxmako/shortcuts.py index 356c80ced3..e83f1b028f 100644 --- a/common/djangoapps/mitxmako/shortcuts.py +++ b/common/djangoapps/edxmako/shortcuts.py @@ -16,8 +16,8 @@ from django.template import Context from django.http import HttpResponse import logging -import mitxmako -import mitxmako.middleware +import edxmako +import edxmako.middleware from django.conf import settings from django.core.urlresolvers import reverse log = logging.getLogger(__name__) @@ -81,15 +81,15 @@ def render_to_string(template_name, dictionary, context=None, namespace='main'): context_instance['marketing_link'] = marketing_link # In various testing contexts, there might not be a current request context. - if mitxmako.middleware.requestcontext is not None: - for d in mitxmako.middleware.requestcontext: + if edxmako.middleware.requestcontext is not None: + for d in edxmako.middleware.requestcontext: context_dictionary.update(d) for d in context_instance: context_dictionary.update(d) if context: context_dictionary.update(context) # fetch and render template - template = mitxmako.lookup[namespace].get_template(template_name) + template = edxmako.lookup[namespace].get_template(template_name) return template.render_unicode(**context_dictionary) diff --git a/common/djangoapps/mitxmako/startup.py b/common/djangoapps/edxmako/startup.py similarity index 94% rename from common/djangoapps/mitxmako/startup.py rename to common/djangoapps/edxmako/startup.py index db9483b366..2b58deac2e 100644 --- a/common/djangoapps/mitxmako/startup.py +++ b/common/djangoapps/edxmako/startup.py @@ -6,7 +6,7 @@ import tempdir from django.conf import settings from mako.lookup import TemplateLookup -import mitxmako +import edxmako def run(): @@ -30,4 +30,4 @@ def run(): encoding_errors='replace', ) - mitxmako.lookup = lookup + edxmako.lookup = lookup diff --git a/common/djangoapps/mitxmako/template.py b/common/djangoapps/edxmako/template.py similarity index 88% rename from common/djangoapps/mitxmako/template.py rename to common/djangoapps/edxmako/template.py index 7dfc6de026..d515425b16 100644 --- a/common/djangoapps/mitxmako/template.py +++ b/common/djangoapps/edxmako/template.py @@ -14,10 +14,10 @@ from django.conf import settings from mako.template import Template as MakoTemplate -from mitxmako.shortcuts import marketing_link +from edxmako.shortcuts import marketing_link -import mitxmako -import mitxmako.middleware +import edxmako +import edxmako.middleware django_variables = ['lookup', 'output_encoding', 'encoding_errors'] @@ -34,7 +34,7 @@ class Template(MakoTemplate): def __init__(self, *args, **kwargs): """Overrides base __init__ to provide django variable overrides""" if not kwargs.get('no_django', False): - overrides = dict([(k, getattr(mitxmako, k, None),) for k in django_variables]) + overrides = dict([(k, getattr(edxmako, k, None),) for k in django_variables]) overrides['lookup'] = overrides['lookup']['main'] kwargs.update(overrides) super(Template, self).__init__(*args, **kwargs) @@ -48,8 +48,8 @@ class Template(MakoTemplate): context_dictionary = {} # In various testing contexts, there might not be a current request context. - if mitxmako.middleware.requestcontext is not None: - for d in mitxmako.middleware.requestcontext: + if edxmako.middleware.requestcontext is not None: + for d in edxmako.middleware.requestcontext: context_dictionary.update(d) for d in context_instance: context_dictionary.update(d) diff --git a/common/djangoapps/mitxmako/templatetag_helpers.py b/common/djangoapps/edxmako/templatetag_helpers.py similarity index 100% rename from common/djangoapps/mitxmako/templatetag_helpers.py rename to common/djangoapps/edxmako/templatetag_helpers.py diff --git a/common/djangoapps/mitxmako/tests.py b/common/djangoapps/edxmako/tests.py similarity index 92% rename from common/djangoapps/mitxmako/tests.py rename to common/djangoapps/edxmako/tests.py index e7e56a9472..a4eb84eda8 100644 --- a/common/djangoapps/mitxmako/tests.py +++ b/common/djangoapps/edxmako/tests.py @@ -1,14 +1,14 @@ from django.test import TestCase from django.test.utils import override_settings from django.core.urlresolvers import reverse -from mitxmako.shortcuts import marketing_link +from edxmako.shortcuts import marketing_link from mock import patch from util.testing import UrlResetMixin class ShortcutsTests(UrlResetMixin, TestCase): """ - Test the mitxmako shortcuts file + Test the edxmako shortcuts file """ @override_settings(MKTG_URLS={'ROOT': 'dummy-root', 'ABOUT': '/about-us'}) @override_settings(MKTG_URL_LINK_MAP={'ABOUT': 'login'}) diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index a995dff22b..aa7536c460 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -28,7 +28,7 @@ from django.utils.http import urlquote, is_safe_url from django.shortcuts import redirect from django.utils.translation import ugettext as _ -from mitxmako.shortcuts import render_to_response, render_to_string +from edxmako.shortcuts import render_to_response, render_to_string try: from django.views.decorators.csrf import csrf_exempt except ImportError: diff --git a/common/djangoapps/pipeline_js/views.py b/common/djangoapps/pipeline_js/views.py index 06b76900cf..6cd97159d4 100644 --- a/common/djangoapps/pipeline_js/views.py +++ b/common/djangoapps/pipeline_js/views.py @@ -5,7 +5,7 @@ import json from django.conf import settings from django.http import HttpResponse from staticfiles.storage import staticfiles_storage -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response def get_xmodule_urls(): diff --git a/common/djangoapps/pipeline_mako/__init__.py b/common/djangoapps/pipeline_mako/__init__.py index 1cdc287e2e..ed343588da 100644 --- a/common/djangoapps/pipeline_mako/__init__.py +++ b/common/djangoapps/pipeline_mako/__init__.py @@ -1,4 +1,4 @@ -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from pipeline.conf import settings from pipeline.packager import Packager diff --git a/common/djangoapps/student/management/commands/massemail.py b/common/djangoapps/student/management/commands/massemail.py index a1864f048e..5ce8afb41f 100644 --- a/common/djangoapps/student/management/commands/massemail.py +++ b/common/djangoapps/student/management/commands/massemail.py @@ -1,7 +1,7 @@ from django.core.management.base import BaseCommand from django.contrib.auth.models import User -import mitxmako +import edxmako class Command(BaseCommand): @@ -15,8 +15,8 @@ body, and an _subject.txt for the subject. ''' #text = open(args[0]).read() #subject = open(args[1]).read() users = User.objects.all() - text = mitxmako.lookup['main'].get_template('email/' + args[0] + ".txt").render() - subject = mitxmako.lookup['main'].get_template('email/' + args[0] + "_subject.txt").render().strip() + text = edxmako.lookup['main'].get_template('email/' + args[0] + ".txt").render() + subject = edxmako.lookup['main'].get_template('email/' + args[0] + "_subject.txt").render().strip() for user in users: if user.is_active: user.email_user(subject, text) diff --git a/common/djangoapps/student/management/commands/massemailtxt.py b/common/djangoapps/student/management/commands/massemailtxt.py index 0228acf923..1ff8557a25 100644 --- a/common/djangoapps/student/management/commands/massemailtxt.py +++ b/common/djangoapps/student/management/commands/massemailtxt.py @@ -4,7 +4,7 @@ import time from django.core.management.base import BaseCommand from django.conf import settings -import mitxmako +import edxmako from django.core.mail import send_mass_mail import sys @@ -39,8 +39,8 @@ rate -- messages per second users = [u.strip() for u in open(user_file).readlines()] - message = mitxmako.lookup['main'].get_template('emails/' + message_base + "_body.txt").render() - subject = mitxmako.lookup['main'].get_template('emails/' + message_base + "_subject.txt").render().strip() + message = edxmako.lookup['main'].get_template('emails/' + message_base + "_body.txt").render() + subject = edxmako.lookup['main'].get_template('emails/' + message_base + "_subject.txt").render().strip() rate = int(ratestr) self.log_file = open(logfilename, "a+", buffering=0) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 2c3099a672..28c4e4f5fd 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -35,7 +35,7 @@ from django.contrib.admin.views.decorators import staff_member_required from ratelimitbackend.exceptions import RateLimitException -from mitxmako.shortcuts import render_to_response, render_to_string +from edxmako.shortcuts import render_to_response, render_to_string from course_modes.models import CourseMode from student.models import ( diff --git a/common/djangoapps/track/views.py b/common/djangoapps/track/views.py index e493babe5d..ec05232395 100644 --- a/common/djangoapps/track/views.py +++ b/common/djangoapps/track/views.py @@ -9,7 +9,7 @@ from django.shortcuts import redirect from django_future.csrf import ensure_csrf_cookie -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from track import tracker from track import contexts diff --git a/common/djangoapps/util/views.py b/common/djangoapps/util/views.py index 10492e383d..b08c719baa 100644 --- a/common/djangoapps/util/views.py +++ b/common/djangoapps/util/views.py @@ -9,7 +9,7 @@ from django.views.defaults import server_error from django.http import (Http404, HttpResponse, HttpResponseNotAllowed, HttpResponseServerError) from dogapi import dog_stats_api -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response import zendesk import calc diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py index 6e6d01c28f..d46065d5a7 100644 --- a/common/djangoapps/xmodule_modifiers.py +++ b/common/djangoapps/xmodule_modifiers.py @@ -9,7 +9,7 @@ import static_replace from django.conf import settings from django.utils.timezone import UTC -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from xblock.fragment import Fragment from xmodule.seq_module import SequenceModule diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 776543ec4e..16f756b233 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -60,7 +60,7 @@ def mongo_store_config(data_dir): 'OPTIONS': { 'default_class': 'xmodule.raw_module.RawDescriptor', 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string' + 'render_template': 'edxmako.shortcuts.render_to_string' } } } @@ -77,7 +77,7 @@ def draft_mongo_store_config(data_dir): modulestore_options = { 'default_class': 'xmodule.raw_module.RawDescriptor', 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string' + 'render_template': 'edxmako.shortcuts.render_to_string' } store = { @@ -126,7 +126,7 @@ def studio_store_config(data_dir): options = { 'default_class': 'xmodule.raw_module.RawDescriptor', 'fs_root': data_dir, - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'render_template': 'edxmako.shortcuts.render_to_string', } store = { diff --git a/docs/internal/overview.md b/docs/internal/overview.md index a74c512fbf..911c93ab24 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -120,7 +120,7 @@ environments, defined in `cms/envs`. - javascript -- we use coffeescript, which compiles to js, and is much nicer to work with. Look for `*.coffee` files. We use _jasmine_ for testing js. -- _mako_ -- we use this for templates, and have wrapper called mitxmako that makes mako look like the django templating calls. +- _mako_ -- we use this for templates, and have wrapper called edxmako that makes mako look like the django templating calls. We use a fork of django-pipeline to make sure that the js and css always reflect the latest `*.coffee` and `*.sass` files (We're hoping to get our changes merged in the official version soon). This works differently in development and production. Test uses the production settings. diff --git a/lms/djangoapps/branding/views.py b/lms/djangoapps/branding/views.py index 3ce8dbd401..215d442de5 100644 --- a/lms/djangoapps/branding/views.py +++ b/lms/djangoapps/branding/views.py @@ -3,12 +3,12 @@ from django.core.urlresolvers import reverse from django.http import Http404 from django.shortcuts import redirect from django_future.csrf import ensure_csrf_cookie -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response import student.views import branding import courseware.views -from mitxmako.shortcuts import marketing_link +from edxmako.shortcuts import marketing_link from util.cache import cache_if_anonymous diff --git a/lms/djangoapps/circuit/views.py b/lms/djangoapps/circuit/views.py index cc85c2a452..5af9ce1b44 100644 --- a/lms/djangoapps/circuit/views.py +++ b/lms/djangoapps/circuit/views.py @@ -4,7 +4,7 @@ import xml.etree.ElementTree from django.http import Http404 from django.http import HttpResponse -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from .models import ServerCircuit diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 3b804c4ba9..436e9ee54a 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -22,7 +22,7 @@ from courseware.masquerade import setup_masquerade from courseware.model_data import FieldDataCache, DjangoKeyValueStore from lms.lib.xblock.field_data import LmsFieldData from lms.lib.xblock.runtime import LmsModuleSystem, handler_prefix, unquote_slashes -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from psychometrics.psychoanalyze import make_psychometrics_data_update_handler from student.models import anonymous_id_for_user, user_by_anonymous_id from util.json_request import JsonResponse diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py index 9a17073a37..fcca925f35 100644 --- a/lms/djangoapps/courseware/tests/__init__.py +++ b/lms/djangoapps/courseware/tests/__init__.py @@ -11,7 +11,7 @@ from django.test.utils import override_settings from django.core.urlresolvers import reverse from django.test.client import Client -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from student.tests.factories import UserFactory, CourseEnrollmentFactory from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from xblock.field_data import DictFieldData diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 17198c372d..b1634a72ac 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -17,7 +17,7 @@ from django.core.urlresolvers import reverse from student.models import CourseEnrollment from student.tests.factories import AdminFactory -from mitxmako.middleware import MakoMiddleware +from edxmako.middleware import MakoMiddleware from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 69fd33f417..2ce1f54571 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -11,7 +11,7 @@ from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required from django.http import Http404, HttpResponse, HttpResponseRedirect from django.shortcuts import redirect -from mitxmako.shortcuts import render_to_response, render_to_string +from edxmako.shortcuts import render_to_response, render_to_string from django_future.csrf import ensure_csrf_cookie from django.views.decorators.cache import cache_control from django.db import transaction diff --git a/lms/djangoapps/dashboard/views.py b/lms/djangoapps/dashboard/views.py index 630222d7bf..8d1f63b410 100644 --- a/lms/djangoapps/dashboard/views.py +++ b/lms/djangoapps/dashboard/views.py @@ -1,5 +1,5 @@ from django.http import Http404 -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from django.db import connection from student.models import CourseEnrollment diff --git a/lms/djangoapps/debug/views.py b/lms/djangoapps/debug/views.py index 317ebcada9..5a5927e350 100644 --- a/lms/djangoapps/debug/views.py +++ b/lms/djangoapps/debug/views.py @@ -6,7 +6,7 @@ import traceback from django.http import Http404 from django.contrib.auth.decorators import login_required from django_future.csrf import ensure_csrf_cookie -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from codejail.safe_exec import safe_exec diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py index 4600f121f7..0e0bedc141 100644 --- a/lms/djangoapps/django_comment_client/base/views.py +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -19,7 +19,7 @@ from django.core.files.storage import get_storage_class from django.utils.translation import ugettext as _ from django.contrib.auth.models import User -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from courseware.courses import get_course_with_access, get_course_by_id from course_groups.cohorts import get_cohort_id, is_commentable_cohorted diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index dac91512d6..83516fc3ac 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -8,7 +8,7 @@ from django.core.context_processors import csrf from django.contrib.auth.models import User import newrelic.agent -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from courseware.courses import get_course_with_access from course_groups.cohorts import (is_course_cohorted, get_cohort_id, is_commentable_cohorted, get_cohorted_commentables, get_course_cohorts, get_cohort_by_id) diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 527a7d0860..a92797f7e6 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -12,7 +12,7 @@ from django.utils import simplejson from django_comment_common.models import Role, FORUM_ROLE_STUDENT from django_comment_client.permissions import check_permissions_by_view -import mitxmako +import edxmako import pystache_custom as pystache from xmodule.modulestore.django import modulestore @@ -310,7 +310,7 @@ def url_for_tags(course_id, tags): def render_mustache(template_name, dictionary, *args, **kwargs): - template = mitxmako.lookup['main'].get_template(template_name).source + template = edxmako.lookup['main'].get_template(template_name).source return pystache.render(template, dictionary) diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 508271fa8b..a7c4fe0b65 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -12,7 +12,7 @@ from django.core.mail import send_mail from student.models import CourseEnrollment, CourseEnrollmentAllowed from courseware.models import StudentModule -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string # For determining if a shibboleth course SHIBBOLETH_DOMAIN_PREFIX = 'shib:' diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py index 9f555b9c56..a8b97fbe32 100644 --- a/lms/djangoapps/instructor/hint_manager.py +++ b/lms/djangoapps/instructor/hint_manager.py @@ -13,7 +13,7 @@ import re from django.http import HttpResponse, Http404 from django_future.csrf import ensure_csrf_cookie -from mitxmako.shortcuts import render_to_response, render_to_string +from edxmako.shortcuts import render_to_response, render_to_string from courseware.courses import get_course_with_access from courseware.models import XModuleUserStateSummaryField diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index de14df2940..9c1cc43a61 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -6,7 +6,7 @@ from functools import partial from django.utils.translation import ugettext as _ from django_future.csrf import ensure_csrf_cookie from django.views.decorators.cache import cache_control -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from django.core.urlresolvers import reverse from django.utils.html import escape from django.http import Http404 diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 98c18dc68b..09046f7593 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -50,7 +50,7 @@ from instructor_task.api import (get_running_instructor_tasks, submit_reset_problem_attempts_for_all_students, submit_bulk_course_email) from instructor_task.views import get_task_completion_info -from mitxmako.shortcuts import render_to_response, render_to_string +from edxmako.shortcuts import render_to_response, render_to_string from psychometrics import psychoanalyze from student.models import CourseEnrollment, CourseEnrollmentAllowed, unique_id_for_user from student.views import course_from_id diff --git a/lms/djangoapps/licenses/views.py b/lms/djangoapps/licenses/views.py index 1c1a80ed31..42ecb9ecf1 100644 --- a/lms/djangoapps/licenses/views.py +++ b/lms/djangoapps/licenses/views.py @@ -5,7 +5,7 @@ from urlparse import urlparse from collections import namedtuple, defaultdict -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User diff --git a/lms/djangoapps/multicourse/views.py b/lms/djangoapps/multicourse/views.py index da9ccb77a6..c48c4477e4 100644 --- a/lms/djangoapps/multicourse/views.py +++ b/lms/djangoapps/multicourse/views.py @@ -1,5 +1,5 @@ from django.conf import settings -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from multicourse import multicourse_settings diff --git a/lms/djangoapps/notes/views.py b/lms/djangoapps/notes/views.py index 01671b7ccd..bf57f80c3d 100644 --- a/lms/djangoapps/notes/views.py +++ b/lms/djangoapps/notes/views.py @@ -1,6 +1,6 @@ from django.contrib.auth.decorators import login_required from django.http import Http404 -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from courseware.courses import get_course_with_access from notes.models import Note from notes.utils import notes_enabled_for_course diff --git a/lms/djangoapps/notification_prefs/views.py b/lms/djangoapps/notification_prefs/views.py index 6be5b8f766..68e8c875da 100644 --- a/lms/djangoapps/notification_prefs/views.py +++ b/lms/djangoapps/notification_prefs/views.py @@ -10,7 +10,7 @@ from django.core.exceptions import PermissionDenied from django.http import Http404, HttpResponse from django.views.decorators.http import require_GET, require_POST -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from notification_prefs import NOTIFICATION_PREF_KEY from user_api.models import UserPreference diff --git a/lms/djangoapps/open_ended_grading/open_ended_notifications.py b/lms/djangoapps/open_ended_grading/open_ended_notifications.py index c4580dd304..e99f51283c 100644 --- a/lms/djangoapps/open_ended_grading/open_ended_notifications.py +++ b/lms/djangoapps/open_ended_grading/open_ended_notifications.py @@ -9,7 +9,7 @@ from xmodule.open_ended_grading_classes.controller_query_service import Controll from courseware.access import has_access from lms.lib.xblock.runtime import LmsModuleSystem -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from student.models import unique_id_for_user from util.cache import cache diff --git a/lms/djangoapps/open_ended_grading/staff_grading_service.py b/lms/djangoapps/open_ended_grading/staff_grading_service.py index 1322f3f069..ba8009ece3 100644 --- a/lms/djangoapps/open_ended_grading/staff_grading_service.py +++ b/lms/djangoapps/open_ended_grading/staff_grading_service.py @@ -13,7 +13,7 @@ from xmodule.open_ended_grading_classes.grading_service_module import GradingSer from courseware.access import has_access from lms.lib.xblock.runtime import LmsModuleSystem -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from student.models import unique_id_for_user from util.json_request import expect_json diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py index ad6298d71a..7e4a805fc9 100644 --- a/lms/djangoapps/open_ended_grading/tests.py +++ b/lms/djangoapps/open_ended_grading/tests.py @@ -27,7 +27,7 @@ from courseware.tests.helpers import LoginEnrollmentTestCase, check_for_get_code from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from lms.lib.xblock.runtime import LmsModuleSystem from courseware.roles import CourseStaffRole -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from student.models import unique_id_for_user from open_ended_grading import staff_grading_service, views, utils diff --git a/lms/djangoapps/open_ended_grading/utils.py b/lms/djangoapps/open_ended_grading/utils.py index bfde229e9c..01d58fb479 100644 --- a/lms/djangoapps/open_ended_grading/utils.py +++ b/lms/djangoapps/open_ended_grading/utils.py @@ -11,7 +11,7 @@ from django.utils.translation import ugettext as _ from django.conf import settings from lms.lib.xblock.runtime import LmsModuleSystem -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string log = logging.getLogger(__name__) diff --git a/lms/djangoapps/open_ended_grading/views.py b/lms/djangoapps/open_ended_grading/views.py index e8002e0883..383187bbb9 100644 --- a/lms/djangoapps/open_ended_grading/views.py +++ b/lms/djangoapps/open_ended_grading/views.py @@ -2,7 +2,7 @@ import logging from django.conf import settings from django.views.decorators.cache import cache_control -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from django.core.urlresolvers import reverse from student.models import unique_id_for_user @@ -19,7 +19,7 @@ from xmodule.modulestore import search from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem from django.http import HttpResponse, Http404, HttpResponseRedirect -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from django.utils.translation import ugettext as _ from open_ended_grading.utils import (STAFF_ERROR_MESSAGE, STUDENT_ERROR_MESSAGE, diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index fa77efb50f..7de62343c7 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -23,7 +23,7 @@ from xmodule.course_module import CourseDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError from course_modes.models import CourseMode -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from student.views import course_from_id from student.models import CourseEnrollment, unenroll_done diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 5d43482bd4..d6811303a1 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -13,7 +13,7 @@ from hashlib import sha1 from textwrap import dedent from django.conf import settings from django.utils.translation import ugettext as _ -from mitxmako.shortcuts import render_to_string +from edxmako.shortcuts import render_to_string from shoppingcart.models import Order from shoppingcart.processors.exceptions import * diff --git a/lms/djangoapps/shoppingcart/tests/payment_fake.py b/lms/djangoapps/shoppingcart/tests/payment_fake.py index fa6f401904..0de87410ef 100644 --- a/lms/djangoapps/shoppingcart/tests/payment_fake.py +++ b/lms/djangoapps/shoppingcart/tests/payment_fake.py @@ -15,7 +15,7 @@ set to "success" or "failure". The view defaults to payment success. from django.views.generic.base import View from django.views.decorators.csrf import csrf_exempt from django.http import HttpResponse, HttpResponseBadRequest -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response # We use the same hashing function as the software under test, diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 0451277ce2..0d23dc0419 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -18,7 +18,7 @@ from shoppingcart.models import Order, CertificateItem, PaidCourseRegistration, from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from shoppingcart.processors import render_purchase_form_html from mock import patch, Mock diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index ad7ef6b080..26cf3e5a51 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -10,7 +10,7 @@ from django.views.decorators.http import require_POST from django.core.urlresolvers import reverse from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from .models import Order, PaidCourseRegistration, OrderItem from .processors import process_postpay_callback, render_purchase_form_html from .exceptions import ItemAlreadyInCartException, AlreadyEnrolledInCourseException, CourseDoesNotExistException diff --git a/lms/djangoapps/static_template_view/views.py b/lms/djangoapps/static_template_view/views.py index e5a8c43ca8..4cff3c77ac 100644 --- a/lms/djangoapps/static_template_view/views.py +++ b/lms/djangoapps/static_template_view/views.py @@ -3,7 +3,7 @@ # List of valid templates is explicitly managed for (short-term) # security reasons. -from mitxmako.shortcuts import render_to_response, render_to_string +from edxmako.shortcuts import render_to_response, render_to_string from mako.exceptions import TopLevelLookupException from django.shortcuts import redirect from django.conf import settings diff --git a/lms/djangoapps/staticbook/views.py b/lms/djangoapps/staticbook/views.py index f73cfb6e5b..db9dce87dd 100644 --- a/lms/djangoapps/staticbook/views.py +++ b/lms/djangoapps/staticbook/views.py @@ -4,7 +4,7 @@ Views for serving static textbooks. from django.contrib.auth.decorators import login_required from django.http import Http404 -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from courseware.access import has_access from courseware.courses import get_course_with_access diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 673564e72c..2ac798bbf2 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -6,7 +6,7 @@ import json import logging import decimal -from mitxmako.shortcuts import render_to_response +from edxmako.shortcuts import render_to_response from django.conf import settings from django.core.urlresolvers import reverse diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index 8236303161..76db5b005b 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -36,7 +36,7 @@ DOC_STORE_CONFIG = { modulestore_options = { 'default_class': 'xmodule.raw_module.RawDescriptor', 'fs_root': TEST_ROOT / "data", - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'render_template': 'edxmako.shortcuts.render_to_string', } MODULESTORE = { diff --git a/lms/envs/cms/dev.py b/lms/envs/cms/dev.py index 0978d88d17..f70d05653f 100644 --- a/lms/envs/cms/dev.py +++ b/lms/envs/cms/dev.py @@ -31,7 +31,7 @@ DOC_STORE_CONFIG = { modulestore_options = { 'default_class': 'xmodule.raw_module.RawDescriptor', 'fs_root': DATA_DIR, - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'render_template': 'edxmako.shortcuts.render_to_string', } MODULESTORE = { diff --git a/lms/envs/cms/mixed_dev.py b/lms/envs/cms/mixed_dev.py index ba45d7e747..f636566558 100644 --- a/lms/envs/cms/mixed_dev.py +++ b/lms/envs/cms/mixed_dev.py @@ -33,7 +33,7 @@ MODULESTORE = { 'OPTIONS': { 'default_class': 'xmodule.raw_module.RawDescriptor', 'fs_root': DATA_DIR, - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'render_template': 'edxmako.shortcuts.render_to_string', } } }, diff --git a/lms/envs/common.py b/lms/envs/common.py index 46c9df1a6e..2341e30cb1 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -281,7 +281,7 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'course_wiki.course_nav.context_processor', # Hack to get required link URLs to password reset templates - 'mitxmako.shortcuts.marketing_link_context_processor', + 'edxmako.shortcuts.marketing_link_context_processor', # Shoppingcart processor (detects if request.user has a cart) 'shoppingcart.context_processor.user_has_cart_context_processor', @@ -592,8 +592,8 @@ STATICFILES_FINDERS = ( # List of callables that know how to import templates from various sources. TEMPLATE_LOADERS = ( - 'mitxmako.makoloader.MakoFilesystemLoader', - 'mitxmako.makoloader.MakoAppDirectoriesLoader', + 'edxmako.makoloader.MakoFilesystemLoader', + 'edxmako.makoloader.MakoAppDirectoriesLoader', # 'django.template.loaders.filesystem.Loader', # 'django.template.loaders.app_directories.Loader', @@ -615,7 +615,7 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', 'track.middleware.TrackMiddleware', - 'mitxmako.middleware.MakoMiddleware', + 'edxmako.middleware.MakoMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'course_wiki.course_nav.Middleware', @@ -928,7 +928,7 @@ INSTALLED_APPS = ( 'service_status', # For asset pipelining - 'mitxmako', + 'edxmako', 'pipeline', 'staticfiles', 'static_replace', diff --git a/lms/envs/dev_ike.py b/lms/envs/dev_ike.py index 50bbfff096..0123c5c1e0 100644 --- a/lms/envs/dev_ike.py +++ b/lms/envs/dev_ike.py @@ -47,7 +47,7 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') # django 1.4 INSTALLED_APPS = tuple([app for app in INSTALLED_APPS if not app.startswith('debug_toolbar')]) MIDDLEWARE_CLASSES = tuple([mcl for mcl in MIDDLEWARE_CLASSES if not mcl.startswith('debug_toolbar')]) -#TEMPLATE_LOADERS = tuple([ app for app in TEMPLATE_LOADERS if not app.startswith('mitxmako') ]) +#TEMPLATE_LOADERS = tuple([ app for app in TEMPLATE_LOADERS if not app.startswith('edxmako') ]) TEMPLATE_LOADERS = ( 'django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', diff --git a/lms/envs/dev_mongo.py b/lms/envs/dev_mongo.py index bd7a8b20aa..3a99f44655 100644 --- a/lms/envs/dev_mongo.py +++ b/lms/envs/dev_mongo.py @@ -21,7 +21,7 @@ MODULESTORE = { 'OPTIONS': { 'default_class': 'xmodule.raw_module.RawDescriptor', 'fs_root': GITHUB_REPO_ROOT, - 'render_template': 'mitxmako.shortcuts.render_to_string', + 'render_template': 'edxmako.shortcuts.render_to_string', } } } From f10df353d2fa68d84da725b9b855aac02bcc88be Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Mon, 2 Dec 2013 16:07:56 -0500 Subject: [PATCH 091/110] Add basic tests of gradeset iteration. --- .../courseware/tests/test_grades.py | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 lms/djangoapps/courseware/tests/test_grades.py diff --git a/lms/djangoapps/courseware/tests/test_grades.py b/lms/djangoapps/courseware/tests/test_grades.py new file mode 100644 index 0000000000..5ee6953161 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_grades.py @@ -0,0 +1,122 @@ +""" +Test grade calculation. +""" +from django.http import Http404 +from django.test.utils import override_settings +from mock import patch + +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from courseware.grades import grade, iterate_grades_for + + +def _grade_with_errors(student, request, course, keep_raw_scores=False): + """This fake grade method will throw exceptions for student3 and + student4, but allow any other students to go through normal grading. + + It's meant to simulate when something goes really wrong while trying to + grade a particular student, so we can test that we won't kill the entire + course grading run. + """ + if student.username in ['student3', 'student4']: + raise Exception("I don't like {}".format(student.username)) + + return grade(student, request, course, keep_raw_scores=keep_raw_scores) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestGradeIteration(ModuleStoreTestCase): + """ + Test iteration through student gradesets. + """ + COURSE_NUM = "1000" + COURSE_NAME = "grading_test_course" + + def setUp(self): + """ + Create a course and a handful of users to assign grades + """ + self.course = CourseFactory.create( + display_name=self.COURSE_NAME, + number=self.COURSE_NUM + ) + self.students = [ + UserFactory.create(username='student1'), + UserFactory.create(username='student2'), + UserFactory.create(username='student3'), + UserFactory.create(username='student4'), + UserFactory.create(username='student5'), + ] + + def test_empty_student_list(self): + """If we don't pass in any students, it should return a zero-length + iterator, but it shouldn't error.""" + gradeset_results = list(iterate_grades_for(self.course.id, [])) + self.assertEqual(gradeset_results, []) + + def test_nonexistent_course(self): + """If the course we want to get grades for does not exist, a `Http404` + should be raised. This is a horrible crossing of abstraction boundaries + and should be fixed, but for now we're just testing the behavior. :-(""" + with self.assertRaises(Http404): + gradeset_results = iterate_grades_for("I/dont/exist", []) + gradeset_results.next() + + def test_all_empty_grades(self): + """No students have grade entries""" + all_gradesets, all_errors = self._gradesets_and_errors_for(self.course.id, self.students) + self.assertEqual(len(all_errors), 0) + for gradeset in all_gradesets.values(): + self.assertIsNone(gradeset['grade']) + self.assertEqual(gradeset['percent'], 0.0) + + @patch('courseware.grades.grade', _grade_with_errors) + def test_grading_exception(self): + """Test that we correctly capture exception messages that bubble up from + grading. Note that we only see errors at this level if the grading + process for this student fails entirely due to an unexpected event -- + having errors in the problem sets will not trigger this. + + We patch the grade() method with our own, which will generate the errors + for student3 and student4. + """ + all_gradesets, all_errors = self._gradesets_and_errors_for(self.course.id, self.students) + student1, student2, student3, student4, student5 = self.students + self.assertEqual( + all_errors, + { + student3: "I don't like student3", + student4: "I don't like student4" + } + ) + + # But we should still have five gradesets + self.assertEqual(len(all_gradesets), 5) + + # Even though two will simply be empty + self.assertFalse(all_gradesets[student3]) + self.assertFalse(all_gradesets[student4]) + + # The rest will have grade information in them + self.assertTrue(all_gradesets[student1]) + self.assertTrue(all_gradesets[student2]) + self.assertTrue(all_gradesets[student5]) + + ################################# Helpers ################################# + def _gradesets_and_errors_for(self, course_id, students): + """Simple helper method to iterate through student grades and give us + two dictionaries -- one that has all students and their respective + gradesets, and one that has only students that could not be graded and + their respective error messages.""" + students_to_gradesets = {} + students_to_errors = {} + + for student, gradeset, err_msg in iterate_grades_for(course_id, students): + students_to_gradesets[student] = gradeset + if err_msg: + students_to_errors[student] = err_msg + + return students_to_gradesets, students_to_errors From c812c5509a2c51571cdde8cd3356b4b2abdb56ae Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Mon, 2 Dec 2013 14:13:23 -0500 Subject: [PATCH 092/110] fix typo in ORA template (ORA-136) --- .../xmodule/combined_open_ended_module.py | 2 +- .../xmodule/xmodule/js/fixtures/rubric.html | 44 +++++++++---------- .../js/src/combinedopenended/edit.coffee | 2 +- .../xmodule/tests/test_util_open_ended.py | 4 +- .../combinedopenended/SampleQuestion.xml | 2 +- .../SampleQuestion1Attempt.xml | 2 +- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 68a0d65617..5a60defe48 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -322,7 +322,7 @@ class CombinedOpenEndedFields(object):

      - Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading. + Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.

      [prompt] [rubric] diff --git a/common/lib/xmodule/xmodule/js/fixtures/rubric.html b/common/lib/xmodule/xmodule/js/fixtures/rubric.html index b869bb1ec4..20a17260fa 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/rubric.html +++ b/common/lib/xmodule/xmodule/js/fixtures/rubric.html @@ -47,7 +47,7 @@
      @@ -99,7 +99,7 @@ Ideas
    4. - 0 points : + 0 points : Difficult for the reader to discern the main idea. Too brief or too repetitive to establish or maintain a focus. @@ -110,7 +110,7 @@ Difficult for the reader to discern the main idea. Too brief or too repetitive
      @@ -121,7 +121,7 @@ Attempts a main idea. Sometimes loses focus or ineffectively displays focus.
      @@ -132,14 +132,14 @@ Presents a unifying theme or main idea, but may include minor tangents. Stays s
    5. - + Content
      @@ -148,7 +148,7 @@ Content
    6. - 0 points : + 0 points : Includes little information with few or no details or unrelated details. Unsuccessful in attempts to explore any facets of the topic. @@ -159,7 +159,7 @@ Includes little information with few or no details or unrelated details. Unsucc
      @@ -170,7 +170,7 @@ Includes little information and few or no details. Explores only one or two fac
      @@ -181,14 +181,14 @@ Includes sufficient information and supporting details. (Details may not be full
    7. - + Organization
      @@ -197,7 +197,7 @@ Organization
    8. - 0 points : + 0 points : Ideas organized illogically, transitions weak, and response difficult to follow. @@ -208,7 +208,7 @@ Ideas organized illogically, transitions weak, and response difficult to follow.
      @@ -219,14 +219,14 @@ Attempts to logically organize ideas. Attempts to progress in an order that enh
    9. - + Style
      @@ -235,7 +235,7 @@ Style
    10. - 0 points : + 0 points : Contains limited vocabulary, with many words used incorrectly. Demonstrates problems with sentence patterns. @@ -246,7 +246,7 @@ Contains limited vocabulary, with many words used incorrectly. Demonstrates pro
      @@ -257,14 +257,14 @@ Contains basic vocabulary, with words that are predictable and common. Contains
    11. - + Voice
      @@ -273,7 +273,7 @@ Voice
    12. - 0 points : + 0 points : Demonstrates language and tone that may be inappropriate to task and reader. @@ -284,7 +284,7 @@ Demonstrates language and tone that may be inappropriate to task and reader.
      @@ -295,7 +295,7 @@ Demonstrates an attempt to adjust language and tone to task and reader.
      diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee index 89bda70a10..80efa37fdf 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee @@ -36,7 +36,7 @@ class @OpenEndedMarkdownEditingDescriptor extends XModule.Descriptor

      -Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading. +Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.

      [prompt]\n """ diff --git a/common/lib/xmodule/xmodule/tests/test_util_open_ended.py b/common/lib/xmodule/xmodule/tests/test_util_open_ended.py index bbb0653512..681ee15869 100644 --- a/common/lib/xmodule/xmodule/tests/test_util_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_util_open_ended.py @@ -105,7 +105,7 @@ TEST_STATE_SA_IN = ["{\"child_created\": false, \"child_attempts\": 2, \"version MOCK_INSTANCE_STATE = r"""{"ready_to_reset": false, "skip_spelling_checks": true, "current_task_number": 1, "weight": 5.0, "graceperiod": "1 day 12 hours 59 minutes 59 seconds", "graded": "True", "task_states": ["{\"child_created\": false, \"child_attempts\": 4, \"version\": 1, \"child_history\": [{\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"\", \"post_assessment\": \"[3]\", \"score\": 3}], \"max_score\": 3, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"The students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the group\\u2019s procedure, describe what additional information you would need in order to replicate the expe\", \"post_assessment\": \"{\\\"submission_id\\\": 3097, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: More grammar errors than average.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the groups procedure , describe what additional information you would need in order to replicate the expe\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3233, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"{\\\"submission_id\\\": 3098, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3235, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"{\\\"submission_id\\\": 3099, \\\"score\\\": 3, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"to replicate the experiment , the procedure would require more detail . one piece of information that is omitted is the amount of vinegar used in the experiment . it is also important to know what temperature the experiment was kept at during the hours . finally , the procedure needs to include details about the experiment , for example if the whole sample must be submerged .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3237, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality3\\\"}\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"{\\\"submission_id\\\": 3100, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"e the mass of four different samples . pour vinegar in each of four separate , but identical , containers . place a sample of one material into one container and label . repeat with remaining samples , placing a single sample into a single container . after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . \\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3239, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"\", \"post_assessment\": \"{\\\"submission_id\\\": 3101, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"invalid essay .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3241, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}], \"max_score\": 3, \"child_state\": \"done\"}"], "attempts": "10000", "student_attempts": 0, "due": null, "state": "done", "accept_file_upload": false, "display_name": "Science Question -- Machine Assessed"}""" # Task state with self assessment only. -TEST_STATE_SA = ["{\"child_created\": false, \"child_attempts\": 1, \"version\": 1, \"child_history\": [{\"answer\": \"Censorship in the Libraries\\r
      'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

      Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.\", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"Censorship in the Libraries\\r
      'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

      Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.\", \"post_assessment\": \"{\\\"submission_id\\\": 1461, \\\"score\\\": 12, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 5414, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"\\\\nIdeas\\\\n3\\\\nContent\\\\n3\\\\nOrganization\\\\n2\\\\nStyle\\\\n2\\\\nVoice\\\\n2\\\"}\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"post_assessment\"}"] +TEST_STATE_SA = ["{\"child_created\": false, \"child_attempts\": 1, \"version\": 1, \"child_history\": [{\"answer\": \"Censorship in the Libraries\\r
      'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

      Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.\", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"Censorship in the Libraries\\r
      'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

      Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.\", \"post_assessment\": \"{\\\"submission_id\\\": 1461, \\\"score\\\": 12, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 5414, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"\\\\nIdeas\\\\n3\\\\nContent\\\\n3\\\\nOrganization\\\\n2\\\\nStyle\\\\n2\\\\nVoice\\\\n2\\\"}\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"post_assessment\"}"] # Task state with self and then ai assessment. TEST_STATE_AI = ["{\"child_created\": false, \"child_attempts\": 2, \"version\": 1, \"child_history\": [{\"answer\": \"In libraries, there should not be censorship on materials considering that it's an individual's decision to read what they prefer. There is no appropriate standard on what makes a book offensive to a group, so it should be undetermined as to what makes a book offensive. In a public library, many children, who the books are censored for, are with their parents. Parents should make an independent choice on what they can allow their children to read. Letting society ban a book simply for the use of inappropriate materials is ridiculous. If an author spent time creating a story, it should be appreciated, and should not put on a list of no-nos. If a certain person doesn't like a book's reputation, all they have to do is not read it. Even in school systems, librarians are there to guide kids to read good books. If a child wants to read an inappropriate book, the librarian will most likely discourage him or her not to read it. In my experience, I wanted to read a book that my mother suggested to me, but as I went to the school library it turned out to be a censored book. Some parents believe children should be ignorant about offensive things written in books, but honestly many of the same ideas are exploited to them everyday on television and internet. So trying to shield your child from the bad things may be a great thing, but the efforts are usually failed attempts. It also never occurs to the people censoring the books, that some people can't afford to buy the books they want to read. The libraries, for some, are the main means for getting books. To conclude there is very little reason to ban a book from the shelves. Many of the books banned have important lessons that can be obtained through reading it. If a person doesn't like a book, the simplest thing to do is not to pick it up.\", \"post_assessment\": \"[1, 1]\", \"score\": 2}, {\"answer\": \"This is another response\", \"post_assessment\": \"[1, 1]\", \"score\": 2}], \"max_score\": 2, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"In libraries, there should not be censorship on materials considering that it's an individual's decision to read what they prefer. There is no appropriate standard on what makes a book offensive to a group, so it should be undetermined as to what makes a book offensive. In a public library, many children, who the books are censored for, are with their parents. Parents should make an independent choice on what they can allow their children to read. Letting society ban a book simply for the use of inappropriate materials is ridiculous. If an author spent time creating a story, it should be appreciated, and should not put on a list of no-nos. If a certain person doesn't like a book's reputation, all they have to do is not read it. Even in school systems, librarians are there to guide kids to read good books. If a child wants to read an inappropriate book, the librarian will most likely discourage him or her not to read it. In my experience, I wanted to read a book that my mother suggested to me, but as I went to the school library it turned out to be a censored book. Some parents believe children should be ignorant about offensive things written in books, but honestly many of the same ideas are exploited to them everyday on television and internet. So trying to shield your child from the bad things may be a great thing, but the efforts are usually failed attempts. It also never occurs to the people censoring the books, that some people can't afford to buy the books they want to read. The libraries, for some, are the main means for getting books. To conclude there is very little reason to ban a book from the shelves. Many of the books banned have important lessons that can be obtained through reading it. If a person doesn't like a book, the simplest thing to do is not to pick it up.\", \"post_assessment\": \"{\\\"submission_id\\\": 6107, \\\"score\\\": 2, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 1898718, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Writing Applications1 Language Conventions 1\\\"}\", \"score\": 2}, {\"answer\": \"This is another response\"}], \"max_score\": 2, \"child_state\": \"assessing\"}"] @@ -117,7 +117,7 @@ TEST_STATE_AI2 = ["{\"child_created\": false, \"child_attempts\": 0, \"version\" TEST_STATE_AI2_INVALID = ["{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"This isn't a real essay, and you should give me a zero on it. \", \"post_assessment\": \"{\\\"submission_id\\\": 18446, \\\"score\\\": [0, 1, 0], \\\"feedback\\\": [\\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"Zero it is! \\\\\\\"}\\\"], \\\"success\\\": true, \\\"grader_id\\\": [1943188, 1940991], \\\"grader_type\\\": \\\"PE\\\", \\\"rubric_scores_complete\\\": [true, true, true], \\\"rubric_xml\\\": [\\\"Writing Applications0 Language Conventions 0\\\", \\\"Writing Applications0 Language Conventions 1\\\", \\\"Writing Applications0 Language Conventions 0\\\"]}\", \"score\": 0}], \"max_score\": 2, \"child_state\": \"post_assessment\"}"] # Self assessment state. -TEST_STATE_SINGLE = ["{\"child_created\": false, \"child_attempts\": 1, \"version\": 1, \"child_history\": [{\"answer\": \"'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

      Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading. \", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"done\"}"] +TEST_STATE_SINGLE = ["{\"child_created\": false, \"child_attempts\": 1, \"version\": 1, \"child_history\": [{\"answer\": \"'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

      Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading. \", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"done\"}"] # Peer grading state. TEST_STATE_PE_SINGLE = ["{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"Passage its ten led hearted removal cordial. Preference any astonished unreserved mrs. Prosperous understood middletons in conviction an uncommonly do. Supposing so be resolving breakfast am or perfectly. Is drew am hill from mr. Valley by oh twenty direct me so. Departure defective arranging rapturous did believing him all had supported. Family months lasted simple set nature vulgar him. Picture for attempt joy excited ten carried manners talking how. Suspicion neglected he resolving agreement perceived at an. \\r

      Ye on properly handsome returned throwing am no whatever. In without wishing he of picture no exposed talking minutes. Curiosity continual belonging offending so explained it exquisite. Do remember to followed yourself material mr recurred carriage. High drew west we no or at john. About or given on witty event. Or sociable up material bachelor bringing landlord confined. Busy so many in hung easy find well up. So of exquisite my an explained remainder. Dashwood denoting securing be on perceive my laughing so. \\r

      Ought these are balls place mrs their times add she. Taken no great widow spoke of it small. Genius use except son esteem merely her limits. Sons park by do make on. It do oh cottage offered cottage in written. Especially of dissimilar up attachment themselves by interested boisterous. Linen mrs seems men table. Jennings dashwood to quitting marriage bachelor in. On as conviction in of appearance apartments boisterous. \", \"post_assessment\": \"{\\\"submission_id\\\": 1439, \\\"score\\\": [0], \\\"feedback\\\": [\\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\"], \\\"success\\\": true, \\\"grader_id\\\": [5337], \\\"grader_type\\\": \\\"PE\\\", \\\"rubric_scores_complete\\\": [true], \\\"rubric_xml\\\": [\\\"\\\\nIdeas\\\\n0\\\\nContent\\\\n0\\\\nOrganization\\\\n0\\\\nStyle\\\\n0\\\\nVoice\\\\n0\\\"]}\", \"score\": 0}], \"max_score\": 12, \"child_state\": \"done\"}"] \ No newline at end of file diff --git a/common/test/data/open_ended/combinedopenended/SampleQuestion.xml b/common/test/data/open_ended/combinedopenended/SampleQuestion.xml index 5dbe285526..99da5e4844 100644 --- a/common/test/data/open_ended/combinedopenended/SampleQuestion.xml +++ b/common/test/data/open_ended/combinedopenended/SampleQuestion.xml @@ -16,7 +16,7 @@

      Censorship in the Libraries

      "All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us." --Katherine Paterson, Author

      -

      Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.

      +

      Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.

      diff --git a/common/test/data/open_ended/combinedopenended/SampleQuestion1Attempt.xml b/common/test/data/open_ended/combinedopenended/SampleQuestion1Attempt.xml index 9bfabca191..bf5c39ebb3 100644 --- a/common/test/data/open_ended/combinedopenended/SampleQuestion1Attempt.xml +++ b/common/test/data/open_ended/combinedopenended/SampleQuestion1Attempt.xml @@ -16,7 +16,7 @@

      Censorship in the Libraries

      "All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us." --Katherine Paterson, Author

      -

      Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.

      +

      Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.

      From f3f4af808773078f3055ac56e3e0a81aceb79f4c Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Mon, 2 Dec 2013 11:55:01 -0500 Subject: [PATCH 093/110] settings.MITX_FEATURES => settings.FEATURES --- CHANGELOG.rst | 4 ++ cms/djangoapps/auth/authz.py | 4 +- cms/djangoapps/auth/tests/test_authz.py | 6 +- .../contentstore/tests/test_contentstore.py | 10 ++-- .../tests/test_course_settings.py | 4 +- .../contentstore/tests/test_request_event.py | 4 +- .../contentstore/tests/test_utils.py | 12 ++-- cms/djangoapps/contentstore/utils.py | 4 +- .../contentstore/views/component.py | 2 +- cms/djangoapps/contentstore/views/course.py | 6 +- cms/djangoapps/course_creators/admin.py | 4 +- .../course_creators/tests/test_admin.py | 6 +- .../course_creators/tests/test_views.py | 4 +- cms/envs/acceptance.py | 2 +- cms/envs/aws.py | 8 +-- cms/envs/common.py | 6 +- cms/envs/dev.py | 8 +-- cms/envs/dev_ike.py | 4 +- cms/envs/dev_shared_preview.py | 2 +- cms/envs/devstack.py | 2 +- cms/envs/test.py | 8 +-- cms/templates/index.html | 8 +-- cms/templates/widgets/qualaroo.html | 2 +- cms/templates/widgets/segment-io.html | 2 +- cms/urls.py | 4 +- common/djangoapps/edxmako/shortcuts.py | 4 +- common/djangoapps/edxmako/tests.py | 4 +- .../tests/test_openid_provider.py | 32 +++++------ .../external_auth/tests/test_shib.py | 14 ++--- .../external_auth/tests/test_ssl.py | 14 ++--- common/djangoapps/external_auth/views.py | 20 +++---- .../templates/static_content.html | 4 +- common/djangoapps/student/models.py | 2 +- .../student/tests/test_auto_auth.py | 10 ++-- .../student/tests/test_bulk_email_settings.py | 12 ++-- common/djangoapps/student/tests/test_login.py | 8 +-- common/djangoapps/student/tests/tests.py | 4 +- common/djangoapps/student/views.py | 26 ++++----- common/djangoapps/track/tests.py | 4 +- .../util/tests/test_submit_feedback.py | 4 +- common/djangoapps/util/views.py | 2 +- common/djangoapps/xmodule_modifiers.py | 4 +- common/lib/xmodule/xmodule/video_module.py | 2 +- docs/internal/development.md | 2 +- docs/internal/remote_gradebook.md | 2 +- lms/djangoapps/branding/__init__.py | 4 +- lms/djangoapps/branding/tests.py | 14 ++--- lms/djangoapps/branding/views.py | 8 +-- lms/djangoapps/bulk_email/models.py | 4 +- .../bulk_email/tests/test_course_optout.py | 4 +- lms/djangoapps/bulk_email/tests/test_email.py | 2 +- lms/djangoapps/bulk_email/tests/test_forms.py | 8 +-- .../bulk_email/tests/test_models.py | 4 +- lms/djangoapps/courseware/access.py | 8 +-- lms/djangoapps/courseware/masquerade.py | 2 +- lms/djangoapps/courseware/module_render.py | 4 +- lms/djangoapps/courseware/tabs.py | 8 +-- lms/djangoapps/courseware/tests/test_tabs.py | 14 ++--- .../courseware/tests/test_video_mongo.py | 4 +- .../courseware/tests/test_video_xml.py | 2 +- .../tests/test_view_authentication.py | 12 ++-- lms/djangoapps/courseware/tests/test_views.py | 4 +- lms/djangoapps/courseware/views.py | 14 ++--- .../django_comment_client/base/tests.py | 2 +- .../django_comment_client/forum/tests.py | 2 +- lms/djangoapps/instructor/hint_manager.py | 2 +- lms/djangoapps/instructor/tests/test_email.py | 14 ++--- .../instructor/tests/test_legacy_email.py | 10 ++-- .../instructor/views/instructor_dashboard.py | 4 +- lms/djangoapps/instructor/views/legacy.py | 14 ++--- lms/djangoapps/notes/utils.py | 4 +- .../shoppingcart/context_processor.py | 4 +- lms/djangoapps/shoppingcart/models.py | 4 +- .../tests/test_context_processor.py | 14 ++--- .../shoppingcart/tests/test_models.py | 4 +- lms/djangoapps/shoppingcart/urls.py | 4 +- lms/djangoapps/verify_student/models.py | 6 +- .../verify_student/tests/test_views.py | 2 +- lms/envs/acceptance.py | 14 ++--- lms/envs/aws.py | 8 +-- lms/envs/cms/acceptance.py | 2 +- lms/envs/cms/dev.py | 4 +- lms/envs/common.py | 12 ++-- lms/envs/dev.py | 56 +++++++++---------- lms/envs/dev_ike.py | 26 ++++----- lms/envs/dev_int.py | 2 +- lms/envs/devstack.py | 4 +- lms/envs/test.py | 24 ++++---- lms/templates/courseware/course_about.html | 8 +-- .../courseware/instructor_dashboard.html | 22 ++++---- lms/templates/dashboard.html | 2 +- .../discussion/_underscore_templates.html | 4 +- lms/templates/help_modal.html | 2 +- lms/templates/index.html | 2 +- .../instructor_dashboard_2/course_info.html | 2 +- .../instructor_dashboard_2/data_download.html | 6 +- .../instructor_dashboard_2/send_email.html | 2 +- .../instructor_dashboard_2/student_admin.html | 8 +-- lms/templates/login.html | 2 +- lms/templates/login_modal.html | 2 +- lms/templates/main.html | 2 +- lms/templates/navigation.html | 14 ++--- lms/templates/staff_problem_info.html | 2 +- lms/templates/widgets/segment-io.html | 2 +- lms/urls.py | 44 +++++++-------- 105 files changed, 396 insertions(+), 392 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4507bcccf9..a375086412 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,10 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Common: Switch over from MITX_FEATURES to just FEATURES. To override items in + the FEATURES dict, the environment variable you must set to do so is also + now called FEATURES instead of MITX_FEATURES. + Blades: Fix Numerical input to support mathematical operations. BLD-525. Blades: Improve calculator's tooltip accessibility. Add possibility to navigate diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 1a1f138cb5..a5d00e14c1 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -261,11 +261,11 @@ def is_user_in_creator_group(user): return True # On edx, we only allow edX staff to create courses. This may be relaxed in the future. - if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False): + if settings.FEATURES.get('DISABLE_COURSE_CREATION', False): return False # Feature flag for using the creator group setting. Will be removed once the feature is complete. - if settings.MITX_FEATURES.get('ENABLE_CREATOR_GROUP', False): + if settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): return user.groups.filter(name=COURSE_CREATOR_GROUP_NAME).count() > 0 return True diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py index 69050539cf..6bbb8d0a41 100644 --- a/cms/djangoapps/auth/tests/test_authz.py +++ b/cms/djangoapps/auth/tests/test_authz.py @@ -33,7 +33,7 @@ class CreatorGroupTest(TestCase): def test_creator_group_enabled_but_empty(self): """ Tests creator group feature on, but group empty. """ - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): self.assertFalse(is_user_in_creator_group(self.user)) # Make user staff. This will cause is_user_in_creator_group to return True. @@ -42,7 +42,7 @@ class CreatorGroupTest(TestCase): def test_creator_group_enabled_nonempty(self): """ Tests creator group feature on, user added. """ - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): self.assertTrue(add_user_to_creator_group(self.admin, self.user)) self.assertTrue(is_user_in_creator_group(self.user)) @@ -70,7 +70,7 @@ class CreatorGroupTest(TestCase): def test_course_creation_disabled(self): """ Tests that the COURSE_CREATION_DISABLED flag overrides course creator group settings. """ - with mock.patch.dict('django.conf.settings.MITX_FEATURES', + with mock.patch.dict('django.conf.settings.FEATURES', {'DISABLE_COURSE_CREATION': True, "ENABLE_CREATOR_GROUP": True}): # Add user to creator group. self.assertTrue(add_user_to_creator_group(self.admin, self.user)) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 0aaf2dfb29..62c4d6b145 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1506,31 +1506,31 @@ class ContentStoreTest(ModuleStoreTestCase): def test_create_course_with_course_creation_disabled_staff(self): """Test new course creation -- course creation disabled, but staff access.""" - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True}): + with mock.patch.dict('django.conf.settings.FEATURES', {'DISABLE_COURSE_CREATION': True}): self.assert_created_course() def test_create_course_with_course_creation_disabled_not_staff(self): """Test new course creation -- error path for course creation disabled, not staff access.""" - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True}): + with mock.patch.dict('django.conf.settings.FEATURES', {'DISABLE_COURSE_CREATION': True}): self.user.is_staff = False self.user.save() self.assert_course_permission_denied() def test_create_course_no_course_creators_staff(self): """Test new course creation -- course creation group enabled, staff, group is empty.""" - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_CREATOR_GROUP': True}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True}): self.assert_created_course() def test_create_course_no_course_creators_not_staff(self): """Test new course creation -- error path for course creator group enabled, not staff, group is empty.""" - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): self.user.is_staff = False self.user.save() self.assert_course_permission_denied() def test_create_course_with_course_creator(self): """Test new course creation -- use course creator group""" - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): add_user_to_creator_group(self.user, self.user) self.assert_created_course() diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 792b28fe4d..1801e7c8dc 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -106,7 +106,7 @@ class CourseDetailsTestCase(CourseTestCase): def test_marketing_site_fetch(self): settings_details_url = self.course_locator.url_reverse('settings/details/') - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_MKTG_SITE': True}): response = self.client.get_html(settings_details_url) self.assertNotContains(response, "Course Summary Page") self.assertNotContains(response, "Send a note to students via email") @@ -127,7 +127,7 @@ class CourseDetailsTestCase(CourseTestCase): def test_regular_site_fetch(self): settings_details_url = self.course_locator.url_reverse('settings/details/') - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_MKTG_SITE': False}): response = self.client.get_html(settings_details_url) self.assertContains(response, "Course Summary Page") self.assertContains(response, "Send a note to students via email") diff --git a/cms/djangoapps/contentstore/tests/test_request_event.py b/cms/djangoapps/contentstore/tests/test_request_event.py index 0126de66c6..166187ee58 100644 --- a/cms/djangoapps/contentstore/tests/test_request_event.py +++ b/cms/djangoapps/contentstore/tests/test_request_event.py @@ -20,7 +20,7 @@ class CMSLogTest(TestCase): {"event": "my_event", "event_type": "my_event_type", "page": "my_page"}, {"event": "{'json': 'object'}", "event_type": unichr(512), "page": "my_page"} ] - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_SQL_TRACKING_LOGS': True}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_SQL_TRACKING_LOGS': True}): for request_params in requests: response = self.client.post(reverse(cms_user_track), request_params) self.assertEqual(response.status_code, 204) @@ -34,7 +34,7 @@ class CMSLogTest(TestCase): {"event": "my_event", "event_type": "my_event_type", "page": "my_page"}, {"event": "{'json': 'object'}", "event_type": unichr(512), "page": "my_page"} ] - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_SQL_TRACKING_LOGS': True}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_SQL_TRACKING_LOGS': True}): for request_params in requests: response = self.client.get(reverse(cms_user_track), request_params) self.assertEqual(response.status_code, 204) diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index 5311396f2f..514fd80278 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -28,33 +28,33 @@ class LMSLinksTestCase(TestCase): @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) def about_page_marketing_site_test(self): """ Get URL for about page, marketing root present. """ - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_MKTG_SITE': True}): self.assertEquals(self.get_about_page_link(), "//dummy-root/courses/mitX/101/test/about") - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_MKTG_SITE': False}): self.assertEquals(self.get_about_page_link(), "//localhost:8000/courses/mitX/101/test/about") @override_settings(MKTG_URLS={'ROOT': 'http://www.dummy'}) def about_page_marketing_site_remove_http_test(self): """ Get URL for about page, marketing root present, remove http://. """ - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_MKTG_SITE': True}): self.assertEquals(self.get_about_page_link(), "//www.dummy/courses/mitX/101/test/about") @override_settings(MKTG_URLS={'ROOT': 'https://www.dummy'}) def about_page_marketing_site_remove_https_test(self): """ Get URL for about page, marketing root present, remove https://. """ - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_MKTG_SITE': True}): self.assertEquals(self.get_about_page_link(), "//www.dummy/courses/mitX/101/test/about") @override_settings(MKTG_URLS={'ROOT': 'www.dummyhttps://x'}) def about_page_marketing_site_https__edge_test(self): """ Get URL for about page, only remove https:// at the beginning of the string. """ - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_MKTG_SITE': True}): self.assertEquals(self.get_about_page_link(), "//www.dummyhttps://x/courses/mitX/101/test/about") @override_settings(MKTG_URLS={}) def about_page_marketing_urls_not_set_test(self): """ Error case. ENABLE_MKTG_SITE is True, but there is either no MKTG_URLS, or no MKTG_URLS Root property. """ - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_MKTG_SITE': True}): self.assertEquals(self.get_about_page_link(), None) @override_settings(LMS_BASE=None) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 0a2ecbd37b..cf4bffc503 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -136,7 +136,7 @@ def get_lms_link_for_item(location, preview=False, course_id=None): if settings.LMS_BASE is not None: if preview: - lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE') + lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE') else: lms_base = settings.LMS_BASE @@ -155,7 +155,7 @@ def get_lms_link_for_about_page(location): """ Returns the url to the course about page from the location tuple. """ - if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False): + if settings.FEATURES.get('ENABLE_MKTG_SITE', False): if not hasattr(settings, 'MKTG_URLS'): log.exception("ENABLE_MKTG_SITE is True, but MKTG_URLS is not defined.") about_base = None diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 269b603eff..0a34045687 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -256,7 +256,7 @@ def unit_handler(request, tag=None, course_id=None, branch=None, version_guid=No break index = index + 1 - preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE') + preview_lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE') preview_lms_link = ( '//{preview_lms_base}/courses/{org}/{course}/' diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 5680be8077..61131fcc11 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -423,7 +423,7 @@ def settings_handler(request, tag=None, course_id=None, branch=None, version_gui 'lms_link_for_about_page': utils.get_lms_link_for_about_page(course_old_location), 'course_image_url': utils.course_image_url(course_module), 'details_url': locator.url_reverse('/settings/details/'), - 'about_page_editable': not settings.MITX_FEATURES.get( + 'about_page_editable': not settings.FEATURES.get( 'ENABLE_MKTG_SITE', False ), 'upload_asset_url': upload_asset_url @@ -822,9 +822,9 @@ def _get_course_creator_status(user): """ if user.is_staff: course_creator_status = 'granted' - elif settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False): + elif settings.FEATURES.get('DISABLE_COURSE_CREATION', False): course_creator_status = 'disallowed_for_this_site' - elif settings.MITX_FEATURES.get('ENABLE_CREATOR_GROUP', False): + elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): course_creator_status = get_course_creator_status(user) if course_creator_status is None: # User not grandfathered in as an existing user, has not previously visited the dashboard page. diff --git a/cms/djangoapps/course_creators/admin.py b/cms/djangoapps/course_creators/admin.py index 87e17fabfa..5eaa8c4ac3 100644 --- a/cms/djangoapps/course_creators/admin.py +++ b/cms/djangoapps/course_creators/admin.py @@ -91,7 +91,7 @@ def send_user_notification_callback(sender, **kwargs): user = kwargs['user'] updated_state = kwargs['state'] - studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL', '') + studio_request_email = settings.FEATURES.get('STUDIO_REQUEST_EMAIL', '') context = {'studio_request_email': studio_request_email} subject = render_to_string('emails/course_creator_subject.txt', context) @@ -118,7 +118,7 @@ def send_admin_notification_callback(sender, **kwargs): """ user = kwargs['user'] - studio_request_email = settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL', '') + studio_request_email = settings.FEATURES.get('STUDIO_REQUEST_EMAIL', '') context = {'user_name': user.username, 'user_email': user.email} subject = render_to_string('emails/course_creator_admin_subject.txt', context) diff --git a/cms/djangoapps/course_creators/tests/test_admin.py b/cms/djangoapps/course_creators/tests/test_admin.py index aa293e008e..4d28f26399 100644 --- a/cms/djangoapps/course_creators/tests/test_admin.py +++ b/cms/djangoapps/course_creators/tests/test_admin.py @@ -69,7 +69,7 @@ class CourseCreatorAdminTest(TestCase): self.studio_request_email ) - with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group_patch): + with mock.patch.dict('django.conf.settings.FEATURES', self.enable_creator_group_patch): # User is initially unrequested. self.assertFalse(is_user_in_creator_group(self.user)) @@ -119,7 +119,7 @@ class CourseCreatorAdminTest(TestCase): else: self.assertEquals(base_num_emails, len(mail.outbox)) - with mock.patch.dict('django.conf.settings.MITX_FEATURES', self.enable_creator_group_patch): + with mock.patch.dict('django.conf.settings.FEATURES', self.enable_creator_group_patch): # E-mail message should be sent to admin only when new state is PENDING, regardless of what # previous state was (unless previous state was already PENDING). # E-mail message sent to user only on transition into and out of GRANTED state. @@ -159,7 +159,7 @@ class CourseCreatorAdminTest(TestCase): self.assertFalse(self.creator_admin.has_change_permission(self.request)) def test_rate_limit_login(self): - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_CREATOR_GROUP': True}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_CREATOR_GROUP': True}): post_params = {'username': self.user.username, 'password': 'wrong_password'} # try logging in 30 times, the default limit in the number of failed # login attempts in one 5 minute period before the rate gets limited diff --git a/cms/djangoapps/course_creators/tests/test_views.py b/cms/djangoapps/course_creators/tests/test_views.py index 95c50ffb76..dbd92365b7 100644 --- a/cms/djangoapps/course_creators/tests/test_views.py +++ b/cms/djangoapps/course_creators/tests/test_views.py @@ -46,7 +46,7 @@ class CourseCreatorView(TestCase): self.assertEqual('unrequested', get_course_creator_status(self.user)) def test_add_granted(self): - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): # Calling add_user_with_status_granted impacts is_user_in_course_group_role. self.assertFalse(is_user_in_creator_group(self.user)) @@ -60,7 +60,7 @@ class CourseCreatorView(TestCase): self.assertTrue(is_user_in_creator_group(self.user)) def test_update_creator_group(self): - with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): self.assertFalse(is_user_in_creator_group(self.user)) update_course_creator_group(self.admin, self.user, True) self.assertTrue(is_user_in_creator_group(self.user)) diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index afbdff7d3f..67ecfa5689 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -87,7 +87,7 @@ PIPELINE = True STATICFILES_FINDERS += ('pipeline.finders.PipelineFinder', ) # Use the auto_auth workflow for creating users and logging them in -MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True +FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True # HACK # Setting this flag to false causes imports to not load correctly in the lettuce python files diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 1b0c0ef648..8853ccf431 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -106,7 +106,7 @@ if STATIC_ROOT_BASE: EMAIL_BACKEND = ENV_TOKENS.get('EMAIL_BACKEND', EMAIL_BACKEND) EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', None) LMS_BASE = ENV_TOKENS.get('LMS_BASE') -# Note that MITX_FEATURES['PREVIEW_LMS_BASE'] gets read in from the environment file. +# Note that FEATURES['PREVIEW_LMS_BASE'] gets read in from the environment file. SITE_NAME = ENV_TOKENS['SITE_NAME'] @@ -138,8 +138,8 @@ COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", []) TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) -for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items(): - MITX_FEATURES[feature] = value +for feature, value in ENV_TOKENS.get('FEATURES', {}).items(): + FEATURES[feature] = value LOGGING = get_logger_config(LOG_DIR, logging_env=ENV_TOKENS['LOGGING_ENV'], @@ -164,7 +164,7 @@ with open(CONFIG_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: # Note that this is the Studio key. There is a separate key for the LMS. SEGMENT_IO_KEY = AUTH_TOKENS.get('SEGMENT_IO_KEY') if SEGMENT_IO_KEY: - MITX_FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False) + FEATURES['SEGMENT_IO'] = ENV_TOKENS.get('SEGMENT_IO', False) AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] if AWS_ACCESS_KEY_ID == "": diff --git a/cms/envs/common.py b/cms/envs/common.py index 961a089a14..a8c293b57f 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2,7 +2,7 @@ This is the common settings file, intended to set sane defaults. If you have a piece of configuration that's dependent on a set of feature flags being set, then create a function that returns the calculated value based on the value of -MITX_FEATURES[...]. Modules that extend this one can change the feature +FEATURES[...]. Modules that extend this one can change the feature configuration in an environment specific config file and re-calculate those values. @@ -14,7 +14,7 @@ Longer TODO: 1. Right now our treatment of static content in general and in particular course-specific static content is haphazard. 2. We should have a more disciplined approach to feature flagging, even if it - just means that we stick them in a dict called MITX_FEATURES. + just means that we stick them in a dict called FEATURES. 3. We need to handle configuration for multiple courses. This could be as multiple sites, but we do need a way to map their data assets. """ @@ -36,7 +36,7 @@ from dealer.git import git ############################ FEATURE CONFIGURATION ############################# -MITX_FEATURES = { +FEATURES = { 'USE_DJANGO_PIPELINE': True, 'GITHUB_PUSH': False, diff --git a/cms/envs/dev.py b/cms/envs/dev.py index ddf1708c87..6d47e65f23 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -76,7 +76,7 @@ DATABASES = { } LMS_BASE = "localhost:8000" -MITX_FEATURES['PREVIEW_LMS_BASE'] = "localhost:8000" +FEATURES['PREVIEW_LMS_BASE'] = "localhost:8000" REPOS = { 'edx4edx': { @@ -178,10 +178,10 @@ DEBUG_TOOLBAR_CONFIG = { DEBUG_TOOLBAR_MONGO_STACKTRACES = False # disable NPS survey in dev mode -MITX_FEATURES['STUDIO_NPS_SURVEY'] = False +FEATURES['STUDIO_NPS_SURVEY'] = False # Enable URL that shows information about the status of variuous services -MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True +FEATURES['ENABLE_SERVICE_STATUS'] = True ############################# SEGMENT-IO ################################## @@ -190,7 +190,7 @@ MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True import os SEGMENT_IO_KEY = os.environ.get('SEGMENT_IO_KEY') if SEGMENT_IO_KEY: - MITX_FEATURES['SEGMENT_IO'] = True + FEATURES['SEGMENT_IO'] = True ##################################################################### diff --git a/cms/envs/dev_ike.py b/cms/envs/dev_ike.py index 6e67f78f36..95ae33e328 100644 --- a/cms/envs/dev_ike.py +++ b/cms/envs/dev_ike.py @@ -9,8 +9,8 @@ from .common import * from .dev import * -MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True +FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True -MITX_FEATURES['USE_DJANGO_PIPELINE'] = False # don't recompile scss +FEATURES['USE_DJANGO_PIPELINE'] = False # don't recompile scss SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') # django 1.4 for nginx ssl proxy diff --git a/cms/envs/dev_shared_preview.py b/cms/envs/dev_shared_preview.py index 119558ba05..ec488a68ba 100644 --- a/cms/envs/dev_shared_preview.py +++ b/cms/envs/dev_shared_preview.py @@ -9,4 +9,4 @@ the same process between preview and published from .dev import * -MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview.localhost:8000" +FEATURES['PREVIEW_LMS_BASE'] = "preview.localhost:8000" diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index e25f092c9a..fa41d5cef8 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -24,7 +24,7 @@ EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' ################################# LMS INTEGRATION ############################# LMS_BASE = "localhost:8000" -MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview." + LMS_BASE +FEATURES['PREVIEW_LMS_BASE'] = "preview." + LMS_BASE ################################# CELERY ###################################### diff --git a/cms/envs/test.py b/cms/envs/test.py index ef7133e0bd..5edea467d3 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -109,7 +109,7 @@ DATABASES = { } LMS_BASE = "localhost:8000" -MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview" +FEATURES['PREVIEW_LMS_BASE'] = "preview" CACHES = { # This is the cache used for most things. Askbot will not work without a @@ -161,9 +161,9 @@ PASSWORD_HASHERS = ( SEGMENT_IO_KEY = '***REMOVED***' # disable NPS survey in test mode -MITX_FEATURES['STUDIO_NPS_SURVEY'] = False +FEATURES['STUDIO_NPS_SURVEY'] = False -MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True +FEATURES['ENABLE_SERVICE_STATUS'] = True # This is to disable a test under the common directory that will not pass when run under CMS -MITX_FEATURES['DISABLE_PASSWORD_RESET_EMAIL_TEST'] = True +FEATURES['DISABLE_PASSWORD_RESET_EMAIL_TEST'] = True diff --git a/cms/templates/index.html b/cms/templates/index.html index 572f40a865..6d3627e254 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -45,8 +45,8 @@ require(["domReady!", "jquery", "jquery.form", "js/index"], function(doc, $) { % if course_creator_status=='granted': ${_("New Course")} - % elif course_creator_status=='disallowed_for_this_site' and settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL',''): - ${_("Email staff to create course")} + % elif course_creator_status=='disallowed_for_this_site' and settings.FEATURES.get('STUDIO_REQUEST_EMAIL',''): + ${_("Email staff to create course")} % endif
    13. @@ -290,10 +290,10 @@ require(["domReady!", "jquery", "jquery.form", "js/index"], function(doc, $) { - % if course_creator_status=='disallowed_for_this_site' and settings.MITX_FEATURES.get('STUDIO_REQUEST_EMAIL',''): + % if course_creator_status=='disallowed_for_this_site' and settings.FEATURES.get('STUDIO_REQUEST_EMAIL',''):

      ${_('Can I create courses in Studio?')}

      -

      ${_('In order to create courses in Studio, you must')} ${_("contact edX staff to help you create a course")}

      +

      ${_('In order to create courses in Studio, you must')} ${_("contact edX staff to help you create a course")}

      % endif diff --git a/cms/templates/widgets/qualaroo.html b/cms/templates/widgets/qualaroo.html index 04d10e08d1..1081c22c08 100644 --- a/cms/templates/widgets/qualaroo.html +++ b/cms/templates/widgets/qualaroo.html @@ -1,4 +1,4 @@ -% if settings.MITX_FEATURES.get('STUDIO_NPS_SURVEY'): +% if settings.FEATURES.get('STUDIO_NPS_SURVEY'): + + + + + + + +.. _webGLDemo.css: + +webGLDemo.css +------------- + +:: + + #container { + background-color: black; + width: 400px; + height:400px; + } + + +.. _webGLDemo.js: + +webGLDemo.js +------------ + +:: + + var WebGLDemo = (function() { + + var width = 400, height = 400; + var container, renderer, scene, camera, projector, + ambientlight, directionalLight, + cylinder, cube, nonSelectedMaterial, selectedMaterial; + // Revolutions per second + var angularSpeed = 0.5, lastTime = 0; + var state = { + 'selectedObjects': { + 'cylinder': false, + 'cube': false + } + }; + + function init() { + container = document.getElementById('container'); + // Renderer + renderer = new THREE.WebGLRenderer({antialias:true}); + renderer.setSize(width, height); + renderer.setClearColor(0x000000, 1); + container.appendChild(renderer.domElement); + + // Scene + scene = new THREE.Scene(); + + // Camera + camera = new THREE.PerspectiveCamera(45, width/height, 1, 1000); + camera.position.z = 700; + + unselectedMaterial = new THREE.MeshPhongMaterial({ + specular: '#a9fcff', + color: '#00abb1', + emissive: '#006063', + shininess: 100 + }); + + selectedMaterial = new THREE.MeshPhongMaterial({ + specular: '#a9fcff', + color: '#abb100', + emissive: '#606300', + shininess: 100 + }); + + // Cylinder: bottomRadius, topRadius, height, segmentsRadius, segmentsHeight + cylinder = new THREE.Mesh(new THREE.CylinderGeometry(0, 100, 150, 50, 50, false), unselectedMaterial); + cylinder.position.x = -125; + cylinder.overdraw = true; + scene.add(cylinder); + + // Cube + cube = new THREE.Mesh(new THREE.CubeGeometry(120, 120, 120), unselectedMaterial); + cube.position.x = 125; + cube.overdraw = true; + scene.add(cube); + + // Ambient light + ambientLight = new THREE.AmbientLight(0x222222); + scene.add(ambientLight); + + // Directional light + directionalLight = new THREE.DirectionalLight(0xffffff); + directionalLight.position.set(1, 1, 1).normalize(); + scene.add(directionalLight); + + // Used to select element with mouse click + projector = new THREE.Projector(); + + renderer.domElement.addEventListener('click', onMouseClick, false); + + // Start animation + animate(); + } + + // This function is executed on each animation frame + function animate() { + // Request new frame + requestAnimationFrame(animate); + render(); + } + + function render() { + // Update + var time = (new Date()).getTime(); + var timeDiff = time - lastTime; + var angleChange = angularSpeed * timeDiff * 2 * Math.PI / 1000; + cylinder.rotation.x += angleChange; + cylinder.rotation.z += angleChange; + cube.rotation.x += angleChange; + cube.rotation.y += angleChange; + lastTime = time; + + // Render + renderer.render(scene, camera); + } + + function onMouseClick(event) { + var vector = new THREE.Vector3((event.clientX / width) * 2 - 1, + -(event.clientY / height) * 2 + 1, 1); + projector.unprojectVector(vector, camera); + + var raycaster = new THREE.Raycaster(camera.position, vector.sub(camera.position).normalize()), + intersects = raycaster.intersectObjects(scene.children); + + if (intersects.length > 0) { + if (intersects[0].object === cylinder) { + state.selectedObjects.cylinder = !state.selectedObjects.cylinder; + } + else if (intersects[0].object === cube) { + state.selectedObjects.cube = !state.selectedObjects.cube; + } + + updateMaterials(); + } + } + + function updateMaterials() { + if (state.selectedObjects.cylinder) { + cylinder.material = selectedMaterial; + } + else { + cylinder.material = unselectedMaterial; + } + + if (state.selectedObjects.cube) { + cube.material = selectedMaterial; + } + else { + cube.material = unselectedMaterial; + } + } + + init(); + + function getState() { + return JSON.stringify(state); + } + + function setState(stateStr) { + state = JSON.parse(stateStr); + updateMaterials(); + } + + function getGrade() { + // The following return value may or may not be used to grade server-side. + // If getState and setState are used, then the Python grader also gets + // access to the return value of getState and can choose it instead to grade. + return JSON.stringify(state['selectedObjects']); + } + + return { + getState: getState, + setState: setState, + getGrade: getGrade + }; + }()); + + +.. _three.min.js: + +three.min.js +------------ + +:: + + // three.js - http://github.com/mrdoob/three.js + 'use strict';var THREE={REVISION:"62"};self.console=self.console||{info:function(){},log:function(){},debug:function(){},warn:function(){},error:function(){}};String.prototype.trim=String.prototype.trim||function(){return this.replace(/^\s+|\s+$/g,"")};THREE.extend=function(a,b){if(Object.keys)for(var c=Object.keys(b),d=0,e=c.length;d>16&255)/255;this.g=(a>>8&255)/255;this.b=(a&255)/255;return this},setRGB:function(a,b,c){this.r=a;this.g=b;this.b=c;return this},setHSL:function(a,b,c){if(0===b)this.r=this.g=this.b=c;else{var d=function(a,b,c){0>c&&(c+=1);1c?b:c<2/3?a+6*(b-a)*(2/3-c):a},b=0.5>=c?c*(1+b):c+b-c*b,c=2*c-b;this.r=d(c,b,a+1/3);this.g=d(c,b,a);this.b=d(c,b,a-1/3)}return this},setStyle:function(a){if(/^rgb\((\d+), ?(\d+), ?(\d+)\)$/i.test(a))return a=/^rgb\((\d+), ?(\d+), ?(\d+)\)$/i.exec(a),this.r=Math.min(255,parseInt(a[1],10))/255,this.g=Math.min(255,parseInt(a[2],10))/255,this.b=Math.min(255,parseInt(a[3],10))/255,this;if(/^rgb\((\d+)\%, ?(\d+)\%, ?(\d+)\%\)$/i.test(a))return a=/^rgb\((\d+)\%, ?(\d+)\%, ?(\d+)\%\)$/i.exec(a),this.r= + Math.min(100,parseInt(a[1],10))/100,this.g=Math.min(100,parseInt(a[2],10))/100,this.b=Math.min(100,parseInt(a[3],10))/100,this;if(/^\#([0-9a-f]{6})$/i.test(a))return a=/^\#([0-9a-f]{6})$/i.exec(a),this.setHex(parseInt(a[1],16)),this;if(/^\#([0-9a-f])([0-9a-f])([0-9a-f])$/i.test(a))return a=/^\#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(a),this.setHex(parseInt(a[1]+a[1]+a[2]+a[2]+a[3]+a[3],16)),this;if(/^(\w+)$/i.test(a))return this.setHex(THREE.ColorKeywords[a]),this},copy:function(a){this.r=a.r;this.g= + a.g;this.b=a.b;return this},copyGammaToLinear:function(a){this.r=a.r*a.r;this.g=a.g*a.g;this.b=a.b*a.b;return this},copyLinearToGamma:function(a){this.r=Math.sqrt(a.r);this.g=Math.sqrt(a.g);this.b=Math.sqrt(a.b);return this},convertGammaToLinear:function(){var a=this.r,b=this.g,c=this.b;this.r=a*a;this.g=b*b;this.b=c*c;return this},convertLinearToGamma:function(){this.r=Math.sqrt(this.r);this.g=Math.sqrt(this.g);this.b=Math.sqrt(this.b);return this},getHex:function(){return 255*this.r<<16^255*this.g<< + 8^255*this.b<<0},getHexString:function(){return("000000"+this.getHex().toString(16)).slice(-6)},getHSL:function(){var a={h:0,s:0,l:0};return function(){var b=this.r,c=this.g,d=this.b,e=Math.max(b,c,d),f=Math.min(b,c,d),h,g=(f+e)/2;if(f===e)f=h=0;else{var i=e-f,f=0.5>=g?i/(e+f):i/(2-e-f);switch(e){case b:h=(c-d)/i+(cf&&c>b?(c=2*Math.sqrt(1+c-f-b),this._w=(i-h)/c,this._x=0.25*c, + this._y=(a+e)/c,this._z=(d+g)/c):f>b?(c=2*Math.sqrt(1+f-c-b),this._w=(d-g)/c,this._x=(a+e)/c,this._y=0.25*c,this._z=(h+i)/c):(c=2*Math.sqrt(1+b-c-f),this._w=(e-a)/c,this._x=(d+g)/c,this._y=(h+i)/c,this._z=0.25*c);this._updateEuler();return this},inverse:function(){this.conjugate().normalize();return this},conjugate:function(){this._x*=-1;this._y*=-1;this._z*=-1;this._updateEuler();return this},lengthSq:function(){return this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w},length:function(){return Math.sqrt(this._x* + this._x+this._y*this._y+this._z*this._z+this._w*this._w)},normalize:function(){var a=this.length();0===a?(this._z=this._y=this._x=0,this._w=1):(a=1/a,this._x*=a,this._y*=a,this._z*=a,this._w*=a);return this},multiply:function(a,b){return void 0!==b?(console.warn("DEPRECATED: Quaternion's .multiply() now only accepts one argument. Use .multiplyQuaternions( a, b ) instead."),this.multiplyQuaternions(a,b)):this.multiplyQuaternions(this,a)},multiplyQuaternions:function(a,b){var c=a._x,d=a._y,e=a._z,f= + a._w,h=b._x,g=b._y,i=b._z,k=b._w;this._x=c*k+f*h+d*i-e*g;this._y=d*k+f*g+e*h-c*i;this._z=e*k+f*i+c*g-d*h;this._w=f*k-c*h-d*g-e*i;this._updateEuler();return this},multiplyVector3:function(a){console.warn("DEPRECATED: Quaternion's .multiplyVector3() has been removed. Use is now vector.applyQuaternion( quaternion ) instead.");return a.applyQuaternion(this)},slerp:function(a,b){var c=this._x,d=this._y,e=this._z,f=this._w,h=f*a._w+c*a._x+d*a._y+e*a._z;0>h?(this._w=-a._w,this._x=-a._x,this._y=-a._y,this._z= + -a._z,h=-h):this.copy(a);if(1<=h)return this._w=f,this._x=c,this._y=d,this._z=e,this;var g=Math.acos(h),i=Math.sqrt(1-h*h);if(0.001>Math.abs(i))return this._w=0.5*(f+this._w),this._x=0.5*(c+this._x),this._y=0.5*(d+this._y),this._z=0.5*(e+this._z),this;h=Math.sin((1-b)*g)/i;g=Math.sin(b*g)/i;this._w=f*h+this._w*g;this._x=c*h+this._x*g;this._y=d*h+this._y*g;this._z=e*h+this._z*g;this._updateEuler();return this},equals:function(a){return a._x===this._x&&a._y===this._y&&a._z===this._z&&a._w===this._w}, + fromArray:function(a){this._x=a[0];this._y=a[1];this._z=a[2];this._w=a[3];this._updateEuler();return this},toArray:function(){return[this._x,this._y,this._z,this._w]},clone:function(){return new THREE.Quaternion(this._x,this._y,this._z,this._w)}};THREE.Quaternion.slerp=function(a,b,c,d){return c.copy(a).slerp(b,d)};THREE.Vector2=function(a,b){this.x=a||0;this.y=b||0}; + THREE.Vector2.prototype={constructor:THREE.Vector2,set:function(a,b){this.x=a;this.y=b;return this},setX:function(a){this.x=a;return this},setY:function(a){this.y=a;return this},setComponent:function(a,b){switch(a){case 0:this.x=b;break;case 1:this.y=b;break;default:throw Error("index is out of range: "+a);}},getComponent:function(a){switch(a){case 0:return this.x;case 1:return this.y;default:throw Error("index is out of range: "+a);}},copy:function(a){this.x=a.x;this.y=a.y;return this},add:function(a, + b){if(void 0!==b)return console.warn("DEPRECATED: Vector2's .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(a,b);this.x+=a.x;this.y+=a.y;return this},addVectors:function(a,b){this.x=a.x+b.x;this.y=a.y+b.y;return this},addScalar:function(a){this.x+=a;this.y+=a;return this},sub:function(a,b){if(void 0!==b)return console.warn("DEPRECATED: Vector2's .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(a,b);this.x-=a.x;this.y-= + a.y;return this},subVectors:function(a,b){this.x=a.x-b.x;this.y=a.y-b.y;return this},multiplyScalar:function(a){this.x*=a;this.y*=a;return this},divideScalar:function(a){0!==a?(a=1/a,this.x*=a,this.y*=a):this.y=this.x=0;return this},min:function(a){this.x>a.x&&(this.x=a.x);this.y>a.y&&(this.y=a.y);return this},max:function(a){this.xb.x&&(this.x=b.x);this.yb.y&&(this.y=b.y); + return this},negate:function(){return this.multiplyScalar(-1)},dot:function(a){return this.x*a.x+this.y*a.y},lengthSq:function(){return this.x*this.x+this.y*this.y},length:function(){return Math.sqrt(this.x*this.x+this.y*this.y)},normalize:function(){return this.divideScalar(this.length())},distanceTo:function(a){return Math.sqrt(this.distanceToSquared(a))},distanceToSquared:function(a){var b=this.x-a.x,a=this.y-a.y;return b*b+a*a},setLength:function(a){var b=this.length();0!==b&&a!==b&&this.multiplyScalar(a/ + b);return this},lerp:function(a,b){this.x+=(a.x-this.x)*b;this.y+=(a.y-this.y)*b;return this},equals:function(a){return a.x===this.x&&a.y===this.y},fromArray:function(a){this.x=a[0];this.y=a[1];return this},toArray:function(){return[this.x,this.y]},clone:function(){return new THREE.Vector2(this.x,this.y)}};THREE.Vector3=function(a,b,c){this.x=a||0;this.y=b||0;this.z=c||0}; + THREE.Vector3.prototype={constructor:THREE.Vector3,set:function(a,b,c){this.x=a;this.y=b;this.z=c;return this},setX:function(a){this.x=a;return this},setY:function(a){this.y=a;return this},setZ:function(a){this.z=a;return this},setComponent:function(a,b){switch(a){case 0:this.x=b;break;case 1:this.y=b;break;case 2:this.z=b;break;default:throw Error("index is out of range: "+a);}},getComponent:function(a){switch(a){case 0:return this.x;case 1:return this.y;case 2:return this.z;default:throw Error("index is out of range: "+ + a);}},copy:function(a){this.x=a.x;this.y=a.y;this.z=a.z;return this},add:function(a,b){if(void 0!==b)return console.warn("DEPRECATED: Vector3's .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(a,b);this.x+=a.x;this.y+=a.y;this.z+=a.z;return this},addScalar:function(a){this.x+=a;this.y+=a;this.z+=a;return this},addVectors:function(a,b){this.x=a.x+b.x;this.y=a.y+b.y;this.z=a.z+b.z;return this},sub:function(a,b){if(void 0!==b)return console.warn("DEPRECATED: Vector3's .sub() now only accepts one argument. Use .subVectors( a, b ) instead."), + this.subVectors(a,b);this.x-=a.x;this.y-=a.y;this.z-=a.z;return this},subVectors:function(a,b){this.x=a.x-b.x;this.y=a.y-b.y;this.z=a.z-b.z;return this},multiply:function(a,b){if(void 0!==b)return console.warn("DEPRECATED: Vector3's .multiply() now only accepts one argument. Use .multiplyVectors( a, b ) instead."),this.multiplyVectors(a,b);this.x*=a.x;this.y*=a.y;this.z*=a.z;return this},multiplyScalar:function(a){this.x*=a;this.y*=a;this.z*=a;return this},multiplyVectors:function(a,b){this.x=a.x* + b.x;this.y=a.y*b.y;this.z=a.z*b.z;return this},applyMatrix3:function(a){var b=this.x,c=this.y,d=this.z,a=a.elements;this.x=a[0]*b+a[3]*c+a[6]*d;this.y=a[1]*b+a[4]*c+a[7]*d;this.z=a[2]*b+a[5]*c+a[8]*d;return this},applyMatrix4:function(a){var b=this.x,c=this.y,d=this.z,a=a.elements;this.x=a[0]*b+a[4]*c+a[8]*d+a[12];this.y=a[1]*b+a[5]*c+a[9]*d+a[13];this.z=a[2]*b+a[6]*c+a[10]*d+a[14];return this},applyProjection:function(a){var b=this.x,c=this.y,d=this.z,a=a.elements,e=1/(a[3]*b+a[7]*c+a[11]*d+a[15]); + this.x=(a[0]*b+a[4]*c+a[8]*d+a[12])*e;this.y=(a[1]*b+a[5]*c+a[9]*d+a[13])*e;this.z=(a[2]*b+a[6]*c+a[10]*d+a[14])*e;return this},applyQuaternion:function(a){var b=this.x,c=this.y,d=this.z,e=a.x,f=a.y,h=a.z,a=a.w,g=a*b+f*d-h*c,i=a*c+h*b-e*d,k=a*d+e*c-f*b,b=-e*b-f*c-h*d;this.x=g*a+b*-e+i*-h-k*-f;this.y=i*a+b*-f+k*-e-g*-h;this.z=k*a+b*-h+g*-f-i*-e;return this},transformDirection:function(a){var b=this.x,c=this.y,d=this.z,a=a.elements;this.x=a[0]*b+a[4]*c+a[8]*d;this.y=a[1]*b+a[5]*c+a[9]*d;this.z=a[2]* + b+a[6]*c+a[10]*d;this.normalize();return this},divide:function(a){this.x/=a.x;this.y/=a.y;this.z/=a.z;return this},divideScalar:function(a){0!==a?(a=1/a,this.x*=a,this.y*=a,this.z*=a):this.z=this.y=this.x=0;return this},min:function(a){this.x>a.x&&(this.x=a.x);this.y>a.y&&(this.y=a.y);this.z>a.z&&(this.z=a.z);return this},max:function(a){this.xb.x&&(this.x=b.x);this.y< + a.y?this.y=a.y:this.y>b.y&&(this.y=b.y);this.zb.z&&(this.z=b.z);return this},negate:function(){return this.multiplyScalar(-1)},dot:function(a){return this.x*a.x+this.y*a.y+this.z*a.z},lengthSq:function(){return this.x*this.x+this.y*this.y+this.z*this.z},length:function(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z)},lengthManhattan:function(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)},normalize:function(){return this.divideScalar(this.length())}, + setLength:function(a){var b=this.length();0!==b&&a!==b&&this.multiplyScalar(a/b);return this},lerp:function(a,b){this.x+=(a.x-this.x)*b;this.y+=(a.y-this.y)*b;this.z+=(a.z-this.z)*b;return this},cross:function(a,b){if(void 0!==b)return console.warn("DEPRECATED: Vector3's .cross() now only accepts one argument. Use .crossVectors( a, b ) instead."),this.crossVectors(a,b);var c=this.x,d=this.y,e=this.z;this.x=d*a.z-e*a.y;this.y=e*a.x-c*a.z;this.z=c*a.y-d*a.x;return this},crossVectors:function(a,b){var c= + a.x,d=a.y,e=a.z,f=b.x,h=b.y,g=b.z;this.x=d*g-e*h;this.y=e*f-c*g;this.z=c*h-d*f;return this},angleTo:function(a){a=this.dot(a)/(this.length()*a.length());return Math.acos(THREE.Math.clamp(a,-1,1))},distanceTo:function(a){return Math.sqrt(this.distanceToSquared(a))},distanceToSquared:function(a){var b=this.x-a.x,c=this.y-a.y,a=this.z-a.z;return b*b+c*c+a*a},setEulerFromRotationMatrix:function(){console.error("REMOVED: Vector3's setEulerFromRotationMatrix has been removed in favor of Euler.setFromRotationMatrix(), please update your code.")}, + setEulerFromQuaternion:function(){console.error("REMOVED: Vector3's setEulerFromQuaternion: has been removed in favor of Euler.setFromQuaternion(), please update your code.")},getPositionFromMatrix:function(a){this.x=a.elements[12];this.y=a.elements[13];this.z=a.elements[14];return this},getScaleFromMatrix:function(a){var b=this.set(a.elements[0],a.elements[1],a.elements[2]).length(),c=this.set(a.elements[4],a.elements[5],a.elements[6]).length(),a=this.set(a.elements[8],a.elements[9],a.elements[10]).length(); + this.x=b;this.y=c;this.z=a;return this},getColumnFromMatrix:function(a,b){var c=4*a,d=b.elements;this.x=d[c];this.y=d[c+1];this.z=d[c+2];return this},equals:function(a){return a.x===this.x&&a.y===this.y&&a.z===this.z},fromArray:function(a){this.x=a[0];this.y=a[1];this.z=a[2];return this},toArray:function(){return[this.x,this.y,this.z]},clone:function(){return new THREE.Vector3(this.x,this.y,this.z)}}; + THREE.extend(THREE.Vector3.prototype,{applyEuler:function(){var a=new THREE.Quaternion;return function(b){!1===b instanceof THREE.Euler&&console.error("ERROR: Vector3's .applyEuler() now expects a Euler rotation rather than a Vector3 and order. Please update your code.");this.applyQuaternion(a.setFromEuler(b));return this}}(),applyAxisAngle:function(){var a=new THREE.Quaternion;return function(b,c){this.applyQuaternion(a.setFromAxisAngle(b,c));return this}}(),projectOnVector:function(){var a=new THREE.Vector3; + return function(b){a.copy(b).normalize();b=this.dot(a);return this.copy(a).multiplyScalar(b)}}(),projectOnPlane:function(){var a=new THREE.Vector3;return function(b){a.copy(this).projectOnVector(b);return this.sub(a)}}(),reflect:function(){var a=new THREE.Vector3;return function(b){a.copy(this).projectOnVector(b).multiplyScalar(2);return this.subVectors(a,this)}}()});THREE.Vector4=function(a,b,c,d){this.x=a||0;this.y=b||0;this.z=c||0;this.w=void 0!==d?d:1}; + THREE.Vector4.prototype={constructor:THREE.Vector4,set:function(a,b,c,d){this.x=a;this.y=b;this.z=c;this.w=d;return this},setX:function(a){this.x=a;return this},setY:function(a){this.y=a;return this},setZ:function(a){this.z=a;return this},setW:function(a){this.w=a;return this},setComponent:function(a,b){switch(a){case 0:this.x=b;break;case 1:this.y=b;break;case 2:this.z=b;break;case 3:this.w=b;break;default:throw Error("index is out of range: "+a);}},getComponent:function(a){switch(a){case 0:return this.x; + case 1:return this.y;case 2:return this.z;case 3:return this.w;default:throw Error("index is out of range: "+a);}},copy:function(a){this.x=a.x;this.y=a.y;this.z=a.z;this.w=void 0!==a.w?a.w:1;return this},add:function(a,b){if(void 0!==b)return console.warn("DEPRECATED: Vector4's .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(a,b);this.x+=a.x;this.y+=a.y;this.z+=a.z;this.w+=a.w;return this},addScalar:function(a){this.x+=a;this.y+=a;this.z+=a;this.w+=a;return this}, + addVectors:function(a,b){this.x=a.x+b.x;this.y=a.y+b.y;this.z=a.z+b.z;this.w=a.w+b.w;return this},sub:function(a,b){if(void 0!==b)return console.warn("DEPRECATED: Vector4's .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(a,b);this.x-=a.x;this.y-=a.y;this.z-=a.z;this.w-=a.w;return this},subVectors:function(a,b){this.x=a.x-b.x;this.y=a.y-b.y;this.z=a.z-b.z;this.w=a.w-b.w;return this},multiplyScalar:function(a){this.x*=a;this.y*=a;this.z*=a;this.w*=a;return this}, + applyMatrix4:function(a){var b=this.x,c=this.y,d=this.z,e=this.w,a=a.elements;this.x=a[0]*b+a[4]*c+a[8]*d+a[12]*e;this.y=a[1]*b+a[5]*c+a[9]*d+a[13]*e;this.z=a[2]*b+a[6]*c+a[10]*d+a[14]*e;this.w=a[3]*b+a[7]*c+a[11]*d+a[15]*e;return this},divideScalar:function(a){0!==a?(a=1/a,this.x*=a,this.y*=a,this.z*=a,this.w*=a):(this.z=this.y=this.x=0,this.w=1);return this},setAxisAngleFromQuaternion:function(a){this.w=2*Math.acos(a.w);var b=Math.sqrt(1-a.w*a.w);1E-4>b?(this.x=1,this.z=this.y=0):(this.x=a.x/b, + this.y=a.y/b,this.z=a.z/b);return this},setAxisAngleFromRotationMatrix:function(a){var b,c,d,a=a.elements,e=a[0];d=a[4];var f=a[8],h=a[1],g=a[5],i=a[9];c=a[2];b=a[6];var k=a[10];if(0.01>Math.abs(d-h)&&0.01>Math.abs(f-c)&&0.01>Math.abs(i-b)){if(0.1>Math.abs(d+h)&&0.1>Math.abs(f+c)&&0.1>Math.abs(i+b)&&0.1>Math.abs(e+g+k-3))return this.set(1,0,0,0),this;a=Math.PI;e=(e+1)/2;g=(g+1)/2;k=(k+1)/2;d=(d+h)/4;f=(f+c)/4;i=(i+b)/4;e>g&&e>k?0.01>e?(b=0,d=c=0.707106781):(b=Math.sqrt(e),c=d/b,d=f/b):g>k?0.01>g? + (b=0.707106781,c=0,d=0.707106781):(c=Math.sqrt(g),b=d/c,d=i/c):0.01>k?(c=b=0.707106781,d=0):(d=Math.sqrt(k),b=f/d,c=i/d);this.set(b,c,d,a);return this}a=Math.sqrt((b-i)*(b-i)+(f-c)*(f-c)+(h-d)*(h-d));0.001>Math.abs(a)&&(a=1);this.x=(b-i)/a;this.y=(f-c)/a;this.z=(h-d)/a;this.w=Math.acos((e+g+k-1)/2);return this},min:function(a){this.x>a.x&&(this.x=a.x);this.y>a.y&&(this.y=a.y);this.z>a.z&&(this.z=a.z);this.w>a.w&&(this.w=a.w);return this},max:function(a){this.xb.x&&(this.x=b.x);this.yb.y&&(this.y=b.y);this.zb.z&&(this.z=b.z);this.wb.w&&(this.w=b.w);return this},negate:function(){return this.multiplyScalar(-1)},dot:function(a){return this.x*a.x+this.y*a.y+this.z*a.z+this.w*a.w},lengthSq:function(){return this.x*this.x+this.y*this.y+this.z*this.z+this.w*this.w},length:function(){return Math.sqrt(this.x* + this.x+this.y*this.y+this.z*this.z+this.w*this.w)},lengthManhattan:function(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)+Math.abs(this.w)},normalize:function(){return this.divideScalar(this.length())},setLength:function(a){var b=this.length();0!==b&&a!==b&&this.multiplyScalar(a/b);return this},lerp:function(a,b){this.x+=(a.x-this.x)*b;this.y+=(a.y-this.y)*b;this.z+=(a.z-this.z)*b;this.w+=(a.w-this.w)*b;return this},equals:function(a){return a.x===this.x&&a.y===this.y&&a.z===this.z&& + a.w===this.w},fromArray:function(a){this.x=a[0];this.y=a[1];this.z=a[2];this.w=a[3];return this},toArray:function(){return[this.x,this.y,this.z,this.w]},clone:function(){return new THREE.Vector4(this.x,this.y,this.z,this.w)}};THREE.Euler=function(a,b,c,d){this._x=a||0;this._y=b||0;this._z=c||0;this._order=d||THREE.Euler.DefaultOrder};THREE.Euler.RotationOrders="XYZ YZX ZXY XZY YXZ ZYX".split(" ");THREE.Euler.DefaultOrder="XYZ"; + THREE.Euler.prototype={constructor:THREE.Euler,_x:0,_y:0,_z:0,_order:THREE.Euler.DefaultOrder,_quaternion:void 0,_updateQuaternion:function(){void 0!==this._quaternion&&this._quaternion.setFromEuler(this,!1)},get x(){return this._x},set x(a){this._x=a;this._updateQuaternion()},get y(){return this._y},set y(a){this._y=a;this._updateQuaternion()},get z(){return this._z},set z(a){this._z=a;this._updateQuaternion()},get order(){return this._order},set order(a){this._order=a;this._updateQuaternion()}, + set:function(a,b,c,d){this._x=a;this._y=b;this._z=c;this._order=d||this._order;this._updateQuaternion();return this},copy:function(a){this._x=a._x;this._y=a._y;this._z=a._z;this._order=a._order;this._updateQuaternion();return this},setFromRotationMatrix:function(a,b){function c(a){return Math.min(Math.max(a,-1),1)}var d=a.elements,e=d[0],f=d[4],h=d[8],g=d[1],i=d[5],k=d[9],m=d[2],l=d[6],d=d[10],b=b||this._order;"XYZ"===b?(this._y=Math.asin(c(h)),0.99999>Math.abs(h)?(this._x=Math.atan2(-k,d),this._z= + Math.atan2(-f,e)):(this._x=Math.atan2(l,i),this._z=0)):"YXZ"===b?(this._x=Math.asin(-c(k)),0.99999>Math.abs(k)?(this._y=Math.atan2(h,d),this._z=Math.atan2(g,i)):(this._y=Math.atan2(-m,e),this._z=0)):"ZXY"===b?(this._x=Math.asin(c(l)),0.99999>Math.abs(l)?(this._y=Math.atan2(-m,d),this._z=Math.atan2(-f,i)):(this._y=0,this._z=Math.atan2(g,e))):"ZYX"===b?(this._y=Math.asin(-c(m)),0.99999>Math.abs(m)?(this._x=Math.atan2(l,d),this._z=Math.atan2(g,e)):(this._x=0,this._z=Math.atan2(-f,i))):"YZX"===b?(this._z= + Math.asin(c(g)),0.99999>Math.abs(g)?(this._x=Math.atan2(-k,i),this._y=Math.atan2(-m,e)):(this._x=0,this._y=Math.atan2(h,d))):"XZY"===b?(this._z=Math.asin(-c(f)),0.99999>Math.abs(f)?(this._x=Math.atan2(l,i),this._y=Math.atan2(h,e)):(this._x=Math.atan2(-k,d),this._y=0)):console.warn("WARNING: Euler.setFromRotationMatrix() given unsupported order: "+b);this._order=b;this._updateQuaternion();return this},setFromQuaternion:function(a,b,c){function d(a){return Math.min(Math.max(a,-1),1)}var e=a.x*a.x,f= + a.y*a.y,h=a.z*a.z,g=a.w*a.w,b=b||this._order;"XYZ"===b?(this._x=Math.atan2(2*(a.x*a.w-a.y*a.z),g-e-f+h),this._y=Math.asin(d(2*(a.x*a.z+a.y*a.w))),this._z=Math.atan2(2*(a.z*a.w-a.x*a.y),g+e-f-h)):"YXZ"===b?(this._x=Math.asin(d(2*(a.x*a.w-a.y*a.z))),this._y=Math.atan2(2*(a.x*a.z+a.y*a.w),g-e-f+h),this._z=Math.atan2(2*(a.x*a.y+a.z*a.w),g-e+f-h)):"ZXY"===b?(this._x=Math.asin(d(2*(a.x*a.w+a.y*a.z))),this._y=Math.atan2(2*(a.y*a.w-a.z*a.x),g-e-f+h),this._z=Math.atan2(2*(a.z*a.w-a.x*a.y),g-e+f-h)):"ZYX"=== + b?(this._x=Math.atan2(2*(a.x*a.w+a.z*a.y),g-e-f+h),this._y=Math.asin(d(2*(a.y*a.w-a.x*a.z))),this._z=Math.atan2(2*(a.x*a.y+a.z*a.w),g+e-f-h)):"YZX"===b?(this._x=Math.atan2(2*(a.x*a.w-a.z*a.y),g-e+f-h),this._y=Math.atan2(2*(a.y*a.w-a.x*a.z),g+e-f-h),this._z=Math.asin(d(2*(a.x*a.y+a.z*a.w)))):"XZY"===b?(this._x=Math.atan2(2*(a.x*a.w+a.y*a.z),g-e+f-h),this._y=Math.atan2(2*(a.x*a.z+a.y*a.w),g+e-f-h),this._z=Math.asin(d(2*(a.z*a.w-a.x*a.y)))):console.warn("WARNING: Euler.setFromQuaternion() given unsupported order: "+ + b);this._order=b;!1!==c&&this._updateQuaternion();return this},reorder:function(){var a=new THREE.Quaternion;return function(b){a.setFromEuler(this);this.setFromQuaternion(a,b)}}(),fromArray:function(a){this._x=a[0];this._y=a[1];this._z=a[2];void 0!==a[3]&&(this._order=a[3]);this._updateQuaternion();return this},toArray:function(){return[this._x,this._y,this._z,this._order]},equals:function(a){return a._x===this._x&&a._y===this._y&&a._z===this._z&&a._order===this._order},clone:function(){return new THREE.Euler(this._x, + this._y,this._z,this._order)}};THREE.Line3=function(a,b){this.start=void 0!==a?a:new THREE.Vector3;this.end=void 0!==b?b:new THREE.Vector3}; + THREE.Line3.prototype={constructor:THREE.Line3,set:function(a,b){this.start.copy(a);this.end.copy(b);return this},copy:function(a){this.start.copy(a.start);this.end.copy(a.end);return this},center:function(a){return(a||new THREE.Vector3).addVectors(this.start,this.end).multiplyScalar(0.5)},delta:function(a){return(a||new THREE.Vector3).subVectors(this.end,this.start)},distanceSq:function(){return this.start.distanceToSquared(this.end)},distance:function(){return this.start.distanceTo(this.end)},at:function(a, + b){var c=b||new THREE.Vector3;return this.delta(c).multiplyScalar(a).add(this.start)},closestPointToPointParameter:function(){var a=new THREE.Vector3,b=new THREE.Vector3;return function(c,d){a.subVectors(c,this.start);b.subVectors(this.end,this.start);var e=b.dot(b),e=b.dot(a)/e;d&&(e=THREE.Math.clamp(e,0,1));return e}}(),closestPointToPoint:function(a,b,c){a=this.closestPointToPointParameter(a,b);c=c||new THREE.Vector3;return this.delta(c).multiplyScalar(a).add(this.start)},applyMatrix4:function(a){this.start.applyMatrix4(a); + this.end.applyMatrix4(a);return this},equals:function(a){return a.start.equals(this.start)&&a.end.equals(this.end)},clone:function(){return(new THREE.Line3).copy(this)}};THREE.Box2=function(a,b){this.min=void 0!==a?a:new THREE.Vector2(Infinity,Infinity);this.max=void 0!==b?b:new THREE.Vector2(-Infinity,-Infinity)}; + THREE.Box2.prototype={constructor:THREE.Box2,set:function(a,b){this.min.copy(a);this.max.copy(b);return this},setFromPoints:function(a){if(0this.max.x&&(this.max.x=b.x),b.ythis.max.y&&(this.max.y=b.y)}else this.makeEmpty();return this},setFromCenterAndSize:function(){var a=new THREE.Vector2;return function(b,c){var d=a.copy(c).multiplyScalar(0.5); + this.min.copy(b).sub(d);this.max.copy(b).add(d);return this}}(),copy:function(a){this.min.copy(a.min);this.max.copy(a.max);return this},makeEmpty:function(){this.min.x=this.min.y=Infinity;this.max.x=this.max.y=-Infinity;return this},empty:function(){return this.max.xthis.max.x||a.ythis.max.y?!1:!0},containsBox:function(a){return this.min.x<=a.min.x&&a.max.x<=this.max.x&&this.min.y<=a.min.y&&a.max.y<=this.max.y?!0:!1},getParameter:function(a){return new THREE.Vector2((a.x-this.min.x)/(this.max.x-this.min.x), + (a.y-this.min.y)/(this.max.y-this.min.y))},isIntersectionBox:function(a){return a.max.xthis.max.x||a.max.ythis.max.y?!1:!0},clampPoint:function(a,b){return(b||new THREE.Vector2).copy(a).clamp(this.min,this.max)},distanceToPoint:function(){var a=new THREE.Vector2;return function(b){return a.copy(b).clamp(this.min,this.max).sub(b).length()}}(),intersect:function(a){this.min.max(a.min);this.max.min(a.max);return this},union:function(a){this.min.min(a.min);this.max.max(a.max); + return this},translate:function(a){this.min.add(a);this.max.add(a);return this},equals:function(a){return a.min.equals(this.min)&&a.max.equals(this.max)},clone:function(){return(new THREE.Box2).copy(this)}};THREE.Box3=function(a,b){this.min=void 0!==a?a:new THREE.Vector3(Infinity,Infinity,Infinity);this.max=void 0!==b?b:new THREE.Vector3(-Infinity,-Infinity,-Infinity)}; + THREE.Box3.prototype={constructor:THREE.Box3,set:function(a,b){this.min.copy(a);this.max.copy(b);return this},addPoint:function(a){a.xthis.max.x&&(this.max.x=a.x);a.ythis.max.y&&(this.max.y=a.y);a.zthis.max.z&&(this.max.z=a.z)},setFromPoints:function(a){if(0this.max.x||a.ythis.max.y||a.zthis.max.z?!1:!0},containsBox:function(a){return this.min.x<=a.min.x&&a.max.x<=this.max.x&&this.min.y<=a.min.y&&a.max.y<=this.max.y&&this.min.z<=a.min.z&&a.max.z<=this.max.z?!0:!1},getParameter:function(a){return new THREE.Vector3((a.x-this.min.x)/(this.max.x-this.min.x), + (a.y-this.min.y)/(this.max.y-this.min.y),(a.z-this.min.z)/(this.max.z-this.min.z))},isIntersectionBox:function(a){return a.max.xthis.max.x||a.max.ythis.max.y||a.max.zthis.max.z?!1:!0},clampPoint:function(a,b){return(b||new THREE.Vector3).copy(a).clamp(this.min,this.max)},distanceToPoint:function(){var a=new THREE.Vector3;return function(b){return a.copy(b).clamp(this.min,this.max).sub(b).length()}}(),getBoundingSphere:function(){var a= + new THREE.Vector3;return function(b){b=b||new THREE.Sphere;b.center=this.center();b.radius=0.5*this.size(a).length();return b}}(),intersect:function(a){this.min.max(a.min);this.max.min(a.max);return this},union:function(a){this.min.min(a.min);this.max.max(a.max);return this},applyMatrix4:function(){var a=[new THREE.Vector3,new THREE.Vector3,new THREE.Vector3,new THREE.Vector3,new THREE.Vector3,new THREE.Vector3,new THREE.Vector3,new THREE.Vector3];return function(b){a[0].set(this.min.x,this.min.y, + this.min.z).applyMatrix4(b);a[1].set(this.min.x,this.min.y,this.max.z).applyMatrix4(b);a[2].set(this.min.x,this.max.y,this.min.z).applyMatrix4(b);a[3].set(this.min.x,this.max.y,this.max.z).applyMatrix4(b);a[4].set(this.max.x,this.min.y,this.min.z).applyMatrix4(b);a[5].set(this.max.x,this.min.y,this.max.z).applyMatrix4(b);a[6].set(this.max.x,this.max.y,this.min.z).applyMatrix4(b);a[7].set(this.max.x,this.max.y,this.max.z).applyMatrix4(b);this.makeEmpty();this.setFromPoints(a);return this}}(),translate:function(a){this.min.add(a); + this.max.add(a);return this},equals:function(a){return a.min.equals(this.min)&&a.max.equals(this.max)},clone:function(){return(new THREE.Box3).copy(this)}};THREE.Matrix3=function(a,b,c,d,e,f,h,g,i){this.elements=new Float32Array(9);this.set(void 0!==a?a:1,b||0,c||0,d||0,void 0!==e?e:1,f||0,h||0,g||0,void 0!==i?i:1)}; + THREE.Matrix3.prototype={constructor:THREE.Matrix3,set:function(a,b,c,d,e,f,h,g,i){var k=this.elements;k[0]=a;k[3]=b;k[6]=c;k[1]=d;k[4]=e;k[7]=f;k[2]=h;k[5]=g;k[8]=i;return this},identity:function(){this.set(1,0,0,0,1,0,0,0,1);return this},copy:function(a){a=a.elements;this.set(a[0],a[3],a[6],a[1],a[4],a[7],a[2],a[5],a[8]);return this},multiplyVector3:function(a){console.warn("DEPRECATED: Matrix3's .multiplyVector3() has been removed. Use vector.applyMatrix3( matrix ) instead.");return a.applyMatrix3(this)}, + multiplyVector3Array:function(){var a=new THREE.Vector3;return function(b){for(var c=0,d=b.length;cd?c.copy(this.origin):c.copy(this.direction).multiplyScalar(d).add(this.origin)},distanceToPoint:function(){var a=new THREE.Vector3;return function(b){var c=a.subVectors(b,this.origin).dot(this.direction);if(0>c)return this.origin.distanceTo(b);a.copy(this.direction).multiplyScalar(c).add(this.origin);return a.distanceTo(b)}}(),distanceSqToSegment:function(a,b,c,d){var e=a.clone().add(b).multiplyScalar(0.5),f=b.clone().sub(a).normalize(),h=0.5*a.distanceTo(b), + g=this.origin.clone().sub(e),a=-this.direction.dot(f),b=g.dot(this.direction),i=-g.dot(f),k=g.lengthSq(),m=Math.abs(1-a*a),l,p;0<=m?(g=a*i-b,l=a*b-i,p=h*m,0<=g?l>=-p?l<=p?(h=1/m,g*=h,l*=h,a=g*(g+a*l+2*b)+l*(a*g+l+2*i)+k):(l=h,g=Math.max(0,-(a*l+b)),a=-g*g+l*(l+2*i)+k):(l=-h,g=Math.max(0,-(a*l+b)),a=-g*g+l*(l+2*i)+k):l<=-p?(g=Math.max(0,-(-a*h+b)),l=0a.normal.dot(this.direction)*b?!0:!1},distanceToPlane:function(a){var b=a.normal.dot(this.direction);if(0==b)return 0==a.distanceToPoint(this.origin)? + 0:null;a=-(this.origin.dot(a.normal)+a.constant)/b;return 0<=a?a:null},intersectPlane:function(a,b){var c=this.distanceToPlane(a);return null===c?null:this.at(c,b)},isIntersectionBox:function(){var a=new THREE.Vector3;return function(b){return null!==this.intersectBox(b,a)}}(),intersectBox:function(a,b){var c,d,e,f,h;d=1/this.direction.x;f=1/this.direction.y;h=1/this.direction.z;var g=this.origin;0<=d?(c=(a.min.x-g.x)*d,d*=a.max.x-g.x):(c=(a.max.x-g.x)*d,d*=a.min.x-g.x);0<=f?(e=(a.min.y-g.y)*f,f*= + a.max.y-g.y):(e=(a.max.y-g.y)*f,f*=a.min.y-g.y);if(c>f||e>d)return null;if(e>c||c!==c)c=e;if(fh||e>d)return null;if(e>c||c!==c)c=e;if(hd?null:this.at(0<=c?c:d,b)},intersectTriangle:function(){var a=new THREE.Vector3,b=new THREE.Vector3,c=new THREE.Vector3,d=new THREE.Vector3;return function(e,f,h,g,i){b.subVectors(f,e);c.subVectors(h,e);d.crossVectors(b,c);f=this.direction.dot(d);if(0< + f){if(g)return null;g=1}else if(0>f)g=-1,f=-f;else return null;a.subVectors(this.origin,e);e=g*this.direction.dot(c.crossVectors(a,c));if(0>e)return null;h=g*this.direction.dot(b.cross(a));if(0>h||e+h>f)return null;e=-g*a.dot(d);return 0>e?null:this.at(e/f,i)}}(),applyMatrix4:function(a){this.direction.add(this.origin).applyMatrix4(a);this.origin.applyMatrix4(a);this.direction.sub(this.origin);this.direction.normalize();return this},equals:function(a){return a.origin.equals(this.origin)&&a.direction.equals(this.direction)}, + clone:function(){return(new THREE.Ray).copy(this)}};THREE.Sphere=function(a,b){this.center=void 0!==a?a:new THREE.Vector3;this.radius=void 0!==b?b:0}; + THREE.Sphere.prototype={constructor:THREE.Sphere,set:function(a,b){this.center.copy(a);this.radius=b;return this},setFromPoints:function(){var a=new THREE.Box3;return function(b,c){var d=this.center;void 0!==c?d.copy(c):a.setFromPoints(b).center(d);for(var e=0,f=0,h=b.length;f=this.radius},containsPoint:function(a){return a.distanceToSquared(this.center)<= + this.radius*this.radius},distanceToPoint:function(a){return a.distanceTo(this.center)-this.radius},intersectsSphere:function(a){var b=this.radius+a.radius;return a.center.distanceToSquared(this.center)<=b*b},clampPoint:function(a,b){var c=this.center.distanceToSquared(a),d=b||new THREE.Vector3;d.copy(a);c>this.radius*this.radius&&(d.sub(this.center).normalize(),d.multiplyScalar(this.radius).add(this.center));return d},getBoundingBox:function(a){a=a||new THREE.Box3;a.set(this.center,this.center);a.expandByScalar(this.radius); + return a},applyMatrix4:function(a){this.center.applyMatrix4(a);this.radius*=a.getMaxScaleOnAxis();return this},translate:function(a){this.center.add(a);return this},equals:function(a){return a.center.equals(this.center)&&a.radius===this.radius},clone:function(){return(new THREE.Sphere).copy(this)}};THREE.Frustum=function(a,b,c,d,e,f){this.planes=[void 0!==a?a:new THREE.Plane,void 0!==b?b:new THREE.Plane,void 0!==c?c:new THREE.Plane,void 0!==d?d:new THREE.Plane,void 0!==e?e:new THREE.Plane,void 0!==f?f:new THREE.Plane]}; + THREE.Frustum.prototype={constructor:THREE.Frustum,set:function(a,b,c,d,e,f){var h=this.planes;h[0].copy(a);h[1].copy(b);h[2].copy(c);h[3].copy(d);h[4].copy(e);h[5].copy(f);return this},copy:function(a){for(var b=this.planes,c=0;6>c;c++)b[c].copy(a.planes[c]);return this},setFromMatrix:function(a){var b=this.planes,c=a.elements,a=c[0],d=c[1],e=c[2],f=c[3],h=c[4],g=c[5],i=c[6],k=c[7],m=c[8],l=c[9],p=c[10],s=c[11],t=c[12],n=c[13],r=c[14],c=c[15];b[0].setComponents(f-a,k-h,s-m,c-t).normalize();b[1].setComponents(f+ + a,k+h,s+m,c+t).normalize();b[2].setComponents(f+d,k+g,s+l,c+n).normalize();b[3].setComponents(f-d,k-g,s-l,c-n).normalize();b[4].setComponents(f-e,k-i,s-p,c-r).normalize();b[5].setComponents(f+e,k+i,s+p,c+r).normalize();return this},intersectsObject:function(){var a=new THREE.Sphere;return function(b){var c=b.geometry;null===c.boundingSphere&&c.computeBoundingSphere();a.copy(c.boundingSphere);a.applyMatrix4(b.matrixWorld);return this.intersectsSphere(a)}}(),intersectsSphere:function(a){for(var b=this.planes, + c=a.center,a=-a.radius,d=0;6>d;d++)if(b[d].distanceToPoint(c)e;e++){var f=d[e];a.x=0h&&0>f)return!1}return!0}}(),containsPoint:function(a){for(var b= + this.planes,c=0;6>c;c++)if(0>b[c].distanceToPoint(a))return!1;return!0},clone:function(){return(new THREE.Frustum).copy(this)}};THREE.Plane=function(a,b){this.normal=void 0!==a?a:new THREE.Vector3(1,0,0);this.constant=void 0!==b?b:0}; + THREE.Plane.prototype={constructor:THREE.Plane,set:function(a,b){this.normal.copy(a);this.constant=b;return this},setComponents:function(a,b,c,d){this.normal.set(a,b,c);this.constant=d;return this},setFromNormalAndCoplanarPoint:function(a,b){this.normal.copy(a);this.constant=-b.dot(this.normal);return this},setFromCoplanarPoints:function(){var a=new THREE.Vector3,b=new THREE.Vector3;return function(c,d,e){d=a.subVectors(e,d).cross(b.subVectors(c,d)).normalize();this.setFromNormalAndCoplanarPoint(d, + c);return this}}(),copy:function(a){this.normal.copy(a.normal);this.constant=a.constant;return this},normalize:function(){var a=1/this.normal.length();this.normal.multiplyScalar(a);this.constant*=a;return this},negate:function(){this.constant*=-1;this.normal.negate();return this},distanceToPoint:function(a){return this.normal.dot(a)+this.constant},distanceToSphere:function(a){return this.distanceToPoint(a.center)-a.radius},projectPoint:function(a,b){return this.orthoPoint(a,b).sub(a).negate()},orthoPoint:function(a, + b){var c=this.distanceToPoint(a);return(b||new THREE.Vector3).copy(this.normal).multiplyScalar(c)},isIntersectionLine:function(a){var b=this.distanceToPoint(a.start),a=this.distanceToPoint(a.end);return 0>b&&0a&&0f||1e;e++)8==e||13==e||18==e||23==e?b[e]="-":14==e?b[e]="4":(2>=c&&(c=33554432+16777216*Math.random()|0),d=c&15,c>>=4,b[e]=a[19==e?d&3|8:d]);return b.join("")}}(),clamp:function(a,b,c){return ac?c:a},clampBottom:function(a,b){return a=c)return 1;a=(a-b)/(c-b);return a*a*(3-2*a)},smootherstep:function(a,b,c){if(a<=b)return 0;if(a>=c)return 1;a=(a-b)/(c-b);return a*a*a*(a*(6*a-15)+10)},random16:function(){return(65280*Math.random()+255*Math.random())/65535},randInt:function(a,b){return a+Math.floor(Math.random()*(b-a+1))},randFloat:function(a,b){return a+Math.random()*(b-a)},randFloatSpread:function(a){return a*(0.5-Math.random())},sign:function(a){return 0>a?-1:0this.points.length-2?this.points.length-1:f+1;c[3]=f>this.points.length-3?this.points.length-1: + f+2;k=this.points[c[0]];m=this.points[c[1]];l=this.points[c[2]];p=this.points[c[3]];g=h*h;i=h*g;d.x=b(k.x,m.x,l.x,p.x,h,g,i);d.y=b(k.y,m.y,l.y,p.y,h,g,i);d.z=b(k.z,m.z,l.z,p.z,h,g,i);return d};this.getControlPointsArray=function(){var a,b,c=this.points.length,d=[];for(a=0;a=b.x+b.y}}(); + THREE.Triangle.prototype={constructor:THREE.Triangle,set:function(a,b,c){this.a.copy(a);this.b.copy(b);this.c.copy(c);return this},setFromPointsAndIndices:function(a,b,c,d){this.a.copy(a[b]);this.b.copy(a[c]);this.c.copy(a[d]);return this},copy:function(a){this.a.copy(a.a);this.b.copy(a.b);this.c.copy(a.c);return this},area:function(){var a=new THREE.Vector3,b=new THREE.Vector3;return function(){a.subVectors(this.c,this.b);b.subVectors(this.a,this.b);return 0.5*a.cross(b).length()}}(),midpoint:function(a){return(a|| + new THREE.Vector3).addVectors(this.a,this.b).add(this.c).multiplyScalar(1/3)},normal:function(a){return THREE.Triangle.normal(this.a,this.b,this.c,a)},plane:function(a){return(a||new THREE.Plane).setFromCoplanarPoints(this.a,this.b,this.c)},barycoordFromPoint:function(a,b){return THREE.Triangle.barycoordFromPoint(a,this.a,this.b,this.c,b)},containsPoint:function(a){return THREE.Triangle.containsPoint(a,this.a,this.b,this.c)},equals:function(a){return a.a.equals(this.a)&&a.b.equals(this.b)&&a.c.equals(this.c)}, + clone:function(){return(new THREE.Triangle).copy(this)}};THREE.Vertex=function(a){console.warn("THREE.Vertex has been DEPRECATED. Use THREE.Vector3 instead.");return a};THREE.UV=function(a,b){console.warn("THREE.UV has been DEPRECATED. Use THREE.Vector2 instead.");return new THREE.Vector2(a,b)};THREE.Clock=function(a){this.autoStart=void 0!==a?a:!0;this.elapsedTime=this.oldTime=this.startTime=0;this.running=!1}; + THREE.Clock.prototype={constructor:THREE.Clock,start:function(){this.oldTime=this.startTime=void 0!==self.performance&&void 0!==self.performance.now?self.performance.now():Date.now();this.running=!0},stop:function(){this.getElapsedTime();this.running=!1},getElapsedTime:function(){this.getDelta();return this.elapsedTime},getDelta:function(){var a=0;this.autoStart&&!this.running&&this.start();if(this.running){var b=void 0!==self.performance&&void 0!==self.performance.now?self.performance.now():Date.now(), + a=0.001*(b-this.oldTime);this.oldTime=b;this.elapsedTime+=a}return a}};THREE.EventDispatcher=function(){}; + THREE.EventDispatcher.prototype={constructor:THREE.EventDispatcher,apply:function(a){a.addEventListener=THREE.EventDispatcher.prototype.addEventListener;a.hasEventListener=THREE.EventDispatcher.prototype.hasEventListener;a.removeEventListener=THREE.EventDispatcher.prototype.removeEventListener;a.dispatchEvent=THREE.EventDispatcher.prototype.dispatchEvent},addEventListener:function(a,b){void 0===this._listeners&&(this._listeners={});var c=this._listeners;void 0===c[a]&&(c[a]=[]);-1===c[a].indexOf(b)&& + c[a].push(b)},hasEventListener:function(a,b){if(void 0===this._listeners)return!1;var c=this._listeners;return void 0!==c[a]&&-1!==c[a].indexOf(b)?!0:!1},removeEventListener:function(a,b){if(void 0!==this._listeners){var c=this._listeners,d=c[a].indexOf(b);-1!==d&&c[a].splice(d,1)}},dispatchEvent:function(){var a=[];return function(b){if(void 0!==this._listeners){var c=this._listeners[b.type];if(void 0!==c){b.target=this;for(var d=c.length,e=0;ef.scale.x)return s;s.push({distance:t,point:f.position,face:null,object:f})}else if(f instanceof + a.LOD)d.getPositionFromMatrix(f.matrixWorld),t=m.ray.origin.distanceTo(d),k(f.getObjectForDistance(t),m,s);else if(f instanceof a.Mesh){var n=f.geometry;null===n.boundingSphere&&n.computeBoundingSphere();b.copy(n.boundingSphere);b.applyMatrix4(f.matrixWorld);if(!1===m.ray.isIntersectionSphere(b))return s;e.getInverse(f.matrixWorld);c.copy(m.ray).applyMatrix4(e);if(null!==n.boundingBox&&!1===c.isIntersectionBox(n.boundingBox))return s;if(n instanceof a.BufferGeometry){var r=f.material;if(void 0=== + r||!1===n.dynamic)return s;var q,u,w=m.precision;if(void 0!==n.attributes.index)for(var z=n.offsets,B=n.attributes.index.array,D=n.attributes.position.array,x=n.offsets.length,F=n.attributes.index.array.length/3,F=0;Fm.far)||s.push({distance:t,point:q,face:null,faceIndex:null,object:f}));else{D=n.attributes.position.array;F=n.attributes.position.array.length;for(n=0;nm.far)||s.push({distance:t,point:q,face:null,faceIndex:null,object:f}))}}else if(n instanceof a.Geometry){B=f.material instanceof a.MeshFaceMaterial;D=!0===B?f.material.materials:null;w=m.precision;z=n.vertices;x=0;for(F=n.faces.length;xm.far)||s.push({distance:t,point:q,face:A,faceIndex:x,object:f})))}}else if(f instanceof a.Line){w=m.linePrecision;r=w*w;n=f.geometry;null===n.boundingSphere&&n.computeBoundingSphere();b.copy(n.boundingSphere);b.applyMatrix4(f.matrixWorld);if(!1===m.ray.isIntersectionSphere(b))return s;e.getInverse(f.matrixWorld);c.copy(m.ray).applyMatrix4(e);if(n instanceof a.Geometry){z=n.vertices;w=z.length;q=new a.Vector3;u=new a.Vector3;F=f.type===a.LineStrip?1: + 2;for(n=0;nr||(t=c.origin.distanceTo(u),tm.far||s.push({distance:t,point:q.clone().applyMatrix4(f.matrixWorld),face:null,faceIndex:null,object:f}))}}},m=function(a,b,c){for(var a=a.getDescendants(),d=0,e=a.length;de&&0>f||0>h&&0>g)return!1;0>e?c=Math.max(c,e/(e-f)):0>f&&(d=Math.min(d,e/(e-f)));0>h?c=Math.max(c,h/(h-g)):0>g&&(d=Math.min(d,h/(h-g)));if(dg.positionScreen.x||1g.positionScreen.y||1g.positionScreen.z||1< + g.positionScreen.z)}ea=0;for(F=ta.length;ea(ia.positionScreen.x-V.positionScreen.x)*(P.positionScreen.y-V.positionScreen.y)-(ia.positionScreen.y-V.positionScreen.y)*(P.positionScreen.x-V.positionScreen.x),Z===THREE.DoubleSide|| + da===(Z===THREE.FrontSide)){if(p===t){var Da=new THREE.RenderableFace3;s.push(Da);t++;p++;l=Da}else l=s[p++];l.id=U.id;l.v1.copy(V);l.v2.copy(P);l.v3.copy(ia);l.normalModel.copy(M.normal);!1===da&&(Z===THREE.BackSide||Z===THREE.DoubleSide)&&l.normalModel.negate();l.normalModel.applyMatrix3(R).normalize();l.normalModelView.copy(l.normalModel).applyMatrix3(J);l.centroidModel.copy(M.centroid).applyMatrix4(v);ia=M.vertexNormals;V=0;for(P=Math.min(ia.length,3);VA.z&&(z===D?(ta=new THREE.RenderableSprite,B.push(ta),D++,z++,w=ta):w=B[z++],w.id=U.id,w.x=A.x*fa,w.y=A.y*fa,w.z=A.z,w.object=U,w.rotation=U.rotation,w.scale.x=U.scale.x*Math.abs(w.x-(A.x+f.projectionMatrix.elements[0])/(A.w+f.projectionMatrix.elements[12])), + w.scale.y=U.scale.y*Math.abs(w.y-(A.y+f.projectionMatrix.elements[5])/(A.w+f.projectionMatrix.elements[13])),w.material=U.material,x.elements.push(w)));!0===m&&x.elements.sort(b);return x}};THREE.Face3=function(a,b,c,d,e,f){this.a=a;this.b=b;this.c=c;this.normal=d instanceof THREE.Vector3?d:new THREE.Vector3;this.vertexNormals=d instanceof Array?d:[];this.color=e instanceof THREE.Color?e:new THREE.Color;this.vertexColors=e instanceof Array?e:[];this.vertexTangents=[];this.materialIndex=void 0!==f?f:0;this.centroid=new THREE.Vector3}; + THREE.Face3.prototype={constructor:THREE.Face3,clone:function(){var a=new THREE.Face3(this.a,this.b,this.c);a.normal.copy(this.normal);a.color.copy(this.color);a.centroid.copy(this.centroid);a.materialIndex=this.materialIndex;var b,c;b=0;for(c=this.vertexNormals.length;bd?-1:1,e.vertexTangents[c]=new THREE.Vector4(z.x,z.y,z.z,d)}this.hasTangents=!0},computeLineDistances:function(){for(var a=0,b=this.vertices,c=0,d=b.length;cd;d++)if(e[d]==e[(d+1)%3]){a.push(f);break}}for(f=a.length-1;0<=f;f--){e=a[f];this.faces.splice(e,1);c=0;for(h=this.faceVertexUvs.length;cb.max.x&&(b.max.x=c),db.max.y&&(b.max.y=d),eb.max.z&&(b.max.z=e)}if(void 0===a||0===a.length)this.boundingBox.min.set(0,0,0),this.boundingBox.max.set(0,0,0)},computeBoundingSphere:function(){var a= + new THREE.Box3,b=new THREE.Vector3;return function(){null===this.boundingSphere&&(this.boundingSphere=new THREE.Sphere);var c=this.attributes.position.array;if(c){for(var d=this.boundingSphere.center,e=0,f=c.length;eQ?-1:1;h[4*a]=J.x;h[4*a+1]=J.y;h[4*a+2]=J.z;h[4*a+3]=N}if(void 0===this.attributes.index||void 0===this.attributes.position||void 0===this.attributes.normal||void 0===this.attributes.uv)console.warn("Missing required attributes (index, position, normal or uv) in BufferGeometry.computeTangents()");else{var b=this.attributes.index.array,c=this.attributes.position.array,d=this.attributes.normal.array,e=this.attributes.uv.array,f=c.length/3;void 0===this.attributes.tangent&& + (this.attributes.tangent={itemSize:4,array:new Float32Array(4*f)});for(var h=this.attributes.tangent.array,g=[],i=[],k=0;ka.length?".":a.join("/"))+"/"},initMaterials:function(a,b){for(var c=[],d=0;da.opacity)i.transparent=a.transparent;void 0!==a.depthTest&&(i.depthTest=a.depthTest);void 0!==a.depthWrite&&(i.depthWrite=a.depthWrite);void 0!==a.visible&&(i.visible=a.visible);void 0!==a.flipSided&&(i.side=THREE.BackSide); + void 0!==a.doubleSided&&(i.side=THREE.DoubleSide);void 0!==a.wireframe&&(i.wireframe=a.wireframe);void 0!==a.vertexColors&&("face"===a.vertexColors?i.vertexColors=THREE.FaceColors:a.vertexColors&&(i.vertexColors=THREE.VertexColors));a.colorDiffuse?i.color=f(a.colorDiffuse):a.DbgColor&&(i.color=a.DbgColor);a.colorSpecular&&(i.specular=f(a.colorSpecular));a.colorAmbient&&(i.ambient=f(a.colorAmbient));a.transparency&&(i.opacity=a.transparency);a.specularCoef&&(i.shininess=a.specularCoef);a.mapDiffuse&& + b&&e(i,"map",a.mapDiffuse,a.mapDiffuseRepeat,a.mapDiffuseOffset,a.mapDiffuseWrap,a.mapDiffuseAnisotropy);a.mapLight&&b&&e(i,"lightMap",a.mapLight,a.mapLightRepeat,a.mapLightOffset,a.mapLightWrap,a.mapLightAnisotropy);a.mapBump&&b&&e(i,"bumpMap",a.mapBump,a.mapBumpRepeat,a.mapBumpOffset,a.mapBumpWrap,a.mapBumpAnisotropy);a.mapNormal&&b&&e(i,"normalMap",a.mapNormal,a.mapNormalRepeat,a.mapNormalOffset,a.mapNormalWrap,a.mapNormalAnisotropy);a.mapSpecular&&b&&e(i,"specularMap",a.mapSpecular,a.mapSpecularRepeat, + a.mapSpecularOffset,a.mapSpecularWrap,a.mapSpecularAnisotropy);a.mapBumpScale&&(i.bumpScale=a.mapBumpScale);a.mapNormal?(g=THREE.ShaderLib.normalmap,k=THREE.UniformsUtils.clone(g.uniforms),k.tNormal.value=i.normalMap,a.mapNormalFactor&&k.uNormalScale.value.set(a.mapNormalFactor,a.mapNormalFactor),i.map&&(k.tDiffuse.value=i.map,k.enableDiffuse.value=!0),i.specularMap&&(k.tSpecular.value=i.specularMap,k.enableSpecular.value=!0),i.lightMap&&(k.tAO.value=i.lightMap,k.enableAO.value=!0),k.uDiffuseColor.value.setHex(i.color), + k.uSpecularColor.value.setHex(i.specular),k.uAmbientColor.value.setHex(i.ambient),k.uShininess.value=i.shininess,void 0!==i.opacity&&(k.uOpacity.value=i.opacity),g=new THREE.ShaderMaterial({fragmentShader:g.fragmentShader,vertexShader:g.vertexShader,uniforms:k,lights:!0,fog:!0}),i.transparent&&(g.transparent=!0)):g=new THREE[g](i);void 0!==a.DbgName&&(g.name=a.DbgName);return g}};THREE.XHRLoader=function(a){this.manager=void 0!==a?a:THREE.DefaultLoadingManager}; + THREE.XHRLoader.prototype={constructor:THREE.XHRLoader,load:function(a,b,c,d){var e=this,f=new XMLHttpRequest;void 0!==b&&f.addEventListener("load",function(c){b(c.target.responseText);e.manager.itemEnd(a)},!1);void 0!==c&&f.addEventListener("progress",function(a){c(a)},!1);void 0!==d&&f.addEventListener("error",function(a){d(a)},!1);void 0!==this.crossOrigin&&(f.crossOrigin=this.crossOrigin);f.open("GET",a,!0);f.send(null);e.manager.itemStart(a)},setCrossOrigin:function(a){this.crossOrigin=a}};THREE.ImageLoader=function(a){this.manager=void 0!==a?a:THREE.DefaultLoadingManager}; + THREE.ImageLoader.prototype={constructor:THREE.ImageLoader,load:function(a,b,c,d){var e=this,f=document.createElement("img");void 0!==b&&f.addEventListener("load",function(){e.manager.itemEnd(a);b(this)},!1);void 0!==c&&f.addEventListener("progress",function(a){c(a)},!1);void 0!==d&&f.addEventListener("error",function(a){d(a)},!1);void 0!==this.crossOrigin&&(f.crossOrigin=this.crossOrigin);f.src=a;e.manager.itemStart(a);return f},setCrossOrigin:function(a){this.crossOrigin=a}};THREE.JSONLoader=function(a){THREE.Loader.call(this,a);this.withCredentials=!1};THREE.JSONLoader.prototype=Object.create(THREE.Loader.prototype);THREE.JSONLoader.prototype.load=function(a,b,c){c=c&&"string"===typeof c?c:this.extractUrlBase(a);this.onLoadStart();this.loadAjaxJSON(this,a,b,c)}; + THREE.JSONLoader.prototype.loadAjaxJSON=function(a,b,c,d,e){var f=new XMLHttpRequest,h=0;f.onreadystatechange=function(){if(f.readyState===f.DONE)if(200===f.status||0===f.status){if(f.responseText){var g=JSON.parse(f.responseText),g=a.parse(g,d);c(g.geometry,g.materials)}else console.warn("THREE.JSONLoader: ["+b+"] seems to be unreachable or file there is empty");a.onLoadComplete()}else console.error("THREE.JSONLoader: Couldn't load ["+b+"] ["+f.status+"]");else f.readyState===f.LOADING?e&&(0===h&& + (h=f.getResponseHeader("Content-Length")),e({total:h,loaded:f.responseText.length})):f.readyState===f.HEADERS_RECEIVED&&void 0!==e&&(h=f.getResponseHeader("Content-Length"))};f.open("GET",b,!0);f.withCredentials=this.withCredentials;f.send(null)}; + THREE.JSONLoader.prototype.parse=function(a,b){var c=new THREE.Geometry,d=void 0!==a.scale?1/a.scale:1,e,f,h,g,i,k,m,l,p,s,t,n,r,q,u=a.faces;p=a.vertices;var w=a.normals,z=a.colors,B=0;if(void 0!==a.uvs){for(e=0;ef;f++)l=u[g++],q=r[2*l],l=r[2*l+1],q=new THREE.Vector2(q,l),2!==f&&c.faceVertexUvs[e][h].push(q),0!==f&&c.faceVertexUvs[e][h+1].push(q)}m&&(m=3*u[g++],s.normal.set(w[m++],w[m++],w[m]),n.normal.copy(s.normal));if(t)for(e=0;4>e;e++)m=3*u[g++],t=new THREE.Vector3(w[m++], + w[m++],w[m]),2!==e&&s.vertexNormals.push(t),0!==e&&n.vertexNormals.push(t);k&&(k=u[g++],k=z[k],s.color.setHex(k),n.color.setHex(k));if(p)for(e=0;4>e;e++)k=u[g++],k=z[k],2!==e&&s.vertexColors.push(new THREE.Color(k)),0!==e&&n.vertexColors.push(new THREE.Color(k));c.faces.push(s);c.faces.push(n)}else{s=new THREE.Face3;s.a=u[g++];s.b=u[g++];s.c=u[g++];h&&(h=u[g++],s.materialIndex=h);h=c.faces.length;if(e)for(e=0;ef;f++)l=u[g++],q=r[2*l],l=r[2*l+1], + q=new THREE.Vector2(q,l),c.faceVertexUvs[e][h].push(q)}m&&(m=3*u[g++],s.normal.set(w[m++],w[m++],w[m]));if(t)for(e=0;3>e;e++)m=3*u[g++],t=new THREE.Vector3(w[m++],w[m++],w[m]),s.vertexNormals.push(t);k&&(k=u[g++],s.color.setHex(z[k]));if(p)for(e=0;3>e;e++)k=u[g++],s.vertexColors.push(new THREE.Color(z[k]));c.faces.push(s)}if(a.skinWeights){g=0;for(i=a.skinWeights.length;gG.parameters.opacity&&(G.parameters.transparent=!0);G.parameters.normalMap?(E=THREE.ShaderLib.normalmap,y=THREE.UniformsUtils.clone(E.uniforms), + q=G.parameters.color,v=G.parameters.specular,r=G.parameters.ambient,I=G.parameters.shininess,y.tNormal.value=A.textures[G.parameters.normalMap],G.parameters.normalScale&&y.uNormalScale.value.set(G.parameters.normalScale[0],G.parameters.normalScale[1]),G.parameters.map&&(y.tDiffuse.value=G.parameters.map,y.enableDiffuse.value=!0),G.parameters.envMap&&(y.tCube.value=G.parameters.envMap,y.enableReflection.value=!0,y.uReflectivity.value=G.parameters.reflectivity),G.parameters.lightMap&&(y.tAO.value=G.parameters.lightMap, + y.enableAO.value=!0),G.parameters.specularMap&&(y.tSpecular.value=A.textures[G.parameters.specularMap],y.enableSpecular.value=!0),G.parameters.displacementMap&&(y.tDisplacement.value=A.textures[G.parameters.displacementMap],y.enableDisplacement.value=!0,y.uDisplacementBias.value=G.parameters.displacementBias,y.uDisplacementScale.value=G.parameters.displacementScale),y.uDiffuseColor.value.setHex(q),y.uSpecularColor.value.setHex(v),y.uAmbientColor.value.setHex(r),y.uShininess.value=I,G.parameters.opacity&& + (y.uOpacity.value=G.parameters.opacity),t=new THREE.ShaderMaterial({fragmentShader:E.fragmentShader,vertexShader:E.vertexShader,uniforms:y,lights:!0,fog:!0})):t=new THREE[G.type](G.parameters);t.name=R;A.materials[R]=t}for(R in C.materials)if(G=C.materials[R],G.parameters.materials){J=[];for(q=0;qg.end&&(g.end=e);b||(b=h)}}a.firstAnimation=b}; + THREE.MorphAnimMesh.prototype.setAnimationLabel=function(a,b,c){this.geometry.animations||(this.geometry.animations={});this.geometry.animations[a]={start:b,end:c}};THREE.MorphAnimMesh.prototype.playAnimation=function(a,b){var c=this.geometry.animations[a];c?(this.setFrameRange(c.start,c.end),this.duration=1E3*((c.end-c.start)/b),this.time=0):console.warn("animation["+a+"] undefined")}; + THREE.MorphAnimMesh.prototype.updateAnimation=function(a){var b=this.duration/this.length;this.time+=this.direction*a;if(this.mirroredLoop){if(this.time>this.duration||0>this.time)this.direction*=-1,this.time>this.duration&&(this.time=this.duration,this.directionBackwards=!0),0>this.time&&(this.time=0,this.directionBackwards=!1)}else this.time%=this.duration,0>this.time&&(this.time+=this.duration);a=this.startKeyframe+THREE.Math.clamp(Math.floor(this.time/b),0,this.length-1);a!==this.currentKeyframe&& + (this.morphTargetInfluences[this.lastKeyframe]=0,this.morphTargetInfluences[this.currentKeyframe]=1,this.morphTargetInfluences[a]=0,this.lastKeyframe=this.currentKeyframe,this.currentKeyframe=a);b=this.time%b/b;this.directionBackwards&&(b=1-b);this.morphTargetInfluences[this.currentKeyframe]=b;this.morphTargetInfluences[this.lastKeyframe]=1-b}; + THREE.MorphAnimMesh.prototype.clone=function(a){void 0===a&&(a=new THREE.MorphAnimMesh(this.geometry,this.material));a.duration=this.duration;a.mirroredLoop=this.mirroredLoop;a.time=this.time;a.lastKeyframe=this.lastKeyframe;a.currentKeyframe=this.currentKeyframe;a.direction=this.direction;a.directionBackwards=this.directionBackwards;THREE.Mesh.prototype.clone.call(this,a);return a};THREE.LOD=function(){THREE.Object3D.call(this);this.objects=[]};THREE.LOD.prototype=Object.create(THREE.Object3D.prototype);THREE.LOD.prototype.addLevel=function(a,b){void 0===b&&(b=0);for(var b=Math.abs(b),c=0;c=this.objects[d].distance)this.objects[d-1].object.visible=!1,this.objects[d].object.visible=!0;else break;for(;d=g||(g*=f.intensity,c.add(La.multiplyScalar(g)))}else f instanceof THREE.PointLight&&(h=ua.getPositionFromMatrix(f.matrixWorld),g=b.dot(ua.subVectors(h,a).normalize()),0>=g||(g*=0==f.distance?1:1-Math.min(a.distanceTo(h)/f.distance,1),0!=g&&(g*=f.intensity,c.add(La.multiplyScalar(g)))))}} + function c(a,b,c,d){m(b);l(c);p(d);s(a.getStyle());C.stroke();ra.expandByScalar(2*b)}function d(a){t(a.getStyle());C.fill()}function e(a,b,c,e,f,h,g,j,i,k,m,l,p){if(!(p instanceof THREE.DataTexture||void 0===p.image||0==p.image.width)){if(!0===p.needsUpdate){var n=p.wrapS==THREE.RepeatWrapping,r=p.wrapT==THREE.RepeatWrapping;Ga[p.id]=C.createPattern(p.image,!0===n&&!0===r?"repeat":!0===n&&!1===r?"repeat-x":!1===n&&!0===r?"repeat-y":"no-repeat");p.needsUpdate=!1}void 0===Ga[p.id]?t("rgba(0,0,0,1)"): + t(Ga[p.id]);var n=p.offset.x/p.repeat.x,r=p.offset.y/p.repeat.y,s=p.image.width*p.repeat.x,q=p.image.height*p.repeat.y,g=(g+n)*s,j=(1-j+r)*q,c=c-a,e=e-b,f=f-a,h=h-b,i=(i+n)*s-g,k=(1-k+r)*q-j,m=(m+n)*s-g,l=(1-l+r)*q-j,n=i*l-m*k;0===n?(void 0===fa[p.id]&&(b=document.createElement("canvas"),b.width=p.image.width,b.height=p.image.height,b=b.getContext("2d"),b.drawImage(p.image,0,0),fa[p.id]=b.getImageData(0,0,p.image.width,p.image.height).data),b=fa[p.id],g=4*(Math.floor(g)+Math.floor(j)*p.image.width), + V.setRGB(b[g]/255,b[g+1]/255,b[g+2]/255),d(V)):(n=1/n,p=(l*c-k*f)*n,k=(l*e-k*h)*n,c=(i*f-m*c)*n,e=(i*h-m*e)*n,a=a-p*g-c*j,g=b-k*g-e*j,C.save(),C.transform(p,k,c,e,a,g),C.fill(),C.restore())}}function f(a,b,c,d,e,f,h,g,j,i,k,m,l){var p,n;p=l.width-1;n=l.height-1;h*=p;g*=n;c-=a;d-=b;e-=a;f-=b;j=j*p-h;i=i*n-g;k=k*p-h;m=m*n-g;n=1/(j*m-k*i);p=(m*c-i*e)*n;i=(m*d-i*f)*n;c=(j*e-k*c)*n;d=(j*f-k*d)*n;a=a-p*h-c*g;b=b-i*h-d*g;C.save();C.transform(p,i,c,d,a,b);C.clip();C.drawImage(l,0,0);C.restore()}function h(a, + b,c,d){va[0]=255*a.r|0;va[1]=255*a.g|0;va[2]=255*a.b|0;va[4]=255*b.r|0;va[5]=255*b.g|0;va[6]=255*b.b|0;va[8]=255*c.r|0;va[9]=255*c.g|0;va[10]=255*c.b|0;va[12]=255*d.r|0;va[13]=255*d.g|0;va[14]=255*d.b|0;j.putImageData(Oa,0,0);Ea.drawImage(Pa,0,0);return wa}function g(a,b,c){var d=b.x-a.x,e=b.y-a.y,f=d*d+e*e;0!==f&&(c/=Math.sqrt(f),d*=c,e*=c,b.x+=d,b.y+=e,a.x-=d,a.y-=e)}function i(a){y!==a&&(y=C.globalAlpha=a)}function k(a){v!==a&&(a===THREE.NormalBlending?C.globalCompositeOperation="source-over": + a===THREE.AdditiveBlending?C.globalCompositeOperation="lighter":a===THREE.SubtractiveBlending&&(C.globalCompositeOperation="darker"),v=a)}function m(a){J!==a&&(J=C.lineWidth=a)}function l(a){ba!==a&&(ba=C.lineCap=a)}function p(a){oa!==a&&(oa=C.lineJoin=a)}function s(a){G!==a&&(G=C.strokeStyle=a)}function t(a){R!==a&&(R=C.fillStyle=a)}function n(a,b){if(pa!==a||N!==b)C.setLineDash([a,b]),pa=a,N=b}console.log("THREE.CanvasRenderer",THREE.REVISION);var r=THREE.Math.smoothstep,a=a||{},q=this,u,w,z,B= + new THREE.Projector,D=void 0!==a.canvas?a.canvas:document.createElement("canvas"),x=D.width,F=D.height,A=Math.floor(x/2),O=Math.floor(F/2),C=D.getContext("2d"),E=new THREE.Color(0),I=0,y=1,v=0,G=null,R=null,J=null,ba=null,oa=null,pa=null,N=0,M,Q,K,ca;new THREE.RenderableVertex;new THREE.RenderableVertex;var Fa,Ba,da,Aa,$,ea,V=new THREE.Color,P=new THREE.Color,Z=new THREE.Color,U=new THREE.Color,ka=new THREE.Color,ta=new THREE.Color,ia=new THREE.Color,La=new THREE.Color,Ga={},fa={},Da,Ua,Qa,xa,bb, + cb,Ma,fb,sb,pb,Ha=new THREE.Box2,la=new THREE.Box2,ra=new THREE.Box2,gb=new THREE.Color,sa=new THREE.Color,ga=new THREE.Color,ua=new THREE.Vector3,Pa,j,Oa,va,wa,Ea,Ra=16;Pa=document.createElement("canvas");Pa.width=Pa.height=2;j=Pa.getContext("2d");j.fillStyle="rgba(0,0,0,1)";j.fillRect(0,0,2,2);Oa=j.getImageData(0,0,2,2);va=Oa.data;wa=document.createElement("canvas");wa.width=wa.height=Ra;Ea=wa.getContext("2d");Ea.translate(-Ra/2,-Ra/2);Ea.scale(Ra,Ra);Ra--;void 0===C.setLineDash&&(C.setLineDash= + void 0!==C.mozDash?function(a){C.mozDash=null!==a[0]?a:null}:function(){});this.domElement=D;this.devicePixelRatio=void 0!==a.devicePixelRatio?a.devicePixelRatio:void 0!==self.devicePixelRatio?self.devicePixelRatio:1;this.sortElements=this.sortObjects=this.autoClear=!0;this.info={render:{vertices:0,faces:0}};this.supportsVertexTextures=function(){};this.setFaceCulling=function(){};this.setSize=function(a,b,c){x=a*this.devicePixelRatio;F=b*this.devicePixelRatio;A=Math.floor(x/2);O=Math.floor(F/2); + D.width=x;D.height=F;1!==this.devicePixelRatio&&!1!==c&&(D.style.width=a+"px",D.style.height=b+"px");Ha.set(new THREE.Vector2(-A,-O),new THREE.Vector2(A,O));la.set(new THREE.Vector2(-A,-O),new THREE.Vector2(A,O));y=1;v=0;oa=ba=J=R=G=null};this.setClearColor=function(a,b){E.set(a);I=void 0!==b?b:1;la.set(new THREE.Vector2(-A,-O),new THREE.Vector2(A,O))};this.setClearColorHex=function(a,b){console.warn("DEPRECATED: .setClearColorHex() is being removed. Use .setClearColor() instead.");this.setClearColor(a, + b)};this.getMaxAnisotropy=function(){return 0};this.clear=function(){C.setTransform(1,0,0,-1,A,O);!1===la.empty()&&(la.intersect(Ha),la.expandByScalar(2),1>I&&C.clearRect(la.min.x|0,la.min.y|0,la.max.x-la.min.x|0,la.max.y-la.min.y|0),0>1,ba=I.height>>1,N=F.scale.x*A,E=F.scale.y*O,x=N*R,v=E*ba,ra.min.set(y.x-x,y.y-v),ra.max.set(y.x+x,y.y+v),!1===Ha.isIntersectionBox(ra)?ra.makeEmpty():(C.save(),C.translate(y.x,y.y),C.rotate(-F.rotation), + C.scale(N,-E),C.translate(-R,-ba),C.drawImage(I,0,0),C.restore())):(N=F.object.scale.x,E=F.object.scale.y,N*=F.scale.x*A,E*=F.scale.y*O,ra.min.set(y.x-N,y.y-E),ra.max.set(y.x+N,y.y+E),!1===Ha.isIntersectionBox(ra)?ra.makeEmpty():(t(J.color.getStyle()),C.save(),C.translate(y.x,y.y),C.rotate(-F.rotation),C.scale(N,E),C.fillRect(-1,-1,2,2),C.restore())):J instanceof THREE.SpriteCanvasMaterial&&(x=F.scale.x*A,v=F.scale.y*O,ra.min.set(y.x-x,y.y-v),ra.max.set(y.x+x,y.y+v),!1===Ha.isIntersectionBox(ra)? + ra.makeEmpty():(s(J.color.getStyle()),t(J.color.getStyle()),C.save(),C.translate(y.x,y.y),C.rotate(-F.rotation),C.scale(x,v),J.program(C),C.restore()))}else if(x instanceof THREE.RenderableLine){if(Q=x.v1,K=x.v2,Q.positionScreen.x*=A,Q.positionScreen.y*=O,K.positionScreen.x*=A,K.positionScreen.y*=O,ra.setFromPoints([Q.positionScreen,K.positionScreen]),!0===Ha.isIntersectionBox(ra))if(y=Q,F=K,J=x,x=v,i(x.opacity),k(x.blending),C.beginPath(),C.moveTo(y.positionScreen.x,y.positionScreen.y),C.lineTo(F.positionScreen.x, + F.positionScreen.y),x instanceof THREE.LineBasicMaterial){m(x.linewidth);l(x.linecap);p(x.linejoin);if(x.vertexColors!==THREE.VertexColors)s(x.color.getStyle());else if(v=J.vertexColors[0].getStyle(),J=J.vertexColors[1].getStyle(),v===J)s(v);else{try{var fa=C.createLinearGradient(y.positionScreen.x,y.positionScreen.y,F.positionScreen.x,F.positionScreen.y);fa.addColorStop(0,v);fa.addColorStop(1,J)}catch(oa){fa=v}s(fa)}C.stroke();ra.expandByScalar(2*x.linewidth)}else x instanceof THREE.LineDashedMaterial&& + (m(x.linewidth),l(x.linecap),p(x.linejoin),s(x.color.getStyle()),n(x.dashSize,x.gapSize),C.stroke(),ra.expandByScalar(2*x.linewidth),n(null,null))}else if(x instanceof THREE.RenderableFace3){Q=x.v1;K=x.v2;ca=x.v3;if(-1>Q.positionScreen.z||1K.positionScreen.z||1ca.positionScreen.z||1 0\nuniform vec3 directionalLightColor[ MAX_DIR_LIGHTS ];\nuniform vec3 directionalLightDirection[ MAX_DIR_LIGHTS ];\n#endif\n#if MAX_HEMI_LIGHTS > 0\nuniform vec3 hemisphereLightSkyColor[ MAX_HEMI_LIGHTS ];\nuniform vec3 hemisphereLightGroundColor[ MAX_HEMI_LIGHTS ];\nuniform vec3 hemisphereLightDirection[ MAX_HEMI_LIGHTS ];\n#endif\n#if MAX_POINT_LIGHTS > 0\nuniform vec3 pointLightColor[ MAX_POINT_LIGHTS ];\nuniform vec3 pointLightPosition[ MAX_POINT_LIGHTS ];\nuniform float pointLightDistance[ MAX_POINT_LIGHTS ];\n#endif\n#if MAX_SPOT_LIGHTS > 0\nuniform vec3 spotLightColor[ MAX_SPOT_LIGHTS ];\nuniform vec3 spotLightPosition[ MAX_SPOT_LIGHTS ];\nuniform vec3 spotLightDirection[ MAX_SPOT_LIGHTS ];\nuniform float spotLightDistance[ MAX_SPOT_LIGHTS ];\nuniform float spotLightAngleCos[ MAX_SPOT_LIGHTS ];\nuniform float spotLightExponent[ MAX_SPOT_LIGHTS ];\n#endif\n#ifdef WRAP_AROUND\nuniform vec3 wrapRGB;\n#endif", + lights_lambert_vertex:"vLightFront = vec3( 0.0 );\n#ifdef DOUBLE_SIDED\nvLightBack = vec3( 0.0 );\n#endif\ntransformedNormal = normalize( transformedNormal );\n#if MAX_DIR_LIGHTS > 0\nfor( int i = 0; i < MAX_DIR_LIGHTS; i ++ ) {\nvec4 lDirection = viewMatrix * vec4( directionalLightDirection[ i ], 0.0 );\nvec3 dirVector = normalize( lDirection.xyz );\nfloat dotProduct = dot( transformedNormal, dirVector );\nvec3 directionalLightWeighting = vec3( max( dotProduct, 0.0 ) );\n#ifdef DOUBLE_SIDED\nvec3 directionalLightWeightingBack = vec3( max( -dotProduct, 0.0 ) );\n#ifdef WRAP_AROUND\nvec3 directionalLightWeightingHalfBack = vec3( max( -0.5 * dotProduct + 0.5, 0.0 ) );\n#endif\n#endif\n#ifdef WRAP_AROUND\nvec3 directionalLightWeightingHalf = vec3( max( 0.5 * dotProduct + 0.5, 0.0 ) );\ndirectionalLightWeighting = mix( directionalLightWeighting, directionalLightWeightingHalf, wrapRGB );\n#ifdef DOUBLE_SIDED\ndirectionalLightWeightingBack = mix( directionalLightWeightingBack, directionalLightWeightingHalfBack, wrapRGB );\n#endif\n#endif\nvLightFront += directionalLightColor[ i ] * directionalLightWeighting;\n#ifdef DOUBLE_SIDED\nvLightBack += directionalLightColor[ i ] * directionalLightWeightingBack;\n#endif\n}\n#endif\n#if MAX_POINT_LIGHTS > 0\nfor( int i = 0; i < MAX_POINT_LIGHTS; i ++ ) {\nvec4 lPosition = viewMatrix * vec4( pointLightPosition[ i ], 1.0 );\nvec3 lVector = lPosition.xyz - mvPosition.xyz;\nfloat lDistance = 1.0;\nif ( pointLightDistance[ i ] > 0.0 )\nlDistance = 1.0 - min( ( length( lVector ) / pointLightDistance[ i ] ), 1.0 );\nlVector = normalize( lVector );\nfloat dotProduct = dot( transformedNormal, lVector );\nvec3 pointLightWeighting = vec3( max( dotProduct, 0.0 ) );\n#ifdef DOUBLE_SIDED\nvec3 pointLightWeightingBack = vec3( max( -dotProduct, 0.0 ) );\n#ifdef WRAP_AROUND\nvec3 pointLightWeightingHalfBack = vec3( max( -0.5 * dotProduct + 0.5, 0.0 ) );\n#endif\n#endif\n#ifdef WRAP_AROUND\nvec3 pointLightWeightingHalf = vec3( max( 0.5 * dotProduct + 0.5, 0.0 ) );\npointLightWeighting = mix( pointLightWeighting, pointLightWeightingHalf, wrapRGB );\n#ifdef DOUBLE_SIDED\npointLightWeightingBack = mix( pointLightWeightingBack, pointLightWeightingHalfBack, wrapRGB );\n#endif\n#endif\nvLightFront += pointLightColor[ i ] * pointLightWeighting * lDistance;\n#ifdef DOUBLE_SIDED\nvLightBack += pointLightColor[ i ] * pointLightWeightingBack * lDistance;\n#endif\n}\n#endif\n#if MAX_SPOT_LIGHTS > 0\nfor( int i = 0; i < MAX_SPOT_LIGHTS; i ++ ) {\nvec4 lPosition = viewMatrix * vec4( spotLightPosition[ i ], 1.0 );\nvec3 lVector = lPosition.xyz - mvPosition.xyz;\nfloat spotEffect = dot( spotLightDirection[ i ], normalize( spotLightPosition[ i ] - worldPosition.xyz ) );\nif ( spotEffect > spotLightAngleCos[ i ] ) {\nspotEffect = max( pow( spotEffect, spotLightExponent[ i ] ), 0.0 );\nfloat lDistance = 1.0;\nif ( spotLightDistance[ i ] > 0.0 )\nlDistance = 1.0 - min( ( length( lVector ) / spotLightDistance[ i ] ), 1.0 );\nlVector = normalize( lVector );\nfloat dotProduct = dot( transformedNormal, lVector );\nvec3 spotLightWeighting = vec3( max( dotProduct, 0.0 ) );\n#ifdef DOUBLE_SIDED\nvec3 spotLightWeightingBack = vec3( max( -dotProduct, 0.0 ) );\n#ifdef WRAP_AROUND\nvec3 spotLightWeightingHalfBack = vec3( max( -0.5 * dotProduct + 0.5, 0.0 ) );\n#endif\n#endif\n#ifdef WRAP_AROUND\nvec3 spotLightWeightingHalf = vec3( max( 0.5 * dotProduct + 0.5, 0.0 ) );\nspotLightWeighting = mix( spotLightWeighting, spotLightWeightingHalf, wrapRGB );\n#ifdef DOUBLE_SIDED\nspotLightWeightingBack = mix( spotLightWeightingBack, spotLightWeightingHalfBack, wrapRGB );\n#endif\n#endif\nvLightFront += spotLightColor[ i ] * spotLightWeighting * lDistance * spotEffect;\n#ifdef DOUBLE_SIDED\nvLightBack += spotLightColor[ i ] * spotLightWeightingBack * lDistance * spotEffect;\n#endif\n}\n}\n#endif\n#if MAX_HEMI_LIGHTS > 0\nfor( int i = 0; i < MAX_HEMI_LIGHTS; i ++ ) {\nvec4 lDirection = viewMatrix * vec4( hemisphereLightDirection[ i ], 0.0 );\nvec3 lVector = normalize( lDirection.xyz );\nfloat dotProduct = dot( transformedNormal, lVector );\nfloat hemiDiffuseWeight = 0.5 * dotProduct + 0.5;\nfloat hemiDiffuseWeightBack = -0.5 * dotProduct + 0.5;\nvLightFront += mix( hemisphereLightGroundColor[ i ], hemisphereLightSkyColor[ i ], hemiDiffuseWeight );\n#ifdef DOUBLE_SIDED\nvLightBack += mix( hemisphereLightGroundColor[ i ], hemisphereLightSkyColor[ i ], hemiDiffuseWeightBack );\n#endif\n}\n#endif\nvLightFront = vLightFront * diffuse + ambient * ambientLightColor + emissive;\n#ifdef DOUBLE_SIDED\nvLightBack = vLightBack * diffuse + ambient * ambientLightColor + emissive;\n#endif", + lights_phong_pars_vertex:"#ifndef PHONG_PER_PIXEL\n#if MAX_POINT_LIGHTS > 0\nuniform vec3 pointLightPosition[ MAX_POINT_LIGHTS ];\nuniform float pointLightDistance[ MAX_POINT_LIGHTS ];\nvarying vec4 vPointLight[ MAX_POINT_LIGHTS ];\n#endif\n#if MAX_SPOT_LIGHTS > 0\nuniform vec3 spotLightPosition[ MAX_SPOT_LIGHTS ];\nuniform float spotLightDistance[ MAX_SPOT_LIGHTS ];\nvarying vec4 vSpotLight[ MAX_SPOT_LIGHTS ];\n#endif\n#endif\n#if MAX_SPOT_LIGHTS > 0 || defined( USE_BUMPMAP )\nvarying vec3 vWorldPosition;\n#endif", + lights_phong_vertex:"#ifndef PHONG_PER_PIXEL\n#if MAX_POINT_LIGHTS > 0\nfor( int i = 0; i < MAX_POINT_LIGHTS; i ++ ) {\nvec4 lPosition = viewMatrix * vec4( pointLightPosition[ i ], 1.0 );\nvec3 lVector = lPosition.xyz - mvPosition.xyz;\nfloat lDistance = 1.0;\nif ( pointLightDistance[ i ] > 0.0 )\nlDistance = 1.0 - min( ( length( lVector ) / pointLightDistance[ i ] ), 1.0 );\nvPointLight[ i ] = vec4( lVector, lDistance );\n}\n#endif\n#if MAX_SPOT_LIGHTS > 0\nfor( int i = 0; i < MAX_SPOT_LIGHTS; i ++ ) {\nvec4 lPosition = viewMatrix * vec4( spotLightPosition[ i ], 1.0 );\nvec3 lVector = lPosition.xyz - mvPosition.xyz;\nfloat lDistance = 1.0;\nif ( spotLightDistance[ i ] > 0.0 )\nlDistance = 1.0 - min( ( length( lVector ) / spotLightDistance[ i ] ), 1.0 );\nvSpotLight[ i ] = vec4( lVector, lDistance );\n}\n#endif\n#endif\n#if MAX_SPOT_LIGHTS > 0 || defined( USE_BUMPMAP )\nvWorldPosition = worldPosition.xyz;\n#endif", + lights_phong_pars_fragment:"uniform vec3 ambientLightColor;\n#if MAX_DIR_LIGHTS > 0\nuniform vec3 directionalLightColor[ MAX_DIR_LIGHTS ];\nuniform vec3 directionalLightDirection[ MAX_DIR_LIGHTS ];\n#endif\n#if MAX_HEMI_LIGHTS > 0\nuniform vec3 hemisphereLightSkyColor[ MAX_HEMI_LIGHTS ];\nuniform vec3 hemisphereLightGroundColor[ MAX_HEMI_LIGHTS ];\nuniform vec3 hemisphereLightDirection[ MAX_HEMI_LIGHTS ];\n#endif\n#if MAX_POINT_LIGHTS > 0\nuniform vec3 pointLightColor[ MAX_POINT_LIGHTS ];\n#ifdef PHONG_PER_PIXEL\nuniform vec3 pointLightPosition[ MAX_POINT_LIGHTS ];\nuniform float pointLightDistance[ MAX_POINT_LIGHTS ];\n#else\nvarying vec4 vPointLight[ MAX_POINT_LIGHTS ];\n#endif\n#endif\n#if MAX_SPOT_LIGHTS > 0\nuniform vec3 spotLightColor[ MAX_SPOT_LIGHTS ];\nuniform vec3 spotLightPosition[ MAX_SPOT_LIGHTS ];\nuniform vec3 spotLightDirection[ MAX_SPOT_LIGHTS ];\nuniform float spotLightAngleCos[ MAX_SPOT_LIGHTS ];\nuniform float spotLightExponent[ MAX_SPOT_LIGHTS ];\n#ifdef PHONG_PER_PIXEL\nuniform float spotLightDistance[ MAX_SPOT_LIGHTS ];\n#else\nvarying vec4 vSpotLight[ MAX_SPOT_LIGHTS ];\n#endif\n#endif\n#if MAX_SPOT_LIGHTS > 0 || defined( USE_BUMPMAP )\nvarying vec3 vWorldPosition;\n#endif\n#ifdef WRAP_AROUND\nuniform vec3 wrapRGB;\n#endif\nvarying vec3 vViewPosition;\nvarying vec3 vNormal;", + lights_phong_fragment:"vec3 normal = normalize( vNormal );\nvec3 viewPosition = normalize( vViewPosition );\n#ifdef DOUBLE_SIDED\nnormal = normal * ( -1.0 + 2.0 * float( gl_FrontFacing ) );\n#endif\n#ifdef USE_NORMALMAP\nnormal = perturbNormal2Arb( -vViewPosition, normal );\n#elif defined( USE_BUMPMAP )\nnormal = perturbNormalArb( -vViewPosition, normal, dHdxy_fwd() );\n#endif\n#if MAX_POINT_LIGHTS > 0\nvec3 pointDiffuse = vec3( 0.0 );\nvec3 pointSpecular = vec3( 0.0 );\nfor ( int i = 0; i < MAX_POINT_LIGHTS; i ++ ) {\n#ifdef PHONG_PER_PIXEL\nvec4 lPosition = viewMatrix * vec4( pointLightPosition[ i ], 1.0 );\nvec3 lVector = lPosition.xyz + vViewPosition.xyz;\nfloat lDistance = 1.0;\nif ( pointLightDistance[ i ] > 0.0 )\nlDistance = 1.0 - min( ( length( lVector ) / pointLightDistance[ i ] ), 1.0 );\nlVector = normalize( lVector );\n#else\nvec3 lVector = normalize( vPointLight[ i ].xyz );\nfloat lDistance = vPointLight[ i ].w;\n#endif\nfloat dotProduct = dot( normal, lVector );\n#ifdef WRAP_AROUND\nfloat pointDiffuseWeightFull = max( dotProduct, 0.0 );\nfloat pointDiffuseWeightHalf = max( 0.5 * dotProduct + 0.5, 0.0 );\nvec3 pointDiffuseWeight = mix( vec3 ( pointDiffuseWeightFull ), vec3( pointDiffuseWeightHalf ), wrapRGB );\n#else\nfloat pointDiffuseWeight = max( dotProduct, 0.0 );\n#endif\npointDiffuse += diffuse * pointLightColor[ i ] * pointDiffuseWeight * lDistance;\nvec3 pointHalfVector = normalize( lVector + viewPosition );\nfloat pointDotNormalHalf = max( dot( normal, pointHalfVector ), 0.0 );\nfloat pointSpecularWeight = specularStrength * max( pow( pointDotNormalHalf, shininess ), 0.0 );\n#ifdef PHYSICALLY_BASED_SHADING\nfloat specularNormalization = ( shininess + 2.0001 ) / 8.0;\nvec3 schlick = specular + vec3( 1.0 - specular ) * pow( 1.0 - dot( lVector, pointHalfVector ), 5.0 );\npointSpecular += schlick * pointLightColor[ i ] * pointSpecularWeight * pointDiffuseWeight * lDistance * specularNormalization;\n#else\npointSpecular += specular * pointLightColor[ i ] * pointSpecularWeight * pointDiffuseWeight * lDistance;\n#endif\n}\n#endif\n#if MAX_SPOT_LIGHTS > 0\nvec3 spotDiffuse = vec3( 0.0 );\nvec3 spotSpecular = vec3( 0.0 );\nfor ( int i = 0; i < MAX_SPOT_LIGHTS; i ++ ) {\n#ifdef PHONG_PER_PIXEL\nvec4 lPosition = viewMatrix * vec4( spotLightPosition[ i ], 1.0 );\nvec3 lVector = lPosition.xyz + vViewPosition.xyz;\nfloat lDistance = 1.0;\nif ( spotLightDistance[ i ] > 0.0 )\nlDistance = 1.0 - min( ( length( lVector ) / spotLightDistance[ i ] ), 1.0 );\nlVector = normalize( lVector );\n#else\nvec3 lVector = normalize( vSpotLight[ i ].xyz );\nfloat lDistance = vSpotLight[ i ].w;\n#endif\nfloat spotEffect = dot( spotLightDirection[ i ], normalize( spotLightPosition[ i ] - vWorldPosition ) );\nif ( spotEffect > spotLightAngleCos[ i ] ) {\nspotEffect = max( pow( spotEffect, spotLightExponent[ i ] ), 0.0 );\nfloat dotProduct = dot( normal, lVector );\n#ifdef WRAP_AROUND\nfloat spotDiffuseWeightFull = max( dotProduct, 0.0 );\nfloat spotDiffuseWeightHalf = max( 0.5 * dotProduct + 0.5, 0.0 );\nvec3 spotDiffuseWeight = mix( vec3 ( spotDiffuseWeightFull ), vec3( spotDiffuseWeightHalf ), wrapRGB );\n#else\nfloat spotDiffuseWeight = max( dotProduct, 0.0 );\n#endif\nspotDiffuse += diffuse * spotLightColor[ i ] * spotDiffuseWeight * lDistance * spotEffect;\nvec3 spotHalfVector = normalize( lVector + viewPosition );\nfloat spotDotNormalHalf = max( dot( normal, spotHalfVector ), 0.0 );\nfloat spotSpecularWeight = specularStrength * max( pow( spotDotNormalHalf, shininess ), 0.0 );\n#ifdef PHYSICALLY_BASED_SHADING\nfloat specularNormalization = ( shininess + 2.0001 ) / 8.0;\nvec3 schlick = specular + vec3( 1.0 - specular ) * pow( 1.0 - dot( lVector, spotHalfVector ), 5.0 );\nspotSpecular += schlick * spotLightColor[ i ] * spotSpecularWeight * spotDiffuseWeight * lDistance * specularNormalization * spotEffect;\n#else\nspotSpecular += specular * spotLightColor[ i ] * spotSpecularWeight * spotDiffuseWeight * lDistance * spotEffect;\n#endif\n}\n}\n#endif\n#if MAX_DIR_LIGHTS > 0\nvec3 dirDiffuse = vec3( 0.0 );\nvec3 dirSpecular = vec3( 0.0 );\nfor( int i = 0; i < MAX_DIR_LIGHTS; i ++ ) {\nvec4 lDirection = viewMatrix * vec4( directionalLightDirection[ i ], 0.0 );\nvec3 dirVector = normalize( lDirection.xyz );\nfloat dotProduct = dot( normal, dirVector );\n#ifdef WRAP_AROUND\nfloat dirDiffuseWeightFull = max( dotProduct, 0.0 );\nfloat dirDiffuseWeightHalf = max( 0.5 * dotProduct + 0.5, 0.0 );\nvec3 dirDiffuseWeight = mix( vec3( dirDiffuseWeightFull ), vec3( dirDiffuseWeightHalf ), wrapRGB );\n#else\nfloat dirDiffuseWeight = max( dotProduct, 0.0 );\n#endif\ndirDiffuse += diffuse * directionalLightColor[ i ] * dirDiffuseWeight;\nvec3 dirHalfVector = normalize( dirVector + viewPosition );\nfloat dirDotNormalHalf = max( dot( normal, dirHalfVector ), 0.0 );\nfloat dirSpecularWeight = specularStrength * max( pow( dirDotNormalHalf, shininess ), 0.0 );\n#ifdef PHYSICALLY_BASED_SHADING\nfloat specularNormalization = ( shininess + 2.0001 ) / 8.0;\nvec3 schlick = specular + vec3( 1.0 - specular ) * pow( 1.0 - dot( dirVector, dirHalfVector ), 5.0 );\ndirSpecular += schlick * directionalLightColor[ i ] * dirSpecularWeight * dirDiffuseWeight * specularNormalization;\n#else\ndirSpecular += specular * directionalLightColor[ i ] * dirSpecularWeight * dirDiffuseWeight;\n#endif\n}\n#endif\n#if MAX_HEMI_LIGHTS > 0\nvec3 hemiDiffuse = vec3( 0.0 );\nvec3 hemiSpecular = vec3( 0.0 );\nfor( int i = 0; i < MAX_HEMI_LIGHTS; i ++ ) {\nvec4 lDirection = viewMatrix * vec4( hemisphereLightDirection[ i ], 0.0 );\nvec3 lVector = normalize( lDirection.xyz );\nfloat dotProduct = dot( normal, lVector );\nfloat hemiDiffuseWeight = 0.5 * dotProduct + 0.5;\nvec3 hemiColor = mix( hemisphereLightGroundColor[ i ], hemisphereLightSkyColor[ i ], hemiDiffuseWeight );\nhemiDiffuse += diffuse * hemiColor;\nvec3 hemiHalfVectorSky = normalize( lVector + viewPosition );\nfloat hemiDotNormalHalfSky = 0.5 * dot( normal, hemiHalfVectorSky ) + 0.5;\nfloat hemiSpecularWeightSky = specularStrength * max( pow( hemiDotNormalHalfSky, shininess ), 0.0 );\nvec3 lVectorGround = -lVector;\nvec3 hemiHalfVectorGround = normalize( lVectorGround + viewPosition );\nfloat hemiDotNormalHalfGround = 0.5 * dot( normal, hemiHalfVectorGround ) + 0.5;\nfloat hemiSpecularWeightGround = specularStrength * max( pow( hemiDotNormalHalfGround, shininess ), 0.0 );\n#ifdef PHYSICALLY_BASED_SHADING\nfloat dotProductGround = dot( normal, lVectorGround );\nfloat specularNormalization = ( shininess + 2.0001 ) / 8.0;\nvec3 schlickSky = specular + vec3( 1.0 - specular ) * pow( 1.0 - dot( lVector, hemiHalfVectorSky ), 5.0 );\nvec3 schlickGround = specular + vec3( 1.0 - specular ) * pow( 1.0 - dot( lVectorGround, hemiHalfVectorGround ), 5.0 );\nhemiSpecular += hemiColor * specularNormalization * ( schlickSky * hemiSpecularWeightSky * max( dotProduct, 0.0 ) + schlickGround * hemiSpecularWeightGround * max( dotProductGround, 0.0 ) );\n#else\nhemiSpecular += specular * hemiColor * ( hemiSpecularWeightSky + hemiSpecularWeightGround ) * hemiDiffuseWeight;\n#endif\n}\n#endif\nvec3 totalDiffuse = vec3( 0.0 );\nvec3 totalSpecular = vec3( 0.0 );\n#if MAX_DIR_LIGHTS > 0\ntotalDiffuse += dirDiffuse;\ntotalSpecular += dirSpecular;\n#endif\n#if MAX_HEMI_LIGHTS > 0\ntotalDiffuse += hemiDiffuse;\ntotalSpecular += hemiSpecular;\n#endif\n#if MAX_POINT_LIGHTS > 0\ntotalDiffuse += pointDiffuse;\ntotalSpecular += pointSpecular;\n#endif\n#if MAX_SPOT_LIGHTS > 0\ntotalDiffuse += spotDiffuse;\ntotalSpecular += spotSpecular;\n#endif\n#ifdef METAL\ngl_FragColor.xyz = gl_FragColor.xyz * ( emissive + totalDiffuse + ambientLightColor * ambient + totalSpecular );\n#else\ngl_FragColor.xyz = gl_FragColor.xyz * ( emissive + totalDiffuse + ambientLightColor * ambient ) + totalSpecular;\n#endif", + color_pars_fragment:"#ifdef USE_COLOR\nvarying vec3 vColor;\n#endif",color_fragment:"#ifdef USE_COLOR\ngl_FragColor = gl_FragColor * vec4( vColor, 1.0 );\n#endif",color_pars_vertex:"#ifdef USE_COLOR\nvarying vec3 vColor;\n#endif",color_vertex:"#ifdef USE_COLOR\n#ifdef GAMMA_INPUT\nvColor = color * color;\n#else\nvColor = color;\n#endif\n#endif",skinning_pars_vertex:"#ifdef USE_SKINNING\n#ifdef BONE_TEXTURE\nuniform sampler2D boneTexture;\nuniform int boneTextureWidth;\nuniform int boneTextureHeight;\nmat4 getBoneMatrix( const in float i ) {\nfloat j = i * 4.0;\nfloat x = mod( j, float( boneTextureWidth ) );\nfloat y = floor( j / float( boneTextureWidth ) );\nfloat dx = 1.0 / float( boneTextureWidth );\nfloat dy = 1.0 / float( boneTextureHeight );\ny = dy * ( y + 0.5 );\nvec4 v1 = texture2D( boneTexture, vec2( dx * ( x + 0.5 ), y ) );\nvec4 v2 = texture2D( boneTexture, vec2( dx * ( x + 1.5 ), y ) );\nvec4 v3 = texture2D( boneTexture, vec2( dx * ( x + 2.5 ), y ) );\nvec4 v4 = texture2D( boneTexture, vec2( dx * ( x + 3.5 ), y ) );\nmat4 bone = mat4( v1, v2, v3, v4 );\nreturn bone;\n}\n#else\nuniform mat4 boneGlobalMatrices[ MAX_BONES ];\nmat4 getBoneMatrix( const in float i ) {\nmat4 bone = boneGlobalMatrices[ int(i) ];\nreturn bone;\n}\n#endif\n#endif", + skinbase_vertex:"#ifdef USE_SKINNING\nmat4 boneMatX = getBoneMatrix( skinIndex.x );\nmat4 boneMatY = getBoneMatrix( skinIndex.y );\n#endif",skinning_vertex:"#ifdef USE_SKINNING\n#ifdef USE_MORPHTARGETS\nvec4 skinVertex = vec4( morphed, 1.0 );\n#else\nvec4 skinVertex = vec4( position, 1.0 );\n#endif\nvec4 skinned = boneMatX * skinVertex * skinWeight.x;\nskinned \t += boneMatY * skinVertex * skinWeight.y;\n#endif",morphtarget_pars_vertex:"#ifdef USE_MORPHTARGETS\n#ifndef USE_MORPHNORMALS\nuniform float morphTargetInfluences[ 8 ];\n#else\nuniform float morphTargetInfluences[ 4 ];\n#endif\n#endif", + morphtarget_vertex:"#ifdef USE_MORPHTARGETS\nvec3 morphed = vec3( 0.0 );\nmorphed += ( morphTarget0 - position ) * morphTargetInfluences[ 0 ];\nmorphed += ( morphTarget1 - position ) * morphTargetInfluences[ 1 ];\nmorphed += ( morphTarget2 - position ) * morphTargetInfluences[ 2 ];\nmorphed += ( morphTarget3 - position ) * morphTargetInfluences[ 3 ];\n#ifndef USE_MORPHNORMALS\nmorphed += ( morphTarget4 - position ) * morphTargetInfluences[ 4 ];\nmorphed += ( morphTarget5 - position ) * morphTargetInfluences[ 5 ];\nmorphed += ( morphTarget6 - position ) * morphTargetInfluences[ 6 ];\nmorphed += ( morphTarget7 - position ) * morphTargetInfluences[ 7 ];\n#endif\nmorphed += position;\n#endif", + default_vertex:"vec4 mvPosition;\n#ifdef USE_SKINNING\nmvPosition = modelViewMatrix * skinned;\n#endif\n#if !defined( USE_SKINNING ) && defined( USE_MORPHTARGETS )\nmvPosition = modelViewMatrix * vec4( morphed, 1.0 );\n#endif\n#if !defined( USE_SKINNING ) && ! defined( USE_MORPHTARGETS )\nmvPosition = modelViewMatrix * vec4( position, 1.0 );\n#endif\ngl_Position = projectionMatrix * mvPosition;",morphnormal_vertex:"#ifdef USE_MORPHNORMALS\nvec3 morphedNormal = vec3( 0.0 );\nmorphedNormal += ( morphNormal0 - normal ) * morphTargetInfluences[ 0 ];\nmorphedNormal += ( morphNormal1 - normal ) * morphTargetInfluences[ 1 ];\nmorphedNormal += ( morphNormal2 - normal ) * morphTargetInfluences[ 2 ];\nmorphedNormal += ( morphNormal3 - normal ) * morphTargetInfluences[ 3 ];\nmorphedNormal += normal;\n#endif", + skinnormal_vertex:"#ifdef USE_SKINNING\nmat4 skinMatrix = skinWeight.x * boneMatX;\nskinMatrix \t+= skinWeight.y * boneMatY;\n#ifdef USE_MORPHNORMALS\nvec4 skinnedNormal = skinMatrix * vec4( morphedNormal, 0.0 );\n#else\nvec4 skinnedNormal = skinMatrix * vec4( normal, 0.0 );\n#endif\n#endif",defaultnormal_vertex:"vec3 objectNormal;\n#ifdef USE_SKINNING\nobjectNormal = skinnedNormal.xyz;\n#endif\n#if !defined( USE_SKINNING ) && defined( USE_MORPHNORMALS )\nobjectNormal = morphedNormal;\n#endif\n#if !defined( USE_SKINNING ) && ! defined( USE_MORPHNORMALS )\nobjectNormal = normal;\n#endif\n#ifdef FLIP_SIDED\nobjectNormal = -objectNormal;\n#endif\nvec3 transformedNormal = normalMatrix * objectNormal;", + shadowmap_pars_fragment:"#ifdef USE_SHADOWMAP\nuniform sampler2D shadowMap[ MAX_SHADOWS ];\nuniform vec2 shadowMapSize[ MAX_SHADOWS ];\nuniform float shadowDarkness[ MAX_SHADOWS ];\nuniform float shadowBias[ MAX_SHADOWS ];\nvarying vec4 vShadowCoord[ MAX_SHADOWS ];\nfloat unpackDepth( const in vec4 rgba_depth ) {\nconst vec4 bit_shift = vec4( 1.0 / ( 256.0 * 256.0 * 256.0 ), 1.0 / ( 256.0 * 256.0 ), 1.0 / 256.0, 1.0 );\nfloat depth = dot( rgba_depth, bit_shift );\nreturn depth;\n}\n#endif",shadowmap_fragment:"#ifdef USE_SHADOWMAP\n#ifdef SHADOWMAP_DEBUG\nvec3 frustumColors[3];\nfrustumColors[0] = vec3( 1.0, 0.5, 0.0 );\nfrustumColors[1] = vec3( 0.0, 1.0, 0.8 );\nfrustumColors[2] = vec3( 0.0, 0.5, 1.0 );\n#endif\n#ifdef SHADOWMAP_CASCADE\nint inFrustumCount = 0;\n#endif\nfloat fDepth;\nvec3 shadowColor = vec3( 1.0 );\nfor( int i = 0; i < MAX_SHADOWS; i ++ ) {\nvec3 shadowCoord = vShadowCoord[ i ].xyz / vShadowCoord[ i ].w;\nbvec4 inFrustumVec = bvec4 ( shadowCoord.x >= 0.0, shadowCoord.x <= 1.0, shadowCoord.y >= 0.0, shadowCoord.y <= 1.0 );\nbool inFrustum = all( inFrustumVec );\n#ifdef SHADOWMAP_CASCADE\ninFrustumCount += int( inFrustum );\nbvec3 frustumTestVec = bvec3( inFrustum, inFrustumCount == 1, shadowCoord.z <= 1.0 );\n#else\nbvec2 frustumTestVec = bvec2( inFrustum, shadowCoord.z <= 1.0 );\n#endif\nbool frustumTest = all( frustumTestVec );\nif ( frustumTest ) {\nshadowCoord.z += shadowBias[ i ];\n#if defined( SHADOWMAP_TYPE_PCF )\nfloat shadow = 0.0;\nconst float shadowDelta = 1.0 / 9.0;\nfloat xPixelOffset = 1.0 / shadowMapSize[ i ].x;\nfloat yPixelOffset = 1.0 / shadowMapSize[ i ].y;\nfloat dx0 = -1.25 * xPixelOffset;\nfloat dy0 = -1.25 * yPixelOffset;\nfloat dx1 = 1.25 * xPixelOffset;\nfloat dy1 = 1.25 * yPixelOffset;\nfDepth = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( dx0, dy0 ) ) );\nif ( fDepth < shadowCoord.z ) shadow += shadowDelta;\nfDepth = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( 0.0, dy0 ) ) );\nif ( fDepth < shadowCoord.z ) shadow += shadowDelta;\nfDepth = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( dx1, dy0 ) ) );\nif ( fDepth < shadowCoord.z ) shadow += shadowDelta;\nfDepth = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( dx0, 0.0 ) ) );\nif ( fDepth < shadowCoord.z ) shadow += shadowDelta;\nfDepth = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy ) );\nif ( fDepth < shadowCoord.z ) shadow += shadowDelta;\nfDepth = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( dx1, 0.0 ) ) );\nif ( fDepth < shadowCoord.z ) shadow += shadowDelta;\nfDepth = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( dx0, dy1 ) ) );\nif ( fDepth < shadowCoord.z ) shadow += shadowDelta;\nfDepth = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( 0.0, dy1 ) ) );\nif ( fDepth < shadowCoord.z ) shadow += shadowDelta;\nfDepth = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( dx1, dy1 ) ) );\nif ( fDepth < shadowCoord.z ) shadow += shadowDelta;\nshadowColor = shadowColor * vec3( ( 1.0 - shadowDarkness[ i ] * shadow ) );\n#elif defined( SHADOWMAP_TYPE_PCF_SOFT )\nfloat shadow = 0.0;\nfloat xPixelOffset = 1.0 / shadowMapSize[ i ].x;\nfloat yPixelOffset = 1.0 / shadowMapSize[ i ].y;\nfloat dx0 = -1.0 * xPixelOffset;\nfloat dy0 = -1.0 * yPixelOffset;\nfloat dx1 = 1.0 * xPixelOffset;\nfloat dy1 = 1.0 * yPixelOffset;\nmat3 shadowKernel;\nmat3 depthKernel;\ndepthKernel[0][0] = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( dx0, dy0 ) ) );\ndepthKernel[0][1] = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( dx0, 0.0 ) ) );\ndepthKernel[0][2] = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( dx0, dy1 ) ) );\ndepthKernel[1][0] = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( 0.0, dy0 ) ) );\ndepthKernel[1][1] = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy ) );\ndepthKernel[1][2] = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( 0.0, dy1 ) ) );\ndepthKernel[2][0] = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( dx1, dy0 ) ) );\ndepthKernel[2][1] = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( dx1, 0.0 ) ) );\ndepthKernel[2][2] = unpackDepth( texture2D( shadowMap[ i ], shadowCoord.xy + vec2( dx1, dy1 ) ) );\nvec3 shadowZ = vec3( shadowCoord.z );\nshadowKernel[0] = vec3(lessThan(depthKernel[0], shadowZ ));\nshadowKernel[0] *= vec3(0.25);\nshadowKernel[1] = vec3(lessThan(depthKernel[1], shadowZ ));\nshadowKernel[1] *= vec3(0.25);\nshadowKernel[2] = vec3(lessThan(depthKernel[2], shadowZ ));\nshadowKernel[2] *= vec3(0.25);\nvec2 fractionalCoord = 1.0 - fract( shadowCoord.xy * shadowMapSize[i].xy );\nshadowKernel[0] = mix( shadowKernel[1], shadowKernel[0], fractionalCoord.x );\nshadowKernel[1] = mix( shadowKernel[2], shadowKernel[1], fractionalCoord.x );\nvec4 shadowValues;\nshadowValues.x = mix( shadowKernel[0][1], shadowKernel[0][0], fractionalCoord.y );\nshadowValues.y = mix( shadowKernel[0][2], shadowKernel[0][1], fractionalCoord.y );\nshadowValues.z = mix( shadowKernel[1][1], shadowKernel[1][0], fractionalCoord.y );\nshadowValues.w = mix( shadowKernel[1][2], shadowKernel[1][1], fractionalCoord.y );\nshadow = dot( shadowValues, vec4( 1.0 ) );\nshadowColor = shadowColor * vec3( ( 1.0 - shadowDarkness[ i ] * shadow ) );\n#else\nvec4 rgbaDepth = texture2D( shadowMap[ i ], shadowCoord.xy );\nfloat fDepth = unpackDepth( rgbaDepth );\nif ( fDepth < shadowCoord.z )\nshadowColor = shadowColor * vec3( 1.0 - shadowDarkness[ i ] );\n#endif\n}\n#ifdef SHADOWMAP_DEBUG\n#ifdef SHADOWMAP_CASCADE\nif ( inFrustum && inFrustumCount == 1 ) gl_FragColor.xyz *= frustumColors[ i ];\n#else\nif ( inFrustum ) gl_FragColor.xyz *= frustumColors[ i ];\n#endif\n#endif\n}\n#ifdef GAMMA_OUTPUT\nshadowColor *= shadowColor;\n#endif\ngl_FragColor.xyz = gl_FragColor.xyz * shadowColor;\n#endif", + shadowmap_pars_vertex:"#ifdef USE_SHADOWMAP\nvarying vec4 vShadowCoord[ MAX_SHADOWS ];\nuniform mat4 shadowMatrix[ MAX_SHADOWS ];\n#endif",shadowmap_vertex:"#ifdef USE_SHADOWMAP\nfor( int i = 0; i < MAX_SHADOWS; i ++ ) {\nvShadowCoord[ i ] = shadowMatrix[ i ] * worldPosition;\n}\n#endif",alphatest_fragment:"#ifdef ALPHATEST\nif ( gl_FragColor.a < ALPHATEST ) discard;\n#endif",linear_to_gamma_fragment:"#ifdef GAMMA_OUTPUT\ngl_FragColor.xyz = sqrt( gl_FragColor.xyz );\n#endif"}; + THREE.UniformsUtils={merge:function(a){var b,c,d,e={};for(b=0;b dashSize ) {\ndiscard;\n}\ngl_FragColor = vec4( diffuse, opacity );",THREE.ShaderChunk.color_fragment,THREE.ShaderChunk.fog_fragment,"}"].join("\n")},depth:{uniforms:{mNear:{type:"f",value:1},mFar:{type:"f",value:2E3},opacity:{type:"f", + value:1}},vertexShader:"void main() {\ngl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n}",fragmentShader:"uniform float mNear;\nuniform float mFar;\nuniform float opacity;\nvoid main() {\nfloat depth = gl_FragCoord.z / gl_FragCoord.w;\nfloat color = 1.0 - smoothstep( mNear, mFar, depth );\ngl_FragColor = vec4( vec3( color ), opacity );\n}"},normal:{uniforms:{opacity:{type:"f",value:1}},vertexShader:["varying vec3 vNormal;",THREE.ShaderChunk.morphtarget_pars_vertex,"void main() {\nvNormal = normalize( normalMatrix * normal );", + THREE.ShaderChunk.morphtarget_vertex,THREE.ShaderChunk.default_vertex,"}"].join("\n"),fragmentShader:"uniform float opacity;\nvarying vec3 vNormal;\nvoid main() {\ngl_FragColor = vec4( 0.5 * normalize( vNormal ) + 0.5, opacity );\n}"},normalmap:{uniforms:THREE.UniformsUtils.merge([THREE.UniformsLib.fog,THREE.UniformsLib.lights,THREE.UniformsLib.shadowmap,{enableAO:{type:"i",value:0},enableDiffuse:{type:"i",value:0},enableSpecular:{type:"i",value:0},enableReflection:{type:"i",value:0},enableDisplacement:{type:"i", + value:0},tDisplacement:{type:"t",value:null},tDiffuse:{type:"t",value:null},tCube:{type:"t",value:null},tNormal:{type:"t",value:null},tSpecular:{type:"t",value:null},tAO:{type:"t",value:null},uNormalScale:{type:"v2",value:new THREE.Vector2(1,1)},uDisplacementBias:{type:"f",value:0},uDisplacementScale:{type:"f",value:1},uDiffuseColor:{type:"c",value:new THREE.Color(16777215)},uSpecularColor:{type:"c",value:new THREE.Color(1118481)},uAmbientColor:{type:"c",value:new THREE.Color(16777215)},uShininess:{type:"f", + value:30},uOpacity:{type:"f",value:1},useRefract:{type:"i",value:0},uRefractionRatio:{type:"f",value:0.98},uReflectivity:{type:"f",value:0.5},uOffset:{type:"v2",value:new THREE.Vector2(0,0)},uRepeat:{type:"v2",value:new THREE.Vector2(1,1)},wrapRGB:{type:"v3",value:new THREE.Vector3(1,1,1)}}]),fragmentShader:["uniform vec3 uAmbientColor;\nuniform vec3 uDiffuseColor;\nuniform vec3 uSpecularColor;\nuniform float uShininess;\nuniform float uOpacity;\nuniform bool enableDiffuse;\nuniform bool enableSpecular;\nuniform bool enableAO;\nuniform bool enableReflection;\nuniform sampler2D tDiffuse;\nuniform sampler2D tNormal;\nuniform sampler2D tSpecular;\nuniform sampler2D tAO;\nuniform samplerCube tCube;\nuniform vec2 uNormalScale;\nuniform bool useRefract;\nuniform float uRefractionRatio;\nuniform float uReflectivity;\nvarying vec3 vTangent;\nvarying vec3 vBinormal;\nvarying vec3 vNormal;\nvarying vec2 vUv;\nuniform vec3 ambientLightColor;\n#if MAX_DIR_LIGHTS > 0\nuniform vec3 directionalLightColor[ MAX_DIR_LIGHTS ];\nuniform vec3 directionalLightDirection[ MAX_DIR_LIGHTS ];\n#endif\n#if MAX_HEMI_LIGHTS > 0\nuniform vec3 hemisphereLightSkyColor[ MAX_HEMI_LIGHTS ];\nuniform vec3 hemisphereLightGroundColor[ MAX_HEMI_LIGHTS ];\nuniform vec3 hemisphereLightDirection[ MAX_HEMI_LIGHTS ];\n#endif\n#if MAX_POINT_LIGHTS > 0\nuniform vec3 pointLightColor[ MAX_POINT_LIGHTS ];\nuniform vec3 pointLightPosition[ MAX_POINT_LIGHTS ];\nuniform float pointLightDistance[ MAX_POINT_LIGHTS ];\n#endif\n#if MAX_SPOT_LIGHTS > 0\nuniform vec3 spotLightColor[ MAX_SPOT_LIGHTS ];\nuniform vec3 spotLightPosition[ MAX_SPOT_LIGHTS ];\nuniform vec3 spotLightDirection[ MAX_SPOT_LIGHTS ];\nuniform float spotLightAngleCos[ MAX_SPOT_LIGHTS ];\nuniform float spotLightExponent[ MAX_SPOT_LIGHTS ];\nuniform float spotLightDistance[ MAX_SPOT_LIGHTS ];\n#endif\n#ifdef WRAP_AROUND\nuniform vec3 wrapRGB;\n#endif\nvarying vec3 vWorldPosition;\nvarying vec3 vViewPosition;", + THREE.ShaderChunk.shadowmap_pars_fragment,THREE.ShaderChunk.fog_pars_fragment,"void main() {\ngl_FragColor = vec4( vec3( 1.0 ), uOpacity );\nvec3 specularTex = vec3( 1.0 );\nvec3 normalTex = texture2D( tNormal, vUv ).xyz * 2.0 - 1.0;\nnormalTex.xy *= uNormalScale;\nnormalTex = normalize( normalTex );\nif( enableDiffuse ) {\n#ifdef GAMMA_INPUT\nvec4 texelColor = texture2D( tDiffuse, vUv );\ntexelColor.xyz *= texelColor.xyz;\ngl_FragColor = gl_FragColor * texelColor;\n#else\ngl_FragColor = gl_FragColor * texture2D( tDiffuse, vUv );\n#endif\n}\nif( enableAO ) {\n#ifdef GAMMA_INPUT\nvec4 aoColor = texture2D( tAO, vUv );\naoColor.xyz *= aoColor.xyz;\ngl_FragColor.xyz = gl_FragColor.xyz * aoColor.xyz;\n#else\ngl_FragColor.xyz = gl_FragColor.xyz * texture2D( tAO, vUv ).xyz;\n#endif\n}\nif( enableSpecular )\nspecularTex = texture2D( tSpecular, vUv ).xyz;\nmat3 tsb = mat3( normalize( vTangent ), normalize( vBinormal ), normalize( vNormal ) );\nvec3 finalNormal = tsb * normalTex;\n#ifdef FLIP_SIDED\nfinalNormal = -finalNormal;\n#endif\nvec3 normal = normalize( finalNormal );\nvec3 viewPosition = normalize( vViewPosition );\n#if MAX_POINT_LIGHTS > 0\nvec3 pointDiffuse = vec3( 0.0 );\nvec3 pointSpecular = vec3( 0.0 );\nfor ( int i = 0; i < MAX_POINT_LIGHTS; i ++ ) {\nvec4 lPosition = viewMatrix * vec4( pointLightPosition[ i ], 1.0 );\nvec3 pointVector = lPosition.xyz + vViewPosition.xyz;\nfloat pointDistance = 1.0;\nif ( pointLightDistance[ i ] > 0.0 )\npointDistance = 1.0 - min( ( length( pointVector ) / pointLightDistance[ i ] ), 1.0 );\npointVector = normalize( pointVector );\n#ifdef WRAP_AROUND\nfloat pointDiffuseWeightFull = max( dot( normal, pointVector ), 0.0 );\nfloat pointDiffuseWeightHalf = max( 0.5 * dot( normal, pointVector ) + 0.5, 0.0 );\nvec3 pointDiffuseWeight = mix( vec3 ( pointDiffuseWeightFull ), vec3( pointDiffuseWeightHalf ), wrapRGB );\n#else\nfloat pointDiffuseWeight = max( dot( normal, pointVector ), 0.0 );\n#endif\npointDiffuse += pointDistance * pointLightColor[ i ] * uDiffuseColor * pointDiffuseWeight;\nvec3 pointHalfVector = normalize( pointVector + viewPosition );\nfloat pointDotNormalHalf = max( dot( normal, pointHalfVector ), 0.0 );\nfloat pointSpecularWeight = specularTex.r * max( pow( pointDotNormalHalf, uShininess ), 0.0 );\n#ifdef PHYSICALLY_BASED_SHADING\nfloat specularNormalization = ( uShininess + 2.0001 ) / 8.0;\nvec3 schlick = uSpecularColor + vec3( 1.0 - uSpecularColor ) * pow( 1.0 - dot( pointVector, pointHalfVector ), 5.0 );\npointSpecular += schlick * pointLightColor[ i ] * pointSpecularWeight * pointDiffuseWeight * pointDistance * specularNormalization;\n#else\npointSpecular += pointDistance * pointLightColor[ i ] * uSpecularColor * pointSpecularWeight * pointDiffuseWeight;\n#endif\n}\n#endif\n#if MAX_SPOT_LIGHTS > 0\nvec3 spotDiffuse = vec3( 0.0 );\nvec3 spotSpecular = vec3( 0.0 );\nfor ( int i = 0; i < MAX_SPOT_LIGHTS; i ++ ) {\nvec4 lPosition = viewMatrix * vec4( spotLightPosition[ i ], 1.0 );\nvec3 spotVector = lPosition.xyz + vViewPosition.xyz;\nfloat spotDistance = 1.0;\nif ( spotLightDistance[ i ] > 0.0 )\nspotDistance = 1.0 - min( ( length( spotVector ) / spotLightDistance[ i ] ), 1.0 );\nspotVector = normalize( spotVector );\nfloat spotEffect = dot( spotLightDirection[ i ], normalize( spotLightPosition[ i ] - vWorldPosition ) );\nif ( spotEffect > spotLightAngleCos[ i ] ) {\nspotEffect = max( pow( spotEffect, spotLightExponent[ i ] ), 0.0 );\n#ifdef WRAP_AROUND\nfloat spotDiffuseWeightFull = max( dot( normal, spotVector ), 0.0 );\nfloat spotDiffuseWeightHalf = max( 0.5 * dot( normal, spotVector ) + 0.5, 0.0 );\nvec3 spotDiffuseWeight = mix( vec3 ( spotDiffuseWeightFull ), vec3( spotDiffuseWeightHalf ), wrapRGB );\n#else\nfloat spotDiffuseWeight = max( dot( normal, spotVector ), 0.0 );\n#endif\nspotDiffuse += spotDistance * spotLightColor[ i ] * uDiffuseColor * spotDiffuseWeight * spotEffect;\nvec3 spotHalfVector = normalize( spotVector + viewPosition );\nfloat spotDotNormalHalf = max( dot( normal, spotHalfVector ), 0.0 );\nfloat spotSpecularWeight = specularTex.r * max( pow( spotDotNormalHalf, uShininess ), 0.0 );\n#ifdef PHYSICALLY_BASED_SHADING\nfloat specularNormalization = ( uShininess + 2.0001 ) / 8.0;\nvec3 schlick = uSpecularColor + vec3( 1.0 - uSpecularColor ) * pow( 1.0 - dot( spotVector, spotHalfVector ), 5.0 );\nspotSpecular += schlick * spotLightColor[ i ] * spotSpecularWeight * spotDiffuseWeight * spotDistance * specularNormalization * spotEffect;\n#else\nspotSpecular += spotDistance * spotLightColor[ i ] * uSpecularColor * spotSpecularWeight * spotDiffuseWeight * spotEffect;\n#endif\n}\n}\n#endif\n#if MAX_DIR_LIGHTS > 0\nvec3 dirDiffuse = vec3( 0.0 );\nvec3 dirSpecular = vec3( 0.0 );\nfor( int i = 0; i < MAX_DIR_LIGHTS; i++ ) {\nvec4 lDirection = viewMatrix * vec4( directionalLightDirection[ i ], 0.0 );\nvec3 dirVector = normalize( lDirection.xyz );\n#ifdef WRAP_AROUND\nfloat directionalLightWeightingFull = max( dot( normal, dirVector ), 0.0 );\nfloat directionalLightWeightingHalf = max( 0.5 * dot( normal, dirVector ) + 0.5, 0.0 );\nvec3 dirDiffuseWeight = mix( vec3( directionalLightWeightingFull ), vec3( directionalLightWeightingHalf ), wrapRGB );\n#else\nfloat dirDiffuseWeight = max( dot( normal, dirVector ), 0.0 );\n#endif\ndirDiffuse += directionalLightColor[ i ] * uDiffuseColor * dirDiffuseWeight;\nvec3 dirHalfVector = normalize( dirVector + viewPosition );\nfloat dirDotNormalHalf = max( dot( normal, dirHalfVector ), 0.0 );\nfloat dirSpecularWeight = specularTex.r * max( pow( dirDotNormalHalf, uShininess ), 0.0 );\n#ifdef PHYSICALLY_BASED_SHADING\nfloat specularNormalization = ( uShininess + 2.0001 ) / 8.0;\nvec3 schlick = uSpecularColor + vec3( 1.0 - uSpecularColor ) * pow( 1.0 - dot( dirVector, dirHalfVector ), 5.0 );\ndirSpecular += schlick * directionalLightColor[ i ] * dirSpecularWeight * dirDiffuseWeight * specularNormalization;\n#else\ndirSpecular += directionalLightColor[ i ] * uSpecularColor * dirSpecularWeight * dirDiffuseWeight;\n#endif\n}\n#endif\n#if MAX_HEMI_LIGHTS > 0\nvec3 hemiDiffuse = vec3( 0.0 );\nvec3 hemiSpecular = vec3( 0.0 );\nfor( int i = 0; i < MAX_HEMI_LIGHTS; i ++ ) {\nvec4 lDirection = viewMatrix * vec4( hemisphereLightDirection[ i ], 0.0 );\nvec3 lVector = normalize( lDirection.xyz );\nfloat dotProduct = dot( normal, lVector );\nfloat hemiDiffuseWeight = 0.5 * dotProduct + 0.5;\nvec3 hemiColor = mix( hemisphereLightGroundColor[ i ], hemisphereLightSkyColor[ i ], hemiDiffuseWeight );\nhemiDiffuse += uDiffuseColor * hemiColor;\nvec3 hemiHalfVectorSky = normalize( lVector + viewPosition );\nfloat hemiDotNormalHalfSky = 0.5 * dot( normal, hemiHalfVectorSky ) + 0.5;\nfloat hemiSpecularWeightSky = specularTex.r * max( pow( hemiDotNormalHalfSky, uShininess ), 0.0 );\nvec3 lVectorGround = -lVector;\nvec3 hemiHalfVectorGround = normalize( lVectorGround + viewPosition );\nfloat hemiDotNormalHalfGround = 0.5 * dot( normal, hemiHalfVectorGround ) + 0.5;\nfloat hemiSpecularWeightGround = specularTex.r * max( pow( hemiDotNormalHalfGround, uShininess ), 0.0 );\n#ifdef PHYSICALLY_BASED_SHADING\nfloat dotProductGround = dot( normal, lVectorGround );\nfloat specularNormalization = ( uShininess + 2.0001 ) / 8.0;\nvec3 schlickSky = uSpecularColor + vec3( 1.0 - uSpecularColor ) * pow( 1.0 - dot( lVector, hemiHalfVectorSky ), 5.0 );\nvec3 schlickGround = uSpecularColor + vec3( 1.0 - uSpecularColor ) * pow( 1.0 - dot( lVectorGround, hemiHalfVectorGround ), 5.0 );\nhemiSpecular += hemiColor * specularNormalization * ( schlickSky * hemiSpecularWeightSky * max( dotProduct, 0.0 ) + schlickGround * hemiSpecularWeightGround * max( dotProductGround, 0.0 ) );\n#else\nhemiSpecular += uSpecularColor * hemiColor * ( hemiSpecularWeightSky + hemiSpecularWeightGround ) * hemiDiffuseWeight;\n#endif\n}\n#endif\nvec3 totalDiffuse = vec3( 0.0 );\nvec3 totalSpecular = vec3( 0.0 );\n#if MAX_DIR_LIGHTS > 0\ntotalDiffuse += dirDiffuse;\ntotalSpecular += dirSpecular;\n#endif\n#if MAX_HEMI_LIGHTS > 0\ntotalDiffuse += hemiDiffuse;\ntotalSpecular += hemiSpecular;\n#endif\n#if MAX_POINT_LIGHTS > 0\ntotalDiffuse += pointDiffuse;\ntotalSpecular += pointSpecular;\n#endif\n#if MAX_SPOT_LIGHTS > 0\ntotalDiffuse += spotDiffuse;\ntotalSpecular += spotSpecular;\n#endif\n#ifdef METAL\ngl_FragColor.xyz = gl_FragColor.xyz * ( totalDiffuse + ambientLightColor * uAmbientColor + totalSpecular );\n#else\ngl_FragColor.xyz = gl_FragColor.xyz * ( totalDiffuse + ambientLightColor * uAmbientColor ) + totalSpecular;\n#endif\nif ( enableReflection ) {\nvec3 vReflect;\nvec3 cameraToVertex = normalize( vWorldPosition - cameraPosition );\nif ( useRefract ) {\nvReflect = refract( cameraToVertex, normal, uRefractionRatio );\n} else {\nvReflect = reflect( cameraToVertex, normal );\n}\nvec4 cubeColor = textureCube( tCube, vec3( -vReflect.x, vReflect.yz ) );\n#ifdef GAMMA_INPUT\ncubeColor.xyz *= cubeColor.xyz;\n#endif\ngl_FragColor.xyz = mix( gl_FragColor.xyz, cubeColor.xyz, specularTex.r * uReflectivity );\n}", + THREE.ShaderChunk.shadowmap_fragment,THREE.ShaderChunk.linear_to_gamma_fragment,THREE.ShaderChunk.fog_fragment,"}"].join("\n"),vertexShader:["attribute vec4 tangent;\nuniform vec2 uOffset;\nuniform vec2 uRepeat;\nuniform bool enableDisplacement;\n#ifdef VERTEX_TEXTURES\nuniform sampler2D tDisplacement;\nuniform float uDisplacementScale;\nuniform float uDisplacementBias;\n#endif\nvarying vec3 vTangent;\nvarying vec3 vBinormal;\nvarying vec3 vNormal;\nvarying vec2 vUv;\nvarying vec3 vWorldPosition;\nvarying vec3 vViewPosition;", + THREE.ShaderChunk.skinning_pars_vertex,THREE.ShaderChunk.shadowmap_pars_vertex,"void main() {",THREE.ShaderChunk.skinbase_vertex,THREE.ShaderChunk.skinnormal_vertex,"#ifdef USE_SKINNING\nvNormal = normalize( normalMatrix * skinnedNormal.xyz );\nvec4 skinnedTangent = skinMatrix * vec4( tangent.xyz, 0.0 );\nvTangent = normalize( normalMatrix * skinnedTangent.xyz );\n#else\nvNormal = normalize( normalMatrix * normal );\nvTangent = normalize( normalMatrix * tangent.xyz );\n#endif\nvBinormal = normalize( cross( vNormal, vTangent ) * tangent.w );\nvUv = uv * uRepeat + uOffset;\nvec3 displacedPosition;\n#ifdef VERTEX_TEXTURES\nif ( enableDisplacement ) {\nvec3 dv = texture2D( tDisplacement, uv ).xyz;\nfloat df = uDisplacementScale * dv.x + uDisplacementBias;\ndisplacedPosition = position + normalize( normal ) * df;\n} else {\n#ifdef USE_SKINNING\nvec4 skinVertex = vec4( position, 1.0 );\nvec4 skinned = boneMatX * skinVertex * skinWeight.x;\nskinned \t += boneMatY * skinVertex * skinWeight.y;\ndisplacedPosition = skinned.xyz;\n#else\ndisplacedPosition = position;\n#endif\n}\n#else\n#ifdef USE_SKINNING\nvec4 skinVertex = vec4( position, 1.0 );\nvec4 skinned = boneMatX * skinVertex * skinWeight.x;\nskinned \t += boneMatY * skinVertex * skinWeight.y;\ndisplacedPosition = skinned.xyz;\n#else\ndisplacedPosition = position;\n#endif\n#endif\nvec4 mvPosition = modelViewMatrix * vec4( displacedPosition, 1.0 );\nvec4 worldPosition = modelMatrix * vec4( displacedPosition, 1.0 );\ngl_Position = projectionMatrix * mvPosition;\nvWorldPosition = worldPosition.xyz;\nvViewPosition = -mvPosition.xyz;\n#ifdef USE_SHADOWMAP\nfor( int i = 0; i < MAX_SHADOWS; i ++ ) {\nvShadowCoord[ i ] = shadowMatrix[ i ] * worldPosition;\n}\n#endif\n}"].join("\n")}, + cube:{uniforms:{tCube:{type:"t",value:null},tFlip:{type:"f",value:-1}},vertexShader:"varying vec3 vWorldPosition;\nvoid main() {\nvec4 worldPosition = modelMatrix * vec4( position, 1.0 );\nvWorldPosition = worldPosition.xyz;\ngl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n}",fragmentShader:"uniform samplerCube tCube;\nuniform float tFlip;\nvarying vec3 vWorldPosition;\nvoid main() {\ngl_FragColor = textureCube( tCube, vec3( tFlip * vWorldPosition.x, vWorldPosition.yz ) );\n}"}, + depthRGBA:{uniforms:{},vertexShader:[THREE.ShaderChunk.morphtarget_pars_vertex,THREE.ShaderChunk.skinning_pars_vertex,"void main() {",THREE.ShaderChunk.skinbase_vertex,THREE.ShaderChunk.morphtarget_vertex,THREE.ShaderChunk.skinning_vertex,THREE.ShaderChunk.default_vertex,"}"].join("\n"),fragmentShader:"vec4 pack_depth( const in float depth ) {\nconst vec4 bit_shift = vec4( 256.0 * 256.0 * 256.0, 256.0 * 256.0, 256.0, 1.0 );\nconst vec4 bit_mask = vec4( 0.0, 1.0 / 256.0, 1.0 / 256.0, 1.0 / 256.0 );\nvec4 res = fract( depth * bit_shift );\nres -= res.xxyz * bit_mask;\nreturn res;\n}\nvoid main() {\ngl_FragData[ 0 ] = pack_depth( gl_FragCoord.z );\n}"}};THREE.WebGLRenderer=function(a){function b(a,b){var c=a.vertices.length,d=b.material;if(d.attributes){void 0===a.__webglCustomAttributesList&&(a.__webglCustomAttributesList=[]);for(var e in d.attributes){var f=d.attributes[e];if(!f.__webglInitialized||f.createUniqueBuffers){f.__webglInitialized=!0;var h=1;"v2"===f.type?h=2:"v3"===f.type?h=3:"v4"===f.type?h=4:"c"===f.type&&(h=3);f.size=h;f.array=new Float32Array(c*h);f.buffer=j.createBuffer();f.buffer.belongsToAttribute=e;f.needsUpdate=!0}a.__webglCustomAttributesList.push(f)}}} + function c(a,b){var c=b.geometry,h=a.faces3,g=3*h.length,i=1*h.length,k=3*h.length,h=d(b,a),m=f(h),l=e(h),p=h.vertexColors?h.vertexColors:!1;a.__vertexArray=new Float32Array(3*g);l&&(a.__normalArray=new Float32Array(3*g));c.hasTangents&&(a.__tangentArray=new Float32Array(4*g));p&&(a.__colorArray=new Float32Array(3*g));m&&(0l;l++)K.autoScaleCubemaps&&!f?(p=k,q=l,t=c.image[l],w=ac,t.width<=w&&t.height<=w||(z=Math.max(t.width,t.height),u=Math.floor(t.width*w/z),w=Math.floor(t.height*w/z),z=document.createElement("canvas"),z.width=u,z.height=w,z.getContext("2d").drawImage(t,0,0,t.width,t.height,0,0,u,w),t=z),p[q]=t):k[l]=c.image[l];l=k[0];p=0===(l.width&l.width-1)&&0===(l.height&l.height-1);q=v(c.format);t=v(c.type);E(j.TEXTURE_CUBE_MAP, + c,p);for(l=0;6>l;l++)if(f){w=k[l].mipmaps;z=0;for(y=w.length;z=Mb&&console.warn("WebGLRenderer: trying to use "+a+" texture units while this GPU supports only "+Mb);P+=1;return a}function D(a,b,c,d){a[b]=c.r*c.r*d;a[b+1]=c.g*c.g*d;a[b+2]=c.b*c.b*d}function x(a,b,c,d){a[b]=c.r*d;a[b+1]=c.g*d;a[b+2]=c.b*d}function F(a){a!==xa&&(j.lineWidth(a),xa=a)}function A(a,b,c){Da!==a&&(a?j.enable(j.POLYGON_OFFSET_FILL):j.disable(j.POLYGON_OFFSET_FILL),Da=a); + if(a&&(Ua!==b||Qa!==c))j.polygonOffset(b,c),Ua=b,Qa=c}function O(a){for(var a=a.split("\n"),b=0,c=a.length;bb;b++)j.deleteFramebuffer(a.__webglFramebuffer[b]),j.deleteRenderbuffer(a.__webglRenderbuffer[b]); + else j.deleteFramebuffer(a.__webglFramebuffer),j.deleteRenderbuffer(a.__webglRenderbuffer);K.info.memory.textures--},Fb=function(a){a=a.target;a.removeEventListener("dispose",Fb);Gb(a)},Hb=function(a){void 0!==a.__webglVertexBuffer&&j.deleteBuffer(a.__webglVertexBuffer);void 0!==a.__webglNormalBuffer&&j.deleteBuffer(a.__webglNormalBuffer);void 0!==a.__webglTangentBuffer&&j.deleteBuffer(a.__webglTangentBuffer);void 0!==a.__webglColorBuffer&&j.deleteBuffer(a.__webglColorBuffer);void 0!==a.__webglUVBuffer&& + j.deleteBuffer(a.__webglUVBuffer);void 0!==a.__webglUV2Buffer&&j.deleteBuffer(a.__webglUV2Buffer);void 0!==a.__webglSkinIndicesBuffer&&j.deleteBuffer(a.__webglSkinIndicesBuffer);void 0!==a.__webglSkinWeightsBuffer&&j.deleteBuffer(a.__webglSkinWeightsBuffer);void 0!==a.__webglFaceBuffer&&j.deleteBuffer(a.__webglFaceBuffer);void 0!==a.__webglLineBuffer&&j.deleteBuffer(a.__webglLineBuffer);void 0!==a.__webglLineDistanceBuffer&&j.deleteBuffer(a.__webglLineDistanceBuffer);if(void 0!==a.__webglCustomAttributesList)for(var b in a.__webglCustomAttributesList)j.deleteBuffer(a.__webglCustomAttributesList[b].buffer); + K.info.memory.geometries--},Gb=function(a){var b=a.program;if(void 0!==b){a.program=void 0;var c,d,e=!1,a=0;for(c=ca.length;ad.numSupportedMorphTargets?(l.sort(k),l.length=d.numSupportedMorphTargets):l.length>d.numSupportedMorphNormals?l.sort(k):0===l.length&&l.push([0,0]);for(m=0;mCa;Ca++)Ga=R[Ca],Ma[hb]=Ga.x,Ma[hb+1]=Ga.y,Ma[hb+2]=Ga.z,hb+=3;else for(Ca=0;3>Ca;Ca++)Ma[hb]=U.x,Ma[hb+1]=U.y,Ma[hb+2]=U.z,hb+=3;j.bindBuffer(j.ARRAY_BUFFER,v.__webglNormalBuffer);j.bufferData(j.ARRAY_BUFFER,Ma,D)}if(yb&&Cb&&N){E=0;for(I=aa.length;ECa;Ca++)Ea=$[Ca],bb[Oa]=Ea.x,bb[Oa+1]=Ea.y,Oa+=2;0Ca;Ca++)La=Fa[Ca],cb[Pa]=La.x,cb[Pa+1]=La.y,Pa+=2;0f;f++){a.__webglFramebuffer[f]=j.createFramebuffer();a.__webglRenderbuffer[f]=j.createRenderbuffer();j.texImage2D(j.TEXTURE_CUBE_MAP_POSITIVE_X+f,0,d,a.width,a.height,0,d,e,null);var h=a,g=j.TEXTURE_CUBE_MAP_POSITIVE_X+f;j.bindFramebuffer(j.FRAMEBUFFER,a.__webglFramebuffer[f]);j.framebufferTexture2D(j.FRAMEBUFFER,j.COLOR_ATTACHMENT0,g,h.__webglTexture,0);I(a.__webglRenderbuffer[f],a)}c&&j.generateMipmap(j.TEXTURE_CUBE_MAP)}else a.__webglFramebuffer=j.createFramebuffer(),a.__webglRenderbuffer= + a.shareDepthFrom?a.shareDepthFrom.__webglRenderbuffer:j.createRenderbuffer(),j.bindTexture(j.TEXTURE_2D,a.__webglTexture),E(j.TEXTURE_2D,a,c),j.texImage2D(j.TEXTURE_2D,0,d,a.width,a.height,0,d,e,null),d=j.TEXTURE_2D,j.bindFramebuffer(j.FRAMEBUFFER,a.__webglFramebuffer),j.framebufferTexture2D(j.FRAMEBUFFER,j.COLOR_ATTACHMENT0,d,a.__webglTexture,0),a.shareDepthFrom?a.depthBuffer&&!a.stencilBuffer?j.framebufferRenderbuffer(j.FRAMEBUFFER,j.DEPTH_ATTACHMENT,j.RENDERBUFFER,a.__webglRenderbuffer):a.depthBuffer&& + a.stencilBuffer&&j.framebufferRenderbuffer(j.FRAMEBUFFER,j.DEPTH_STENCIL_ATTACHMENT,j.RENDERBUFFER,a.__webglRenderbuffer):I(a.__webglRenderbuffer,a),c&&j.generateMipmap(j.TEXTURE_2D);b?j.bindTexture(j.TEXTURE_CUBE_MAP,null):j.bindTexture(j.TEXTURE_2D,null);j.bindRenderbuffer(j.RENDERBUFFER,null);j.bindFramebuffer(j.FRAMEBUFFER,null)}a?(b=b?a.__webglFramebuffer[a.activeCubeFace]:a.__webglFramebuffer,c=a.width,a=a.height,e=d=0):(b=null,c=Ma,a=fb,d=bb,e=cb);b!==da&&(j.bindFramebuffer(j.FRAMEBUFFER,b), + j.viewport(d,e,c,a),da=b);sb=c;pb=a};this.shadowMapPlugin=new THREE.ShadowMapPlugin;this.addPrePlugin(this.shadowMapPlugin);this.addPostPlugin(new THREE.SpritePlugin);this.addPostPlugin(new THREE.LensFlarePlugin)};THREE.WebGLRenderTarget=function(a,b,c){this.width=a;this.height=b;c=c||{};this.wrapS=void 0!==c.wrapS?c.wrapS:THREE.ClampToEdgeWrapping;this.wrapT=void 0!==c.wrapT?c.wrapT:THREE.ClampToEdgeWrapping;this.magFilter=void 0!==c.magFilter?c.magFilter:THREE.LinearFilter;this.minFilter=void 0!==c.minFilter?c.minFilter:THREE.LinearMipMapLinearFilter;this.anisotropy=void 0!==c.anisotropy?c.anisotropy:1;this.offset=new THREE.Vector2(0,0);this.repeat=new THREE.Vector2(1,1);this.format=void 0!==c.format?c.format: + THREE.RGBAFormat;this.type=void 0!==c.type?c.type:THREE.UnsignedByteType;this.depthBuffer=void 0!==c.depthBuffer?c.depthBuffer:!0;this.stencilBuffer=void 0!==c.stencilBuffer?c.stencilBuffer:!0;this.generateMipmaps=!0;this.shareDepthFrom=null}; + THREE.WebGLRenderTarget.prototype={constructor:THREE.WebGLRenderTarget,clone:function(){var a=new THREE.WebGLRenderTarget(this.width,this.height);a.wrapS=this.wrapS;a.wrapT=this.wrapT;a.magFilter=this.magFilter;a.minFilter=this.minFilter;a.anisotropy=this.anisotropy;a.offset.copy(this.offset);a.repeat.copy(this.repeat);a.format=this.format;a.type=this.type;a.depthBuffer=this.depthBuffer;a.stencilBuffer=this.stencilBuffer;a.generateMipmaps=this.generateMipmaps;a.shareDepthFrom=this.shareDepthFrom; + return a},dispose:function(){this.dispatchEvent({type:"dispose"})}};THREE.EventDispatcher.prototype.apply(THREE.WebGLRenderTarget.prototype);THREE.WebGLRenderTargetCube=function(a,b,c){THREE.WebGLRenderTarget.call(this,a,b,c);this.activeCubeFace=0};THREE.WebGLRenderTargetCube.prototype=Object.create(THREE.WebGLRenderTarget.prototype);THREE.RenderableVertex=function(){this.positionWorld=new THREE.Vector3;this.positionScreen=new THREE.Vector4;this.visible=!0};THREE.RenderableVertex.prototype.copy=function(a){this.positionWorld.copy(a.positionWorld);this.positionScreen.copy(a.positionScreen)};THREE.RenderableFace3=function(){this.id=0;this.v1=new THREE.RenderableVertex;this.v2=new THREE.RenderableVertex;this.v3=new THREE.RenderableVertex;this.centroidModel=new THREE.Vector3;this.normalModel=new THREE.Vector3;this.normalModelView=new THREE.Vector3;this.vertexNormalsLength=0;this.vertexNormalsModel=[new THREE.Vector3,new THREE.Vector3,new THREE.Vector3];this.vertexNormalsModelView=[new THREE.Vector3,new THREE.Vector3,new THREE.Vector3];this.material=this.color=null;this.uvs=[[]];this.z= + 0};THREE.RenderableObject=function(){this.id=0;this.object=null;this.z=0};THREE.RenderableSprite=function(){this.id=0;this.object=null;this.rotation=this.z=this.y=this.x=0;this.scale=new THREE.Vector2;this.material=null};THREE.RenderableLine=function(){this.id=0;this.v1=new THREE.RenderableVertex;this.v2=new THREE.RenderableVertex;this.vertexColors=[new THREE.Color,new THREE.Color];this.material=null;this.z=0};THREE.GeometryUtils={merge:function(a,b,c){var d,e,f=a.vertices.length,h=b instanceof THREE.Mesh?b.geometry:b,g=a.vertices,i=h.vertices,k=a.faces,m=h.faces,a=a.faceVertexUvs[0],h=h.faceVertexUvs[0];void 0===c&&(c=0);b instanceof THREE.Mesh&&(b.matrixAutoUpdate&&b.updateMatrix(),d=b.matrix,e=(new THREE.Matrix3).getNormalMatrix(d));for(var b=0,l=i.length;ba?b(c,e-1):k[e]>8&255,i>>16&255,i>>24&255)),d}d.mipmapCount=1;g[2]&131072&&!1!==b&&(d.mipmapCount=Math.max(1,g[7]));d.isCubemap=g[28]&512?!0:!1;d.width=g[4];d.height=g[3];for(var g=g[1]+4,f=d.width,h=d.height,i=d.isCubemap?6:1,m=0;ml-1?0:l-1,s=l+1>e-1?e-1:l+1,t=0>m-1?0:m-1,n=m+1>d-1?d-1:m+1,r=[],q=[0,0,g[4*(l*d+m)]/255*b];r.push([-1,0,g[4*(l*d+t)]/255*b]);r.push([-1,-1,g[4*(p*d+t)]/255*b]);r.push([0,-1,g[4*(p*d+m)]/255*b]);r.push([1,-1,g[4*(p*d+n)]/255*b]);r.push([1,0,g[4*(l*d+n)]/255*b]);r.push([1,1,g[4*(s*d+n)]/255*b]);r.push([0,1,g[4*(s*d+m)]/255*b]);r.push([-1,1,g[4*(s*d+t)]/255*b]);p=[];t=r.length;for(s=0;se)return null;var f=[],h=[],g=[],i,k,m;if(0=l--){console.log("Warning, unable to triangulate polygon!");break}i=k;e<=i&&(i=0);k=i+1;e<=k&&(k=0);m=k+1;e<=m&&(m=0);var p;a:{var s=p=void 0,t=void 0,n=void 0,r=void 0,q=void 0,u=void 0,w=void 0,z= + void 0,s=a[h[i]].x,t=a[h[i]].y,n=a[h[k]].x,r=a[h[k]].y,q=a[h[m]].x,u=a[h[m]].y;if(1E-10>(n-s)*(u-t)-(r-t)*(q-s))p=!1;else{var B=void 0,D=void 0,x=void 0,F=void 0,A=void 0,O=void 0,C=void 0,E=void 0,I=void 0,y=void 0,I=E=C=z=w=void 0,B=q-n,D=u-r,x=s-q,F=t-u,A=n-s,O=r-t;for(p=0;pi)h=d+1;else if(0b&&(b=0);1=b)return b=c[a]-b,a=this.curves[a],b=1-b/a.getLength(),a.getPointAt(b);a++}return null};THREE.CurvePath.prototype.getLength=function(){var a=this.getCurveLengths();return a[a.length-1]}; + THREE.CurvePath.prototype.getCurveLengths=function(){if(this.cacheLengths&&this.cacheLengths.length==this.curves.length)return this.cacheLengths;var a=[],b=0,c,d=this.curves.length;for(c=0;cb?b=g.x:g.xc?c=g.y:g.yd?d=g.z:g.zMath.abs(d.x-c[0].x)&&1E-10>Math.abs(d.y-c[0].y)&&c.splice(c.length-1,1);b&&c.push(c[0]);return c}; + THREE.Path.prototype.toShapes=function(a){var b,c,d,e,f=[],h=new THREE.Path;b=0;for(c=this.actions.length;b + g&&(g+=c.length);g%=c.length;0>h&&(h+=k.length);h%=k.length;e=0<=g-1?g-1:c.length-1;f=0<=h-1?h-1:k.length-1;n=[k[h],c[g],c[e]];n=THREE.FontUtils.Triangulate.area(n);r=[k[h],k[f],c[g]];r=THREE.FontUtils.Triangulate.area(r);l+p>n+r&&(g=s,h=m,0>g&&(g+=c.length),g%=c.length,0>h&&(h+=k.length),h%=k.length,e=0<=g-1?g-1:c.length-1,f=0<=h-1?h-1:k.length-1);l=c.slice(0,g);p=c.slice(g);s=k.slice(h);m=k.slice(0,h);f=[k[h],k[f],c[g]];t.push([k[h],c[g],c[e]]);t.push(f);c=l.concat(s).concat(m).concat(p)}return{shape:c, + isolatedPts:t,allpoints:d}},triangulateShape:function(a,b){var c=THREE.Shape.Utils.removeHoles(a,b),d=c.allpoints,e=c.isolatedPts,c=THREE.FontUtils.Triangulate(c.shape,!1),f,h,g,i,k={};f=0;for(h=d.length;fd;d++)i=g[d].x+":"+g[d].y,i=k[i],void 0!==i&&(g[d]=i)}f=0;for(h=e.length;fd;d++)i=g[d].x+":"+g[d].y,i=k[i],void 0!==i&&(g[d]=i)}return c.concat(e)}, + isClockWise:function(a){return 0>THREE.FontUtils.Triangulate.area(a)},b2p0:function(a,b){var c=1-a;return c*c*b},b2p1:function(a,b){return 2*(1-a)*a*b},b2p2:function(a,b){return a*a*b},b2:function(a,b,c,d){return this.b2p0(a,b)+this.b2p1(a,c)+this.b2p2(a,d)},b3p0:function(a,b){var c=1-a;return c*c*c*b},b3p1:function(a,b){var c=1-a;return 3*c*c*a*b},b3p2:function(a,b){return 3*(1-a)*a*a*b},b3p3:function(a,b){return a*a*a*b},b3:function(a,b,c,d,e){return this.b3p0(a,b)+this.b3p1(a,c)+this.b3p2(a,d)+ + this.b3p3(a,e)}};THREE.LineCurve=function(a,b){this.v1=a;this.v2=b};THREE.LineCurve.prototype=Object.create(THREE.Curve.prototype);THREE.LineCurve.prototype.getPoint=function(a){var b=this.v2.clone().sub(this.v1);b.multiplyScalar(a).add(this.v1);return b};THREE.LineCurve.prototype.getPointAt=function(a){return this.getPoint(a)};THREE.LineCurve.prototype.getTangent=function(){return this.v2.clone().sub(this.v1).normalize()};THREE.QuadraticBezierCurve=function(a,b,c){this.v0=a;this.v1=b;this.v2=c};THREE.QuadraticBezierCurve.prototype=Object.create(THREE.Curve.prototype);THREE.QuadraticBezierCurve.prototype.getPoint=function(a){var b;b=THREE.Shape.Utils.b2(a,this.v0.x,this.v1.x,this.v2.x);a=THREE.Shape.Utils.b2(a,this.v0.y,this.v1.y,this.v2.y);return new THREE.Vector2(b,a)}; + THREE.QuadraticBezierCurve.prototype.getTangent=function(a){var b;b=THREE.Curve.Utils.tangentQuadraticBezier(a,this.v0.x,this.v1.x,this.v2.x);a=THREE.Curve.Utils.tangentQuadraticBezier(a,this.v0.y,this.v1.y,this.v2.y);b=new THREE.Vector2(b,a);b.normalize();return b};THREE.CubicBezierCurve=function(a,b,c,d){this.v0=a;this.v1=b;this.v2=c;this.v3=d};THREE.CubicBezierCurve.prototype=Object.create(THREE.Curve.prototype);THREE.CubicBezierCurve.prototype.getPoint=function(a){var b;b=THREE.Shape.Utils.b3(a,this.v0.x,this.v1.x,this.v2.x,this.v3.x);a=THREE.Shape.Utils.b3(a,this.v0.y,this.v1.y,this.v2.y,this.v3.y);return new THREE.Vector2(b,a)}; + THREE.CubicBezierCurve.prototype.getTangent=function(a){var b;b=THREE.Curve.Utils.tangentCubicBezier(a,this.v0.x,this.v1.x,this.v2.x,this.v3.x);a=THREE.Curve.Utils.tangentCubicBezier(a,this.v0.y,this.v1.y,this.v2.y,this.v3.y);b=new THREE.Vector2(b,a);b.normalize();return b};THREE.SplineCurve=function(a){this.points=void 0==a?[]:a};THREE.SplineCurve.prototype=Object.create(THREE.Curve.prototype);THREE.SplineCurve.prototype.getPoint=function(a){var b=new THREE.Vector2,c=[],d=this.points,e;e=(d.length-1)*a;a=Math.floor(e);e-=a;c[0]=0==a?a:a-1;c[1]=a;c[2]=a>d.length-2?d.length-1:a+1;c[3]=a>d.length-3?d.length-1:a+2;b.x=THREE.Curve.Utils.interpolate(d[c[0]].x,d[c[1]].x,d[c[2]].x,d[c[3]].x,e);b.y=THREE.Curve.Utils.interpolate(d[c[0]].y,d[c[1]].y,d[c[2]].y,d[c[3]].y,e);return b};THREE.EllipseCurve=function(a,b,c,d,e,f,h){this.aX=a;this.aY=b;this.xRadius=c;this.yRadius=d;this.aStartAngle=e;this.aEndAngle=f;this.aClockwise=h};THREE.EllipseCurve.prototype=Object.create(THREE.Curve.prototype); + THREE.EllipseCurve.prototype.getPoint=function(a){var b;b=this.aEndAngle-this.aStartAngle;0>b&&(b+=2*Math.PI);b>2*Math.PI&&(b-=2*Math.PI);b=!0===this.aClockwise?this.aEndAngle+(1-a)*(2*Math.PI-b):this.aStartAngle+a*b;a=this.aX+this.xRadius*Math.cos(b);b=this.aY+this.yRadius*Math.sin(b);return new THREE.Vector2(a,b)};THREE.ArcCurve=function(a,b,c,d,e,f){THREE.EllipseCurve.call(this,a,b,c,c,d,e,f)};THREE.ArcCurve.prototype=Object.create(THREE.EllipseCurve.prototype);THREE.LineCurve3=THREE.Curve.create(function(a,b){this.v1=a;this.v2=b},function(a){var b=new THREE.Vector3;b.subVectors(this.v2,this.v1);b.multiplyScalar(a);b.add(this.v1);return b});THREE.QuadraticBezierCurve3=THREE.Curve.create(function(a,b,c){this.v0=a;this.v1=b;this.v2=c},function(a){var b,c;b=THREE.Shape.Utils.b2(a,this.v0.x,this.v1.x,this.v2.x);c=THREE.Shape.Utils.b2(a,this.v0.y,this.v1.y,this.v2.y);a=THREE.Shape.Utils.b2(a,this.v0.z,this.v1.z,this.v2.z);return new THREE.Vector3(b,c,a)});THREE.CubicBezierCurve3=THREE.Curve.create(function(a,b,c,d){this.v0=a;this.v1=b;this.v2=c;this.v3=d},function(a){var b,c;b=THREE.Shape.Utils.b3(a,this.v0.x,this.v1.x,this.v2.x,this.v3.x);c=THREE.Shape.Utils.b3(a,this.v0.y,this.v1.y,this.v2.y,this.v3.y);a=THREE.Shape.Utils.b3(a,this.v0.z,this.v1.z,this.v2.z,this.v3.z);return new THREE.Vector3(b,c,a)});THREE.SplineCurve3=THREE.Curve.create(function(a){this.points=void 0==a?[]:a},function(a){var b=new THREE.Vector3,c=[],d=this.points,e,a=(d.length-1)*a;e=Math.floor(a);a-=e;c[0]=0==e?e:e-1;c[1]=e;c[2]=e>d.length-2?d.length-1:e+1;c[3]=e>d.length-3?d.length-1:e+2;e=d[c[0]];var f=d[c[1]],h=d[c[2]],c=d[c[3]];b.x=THREE.Curve.Utils.interpolate(e.x,f.x,h.x,c.x,a);b.y=THREE.Curve.Utils.interpolate(e.y,f.y,h.y,c.y,a);b.z=THREE.Curve.Utils.interpolate(e.z,f.z,h.z,c.z,a);return b});THREE.ClosedSplineCurve3=THREE.Curve.create(function(a){this.points=void 0==a?[]:a},function(a){var b=new THREE.Vector3,c=[],d=this.points,e;e=(d.length-0)*a;a=Math.floor(e);e-=a;a+=0a.hierarchy[c].keys[d].time&& + (a.hierarchy[c].keys[d].time=0),void 0!==a.hierarchy[c].keys[d].rot&&!(a.hierarchy[c].keys[d].rot instanceof THREE.Quaternion)){var g=a.hierarchy[c].keys[d].rot;a.hierarchy[c].keys[d].rot=new THREE.Quaternion(g[0],g[1],g[2],g[3])}if(a.hierarchy[c].keys.length&&void 0!==a.hierarchy[c].keys[0].morphTargets){g={};for(d=0;ds;s++){c=b[s];h=i.prevKey[c];g=i.nextKey[c];if(g.time<=m){if(kd||1d?0:1;if("pos"===c)if(c=a.position,this.interpolationType===THREE.AnimationHandler.LINEAR)c.x=e[0]+(f[0]-e[0])*d,c.y=e[1]+(f[1]-e[1])*d,c.z=e[2]+ + (f[2]-e[2])*d;else{if(this.interpolationType===THREE.AnimationHandler.CATMULLROM||this.interpolationType===THREE.AnimationHandler.CATMULLROM_FORWARD)this.points[0]=this.getPrevKeyWith("pos",l,h.index-1).pos,this.points[1]=e,this.points[2]=f,this.points[3]=this.getNextKeyWith("pos",l,g.index+1).pos,d=0.33*d+0.33,e=this.interpolateCatmullRom(this.points,d),c.x=e[0],c.y=e[1],c.z=e[2],this.interpolationType===THREE.AnimationHandler.CATMULLROM_FORWARD&&(d=this.interpolateCatmullRom(this.points,1.01*d), + this.target.set(d[0],d[1],d[2]),this.target.sub(c),this.target.y=0,this.target.normalize(),d=Math.atan2(this.target.x,this.target.z),a.rotation.set(0,d,0))}else"rot"===c?THREE.Quaternion.slerp(e,f,a.quaternion,d):"scl"===c&&(c=a.scale,c.x=e[0]+(f[0]-e[0])*d,c.y=e[1]+(f[1]-e[1])*d,c.z=e[2]+(f[2]-e[2])*d)}}}}; + THREE.Animation.prototype.interpolateCatmullRom=function(a,b){var c=[],d=[],e,f,h,g,i,k;e=(a.length-1)*b;f=Math.floor(e);e-=f;c[0]=0===f?f:f-1;c[1]=f;c[2]=f>a.length-2?f:f+1;c[3]=f>a.length-3?f:f+2;f=a[c[0]];g=a[c[1]];i=a[c[2]];k=a[c[3]];c=e*e;h=e*c;d[0]=this.interpolate(f[0],g[0],i[0],k[0],e,c,h);d[1]=this.interpolate(f[1],g[1],i[1],k[1],e,c,h);d[2]=this.interpolate(f[2],g[2],i[2],k[2],e,c,h);return d}; + THREE.Animation.prototype.interpolate=function(a,b,c,d,e,f,h){a=0.5*(c-a);d=0.5*(d-b);return(2*(b-c)+a+d)*h+(-3*(b-c)-2*a-d)*f+a*e+b};THREE.Animation.prototype.getNextKeyWith=function(a,b,c){for(var d=this.data.hierarchy[b].keys,c=this.interpolationType===THREE.AnimationHandler.CATMULLROM||this.interpolationType===THREE.AnimationHandler.CATMULLROM_FORWARD?c=h?b.interpolate(c,h):b.interpolate(c,c.time)}this.data.hierarchy[a].node.updateMatrix();d.matrixWorldNeedsUpdate=!0}}if(this.JITCompile&&void 0===f[0][e]){this.hierarchy[0].updateMatrixWorld(!0);for(a=0;ag?(b=Math.atan2(b.y-a.y,b.x-a.x),a=Math.atan2(c.y-a.y,c.x-a.x),b>a&&(a+=2*Math.PI),c=(b+a)/2,a=-Math.cos(c),c=-Math.sin(c),new THREE.Vector2(a,c)):d.multiplyScalar(g).add(h).sub(a).clone()}function e(c,d){var e,f;for(N=c.length;0<=--N;){e=N;f=N-1;0>f&&(f=c.length-1);for(var g=0,h=s+2*m, + g=0;gMath.abs(c-i)?[new THREE.Vector2(b,1-e),new THREE.Vector2(d,1-f),new THREE.Vector2(k,1-h),new THREE.Vector2(l,1-a)]:[new THREE.Vector2(c,1-e),new THREE.Vector2(i,1-f),new THREE.Vector2(m,1-h),new THREE.Vector2(p,1-a)]}};THREE.ExtrudeGeometry.__v1=new THREE.Vector2;THREE.ExtrudeGeometry.__v2=new THREE.Vector2;THREE.ExtrudeGeometry.__v3=new THREE.Vector2;THREE.ExtrudeGeometry.__v4=new THREE.Vector2; + THREE.ExtrudeGeometry.__v5=new THREE.Vector2;THREE.ExtrudeGeometry.__v6=new THREE.Vector2;THREE.ShapeGeometry=function(a,b){THREE.Geometry.call(this);!1===a instanceof Array&&(a=[a]);this.shapebb=a[a.length-1].getBoundingBox();this.addShapeList(a,b);this.computeCentroids();this.computeFaceNormals()};THREE.ShapeGeometry.prototype=Object.create(THREE.Geometry.prototype);THREE.ShapeGeometry.prototype.addShapeList=function(a,b){for(var c=0,d=a.length;cc&&1===a.x&&(a=new THREE.Vector2(a.x-1,a.y));0===b.x&&0===b.z&&(a=new THREE.Vector2(c/2/Math.PI+0.5,a.y));return a.clone()}THREE.Geometry.call(this);for(var c=c||1,d=d||0,g=this,i=0,k=a.length;ip&&(0.2>a&&(d[0].x+=1),0.2>b&&(d[1].x+=1),0.2>m&&(d[2].x+=1));i=0;for(k=this.vertices.length;ic.y?this.quaternion.set(1,0,0,0):(a.set(c.z,0,-c.x).normalize(),b=Math.acos(c.y),this.quaternion.setFromAxisAngle(a,b))}}();THREE.ArrowHelper.prototype.setLength=function(a){this.scale.set(a,a,a)}; + THREE.ArrowHelper.prototype.setColor=function(a){this.line.material.color.setHex(a);this.cone.material.color.setHex(a)};THREE.BoxHelper=function(a){var b=[new THREE.Vector3(1,1,1),new THREE.Vector3(-1,1,1),new THREE.Vector3(-1,-1,1),new THREE.Vector3(1,-1,1),new THREE.Vector3(1,1,-1),new THREE.Vector3(-1,1,-1),new THREE.Vector3(-1,-1,-1),new THREE.Vector3(1,-1,-1)];this.vertices=b;var c=new THREE.Geometry;c.vertices.push(b[0],b[1],b[1],b[2],b[2],b[3],b[3],b[0],b[4],b[5],b[5],b[6],b[6],b[7],b[7],b[4],b[0],b[4],b[1],b[5],b[2],b[6],b[3],b[7]);THREE.Line.call(this,c,new THREE.LineBasicMaterial({color:16776960}),THREE.LinePieces); + void 0!==a&&this.update(a)};THREE.BoxHelper.prototype=Object.create(THREE.Line.prototype); + THREE.BoxHelper.prototype.update=function(a){var b=a.geometry;null===b.boundingBox&&b.computeBoundingBox();var c=b.boundingBox.min,b=b.boundingBox.max,d=this.vertices;d[0].set(b.x,b.y,b.z);d[1].set(c.x,b.y,b.z);d[2].set(c.x,c.y,b.z);d[3].set(b.x,c.y,b.z);d[4].set(b.x,b.y,c.z);d[5].set(c.x,b.y,c.z);d[6].set(c.x,c.y,c.z);d[7].set(b.x,c.y,c.z);this.geometry.computeBoundingSphere();this.geometry.verticesNeedUpdate=!0;this.matrixAutoUpdate=!1;this.matrixWorld=a.matrixWorld};THREE.BoundingBoxHelper=function(a,b){var c=b||8947848;this.object=a;this.box=new THREE.Box3;THREE.Mesh.call(this,new THREE.CubeGeometry(1,1,1),new THREE.MeshBasicMaterial({color:c,wireframe:!0}))};THREE.BoundingBoxHelper.prototype=Object.create(THREE.Mesh.prototype);THREE.BoundingBoxHelper.prototype.update=function(){this.box.setFromObject(this.object);this.box.size(this.scale);this.box.center(this.position)};THREE.CameraHelper=function(a){function b(a,b,d){c(a,d);c(b,d)}function c(a,b){d.vertices.push(new THREE.Vector3);d.colors.push(new THREE.Color(b));void 0===f[a]&&(f[a]=[]);f[a].push(d.vertices.length-1)}var d=new THREE.Geometry,e=new THREE.LineBasicMaterial({color:16777215,vertexColors:THREE.FaceColors}),f={};b("n1","n2",16755200);b("n2","n4",16755200);b("n4","n3",16755200);b("n3","n1",16755200);b("f1","f2",16755200);b("f2","f4",16755200);b("f4","f3",16755200);b("f3","f1",16755200);b("n1","f1",16755200); + b("n2","f2",16755200);b("n3","f3",16755200);b("n4","f4",16755200);b("p","n1",16711680);b("p","n2",16711680);b("p","n3",16711680);b("p","n4",16711680);b("u1","u2",43775);b("u2","u3",43775);b("u3","u1",43775);b("c","t",16777215);b("p","c",3355443);b("cn1","cn2",3355443);b("cn3","cn4",3355443);b("cf1","cf2",3355443);b("cf3","cf4",3355443);THREE.Line.call(this,d,e,THREE.LinePieces);this.camera=a;this.matrixWorld=a.matrixWorld;this.matrixAutoUpdate=!1;this.pointMap=f;this.update()}; + THREE.CameraHelper.prototype=Object.create(THREE.Line.prototype); + THREE.CameraHelper.prototype.update=function(){var a=new THREE.Vector3,b=new THREE.Camera,c=new THREE.Projector;return function(){function d(d,h,g,i){a.set(h,g,i);c.unprojectVector(a,b);d=e.pointMap[d];if(void 0!==d){h=0;for(g=d.length;hd;d++)c.faces[d].color=this.colors[4>d?0:1];d=new THREE.MeshBasicMaterial({vertexColors:THREE.FaceColors,wireframe:!0});this.lightSphere=new THREE.Mesh(c,d);this.add(this.lightSphere); + this.update()};THREE.HemisphereLightHelper.prototype=Object.create(THREE.Object3D.prototype);THREE.HemisphereLightHelper.prototype.dispose=function(){this.lightSphere.geometry.dispose();this.lightSphere.material.dispose()}; + THREE.HemisphereLightHelper.prototype.update=function(){var a=new THREE.Vector3;return function(){this.colors[0].copy(this.light.color).multiplyScalar(this.light.intensity);this.colors[1].copy(this.light.groundColor).multiplyScalar(this.light.intensity);this.lightSphere.lookAt(a.getPositionFromMatrix(this.light.matrixWorld).negate());this.lightSphere.geometry.colorsNeedUpdate=!0}}();THREE.PointLightHelper=function(a,b){this.light=a;this.light.updateMatrixWorld();var c=new THREE.SphereGeometry(b,4,2),d=new THREE.MeshBasicMaterial({wireframe:!0,fog:!1});d.color.copy(this.light.color).multiplyScalar(this.light.intensity);THREE.Mesh.call(this,c,d);this.matrixWorld=this.light.matrixWorld;this.matrixAutoUpdate=!1};THREE.PointLightHelper.prototype=Object.create(THREE.Mesh.prototype);THREE.PointLightHelper.prototype.dispose=function(){this.geometry.dispose();this.material.dispose()}; + THREE.PointLightHelper.prototype.update=function(){this.material.color.copy(this.light.color).multiplyScalar(this.light.intensity)};THREE.SpotLightHelper=function(a){THREE.Object3D.call(this);this.light=a;this.light.updateMatrixWorld();this.matrixWorld=a.matrixWorld;this.matrixAutoUpdate=!1;a=new THREE.CylinderGeometry(0,1,1,8,1,!0);a.applyMatrix((new THREE.Matrix4).makeTranslation(0,-0.5,0));a.applyMatrix((new THREE.Matrix4).makeRotationX(-Math.PI/2));var b=new THREE.MeshBasicMaterial({wireframe:!0,fog:!1});this.cone=new THREE.Mesh(a,b);this.add(this.cone);this.update()};THREE.SpotLightHelper.prototype=Object.create(THREE.Object3D.prototype); + THREE.SpotLightHelper.prototype.dispose=function(){this.cone.geometry.dispose();this.cone.material.dispose()};THREE.SpotLightHelper.prototype.update=function(){var a=new THREE.Vector3,b=new THREE.Vector3;return function(){var c=this.light.distance?this.light.distance:1E4,d=c*Math.tan(this.light.angle);this.cone.scale.set(d,d,c);a.getPositionFromMatrix(this.light.matrixWorld);b.getPositionFromMatrix(this.light.target.matrixWorld);this.cone.lookAt(b.sub(a));this.cone.material.color.copy(this.light.color).multiplyScalar(this.light.intensity)}}();THREE.VertexNormalsHelper=function(a,b,c,d){this.object=a;this.size=b||1;for(var b=c||16711680,d=d||1,c=new THREE.Geometry,a=a.geometry.faces,e=0,f=a.length;el;l++){b[0]=m[e[l]];b[1]=m[e[(l+1)%3]];b.sort(d);var p=b.toString();void 0===c[p]&&(f.vertices.push(h[b[0]]),f.vertices.push(h[b[1]]),c[p]=!0)}THREE.Line.call(this,f,new THREE.LineBasicMaterial({color:16777215}),THREE.LinePieces);this.matrixAutoUpdate=!1;this.matrixWorld=a.matrixWorld}; + THREE.WireframeHelper.prototype=Object.create(THREE.Line.prototype);THREE.ImmediateRenderObject=function(){THREE.Object3D.call(this);this.render=function(){}};THREE.ImmediateRenderObject.prototype=Object.create(THREE.Object3D.prototype);THREE.LensFlare=function(a,b,c,d,e){THREE.Object3D.call(this);this.lensFlares=[];this.positionScreen=new THREE.Vector3;this.customUpdateCallback=void 0;void 0!==a&&this.add(a,b,c,d,e)};THREE.LensFlare.prototype=Object.create(THREE.Object3D.prototype); + THREE.LensFlare.prototype.add=function(a,b,c,d,e,f){void 0===b&&(b=-1);void 0===c&&(c=0);void 0===f&&(f=1);void 0===e&&(e=new THREE.Color(16777215));void 0===d&&(d=THREE.NormalBlending);c=Math.min(c,Math.max(0,c));this.lensFlares.push({texture:a,size:b,distance:c,x:0,y:0,z:0,scale:1,rotation:1,opacity:f,color:e,blending:d})}; + THREE.LensFlare.prototype.updateLensFlares=function(){var a,b=this.lensFlares.length,c,d=2*-this.positionScreen.x,e=2*-this.positionScreen.y;for(a=0;ag.end&&(g.end=f);c||(c=i)}}for(i in d)g=d[i],this.createAnimation(i,g.start,g.end,a);this.firstAnimation=c}; + THREE.MorphBlendMesh.prototype.setAnimationDirectionForward=function(a){if(a=this.animationsMap[a])a.direction=1,a.directionBackwards=!1};THREE.MorphBlendMesh.prototype.setAnimationDirectionBackward=function(a){if(a=this.animationsMap[a])a.direction=-1,a.directionBackwards=!0};THREE.MorphBlendMesh.prototype.setAnimationFPS=function(a,b){var c=this.animationsMap[a];c&&(c.fps=b,c.duration=(c.end-c.start)/c.fps)}; + THREE.MorphBlendMesh.prototype.setAnimationDuration=function(a,b){var c=this.animationsMap[a];c&&(c.duration=b,c.fps=(c.end-c.start)/c.duration)};THREE.MorphBlendMesh.prototype.setAnimationWeight=function(a,b){var c=this.animationsMap[a];c&&(c.weight=b)};THREE.MorphBlendMesh.prototype.setAnimationTime=function(a,b){var c=this.animationsMap[a];c&&(c.time=b)};THREE.MorphBlendMesh.prototype.getAnimationTime=function(a){var b=0;if(a=this.animationsMap[a])b=a.time;return b}; + THREE.MorphBlendMesh.prototype.getAnimationDuration=function(a){var b=-1;if(a=this.animationsMap[a])b=a.duration;return b};THREE.MorphBlendMesh.prototype.playAnimation=function(a){var b=this.animationsMap[a];b?(b.time=0,b.active=!0):console.warn("animation["+a+"] undefined")};THREE.MorphBlendMesh.prototype.stopAnimation=function(a){if(a=this.animationsMap[a])a.active=!1}; + THREE.MorphBlendMesh.prototype.update=function(a){for(var b=0,c=this.animationsList.length;bd.duration||0>d.time)d.direction*=-1,d.time>d.duration&&(d.time=d.duration,d.directionBackwards=!0),0>d.time&&(d.time=0,d.directionBackwards=!1)}else d.time%=d.duration,0>d.time&&(d.time+=d.duration);var f=d.startFrame+THREE.Math.clamp(Math.floor(d.time/e),0,d.length-1),h=d.weight; + f!==d.currentFrame&&(this.morphTargetInfluences[d.lastFrame]=0,this.morphTargetInfluences[d.currentFrame]=1*h,this.morphTargetInfluences[f]=0,d.lastFrame=d.currentFrame,d.currentFrame=f);e=d.time%e/e;d.directionBackwards&&(e=1-e);this.morphTargetInfluences[d.currentFrame]=e*h;this.morphTargetInfluences[d.lastFrame]=(1-e)*h}}};THREE.LensFlarePlugin=function(){function a(a,c){var d=b.createProgram(),e=b.createShader(b.FRAGMENT_SHADER),f=b.createShader(b.VERTEX_SHADER),g="precision "+c+" float;\n";b.shaderSource(e,g+a.fragmentShader);b.shaderSource(f,g+a.vertexShader);b.compileShader(e);b.compileShader(f);b.attachShader(d,e);b.attachShader(d,f);b.linkProgram(d);return d}var b,c,d,e,f,h,g,i,k,m,l,p,s;this.init=function(t){b=t.context;c=t;d=t.getPrecision();e=new Float32Array(16);f=new Uint16Array(6);t=0;e[t++]=-1;e[t++]=-1; + e[t++]=0;e[t++]=0;e[t++]=1;e[t++]=-1;e[t++]=1;e[t++]=0;e[t++]=1;e[t++]=1;e[t++]=1;e[t++]=1;e[t++]=-1;e[t++]=1;e[t++]=0;e[t++]=1;t=0;f[t++]=0;f[t++]=1;f[t++]=2;f[t++]=0;f[t++]=2;f[t++]=3;h=b.createBuffer();g=b.createBuffer();b.bindBuffer(b.ARRAY_BUFFER,h);b.bufferData(b.ARRAY_BUFFER,e,b.STATIC_DRAW);b.bindBuffer(b.ELEMENT_ARRAY_BUFFER,g);b.bufferData(b.ELEMENT_ARRAY_BUFFER,f,b.STATIC_DRAW);i=b.createTexture();k=b.createTexture();b.bindTexture(b.TEXTURE_2D,i);b.texImage2D(b.TEXTURE_2D,0,b.RGB,16,16, + 0,b.RGB,b.UNSIGNED_BYTE,null);b.texParameteri(b.TEXTURE_2D,b.TEXTURE_WRAP_S,b.CLAMP_TO_EDGE);b.texParameteri(b.TEXTURE_2D,b.TEXTURE_WRAP_T,b.CLAMP_TO_EDGE);b.texParameteri(b.TEXTURE_2D,b.TEXTURE_MAG_FILTER,b.NEAREST);b.texParameteri(b.TEXTURE_2D,b.TEXTURE_MIN_FILTER,b.NEAREST);b.bindTexture(b.TEXTURE_2D,k);b.texImage2D(b.TEXTURE_2D,0,b.RGBA,16,16,0,b.RGBA,b.UNSIGNED_BYTE,null);b.texParameteri(b.TEXTURE_2D,b.TEXTURE_WRAP_S,b.CLAMP_TO_EDGE);b.texParameteri(b.TEXTURE_2D,b.TEXTURE_WRAP_T,b.CLAMP_TO_EDGE); + b.texParameteri(b.TEXTURE_2D,b.TEXTURE_MAG_FILTER,b.NEAREST);b.texParameteri(b.TEXTURE_2D,b.TEXTURE_MIN_FILTER,b.NEAREST);0>=b.getParameter(b.MAX_VERTEX_TEXTURE_IMAGE_UNITS)?(m=!1,l=a(THREE.ShaderFlares.lensFlare,d)):(m=!0,l=a(THREE.ShaderFlares.lensFlareVertexTexture,d));p={};s={};p.vertex=b.getAttribLocation(l,"position");p.uv=b.getAttribLocation(l,"uv");s.renderType=b.getUniformLocation(l,"renderType");s.map=b.getUniformLocation(l,"map");s.occlusionMap=b.getUniformLocation(l,"occlusionMap");s.opacity= + b.getUniformLocation(l,"opacity");s.color=b.getUniformLocation(l,"color");s.scale=b.getUniformLocation(l,"scale");s.rotation=b.getUniformLocation(l,"rotation");s.screenPosition=b.getUniformLocation(l,"screenPosition")};this.render=function(a,d,e,f){var a=a.__webglFlares,u=a.length;if(u){var w=new THREE.Vector3,z=f/e,B=0.5*e,D=0.5*f,x=16/f,F=new THREE.Vector2(x*z,x),A=new THREE.Vector3(1,1,0),O=new THREE.Vector2(1,1),C=s,x=p;b.useProgram(l);b.enableVertexAttribArray(p.vertex);b.enableVertexAttribArray(p.uv); + b.uniform1i(C.occlusionMap,0);b.uniform1i(C.map,1);b.bindBuffer(b.ARRAY_BUFFER,h);b.vertexAttribPointer(x.vertex,2,b.FLOAT,!1,16,0);b.vertexAttribPointer(x.uv,2,b.FLOAT,!1,16,8);b.bindBuffer(b.ELEMENT_ARRAY_BUFFER,g);b.disable(b.CULL_FACE);b.depthMask(!1);var E,I,y,v,G;for(E=0;EF;F++)z[F]=new THREE.Vector3,u[F]=new THREE.Vector3;z=B.shadowCascadeNearZ[w];B=B.shadowCascadeFarZ[w];u[0].set(-1,-1,z);u[1].set(1,-1,z);u[2].set(-1, + 1,z);u[3].set(1,1,z);u[4].set(-1,-1,B);u[5].set(1,-1,B);u[6].set(-1,1,B);u[7].set(1,1,B);x.originalCamera=p;u=new THREE.Gyroscope;u.position=n.shadowCascadeOffset;u.add(x);u.add(x.target);p.add(u);n.shadowCascadeArray[q]=x;console.log("Created virtualLight",x)}w=n;z=q;B=w.shadowCascadeArray[z];B.position.copy(w.position);B.target.position.copy(w.target.position);B.lookAt(B.target);B.shadowCameraVisible=w.shadowCameraVisible;B.shadowDarkness=w.shadowDarkness;B.shadowBias=w.shadowCascadeBias[z];u=w.shadowCascadeNearZ[z]; + w=w.shadowCascadeFarZ[z];B=B.pointsFrustum;B[0].z=u;B[1].z=u;B[2].z=u;B[3].z=u;B[4].z=w;B[5].z=w;B[6].z=w;B[7].z=w;D[r]=x;r++}else D[r]=n,r++;s=0;for(t=D.length;sw;w++)z=B[w],z.copy(u[w]),THREE.ShadowMapPlugin.__projector.unprojectVector(z,q),z.applyMatrix4(r.matrixWorldInverse),z.xk.x&&(k.x=z.x),z.yk.y&&(k.y=z.y),z.zk.z&& + (k.z=z.z);r.left=i.x;r.right=k.x;r.top=k.y;r.bottom=i.y;r.updateProjectionMatrix()}r=n.shadowMap;u=n.shadowMatrix;q=n.shadowCamera;q.position.getPositionFromMatrix(n.matrixWorld);m.getPositionFromMatrix(n.target.matrixWorld);q.lookAt(m);q.updateMatrixWorld();q.matrixWorldInverse.getInverse(q.matrixWorld);n.cameraHelper&&(n.cameraHelper.visible=n.shadowCameraVisible);n.shadowCameraVisible&&n.cameraHelper.update();u.set(0.5,0,0,0.5,0,0.5,0,0.5,0,0,0.5,0.5,0,0,0,1);u.multiply(q.projectionMatrix);u.multiply(q.matrixWorldInverse); + g.multiplyMatrices(q.projectionMatrix,q.matrixWorldInverse);h.setFromMatrix(g);b.setRenderTarget(r);b.clear();B=l.__webglObjects;n=0;for(r=B.length;n 0 ) {\nfloat depth = gl_FragCoord.z / gl_FragCoord.w;\nfloat fogFactor = 0.0;\nif ( fogType == 1 ) {\nfogFactor = smoothstep( fogNear, fogFar, depth );\n} else {\nconst float LOG2 = 1.442695;\nfloat fogFactor = exp2( - fogDensity * fogDensity * depth * depth * LOG2 );\nfogFactor = 1.0 - clamp( fogFactor, 0.0, 1.0 );\n}\ngl_FragColor = mix( gl_FragColor, vec4( fogColor, gl_FragColor.w ), fogFactor );\n}\n}"}}; diff --git a/docs/course_authors/source/change_log.rst b/docs/course_authors/source/change_log.rst index 1a1e3e3d71..c986a58aba 100644 --- a/docs/course_authors/source/change_log.rst +++ b/docs/course_authors/source/change_log.rst @@ -8,14 +8,7 @@ Change Log ============== ================================================================ DATE CHANGE ============== ================================================================ -7/1/2013 Online help and pdf files finalized in Sphinx 1.2b1 - -5/6/2013 Universal change to UTC for all GMT references. Changed “Add Course Catalog Information” to show URL and note that on this page, the course author sees local time from browser. Changed “Invite Students to Register” to reflect new link. - -4/18/13 “Create a Discussion” graphic change - -4/9/13 Changed order of sections (moved “Create a Lesson in Studio” after “Create a New Course”) Added content to “Export or Import a Course,”Create Lesson,” “Create Schedule,” and Upload a File to the "Files & Updates Page” sections. Revised “Add an Announcement" or Update" section. - -3/22/13 Revised “Add Manual Policy Data” section. Added “Appendix C: Time Zones” +12/05/2013 Complete revision of edX Studio documentation and integration + of edX101 content. ============== ================================================================ diff --git a/docs/course_authors/source/checking_student_progress.rst b/docs/course_authors/source/checking_student_progress.rst index 01109bb853..54c5f33cda 100644 --- a/docs/course_authors/source/checking_student_progress.rst +++ b/docs/course_authors/source/checking_student_progress.rst @@ -1,23 +1,23 @@ -************************************************** +.. _Checking Student Progress and Issuing Certificates: + +################################################### Checking Student Progress and Issuing Certificates -************************************************** +################################################### -As will be discussed more in later sections, the grading policy and stored -problem scores are used to record progress through the course, determine +The grading policy and stored problem scores are used to record progress through the course, determine final grades, and issue certificates at the end. This unit will give you some advance information about how the grading policy will be visible to the students during the run of the course and what you will need to do at the end of the course to give out grades. - -Checking Progress as a Student +.. _A Student's View: +****************************** +A Student's View ****************************** - -During the run of a course, students can check their progress by clicking on -the Progress tab of the course on Edge. (This is the same page they would go -to to view subsection problem scores, as described in Viewing Scores.) The +Students can check their progress by clicking on +the **Progress** tab in the course. The student's progress through the graded part of the course is displayed at the top of this page, above the subsection scores, as a chart with entries for all the assignments, total percentage earned in the course so far and @@ -26,17 +26,15 @@ progress through edX101. .. image:: Images/image245.png - + :width: 800 -The student will be able to see from this page that, at the time this -screenshot was taken, edX101 was graded as a Pass/Fail course with a cutoff +The student can see from this page that edX101 was graded as a Pass/Fail course with a cutoff of 34% and that the grading rubric contained one assignment type, called -Learning Sequence, consisting of 11 assignments total. Furthermore, this -picture says that this particular student has only submitted correct -responses to two assignments, and that their current total percent grade in -the course is 6%. By hovering over each progress bar, the student would be -able to get further statistics of how much each assignment was counted as. +Learning Sequence, consisting of 11 assignments total. Furthermore, this particular student has only +submitted correct responses to two assignments, and that her current total percent grade in +the course is 6%. By hovering over each progress bar, the student can +get further statistics of how much each assignment was counted as. As was mentioned in the unit on Viewing Scores, further down on the Progress @@ -46,29 +44,27 @@ down view of the example Progress page for the student in the example above: .. image:: Images/image247.png - + :width: 800 Again, note that point scores from graded sections are called "Problem Scores", while point scores from ungraded sections are called "Practice Scores". -.. raw:: latex - - \newpage % - -Checking Progress of Students as an Instructor +.. _Check Progress of Students as an Instructor: + +********************************************** +Check Progress of Students as an Instructor ********************************************** - -To check the progress of the student through the course, visit the -Instructor dashboard of your course in instructor view on Edge and click on -the Grades page. The Instructor dashboard for courses sometimes changes as +To check the progress of the student, go to the +Instructor Dashboard of your course click +the Grades page. The Instructor Dashboard for courses sometimes changes as more course-specific tools get added. Here is the current view of the top of the Grades page of the Instructor dashboard for edX101: .. image:: Images/image249.png - + :width: 800 Here you see several options for viewing or downloading student grades, viewing individual progress through a course or resetting problem attempts. @@ -84,7 +80,7 @@ viewing individual progress through a course or resetting problem attempts. .. note:: The stored scores visible to you on the Instructor tab and to - the students from the Progress tab in the course on Edge are a snapshot of the + the students from the Progress tab in the course are a snapshot of the current state of the problem score database. They may be slightly out of sync with actual problem scores. (Asynchronicities may happen if, for example, the weight of a live problem was changed during an assignment, and not @@ -92,17 +88,15 @@ viewing individual progress through a course or resetting problem attempts. are usually recomputed at the end of the semester before determining final grades and issuing Certificates. -.. raw:: latex - - \newpage % - - -Assigning Final Grades and Issuing Certificates +.. _Assign Final Grades and Issuing Certificates: + +*********************************************** +Assign Final Grades and Issuing Certificates *********************************************** The final grades of a student in the course and the grading rubric you have set are used to determine whether the student has earned a Certificate of Mastery for the course. The process for issuing Certificates has to be started manually by you or by the edX support team at the end of the -course run. For more information about issuing Certificates, see TBD. +course run. diff --git a/docs/course_authors/source/common_problems.rst b/docs/course_authors/source/common_problems.rst new file mode 100644 index 0000000000..abdbd8f97a --- /dev/null +++ b/docs/course_authors/source/common_problems.rst @@ -0,0 +1,340 @@ +.. _Common Problems: + +Common Problems +=============== + +*Common problems* are typical problems such as multiple choice problems +and other problems whose answers are simple for students to select or +enter. You can create all of these problems using the Simple Editor in +Studio. You don't have to use XML or switch to the Advanced Editor. + +The following are the common problem types in Studio: + +- :ref:`Checkbox` In checkbox problems, students select one or more options + from a list of possible answers. +- :ref:`Dropdown` In dropdown problems, students select one answer from a + dropdown list. +- :ref:`Multiple Choice` Multiple choice problems require students to + select one answer from a list of choices that appear directly below + the question. +- :ref:`Numerical Input` Numerical input problems require answers that + include only integers, fractions, and a few common constants and + operators. +- :ref:`Text Input` In text input problems, students enter a short text + answer to a question. + +These problems are easy to access in Studio. To create them, click +**Problem** under **Add New Component**, click the **Common Problem +Types** tab, and then click the name of the problem. (Note that +**Checkbox** doesn't appear in the list of common problem types. To +create a checkbox problem, you'll click **Blank Common Problem**.) + +.. _Checkbox: + +Checkbox +-------- + +In checkbox problems, the student selects one or more options from a +list of possible answers. The student must select all the options that +apply to answer the problem correctly. Each checkbox problem must have +at least one correct answer. + +.. image:: Images/CheckboxExample.gif + +Create a Checkbox Problem +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. Under **Add New Component**, click **Problem**. +#. In the **Select Problem Component Type** screen, click **Blank Common + Problem** on the **Common Problem Types** tab. +#. In the Problem component that appears, click **Edit**. +#. In the component editor, replace the default text with the text of your + problem. Enter each answer option on its own line. +#. Select all the answer options, and then click the checkbox button. + + When you do this, brackets appear next to each answer choice. + +#. Add an **x** between the brackets for the correct answer or answers. +#. In the component editor, select the text of the explanation, and then click the + explanation button to add explanation tags around the text. + + .. image:: Images/ProbCompButton_Explanation.gif + +#. On the **Settings** tab, specify the settings that you want. +#. Click **Save**. + +For the example problem above, the text in the Problem component is the +following. + +:: + + Learning about the benefits of preventative healthcare can be particularly + difficult. Check all of the reasons below why this may be the case. + + [x] A large amount of time passes between undertaking a preventative measure + and seeing the result. + [ ] Non-immunized people will always fall sick. + [x] If others are immunized, fewer people will fall sick regardless of a + particular individual's choice to get immunized or not. + [x] Trust in healthcare professionals and government officials is fragile. + + [explanation] + People who are not immunized against a disease may still not fall sick from + the disease. If someone is trying to learn whether or not preventative measures + against the disease have any impact, he or she may see these people and conclude, + since they have remained healthy despite not being immunized, that immunizations + have no effect. Consequently, he or she would tend to believe that immunization + (or other preventative measures) have fewer benefits than they actually do. + [explanation] + + +.. _Dropdown: + +Dropdown +-------- + +Dropdown problems allow the student to choose from a collection of +answer options, presented as a dropdown list. Unlike multiple choice +problems, whose answers are always visible directly below the question, +dropdown problems don't show answer choices until the student clicks +the dropdown arrow. + +.. image:: Images/DropdownExample.gif + +Create a Dropdown Problem +~~~~~~~~~~~~~~~~~~~~~~~~~ + +To create a dropdown problem, follow these steps. + +#. Under **Add New Component**, click **Problem**. +#. In the **Select Problem Component Type** screen, click + **Dropdown** on the **Common Problem Types** tab. +#. In the new Problem component that appears, click **Edit**. +#. Replace the default text with the text for your problem. Enter each of the possible + answers on the same line, separated by commas. +#. Select all the answer options, and then click the dropdown button. + + .. image:: Images/ProbCompButton_Dropdown.gif + + When you do this, a double set of brackets ([[ ]]) appears and surrounds the + answer options. + +#. Inside the brackets, surround the correct answer with parentheses. +#. In the component editor, select the text of the explanation, and then click the + explanation button to add explanation tags around the text. + + .. image:: Images/ProbCompButton_Explanation.gif + +#. On the **Settings** tab, specify the settings that you want. +#. Click **Save**. + +For the example problem above, the text in the Problem component is the +following. + +:: + + What type of data are the following? + + Age: + [[Nominal, Discrete, (Continuous)]] + Age, rounded to the nearest year: + [[Nominal, (Discrete), Continuous]] + Life stage - infant, child, and adult: + [[(Nominal), Discrete, Continuous]] + + +.. _Multiple Choice: + +Multiple Choice +--------------- + +In multiple choice problems, students select one option from a list of +answer options. Unlike with dropdown problems, whose answer choices +don't appear until the student clicks the drop-down arrow, answer +choices for multiple choice problems are always visible directly below +the question. + +.. image:: Images/MultipleChoiceExample.gif + +Create a Multiple Choice Problem +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. Under **Add New Component**, click **Problem**. +#. In the **Select Problem Component Type** screen, click **Multiple + Choice** on the **Common Problem Types** tab. +#. When the new Problem component appears, click **Edit**. +#. In the component editor, replace the sample problem text with the text of your + problem. Enter each answer option on its own line. +#. Select all the answer options, and then click the multiple choice button. + + .. image:: Images/ProbCompButton_MultChoice.gif + + When you do this, the component editor adds a pair of parentheses next to each + possible answer. + +#. Add an "x" between the parentheses next to the correct answer. + +#. In the component editor, select the text of the explanation, and then click the + explanation button to add explanation tags around the text. + + .. image:: Images/ProbCompButton_Explanation.gif + +#. On the **Settings** tab, specify the settings that you want. +#. Click **Save**. + +For the example problem above, the text in the Problem component is the +following. + +:: + + Lateral inhibition, as was first discovered in the horsehoe crab: + + ( ) is a property of touch sensation, referring to the ability of crabs to + detect nearby predators. + ( ) is a property of hearing, referring to the ability of crabs to detect + low frequency noises. + (x) is a property of vision, referring to the ability of crabs eyes to + enhance contrasts. + ( ) has to do with the ability of crabs to use sonar to detect fellow horseshoe + crabs nearby. + ( ) has to do with a weighting system in the crabs skeleton that allows it to + balance in turbulent water. + + [Explanation] + Horseshoe crabs were essential to the discovery of lateral inhibition, a property of + vision present in horseshoe crabs as well as humans, that enables enhancement of + contrast at edges of objects as was demonstrated in class. In 1967, Haldan Hartline + received the Nobel prize for his research on vision and in particular his research + investigating lateral inhibition using horseshoe crabs. + [Explanation] + +.. _Numerical Input: + +Numerical Input +--------------- + +In numerical input problems, students enter numbers or specific and +relatively simple mathematical expressions to answer a question. + +.. image:: Images/NumericalInputExample.gif + +Note that students' responses don't have to be exact for these problems. You can +specify a margin of error. For more information, see the instructions below. + +Responses for numerical input problems can include integers, fractions, +and constants such as *pi* and *g*. Responses can also include text +representing common functions, such as square root (sqrt) and log base 2 +(log2), as well as trigonometric functions and their inverses, such as +sine (sin) and arcsine (arcsin). For these functions, Studio changes the +text that the student enters into mathematical symbols. The following +example shows the way Studio renders students' text responses in +numerical input problems. To see more examples, scroll down to **Examples**. + +.. image:: Images/Math5.gif + +Create a Numerical Input Problem +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. Under **Add New Component**, click **Problem**. +#. In the **Select Problem Component Type** screen, click **Numerical + Input** on the **Common Problem Types** tab. +#. When the new Problem component appears, click **Edit**. +#. In the component editor, replace the sample problem text with your own text. + +#. Select the text of the answer, and then click the numerical input button. + + .. image:: Images/ProbCompButton_NumInput.gif + + When you do this, an equal sign appears next to the answer. + +#. (Optional) If you want to include a margin of error, add **+-NUMBER** after the answer. For + example, if you want to include a 2% margin of error, add **+-2%**. + +#. In the component editor, select the text of the explanation, and then click the + explanation button to add explanation tags around the text. + + .. image:: Images/ProbCompButton_Explanation.gif + +#. On the **Settings** tab, specify the settings that you want. +#. Click **Save**. + +For the example problem above, the text in the Problem component is the +following. + +:: + + How many different countries do edX students live in as of May 2013? + + = 193 +- 5% + + [explanation] + As of edX's first birthday, in May 2013, edX students live in 193 different countries. + [explanation] + +**Examples** + +The following are a few more examples of the way that Studio renders numerical input +text that students enter. + +.. image:: Images/Math1.gif +.. image:: Images/Math2.gif +.. image:: Images/Math3.gif +.. image:: Images/Math4.gif + +For more information, see `Formula Equation Input +`_. + +.. _Text input: + +Text Input +---------- + +In text input problems, students enter text into a response field. The +response can include numbers, letters, and special characters such as +punctuation marks. Because the text that the student enters must match +the instructor's specified answer exactly, including spelling and +punctuation, we recommend that you specify more than one attempt for +text input problems to allow for typographical errors. + +.. image:: Images/TextInputExample.gif + +Create a Text Input Problem +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To create a text input problem, follow these steps. + +#. Under **Add New Component**, click **Problem**. +#. In the **Select Problem Component Type** screen, click **Text Input** + on the **Common Problem Types** tab. +#. In the new Problem component that appears, click **Edit**. +#. Replace the default text with the text for your problem. +#. Select the text of the answer, and then click the text input button. + + .. image:: Images/ProbCompButton_TextInput.gif + + When you do this, an equal sign appears next to the answer. + + +#. In the component editor, select the text of the explanation, and then click the + explanation button to add explanation tags around the text. + + .. image:: Images/ProbCompButton_Explanation.gif + +#. On the **Settings** tab, specify the settings that you want. +#. Click **Save**. + +For the example problem above, the text in the Problem component is the +following. + +:: + + What is the technical term that refers to the fact that, when enough people + sleep under a bednet, the disease may altogether disappear? + = herd immunity + + [explanation] + The correct answer is herd immunity. As more and more people use bednets, + the risk of malaria begins to fall for everyone – users and non-users alike. + This can fall to such a low probability that malaria is effectively eradicated + from the group (even when the group does not have 100% bednet coverage). + [explanation] \ No newline at end of file diff --git a/docs/course_authors/source/conf.py b/docs/course_authors/source/conf.py index 90a78f48fd..978a2c0f75 100644 --- a/docs/course_authors/source/conf.py +++ b/docs/course_authors/source/conf.py @@ -23,3 +23,11 @@ templates_path.append('source/_templates') html_static_path.append('source/_static') +# General information about the project. +project = u'Building a Course with edX Studio' +copyright = u'2013, edX Documentation Team' + +# The short X.Y version. +version = '' +# The full version, including alpha/beta/rc tags. +release = '' \ No newline at end of file diff --git a/docs/course_authors/source/create_discussion.rst b/docs/course_authors/source/create_discussion.rst index 3fd7cd63ca..566ac1656b 100644 --- a/docs/course_authors/source/create_discussion.rst +++ b/docs/course_authors/source/create_discussion.rst @@ -1,96 +1,86 @@ +.. _Working with Discussion Components: -******************* -Create a Discussion +################################### +Working with Discussion Components +################################### + +******************* +Overview ******************* -To create a discussion in your course, you create a question and Discussion -component in Studio. You can then encourage the students to respond by seeding -the discussion space on edX or Edge. +You can add a Discussion component to a Unit, to pose a question related to the Unit and give students a chance to respond and interact. + +* :ref:`Create a Discussion Component` +* :ref:`A Student's View of the Discussion` +* :ref:`Seed a Discussion Space in Your Course` +Before you add a Discussion component, it is generally a good idea to add an HTML component that +introduces the topic to be discussed. The Discussion component itself does not contain any text and may be easy for students to overlook. + +.. _Create a Discussion Component: + +***************************** Create a Discussion Component ***************************** -Keep in mind the following best practices when you create a Discussion -component. +.. note:: Before you create a Discussion component, consider that Discussion categories are immediately visible in your forum (on the Discussion tab for your course) when you create them, even though the unit that contains the Discussion component is set to Private. -• Be very sure that you want to add the Discussion component. Discussion -• categories are immediately visible in your forum (on the Discussion tab for -• your course) when you create them, even if the unit that contains the -• Discussion component is set to Private -• When you create an ID for the Discussion component, be very careful—especially -• if you are adding the Discussion component to a running course. Follow the -• format in step 10 below to make sure that the ID is unique across all courses -• on edX. +To create a new HTML component in an existing Unit, ensure the Unit is Private. +For more information on Public and Private Units, see LINK. -• Edit only the fields at the top of the Discussion component edit box. Do not -• change the XML in the large box. +#. Under **Add New Component**, click the **discussion** icon. -To add a Discussion component: - -1. Open Studio. - -2. Make a note of the **Display Name** of the Subsection you are in and the -**Display Name** of the Unit you are in. - -3. At the location in the Unit where you want to start your discussion :doc:`create_html_component` -that contains the question you want students to discuss. - -4. Directly under this new HTML component, click **Discussion** under **Add New -Component.** - -.. image:: Images/image057.png - -5. When the following box appears, click **Discussion Tag.** - -.. image:: Images/image059.png - -6. When the following box appears, click **Edit.** - -.. image:: Images/image061.png - -The following editing box opens. You will change the values in the small boxes, -but you will not change the text in the large box. - -.. image:: Images/image063.png - -.. note:: - - In the future, these boxes may be filled in for you with a default value. - -7. In the **discussion_category** box, type the name of the category that you -want to create for the discussion. You can include spaces. - -8. In the **discussion_target** box, type the name of the subcategory that you -want to create for the discussion. You can include spaces. - -.. note:: - The category and subcategory names only appear in the discussion forum for - your course. They do not appear in the discussion space inside the Unit. - -For example, if you set **discussion_category** to be “The Discussion Component” -and you set **discussion_target** to be “Online Instruction Methods,” the -category and subcategory appear as follow in the category list in the discussion -forum: - -.. image:: Images/image065.png - :width: 300 - -9. In the **display_name** box, type a name for the discussion. The display name -appears when a student hovers the mouse over the ribbon. - -10. Click **Save.** - -.. raw:: latex + .. image:: Images/NewComponent_Discussion.png - \newpage % + The Discussion component is added: + + .. image:: Images/EditDiscussionComponent.png +#. In the Discussion component, click **Edit**. + + The Discussion component editor opens. + + .. image:: Images/DiscussionComponentEditor.png + +#. Follow the guidelines in the editor to fill in the **Discussion Category**, **Display Name**, and **Subcategory**. + +#. Click **Save**. + +.. _A Student's View of the Discussion: + +********************************** +A Student's View of the Discussion +********************************** + +For students, Discussion component names appear in the course ribbon at the top of the page: + +.. image:: Images/DiscussionComponent_LMS_Ribbon.png + +The Discussion space appears under other components in the unit. +It doesn't have a label in the body of the unit. +Instead, students see "Show discussion" or "Hide discussion" on the left, +and a blue **New Post** button on the right. + +In the following example, the Discussion component follows Video and HTML components: + +.. image:: Images/DiscussionComponent_LMS.png + +In the **Discussion** tab at the top of the page, +students can find the category and subcategory of the discussion in the left pane. + +.. image:: Images/DiscussionComponent_Forum.png + + +.. _Seed a Discussion Space in Your Course: + +************************************** Seed a Discussion Space in Your Course ************************************** -When you create a discussion, many students may feel hesitant to be the first to +Many students may feel hesitant to be the first to post an answer to your question. You can get the discussion started by posting your own answer—preferably anonymously or as a student, so that students will be more comfortable replying if they disagree with your post. @@ -98,24 +88,21 @@ more comfortable replying if they disagree with your post. To post as a student, follow the steps below. If you later want to reply as yourself, log back into your usual account and omit steps 1 and 2. -1. Set up a test account on edX or Edge with an e-mail address that is not -associated with your Course Team. +#. Set up a test account on with an e-mail address that is not associated with your Course Team. -2. Go to your course URL and register for your course. +#. Go to your course URL and register for your course. -3. On edX or Edge, locate the Unit that contains the Discussion component. + a. Locate the Unit that contains the Discussion component. -4. In the Unit, locate the discussion space. + b. In the Unit, locate the discussion space. -5. Click **New post.** + c. Click **New post.** -6. Type a title for your post in the Title box, and then enter text for your -post. +3. Type a title for your post in the Title field, and then enter text for your post. -7. If you want to, select the **post anonymously** check box or the **follow -this post** check box. +#. If you want to, select the **post anonymously** check box or the **follow this post** check box. -8. When you are satisfied with your post, click **Add Post.** +#. When you are satisfied with your post, click **Add Post.** Your new post appears at the top of list in the unit. Posts are listed in reverse chronological order. diff --git a/docs/course_authors/source/create_html_component.rst b/docs/course_authors/source/create_html_component.rst index 818873a528..fba6a044ba 100644 --- a/docs/course_authors/source/create_html_component.rst +++ b/docs/course_authors/source/create_html_component.rst @@ -1,249 +1,236 @@ +.. _Working with HTML Components: -************************ + +############################# +Working with HTML Components +############################# + +******************* +Overview +******************* + +You use an HTML component to add and format text for your course. +You can add text, lists, links and images in an HTML component. + +* :ref:`Create an HTML Component` +* :ref:`Work with the Visual and HTML Editors` +* :ref:`Use the Announcement Template` +* :ref:`Import Content from LaTex` +* :ref:`Add a Link in an HTML Component` +* :ref:`Add an Image to an HTML Component` + +.. note:: Ensure you understand the chapter :ref:`Organizing Your Course Content` before working with HTML components. + + +.. _Create an HTML Component: + +***************************** Create an HTML Component -************************ - - .. image:: Images/image067.png - -The HTML component is the most basic component type. These components are the -building blocks of text-based courses. They are used to add information such as -text, lists, links, and images to units. For example, you can use these -components between Problem components to add explanatory text. You can also use -HTML components to import LaTeX code into your course. - -The HTML component editor has two views: **Visual view** and **HTML view.** -Visual view offers you a “what you see is what you get” (WYSIWYG) editor for -editing a pre-formatted version of the text. HTML view gives you a text editor -in which you can edit HTML code directly. - -.. note:: - - Studio processes the HTML code entered when saving it and before rendering - it. Make sure that the component you created looks the way you expect live if - you go back and forth between Visual and HTML view. - -.. raw:: latex - - \newpage % - -Create a Basic HTML Component ***************************** -**To create a basic, blank HTML component:** +To create a new HTML component in an existing Unit, ensure the Unit is Private. +For more information on Public and Private Units, see :ref:`Public and Private Units`. -1. Under Add New Component, click **html**, and then click **Empty.** The -following blank component appears. +#. Under **Add New Component**, click the **html** icon. -.. image:: Images/image069.png + .. image:: Images/NewComponent_HTML.png -2. In the blank component, click **Edit.** The HTML editor opens. +2. In the list that appears, click **Text**. -.. image:: Images/image071.png - -3. Enter the information that you want, and then click **Save.** - -.. note:: - - If you want to enter links to other pages or to images or to edit the - HTML directly, switch to the HTML tab. - -.. raw:: latex + An empty component appears at the bottom of the Unit. + + .. image:: Images/HTMLComponent_Edit.png + +3. In the empty component, click **Edit**. + + The HTML Component Editor opens. - \newpage % + .. image:: Images/HTMLEditor.png -**To create a basic HTML component that includes a template you can use:** +4. Click **Settings** to enter the **Display Name** for the HTML component. -1. Under **Add New Component,** click **html** and then click **Announcement.** + A student sees the Display when hovering your mouse over the icon for the Unit in the Subsection accordian. + + Click **Save** to return to the Component Editor. + +5. Enter text as needed. + +6. Click **Save** to save the HTML component. + +For more information, see: + +* :ref:`Work with the Visual and HTML Editors` +* :ref:`Use the Announcement Template` +* :ref:`Import Content from LaTex` +* :ref:`Add a Link in an HTML Component` +* :ref:`Add an Image to an HTML Component` + +ADD LINKS + +.. _Work with the Visual and HTML Editors: + +***************************************** +Work with the Visual and HTML Editors +***************************************** + +The HTML Component editor has two views: the **Visual view** and the **HTML view.** + +You select the view by clicking the tab in the upper-right of the component Editor. + +.. image:: Images/HTMLEditorTabs.png + +============== +Visual Editor +============== + +The Visual view provides a “what you see is what you get” (WYSIWYG) editor for +editing a pre-formatted version of the text. + +.. image:: Images/HTMLEditor_Visual.png + +Use the buttons at the top of the Visual editor to change the formatting as needed. +For example, you can enclose the title in heading tags, create bulleted or numbered lists, +or apply bold, italic, or underline formatting. + +============== +HTML Editor +============== +The HTML allows you to edit HTML code directly. + +.. image:: Images/HTMLEditor_HTML.png + +.. note:: Studio processes the HTML code entered when saving it and before rendering + it. Make sure that the text you create looks the way you expect if + you go back and forth between the Visual and HTML views. + +.. _Use the Announcement Template: + +************************************ +Use the Announcement Template +************************************ + +When you create a new HTML component, you can select to use a built-in Announcement template. + +When creating the new HTML component, select **Announcement**. + +.. image:: Images/HTML_Component_Type.png + :width: 800 + The following screen opens. .. image:: Images/image073.png -2. Click **Edit.** +Edit the content of the announcement just as you would any HTML component. - The text editor opens in Visual view. Replace the template text with your - announcement text. +.. _Import Content from LaTeX: -.. note:: +************************* +Import Content from LaTeX +************************* - If you want to enter links to other pages or to images or to edit the - HTML directly, switch to the HTML tab. +If LaTeX is enabled for your course, you can create an HTML component from imported LaTeX code. -.. image:: Images/image075.png +Studio uses a third-party LaTeX processor to convert LaTeX code to XML. The LaTeX processor must be up and running. -3. Click **Save.** +1. When creating the new HTML component, select **E-text Written in LaTeX**. -.. raw:: latex - - \newpage % + The new HTML component opens, with an **upload** link: + + .. image:: Images/latex_upload.png + :width: 800 -Create Links -************ +2. To upload a LaTeX file from your computer, click **upload**. -Link to a Handout or Image -========================== + You are prompted to select a file. The file loads in the LaTeX editor. + +3. In the LaTeX editor, click **Save & Compile to edX XML**. -To link to a document, image, or other file that you uploaded to the Files & -Uploads page: + The LaTeX content is added to the HTML component. For example: + + .. image:: Images/Latex_component.png + :width: 800 -1. Create a blank HTML component, and switch to HTML view. -2. In the HTML box, create links to your files. +4. Verify that your newly created component looks the way you want it to. -To create a link to a document, enter the following syntax, where URL OF FILE is -the URL that you noted in step 5 of Upload a File to the Files & Uploads Page -and LINK TEXT is the text that the user will click. :: +You can edit the HTML component with LaTeX as you can any other component. +In the editor, you can launch the LaTeX source compiler. -

      [LINK TEXT]

      -For example, to create a link to the HTML template for the “About” page document -whose URL is /c4x/edX/edX101/asset/AboutPage_Template.txt, use the following -code. :: +.. _Add a Link in an HTML Component: -

      HTML Template for -

      +*********************************** +Add a Link in an HTML Component +*********************************** -To create a link to an image that you’ve uploaded, enter the following syntax, -where URL OF FILE is the URL that you noted in step 5 of Upload a File to the -Files & Uploads Page. :: +You can add a link in an HTML component to any file you uploaded for the course. -

      +Find any copy the URL of the file in the Files & Uploads page. -For example, to create a link to the CourseImage.jpg file whose URL is -/c4x/edX/edX101/asset/CourseImage.jpg, use the following code. :: +See :ref:`Add Files to a Course` for more information. -

      +While editing the HTML component: -When you use this code, the following image appears. +#. Switch to the HTML view. -.. image:: Images/image078.png - :width: 800 +#. To create a link to a document, enter the following syntax, where URL OF FILE is the URL that you copied from the Files & Uploads Page and LINK TEXT is the text that the user will click. + + ``

      [LINK TEXT]

      `` -3. Click **Save.** Your files or images appear in the component. -.. raw:: latex - - \newpage % - -Link to Course Units -==================== +.. _Add a Link to a Course Unit: -To direct the student to a specific place in your own course, you must add an -HTML link to that unit. To do this: +============================ +Add a Link to a Course Unit +============================ -1. Determine the relative directory of your course. +You can add a link to a course unit in an HTML component. -a. On the Course Settings tab, click the blue your course URL link under Basic - Information. +#. Determine the unit identifier of the unit you're linking to. To do this, open the + unit page in Studio, and locate the **Unit Identifier** field under **Unit Location** in the right pane. -.. image:: Images/image079.png - :width: 800 +#. Copy the unit identifier. -The registration page for your course opens. +#. Open the HTML component where you want to add the link. -b. In the address bar at the top of the page, locate the URL. +#. Select the text that you want to make into the link. -c. Copy the part of the URL after “.org” and before “about”, including the -forward slashes. The syntax is the following. :: +#. Click the link icon in the toolbar. - /courses/[organization]/[course_number]/[course_name]/ +#. In the Insert/Edit Link dialog box, enter the following in the Link URL field. + + Make sure to replace (including the brackets) with the unit + identifier that you copied in step 2, and make sure to include both forward slashes (/). + + ``/jump_to_id/`` -For example, for edX101: How to Create an edX Course from edX, the complete URL -is the following. :: +#. If you want the link to open in a new window, click the drop-down arrow next to + the Target field, and then select Open Link in a New Window. If not, you can leave the default value. + +#. Click **Insert**. - https://edge.edx.org/courses/edX/edX101/How_to_create_an_edX_course/about +#. Save the HTML component and test the link. -The relative directory is the following. :: - /courses/edX/edX101/How_to_create_an_edX_course/ +.. _Add an Image to an HTML Component: -2. Determine the location ID of the target unit. Studio generates the location -ID for each unit when you create the unit. The location ID uses the following -syntax. :: +*********************************** +Add an Image to an HTML Component +*********************************** - i4x:////vertical/ +You can add an any image that you have uploaded for the course to an HTML component. -.. note:: +Find any copy the URL of the image in the Files & Uploads page. - To find the location ID, open the page of the unit you are trying to link - to in Studio and look at the URL in the browser’s address bar. The location ID - is the text in the URL after edit, as in the following example. +See :ref:`Add Files to a Course` for more information. -.. image:: Images/image081.png +While editing the HTML component: +#. Switch to the HTML view. -3. Open the unit that you want to link from. - -4. Under Add New Component, click html, and then click Empty. A new, blank -component appears. - -.. image:: Images/image083.png - :width: 800 - -5. Click **edit**. - -6. In the HTML editor that opens, click the HTML tab. - -7. Next to the number 1, type the following. Replace relative course directory, -location id of unit, and link text with your information. :: - - [link text] - -For example, a link to the “Creating an HTML Component” unit in edx101 -resembles the following :: - - Creating an HTML - - - -.. raw:: latex - - \newpage % - -Import from LaTeX -***************** - -You can create an HTML component from imported LaTeX code. - -.. note:: - - This feature is currently under development. - -1. Under **Add New Component**, click **html**, and then click **E-text Written -in LaTeX.** - -.. image:: Images/image067.png - :width: 800 - -2. In the component that appears, click Edit. - -.. image:: Images/image083.png - :width: 800 - -3. The component editor opens. In the top left corner of the editor, click the -yellow **Edit High Level Source** text. - -.. image:: Images/image085.png - :width: 800 - -4. In the **High Level Source Editing** screen that opens, replace the sample -code with your LaTeX code. - -.. image:: Images/image087.png - :width: 800 - -5. Click **Save and compile to edX XML** to convert the LaTeX code into edX XML -code. - -.. note:: - - Studio uses a third-party LaTeX processor to convert LaTeX code to XML. - The LaTeX processor must be up and running. - -6. Click **Save**. Verify that your newly created component looks the way you -want it to. - +#. To add the image to a document, enter the following syntax, where URL OF FILE is the URL that you copied from the Files & Uploads Page. + + ``

      `` diff --git a/docs/course_authors/source/create_lesson.rst b/docs/course_authors/source/create_lesson.rst deleted file mode 100644 index d0974ea8dc..0000000000 --- a/docs/course_authors/source/create_lesson.rst +++ /dev/null @@ -1,128 +0,0 @@ - -************************** -Create a Lesson in Studio -************************** - -Once you have created a course, you are ready to create content for that course. - -.. warning:: - - The alpha version of Studio does not have versioning or automatic - updating of your browser between refreshes. Versioning is planned for future - refresheseleases, but, in the meantime, only one author should edit a unit, in one - browser, on only one tab.  If a unit is open for editing in multiple browser - sessions, the session that saves last will overwrite any previously saved - content without displaying a warning. Also, older browser sessions can overwrite - more recent content, so refresh your browser before you start working every time - you work with a private unit or edit a draft of a public unit. - - -Introduction -************ - -Just as in an offline course, content in an online course is broken down into -smaller pieces. In Studio, these pieces are categories called **sections, -subsections, and units** Units, in turn, are made up of **components** that -contain the actual content of your course. - -Sections, for example, may correspond to weeks in your course, while subsections -often correspond to lessons, homework assignments, or exams. A lesson is an -interwoven selection of units of different types, such as videos, text, images, -discussions, and problems. It is an interactive representation of the material -that you would cover in a typical class period. - -On the **Course Outline** page, you can see all the sections, subsections, and -units in your course at a glance, as well as whether the subsections are public -or private. - - - .. image:: Images/image029.png - :width: 800 - - .. image:: Images/image031.png - :width: 800 - -.. raw:: latex - - \newpage % - -Section -******* - -A section is the topmost category that you use to organize your course. Many -instructors name sections according to the number of weeks in the course—for -example, section 1 is named Week 1, section 2 is named Week 2, and so on. -Sections contain subsections, which in turn contain units. - -You can set an individual release date for each section in your course. None of -the content in the section is visible until its release date has passed. - -For more information about how to create a section, see -:doc:`create_section_sub_section`. - -.. raw:: latex - - \newpage % - -Subsection -********** - -A subsection is a subcategory of a section. Many instructors name subsections -according to the topics in their courses. Subsection names appear along with -section names in the left pane when you view your course on Edge. - - .. image:: Images/image033.png - -You can set subsections to be one of the assignment types that you created when -you set up grading. You can then include assignments in the body of that -subsection. - -You can set an individual release date for each subsection in your course. None -of the content in the subsection is visible until its release date has passed. -If you do not set a release date, the subsection has the same release date as -its section. - -For more information about how to create a subsection, see -:doc:`create_section_sub_section`. - -.. raw:: latex - - \newpage % - -Unit -**** - -A unit is a further category that helps you organize your course materials. -Units contain components, which are the building blocks of lessons. Units do not -appear in the left pane with section and subsection headings. Instead, each unit -appears as a part of the course accordion at the top of the page when you view -your course on Edge. The following page shows a subsection that has two Units. - - .. image:: Images/image035.png - -Note that by default, all units are set to **Private.** To make a unit visible -to students, you have to explicitly change the unit’s visibility to **Public.** -For more information see :doc:`set_content_releasedates` . - -.. raw:: latex - - \newpage % - - -Component -********* - -A component is the part of a unit that contains your actual course content. The -names of all components in a unit appear when you hover over the unit in the -course accordion at the top of the page. - -.. image:: Images/image037.png - :width: 800 - -There are four types of components: Discussion components, HTML components, -Problem components, and Video components. For more information, -see :doc:`create_discussion`, :doc:`create_html_component`, :doc:`create_problem`, and :doc:`create_video` . - - - - diff --git a/docs/course_authors/source/create_new_course.rst b/docs/course_authors/source/create_new_course.rst new file mode 100644 index 0000000000..9e7317cd82 --- /dev/null +++ b/docs/course_authors/source/create_new_course.rst @@ -0,0 +1,360 @@ +.. _Setting up a New Course: + +########################### +Setting up a New Course +########################### + + +******************* +Overview +******************* + +This chapter describes how to create and set up your course: + +#. :ref:`Create a New Course` +#. :ref:`Edit Your Course` +#. :ref:`Use the Course Checklist` +#. :ref:`Add Course Team Members` +#. :ref:`Set Important Dates for Your Course` +#. :ref:`Describe Your Course` +#. :ref:`Add a Course Image` +#. :ref:`Add a Course Video` +#. :ref:`Set Course Requirements` +#. :ref:`Add Files to a Course` +#. :ref:`Add Static Pages` +#. :ref:`Add a Course Update` +#. :ref:`Add Course Handouts` +#. :ref:`Add Textbooks` + +Also see the chapters :ref:`Establish a Grading Policy` and :ref:`Organizing Your Course Content`. + + +.. _Create a New Course: + +******************* +Create a New Course +******************* + +#. Log in to Studio. +#. Click **New Course**. +#. Enter course information as needed and click **Create**. + + .. image:: Images/new_course_info.png + :width: 800 + + .. note:: Enter new course information carefully. This information becomes part of the URL for your course. To change the URL after the course is created, you must contact edX through the Help site (http://help.edge.edx.org). Additionally, because this information becomes part of your course URL, the total number of characters in the following three fields must be 65 or fewer. + + * For **Course Name**, enter the title of your course. For example, the name may be “Sets, Maps and Symmetry Groups". Use title capitalization for the course title. + + * For **Organization**, enter the name of your university. Do not include whitespace or special characters. + + * For **Course Number**, enter both a subject abbreviation and a number. For example, for public health course number 207, enter **PH207**. For math course 101x, enter **Math101x**. Do not include whitespace or special characters in the course number. + + *Note: If your course will be open to the world, be sure to include the "x". If it is exclusively an on-campus offering, do not include the "x".* + +4. Click **Save.** + +You then see the empty Course Outline. + +.. _Edit Your Course: + +************************ +Edit Your Course +************************ +When you create a new course, the course opens in Studio automatically and you can begin editing. + +If you come back to Studio later, your courses are listed on the Studio log in page. + + .. image:: Images/open_course.png + :width: 800 + +To open the course, click the course name. + +When you open a course, you go to the Course Outline. The next topic discusses working with your course outline. + +The rest of this page discusses other tasks you must do to set up your course. + +.. _Use the Course Checklist: + +************************ +Use the Course Checklist +************************ + +You can use a Course Checklist within Studio to help you work through the tasks of building a course. + +Categories of tasks in the Course Checklist include: + +* Getting Started with Studio +* Draft a Rough Course Outline +* Explore edX's Support Tools +* Draft Your Course About Page + +From the **Tools** menu, select **Checklists**. + + .. image:: Images/checklist.png + :width: 800 + + +As shown above for the **Add Course Team Members** task, if you hover over a task, a button is displayed that takes you to the page to complete that task. + +You can expand and collapse sections of this page as needed. + +You can check tasks as you complete them. Studio saves your changes automatically. Other course staff can see your changes. + +.. _Add Course Team Members: + +************************ +Add Course Team Members +************************ + +Course team members are users who help you build your course. + +Only a team member with Admin access can add or remove course team members, or grant Admin access to other team members. + +Other course team members can edit the course and perform all tasks except adding and removing other new team members and granting Admin access. + +.. note:: Any course team member can delete content created by other team members. + +All course team members must be registered with Studio and have an active account. + +To add a course team member: + +#. Ensure you have Admin access. +#. Ensure that the new team member has registered with Studio. +#. From the **Settings** menu, select **Course Team**. +#. Click **Add a New Team Member**. +#. Enter the new team member's email address, then click **ADD USER**. + +.. _Set Important Dates for Your Course: + +*********************************** +Set Important Dates for Your Course +*********************************** +You must set dates and times for enrollment and for the course. + +From the **Settings** menu, select **Schedule and Details**. + +.. image:: Images/schedule.png + +Follow the on-screen text to enter the course and enrollment schedule. + +.. note:: + + The Time fields on this page reflect the current time zone in your browser, depending on your geography. Course start times for students are shown as UTC. + +.. _`Describe Your Course`: + +************************ +Describe Your Course +************************ + +The description of your course appears on the Course Summary page that students see, and includes a course summary, prerequisites, staff information and FAQs. + +#. From the **Settings** menu, select **Schedule & Details**. +#. Scroll down to the **Introducing Your Course** section, then locate the **Course Overview** field. + +.. image:: Images/course_overview.png + :width: 800 + +3. Overwrite the content as needed for your course, following the directions in the boilerplate text. Do not edit HTML tags. For a template that includes placeholders, see :doc:`appendices/a`. + + .. note:: There is no save button. Studio automatically saves your changes. + +4. Click **your course summary page** in the text beneath the field to test how the description will appear to students. + +.. _`Add a Course Image`: + +************************ +Add a Course Image +************************ + +The course image appears on the Course Summary page that students see. + +The course image should be a minimum of 660 pixels in width by 240 pixels in height, and in .JPG or .PNG format. + +#. From the **Settings** menu, select **Schedule & Details**. +#. Scroll down to the **Course Image** section. +#. To select an image from your computer, click **Upload Course Image**, then follow the prompts to find and upload your image. +#. View your course summary page to test how the image will appear to students. + +.. _`Add a Course Video`: + +************************ +Add a Course Video +************************ +The course video appears on the Course Summary page that students see. + + +#. Upload the course video to YouTube. Make note of the code that appears between **watch?v =** and **&feature** in the URL. This code appears in the green box below. + + .. image:: Images/image127.png + :width: 800 + +2. From the **Settings** menu, select **Schedule & Details**. +#. Scroll down to the **Course Introduction Video** section. +#. In the field below the video box, enter the YouTube video ID. When you add the code, the video automatically loads in the video box. Studio automatically saves your changes. +#. View your course summary page to test how the video will appear to students. + +.. _`Set Course Requirements`: + +************************ +Set Course Requirements +************************ +The estimated Effort per Week appears on the Course Summary page that students see. + +#. From the **Settings** menu, select **Schedule & Details**. +#. Scroll down to the **Requirments** section. +#. In the **Hours of Effort per Week** field, enter the number of hours you expect students to work on this course each week. +#. View your course summary page to test how the video will appear to students. + +.. _`Add Files to a Course`: + +********************** +Add Files to a Course +********************** + +You can add files that you want students to access in the course. After you add a file, +you must link to it from a course component, update, or in the course handouts. A file +is only visible to students if you create a link to it. + +.. note:: Because the file name becomes part of the URL, students can see the name of the file when they open it. Avoid using file names such as AnswerKey.pdf. + +.. warning:: If you upload a file with the same name as an existing course file, the original file is overwritten without warning. + +To add a file: + +#. From the **Content** menu, select **Files & Uploads**. +#. Click **Upload New File**. +#. In the **Upload New File** dialog box, click **Choose File**. +#. In the **Open** dialog box, locate the file that you want, and then click **Open**. +#. To add another file, click **Load Another File**. +#. To close the dialog box, click the **x** in the top right corner. + +When you close the dialog box, the new files appear on the **Files & Uploads** page. + +================== +Get the File URL +================== +To link to the file from a course component, update, or the course handout list, you must get the file URL. + +In the **Files & Uploads** page, locate the file. The **URL** column shows the value to use in links. + +You can double click a value in the **URL** column to select the value, then copy it. + +================== +Lock a file +================== +By default, anyone can access a file you upload if they know the URL, even people not enrolled in your class. + +To ensure that those not in your class cannot view the file, click the lock icon. + +================== +Delete a file +================== +To delete a file, click the **x** icon next to the file. You are prompted to confirm the deletion. + +.. warning:: If you have links to a file you delete, those links will be broken. Ensure you change those links before deleting the file. + +.. _`Add Static Pages`: + +**************** +Add Static Pages +**************** +You can add static pages to your course. Each static page appears in your courses navigation bar. +For example, the following navigation bar includes a +**Syllabus** and **Projects** static pages. + +.. image:: Images/image157.png + +You can use static pages for a syllabus, grading policy, course handouts, or any other purpose. + +To create a static page: + +#. From the **Content** menu, select **Static Pages**. +#. Click **New Page**. The following screen opens: + + .. image:: Images/image161.png + :width: 800 + +3. Click **Edit**. + +#. Enter text for your page. You can switch to HTML mode if needed. +#. To edit the Display Name, click **Settings**. +#. Click **Save**. + + +================== +Add a Calendar +================== +You can also use a static page to show a course calendar. + +You can embed a Google calendar. Paste the embed code for the calendar in the static page. + +You can also create a dynamic HTML calendar. See :ref:`Appendix B`. + +.. _`Add a Course Update`: + +********************** +Add a Course Update +********************** + +You add updates to notify students of exams, changes in the course schedule, or anything else of a more urgent nature. + +Students see course updates in **Course Info** tab when they log in to the course: + +.. image:: Images/course_info.png + :width: 800 + +To add a course update: + +#. From the **Content** menu, select **Updates**. +#. Click **New Update**. +#. Enter your update as as HTML. + + .. note:: You must enter the update in HTML. For a template that includes placeholders, see :ref:`Appendix A`. + +4. Click **Save**. + +.. _`Add Course Handouts`: + +********************** +Add Course Handouts +********************** +You can add course handouts that are visible to students on the **Course Info** page. + +.. note:: You must :ref:`Add Files to a Course` before you can add them as course handouts. + +#. From the **Content** menu, select **Updates**. +#. In the **Course Handouts** page, click **Edit**. +#. Edit the HTML to add links to the files you uploaded. See :ref:`Add a Link in an HTML Component` for more information. +#. Click **Save**. + +.. _`Add Textbooks`: + +**************** +Add Textbooks +**************** +You can add textbooks for your course as PDF files. + +Each textbook that you add is displayed to students as a tab in the course navigation bar. + +It's recommended that you upload a separate PDF file for each chapter of your textbook. + +When students open the textbook tab in the course, they can navigate the textbook by chapter: + +.. image:: Images/textbook_chapters.png + :width: 800 + +To add a textbook: + +#. From the **Content** menu, select **Textbooks**. +#. Click **New Textbook**. The following screen opens: + + .. image:: Images/textbook_new.png + :width: 800 + +3. Enter the **Textbook Name**. +#. Enter the first **Chapter Name**. +#. To upload a PDF file from your computer, click **Upload PDF**. Follow the prompts to upload your file. +#. To add addition chapters, click **+Add a Chapter** and repeat steps 3 and 4. +#. Click **Save**. \ No newline at end of file diff --git a/docs/course_authors/source/create_problem.rst b/docs/course_authors/source/create_problem.rst deleted file mode 100644 index 3fe8264b4d..0000000000 --- a/docs/course_authors/source/create_problem.rst +++ /dev/null @@ -1,374 +0,0 @@ - -**************** -Create a Problem -**************** - -Overview -******** - - -The problem component allows you to add interactive, automatically graded exercises to your course content. You can create many different types of problems -in Studio. - -By default, all problems are ungraded practice problems. To change the problems to graded problems, change the assignment type of the subsection. - -To create a problem, determine: - -• The type of problem that you want. - -• The weight to assign to the problem. - -• Whether you want to randomize the problem. - -• How to close the problem-that is, how to set the number of attempts a student has, [as well as set the due date]. - -• How you want to provide feedback to students; show answer on/off. - -This course contains several places with more information about creating exercises and integrating them into your course. - -• `Writing Exercises `_ has more in-depth discussion about problem types, and some general pedagogical considerations for adapting to the online format and a `Gallery of Response Types `_ - -• `Appendix E ` contains XML documentaion for the different problem response types. - -• The `Discussion Forum `_ for this class is a good place to ask questions about exercise types, report any errors or oddities that you may encounter, and get technical support. - -• Creating problems for the online format opens a new playing field in the educational process. A big part of the community aspect of edX is to initiate and grow a `Creative Problems `_ . Please look here to be inspired by new approaches when first making your class. Please also come back to post interesting approaches that you came up with while running your class, and to share with the community what worked well and what did not. - -**Simple Editor and Advanced Editor** - - -Studio offers two interfaces for editing problem components. - -• The **Simple Editor** allows you to edit problems visually, without having to work with XML. - -• The **Advanced Editor** converts the problem to edX's XML standard and allows you to edit that XML directly. For more information about the XML for different problem types, see `Appendix E ` . - - -Some of the simpler problem templates, including multiple choice, open in the Simple Editor and allow you to switch to the Advanced Editor. The more complicated problem types, such as Circuit Response, open in the Advanced Editor. - -.. note:: - - You can switch at any time from the Simple Editor to the Advanced Editor by clicking "Advanced Editor" in the Simple Editor interface. However, there is no way to go back from the Advanced Editor to the Simple Editor without making a new component. - -To open the Advanced Editor, click **Advanced Editor** in the top right corner of the Simple Editor. - -.. image:: Images/image275.png - :width: 600px - - -The following is a multiple choice problem in the Advanced Editor. - -.. image:: Images/image276.png - :width: 600px - -.. raw:: latex - - \newpage % - - -Problem Type -************ - -Links to description of all the different problem types-brief. Then include links to all the XML, etc. - -You may want to create a problem that has more than one response type. For example, you may want to create a multiple choice question, and then ask the -student to explain his or her response. You may also want a student to be able to check the answers to many problems at one time. To accomplish these -tasks, you can include multiple problems inside a single Problem component. (LINK) - -.. raw:: latex - - \newpage % - -Randomizing -*********** - -The **rerandomize** setting determines whether any random variable inputs for a problem are randomized each time a student loads the problem. -(This is only applicable to problems with randomly generated numeric variables.) - -.. raw:: latex - - \newpage % - -Scoring and Weight -****************** - -Problems store a **point score** for submitted responses. The score that a student earns is the number of correct responses the student -submits divided by the maximum score for the problem. The default maximum score, or weight, is the integer number of response type inputs the problem has. -Thus, the weight attribute for a problem with one response type input is set to 1 (one point). You can change the maximum score for an individual problem -by manually changing the problem **weight** attribute to another number. When you do this, the number of points that you specify appears next -to the problem title ( to one decimal precision). - -**WEIGHT: 0 POINTS** - -Scores are stored for all problems, but they only contribute to a student's grade in the course if they are part of a subsection marked as graded. For more -information, see the material on attempts and closing problems in 7B: Feedback and Grading. - -.. raw:: latex - - \newpage % - -**Computing Point Scores** - -The point score of a response for a problem reflects the correctness of the response and is recorded as the number of points earned out of the maximum -possible score for the problem (also known as the problem weight). The formula used for computing the recorded point score is the following: - -• **point score of response = problem weight * (# inputs correct / # total inputs)** - -• **point score of response** is the point score "earned" by this response for the problem. - -• **problem weight** is the maximum possible point score that can be earned for the problem. By default, this is the integer number of response types in that problem. This can be changed to another value by setting the weight attribute of the problem, as described in Setting Problem Attributes. - -• ** # inputs correct** is the number of values for this response that were evaluated as correct by the response type fields. - -• **# total inputs** is the total number of response type fields in the problem. - -.. raw:: latex - - \newpage % - -**Examples** - -The following are some examples of setting problem weight and computing problem scores. - - -**Example 1** - -A problem with two response type inputs and a blank weight attribute has a maximum score of 2.0 points. - -A student response to this problem that consists of one correct input value and one incorrect input value would be marked as having a score of 1.0 points -out of 2.0 points possible. - - -**Example 2** - -A problem with three response type inputs and a weight attribute of 12 has a maximum score of 12.0 points. - -A student response to this problem that consists of one correct input value and two incorrect input values would be marked as having a score of 4.0 points out of 12.0 points possible. - - -**Example 3** - -A problem with four response type inputs and a weight attribute of 2 has a maximum score of 2.0 points. - -A student response to this problem that consists of two correct input values and two incorrect input values would be marked as having a score of 0.5 of a point out of 2.0 points total. - -**PROBLEM: 20.0 POINTS** - -• The weight attribute for this problem has been changed from the default. - -• How many points is the entire problem worth? - -• What number is the weight attribute of this problem set to? - -• How many response inputs does this problem have? - -• What is the default maximum score for this problem? - -• If a response to this problem got one value right and the rest wrong, what score would it be assigned? - -.. raw:: latex - - \newpage % - -Close -***** - -To stop accepting responses and recording points, problems can be **closed.** Closed problems do not display a **Check** button. Students -can still see questions, solutions, and revealed explanations in a closed problem, but they can no longer check their work, submit responses, or change their stored score. - -There are several ways that you can close problems: - -• Set a due date for the problems in a subsection. Note that you cannot set due dates for individual problems -- only for containing subsections (assignments). By default, due dates are not set. To set a due date, see LINK. - -• Specify a grace period for your course. Note that the grace period applies to the entire course. To set a grace period, see LINK. - -• Set the number of attempts for the individual problem component. The attempts setting determines the number of times a student is allowed to check their answer by clicking Check before the problem closes. If this field is left blank, a student has unlimited attempts. If you specify a number for the attempts setting, the number of total allowed and remaining attempts appears next to the Check button for the problem. Problems with a limited number of attempts also always display a Save button that allows response values to be saved without being submitted. When there is only one submission left, the student will receive a warning, and the Check button will be replaced with a Final Check button. When no attempts are left, both the Save and Check button will disappear.For more information, see Problem Attributes. - -• Manually increase the number of attempts left for a given problem for a particular student from the Instructor tab in the live view of your course, when accessed in the Instructor view on Edge. This is recommended only for unusual situations, such as if you have to fix live problems during an exam. - -.. raw:: latex - - \newpage % - -Feedback -******** - -Studio includes several tools to provide feedback to students: the **Check** button, the **Show Answer** button, and the**Attempts** setting. When you use the **Show Answer** button, you can also provide a detailed explanation of the answer. - -TBD-SCREENSHOT OF PROBLEM WITH THESE ELEMENTS CIRCLED - -**Check Button** - -The student clicks the **Check** button to submit a response. The problem module then performs the following steps. - -• Accepts and stores the responses entered into each input. - -• Checks the response values against the correct answers or solutions using an automatic grader. - -• Visually marks the correct responses with a green check mark and the incorrect responses with a red x. - -• Stores the point score earned by this response for this problem for this student. - -If a student wants to save but not submit a response, the student can click **Save**. - -In the following problem, enter a response, and then click **Check**. The problem tells you if your response is correct or incorrect. -Additionally, although you don't see it, a point score is also automatically stored for the response you submit. - -.. image:: Images/image277.png - :width: 600px - -**Show Answer button** - -When a student clicks **Show Answer**, the problem shows the correct answers next to the corresponding response inputs and reveals any -additional explanations that you have provided. **Show Answer** is controlled by the **showanswer** attribute in the problem -editor. It can be set to be never visible, always visible, or visible only when the problem has closed. [Reference: Setting Problem Attributes.] - -In the following problem, the **Show Answer ** button appears after the student has made at least one attempt to answer. Enter a response that you know is wrong, and then click **Check**. - -.. image:: Images/image278.png - :width: 600px - -Now, click **Show Answer** to view the correct answer and its explanation. - -.. image:: Images/image279.png - :width: 600px - - -.. raw:: latex - - \newpage % - - - -Create a Problem -**************** - -.. note:: - - You can also include non-graded exercises throughout your course. - - -To add interactive, automatically graded exercises to your course content, use the Problem component. This component allows you to include an explanation -that the student can see when the student clicks **Show Answer**. - - Studio offers several templates that you can use. Alternatively, you can create your own problem type in XML. - For detailed information about different problem types, see `Appendix E `. - - -1. Under **Add New Component**, click **Problem**. - -.. image:: Images/image096.png - :width: 600px - - -The **Select Problem Component Type** screen appears. By default, the **Common Problem Types** tab is selected. - -.. image:: Images/image097.png - :width: 600px - - -To see a list of more complex problem types, click the **Advanced** tab. - - -.. image:: Images/image099.png - :width: 600px - - -2. Click the problem type that you want. - -.. note:: - - To create your own problem in XML, click "Empty" to open a blank XML editor. - -A new problem component with sample template text appears. - -For example, if you click **Multiple Choice**, the following problem component appears. - -.. image:: Images/image101.png - :width: 600px - - - -3. Click **Edit**. This opens the Simple Editor for the problem component. The following example shows this view for a multiple choice -problem. - -.. image:: Images/image103.jpg - :width: 600px - - -4. Set the problem attributes. - -In the **display_name** box, type the text that you want the student to see when the student hovers over the icon in the bar at the top of the page. This text also appears as a header for the problem. - -a. In the **weight** box, set a weight for the problem. If you want the problem to be a practice problem, set this to zero (0). - -b. In the **rerandomize** box, - -c. In the **attempts** box, specify the number of attempts that you want to allow the student. - -d. In the **showanswer** box, enter one of the following settings. - -.. raw:: latex - - \newpage % - -**Reference** - -• **never** = The Show Answer button is never visible. - -• **closed** = The Show Answer button is not visible if either the due date has passed, or the student has no attempts left. - -• **attempted** = The Show Answer button appears after the student has checked an answer once, regardless of correctness. - -• **always** = The Show Answer button always appears. - - -5. Modify the problem text, and then click **Save** to save and check your work. Make sure to publish the draft you are working on to view the problem live. - -.. raw:: latex - - \newpage % - -Modify a Released Problem -************************* - - **WARNING: Be careful when you modify problems after they have been released!** - -Currently, problems cache the following information per student: - -• The student's last **submitted** response. - -• The score the student earned for that last response. - -• The maximum point score for that problem. - -This information is updated when a student submits a response to a problem. If the student refreshes the **Progress** page, solutions are not re-checked. If a student refreshes the page of a problem, the latest version of the problem statement is loaded, but their previous response is NOT reevaluated. Rather, the previous response is loaded on top of the current problem statement. That is **existing** student responses for a problem are not reevaluated if the problem statement or attributes are changed, until a student goes back and resubmits the problem. Furthermore, as of the time of writing, if the problem weight attribute is changed, stored scores are re-weighted (without rechecking the response) when the student reloads the **Progress** page. - -For example, you may release a problem that has two inputs. After some students have submitted answers, if you change the solution to one of the inputs, the existing student scores are not updated. - -Example: If you change the number of inputs to three, students who submitted answers before the change will have a score of 0, 1, or 2 out of 2.0. Students who submitted answers after the change will have scores of 0, 1, 2, or 3 out of 3.0 for the same problem. - -However, if you go in and change the weight of the problem, the existing scores update when you refresh the **Progress** page. - -Note that the behavior of re-grading in case of error is an edX Edge case. It is dependent on the implementation of grading, and may change. The goal in the future is to include re-grading that will allow some basic updates to live problems, whether or not students have submitted a response. - -.. raw:: latex - - \newpage % - - -Workarounds -=========== - -If you have to modify a released problem in a way that affects grading, you have two options. Note that both options require you to ask your students to go back and resubmit a problem. - - -1. Increase the number of attempts on the problem in the same Problem component. Then ask all the students in your class to redo the problem. - -2. Delete the entire Problem component in Studio and create a new Problem component with the content and settings that you want. Then ask all the students in your course to go back to this assignment and complete problem. - -Check your **Progress** view or the **Instructor** tab on Edge as described in the Viewing Scores unit to see if point scores are being stored as you expect. If there are issues with stored scores that you do not understand or cannot fix, contact support on the Studio help page. - -For a discussion of some trade-offs and some suggestions for cleaner solutions in the future, see the following `discussion thread `_ on the Studio help desk. - -You can include multiple problems of different types inside a single Problem component, even if you select a particular template when you create a problem. A template is simply an XML editor with template text already filled in. You can add to or replace the template text. diff --git a/docs/course_authors/source/create_problem_component.rst b/docs/course_authors/source/create_problem_component.rst new file mode 100644 index 0000000000..37b244f61e --- /dev/null +++ b/docs/course_authors/source/create_problem_component.rst @@ -0,0 +1,404 @@ +.. _Working with Problem Components: + +################################ +Working with Problem Components +################################ + +********* +Overview +********* + +The problem component allows you to add interactive, automatically +graded exercises to your course content. You can create many different +types of problems in Studio. + +All problems receive a point score, but, by default, problems do not count +toward a student's grade. If you want the problems to count toward the +student's grade, change the assignment type of the subsection that contains the +problems. + +.. _Components and the User Interface: + +************************************ +Components and the User Interface +************************************ + +This section contains a description of the various components of a +problem as students see it in the LMS, as well as an introduction to the +Studio user interface for course creators. + +============================== +The Student View of a Problem +============================== + +All problems on the edX platform have several component parts. + +.. image:: Images/AnatomyOfExercise1.gif + +#. **Problem text.** The problem text can contain any standard HTML formatting. + +#. **Response field with the student’s answer.** Students enter answers + in *response fields*. The appearance of the response field depends on + the type of the problem. + +#. **Rendered answer.** For some problem types, Studio uses MathJax to + render plain text as “beautiful math.” + +#. **Check button.** The student clicks **Check** to submit a response + or find out if his answer is correct. If the answer is correct, a green + check mark appears. If it is incorrect, a red X appears. When the + student clicks the **Check button**, Studio saves the grade and current + state of the problem. + +#. **Save button.** The student can click **Save** to save his current + response without submitting it for a grade. This allows the student to + stop working on a problem and come back to it later. + +#. **Show Answer button.** This button is optional. When the student + clicks **Show Answer**, the student sees both the correct answer (see 2 + above) and the explanation (see 10 below). The instructor sets whether + the **Show Answer** button is visible. + +#. **Attempts.** The instructor may set a specific number of attempts or + allow unlimited attempts. + + .. image:: Images/AnatomyOfExercise2.gif + +#. **Feedback.** After a student clicks **Check**, all problems return a + green check mark or a red X. + + .. image:: Images/AnatomyofaProblem_Feedback.gif + +#. **Correct answer.** Most problems require that the instructor specify + a single correct answer. + +#. **Explanation.** The instructor may include an explanation that + appears when a student clicks **Show Answer**. + +#. **Reset button.** This button clears the student input, so that the + problem looks the way it did originally. + +#. **Hide Answer button.** + + .. image:: Images/AnatomyOfExercise3.gif + +#. **Grading.** The instructor may specify whether a group of problems + is graded. If a group of problems is graded, a clock icon appears for + that assignment in the course accordion. + + .. image:: Images/clock_icon.gif + +#. **Due date.** The date that the problem is due. A problem that is + past due does not have a **Check** button. It also does not accept + answers or provide feedback. + +**Note** Problems can be **open** or **closed.** Closed problems do not +have a **Check** button. Students can still see questions, solutions, +and revealed explanations, but they cannot check their work, submit +responses, or change their stored score. + +There are also some attributes of problems that are not immediately +visible. + +- **Randomization.** For some problems, the instructor can specify + whether a problem will use randomly generated numbers that vary from + student to student. +- **Weight.** Different problems in a particular problem set may be + given different weights. + +============================== +The Studio User Interface +============================== + +Studio offers two interfaces for editing problem components: the Simple +Editor and the Advanced Editor. + +- The **Simple Editor** allows you to edit problems visually, without + having to work with XML. +- The **Advanced Editor** converts the problem to edX’s XML standard + and allows you to edit that XML directly. + +**Note** You can switch at any time from the Simple Editor to the +Advanced Editor by clicking **Advanced Editor** in the top right corner +of the Simple Editor interface. However, it is not possible to switch from +the Advanced Editor to the Simple Editor. + +The Simple Editor +~~~~~~~~~~~~~~~~~ +The Common Problem templates, including multiple choice, open in the Simple Editor. The +following image shows a multiple choice problem in the Simple Editor. + +The Simple Editor includes a toolbar that helps you format the text of your problem. +When you select text and then click the formatting buttons, the Simple Editor formats +the text for you automatically. The toolbar buttons are the following: + +1. Create a level 1 heading. +2. Create multiple choice options. +3. Create checkbox options. +4. Create text input options. +5. Create numerical input options. +6. Create dropdown options. +7. Create an explanation that appears when students click **Show Answer**. +8. Open the problem in the Advanced Editor. +9. Open a list of formatting hints. + +The following image shows a multiple choice problem in the Simple Editor. + +.. image:: Images/MultipleChoice_SimpleEditor.gif + +The Advanced Editor +~~~~~~~~~~~~~~~~~~~ +The **Advanced Editor** opens a problem in XML. The Advanced Problem templates, +such as the circuit schematic builder, open directly in the Advanced Editor. + +For more information about the XML for different problem types, see :ref:`Appendix E`. + +The following image shows the multiple choice problem above in the Advanced Editor +instead of the Simple Editor. + +.. image:: Images/MultipleChoice_AdvancedEditor.gif + +.. _Problem Settings: + +****************** +Problem Settings +****************** + +All problems except word cloud and open response assessment problems +have the following settings. These settings appear on the **Settings** tab in +the component editor. (The settings for open response assessments and word clouds +are listed on the page for those problem types.) + +- Display Name +- Maximum Attempts +- Problem Weight +- Randomization +- Show Answer + +.. image:: Images/ProbComponent_Attributes.gif + +=============== +Display Name +=============== + +This setting indicates the name of your problem. The display name +appears as a heading over the problem in the LMS and in the course +ribbon at the top of the page. + +.. image:: Images/ProbComponent_LMS_DisplayName.gif + +============================== +Maximum Attempts +============================== + +This setting specifies the number of times a student can try to answer +the problem. By default, a student has an unlimited number of attempts. + +============================== +Problem Weight +============================== + +**Note** Studio stores scores for all problems, but scores only count +toward a student’s final grade if they are in a subsection that is +graded. + +This setting specifies the maximum number of points possible for the +problem. The problem weight appears next to the problem title. + +.. image:: Images/ProblemWeight_DD.gif + +By default, each response field, or “answer space,” in a Problem +component is worth one point. Any Problem component can have multiple +response fields. For example, the Problem component above +contains one dropdown problem that has three separate questions for students +to answer, and thus has three response fields. + +The following Problem component contains one text input problem, +and has just one response field. + +.. image:: Images/ProblemWeight_TI.gif + +Computing Scores +~~~~~~~~~~~~~~~~ + +The score that a student earns for a problem is the result of the +following formula: + +**Score = Weight × (Correct answers / Response fields)** + +- **Score** is the point score that the student receives. +- **Weight** is the problem’s maximum possible point score. +- **Correct answers** is the number of response fields that contain + correct answers. +- **Response fields** is the total number of response fields in the + problem. + +**Examples** + +The following are some examples of computing scores. + +*Example 1* + +A problem’s **Weight** setting is left blank. The problem has two +response fields. Because the problem has two response fields, the +maximum score is 2.0 points. + +If one response field contains a correct answer and the other response +field contains an incorrect answer, the student’s score is 1.0 out of 2 +points. + +*Example 2* + +A problem’s weight is set to 12. The problem has three response fields. + +If a student’s response includes two correct answers and one incorrect +answer, the student’s score is 8.0 out of 12 points. + +*Example 3* + +A problem’s weight is set to 2. The problem has four response fields. + +If a student’s response contains one correct answer and three incorrect +answers, the student’s score is 0.5 out of 2 points. + +=============== +Randomization +=============== + +This setting only applies to problems that have randomly generated +numeric values. It specifies whether random variable inputs are +randomized when a student loads the problem. + +=============== +Show Answer +=============== + +This setting defines when the problem shows the answer to the student. +This setting has seven options. + ++-------------------+--------------------------------------+ +| **Always** | Always show the answer when the | +| | student clicks the **Show Answer** | +| | button. | ++-------------------+--------------------------------------+ +| **Answered** | Show the answer after the student | +| | has submitted her final answer. | ++-------------------+--------------------------------------+ +| **Attempted** | Show the answer after the student | +| | has tried to answer the problem one | +| | time, whether or not the student | +| | answered the problem correctly. | ++-------------------+--------------------------------------+ +| **Closed** | Show the answer after the student | +| | has used up all his attempts to | +| | answer the problem or the due date | +| | has passed. | ++-------------------+--------------------------------------+ +| **Finished** | Show the answer after the student | +| | has answered the problem correctly, | +| | the student has no attempts left, or | +| | the problem due date has passed. | ++-------------------+--------------------------------------+ +| **Past Due** | Show the answer after the due date | +| | for the problem has passed. | ++-------------------+--------------------------------------+ +| **Never** | Never show the answer. In this case, | +| | the **Show Answer** button does not | +| | appear next to the problem in Studio | +| | or in the LMS. | ++-------------------+--------------------------------------+ + +=============== +Problem Types +=============== + +Studio includes templates for many different types of problems, from +simple multiple choice problems to advanced problems that require the +student to “build” a virtual circuit. Details about each problem type, +including information about how to create the problem, appears in the +page for the problem type. + +- :ref:`Common Problems` appear on the **Common Problem Types** tab when you + create a new Problem component in Studio. You create these problems + using the Simple Editor. +- :ref:`Advanced Problems` appear on the **Advanced** tab when you create a + new Problem component. You create these problems using the Advanced + Editor. +- :ref:`Specialized Problems` are advanced problems that aren’t available by + default. To add these problems, you first have to modify the advanced + settings in your course. The Advanced component then appears under + **Add New Component** in each unit, and these problems are available + in the Advanced component. +- :ref:`Open Response Assessment Problems` are a new kind of problem that allow you, the + students in your course, or a computer algorithm to grade responses in the form + of essays, files such as computer code, and images. + +************************************ +Multiple Problems in One Component +************************************ + +You may want to create a problem that has more than one response type. +For example, you may want to create a numerical input problem, and then +include a multiple choice question about the numerical input problem. +Or, you may want a student to be able to check the answers to +many problems at one time. To do this, you can include multiple problems +inside a single Problem component. The problems can be different types. + +To create multiple problems in one component, create a new Blank +Advanced Problem component, and then paste the XML for each problem in +the component editor. You only need to include the XML for the problem +and its answers. You don’t have to include the code for other elements, +such as the **Check** button. + +Elements such as the **Check**, **Show Answer**, and **Reset** buttons, +as well as the settings that you select for the Problem component, apply +to all of the problems in that component. Thus, if you set the maximum +number of attempts to 3, the student has three attempts to answer +the entire set of problems in the component as a whole rather than three +attempts to answer each problem individually. If a student clicks +**Check**, the LMS scores all of the problems in the component at once. +If a student clicks **Show Answer**, the answers for all the problems in +the component appear. + +************************************ +Modifying a Released Problem +************************************ + +**WARNING: Be careful when you modify problems after they have been +released!** + +After a student submits a response to a problem, Studio stores the +student’s response, the score that the student received, and the maximum +score for the problem. Studio updates these values when a student +submits a new response to a problem. However, if an instructor changes a +problem or its attributes, Studio does not automatically update existing +student information for that problem. + +For example, you may release a problem and specify that its answer is 3. +After some students have submitted responses, you notice that the answer +should be 2 instead of 3. When you update the problem with the correct +answer, Studio doesn’t update scores for students who answered 2 for the +original problem and thus received the wrong score. + +For another example, you may change the number of response fields to +three. Students who submitted answers before the change have a score of +0, 1, or 2 out of 2.0 for that problem. Students who submitted answers +after the change have scores of 0, 1, 2, or 3 out of 3.0 for the same +problem. + +If you change the weight of the problem, however, the existing scores +update when you refresh the **Progress** page. + +=============== +Workarounds +=============== + +If you have to modify a released problem in a way that affects grading, +you have two options. Note that both options require you to ask your +students to go back and resubmit a problem. + +- In the Problem component, increase the number of attempts for the + problem. Then ask all your students to redo the problem. +- Delete the entire Problem component in Studio and create a new + Problem component with the content and settings that you want. Then + ask all your students to complete the new problem. diff --git a/docs/course_authors/source/create_section_sub_section.rst b/docs/course_authors/source/create_section_sub_section.rst deleted file mode 100644 index ebd509d394..0000000000 --- a/docs/course_authors/source/create_section_sub_section.rst +++ /dev/null @@ -1,67 +0,0 @@ - -******************************* -Create a Section and Subsection -******************************* - - -1. Sign in to Studio, and then click the course that you want. - - -2. On the **Course Outline** page, click **New Section.** - - -.. image:: Images/image039.png - :width: 800 - - - -3. In the **New Section** Name box, type a section name, and then click -**Save**. - -.. note:: - - In most courses, the name of the first section is Week 1. - The section that you have created appears on the **Course Outline** page. - -.. image:: Images/image041.png - :width: 800 - - -The name you enter also appears in the navigation ribbon, as follows. - - -.. image:: Images/image043.png - :width: 800 - - -4. To create a new lesson or assessment in your section, click **New -Subsection.** - - -5. In the **New Subsection** box, enter the name for this subsection, and then -click **Save.** - -For example, if you enter **Week 1** as the section title and **Subsection 1** -as the subsection title, you see the following. - - -.. image:: Images/image045.png - :width: 800 - - -If you view your course as a student would see it, you see the following. - - -.. image:: Images/image047.png - :width: 800 - - -6. Click the new subsection that you just created. In this example, you would -click **Subsection 1.** You see the following screen. - - -.. image:: Images/image049.png - :width: 800 - - - diff --git a/docs/course_authors/source/create_seed_wiki b/docs/course_authors/source/create_seed_wiki deleted file mode 100644 index 3eb688f505..0000000000 --- a/docs/course_authors/source/create_seed_wiki +++ /dev/null @@ -1,7 +0,0 @@ - -************************ -Create and Seed the Wiki -************************ - -Create wiki ("seed" the wiki) - \ No newline at end of file diff --git a/docs/course_authors/source/create_seed_wiki.rst b/docs/course_authors/source/create_seed_wiki.rst deleted file mode 100644 index c6c66c923e..0000000000 --- a/docs/course_authors/source/create_seed_wiki.rst +++ /dev/null @@ -1,7 +0,0 @@ - -************************ -Create and Seed the Wiki -************************ - -Create wiki ("seed" the wiki) - diff --git a/docs/course_authors/source/create_unit.rst b/docs/course_authors/source/create_unit.rst deleted file mode 100644 index 5136b935e3..0000000000 --- a/docs/course_authors/source/create_unit.rst +++ /dev/null @@ -1,25 +0,0 @@ - -************* -Create a Unit -************* - - 1. On the **Course Outline** page, click to open the subsection where you want to create the unit. - - 2. Click **New Unit.** The following screen appears. - - .. image:: Images/image051.png - :width: 800 - - - 3. In the **Display Name** box, type the name of the unit. This name appears in the course ribbon at - the top of the screen on Edge. - - Each unit has one or more components. - - -To create a discussion space where you or your students can post questions or participate in a discussion, click **Discussion.** - - -To create a component where you can add text, images, or other content, click **html.** - - -To create a problem for your students to solve, click **Problem.** - - -To add a video, click **Video.** diff --git a/docs/course_authors/source/create_video.rst b/docs/course_authors/source/create_video.rst index 3423f93d8a..d043c552fc 100644 --- a/docs/course_authors/source/create_video.rst +++ b/docs/course_authors/source/create_video.rst @@ -1,60 +1,110 @@ +.. _Working with Video Components: -************** -Create a Video -************** +############################# +Working with Video Components +############################# -Many instructors use videos to take the place of in-class lectures. You can create a video of your lecture, and interweave other components—such as discussions and problems—to promote active learning. -To add a video to the unit, you must upload your video to YouTube, and then create a video component. You can also add a transcript to your video. +******************* +Overview +******************* +You can create a video of your lecture, and add it to your course with other components—such as discussions and problems—to promote active learning. + +You can also associate a timed transcript with your video, which students can read and download. + +When you add a video to your course, you first post the video online, and then create a link to that video in the body of your course. + +* :ref:`Video Formats` +* :ref:`Video Hosting` +* :ref:`Create a Video Component` + + + +.. _Video Formats: + +******************* +Video Formats +******************* + +The edX video player supports videos in .mp4, .ogg, and .mpeg format. + + +.. _Video Hosting: + +******************* +Video Hosting +******************* + +All course videos should be posted to YouTube. +By default, the edX video player accesses your YouTube videos. +However, because YouTube is not available in all locations, we recommend that you also post copies of your videos on a third-party site such as Amazon S3. +When a student views a video in your course, if YouTube is not available in that student's location or if the YouTube video doesn't play, the video on the backup site starts playing automatically. +The student can also click a link to download the video from the backup site. + +You can use any video backup site that you want. Keep in mind, however, that the site where you post the videos may have to handle a lot of traffic. + + +.. _Create a Video Component: + +************************* +Create a Video Component +************************* + +To add a video to the unit, you must obtain the YouTube ID for the video, obtain the URL for the backup video, and then create a video component. + +To determine the YouTube ID for a video, locate the video on YouTube and make a note of the code that appears between **watch?v =** and **&feature** in the URL. +This code appears circled below. + +.. note:: If **&feature** does not appear in the URL, just use the code that follows **watch?v=** in the URL. + +.. image:: Images/VideoComponent_YouTubeCode.png + You can include videos that run at 0.75 speed, 1.25 speed, and 1.50 speed as well as at normal speed. To do this, you must upload each of these videos to YouTube separately. -.. note:: - - YouTube only hosts videos of up to 15 minutes. If you encode a 0.75 speed option, you must make +.. note:: YouTube only hosts videos of up to 15 minutes. If you encode a 0.75 speed option, you must make sure that source video segments are only 11.25 minutes long so that YouTube can host all speeds. YouTube offers paid accounts that relax this restriction. +After you have uploaded the video to YouTube: -1. Upload the video that you want to YouTube. Make note of the code that appears between **watch?v** -= and **&feature** in the URL. This code appears in the green box below. +#. Under **Add New Component**, click the **video** icon. -.. image:: Images/image053.png - :width: 800 px + .. image:: Images/NewComponent_Discussion.png + + The Video component is added: -2. In Studio, go to the unit that you want. + .. image:: Images/VideoComponent_Default.png -3. Under **Add New Component,** click **Video.** -4. In the screen that appears, click **default.** +2. When the new video component appears, click **edit**.** The video editor opens and displays the Basic settings. -5. When the new video component appears, click **edit**.** A video component opens, and a sample video -begins playing automatically. + .. image:: Images/video-edit.png + +3. In the **Display Name** field, enter the name you want students to see when they hover the mouse over the icon unit icon in the course accordian. This text also appears as a header for the video. -6. In the **display_name** box, type the text that you want the student to see when the student hovers -the mouse over the icon in the bar at the top of the page. This text also appears as a headerfor the video. +#. Enter the URL of the YouTube video. -7. Change the codes in the green boxes to the YouTube codes that you noted in step 1. The first -code (immediately to the right of "0.75:") corresponds to the video at 0.75 speed, the next -corresponds to 1.0 speed, etc. + When you enter a video URL, Studio checks to see if a timed transcript for that video exists on edX. + If the transcript exists, Studio automatically associates the transcript with the video. -.. image:: Images/image055.png - :width: 800 + If your video is on YouTube, you can import a timed transcript from YouTube. This YouTube transcript overwrites the edX version of the transcript. + +#. If no transcript exists, click **Upload New Timed Transcript** to upload a transcript file from your computer. -8. Click **Save.** +#. If you want to modify the transcript, click **Download to Edit**. You can then make your changes and upload the new file. -.. note:: +#. To specify additional sources for the video, click **Add more video sources**, and enter the URL and file type for the video. - All videos embedded using the edX player begin playing automatically. - There is currently no way to turn off the autoplay feature. +#. Optionally, click **Advanced** to set the following for the video: + + * **Download Transcript**: the external URL for non-YouTube video transcripts. + * **Download Video**: the external URL to download the video. + * **Start Time** and **End Time** for the video + * **Video Sources**: URLs and filenames for other sources of the video. + * **Youtube ID**: IDs for different speed videos on YouTube. + +#. Click **Save.** -**To add a transcript for your video:** - -1. Save your srt.sjson file as **subs_YOUTUBEID.srt.sjson,** where **YOUTUBEID** is the YouTube ID of your video. - -2. Upload the **subs_YOUTUBEID.srt.sjson** file to the **Files & Uploads** page. - -3. Create a link to this file by following the steps in the Add Items to the Handouts Sidebar -section. diff --git a/docs/course_authors/source/create_welcome_announcement.rst b/docs/course_authors/source/create_welcome_announcement.rst deleted file mode 100644 index 4bf5bbd7ba..0000000000 --- a/docs/course_authors/source/create_welcome_announcement.rst +++ /dev/null @@ -1,14 +0,0 @@ - -**************************************** -Create a Welcome Announcement and E-Mail -**************************************** - - -Send e-mail (welcome and weekly) (surveys?) (TBD) - -Create welcome announcement - -Create landing page announcement (change "landing page" to "Course Info" -page) - -Create discussion forum diff --git a/docs/course_authors/source/establish_course_settings.rst b/docs/course_authors/source/establish_course_settings.rst deleted file mode 100644 index ce63aa9e4e..0000000000 --- a/docs/course_authors/source/establish_course_settings.rst +++ /dev/null @@ -1,232 +0,0 @@ -************************* -Establish Course Settings -************************* - -Add Collaborators -***************** - - - Studio has support for rudimentary collaborative editing of a course. Users must have registered at studio.edge.edx.org, and must have activated their account via the mail link. If a user is not found, you will be notified. - - - Before you add a new user, consider the following. - - - · Invited users have full permissions to edit your course, including deleting content created by anyone else. - - - · Invited users cannot currently grant new permissions on the course. - - - · Editing conflicts are currently not managed. Thus, the state of the course might change between refreshes of the page. - - - To give another user permission to edit your course: - - - 1. On the navigation bar, click **Course Settings**, and then click **Course Team**. - - - .. image:: Images/image115.png - - - - 2. Click **New User**. - - - .. image:: Images/image117.png - - - 3. In the **email** box, type the mail address of the user, and then click **Add User**. - - -.. raw:: latex - - \newpage % - - - -Add Manual Policy Data -********************** - - - - You can add manual policy data on the **Advanced Settings** page. These advanced configuration options are specified using JSON key and value - pairs. - - - You should only add manual policy data if you are very familiar with valid configuration key value pairs and the ways these pairs will affect your course. - Errors on this page can cause significant problems with your course. - - - The edX program managers can help you learn about how to apply these settings. - - - 1. On the navigation bar, click **Course Settings**, and then click **Advanced Settings**. - - - 2. Click **New Manual Policy** . - - - .. image:: Images/image119.png - - - 3. In the **Policy Key** box, enter the policy key. - - - 4. In the **Policy Value** box, enter the value of the policy. - - -.. raw:: latex - - \newpage % - - -Add About Page Information -*************************** - - - To add scheduling information, a description, and other information for your course, use the **Course Settings** menu. - - - .. image:: Images/image121.png - - - This takes you to the - -Schedule and Details Page -========================= - - -1. At the top of this page, you will find a section with the **Basic Information** for your course. It is here that you can locate the title of your course and find the URL for your course, which you can mail to students to invite students to enroll in your course. - - .. image:: Images/image281.png - - -2. In the **Course Schedule** section, enter the date you want your course to start in the **Course Start Date** box, and then enter the time you want your course to start in the **Course** **Start Time** box. - - -.. note:: - - The Course Start Time on this screen will reflect the current time zone in your browser, depending on your geography. Course start times for students will show as UTC on Edge. - - -3. In the **Course Schedule** section, enter the date you want your course to end in the **Course** **End Date** - box, and then enter the time you want your course to end in the **Course** **End Time** box. - - -Add Enrollment Information -========================== - - -1. On the navigation bar, click **Course **Settings, and then click **Schedule & Details** . - - -2. In the **Course Schedule** section, enter the date you want enrollment for your course to start in the **Enrollment Start Date** box, and then enter the time you want enrollment for your course to start in the **Enrollment Start Time** box. - - -3. In the **Course Schedule** section, enter the date you want enrollment for your course to end in the **Enrollment End Date** -box, and then enter the time you want enrollment for your course to end in the **Enrollment End Time** box. - - -.. note:: - - The Enrollment dates on this screen will reflect the current time zone in your browser, depending on your geography. Enrollment times for students will show as UTC on Edge. - - - -Add a Course Overview -===================== - - -1. On the navigation bar, click **Course Settings**, and then click **Schedule & Details** . - - -2. Scroll down to the **Introducing Your Course** section, and then locate the **Course Overview** box. - -.. image:: Images/image123.png - - - - -3. In the **Course Overview** box, enter a description of your course. - - -The content for this box must be formated in HTML. For a template that you -can use that includes placeholders, see :doc:`appendices/a`. - - - -If your course has prerequisites, you can include that information in the course overview. - - -.. note:: - - There is no save button. Studio automatically saves your changes. - - -The following is example content for the **Course Overview** box: - - -.. image:: Images/image125.png - -Add a Descriptive Picture -========================= - -1. Select a high-resolution image that is a minimum of 660 pixels in width by 240 pixels in height. - -2. Change the file name of the picture that you want to use to **images_course_image.jpg**. - -3. Upload the file to the **Files & Uploads** page. - - -The picture that is named **images_course_image.jpg** automatically appears on the course About page. - -Add an About Video -================== - - -You can create an About video that will appear on the **About** page for your course. - - -1. Upload the video that you want to YouTube. Make note of the code that appears between ** watch?v =** and ** &feature** in the URL. This code appears in the green box below. - - -.. image:: Images/image127.png - - -2. On the navigation bar, click **Course Settings**, and then click **Schedule & Details** . - - -3. Scroll down to the **Introducing Your Course** section, and then locate the **Course** **Introduction Video** - field. If you have not already added a video, you see a blank field above an **id** box. - - -.. image:: Images/image129.png - - -4. In the **your YouTube video's ID** box, enter your video code. When you add the code, the video automatically appears in the field above the **your YouTube video's ID** box. - - -.. note:: - - There is no save button. Studio automatically saves your changes. - - -For example, your course introduction video appears as follows. - - -.. image:: Images/image131.png - - -Add Weekly Time Requirements Information -======================================== - - -1. On the navigation bar, click **Course Settings**, and then click **Schedule & Details** . - - -2. Scroll down to the **Requirments** section. - - -3. In the **Hours of Effort per Week** box, enter the number of hours you expect students to work on this course each week. diff --git a/docs/course_authors/source/establish_grading_policy.rst b/docs/course_authors/source/establish_grading_policy.rst index 9e6c5f6fe8..d47fb9be6d 100644 --- a/docs/course_authors/source/establish_grading_policy.rst +++ b/docs/course_authors/source/establish_grading_policy.rst @@ -1,216 +1,165 @@ -*************************** -Establish a Grading Policy -*************************** - +.. _Establish a Grading Policy: -Overview -******** - - -Grades in edX courses are based on homework assignments and exams. - - -Setting up grading in edX Studio has several steps. These steps will be explained in more detail later in the course. To skip to the detailed information, click the links below. - +############################## +Establishing a Grading Policy +############################## -1. Establish an overall grading policy, also see :ref:`Set Grade Brackets`. - -.. image:: Images/image139.png +******************* +Overview +******************* + +Establishing a grading policy takes several steps. You must: + +#. :ref:`grade` +#. :ref:`Set the Grace Period` +#. :ref:`configure` +#. :ref:`set_assignment` +#. :ref:`student_view` + + +.. _grade: + +******************* +Set the Grade Range +******************* + +You must set the grade range for the course. For example, your course can be pass/fail, or can have letter grades A through F. + +To set the grade range, from the **Settings** menu, select **Grading**. + +The control for the grade range is at the top of the Grading page. + +.. image:: Images/grade_range.png + :width: 800 + +The above example shows that you have a pass/fail grade range, with a score of 50 as the cutoff. This is the default setting used when you create a course. + +You use the grade range control to change these settings: + +* To add a grade in the range, click the **+** icon. + + A new grade is added to the range between the existing grades. For example, if you add a grade in the default setting, + the grade range changes to **F** (0 to 50), **B** (50 to 75), and **A** (75 to 100): + + .. image:: Images/grade_range_b.png + :width: 800 + +* To change the score range, hover the mouse over the line dividing two grades, click and drag the line left or right. + + You can see the range numbers of the two grades adjacent to the line change. Release the mouse button when the line is where you want it. -This is done at the course level in the **Course Settings** menu. +* To remove a grade, hover the mouse button over the grade. + + A **remove** link appears above the grade. Click the link. - -Establish whether your course is pass-fail or graded by letter, and what the thresholds are for each grade. - - -Create assignment types for the course and determine the weight of the student's total grade for each assignment type. For example, you may have 10 homework assignments, worth a total of 50% of the grade; three exams, worth a total of 10% of the grade; and one final exam, worth 20% of the grade. By default, Studio includes four assignment types when you create the course: homework, lab, midterm exam, and final exam. You can also create additional assignment types, such as quizzes. + You cannot remove F or A. + +After you make any changes to the grade range, you must click **Save Changes** at the bottom of the page. + + +.. _Set the Grace Period: + +************************* +Set the Grace Period +************************* - -2. Create subsections that contain graded assignments in the body of the course, see :ref:`Create Subsections that Contain Graded Assignments`. - +You can set a grace period that extends homework due dates for your students. -.. image:: Images/image135.png +.. note:: The grace period applies to the whole course; you cannot set a grace period for individual assignments. + +In the Grading page, under **Grading Rules & Policies**, enter a value in the **Grace Period on Deadline** field. Enter the value in Hours:Minutes format. - -Each subsection in your course can be designated as one of the assignment types that you have specified in the grading policy. You can also specify a release date and a due date. - - -.. note:: - - You can create problems in Studio without specifying that the subsection is an assignment type. However, problems do not count toward a student's grade unless you set the subsection as a graded assignment type. +.. _configure: -For more information on creating problems, see `Create a Problem `_ . +****************************** +Configure the Assignment Types +****************************** + +You must create assignment types for your course and determine the weight of the student's total grade for each assignment type. + +For example, you may have: + +* 10 homework assignments, worth a total of 50% of the grade; +* A midterm exam, worth a total of 20% of the grade; +* A final exam, worth 30% of the grade. + +By default, a new course you create has four assignment types: + +* Homework +* Lab +* Midterm Exam +* Final Exam + +You can use these assignment types, modify or remove them, and create new assignment types. + +To create a new assignment type, in the bottom of the Grading page, click **New Assignment Type**, then configure the fields described below. + +========================== +Assignment Type Fields +========================== +You configure the following fields for each assignment type: + +* **Assignment Type Name:** + + The general category of the assignment. This name will be visible to students. -3. In the assignment subsections, create individual problems - -.. image:: Images/image137.png + .. note:: All assignments of a particular type are automatically worth the same amount. Thus, a homework assignment that contains 10 problems is worth the same percentage of a student's grade as a homework assignment that contains 20 problems. -You can then establish the settings for these problems (including the number of attempts a student has and the problem's point value, or weight). +* **Abbreviation:** + + This is the short name that appears next to an assignment on a student's **Progress** tab. + +* **Weight of Total Grade:** + + The assignments of this type together account for the percent value set in **Weight of Total Grade**. + + The total weight of all assignment types must equal 100. + + .. note:: Do not include the percent sign (%) in this field. + + + +* **Total Number:** + + The number of assignments of this type that you plan to include in your course. + + + +* **Number of Droppable** + + The number of assignments of this type that the grader will drop. The grader will drop the lowest-scored assignments first. + + +.. _set_assignment: + +********************************************** +Set the Assignment Type for Graded Subsections +********************************************** +After you configure assignment types, as you are organizing your course, +you set the assignment type for Subsections that contain problems that are to be graded. + +You can designate a Subsection as one, and only one, of the assignment types you configured. You can also set a due date. + +See :ref:`subsections` for instructions on configuring a Subsection. + +Within a graded Subsection, you create problems of the type designated for that Subsection. +You should not mix problems of different assignment types in the same Subsection. + +For example, if you want to create a homework assignment and a lab for a specific topic, create two Subsections. +Set one Subsection as the Homework assignment type and the other as the Lab assignment type. +Both Subsections can contain other content as well as the actual homework or lab problems. + +.. note:: You can create problems in Studio without specifying that the Subsection is an assignment type. However, such problems will not count toward a student's grade. + +See :ref:`Working with Problem Components` for instructions on creating problems. + +.. _student_view: + +************************** +The Student View of Grades +************************** Once a grading policy is in place, students can view both their problem scores and the percent completed and current grade at the top of their **Progress** tab for the course. -Additionally, as an instructor, you can access your students' scores on graded content. On the live published page of your course on Edge (not from the Preview page in Studio), click the **Instructor** tab. Many options appear for viewing or downloading a snapshot of the currently stored student grades. On the **Instructor** tab you can also request a link to a view of the student's individual progress page, including both graded and not graded scores. For more information, see Progress and Certificates. - - -.. _Set-Grade-Brackets: - -Set Grade Brackets -++++++++++++++++++ - -To set the thresholds for course grades: - -1. On the navigation bar, click **Course Settings**, and then click **Grading**. - -2. Under **Overall Grade Range**, click and drag the dividing line between grade divisions to move each threshold up or down. - -.. note:: - - The default grade divisions are Pass and Fail. To add more grade divisions (such as A, B, C, or D), click the plus sign (+). - - -.. image:: Images/image133.png - -To remove a grade division, hover the mouse over the grade division (shown above) and then click the **Remove** link that appears above the grade division. - -.. _Set-Grace-Period: - -Set a Grace Period -++++++++++++++++++ - -You can set a grace period that extends homework due dates for your students. Note that this setting applies to the whole course; you cannot set a grace period for individual assignments. - - -1. On the navigation bar, click **Course Settings**, and then click **Grading**. - - -2. Under **Grading Rules & Policies** enter a value in the **Grace Period on Deadline** box. - - -Create Assignment Types -+++++++++++++++++++++++ - - -By default, Studio includes four assignment types for your course when you create the course: homework, lab, midterm exam, and final exam. You decide the weight of the student's total grade for each assignment type. - - -To set an assignment type: - - -1. On the navigation bar, click **Course Settings**, and then click **Grading**. - - -2. Under **Assignment Types**, locate the settings for the assignment type that you want. - - -If you want to create a new assignment type, scroll to the bottom of the page, and then click **New Assignment Type**. - - -3. Enter values in each of the following boxes. - - -**Assignment Type Name:** -This is a general category of assessment (homework, exam, exercise). All assignments within an assignment type are given equal weight. This name will be visible to students. - - -**Abbreviation:** -This is the short name that will appear next to an assignment on every student's **Progress** tab (see below). - -.. image:: Images/image141.png - - -**Weight of Total Grade:** -The assignments of a particular type together account for the percent value set in **Weight of Total Grade**. - - -**Total Number:** -The number of assignments of that type that you plan to present in your course. - - -**Number of Droppable** -(optional): Specify the number of assignments that the grader will drop. The grader will omit the lowest-scored assignments first. - - -For example, the following course has two types of assignments. The overall course grade is broken down as 40% Homework and 60% Final Exam. There are eight Homework assignments, and the grader will omit the lowest-scored assignment from the final grade. Thus, the seven remaining Homework assignments are each worth 40 7 = 5.8% of the final grade. - -.. image:: Images/image143.png - -Troubleshooting -+++++++++++++++ - -If you have problems creating assignment types, try the following. - -In the **Weight of Total Grade** field, omit the % sign. Be sure that your **Weight of Total Grade** fields add up to 100. - -.. _Create-Graded-Subsections: - - -.. raw:: latex - - \newpage % - -Create Subsections Containing Graded Assignments -************************************************** - -After you establish your grading rubric, you can create a graded assignment or a test for your students. To do this, you must first create a subsections and then set up grading for the subsection. This includes setting the assignment type, the assignment release date, and the due date. - - -.. note:: - - When you set a due date, keep in mind that students will be in different time zones across the world. By default, the time zone appears as UTC-not the student's local time. If you tell your students an assignment is due at 5:00 PM, make sure to specify that the time is 5:00 PM UTC and point them to a time converter. - - -Alternatively, you can :ref:`set a grace period` for your assignments to cover any misunderstandings about time. For example, some classes have set a grace period of 1 day, 6 hours, and 1 minute. This grace period applies to the entire course. - -Keep in mind that a subsection can only have one assignment type. If you want to create a homework assignment and a lab for a specific topic, you would create two subsections for that topic. You would set one subsection as the Homework assignment type and the other as the Lab assignment type. Both subsections can contain other content as well as the actual homework or lab problems. - - -All assignments of a particular type are automatically worth the same amount. Thus, a homework assignment that contains 10 problems is worth the same percentage of a student's grade as a homework assignment that contains 20 problems. If you want the assignment with 20 problems to be worth twice as much as the assignment with 10 problems, you can create two assignments. - - -1. On the navigation bar, click **Course Content**, and then click **Outline**. - - -.. image:: Images/image145.png - - -2. Under **Course Outline**, locate the section where you want to add an assignment. - -3. Under the name of the section, click **New Subsection**. - -4. In the text box, replace **New Subsection** with the name of your subsection, and then click **Save**. - -Click the subsection you want. The edit page for the subsection opens. In the top right corner of the page, locate the **Subsection Settings** box. - - -.. image:: Images/image147.png - -Set the assignment type. To do this, locate the blue link next to **Graded as**. Because all subsections are set to **Not Graded** by default, the text for this link is **NOT GRADED**. - -.. image:: Images/image149.png - -Click this link to open a list of the assignment types that you specified in your grading rubric, and then click the assignment type that you want. - - -.. image:: Images/image151.png - -Set the assignment's release date and time. To set the date, click inside the **Release date** field, and then select the date that you want in the calendar that appears. To set the release time, click inside the time input field, and then specify the time you want. - -Set a due date for the assignment. To do this, click the blue **SET A DUE DATE** link, and click inside the **Due date** box, and then select the date you want in the calendar that appears. To set the time, click inside the time input field, and then specify the time you want. - -Change a Subsection's Assignment Type -+++++++++++++++++++++++++++++++++++++ - -To set the assignment type for a subsection: - -1. On the navigation bar, click **Course Content**, and then click **Course Outline**. - -2. Under **Course Outline**, locate the subsection that you want. - -3. On the right side of the screen, click the blue check mark for the subsection, and then select the assignment type. - -.. image:: Images/image153.png - -.. note:: - - If you change an assignment type name in the Grading page, make sure the assignment type names on the Course Outline still match. +ADD IMAGE \ No newline at end of file diff --git a/docs/course_authors/source/export_import_course.rst b/docs/course_authors/source/export_import_course.rst index db45b4a225..9fc5c3c6f3 100644 --- a/docs/course_authors/source/export_import_course.rst +++ b/docs/course_authors/source/export_import_course.rst @@ -1,116 +1,86 @@ - -************************* -Export or Import a Course -************************* - -Studio has an Import tool and an Export tool that allow you to import and -export courses. +.. _Exporting and Importing a Course: - +##################################### +Exporting and Importing a Course +##################################### + +You can :ref:`Export a Course` and :ref:`Import a Course` through Studio. + +.. _Export a Course: + +*************** Export a Course *************** - -You can export a course that has been created in Studio. You can export a -course for use by another instructor, or you can back up your course. - - -For example, you may create a course in Studio, and then run that course. A -friend or colleague, including a friend from another institution, may be -interested in running their own customized version of that course. You can -export the course that you have created and give it to the other instructor. -That instructor can then import the course, make any changes that are -necessary to reflect that instructor's situation, and then release the -course to students. - - -Or, you may export your course, and then make changes to your course in -Studio. If you later want to revert to the earlier version of your course, -you can import the version that you exported. Be careful if you do this -while you are running your course, as you could lose your students' work. +There are several reasons you may want to export your course: + +* To edit the XML in your course directly +* To create a backup copy of your course, which you can import if you want to revert the course back to a previous state +* To create a copy of your course that you can later import into another course instance and customize +* To share with another instructor for another class When you export your course, Studio creates a **.tar.gz** file that includes the following course data: - -1. Course structure (the order of sections and subsections) - - -2. Individual units - - -3. Individual problems - - -4. Additional pages - - -5. Files on the Files & Uploads page +* Course content (all Sections, Subsections, and Units) +* Course structure +* Individual problems +* Static pages +* Course assets +* Course settings +The following data is not exported with your course: -The exported file does not include the following data: +* User data +* Course team data +* Forum/discussion data +* Certificates + +To export a course: - -1. Student or user data - - -2. Discussion forum data - - -3. Course settings - - -4. Certificates - - -5. Grading information +#. From the **Tools** menu, select **Export**. +#. Click **Export Course Content**. + +When the export completes you can then access the .tar.gz file on your computer. -.. raw:: latex - - \newpage % - +.. _Import a Course: +*************** Import a Course *************** - - .. warning:: - This feature should be used with caution! - Importing a new course will delete all course content currently associated - with your course and replace it with the contents of the uploaded file. - Importing a course cannot be undone. - - -You can import courses that have already been created in Studio. These can -be courses that you or someone else has created and exported. - - -The file that you import must be a **.tar.gz** file that contains, at a -minimum, a Course.xml file in a course data directory. The tar.gz file must -have the same name as the course data directory. + Content of the imported course replaces all the content of this course. + **You cannot undo a course import**. We recommend that you first export the current course, + so you have a backup copy of it. +There are several reasons you may want to import a course: + +* To run a new version of an existing course +* To replace an existing course +* To load a course you developed outside of Studio + + +The course that you import must be in a .tar.gz file (that is, a .tar file compressed with GNU Zip). +This .tar.gz file must contain a course.xml file in a course data directory. The tar.gz file must +have the same name as the course data directory. It may also contain other files. If your course uses legacy layout structures, you may not be able to edit the course in Studio, although it will probably appear correctly on Edge. To make sure that your course is completely editable, ensure that all of your material is embedded in a unit. +The import process has five stages. During the first two stages, you must stay on the Course Import page. +You can leave this page after the Unpacking stage has completed. We recommend, however, +that you don't make important changes to your course until the import operation has completed. To import a course: - -1. On the navigation bar, click **Tools**, and then click **Import**. - - -.. image:: Images/image243.png - - -2. Under **Course to Import**, click **Choose File**. - - -3. Locate the file that you want, and then click **Open**. +#. From the **Tools** menu, select **Import**. +#. Click **Choose a File to Import**. +#. Locate the file that you want, and then click **Open**. +#. Click **Replace my course with the one above**. diff --git a/docs/course_authors/source/get_started.rst b/docs/course_authors/source/get_started.rst index b7d854abe9..138fafb545 100644 --- a/docs/course_authors/source/get_started.rst +++ b/docs/course_authors/source/get_started.rst @@ -1,178 +1,176 @@ .. image:: Images/image001.png + :width: 800 +.. _Getting Started with Studio: ########################### Getting Started with Studio ########################### - -************ -Introduction -************ +*************** +Overview +*************** -Since the launch of edX to our original partners, we have been working to provide opportunities for additional educators to create courses on our platform. The fruits of our efforts are Edge and Studio. These tools are available not only to our edX partners, but to all faculty at consortium universities. +This chapter describes the tools you use to build an edX course, and how to create your first course: -EdX (http://edx.org) is our original, premiere learning portal. Publication to -edX is available on a limited basis, depending on your university’s agreement -with edX. You need specific approval from your university to release your -course on the edX portal. Once a course is released on the edX portal, it -becomes a publicly available massively open online course (MOOC). +* :ref:`What is Studio?` +* :ref:`What is Edge?` +* :ref:`Get Started on Edge` +* :ref:`Use Studio on Edge` +* :ref:`Create Your First Course` +* :ref:`View Your Course on Edge` +* :ref:`Register Your Course on edX.org` + +If you are using an instance of Open edX, some specifics in this chapter may not apply. + +.. _What is Studio?: + +*************** +What is Studio? +*************** + +Studio is the edX tool you use to build your courses. + +You use Studio to create course content, problems, videos, and other resoruces for students. + +With Studio, you can also manage your schedule and course team, set grading policies, publish your course, and more. + +You use Studio directly through your browser. You do not need any additional software. -Edge (http://edge.edx.org) is our newest online learning portal. It is almost identical to edX.org both visibly and functionally. +.. _What is Edge?: -Edge is where you view the content you create with Studio, our course authoring -tool. Courses on Edge cannot be seen publicly; rather, only you, your -colleagues, and the students with whom you explicitly share a course link can -see your course. Instructors are encouraged to use Edge to experiment with -creating courses. You do not need approval to release a course on Edge--you can -create a course and release it immediately. +****************** +What is Edge? +****************** +EdX Edge_ is the site where you can create courses with Studio, then run courses through the edX Learning Management System. -Studio (http://studio.edge.edx.org) is our web-based course authoring tool. It is the easiest way for educators to develop courses for the edX platform. You can create courses in Studio and view and enroll in them instantly on Edge—even before you have finished creating the course. +Visually and functionally, edX Edge is the same as edX.org_. +However, on Edge you can freely publish courses. +There is no course catalog on Edge and other users will not find your course. You must explicitly invite students to participate in your course. +Courses on Edge are not published on edX.org. All course data and accounts on Edge and edX.org are separate. +To publish courses on edX.org, you must have an agreement with edX and specific approval from your university. -**There is a workflow to getting started.** Here is a quick summary: - - - -1. First, go to: https://studio.edge.edx.org. Sign up and create an account. - - -.. image:: Images/image009.png - :width: 800 - - -**then** - - -2. Create a course in Studio. - - -.. image:: Images/image021.png - :width: 800 - - -**then** - - -3. View your course on Edge. - - -.. image:: Images/image027.png - :width: 800 - -.. raw:: latex - - \newpage % - - -***************** -Create an Account -***************** - -To begin using Studio, create an account with a unique user ID and password. A Studio account isn't the same as an edX account. Even if you already have an edX account, you still need to create a separate Studio account. You can use the same e-mail address for both accounts. - -When you create your account on Studio, an account on Edge is automatically created using the same user name and password. You don't have to create separate accounts on Studio and Edge. - -Edge has two views - an Instructor view and a Student view. When you view your course on Edge, you view your course as an instructor, and you see the **Instructor** tab at the top of the page. - -.. image:: Images/image007.png - :width: 800 - - -Your students will view your courses in Student view, which is similar to Instructor view, but does not include the Instructor tab or release dates. - -To create an account: - -1. Go to http://studio.edge.edX.org. The Welcome to edX Studio page opens. - -.. image:: Images/image009.png - :width: 800 +.. _Edge: http://edge.edx.org +.. _edX.org: http://edx.org -2. Scroll to the bottom of the page and click **Sign Up & Start Making an edX Course.** +.. _Get Started on Edge: + +******************* +Get Started on Edge +******************* -.. image:: Images/image011.png - :width: 800 +Go to https://edge.edx.org, click **Register**, and fill out required information to create your account. - -3. In the page that opens, fill in the fields. Required fields are marked with an asterisk (*). +.. note:: Students will see your **Public Username**, not your **Full Name**. +.. note:: If you are at an edX consortium university, you should use your institutional e-mail + address. -.. image:: Images/image013.png - :width: 800 +After you click \ **Create My Account**, you will receive an activation +e-mail message. To finish creating your account, click the link in the e-mail. - -a. In the **Email Address** box, type your e-mail address. -b. In the **Password** box, type a unique password. -c. In the **Public Username** field, type the name you want students to see when you post on the user forum. Most websites call this the "user name". -d. In the **Full Name** box, type your full name. This name only appears in Studio. - Your students will not see this name. -e. Click to select I agree to the Terms of Service. +When you log in to Edge, you can view edX101_. -.. image:: Images/image017.png - :width: 800 +edX101 is both an example of a course you can build with Studio, +and a self-paced walk through of planning, building, and running your own online course. -4. Click **Create My Account & Start Authoring Courses,** After you click this button, the following page opens. +.. _edX101: https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about -.. image:: Images/image015.png - :width: 800 +.. _Use Studio on Edge: -5. To finish creating your account, click the link that you receive in the activation e-mail. When you click this link, the following page opens. +****************** +Use Studio on Edge +****************** -.. image:: Images/image017.png - :width: 800 +You can use Studio_ on Edge to build your own courses. -.. raw:: latex +Go to: https://studio.edge.edx.org. Sign in with the account you created on Edge. + +You must then request access to create courses: + +#. Click the **+** sign to expland the field labeled **Becoming a Course Creator in Studio**. + +#. Click **Request the Ability to Create Courses**. + +EdX then evaluates your request. When course creation permissions are granted, you receive an email message. + +.. _Studio: https://studio.edge.edx.org - \newpage % +.. _Create Your First Course: + +*************************** +Create Your First Course +*************************** -******************* -Create a New Course -******************* +When you receive notice that you can create courses, log into Studio_. -The first time that you log in to Studio, the following page opens. +You see the following page: -.. image:: Images/image017.png - :width: 800 - -Click the **dashboard** link, and the **My Courses** page opens. +.. image:: Images/first_course.png + :width: 800 -.. image:: Images/image021.png - :width: 800 +#. Click **Create Your First Course**. +#. Enter course information as needed and click **Create**. -Once you have created a course in Studio, it will be listed on this page. From here, you can start creating courses immediately by clicking the **New Course** button. + .. image:: Images/new_course_info.png + :width: 800 + .. note:: Enter new course information carefully. This information becomes part of the URL for your course. To change the URL after the course is created, you must contact edX through the Help site (http://help.edge.edx.org). Additionally, because this information becomes part of your course URL, the total number of characters in the following three fields must be 65 or fewer. -To create a new course: + * For **Course Name**, enter the title of your course. For example, the name may be “Sets, Maps and Symmetry Groups". Use title capitalization for the course title. -1. Click **New Course.** A screen opens. + * For **Organization**, enter the name of your university. Do not include whitespace or special characters. + * For **Course Number**, enter both a subject abbreviation and a number. For example, for public health course number 207, enter **PH207**. For math course 101x, enter **Math101x**. Do not include whitespace or special characters in the course number. -.. note:: + *Note: If your course will be open to the world, be sure to include the "x". If it is exclusively an on-campus offering, do not include the "x".* - Enter your new course information carefully. This information becomes part of the URL for your course. To change the URL after the course is created, you must contact edX through the Help site (http://help.edge.edx.org).Additionally, because this information becomes part of your course URL, the total number of characters in the following three fields must be 65 or fewer. +3. Click **Save.** +You then see the empty Course Outline. -2. For **Course Name**, enter the title of your course. For example, the name may be “Sets, Maps and Symmetry Groups". Use title capitalization for the course title. +In your browser’s address bar, notice that the URL of your course includes the course organization, number, and course run. -3. For **Organization**, enter the name of your university. Do not include whitespace or special characters. - -4. For **Course Number**, enter both a subject abbreviation and a number. For example, for public health course number 207, enter "PH207". For math course 101x, enter “Math101x”. Do not include whitespace or special characters in the course number. - - *Note: If your course will be open to the world, be sure to include the "x". If it is exclusively anon-campus offering, do not include the "x".* - -5. Click **Save.** - -.. image:: Images/image025.png - :width: 800 +The rest of this documentation describes how you now build and run your course. But first, lets view your empty course on Edge. + +.. _View Your Course on Edge: -If you click **View Live** your course appears as follows on Edge. +************************ +View Your Course on Edge +************************ +You can now view the course you just created, even though it doesn't have any content. -.. image:: Images/image027.png +In the Course Outline in Studio, click **View Live**. The course opens on Edge. + +You can also go directly to Edge_. Log in if prompted. You see the course you just created listed: + +.. image:: Images/new_course.png :width: 800 -*Note: Although the start date is set to the current date by default, your course will not be advertised, so it will not be visible to the general public. You can change the start date of your course in Studio.* +You can view the course and see that there is no content yet. + +To build your course, keep reading this document. + +.. _Register Your Course on edx.org: + +************************************ +Register Your Course on edX.org +************************************ + +If you're creating your course on **edX**, you must register +for your course. + +#. On the **Course Outline** page, click the blue **View + Live** button in the upper-right corner of your screen. + + Your course registration page opens in a new tab on the LMS. + +#. Click the blue **Register** button to register for your course. +#. In your browser, switch back to the tab that shows Studio. You will + still be on the **Course Outline** page. diff --git a/docs/course_authors/source/index.rst b/docs/course_authors/source/index.rst index 1708770eec..efba027ddc 100755 --- a/docs/course_authors/source/index.rst +++ b/docs/course_authors/source/index.rst @@ -10,37 +10,32 @@ Contents .. toctree:: + :numbered: :maxdepth: 5 read_me get_started - create_lesson - create_section_sub_section - create_unit + create_new_course + establish_grading_policy + organizing_course + create_html_component create_video create_discussion - create_html_component + create_problem_component + common_problems + advanced_problems + javascript_input create_lti - create_problem + specialized_problems + open_response_assessment set_content_releasedates - establish_course_settings - establish_grading_policy - add_syllabus view_course_content - modify_published_content export_import_course - create_welcome_announcement - create_seed_wiki - invite_students_to_register checking_student_progress change_log - - - - Appendices ========== diff --git a/docs/course_authors/source/invite_students_to_register.rst b/docs/course_authors/source/invite_students_to_register.rst deleted file mode 100644 index 4efeee3cf8..0000000000 --- a/docs/course_authors/source/invite_students_to_register.rst +++ /dev/null @@ -1,29 +0,0 @@ - -*************************** -Invite Students to Register -*************************** - -To invite students to register for your course on Edge through the course -registration page, direct students to the registration page, and provide -instructions for completing the registration process. - - -1. Determine the link to your class registration page on Edge. To do this: - - -a. Click the **Settings **tab of your course in Studio, and then locate the -**Course Details** section. - - -b. Under **Basic Information**, you will see a link to email and invite -students to enroll in your course. - - -c. Click "**Invite Your Students**" link. Clicking the link creates an email -template. - - -.. image:: Images/image286.png - - -d. Email this to your chosen mail list. \ No newline at end of file diff --git a/docs/course_authors/source/javascript_input.rst b/docs/course_authors/source/javascript_input.rst new file mode 100644 index 0000000000..639e30a4d2 --- /dev/null +++ b/docs/course_authors/source/javascript_input.rst @@ -0,0 +1,113 @@ +.. _JavaScript Input: + +JavaScript Input +---------------- + +The JavaScript Input problem type allows you to create your own learning tool +using HTML and other standard Internet languages and then add the tool directly +into Studio. When you use this problem type, Studio embeds your tool in an +IFrame so that your students can interact with it in the LMS. You can grade +your students' work using JavaScript and some basic Python, and the grading +is integrated into the edX grading system. + +This problem type doesn't appear in the menu of advanced problems in Studio. To +create a JavaScript input problem type, you'll create a blank advanced problem, +and then enter your code into the component editor. + +.. image:: /Images/JavaScriptInputExample.gif + +Create a JavaScript Input Problem +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. Create your JavaScript application, and then upload all files associated with + that application to the **Files & Uploads** page. +#. In the unit where you want to create the problem, click **Problem** + under **Add New Component**, and then click the **Advanced** tab. +#. Click **Blank Advanced Problem**. +#. In the component that appears, click **Edit**. +#. Click the **Settings** tab. +#. Set **Maximum Attempts** to a number larger than zero. +#. In the component editor, enter your code. +#. Click **Save**. + +To re-create the example problem above, follow these steps. + +#. Go to :ref:`Appendix F` and create the following files: + + - webGLDemo.html + - webGLDemo.js + - webGLDemo.css + - three.min.js + +#. On the **Files & Uploads** page, upload the four files you just created. +#. Create a new blank advanced problem component. +#. On the **Settings** tab, set **Maximum Attempts** to a number larger than + zero. +#. In the problem component editor, paste the code below. +#. Click **Save.** + + + +JavaScript Input Problem Code +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:: + + + In the image below, click the cone. + + + + + + + + +**Notes** + +- The webGLDemo.js file defines the three JavaScript functions (**WebGLDemo.getGrade**, + **WebGLDemo.getState**, and **WebGLDemo.setState**). + +- The JavaScript input problem code uses **WebGLDemo.getGrade**, **WebGLDemo.getState**, + and **WebGLDemo.setState** to grade, save, or restore a problem. These functions must + be global in scope. + +- **WebGLDemo.getState** and **WebGLDemo.setState** are optional. You only have to define + these functions if you want to conserve the state of the problem. + +- **Width** and **height** represent the dimensions of the IFrame that holds the + application. + +- When the problem opens, the cone and the cube are both blue, or "unselected." When + you click either shape once, the shape becomes yellow, or "selected." To unselect + the shape, click it again. Continue clicking the shape to select and unselect it. + +- The response is graded as correct if the cone is selected (yellow) when the user + clicks **Check**. + +- Clicking **Check** or **Save** registers the problem's current state. \ No newline at end of file diff --git a/docs/course_authors/source/modify_published_content.rst b/docs/course_authors/source/modify_published_content.rst deleted file mode 100644 index fe13490be7..0000000000 --- a/docs/course_authors/source/modify_published_content.rst +++ /dev/null @@ -1,62 +0,0 @@ -*************************** -Modifying Published Content -*************************** - -When you set a Unit to Public, that content appears on edX or Edge when you -view the content as an instructor. If the release date has passed, the -content is also visible to students. - - -If you want to modify content after it has been set to Public, you must -create a draft. The draft does not appear on edX or Edge. However, the draft -does appear when you view your content in Preview mode. - - -To create a draft, open the Unit that you want. Note that no **Edit** button -appears on the page for that Unit, and you cannot make changes to the Unit. - - -.. image:: Images/image231.png - :width: 600 - - -In the right pane, click the blue **edit a draft** link in the **Unit -Settings** box. - - -.. image:: Images/image233.png - :width: 600 - - -After you click **edit a draft**, you can make changes to the Unit. You can -edit existing content or add new content. - - -.. image:: Images/image235.png - :width: 600 - - -If you want to view the version of your content that is currently live, -click **Preview the published version **in the yellow banner at the top of -the page. - - -.. image:: Images/image237.png - :width: 600 - - -If you want to view the draft version that you are working on, click -**Preview** under **Unit Settings**. This opens your course in Preview -mode.**** - - -.. image:: Images/image239.png - :width: 600 - - -When you are done making changes to the Unit, click the blue **replace it -with this draft** link under **Unit Settings**. - - -.. image:: Images/image241.png - :width: 600 diff --git a/docs/course_authors/source/open_response_assessment.rst b/docs/course_authors/source/open_response_assessment.rst new file mode 100644 index 0000000000..438b7fbdaf --- /dev/null +++ b/docs/course_authors/source/open_response_assessment.rst @@ -0,0 +1,636 @@ +.. _Open Response Assessment Problems: + +Open Response Assessment Problems +--------------------------------- + +Introduction to Open Response Assessments +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Note** Open response assessments are still in beta. We recommend that +you test them thoroughly in a practice course and only add them to +courses that are **not** already running. Contact your edX Program Manager for more information. + +Open response assessments allow instructors to assess student learning +through questions that may not have definite answers. Tens of thousands +of students can receive feedback on written responses of varying lengths +as well as files, such as computer code or images, that the students +upload. Open response assessment technologies include self assessment, +peer assessment, and artificial intelligence (AI) assessment (sometimes +called "machine assessment" or "machine grading"). With self +assessments, students learn by comparing their answers to a rubric that +you create. With peer assessments, students compare their peers' answers +to the rubric. + +A Few Notes about Open Response Assessments +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Open response assessment technology is still in beta.** For a good +experience with open response assessments, you'll need to follow a few +guidelines. + +- Do not create a new open response assessment in a running course. + Only create open response assessments in a test course. +- If your course will include open response assessments, add and + thoroughly test all the open response assessments *before* the course + is live. +- Set open response assessments to be optional, ungraded, or droppable + exercises until you've used the technology a few times and have + become familiar with it. +- Use open response assessments sparingly at first. Only include a few + in your course, and make sure that you have contingency plans in case + you run into problems. + +Finally, if you're at an edX consortium university and you plan to +include open response assessments in a MOOC, make sure to notify your +edX project manager (PM). + +Components of an Open Response Assessment +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An open response assessment has three elements: + +- The assessment type or types--self, peer, or artificial intelligence + (AI). The type of assessment and the order in which the assessments + run appears in the upper right corner of the ORA problem. In the + following example, the student performs a self assessment, then peers + perform peer assessments, and then an AI assessment runs. + + .. image:: Images/CITL_AssmtTypes.gif + +- The question that you want your students to answer. This appears near + the top of the component, followed by a field where the student + enters a response. + + .. image:: Images/CITLsample.gif + +- A rubric that you design. After the student enters a response and + clicks **Submit**, if the assessment is a self assessment, the + student sees the rubric below his answer and compares his answer to + the rubric. (If the assessment is an AI or peer assessment, the + student sees a "Your response has been submitted" message but doesn't + see the rubric.) + + .. image:: Images/CITL_SA_Rubric.gif + +Open Response Assessment Types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There are three types of assessments for ORAs: self assessment, AI +assessment, and peer assessment. + +- Self assessment allows students to answer a question, and then assess + their response according to the rubric you created for the question. +- In AI assessment, a computer algorithm learns how to grade according + to the rubric from 100 or more instructor-graded responses, and + attempts to grade the rest of the student responses in the same way. +- Peer assessment allows students to score each other and provide + feedback, again using the same rubric. + +You can use one or more of these assessments in any problem. You can +also set thresholds within the problem for each assessment, so that a +response with a low score in one assessment does not move on to the next +assessment. + +Effective Questions +~~~~~~~~~~~~~~~~~~~ + +When you write your question, we recommend that you specify an +approximate number of words or sentences that a student's response has +to have in the body of your question. You may also want to provide +information about how to use the LMS. If you require students to upload +a file as a response, you can provide specific instructions about how to +upload and submit their files. You can let students know what to expect +after they submit responses. You can also mention the number of times +that a student will be able to submit a response for the problem. + +Rubrics +~~~~~~~ + +The same rubric is used for all three ORA types, and it can include +anything that you want it to include. + +In Studio, rubrics are arranged by *categories*. Each category has two +or more *options*, and each option has a point value. + +Options must be listed in ascending order starting at 0 points. For +example, in a category with three options, the first option is worth 0 +points, the second is worth 1 point, and the third is worth 2 points. +The person or algorithm that grades the problem selects one value for +each category. + +Different categories in the same problem can have different numbers of +options. + +Create an Open Response Assessment Problem +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Creating an open response assessment is a multi-step process. + +#. Create the component for the open response assessment. +#. Add the question. +#. Add the rubric. +#. Set the assessment type and scoring. +#. Set the problem name. +#. Set other options. +#. Save the problem. +#. Add the peer grading interface (for peer assessments only). +#. Test the problem. + +Each of these steps is described in detail below. + +1. Create the Component +~~~~~~~~~~~~~~~~~~~~~~~ + +#. Add the advanced component for open response assessments. To do this, + add the "peergrading","combinedopenended" key value to the **Advanced + Settings** page. (For more information, see the instructions in + Specialized Problems.) +#. In Studio, open the unit where you want to create the ORA. +#. Under **Add New Component**, click **Advanced**, and then click + **Open Response Assessment**. +#. In the problem component that appears, click **Edit**, and then click + **OK** in the dialog box that appears. +#. The component editor opens. The component editor contains a sample + question ("prompt"), rubric, assessment type specification, and + scoring. You'll replace this sample content with the content for your + problem. + +2. Add the Question +~~~~~~~~~~~~~~~~~~~ + +- In the component editor, locate the [prompt] tags. + + .. image:: Images/ORA_Prompt.gif + +Replace the sample text between the **[prompt]** tags with the text of +your question. When you replace the sample text, make sure you follow +these guidelines to avoid common formatting mistakes. + +- Leave the **[prompt]** tags in place. +- Enclose all text in HTML tags. + +3. Add the Rubric +~~~~~~~~~~~~~~~~~ + +#. In the component editor, locate the [rubric] tags. (The sample rubric + is long, so you'll have to scroll down to locate the second tag.) + + .. image:: Images/ORA_Rubric.gif + +#. Replace the sample rubric with the text of your rubric. Make sure to + do the following. + +- Include the beginning and ending [rubric] tags. +- Precede the categories with a plus (+) sign. +- Precede the options with a minus (-) sign. + +- List the option that scores zero points first, followed by the option + that scores one point, and so on. + + For example, your rubric might resemble the following rubric. + +:: + + [rubric] + + + Writing Applications + - The essay loses focus, has little information or supporting details, and the organization makes it difficult to follow. + - The essay presents a mostly unified theme, includes sufficient information to convey the theme, and is generally organized well. + + + Language Conventions + - The essay demonstrates a reasonable command of proper spelling and grammar. + - The essay demonstrates superior command of proper spelling and grammar. + + [rubric] + +4. Set the Assessment Type and Scoring +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To set the assessment type and scoring for your open response +assessment, you'll enter code that specifies the type and order of +assessments to use along with the scoring thresholds for each +assessment. The code uses the following format. + +:: + + [tasks] + (Type 1), ({min-max}Type 2), ({min-max}Type 3) + [tasks] + +- The **[tasks]** tags surround the code. +- **Type 1**, **Type 2**, and **Type 3** are the names of the types of + assessments. Assessments run in the order in which they're listed. +- **min** is the point value the response must receive in the previous + assessment to move to this assessment. Note that you do not define a + scoring threshold for the first assessment, because there is no + required previous assessment. +- **max** is the maximum point value for the assessment. The maximum + score is the second number in the pair of numbers for each assessment + after the first assessment. + +For example, a problem might contain the following code. + +:: + + [tasks] + (Self), ({5-7}Peer), ({4-7}AI) + [tasks] + +The problem that includes this code has the following characteristics. + +- The problem has a self assessment, a peer assessment, and then an AI + assessment. +- The maximum score for the problem is 7. +- To advance to the peer assessment, the response must have a self + assessment score of 5 or greater. +- To advance to the AI assessment, the response must have a peer + assessment score of 4 or greater. + +Set the Type and Scoring +^^^^^^^^^^^^^^^^^^^^^^^^ + +#. In the component editor, locate the [tasks] tags. + + .. image:: Images/ORA_Tasks.gif + +#. Replace the sample code with the code for your problem. + +5. Set the Problem Name +~~~~~~~~~~~~~~~~~~~~~~~ + +The name of the problem appears as a heading above the problem in the +courseware. It also appears in the list of problems on the **Staff +Grading** page. + +.. image:: Images/ORA_ProblemName1.gif + +To change the name: + +#. In the upper-right corner of the component editor, click + **Settings**. +#. In the **Display Name** field, replace **Open Response Assessment** + with the name of your problem. + +6. Set Other Options +~~~~~~~~~~~~~~~~~~~~ + +If you want to change the problem settings, which include the number of +responses a student has to peer grade and whether students can upload +files as part of their response, click the **Settings** tab, and then +specify the options that you want. + +.. image:: Images/ORA_Settings.gif + +Open response assessments include the following settings. + ++---------------------------------------------+--------------------------------------------------------------------+ +| **Allow "overgrading" of peer submissions** | This setting applies only to peer grading. If all of the responses | +| | for a question have been graded, the instructor can allow | +| | additional students to grade responses that were previously | +| | graded. This can be helpful if an instructor feels that peer | +| | grading has helped students learn, or if some students haven't | +| | graded the required number of responses yet, but all available | +| | responses have been graded. | ++---------------------------------------------+--------------------------------------------------------------------+ +| **Allow File Uploads** | This setting specifies whether a student can upload a file, such | +| | as an image file or a code file, as a response. Files can be of | +| | any type. | ++---------------------------------------------+--------------------------------------------------------------------+ +| **Disable Quality Filter** | This setting applies to peer grading and AI grading. When the | +| | quality filter is disabled (when this value is set to True), | +| | Studio allows submissions that are of "poor quality" (such as | +| | responses that are very short or that have many spelling or | +| | grammatical errors) to be peer graded. For example, you may | +| | disable the quality filter if you want students to include URLs to | +| | external content—otherwise Studio sees a URL, which may contain a | +| | long string of seemingly random characters, as a misspelled word. | +| | When the quality filter is enabled (when this value is set to | +| | False), Studio does not allow poor-quality submissions to be peer | +| | graded. | ++---------------------------------------------+--------------------------------------------------------------------+ +| **Display Name** | This name appears in two places in the LMS: in the course ribbon | +| | at the top of the page and above the exercise. | ++---------------------------------------------+--------------------------------------------------------------------+ +| **Graded** | This setting specifies whether the problem counts toward a | +| | student's grade. By default, if a subsection is set as a graded | +| | assignment, each problem in that subsection is graded. However, if | +| | a subsection is set as a graded assignment, and you want this | +| | problem to be a "test" problem that doesn't count toward a | +| | student's grade, you can change this setting to **False**. | ++---------------------------------------------+--------------------------------------------------------------------+ +| **Maximum Attempts** | This setting specifies the number of times the student can try to | +| | answer the problem. Note that each time a student answers a | +| | problem, the student's response is graded separately. If a student | +| | submits two responses to a peer-assessed problem (for example, by | +| | using the **New Submission** button after her first response | +| | receives a bad grade or because she wants to change her original | +| | response), and the problem requires three peer graders, three | +| | separate peer graders will have to grade each of the student's two | +| | responses. We thus recommend keeping the maximum number of | +| | attempts for each question low. | ++---------------------------------------------+--------------------------------------------------------------------+ +| **Maximum Peer Grading Calibrations** | This setting applies only to peer grading. You can set the maximum | +| | number of responses a student has to "practice grade" before the | +| | student can start grading other students' responses. The default | +| | value is 6, but you can set this value to any number from 1 to 20. | +| | This value must be greater than or equal to the value set for | +| | **Minimum Peer Grading Calibrations**. | ++---------------------------------------------+--------------------------------------------------------------------+ +| **Minimum Peer Grading Calibrations** | This setting applies only to peer grading. You can set the minimum | +| | number of responses a student has to "practice grade" before the | +| | student can start grading other students' responses. The default | +| | value is 3, but you can set this value to any number from 1 to 20. | +| | This value must be less than or equal to the value set for | +| | **Maximum Peer Grading Calibrations**. | ++---------------------------------------------+--------------------------------------------------------------------+ +| **Peer Graders per Response** | This setting applies only to peer grading. This setting specifies | +| | the number of times a response must be graded before the score and | +| | feedback are available to the student who submitted the response. | ++---------------------------------------------+--------------------------------------------------------------------+ +| **Peer Track Changes** | This setting is new and still under development. This setting | +| | applies only to peer grading. When this setting is enabled (set to | +| | **True**), peer graders can make inline changes to the responses | +| | they're grading. These changes are visible to the student who | +| | submitted the response, along with the rubric and comments for the | +| | problem. | ++---------------------------------------------+--------------------------------------------------------------------+ +| **Problem Weight** | This setting specifies the number of points the problem is worth. | +| | By default, each problem is worth one point. | ++---------------------------------------------+--------------------------------------------------------------------+ +| **Required Peer Grading** | This setting specifies the number of responses that each student | +| | who submits a response has to grade before the student receives a | +| | grade for her response. This value can be the same as the value | +| | for the **Peer Graders per Response** setting, but we recommend | +| | that you set this value higher than the **Peer Graders per | +| | Response** setting to make sure that every student's work is | +| | graded. (If no responses remain to be graded, but a student still | +| | needs to grade responses, you can set the **Allow "overgrading" of | +| | peer submissions** setting to allow more students to grade | +| | previously graded responses.) | ++---------------------------------------------+--------------------------------------------------------------------+ + +7. Save the Problem +~~~~~~~~~~~~~~~~~~~ + +- After you have created the prompt and the rubric, set the assessment + type and scoring, changed the name of the problem, and specified any + additional settings, click **Save**. + + The component appears in Studio. In the upper right corner, you can + see the type of assessments that you have set for this problem. + +.. image:: Images/ORA_Component.gif + +8. Add the Peer Grading Interface (for peer assessments only) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can add just one peer grading interface for the whole course, or you +can add a separate peer grading interface for each individual problem. + +Add a Single Peer Grading Interface for the Course +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When you add just one peer grading interface for the entire course, we +recommend that you create that peer grading interface in its own section +so that students can find it easily. Students will be able to access all +the ORA problems for the course through this peer grading interface. + +#. Create a new section, subsection, and unit. You can use any names + that you want. One course used "Peer Grading Interface" for all + three. +#. Under **Add New Component** in the new unit, click **Advanced**, and + then click **Peer Grading Interface**. + + A new Peer Grading Interface component appears. + +#. To see the peer grading interface in the course, set the visibility + of the unit to **Public**, and then click **View Live**. + + The following page opens. + + .. image:: Images/PGI_Single.gif + + When students submit responses for peer assessments in your course, + the names of the problems appear in this interface. + +Add the Peer Grading Interface to an Individual Problem +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When you add a peer grading interface for an individual problem, you +must add the identifier for the problem to that peer grading interface. +If you don't add the identifier, the interface will show all of the peer +assessments in the course. + +Note that the peer grading interface doesn't have to appear under the +problem you want it to be associated with. As long as you've added the +identifier of the problem, the peer grading interface will be associated +with the problem, even if you include the peer grading interface in a +later unit (for example, if you want the problem to be due after a +week). + +#. Open the unit that contains the ORA. +#. If the visibility of the unit is set to Public, click **View Live**. + If the visibility is set to Private, click **Preview**. The unit + opens in the LMS in a new tab. Make sure you're in Staff view rather + than Student view. +#. Scroll down to the bottom of the ORA, and then click **Staff Debug + Info**. +#. In the image that opens, locate the string of alphanumeric characters + to the right of the word **location**. Press CTRL+C to copy this + string, starting with **i4x**. + + .. image:: Images/PA_StaffDebug_Location.gif + +#. Switch back to the unit in Studio. If the visibility of the unit is + set to **Public**, change the visibility to **Private**. +#. Scroll to the bottom of the unit, click **Advanced** under **Add New + Component**, and then click **Peer Grading Interface**. +#. On the Peer Grading Interface component that opens, click **Edit**. +#. In the Peer Grading Interface component editor, click **Settings**. +#. In the **Link to Problem Location** field, paste the string of + alphanumeric characters that you copied in step 4. Then, change the + **Show Single Problem** setting to **True**. + + .. image:: Images/PGI_CompEditor_Settings.gif + +#. Click **Save** to close the component editor. + +9. Test the Problem +~~~~~~~~~~~~~~~~~~~ + +Test your problem by adding and grading a response. + +#. In Studio, open the unit that contains your ORA problem. +#. Under **Unit Settings**, change the **Visibility** setting to + **Public**, and then click **View Live**. + + When you click **View Live**, the unit opens in the LMS in a new tab. + +#. In the LMS, locate your ORA question, and then type your response in + the Response field under the question. + + .. image:: Images/ThreeAssmts_NoResponse.gif + + Note that when you view your ORA problem in the LMS as an instructor, + you see the following message below the problem. This message never + appears to students. + + .. image:: Images/ORA_DuplicateWarning.gif + +#. Test the problem to make sure that it works as expected. + +To test your open response assessment, you may want to sign into your +course as a student, using an account that's different from the account +that you use as an instructor. + +- If you want to keep your course open as an instructor when you sign + in as a student, either open a window in Incognito Mode in Firefox or + Chrome or use a different browser to access your course. For example, + if you used Firefox to create the course, use Chrome when you sign in + as a student. +- If you don't need to keep your course open, sign out of your course, + and then sign back in using a different account. Note that if you do + this, you can't make changes to your course without signing out and + signing back in as an instructor. + +Grade an Open Response Assessment Problem +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You'll grade student responses to both AI assessments and peer +assessments from the **Staff Grading** page in the LMS. Take a moment to +familiarize yourself with the features of this page. + +The Staff Grading Page +^^^^^^^^^^^^^^^^^^^^^^^ + +When a response is available for you to grade, a yellow exclamation mark +appears next to **Open Ended Panel** at the top of the screen. + +.. image:: Images/OpenEndedPanel.gif + +To access the **Staff Grading** page, click **Open Ended Panel**. + +When the **Open Ended Console** page opens, click **Staff Grading**. +Notice the **New submissions to grade** notification. + +.. image:: Images/OpenEndedConsole_NewSubmissions.gif + +When the **Staff Grading** page opens, information about your open +response assessment appears in several columns. + +.. image:: Images/ProblemList-DemoCourse.gif + ++----------------------------------------------------+--------------------------------------------------------------------+ +| **Problem Name** | The name of the problem. Click the name of the problem to open it. | +| | Problems in your course do not appear under **Problem Name** on | +| | the **Staff Grading** page until at least one response to the | +| | problem has been submitted and is available to grade. | ++----------------------------------------------------+--------------------------------------------------------------------+ +| **Graded** | The number of responses for that problem that you have already | +| | graded. Even if the AI algorithm has graded all available | +| | responses, you can still grade the responses that the algorithm | +| | designates as low-confidence responses by clicking the problem | +| | name in the list. | ++----------------------------------------------------+--------------------------------------------------------------------+ +| **Available to grade** | The total number of ungraded student submissions. | ++----------------------------------------------------+--------------------------------------------------------------------+ +| **Required** | The number of responses remaining to be graded to train the | +| | algorithm for AI or to calibrate the responses for peer grading. | +| | If your open response assessment calls for both AI and peer | +| | assessment, the 20 responses that you grade for the peer | +| | assessment count toward the 100 responses for the AI assessment. | ++----------------------------------------------------+--------------------------------------------------------------------+ +| **Progress** | A visual indication of your progress through the grading process. | ++----------------------------------------------------+--------------------------------------------------------------------+ + +Grade Responses +^^^^^^^^^^^^^^^ + +#. Go to the **Staff Grading** page. +#. Under **Problem Name**, click the name of the problem that you want. + + When the problem opens, the information about the number of responses + that are still available to grade, that have been graded, and that an + instructor is required to grade appears under the problem name. You + can also find out about the AI algorithm's error rate. The error rate + is a calculation of the difference between the scores that AI + algorithm provides and the scores that the instructor provides. + + .. image:: Images/ResponseToGrade.gif + +#. In the rubric below the response, select the option that best + describes the response. +#. If applicable, add additional feedback. + + - You can provide comments for the student in the **Written + Feedback** field. + - If you do not feel that you can grade the response (for example, + if you're a member of course staff but you would rather have the + instructor grade the response), you can click **Skip** to skip it. + - If the response contains inappropriate content, you can select the + **Flag as inappropriate content for later review** check box. + Flagged content is accessed on the **Staff Grading** page. If + necessary, course staff can ban a student from peer grading. + + .. image:: Images/AdditionalFeedback.gif + +#. When you are done grading the response, click **Submit**. + +When your course is running, another response opens automatically after +you grade the first response, and a message appears at the top of the +page. + +.. image:: Images/FetchingNextSubmission.gif + +After you've graded all responses for this problem, **No more +submissions to grade** appears on the page. + +.. image:: Images/NoMoreSubmissions.gif + +Click **Back to problem list** to return to the list of problems. You +can also wait for a few minutes and click **Re-check for submissions** +to see if any other students have submitted responses. + +Access Scores and Feedback +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You access your scores for your responses to AI and peer assessment +problems through the **Open Ended Console** page. + +#. From any page in the LMS, click the **Open Ended Panel** tab at the + top of the page. + + .. image:: Images/OpenEndedPanel.gif + +#. On the **Open Ended Console** page, click **Problems You Have + Submitted**. + + .. image:: Images/ProblemsYouHaveSubmitted.gif + +#. On the **Open Ended Problems** page, check the **Status** column to + see whether your responses have been graded. +#. When grading for a problem has been finished, click the name of a + problem in the list to see your score for that problem. When you + click the name of the problem, the problem opens in the courseware. + +For both AI and peer assessments, the score appears below your response +in an abbreviated version of the rubric. For peer assessments, you can +also see the written feedback that your response received from different +graders. + +**Graded AI Assessment** + +.. image:: Images/AI_ScoredResponse.gif + +**Graded Peer Assessment** + +.. image:: Images/Peer_ScoredResponse.gif + +If you want to see the full rubric for either an AI or peer assessment, +click **Toggle Full Rubric**. + +**Note** For a peer assessment, if you haven't yet graded enough +problems to see your score, you receive a message that lets you know how +many problems you still need to grade. + +.. image:: Images/FeedbackNotAvailable.gif diff --git a/docs/course_authors/source/organizing_course.rst b/docs/course_authors/source/organizing_course.rst new file mode 100644 index 0000000000..73ef085cc1 --- /dev/null +++ b/docs/course_authors/source/organizing_course.rst @@ -0,0 +1,219 @@ +.. _Organizing Your Course Content: + +############################### +Organizing Your Course Content +############################### + +.. _How a Course is Organized: + +************************* +How a Course is Organized +************************* + +You organize your course in the following hierarchy: + +- :ref:`Sections`, which contain + - :ref:`Subsections`, which contain + - :ref:`Units`, which contain + - :ref:`Components`, which contain your actual course content. + + +Studio provides you with flexibility when organizing your course. +A common course model is for Sections to correspond to weeks, and for Subsections to correspond to lessons. + +================== +The Course Outline +================== + +In Studio, you view your course organization through the Course Outline. + +To open the Course Outline, from the **Content** menu, select **Outline**. + +The following example shows a course outline with callouts to identify the different course elements: + +.. image:: Images/course_outline.png + :width: 800 + +The following example shows how a student would view this course content: + +.. image:: Images/course_outline_student_view.png + :width: 800 + +.. _Sections: + +******** +Sections +******** + +A Section is the topmost category in your course. A Section can represent a time-period in your course, or another organizing principle. + +To create a Section: + +#. In the Course Outline, click **New Section**. +#. In the field that opens at the top of the outline, enter the new Section name. +#. Click **Save**. + +The new, empty Section is placed at the bottom of the course outline. +You must now add Subsections to the Section. + +Whether or not students see the new Section depends on the release date. +See LINK for more information on releasing your course. + +.. _Subsections: + +**************** +Subsections +**************** + +Sections are divided into Subsections. A Subsection may represent a topic in your course, or another organizing principle. + +You can set a Subsection to an assignment type that you created when +you set up grading. You can then include assignments in the body of that +Subsection. For more information on grading, see LINK. + +To create a Subsection: + +#. Within the Section, click **New Subsection**. +#. In the field that opens at the bottom of the section, enter the new Subsection name. +#. Click **Save**. + +The new, empty Subsection is placed at the bottom of the Section. +You must now add Units to the Subsection. + +Whether or not students see the new Subsection depends on its release date. +See LINK for more information on releasing your course. + + +================== +Edit a subsection +================== + +You can add and delete Subsections, and select the grading policy, directly from the Course Outline. + +You can also open the Subsection in its own page, to perform those tasks as well as to +set the Subsection release date, set a due date, preview a draft of the Subsection, or view the live course. + +Click on the Subsection title. The Subsection opens in its own page: + + .. image:: Images/subsection.png + :width: 800 + + +======================= +Set the Grading Policy +======================= + +You can designate a Subsection as one of the assignment types that you specified in the grading policy. + +You set the grading policy for the Subsection from the Course Outline or from the Subsection page. + +From the Course Outline, click the checkmark next to the Subsection. Then select a grading policy from the popup menu: + + .. image:: Images/course_outline_set_grade.png + :width: 800 + +From the Subsection page, click the text next to the **Graded as** label, then select a grading policy from the popup menu: + + .. image:: Images/subsection_set_grade.png + :width: 800 + +See :ref:`Establish a Grading Policy` for more information. + + +================== +Set the Due Date +================== + +For Subsections that contain graded problems, you can set a due date. Students must complete the problems in the Subsection before the due date to get credit. + +#. From the Subsection page, click **SET A DUE DATE**. The Due Day and Due Time fields appear. +#. Place the cursor in the Due Date field, and pick a day from the popup calendar. +#. Place the cursor in the Due Time field and pick a time. + +.. note:: When you set a due date, keep in mind that students will be in different time zones. By default, the time zone appears as UTC, not the student's local time. If you tell your students an assignment is due at 5:00 PM, make sure to specify that the time is 5:00 PM UTC and point them to a time converter. + +Alternatively, you can :ref:`Set the Grace Period` for your assignments to cover any misunderstandings about time. For example, some classes have set a grace period of 1 day, 6 hours, and 1 minute. The grace period applies to all assignments. + +For more information, see :ref:`Establish a Grading Policy`. + +.. _Units: + +****** +Units +****** + +Subsections are divided into Units. A Unit contains one or more Components. + +For students, each Unit in the Subsection is represented as a link on the accordian at the top of the page. +The following page shows a Subsection that has nine Units: + +.. image:: Images/units_students.png + :width: 800 + +.. warning:: + + Studio does not have versioning or automatic + updating of your browser between refreshes. Versioning is planned for future + releases, but, in the meantime, only one author should edit a unit, in one + browser, on only one tab. If a unit is open for editing in multiple browser + sessions, the session that saves last will overwrite any previously saved + content without displaying a warning. Also, older browser sessions can overwrite + more recent content, so refresh your browser before you start working every time + you work with a private unit or edit a draft of a public unit. + + +To create a Unit from the Course Outline or the Subsection page: + +#. Within the Subsection, click **New Unit**. +#. Enter the Display Name that students will see. +#. Click a Component type to add a the first Component in the Unit. + .. image:: Images/Unit_DisplayName_Studio.png + +#. Follow the instructions for the type of Component, listed below. +#. By default, the Unit visibility is **Private**, meaning students will not be able to see the Unit. Unless you want to publish the Unit to students immediately, leave this setting. See LINK for more information on releasing your course. + +The Unit with the single Component is placed at the bottom of the Subsection. + +.. _Components: + +********** +Components +********** + +A component is the part of a unit that contains your actual course content. A unit can can contain one or more components + +A student can view the name of all components in a unit by hovering over the unit icon in the accordian at the top of the page. + +You add the first component when creating the unit. + +To add another component to the unit: + +#. If the Unit is Public, change the **Visibility** setting to **Private**. You cannot modify a Public Unit. +#. In the **Add New Component** panel at the bottom of the Unit, click the type of Component to add. + .. image:: Images/Unit_DisplayName_Studio.png +#. Follow the instructions for the type of Component: + + * :ref:`Working with HTML Components` + * :ref:`Working with Video Components` + * :ref:`Working with Discussion Components` + * :ref:`Working with Problem Components` + + + +.. _Reorganize Your Course: + +********************** +Reorganize Your Course +********************** + +You can reorganize your course by dragging and dropping elements in the Course Outline. + +To move a Section, Subsection, or Unit, click the mouse on the element's handle on the right side of the outline, then move the element to the new location. +Element handles are highlighed in the following image: + + .. image:: Images/drag_drop.png + :width: 800 + +When you move a course element, a blue line indicates the new position. You can move a Subsection to a new Section, and a Unit to a new Subsection. + +You can reorganize Components within a Unit in the same way. \ No newline at end of file diff --git a/docs/course_authors/source/read_me.rst b/docs/course_authors/source/read_me.rst index c305df3f4e..b414160d13 100644 --- a/docs/course_authors/source/read_me.rst +++ b/docs/course_authors/source/read_me.rst @@ -2,17 +2,17 @@ Read Me ******* -The edX "Getting Started with Studio" help and PDF documentation is created -using Sphinx_ and LaTeX_. You, the user community, can help update and revise +The edX *Building a course with Studio* documentation is created +using RST_ files and Sphinx_. You, the user community, can help update and revise this documentation project on GitHub:: https://github.com/edx/edx-platform/tree/master/docs/course_authors/source To suggest a revision, fork the project, make changes in your fork, and submit a pull request back to the original project: this is known as the `GitHub Flow`_. -All pull requests will need approval from an engineering contact at edX. For -more information, contact edX at docs@edx.org. +All pull requests need approval from edX. For more information, contact edX at docs@edx.org. .. _Sphinx: http://sphinx-doc.org/ .. _LaTeX: http://www.latex-project.org/ .. _`GitHub Flow`: https://github.com/blog/1557-github-flow-in-the-browser +.. _RST: http://docutils.sourceforge.net/rst.html \ No newline at end of file diff --git a/docs/course_authors/source/set_content_releasedates.rst b/docs/course_authors/source/set_content_releasedates.rst index b55afc1e8c..24755e9abc 100644 --- a/docs/course_authors/source/set_content_releasedates.rst +++ b/docs/course_authors/source/set_content_releasedates.rst @@ -1,82 +1,163 @@ +.. _Publishing Your Course: -***************************************** -Set Content Release Dates and Visibility -***************************************** +########################### +Publishing Your Course +########################### -The release schedule for course material is determined by setting release dates -for sections and subsections. +When you have set up your course, established a grading system, and organized your course content, +you are ready to publish your course and make it available to students. -Section -******* +Understanding the content your students can view, and knowing how to change what students can view, is complex. +Read the following sections carefully: - To set the release date for a section: +* :ref:`Understanding Content Students Can View` +* :ref:`Release Dates` +* :ref:`Public and Private Units` +* :ref:`Modifying Public Units` - 1. On the **Course Content** menu, click **Course Outline**. - 2. Find the section you are looking for in the course outline. +.. _Understanding Content Students Can View: - 3. Under **Will Release**, click **Edit**, and then change the date. - .. image:: Images/image280.png +****************************************** +Understanding Content Students Can View +****************************************** -Subsection -********** +When you create a course on Studio, students cannot see any of your course content until the course start date has passed. +After the course start date has passed, whether a student can see your course materials depends on two settings that you can control: release dates and visibility. + +* The **Release Date**. Sections and subsections have release dates. If the current date + of a section or subsection is before the release date, the content of that course element is + not yet published, and not visible to students. + + For a student to view a subsection, both it and its containing section must be have a release date + earlier than the current date. It is possible that a section is released, but a subsection within it + is not released. In this case, students cannot view that subsection. + + Course staff can see sections and subsections before the release date in the LMS. + +* The unit must be **Public**. All units have a **Visibility** setting that is **Public** or **Private**. + When you create a unit, it is **Private** by default. + + Students cannot view a **Private** unit, even if the containing section and subsection are released. + + Students cannot view a **Public** unit if the containing section and subsection are *not* released. + + Course staff *cannot* see Private units in the LMS. + +In summary, for students to see content, the unit must be **Public**, and the containing section and +subsection must be released. If all these criteria are not met, students do not see that unit. + +Continue reading this chapter for more details. + + +.. _Release Dates: + +******************* +Release Dates +******************* + +Release dates specify the dates when content is available to students. +Release dates are set at the section and subsection levels. +Neither a section nor its contents are visible to students until the release date passes. +However, course staff can see content in the LMS regardless of its release date. + +======================================== +Set the Release Date for a Section +======================================== + +You can set release date and time for each section. +Before the release date and time, students are not able to view any content in that section. + +To set a release date for a section: + +#. In the **Will Release:** field next to the section title, click **Edit**. +#. Enter the release date in MM/DD/YYYY format, and the UTC time. +#. Click **Save**. + + +======================================== +Set the Release Date for a Subsection +======================================== Subsections inherit the release date of the section they are in, but you can change this date so that different subsections are released at different times. Note that if the release date for a subsection falls before the release date for the section that contains it, students will not be able to see the subsection -until the release date for the *section *has passed. Section release dates +until the release date for the *section* has passed. Section release dates override subsection release dates. - To set the release date for a subsection: +To set the release date for a subsection: - 1. Click to open the subsection. +#. Open the subsection. +#. Locate the **Subsection Settings** box in the top right corner. +#. Enter the release date in MM/DD/YYYY format, and the UTC time. - 2. Locate the **Subsection Settings** box in the top right corner. - 3. Under **Release Date**, change the date. +================================================ +Synch the Release Date for a Subsection +================================================ -Unit -**** - -Individual units inherit the release date of the subsection they are in, but -have an additional **Visibility** setting that can be set to **Public** or -**Private**. Private units are never visible to students. +You or other course staff could inadvertantly set the release date for a subsection +earlier than the release date for the containing section. In this situation, the subsection is +not released until the section is released. -.. note:: +To help you keep your course and release dates organized, Studio flags subsections with earlier release dates +than their containing section. In this situation, when you open the subsection, in the Subsection Settings, +you see the following message: -You can modify private units directly. To modify a unit that is marked -Public you must create a draft. +``The date above differs from the release date of
      -
      . Sync to
      .`` -For more information, see :doc:`modify_published_content` . +Click **Sync to
      ** to have the subsection inherit the later section release date. + +.. _Public and Private Units: + +************************* +Public and Private Units +************************* + +Units are released at the release date of the subsection they are in. + +In addition, unites have a **Visibility** setting that you can set to **Public** or +**Private**. + +When you create a unit, it is Private by default. +A Private unit is never visible to students, even if it is contained by a subsection that has been released. + +When you change the visibility setting of a unit from Private to Public, you publish the unit and its contents. +You must set the Visibility to Public for students to be able to see the unit. + +Course staff cannot see Private units in the LMS. + + +.. _Modifying Public Units: + +************************* +Modifying Public Units +************************* + +To make revisions to a unit that has been published, you create and edit a draft of that unit. +To create a draft, go to the unit's page, and then click **edit a draft** in the right pane. + +.. image:: Images/Viz_Revise_EditDraft.png + :width: 800 + +When you edit a draft of a unit, you can view the unit's contents in two ways. + +* To view the already-published content as it appears in the live course, click **View the Live Version** in the upper-right corner of the page. +* To view the unpublished content as you're working on it, click **Preview**. + +.. image:: Images/Viz_Revise_ViewLiveandPreview.png + :width: 800 + +When you're ready to publish the draft of your revised content, +click **replace it with this draft** in the right pane. + +If you decide you don't want to keep the revised content, click **Delete Draft**. + +.. image:: Images/Viz_Revise_ReplaceorDelete.png + +.. Warning:: Historical versions of units are not stored by Studio. After you replace the live version with a new draft, you cannot revert the unit to the previous version. - - - To change the **Visibility** setting for a private unit: - - - 1. Click to open the unit. - - - 2. Locate the **Unit Settings** box in the top right corner. - - - 3. For **Visibility**, select **Public**. - - - To change the **Visibility** setting for a public unit: - - - 1. Click to open the unit. - - - 2. Locate the **Unit Settings** box in the top right corner. - - - 3. Under **Unit Settings**, click **edit a draft**. - - - 4. For **Visibility**, select **Private**. \ No newline at end of file diff --git a/docs/course_authors/source/specialized_problems.rst b/docs/course_authors/source/specialized_problems.rst new file mode 100644 index 0000000000..ee0bdf4235 --- /dev/null +++ b/docs/course_authors/source/specialized_problems.rst @@ -0,0 +1,238 @@ +.. _Specialized Problems: + +Specialized Problems +==================== + +Specialized problems are advanced problems such as annotations, open +response assessments, and word clouds. These problems are available +through the Advanced component in Studio. To add the Advanced component +to your course, you'll modify your course's advanced settings. The +Advanced component then appears under **Add New Component** in each +unit. + +- :ref:`Annotation` Annotation problems ask students to respond to + questions about a specific block of text. The question appears above + the text when the student hovers the mouse over the highlighted text + so that students can think about the question as they read. +- :ref:`Open Response Assessment` Open response assessment problems allow students + to enter short answer or essay responses that students or a computer + algorithm can then grade. +- :ref:`Word Cloud` Word cloud problems show a colorful graphic of the + words that students enter as responses to a prompt. + + +**Add the Advanced Component to Your Course** + +By default, when you create a new component in Studio, you see the +following options. + +.. image:: Images/AddNewComponent.gif + +To create a specialized problem, you must first add the Advanced +component to your course. To do this, follow these steps. + +#. On the **Settings** menu, click **Advanced Settings**. + +#. On the **Advanced Settings** page, locate the **Manual Policy + Definition** section, and then locate the **advanced_modules** + policy key (this key is at the top of the list). + + .. image:: Images/AdvancedModulesEmpty.gif + +#. Under **Policy Value**, place your cursor between the brackets, and + then enter the value for the type of problem that you want to create. + Make sure to include the quotation marks, but not the period. + + - For annotations, enter **"annotatable"**. + + - For open response assessments, enter + **"combinedopenended","peergrading"**. (Include the comma but no + spaces between the words.) + + - For word clouds, enter **"word_cloud"**. + + You can enter more than one problem type at a time. When you do, + make sure to surround each problem type with quotation marks and + separate each problem type with a comma, but do not include any + spaces. + + For example, if you wanted to add annotations, open response + assessments, and word cloud problems in your course, you would enter + the following between the brackets. + + :: + + "annotatable","combinedopenended","peergrading","word_cloud" + + .. image:: Images/AdvSettings_Before.gif + +#. At the bottom of the page, click **Save Changes**. + + The page refreshes automatically. At the top of the page, you see a + notification that your changes have been saved. + + The text in the **Policy Value** field now appears as follows. + + .. image:: Images/AdvSettings_After.gif + +#. Return to the unit where you want to add the specialized problem. The + list of possible components now contains an Advanced component. + + .. image:: Images/AdvancedComponent.gif + +When you click the Advanced component, you see the following list. + +.. image:: Images/SpecProbs_List.gif + +You can now create annotations, open response assessments, and word +clouds in your course. More information about how to create each problem +is provided in the page for that problem type. + +.. _Annotation: + +Annotation +---------- + + +In an annotation problem, the instructor highlights specific text +inside a larger text block and then asks questions about that text. The +questions appear when students hover the mouse over the highlighted +text. The questions also appear in a section below the text block, along +with space for students' responses. + +.. image:: Images/AnnotationExample.gif + +Create an Annotation Problem +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + +To create an annotation problem: + +Add the Annotation advanced component. To do this, add the "annotatable" +key value to the **Advanced Settings** page. (For more information, see +the instructions in :ref:`Specialized Problems`.) + +Add the **Instructions** and **Guided Discussion** segments of the +problem. + + +#. In the unit where you want to create the problem, click **Advanced** + under **Add New Component**. +#. In the list of problem types, click **Annotation**. +#. In the component that appears, click **Edit**. +#. In the component editor, replace the example code with your own code. +#. Click **Save**. + + +Add the **Annotation problem** segment of the problem. + + +#. Under the Annotation component, create a new blank Advanced Problem + component. +#. Paste the following code in the Advanced Problem component, replacing + placeholders with your own information. + + + :: + + + + + PLACEHOLDER: Text of annotation + PLACEHOLDER: Text of question + PLACEHOLDER: Type your response below: + PLACEHOLDER: In your response to this question, which tag below + do you choose? + + + + + + + + +

      PLACEHOLDER: Detailed explanation of solution

      +
      +
      + +#. Click **Save**. + + +.. _Open Response Assessment: + +Open Response Assessment +------------------------ + + +In open response assessments, tens of thousands of students can receive feedback +on written responses of varying lengths as well as files, such as computer code or +images, that the students upload. + + +Because open response assessments are more complex than most other problem types, +they have a separate section. For more information about these problems, see +:ref:`Open Response Assessment Problems`. + + + +.. _Word Cloud: + +Word Cloud +---------- + + +In a word cloud problem, students enter words into a field in response +to a question or prompt. The words all the students have entered then +appear instantly as a colorful graphic, with the most popular responses +appearing largest. The graphic becomes larger as more students answer. +Students can both see the way their peers have answered and contribute +their thoughts to the group. + + +For example, the following word cloud was created from students' +responses to a question in a HarvardX course. + +.. image:: Images/WordCloudExample.gif + +Create a Word Cloud Problem +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To create a word cloud problem: + + +#. Add the Word Cloud advanced component. To do this, add the + "word_cloud" key value to the **Advanced Settings** page. (For more + information, see the instructions in :ref:`Specialized Problems`.) +#. In the unit where you want to create the problem, click **Advanced** + under **Add New Component**. +#. In the list of problem types, click **Word Cloud**. +#. In the component that appears, click **Edit**. +#. In the component editor, specify the settings that you want. You can + leave the default value for everything except **Display Name**. + + + - **Display Name**: The name that appears in the course ribbon and + as a heading above the problem. + - **Inputs**: The number of text boxes into which students can enter + words, phrases, or sentences. + - **Maximum Words**: The maximum number of words that the word cloud + displays. If students enter 300 different words but the maximum is + set to 250, only the 250 most commonly entered words appear in the + word cloud. + - **Show Percents**: The number of times that students have entered + a given word as a percentage of all words entered appears near + that word. + + +#. Click **Save**. + + +For more information, see `Xml Format of "Word Cloud" Module +`_. \ No newline at end of file diff --git a/docs/course_authors/source/view_course_content.rst b/docs/course_authors/source/view_course_content.rst index 9f5dac88af..7f98568e1a 100644 --- a/docs/course_authors/source/view_course_content.rst +++ b/docs/course_authors/source/view_course_content.rst @@ -1,340 +1,80 @@ -******************* -View Course Content -******************* +.. _Testing Your Course: -When you create a course on Studio, you can control when students can see -the content of your course. This means that you can continue building a -course, but students won't be able to see the changes you make until you -make those changes available. You can set release dates that control when -content is released to the internet. You can also set the visibility of -specific Units (a subdivision that helps you organize your course content) -to Public or Private. By default, all content is set to Private. - - -Your content is not visible to students on edX or Edge until three -conditions are met: +########################### +Testing Your Course +########################### -1. The course start date has passed. +The way your course looks in Studio is not the way students see and experience your course. -2. The release dates for the Section and Subsection that contain the - content have passed. - - -* Sections and Subsections are categories that you use to organize your -course. For example, Sections may correspond to weeks in your course, while -Subsections may correspond to the topics in your course. - - -* Neither a Section nor its contents are visible until the release date -passes. If the release date for the Section has passed, but the release date -for the Subsection has not passed, the student can see the Section heading -in the left pane. However, the student cannot see the Subsection heading or -any of the Subsection's content. - - -* Subsections inherit the release date of the Section they are in, but you -can change this date so that individual Subsections are released at different -times after the Section has been released. - - -.. image:: Images/image189.png - - -.. image:: Images/image191.png +Therefore, as and after you develop course content, you must view and test your course from a students' point of view. - -3. You set the Unit that contains the content to Public.** By default, all -Units are set to Private. - - -There are four ways of viewing your course on edX or Edge while you are -still creating it: - - -* In Studio +From within Studio, you can test your course in two ways: +* :ref:`Preview Your Course` +* :ref:`View Your Live Course` -.. note:: +.. _Preview Your Course: - The way your course looks in Studio is not the way it looks to students on - edX or Edge. +*********************** +Preview Your Course +*********************** - -* On edX or Edge in Preview mode - - Any content that is set to Private is only visible in Preview mode. - - -* On edX or Edge as an Instructor - - When you view content as an instructor, you see the **Instructor** tab at the - the top of the screen. - - -* On edX or Edge as a Student - -.. raw:: latex - - \newpage % - - -Outline View -============ - - -When you want to see the overall organization of your course in Studio, you can -go to the **Course Outline **page. On the**Course Outline **page, you can -see the "macro" outline of your course, down to individual Units. - - -.. image:: Images/image193.png - - -.. raw:: latex - - \newpage % - -Subsection View -=============== - -You can also view content by Subsection. In this view, you can see the name -of the Subsection and the Units that the Subsection contains. You can see if -the Section is graded or not graded; if it is graded, you can see the -assignment type of the Subsection. You can also see if the individual Units -are set to Public or Private. Private Units appear in light gray text with -"PRIVATE" next to the Unit name. All other Units are Public. - - -.. image:: Images/image195.png - - -.. raw:: latex - - \newpage % - -Unit View -========= - -When you want to see the actual text, problems, and other content in your -course, you can open an individual Unit. You then see the Components for -that Unit. You can see this content whether it is set to Public or Private, -and whether or not the release date has passed. - - -The following example shows the Studio view of two Units in the "What Does -an edX Course Look Like?" Subsection. - - -The following Unit is set to Public. The release date for the Subsection has -passed. - - -.. image:: Images/image197.png - - -The following Unit is set to Private. The release date for the Subsection -has passed. - - -.. image:: Images/image199.png - - -If you change the release date for the "What Does an edX Course Look Like?" -Subsection to a date in the future (in this example, January 1, 2099), you -still see both Units in Studio. - - -Public Unit -^^^^^^^^^^^ - - - -.. image:: Images/image201.png - - -Private Unit -^^^^^^^^^^^^ - - - -.. image:: Images/image203.png - - - -.. raw:: latex - - \newpage % - - -Preview Mode -============ - -When you view your course on edX or Edge using Preview mode, you see all the +When you view your course through Preview mode, you see all the Units of your course, regardless of whether they are set to Public or Private and regardless of whether the release dates have passed. -**Using Preview mode is the only way to see content that is set to Private +Using Preview mode is the only way to see content that is set to Private **as a student would see it.** You can enter Preview mode in two ways. - -1. On any Subsection page, click** Preview Drafts**. - - +* On any subsection page, click **Preview Drafts**. + .. image:: Images/image205.png + :width: 800 +* On any Unit page, click **Preview**. -2. On any Unit page, click **Preview**. - - -The following example shows the **Preview** button on a page for a Unit that +The following example shows the **Preview** button for a unit that is set to Public. - .. image:: Images/image207.png + :width: 800 -The following example shows the **Preview** button on a page for a Unit that +The following example shows the **Preview** button for a unit that is set to Private. - .. image:: Images/image209.png + :width: 800 -Example -======= - -The following example shows the first Unit of the "What Does an edX Course -Look Like?" Subsection in Preview mode. - - -.. image:: Images/image211.png - - -Remember that the release date for the Subsection is in the past. However, -even if you change the release date for the "What Does an edX Course Look -Like?" Subsection to a date in the future, you still see both Units in -Preview. - - -In the "What Does an edX Course Look Like?" Subsection, Unit 1 ("Welcome to -edX 101") is set to Public, and Unit 2 ("New edX Information") is set to -Private. Both Units appear in the course ribbon at the top of the screen. - - -.. image:: Images/image213.png +.. _View Your Live Course: -When you click Unit 2 in the course ribbon, you see the content in Unit 2: - - -.. image:: Images/image215.png - -**On edX or Edge as an Instructor** - -When you view your course on edX or Edge as an instructor: - - -* You see all the Units of your course that you have set to Public. -* Release dates do not matter. - - -You do not see Units that are set to Private. - - -Additionally, at the top of the page on edX or Edge, you can see the -**Instructor** tab. - - -To view your course on edX or Edge as an instructor, click **View Live**. The -**View Live **button is available in three places.** ** - -The **Course Outline** page. - - -.. image:: Images/image217.png - - -Any Subsection page. - - -.. image:: Images/image219.png - - - -The Unit page, if the Unit is Public. - - -.. image:: Images/image221.png - -Example -======= - -The following example shows the first Unit of the "What Does an edX Course -Look Like?" Subsection as if you were viewing it on edX or Edge as an -instructor. Notice the **Instructor** tab at the top of the page. - - -.. image:: Images/image223.png - - -The release date for the "What Does an edX Course Look Like?" Subsection is -set to January 1, 2099. However, you still see this Unit on edX or Edge as -an instructor. - - -On the other hand, remember that Unit 1 is set to Public, and Unit 2 is set -to Private. Unit 2 does not appear in the course ribbon at the top of the -screen. Instead, the next public unit, **Tabs**, appears. - - -.. image:: Images/image225.png - -**On edX or Edge as a Current Student** - -When you view your course as a current student would see it, you can only -see material that meets all three publishing conditions: - - -The course start date has passed. - - -* The release dates for the Section and Subsection have passed. - -* The Unit that contains the material is set to Public. - - -You can use this view to make sure that material does not appear in your -course prematurely. - - -To view your course as a student, set up a test account on edX or Edge with -an e-mail address that is not associated with your Course Team, and then go -to your course URL and register for your course. +*********************** +View Your Live Course +*********************** -Example -======= +When you view your course as an staff member (that is, using the same account you use to build the course in Studio), +you see all the units of your course that are set to **Public**, regardless of the release dates of the containing section or subsection. -The following example shows the first Unit of the "What Does an edX Course -Look Like?" Subsection as if you were viewing it on edX or Edge as a -student. Notice that the **Instructor** tab does not appear at the top of -the page. +You do not see units that are set to **Private**. To see Private units, you must use Preview mode as described above. +You can view the live course from three different places in Studio: -.. image:: Images/image227.png +* The **Course Outline** page. + + .. image:: Images/image217.png + :width: 800 +* Any Subsection page. -Remember that Unit 1 is set to Public, and Unit 2 is set to Private. Unit 2 -does not appear in the course ribbon at the top of the screen. Instead, the -next public unit, **Tabs**, appears. + .. image:: Images/image219.png + :width: 800 +* The Unit page, if the Unit is Public. -.. image:: Images/image229.png - - -If you change the release date of the Subsection to a future date (such as -January 1, 2099), the student cannot see it. - - -If you set the Unit to Private, the student cannot see it. + .. image:: Images/image221.png + :width: 800 + From 29b49ce10d824b14f96affbe3ab08db880764538 Mon Sep 17 00:00:00 2001 From: Mark Hoeber Date: Mon, 2 Dec 2013 11:55:22 -0500 Subject: [PATCH 099/110] Editorial updates update to checking student progress, create new course, index, open response assessment --- docs/course_authors/source/checking_student_progress.rst | 4 ++-- docs/course_authors/source/create_new_course.rst | 6 +++++- docs/course_authors/source/index.rst | 2 -- docs/course_authors/source/open_response_assessment.rst | 8 +++++--- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/course_authors/source/checking_student_progress.rst b/docs/course_authors/source/checking_student_progress.rst index 54c5f33cda..0b4429f103 100644 --- a/docs/course_authors/source/checking_student_progress.rst +++ b/docs/course_authors/source/checking_student_progress.rst @@ -91,12 +91,12 @@ viewing individual progress through a course or resetting problem attempts. .. _Assign Final Grades and Issuing Certificates: *********************************************** -Assign Final Grades and Issuing Certificates +Assign Final Grades and Issue Certificates *********************************************** The final grades of a student in the course and the grading rubric you have set are used to determine whether the student has earned a Certificate of Mastery for the course. The process for issuing Certificates has to be started manually by you or by the edX support team at the end of the -course run. +course. diff --git a/docs/course_authors/source/create_new_course.rst b/docs/course_authors/source/create_new_course.rst index 9e7317cd82..07b72375bb 100644 --- a/docs/course_authors/source/create_new_course.rst +++ b/docs/course_authors/source/create_new_course.rst @@ -266,7 +266,9 @@ For example, the following navigation bar includes a .. image:: Images/image157.png -You can use static pages for a syllabus, grading policy, course handouts, or any other purpose. +You can use static pages for a syllabus, grading policy, course handouts, or any other purpose. + +.. note:: The Course Info, Discussion, Wiki, and Progress pages are displayed to students by default. You cannot delete these pages. To create a static page: @@ -282,6 +284,8 @@ To create a static page: #. To edit the Display Name, click **Settings**. #. Click **Save**. +To delete a static page, click **Delete** in the row for the page. Confirm the deletion. + ================== Add a Calendar diff --git a/docs/course_authors/source/index.rst b/docs/course_authors/source/index.rst index efba027ddc..fe82ca5024 100755 --- a/docs/course_authors/source/index.rst +++ b/docs/course_authors/source/index.rst @@ -4,11 +4,9 @@ contain the root `toctree` directive. - Contents ======== - .. toctree:: :numbered: :maxdepth: 5 diff --git a/docs/course_authors/source/open_response_assessment.rst b/docs/course_authors/source/open_response_assessment.rst index 438b7fbdaf..ffceca0022 100644 --- a/docs/course_authors/source/open_response_assessment.rst +++ b/docs/course_authors/source/open_response_assessment.rst @@ -6,9 +6,11 @@ Open Response Assessment Problems Introduction to Open Response Assessments ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -**Note** Open response assessments are still in beta. We recommend that -you test them thoroughly in a practice course and only add them to -courses that are **not** already running. Contact your edX Program Manager for more information. +.. note:: + + Open response assessments are still in beta. We recommend that + you test them thoroughly in a practice course and only add them to + courses that are **not** already running. Contact your edX Program Manager for more information. Open response assessments allow instructors to assess student learning through questions that may not have definite answers. Tens of thousands From 7c7bfc658cb6e8cd7b4487c4f14c511e24eccf07 Mon Sep 17 00:00:00 2001 From: Mark Hoeber Date: Mon, 2 Dec 2013 11:56:40 -0500 Subject: [PATCH 100/110] adding images for ORA --- .../source/Images/CITL_AssmtTypes.gif | Bin 0 -> 32508 bytes .../source/Images/CITL_SA_Rubric.gif | Bin 0 -> 35203 bytes .../course_authors/source/Images/CITLsample.gif | Bin 0 -> 32038 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/course_authors/source/Images/CITL_AssmtTypes.gif create mode 100644 docs/course_authors/source/Images/CITL_SA_Rubric.gif create mode 100644 docs/course_authors/source/Images/CITLsample.gif diff --git a/docs/course_authors/source/Images/CITL_AssmtTypes.gif b/docs/course_authors/source/Images/CITL_AssmtTypes.gif new file mode 100644 index 0000000000000000000000000000000000000000..ad290066088cca01c7a2e124d6a8aa8c40069489 GIT binary patch literal 32508 zcmWifi$BxfAICqp?K3mXeeNT-kmfGexnDwaDWWu@2sODRl)0-^qm)oh7ldl$i*%d2 zM2(P8O%$ce=%%Rs{67D{`JBt+an9@ce!ks2TrD?+@j+wgJpkM+N=RbW-g*9MVPRqJ z_rIsN3;+F@XWbgOS#n}-ZtmmDf#apOg8f4{z2i+)r~4)rs#~6(scL%t`t`$y565~N zp0!>(bNI}Y`PCPnS61eptbBfX@?h+@f5QEV$6o#TC;ahB_-`>kFK=P2tyLrPb=T9; z(b4Zy_sU93-}l$defhcYZlb#S`sD0FZ%_BnFLUJspXPfmTqwxO$uFxZt)9HpJUh_b zJ2q8uuHbmd#i^SeuSR;>nyNbnCayQ%yH?v)UQp3iTlr)3=9llEzODRP`t|Sro8KLm zj-PJhul`&tue>z7@MY}XRR3GS*SAmV8*7hUYX7n@^ZMTDh2dMP(~lZDIzG?M&b$|l zyqX?;I`rn%^Jk0y3Rx#>xctcu_MO`;mpfi9KD{>k=KY%o)t|pjJsBQ&we;m!sO$65 zw^L)Iqf^fpzb+j;%Di%^{r#tz_b=WE7kP_wpMU-O)!~`3va<5++c%-`&x%m^?bkQq z%Ab`L;o`5~zkV(L5&l{F_WR41FG8Wv09gC}zZU>1WQJyC=j7()7aTi&qVVLY)2yQ6 zGiOWAoiDvmR$g)O5-UF8%GGPvtEy{m)YjGCymgz?(0Hfmzvhx%m$t7e0MnTZykr-@gC& z`D^+2pZ3ts6(IoQmEAe)<`k^7-i|&_W#@jJnrDT3!#WLd!?5F4awfrp`wSNbBC3qbd@}S8Ey;M^*|X)0`ID|VTDs9nKYZm7CA!HfFub@XDbtP}5X^U8x(%a=ktme|F=QDUW`gEF9ixz@3ckd(hy377R9@ zH#!~t%MEME`00uZUX|>YzJCGB&FW$kGmp4-1?EexC;*p&)Dt7A1mm1{q@!f+uU;j4 zBHICHm@4ZOk#P3c)M;w4^eeS+1FnM6_$K#4Ax_eg_EwBUcY9TAtbF5*8r3o-65}eD zpq3vfWx4z0hrfFjp`r)z9pbJ}&y^W%mpbEc-ekXTB}q-=D%;3xR^iHS*X2mANI_T& z`4W}(OUo!Qbq%ISt5$+u{wpE@am)((m z><`#2)~CQ^5?UXOM@#9~$LH+E$+Tjm^~jX#hl`?_FbFWh=BwiET3w|^UURVFJH1Z4 zWa7Lgs#TI@I6Zie@6?t1X> zX%WgqRPGLGCAHH7tu*nkwd!ZpG;n?Sl9FCK3ZEigVcJIGrI@Zz*(SZ0r!q+3`=jB* zL1mY8A2rmcd)z;EZBWNOYVU!=s(1Dpz4@u~M$Io^Q;pmmv#QHT^e4W~>zh`oyHEAp zPmLI_*EgFzesA!5Xu1hTogJi9KMmrx4TQOgNxnYBR4r|>ypl}rV(*ll+aqu~>#BSB zU`7bR@bL7Wp7G4LU5MV?^!h+ju8`^6t;6P~LZ%D8`OVrafS zXZrVk>er-JzXfdRS@py3)_ah>4-}p$ST;T zRNJt_@wXPsbn{Zv%&7jry0o&--UoogMq zZg}YNlh8j)=bOK0YZ-mW&s*QgOmL@?uq@^tt8W*0g;l229)tUWzm<&-RGGIt9^9Y$ zt(;G&w!C~J2V`d=^Ip|fFCITlZu@poP*`m<=kY9c;@hRKiP>ruj|V8c=~8BZt1-&P zhADn$n-%(-9scCi?&B-_~@p0Dv@6}YXT3Wv6$Q4Di8mGQ?T-3+BDytti zXeVm}sy#<*gMZWp2?uMpwe*kPC}v_e|Cn>o0%Wo=G767vYJ*?&k2Sabxao8q7I9?) z_VbzIDT8&PzxrQ1{PW{BQ>>njZXWMY{K?69(CPM$k6x|~1CCN%6W_9c%N;)&O9$)s zSk(DiF$2UB`Snq5bwkzzC0GlyC`dDHY&XvVI;V9rcJJoNiSUu8b$#u`*z%!me}1-H zcAi(EDh7v%lL_@3>SDs4Ouo1Jb@xB-kGO;QoyWwfP#9^ii5B@DQiXuXhw@in{8*nox zw#!K<^thfgDz>PN(4HHw@6Rl*D88Zw!=7pT)P{ao#&cbCADNxnI>GHGaq22NK~ z3XD@L_pty`DWO3`u50SB_t@f@6Y~Sc8~t zDGMj-rt%9keDn==BNyQ!Ah`#bYaB zo8_0v_VQl?oxm#9218eh|nB?)rW zN$nq1$jxehsLf9)Y@CKA56YosiYM}c8+Zp4VnNk|ui#1%5Eduqmp$5v6KC85ip2%O zjx7PBgjX;$#)oaGAdpqfoDiWgVKXj<^|h2V^^j@MMg3~YOG66Czr_>(E}*GY84@oO zOKShTg9hG^R9py8=R$58nfRRo7+;#X=Ye_C&fBJT#6MJ#cv^eEOdY&Yx4qHPElSN! zF0<`4>*;2lhBKXBpN&E$0VO6iGZ4vyR(lbZpbqRqUXBcD3%UJ(6RepfD(L*?QR)Ads7JvPz@-E;TaX@ z>uYc@B#ya09o#NS-bpvK@I$n9VF?;Q;1yWX_DKgFmy?SSA4S=v2!VmP3sOb7P;?7c zjEAWl2aVYXNB2Yw^@LhB=uHPzxd)@U1zGL|W<)@dv(BBCV9da3`X5~HkY&RMj5)By zGK}Q|`T+#vYP7B;E{!3Cjro8Q6{EsOT=IdVl8GUE;(W5?p*D8~d3&_T>$ zmODpXQ~{t7;ovjq*J%~4Xxe{=2)Z1|f(UG2U}yq3jSt+HOGigfuSlY5L>Q!iHuS6j z3K%DVH|5jz>Ut$oK{+0*PlPtpPCxybccALXaL~hmeJE zB%2lCpOMPLe1CzI?M*P|AXFlx&WEF0FbDHN4G#8TAVMK3DRQvpe4yemkn{3_BQaBF zeP+RV+PoZqP}ct7D2^RxLjx=B0*Sz3buKn=zTBF7A(joR1BFU#j9md(@S)V*0|YrJ z%MF7gLQOTWY(!2UUt!`Wpg{*qry)5y00>Gl1Sc^>u*4bNNxwv-V2wB!QQo-&(Rp7j zO98>!sfHL0kQX)OT-W*Hp2Xi#;VV&bWzDb+2Q`%kWvH0z&FG-pK0QJDRtDzLSY`J$ zIFN`c)FMF~SegnH>BbsR(Ns1N%D|+tAp?H6wv}+-^{VJla`K&*S4FD=>Z+iD>)0?d zcx4ZKh;}8B9aDkDl;>TuqJYvgERBWfn8msjU}0vZWm_dX8RJM!3ncHeVWWCncvyE+ z5D(f*CG6)`r2}X(>&#+O~+AaoY9c zH*WkfIs2#PbgFsw-+ELeblT=bHYhE%=7A7dlo|luo8LzviF*@!;s_#Po%`bnh7LV=b zr;5ET^PLU$ec#4Dx98^9iQBw)H)YIkkI`v5F<(y?=tks!^7n32>;Bs>8ZeFZ+hC0(hO{K7wWO4{q~2>u zpKLk0(!x|~&9ZJytHaV=VBCK-2hD|XnU8NDSZQ^2X;OFbH>KS@mv*c4;43|R zYyEKciMPhwos-sH5%(T`ntb?W<)J|7(USF}?;(#OLLRhR1I0~`R@1~E=R6#(eDv3v zi`*9z*4_5e;bQJ{39-_3x!haVxnef$GG5XW>HnS6ZkN5^E|$(ERdAJ+J5Fr1bsb#MFvU+Drm;eI%KbKN^G>t-oktQoKVR>(QSM5d?Hm)m=a}Ag z*tzTXi7qc2H&263Z{_Z|?_HY=y0_U#9HBd8@X-5mXmBB68yRrnSS?Vy71Fz-ro{IO zpe_Nhk%@S*l%%*leFDIZ3~UrY5mST&0gz@RwkNhD+NN)g(-XG}B``?_0tn*BZxcY@ zVQ2@{eisedM^ju+Q}hx**Bkm=7+5y}M51Go+1SGd@(gD0rwdY=%AH%i`Z!+VHVmXm zx&Ou|)QyHz79tQA*+o-yXCM{vSQjoF+YY<1fCsCP0Rxd|f?ljAo?MI*?Qu#WT4?}0 zS|Vf)bTq6A2Vc|sGMK%v05B&igpq;dRp_pb{4TCS-vBHd4$Jd^%1;Be15XeR3UT@} z+A$;^Aa$*GYO3Edy=VQRDES53j368wd%|TT@xX~Bg z!C<+uF+HnjNky;PU#V?e%z7@y?)kHgbhLeSSec3JVyQpj4bre(sXw1nRYn|bN1S(# zxE>jCzc}LgV8rXyh|k{9>q=d|CQ)_F!eN!IQVyJ zScN!b`)cf>#ORS%6W$UpAG~_~O8k|WSmhugx#syd`s?z>h$-@frY~v|D6Y`)TE23} z(naz4&(cTvXFr}4oAxHS^Y$%l#cZPzC_JEAozllCY={>(q{npg5CbN`jH^6A19x2| zB(b3#BLqc;Q$HKZC=^q?B_Vg~;20An?@!aB$|^&U57%~?@^*wNL`1ksiVVyzb*IoG z1Sc-0DCnM|OurD22G?U_s3u~DVt@aVO?c3rMb#}-f-eBxaj-w-aFrs0Eaq$eSVZH2 z@vUU=+75y{88i!hZvc#^(B4xGAxI1xKmiQM@_34D3LlbU7>09|4LQ@Xh8yHrN(!o2 zySK990zi*q=}yE3-=A-;0`(cstwt531;C)YL2%(5{0}U7s*pw<-#CPtZUCvFK*1$w zW9Hl4N9Uy2^I;TzH|M<`<$V$j6a5d3@%bokx}cCXC#PwV8A>qZLUUQ8Nq}k0CBV(^ zy(b?MXM#De6(0Wp zSYQJkPM`s$U%uQpg{&o@hnGMfHWJ3OF=x#rNMaSaGfC7LXBzr7*)S$_|9xM6^QDg( zZb%Ak=tsjm=L>27euuFA4rWx$-E)HGOJCad6H<;EYVb5D(!HCP-pAX`bqPL0qm!E} z2xaH6dxj7%k{pHl4#(v0Btjtq40hP)U@?LSNG^0p_mI4N(?$13Idab}Ea@AJk)tXC zj2{Q1L-`@I#&iI{g9y1c&RK1Aiad6i#7PyUSZQ>4oUxILs&9w~$uGnR&A?b% z#CTPRwvUA0uC$pQawaaQ}4EJA$76}9!3QXzA;CTuWU zQ6*8lQ|CQ1Q^$$f!z*n7+&eM=lJhq9%zwKvq4z}s+3u`I*z_zB?%YQqsH=qF{+Vb^ zRSjqC<3tyRDPKa&AO`Jz{1=HrMd7D+g7^7EwEIHB0Ke2RU%k9n9aj>&nXBo7YZ=iP zdZQ>2DrAoH*pKr>F^(P=`b0*Rl#!7lZfNAxbwJ5X@J^nt7U!3Xx42Js^z!P{ph~M~ zHV3&Skp+%gJMJ$!>upk(#@IO&21tdr#8F>wddE5Q)Jm{A?Q?P_o#UXP!eBpB?>?rH zt4ZI^hpZsbw&oFY_8cLk6rPJ5L;k*;-)x}7b8w=Iau=ZvN_ zX&v+IVme8AM0NNS&J7gIoV48U*bYfW2;-e3?KK$J&FrJXs05vpdvrna6!8@*7rOzQ zp-qT(Ea~lw)Ucy7qM07LzOwuvGgCg*tpw+Pp}Zl7;SigYMGVED1>8IpKLa+^(tnlL zgST2W8x;XB2D7%6q<3#JR;!l;)!w9V%&jp|QnlDa55n4sK-4S?vaer|tQVac`tgD^ zHSyKHxJa8YlXD=82_z-T1p+Ro;EIc*?wjLc;u4saB-_=Yu*A$o@$vt3Iq_fG{8vjJ z4u18KnLAT%8XxGND&zO~9vVTJ6kB@P9v93p|mx7U9@nt_uw zHpSLpY5;lB5iVg57hFFp{GzPmc+u5H6VeMHhsx=m6*NZV(3%)Ks3t`Y*Lbz}4g;Xy z4aw&{(@lyFVs1G#(r$E`=#jW91~Eie2i8!rSp{9xh3J552JHQE$&eG(SxSzZU;bl( zJjJdwtI5ndYpG6+7|wnBG>=K_vW|8FK zXW*&3+ZD{{P0}=|$g{3fGjH*{Q|sb(;#oQdddaNgQ%{qE+;Lf98n0i1$2y3rT3wtx zqRoCi3tXnh+XBLY9I~=P>01%)MuP|v`JCN&HhjhGCN5wzkPOU~ zPXiX={B&WTWe%Zv{ua(B0F8xYpB@E{IlOdwg9 z*ezr=I7NIXs@iu+O7&EtHudYoX-ldy^;FICiq&|}zHAj1F^>|Fr{TE_Z~4JFwTT!l zZvQGr^ehEcY#*tj{LS`_JC3BrhS!uA>?@OEK^ITKb&g_AcxK0_cRh^kV@gR$@rJMV zOiLKjK}i951fh1-r?XCPUF7-Ovd+3ErNpQ(_MwiHO8n6up-=~X)PCHQV|-nBBLFPF z(0=?!U0CZvNN>9=mvLR2;lv>2PAX)tIawpMivnElKi}$}ir1q1DEt~i*QT{-0 z_k;5xuYOk6Fpt!MuPw!i#Ic50**F!wklZKxWEFP!W(VBam;dhVId_}SwYwu1@+T%h ziL;UC{8jYFMxOqxh9))`^J&4lvjMdWH6PX5lmY}x=k8p1@HJ`ElYnZeW6hjgH;;wehj%k|UvkmU~2tszxCav>pYxsxnzl!dP5pJ`loUBjp0 zc;LWWxqHjqa$h<)bgvo3{L(IMJM?(mz>Mn3axYc9k(E!_tt>yDk7_p-3(pVCYFqz) zOgqIXicEW}>Gr!n=xO7*mVro-Eibt{#qX5%t`S@K_Y?Pr9DL$Zriu-xLy>Xk!d$wz zeBlpeJw}InMtxR8vx1y$sNO}DdQfhhG^qQZ@u_Qq_X!aZ>UI=UX-uliEA@3%8Eh`* zBPCnay-kF9?v$9Gm!@=KYP%vk>$-xixHWxZ)F#gilZBNClK&JTBIhKYX$)ChBbq=k;dX zQI8;ST@5R?py@EUO;-xf_O*!t_#|=n$FjBM*uC|YAk7iR(gESgds(1b#XLyVpln-% z`m&1Shg3YNOhP>uWrCtlT3fPlGN_M^rISqn71SfQPq+h)Z5xT02;b>D_hd+2 z)@C6mneAUCfz@%d$}EFV+?R8_R>1O0{8!;t@@xjpL2*J%My0?BBOAONRHC|yPQ0D7 zk;V+08XfxJ+YScZ&K%psJRDOvF_*QgY*ShCtn&-pp=eIBOawLW(cgAZjOO{@@V5i6 zc3uUeT8|KE@hzgnY!MoSYy^D70Fob*@p}!QaTv+`H6CxaohW{v4DJmy>;r(EI+z$P zY|W98<-t)5$U#H2p&H!e3lJ%8pVVP3frZ`>e5fi@jEdq|S$W@42M!p(2X%?ycAls& z57c6c*)W0UjTjr6;qVo$a3IULUFmxz3J}+bt9kn&jA;@0U0uPq7e49$80TR^vmi?* zMu&@-(P8KKKwYX($Dae*0wR`FtPXpF3Kte<rgRfl2H<$>GeHM|W-Wz*TSl$dh{G`gYU!|}QA3R)ge`~)*EQth zO5JQVOx1^MEq$@pjnE#dsu~0CHZlzUhV>XG*JV<3589>q1_j>{DRR;TI9PoS=*Gij zebea}!brD^MpNL59>|E}ZD=ezJ|xx4(QS9Y?~;@&=VXkU!4Yhe56&2Swq!T}nz7v$ z-kSJRq@W^WJN~KmMa!ibJ>6mW1{03tob}*@twoAi$9ZT5(1Xf-sWmOKiuCDNgT$D}v2%u9H{ zJqN#B1@vx`hWd7N-S*-=Y^C4xN=nnPxiP|I!K2I=f*?H6j*`@*xUuDvFm1fo!;%;Z>E8~Za>IbLUz+Z2`2VEPtp8rTD6~D z@nf`>t19-JC3)-&&fTHI7;C|W@d8&30Nkd5O;-ok1LxJlEE1zZ8!~v%OcqBIt8)N+ zbzntF!^awD+&jPnFV+9DSfjEqkqk<4L9v4yIvl`^EMF%+vVo6Dr-Ne5D~Dutx5b;B z2!||r^1HzGxUyUCsIl>-Z$aza z`Di*zQrS}p;9}z0*g-{>_97ZBD+&kzcjl41IiLm)e6@@mAu@@FZ@P_KR+0wd$S{k8 za|ZzVN_%-K`ZD!o^(Y)9OQYq1hjCM{JXK*Kpe zDoeP{mv}br1{lHwOCNhy@@1U>l<8{@G28+hbv@fr91+mmyv~6K%2VY9@qi%_cA+U4 za%{rsupCHIU`T3Hu1hI_rsKd+AZ$WMTVX9-0iw*cO;E5|#gm>))VG29O<_4sYm=F& z3Y9Y3kSQ^&pqJPV%JRUkhd9w$V#zd+z{V(W8g$MUkJ22CLnyy=8&Cw;Riu=x4)iPh zr`zXl@PcJF-1K`wYelg!nP_-`sr@L-=go6-1dvao(WqhOic}psRpclUmE~e=-Lq$h zTzp?zPSX+I9U8VMB^5wDBI{?}A|K-=zG$AW{|P>q({1Sj%dkQyfC^w4tVAo%hYcQ* zoa}sllb=7r4Y6V85wsbYG4?*uQCONSeWPg0)se6p2K5#k=;O=FvkC7)C^pQx@-8n8%09e6A5Tg3V{EpXE*>R&{!&s_7m!AIh!_=T zOvP+%7gP8q#3(C-vd1wMl|ce*J-$fFgVu?+y)|J ziM~dB&1Bv~rg7Zg=l5i|2-lh=;Fs_9RaPY8kE*u|Vy+g28=A1CHHh%0Fkr3@(&!EI z4~uJH;-m@@D*%aMN=)mk_1CpuIY;01P1q4}-(vqPXtnxKNAO@tp6 zfc{+ARvA1R*K%@SpCei+`ih=(712t1m%#9xpC=U+X3Q+e!m{d3Ro@1YHhxFV^2KXgw> z<{`bxX8#BhWDY#a^-Pwj{_$QI{_0a$5)W%Nw0RDI4CtsLfJhI9dlQdlG?^{6Ly(Hu z>GEM}j%skSqS(*;B^&X-EnA%d=$J;hKZBx&mD5%+lj|ME&|5`8DH{DL?mg54+2ND* z1q`uZ0eAq>GGkElfA%5gcn?1fni1W-=#Q28r~a->!e_cmQo%hu&V~!) zbn37WNT)+SOc8RXuZN8P&Wj@XKuZYig>S4+cL7{LkWJbt5+i^?q9&q~fDw#89Kyu# zZ>g~lKHs21&b(ko2KuR!Xgr*_fw21l;K*>jFlL-{l29s!XG+1{-rk9yo0Pr8K6KH{ z-P~mva+x_vb$2B*XY&a>P?{pTY4+8Z37$SViFPGdMF3G)ZWqY-<1DAl4x0HHx2~iq zYN5(A?tSA|i8M#)7t5eJ6O2nh4;dRC_7-6*9=)g2t673N-(BN>Z)U$foC(?)Ui7J-7Yh*p zLJ}yZstWCkyB2bP)`j~~8%Oe|Tu;9u9pMM45Z$-t+^}Oo_a{L&3g+XmjOHS|V(DpT zcda^G|4`zQ8;Ol5tFL>;XWA5VQ)46MiU@93ucZL^kWicCD6`Z6Z5F^~>}#&XZ)~pn zR(oj`&J>Zi@cbjn(($4`T*tyh=T@K@r z66b5@8a<(BE)7eNa&-g!h<@asq5$UQd)90`&YX)iVVsRSKq%)tUrJoE7`VHON;39e z4*dPygH_fu9A@|GoHbQMfe%;rx1PmIq$h^k7^c2K_sw6qjQZfe17;fb z?7J9AvWE)|4a|N##yImI1g+skVn(*Mq_Ubgl3@+W+GVp?u9DO&jGMo{vZ8&y{cgGA zMGlh58GdMntfY-{Iwm;73_aj+g^YtT#5ei}B$F*8?`CL&%0=Evo9&TS`Rwp5$=DEi z*EU%(*BwO}cqP?r%#CbvEY*fA*D%voYht22o)J#;3&_BVIvTmW_O=o;W&)jLNx5@5 zOXf8`g}BiD%FBSWRa|^Gjx4?$x82?pa{_Bk0xl-*vrJ*m8UCJ@l~MI-RG@O_IHo0vxc7 z_87!Va=E(N5EY95<9wUWXF1nCqjIdHt}DQ1CA6z9Vx5mM@+aqo9EE7=N*3denN>lm%!Ia&ZPN1?6xk3!Obmt-@sVx|n z73~Iz78<(h!pXFgfV3?wyAz`wJB@)r_BAfvrw>+xwg`;+P>XuN8AaX#&NxyOF-t<9 z!=Yzii6VC5$6PWi^^7g2yKTNka2)hBVyBUO`s{8%T-AN(io|+T=kho*!0s-HEzMU& zl4)FD;ubGbhwRV;I#0+X4rPF@f4KH1n-r`|H zb2y+f??*-7R-9M$T&vU#VEm$5i#7qyoNR&@ZQh9h?f9)LubASymi3zW-FQ2809W2| zbd0K^AF-|z&L&SYB`m0P?%os1pa`b=&!}$}#L@4WCF#kn6i}2# z%-p)0rAu+Z(@G&1mZBHs_NE{&9<;@ZR+Odau1QCw!p42wy2vsEu%}pgqZ{6PnJf@f z(O8!wn^D8Ly=kF2hN+e@Qp9|KPlt3*NP{<%VfU-Xwr6~8})1A8St{17u8nkrM z_=;%WW8|$*nkTEN(dNTWVdBlEyYCuuJ{cST@!=(elsyf^pyv9vv)2;%t+m?No-z3f4i_?6SdMEgGc5d3g zmC0mv{nhU;zAC!Lc}EKWt^Se_%H#JTDz*xC)<8`_x zvi_~>Qyilz6H-|jxjlRPkKOQn(-N-RrJuTZg1+Y1 z9`1A2NrjJ3)@I|2E&lJ=o@C_gR%jtP|2alRyslgp4`O2Kj1WYNmLvY&grphaV0m&a~j%EcL9Dg1rO_cBT3?42{hlC?KQa{;D@f-Ukwnl+NcGNr*XAQh454`q~nJ35c z!na-9ELw3a-_}$;p(R~W3LAafmKs)46e}uM??I}YR>!!HgNO|=7dy|z_K~W^`afhz zk(Y`Av3W_8^$yQdu&53{s}Q0%#`?`5!NMA<)HcBh2Nqoz1@!QglT4E1<1r;V&*SCOV5SB5%=P^1nEj&A;WtmtAPM-48Y*QzPRr<8KdeB zccT;~ORpO$(-BQ)4z!QbjiVk%R6i2)6U%0>1l>W4U`F=F2&%M<6iP(MScS8Iqr!NW zMiygjKtORx+5t`BGsP>k@Rp@q3EI}dQskqA?mSaWnVfBuLza^b0v;PPq={Vs{GH`^ z_I@S?Uq21~ScQc_Z@$SqV4A_}F_~^4zqmE{SDR@?j0)JKVNUE1C$qQTF^*Q;w|gB%P{>3!KjRU$Dp z>m_nDvQ0m6HQImf(nxos_eZ52RgoP0WpR=PEQF$Vl7$rfgR3QAp2$9JeeEe7ci}&3^CR6&9OQLU@R- zi$9dMI%7MafqdxO&64L~q>S(O2ZmBpoE%>poW47-_aXJX1oYnY+1;my#O;OUk6eds zB-(h{UlJ-L{?5Ax9v;fP^u5aZ)zIdQwvp$mb>4|SXTTTD7+Gpq9`x%+uLf5RmFKDq!w_*4-^%)WU753U%QUaDH`e!q~P^Oq90@Cl3$dz7j%=J1Ub^&lo^t_)8B5zmzIasG=xudOKpZ{^h|7xn`> zjBBqO?{}yRvg|tF$BBP}?LE1tAAZqMUCE#d$+{FVj6w0LOID8>(p5wl7iNW2k#pDg zZQ*JUxM2#;Fh0y3DZ1wIMu(Zr&G_&_@nd7U$o!G{1g1y<^Zogw_rm4qG`#5e5iI-2 zM-BYP>Z93}%ysVjHte^WFkj0bm2J%w3Cg@#1p4r|DNxY`s~y5Lsf~0J`q-7Z^f+TKO%N5|HTm1)f=!*7?GVENG%Maxhp@Y zlGA6vF>Lt*dA&fJ4)u!9WZL;q1z*ARdOo3@J%?P2L0U<&gNpN-Q%MEn?e{}xdN^6?Bo zt_~A?@DuQ(Dy#8fJ*_xXzi4|G%$gc((*Vmep%U$kKg9KpFrLXi}$nYkIGkcH^1_lMC|ExOaK90==irpuS@`kBm7b3#l`D1btHKy!BRmEpYI}o9K zI_Sv+Hj-sr0Pkcv^h}rZgCRv_m1u$?((xhMWcUzYi$c`W=IUf~@{W{>4l}e-3dWAT zXSkby&;k$|AhjLbL1P7-L-5Q9ZKAq3AO`RN^Y-in9%j1;ya(Kg0Kn!*Fu#aAtB$uD zl{}tupxqZ+XQnR7!>V2fbyygu|5WydAtt%N4zAceKUEfSZC#LYeu!nI@^L1xiyzV? zr#17ASiOlbx)ldGZqaE!m$uflAxEvHDwy*nhLgmE zIl>(z2o4aymgAnOAhH9%URAi9!}81KTriI(4gW^m)bS60qyH2T6+Yq4OGzWv86R+4 z1_0bYS3>V7$;OH>K#~(xobw;>;evL;8h)cmE3rit8HG6R1vxrw&)I((dt{GuN>zIf zg)OQkDC@tzE2RMzqk$?MObmyWzKB&KpH}CCeS1!1hT~1BSUEcJ$H>)Nk3rvB)Ft=Y zRd?_P6Ds9hQ)j~K1q!ZI&$w z-?V2gvg+Kn!V4(9T*j(9ug+(3w|oQ7;T-TwpP7#D#&E->Z=n5xccIhaz;`16M|j3|3TN zz5|-8j(e=#Y#esf^e!QrluigRN)=c&QmR0~CBf$dM6V?lKD_w>}Y#U&y zjt#7oP$I*fB#fcC*qt`?&N0ZA3^ku)^;Ckl$9Cy8i}HxXz3n%h+Af)4NhfcJV(IeY zbRyDF6R081j7JOlkwY?|^NhX6-Lp-&Ykqf+aI|j|+U4CcrHfg0-s^XI@nwv)fhYJ9 z<1tvjpP1lLbzcjA+t@ElfXyG4RDR1AqX~iIF8JYhUv zTo{erJcSD%Lb@z*Y>|-4y9&~Eh-{m@)G>g_!<2?aHNOK#$r&X{s8=NzE6`8=v4#7= zHijt;P%%_rbktI@i-Myt70x&Mw^@ZQ@WjJ6wm?l1>Y&)9 z@?^97sHa@yJ2;IPpd%2f#qbdehLjmud41Adkc=!nfj?Tq!5Hnt^}Ee}_`5o)=)OI_ zK3>DITU*BrUW48@3uwK|SOQUI4_QBmzYQQqr7i9LTv->{j(mQH#FFvejDRekUk&iaa9U-1a;r)Su(-%#pG6+{~CL?v4IWPux`(ff!r?Y&D7Dhm83Db1p*4c@~&n=CnAu+3tr!gWc^U=?{86IEQ;;YjZC;Om8F zpS9FGV$qwJ#7C9sHXC+h#;-yfSdbwdzomz@fos|{<94bOPA8kOWpsnM@D(;NIOP*p zi6qe|Y-iF2u8)PhW+G8j)&zW-XkLqZwvTn73$YqJ^OV-CMy9xbfztW_rt`u!MmDlv zKzV%LWF-^n|Bh~_Q+6k87QItv%NyLg$h*mhzAQb}qT`bjFzZ?9$Ag$3^&8PMX&F51 zt!hJovIvb%Il>*-z$ZMfx10%w&zVT|ZzdJ&HrByGn(NY&m_z+0l>Iy*luF#xSBQGj zVm=UL?CubKO`dPK3U2^XGsw@1*wBhE>d8*Ci!-})71Z24To7U5J!6=}9$*QwZ}NFd z@dIsFk*@=u$q9xXEw*SjTJ;@0%0~O#(rkE;`NUK1%!>m5jeWl;hHL4WY_w!EGFXq@ zW%9I&IiySumI!`Sl>5`RTpIfr!boEmL9DQv-jeg65`pwy(D6yc#+3%5Co} z&8A7CKy)8%!aOI{H3!|tc7A=*cFxv0kv@f`L5h)1>8KErWk9EMsESnV&*s*3M*Hfk z;l>uK&yoY4y;a(eh{%}j-)fJiU!8=I)tyw(j{4-CJ^b3f+u1&AzVQIV;g{HJkwk6l zcba>%V8SI({e)w>n~E3<^XR6{v<;5NBzQdt!d4F9PfD#7;k9^>W&Decg$h=5VvY1O z#WT=f8}JLXM-0F`7~zeK3~zc3?A~p`cHZ<;1JR?ycoyRKdfbM-(cu*0NFOaQu^Kf+ zq8P>}UqMHJ0P>1qitT$652@-Lkar>m15gC;tfsJs*3+8Z*vgiMRElb3i8eFV5ou_nR3j?lQ=?QWY3xFpqEb<#O^Y;I zk(zt1>wA5FxF3)EKRAClxA$>8U(Xj#l9XA2jaTpmGjFF&r^3%137Je?mMwfBFFl|@ z$5-7Ee>k`e-pjN8?+rCC7T)?TKSYe5y8h|GA!!35+ z+R>j2HF&JL_50B>c`4WX;7>2mnY+jF`@0?avYLv~Uu{2cu6X10@(m12MalbbpLEP% zyeDcK9(5f(x18Y5llp9{XT?N&Wu%gsAjup!PkBXVfb*+9TGcHR_1NZek^Ma%+^vvp zZydh5IongRG3f(OM+9H{4Fv0KqEC8-6FoLQKjH^ zsEEvfN4XGQ0qogruK8;2r)Jrev#^c0h9KCY7#S)DB4i%qEI>>!6Wg3H7bYm#l76AFo@>O$FCAyf{=?pY~7lMJ0# zS!Uc!L}@3dEA8;ku)8LVvhce`Dc)%DEX?<7CJQQlr{LFde|LMRn(!fc>Nki%_oKyp z1zwx)Sk`gMgih){yIjE!_$5s`qokj^{Vudl8U0xbno`C8uv=DU`(Xzbu!4EQ{K&KLa(&{;)HNfVv=Jar!w<=|w!*-CZ)3kKoP%oF4jUbu%A}i)ua$CN zD3nV$dYmqkmAh_rZlslb2t{58OZC4~mg%#%9y?Otc}m$^8lx+j#kR+1`yR1SWY900 z%D62JB(kvj<*z-_4TMwxvvB zPZik!Eb}AIxu9!xm===g#WUm&EZ-&erA4Ld4IXx*tnf;1VP(*A(-n}PuQ!gQ`DEan z#f)xPq#Mxj*Nj2PRT`RipF%TRw@BhpQ~*o$m%ntBE~@vg=dTgGKgCe z;2_r-5&)tET5;;!{TpJEbK+k?zDori=t69G6;E_=Bq?_s$`baVJ|aFFT%Z{d8S?)# zb>(~^K}hJG%yk-WmWhbEO4TYqyvbwtPO0<-IN#V_Z40EiVfoE`fI?&BJJkpT+5tvE zyu#23%1unuZJ^ROVx47#Nn$FEkEaTHxICa9SXD*hX*<;&V;lX?r% zqDAH8K(&^s3uAI~@WmaL7h`N?J93O{>&$6M>*QzA^VTcT zWT0D?p4K#tvXw^AvvDt`)Wg8`d&?t4V3(BHCI(!peXYtn*;p+cXxI8}Yhp0R6~%%e zlaX(ko6MKnnY(;SeMC3K@IwomX$Q@h^u`uZrbQ8&pZd2YQrK`DX10i&X_ z42?gd=&97V30*{8&bqN969r`_-PBbD-1I7Pw)WpqUDFKV@!rW~XXkeg24~kjUA^bK*z(o> zlxCJ=Ruu&%D?iwXrHLIV%r@j7xzL$vcu@%?$i9>X{tH&mdzrRjOc9v<`Xb=2rOot2 zI_Ojm0=y!7OD<6m3s>#dD@&r#Da< zhh0Ul+5NiOB7&)|L}ETo;^lcdFjvava8tZUdG{9YaAYDcf zY#{->Hwnq+Dnhnaau-gX?@l~G38gONf!istE(anYBu^0KfCfi5$sKCqYcNPGnX#!N zvYH3l9G)LznkwffVkx{;%PZ*Yk+R{iC|=Y~&*ilh;;SMIw;xkNgDbEw-y=}mXhFce zigMKkcaWvm71TT>wSk_$-iz9=OzlL%Y?(x75eSXYSv^h#^6m7}fXv$bXW5>F@<;G{7?U;6PI z|8Jo?7dSl9@m}P%Kl2EAy8%tx)v?$hM`@L=ccb6J#z&Pp({(v?snssG!j9iMa(40d z7VkRS(diGZXYIb^>4x|_Ri2MHzvZLY%>Vb((DD-*^&?VwsM*DcbF+uCv4#H7Jqg8U z-e)ug?&}Zyo~V6d@kqV>e~qClrPrK4ee}XMxk3LDe?oQMzK9PSFLM9bROaw`0GmR3wYDYp=TrdwUF)SX74?$haHaBaU@Z| z=Cwo5pWOO&jd$>Wr3n37`BLwd{)=BW`1B3JyUv%p#3&A!c&+ifZQ$Ie?S3y_aFm0a7T; zB9lgUSr1_odpBwK@^*zv=WY{#3P32Gsjv3Y0;U0k^&Vjg9!w?MIKAOqK_}c09&9)a zvYW?c3ZEwe<_rw+`gZhWd#iyo>4fBNvpel1LN+b0MUA(z@MAYm6DhY))d4`gr=MU* z;|J`Y@gT?pVkzv8KN!daVTg;^(&=nHzy}w@*yI_;FCS|5l!?NMRsdXhJtt4`#22E^ zcF=aSPceZpx1P)$1mtM$)hSfa<8Jp0H5x|tko({{W{2Ec6QFOheAX~qSfR%Uai(-_ zlt9={dlDTT>gp%K-&!^v@5Arn2uR)jDyb-HnrOfVDFPU;@LIEFpA z9iF~p1*^Khvz5d`}H?lL0CR2**`?&*URngMC)waKkouqj+5?= z$_`y0^z!v>y!b?%OqT{#xE`whw`ZmultJQ|KbZ-OkMGW@=uiPC_B!-nSB!LENaAa6 zK5`f2g9Vh1m1TFA?&W%>o#3HFHV8>F^35W=F9*^Wr~)LwOvF$lq1*6)rUG$s^Db=) z(9jLy?ELyg$dBJ>d^n;`2RBlI_F^JA3F*PfKoqg679|(V1ZWgJt2s^aT97P+OaxLg zV3w6{YUq>#3?OklIF19#iXh@yIE9bMgsmCtEVz{p%h9P+3cL{m8#f?wJmnhCRQ6`5 z(S5IC1MqiRL5c&BmHiLbWe%NNr}dr~j|*G9fiO*0XS?re!DPC4s2)vabZ26CqTNIV zAy;6L*ayT4+_y1(^nO6$B*;~Omkfy33oIls>)vrNh9tG%3Hq!Cs65gg4}%X}@{b#O zYg=HG3eZG=vsFYcxm&Ab1BoKdr&p07rsC=fl?Z*AOpk0f1Ck<1U!j4%48NFU!0mB{;~g6a?8 z4({e$(g7Eiwx%QqC;_Bsm6<#f3GE!q149IeC|f^}BK48Q-WTQCbuIIJrq3;U!-M0gu$qIOUbU$Pqw2*yf`_l<3L%-}i2oSu;d%IDg|XN` z)@07+X@#~L761O`DU60F(+x*#(4de0I8QzK-EnK~tsAW%(k1=}8CAJS6VQf1l;^{e zU`d4lHe%p_G$5L^OP~%qQUgCkvG+Z6kYl2jT##Vv6(@v}xNwA5ijPG8Q3T)_by)zq znWJLPM2+ZFa1;EFS@l088{hj|F|Mu_jHSc!0I1U?tImX12vvPKOf6FAsZMVj40Ys5 z-=5!?b{s9P1XB(GBqkEY!R2HlI36m;L`?Nj85#r;Jg(d$JU#CyVMHfQ8`U_jcg%_- zIOisR*ImkU+Gm3v7pZ<6sCo7jh(57bo#aj7NdQ0zg9EPRyBv@@-)C2o%dL^4fX)e$ zBW#^SPUCmjxbFlmid$2fL0k!lL70i#0+f{rPm}L?keG#5$*UDAEBuY2*a8LDm_}O; zu9sV5dX-|tfcdnv4!W$n5HYUIUxk1x7eUEk(;k9TgdVdrKt|ZimRfd_0&KbmM{yt> zX{l9ehy<&*zfg&0n%gs=H5?o~ zXI#t`gBBo^#BaJzG13*H98w#GfaanbtRc*Vk0W?rZFq!8*1yRhGrcR_Co`!n*Gg~}iV_}^ z0-g`REq()yn%cth*8iw5dJ>8^>ck;jpx2{y=N^cN-8vr&!4J`Oe?YS#)cI=5<;N}B zE3{j%P?SQ3U--&fE#!Q0Bka5GrrpNw%&yCY z5X~o6R<>BD$gJ2e^iWT6TTgrtcUdL}&2x&1x2rDv+3hadRUDZfOAA(dXTLW&;c+39 zB2ejncf)rnY+H8xj&Yf^C_K#QDLo3T;quomX=T$;%ecN46pCaN9rNk+0}^i8TlsPftXEX1GJ z)V7I~fC2b4M&wpe@49Z-j4EHsL|~!nUFRX59RQQW!~d9gSU4;hX|}5lW$<8;hj#x8 zU|H7vWlt3Errx@;BtJGYT$;)}^%NQw41cBJt%dllKtj{r2lLHw{~m4JoqAV0Vsriq z19I;sr|x2tP{4$PWF7^{96dd%stSl>w2190tF@Az2ISoM-t zdR=06zPx5kiVYtreDk8-9U6?)Asada`tZlirg7H3zki%j0+L@}Lh3V@cPdyieFj_;l955=SWV)J6aj{)z# z0IX!dsZ_+dRg41u8jJX4GIq%q=FXb)0HEku@yC}kSZ>u{-~euw0|&WsNAJrOZ9N90 zCs2Qe8SR}1u1^zR+@!|xaocaasqOSMA?+I8X8m>mRHnfGd(!=Vrs9L|sqELHa8OI; zh&2-jvydl)duUADr&6T-Q*S8^U#f{82}LDxo(%de8m|d4jn1T$B+#HWB&og@e6r5) z9?F`FAav^@z0}nxnXj11oBqec?+lza;weZh2za0A%>sO9`?N16yWQ{R15{BmcxR zl!+OE>z6Ut_($$~H;PoPccm{t)B)elq=9P$~`LKHMme{uCWG&jfX`@Be6U&vqF_;CeBNT1e8fy!NXmCCL z5%{fNcVFy+b-w;~(R*;Q1z#7JvWDY?=-*E#4O?>&n@A^v$BKhm${p>)_vz@J8esb? z#ls;NO1}=J=sw`S`mkL~$(lM?{~z1{L0mUH#-tsa&5`B&(P82{wH9!6+&B8CtYG|z zJHE3Sil^i4NgzEAaA5-1C}^$C@1?FVF;o%zG(P9ON^wkQ7M8(Bhb5RpQN%gW77D@jk!wf5J_O=oA8 zmtcvsqbpFS=pqi^3s|Qg4 zKfNT9uc1-iDWcyI2LkI&*AZncthtR-mq&ysTGWqMLNFeYa9v0tB@LC>lVnprh~9O} z6&dcBAaSSOK@cv!k3gkvsBl}?!H!adx^xZPytNv>KUjUy)FhZrYWa$Fkd|yG9CDZs zPO9nYGk~kTI>(#zG?FIt;q^C+qcp47wBoK(H}r{+5A@`#wd7(|A2H^mA|MyY5ZYCN zuvPeq<^+LW(FVxWv4Q$#e49Q;$96y{W(qy@isbeD3ywA5p6r)FPf$y)Xck(fAZnPf zw$D{J%N+Sp)gU-wxLYgv9zC+>vxc*64I`zXGha{3m9a?(0}2aqD%;^*%f|p!E;go`eTtDU z+pH&RBe`2Vr64J{K9XtFY(jtL7vTCX)f`Samp$gtY@&i<3iPP)iuP|RgB52Ve`Dar? zcO@P5IYfKQ30{%m7V#~TCyXvPq3I{ii}29e9{O`)AmwNOUP=!Zr;k#eB*Pl1j9wy< zVb`momT-W-4r73QP*yuDpQQ5kTd|sLJ&h%MWx4-ET<0BnS8^B70@F((gjK68jINK< zm8hZ~C8DE?vfux^P5}FIc`c-l_fE%2(n(hF!S$!r+VsC zUT>1Hy}h&;^Kw&&M$5-Z187(B68D1nU&xP_pb zI6E*wt{%4nR#c`uLnVnkwTnt^Yj~atzh90`>poGq?@s!2e?I8oOU{Oom8&j><4G3t zKpqiOiv(Q#?OQsfV&}O&pw5zeC%MyW&Krdy=n*)-q}u1wZs1Q;+9Q*6G`x$#!@VT> zy7%hh+M+Qyzlaq1-fpG2XG8#nE5ytE_}i~ZHYKreD!yUZke@c^VH+!az7yx*Rp_p0 zBjopZRtf~r*<3CFN^`Gv)cRURP!?231Buv|$B!c2ucjj5+DCMw`@DDu$O%P}2O9ZI zULQVe&ef*Fb5anEigtkC-SK`Sw;d!4iZMDNSF;Rl%L zkH?ogA^}E8a+lOMqasmSK&2LyucGmrr9RB}Ed5bLE(_De@1C`;lf_9tcWC&g)oc1o z;guAHR;*iv153X7sscp-!PtYrDsg|4r&oj~(dABw2j7Ky6?#4Wi*C6>@Tt>S-Sib@ z9mr*18nQ>(D`_m$YOKp&fT?$8VE8_dV}PxwgGd$1j3kEPqeww1Tjn-5T`@cs%()k| zzYltocYO7TH|pd}CErG*m+ASon<@z97V?zt`lwMcdp=l?xXWZqo)A-9B3nHK?;z+R zII!@iszS80)MTC1rV!CQ*q_K9mKIDCS1-YziztDCfH3rX7jgL3W;`mK*|d<7hd$>Z zMAH368!3U@|&jsMB`#P{@piZYZ^txuF!M0BW7+yp! zke@2tzD6PVrR}2|cl}14YSQ^)*DA&d!&dLTo{udUuIcb~YAvfKJzUX|Qwb#aZekP2 zu~ZT3^(kS<&yN84cLK@m!#mu%H0I`iK;TjVT}FlkktD8y%szl3et5vwx1dL9I7`O4 zA)lPYSgl;Zi@B`K_uLv;TsVOckOt6p)eY?)2R3M-o|oj<9Xx;M(Ed1vXp|tfQum=aHzlWXEr{z=fgHF`b=jTyOowmHh?wx4W5u8z0hq2B&PwW@g|xM>`OuGDR%gz7CfD>Vc7pepN=5~)E`SA=YD|o zBRJD8zZV{KQkR>?i8lEop37H-gEBjDsMLpAjm@(-h2hYT)bUzr1yZrp8agUf--WIP zP#x=s#g0rzkPU!m{)p=7Gq3NngY9e$g&!TpYvo}Fiq_jDyff(p@xSx=ItsvM5~fGY zX69Q9u>yyNPI+I-53P&b_o*JXqcwZdYyP8dj&0&U%H(9|aOINMn{>&S8Rr>{3@3}j z@jj0pW~5t;#1&KXV~9=ry>vbFKous0Y$J8);K~srEs|8vCWQK$aot#GK6zNB?{1^A z$dRx}Zuco5Nsq|+bWf$0l@qErhVJmg;)rUid%h699CT{iD?H?z*>b3suzFrkzT;&p z)q#5iG35G%Z}K{t|FHkYKbwySzfEn@i<#;)zAl-&B8HF_}c8 z*yo=~5Zmi}^5x~#crT`B4^kgZYMMqdbt3rp>%UffTE9fOxJO&z@~?qCcjS*=aLQ9ce%>`X`!PzV{oU%2#YZRq{b&fe^J(p&%m-pI_#@1#55?-u**3^5Z9 z;=G5ix_$quDZ<=G3TJQ zUHFRt;=(NOCpad4K-EGelgR?)A!zUo#Dp-H3rPb7y{z0o(=B-nG#5bEidJFN!X!Qz z3OM>S6hDdp{YW0xoI+QMK_(eg5+PS8kc9w#R#6OJfTE~y02lUS?jtNOnRyLF@Wb}Q zMb*74bO1z+D#sPt*$T-@9K?PeFX2MgF%`BxVdruSf^E=cMIeO+rV0x-w!xS3AzY`m zCf$faMDQrMXcREt!|?M}XX47cVtIKK;|(v^&W&8L0Q{Ssu3 z9yjTwp2Cz?l-PkKFcQg44nQ{3#O0g01#7qtQjM^)XoJN6Fa{}rB7lmaPC^*o42(o= zs^>!L1T>h9EUpDyNGl(63L;4OBtY7pgP?`7&*v5Gc?E|AIAwlL&s9K;$$kbyNleB0 z)#V;-8E1<3PI5M`$c9~b@GTgSI3ND~z=u;cG#F4c4^`wraa45ahm|*g)5g%8^WmB# z(30t}g2SHhz*$iZK@7K&fr!2x$6AuRov-D%uJ`UI|)1#V(EihY@bk+!Z5RDk~z*iBN zCIEWS5gF|GU!H2+7&ME|@l@OWN%CIvT|QuDunzFc-wE1dVxu(=5jzU@Y9~l1fs-OI zmST`703}SiEd|`8pRPkTb_6UAzP6bct@M9a=vD*=i(Gpv5P2JoLZOZ3c@GPbzT;1L z8xxQbH0W@d_tOEKAmX6uTFICp%Sd)-m8P)(-YZzOQ4k)EF}Hj9uk`~QyAgl^Uh6@0 zGf;p62lK!mojL?1*OlrJo&vcEq%ab^vQYJ}(6mY4^yb;PBk89kKYw-%800ybw*VG= z3DV7aJ?h|?4odx8mP@g_-+lgsIkHk1;8=$$0U$_1N%1Qglg=W7B;a$HTJ$)8MD22YlZ5>@A!;grVLyw$THCo|5eatAj*M zt1C=nnE19I?wh%`Ml>jE4Pjo`?8t=I@-EqvT(?J@yuxt0R!?b)kir=2OMeE)F^;Yf zSrfSE$Bl3@MGS^qPw4Tll5AR1FtwMvlbZD5fF?wPX+OzrxoFfHeYPO>wMy|xX^q=q zs&u6PUE)_tP$gAIjpv$4t)2T0*<=8+Z&W8h8&!T@o=r37HJS0v(Jx7&*0|Qlbg1OE zT(;9k8LD0e$i;KvExpc*Ta73Mx~^2C=c56;3Vk=k;G=~N=y=vjr+u$T#xa;Z{=IL^ zMR5MA@hd5PNoaYe_xbJ^#eEwf6(NYJ;&;;sCEs)-UniRB!}t}}>v*~*WdU^s+fD&c zyE^qo7kD5{%Wlr;P?+VAp4&6S8%I7F=UsK-ecI?~=cY-wup3;3;@FL5GLdUdgj+O?^_eR7e;!?v2+3f`fdVj_gQrZ$sq+-Gn7xLXl1I8TT#!)n z#qK*>(v8G?3tc>hS<@A-RFs_4jG>)=t)PQ-Tc-m&!Y_Y!gcz!)4J} zIvlbjLG4pbytZ&~yL6MX5X|JjYIOW6VZq8Gy($_Ua$EPd9vngP7-5|>1eSZ(d3QPH znCi*2QR3RF3CpX$(E10fA=#9+@g zu;O!QWu%!0D5ontNSQmdcotOT*sXjIM_}R%MV_MTCC}XgyD5U=?{naU3F0S_;>-he>|@x~@|htKCWhPv+lSvu)V9Vso2PWbH`x*5n> zfd(^P`l%upKFcX_U;6Gp@_SwGC()QIA2AkUE=+={8bKpA6dL3c&5#Yvx08faFY4=sjV_TNo-DgPPoFix zT<7E+P5B??zJ&Lk1>_lG1t0g(1O1H>C59ALkVyr-sp!{{uTBg&Zc(wj>U`$*Y5(%+ zSES{tT!m!-j$cvj6-G$NjI(Ef_ahNg&f1bzTutHJ_>B{_XreAzs7SI1ZjfY(d` z5caEStLYAE*ZPf01Ug)0M%1ly+EhcdShwm{0{HYfvHh7`8WTD5E#%Uhn)JRdT?z@H z8=X?V&Q(T^o%&^Ki`U?suL{E?nHqfxyoYXT4jTOv5WPKftaN2JK7T>|su07ue?K!1 zpex(kh)GEg9%36nH+7(&2QTAXEI&4P@9$(g{y|Q8+pBILJUQ0v;BAQckxWNFUt8f# zil}>n1d~RE3hqgK+<6Ch!-?;0(ldJ0uq;*M`4=kH5^;&-l4!$wbgSz)en3Mn0nD-5^IstrWFbAyf?EyxXX z86psa+O^e;Q^;n|+pQ5X zm2A?!gj_eB$s&;6z*W7aYIgryJl21R;(1`g!36YK7XLoA-VUpiQs-*518=%V@LC9o z&}QmW#BtA5TBBx~7o=#CwcX`UI3u9KI<{lt7W2@d{5W-bhZMha;H<3MuL)D{P+3hn zD!=q`r_fU&TjlD`0e>!LM#i7uFVzvE4yv0FWdDXocp*+W##?g%M!M#xT)A9G>t|W5 zz*5`laI!sR$|S74DW6$Vu{o8PcDl6(I`47qyo&xLbJ^`@guM|Bm%=T*U^N!`#4 zzLzuEfydNF2dM6qjr6m5s*1xx_YlSGPIqt_li7W$FoN?&UP~GXj9Z{U@x`j)=uPb{y}4~d!`tQ;=w#?U8t1F1Ga5@!GHcaZr}$!b zOCdh;Lo-Wmh)LNA|H2GlXG;ACFV z0@8xR>YSZ-%=t$4|NWv&d58xK_6p~KG_Kqsu;j^sbZDpDyK5sf!!krB@+nrRyG>22 z@5x*kLH2&`k-$O)gb+Nh>qY!89p}XO{H3k+>Gs>J-^FCiGIhQ%UVQmeB3(wg%o?+g zTfEt;zJH62vZu9fW*Qg#&L3aMqavYQE)l*33&VVy*ZFwm{jAt*v+ z6Xf^(|D*`w-?08y&Onc`<6H@Hggd+4)XOwx?m$%gSp|!#UTcXIVflo2*gH zlZ^-0P^jATT-jYm5`Qz zB!kCP;c;=XReU*w!%_^yZ2QhaPzN2-Y86TcMY+ptVzw`-DOQ{QO$46^z{Va5L?Co3 zFfmkqjJra5{0VkK!kcAF#&7AJ(pzIdb%zAl*tXH(@9Gifowny_y=T{*=M=aU0FVML zPn$yn&By2zdy)I#KYAW2E8U>%?x9-4%SU~g=%?-nes%t9O&q)R);Dw#gZc33?d|~C zvGb5ClWFB_9*-3=alyPo90r0W4Sq`Sa@vI_Kd-Fy%Jb)N?N%P4pnI@xM z8VPCt;~l70drc0{Dp7NvJFt0gkIIIE?&Tvb#BG7r3I@y1_2>JQ$ZS!SU5#P2%c^2skLafia6UOk_6^}qWO zsl?b9!|$`U-}x_@p&2_dq?~p0{<<%lryZa3t5)0&IykE^wD{ut^~^i>?tI!i`eO2F z(JsBWUv>n2jTPonvO6vIiR=PSj>}xg=!Wmiu#0!UuH4_&8*nf!a_!zX)w69^?B#dn z!|g8!YqRhDw%nO1{=Mg2!S+?0r*hwYhL&C~znn81;yQc8Gj4io$HJvt`5#mnmlxi; zKOWAV$~gAjdD1R7XSBEdM@@*!hmae;1($=TF9w|Yyyn+pf9J@?mcunOJ`Wu4_Lu)M zKk4$)YlqXpSFWPAp?$p>A#Iai|4p?w)qKrfd;0Zv5`pWi85IKQX2F>PDZN>!!n(mu}hKT?pOh`Xk)@??;2Hi%-Nx zzpmyEeM!$f_ENmxvU}OTHI;Y8OO4KMiy@8wejd8=cUEk;^ybyQh0)wwSAMs>|C;-E z=}^tDrQ)z(s?3VzOr*^hm(46Z!jx`X_iuV<>Qm;M-^@@|mO}D=C3gDyU96ACS+V^r z;WWz^pQq@s8(h0PcXi%3lf1wSdCfgc>kmFuwfxlO`OA}iGH8%xa^}hperZB5M(G_9^QF9|kM=M?fPsp^uhF^<)$ zJz|@~4%LSp7s{vS#l=m@6;A3$JXRg~{&V$*f!H>-|Bb36X!emnsr|tVN8YF&4coFm zLfvC4=V;8$qjArU#xESr;H+N1>R3|rF}iw6w~(XQ3E+EtAr;tulkQu6Y^rj- z1V7t-^Z4Fp$M-KBXPO;{@l^%URYlQN$@JsLt{ckY_=3VNkLFuSyfe?<8;#OMB#ij++W@BkzF5s;@Z{fY=SRY_QCs!NUH7VBRoq4gV5<->A;7pLHAx@vLm!KVONt~hrdO5g6|f>$6c`~W zgsDYS(M9f641t-rh$etMm5cICPfIa_G{R49P{TbfIW^UK^0qOUz&o>a^UUNu05At4 zY0z4raKJ1^iiy5>c1AYl^inT(sS!~ExRN5hKj$DL0nP>hqj{&63UQXqT7Ry-91WIX zprq$#er`GQr}mVY1`ehpvJ7Ay0RRT!O&nEO5xQa!aOMEZK5-YT&)PWFNy4S8%q77m zbQ8dpl=f+2wZJ0sP>3oGLYQ>zV(;1Bk~1bN=p*W|tf<~a1Mm@m+t0#E&p}I!4&%=& zkj_c^+a$A1|G|1~9_*xX4&wtdJW$e2rzt{1p99;>=@x_MZoE6^?nB?ghx|{7kqv`D z2n7m%j&5+29a%-+;Yi;?!SzA7A&V&5~kbI;>UcV-a1k2E2^hx7~iXQ=*Z9=^pL z`B;NF)-N_*Il0(EwrF`b*pPNUKmFNy$0$NFcGW&sOkX8))#i57qUsJKCeACi)n`wu z&qXNy8-)?3-7wIKlaAXKQ|=BW1CLi(5_e?g|!uw zqV*-XlBPD!pX)_U*WR0hhcs{QYT`Ob+Q(kpJgli;ax`C3K(_s4Tf%}Qzzx*}+{!)K z##p#1opg!k6o19(c1r7!>wml?)nh@ex7$m-I{w`DQEKlB@al%s}y|^u@9^*@@$DG<A+}Rx`0m`vPSNcf%z@6wrJa;vOu@OU^k%1V*&WlGu5*T6mNjlhOIM@z=bo1-ExV*h5Zifk`ZUiTC2 z_ef*+PS0RmtwGu4gY}k}#ALg8c<^GGTl2)=uY83o&O@ydjqTo{wwj?^?L+MoLmf;1 ar?Ksc8}8dXJn+97+u?~}z7_#+`hNhgRfo?2 literal 0 HcmV?d00001 diff --git a/docs/course_authors/source/Images/CITL_SA_Rubric.gif b/docs/course_authors/source/Images/CITL_SA_Rubric.gif new file mode 100644 index 0000000000000000000000000000000000000000..69a009f50f8f28a1d0c2847765cfe449a7bfee7d GIT binary patch literal 35203 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 HfdBwIBTVI7 literal 0 HcmV?d00001 diff --git a/docs/course_authors/source/Images/CITLsample.gif b/docs/course_authors/source/Images/CITLsample.gif new file mode 100644 index 0000000000000000000000000000000000000000..58de41f0b3098b61d0f499457930ef68472cf044 GIT binary patch literal 32038 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 literal 0 HcmV?d00001 From c495fdd3ae218c0ec8289a4d6513f1db9beec3f2 Mon Sep 17 00:00:00 2001 From: Mark Hoeber Date: Tue, 3 Dec 2013 14:44:30 -0500 Subject: [PATCH 101/110] Continued Editorial work Continued Editorial work --- .../source/Images/Progress_tab.png | Bin 0 -> 31459 bytes .../source/advanced_problems.rst | 11 +-- .../course_authors/source/common_problems.rst | 89 +++++++++++++++--- .../source/create_discussion.rst | 2 + .../source/create_html_component.rst | 4 +- .../source/create_problem_component.rst | 39 +++++--- docs/course_authors/source/create_video.rst | 2 + .../source/establish_grading_policy.rst | 5 +- .../source/open_response_assessment.rst | 6 +- .../source/organizing_course.rst | 2 +- .../source/set_content_releasedates.rst | 4 + 11 files changed, 126 insertions(+), 38 deletions(-) create mode 100644 docs/course_authors/source/Images/Progress_tab.png diff --git a/docs/course_authors/source/Images/Progress_tab.png b/docs/course_authors/source/Images/Progress_tab.png new file mode 100644 index 0000000000000000000000000000000000000000..2495ec72e33c21b051d9ce9ec8bce8f46848b664 GIT binary patch literal 31459 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. For more -information about how to create mathematical expressions in Studio using -MathJax, see *A Brief Introduction to MathJax in -Studio*. +.. note:: If you want to use LaTeX to typeset mathematical expressions + in problems that you haven't yet written, use any of the other problem + templates together with `MathJax `_. For more + information about how to create mathematical expressions in Studio using + MathJax, see *A Brief Introduction to MathJax in Studio*. .. image:: Images/ProblemWrittenInLaTeX.gif diff --git a/docs/course_authors/source/common_problems.rst b/docs/course_authors/source/common_problems.rst index abdbd8f97a..ce9b9592c1 100644 --- a/docs/course_authors/source/common_problems.rst +++ b/docs/course_authors/source/common_problems.rst @@ -1,7 +1,8 @@ .. _Common Problems: +############################# Common Problems -=============== +############################# *Common problems* are typical problems such as multiple choice problems and other problems whose answers are simple for students to select or @@ -31,8 +32,9 @@ create a checkbox problem, you'll click **Blank Common Problem**.) .. _Checkbox: +******************* Checkbox --------- +******************* In checkbox problems, the student selects one or more options from a list of possible answers. The student must select all the options that @@ -41,8 +43,9 @@ at least one correct answer. .. image:: Images/CheckboxExample.gif +========================== Create a Checkbox Problem -~~~~~~~~~~~~~~~~~~~~~~~~~~ +========================== #. Under **Add New Component**, click **Problem**. #. In the **Select Problem Component Type** screen, click **Blank Common @@ -90,8 +93,9 @@ following. .. _Dropdown: +******************* Dropdown --------- +******************* Dropdown problems allow the student to choose from a collection of answer options, presented as a dropdown list. Unlike multiple choice @@ -101,8 +105,9 @@ the dropdown arrow. .. image:: Images/DropdownExample.gif +========================== Create a Dropdown Problem -~~~~~~~~~~~~~~~~~~~~~~~~~ +========================== To create a dropdown problem, follow these steps. @@ -145,8 +150,9 @@ following. .. _Multiple Choice: +******************* Multiple Choice ---------------- +******************* In multiple choice problems, students select one option from a list of answer options. Unlike with dropdown problems, whose answer choices @@ -156,8 +162,9 @@ the question. .. image:: Images/MultipleChoiceExample.gif +================================== Create a Multiple Choice Problem -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +================================== #. Under **Add New Component**, click **Problem**. #. In the **Select Problem Component Type** screen, click **Multiple @@ -210,8 +217,9 @@ following. .. _Numerical Input: +******************* Numerical Input ---------------- +******************* In numerical input problems, students enter numbers or specific and relatively simple mathematical expressions to answer a question. @@ -232,8 +240,9 @@ numerical input problems. To see more examples, scroll down to **Examples**. .. image:: Images/Math5.gif +================================== Create a Numerical Input Problem -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +================================== #. Under **Add New Component**, click **Problem**. #. In the **Select Problem Component Type** screen, click **Numerical @@ -286,8 +295,9 @@ For more information, see `Formula Equation Input .. _Text input: +******************* Text Input ----------- +******************* In text input problems, students enter text into a response field. The response can include numbers, letters, and special characters such as @@ -298,8 +308,9 @@ text input problems to allow for typographical errors. .. image:: Images/TextInputExample.gif +================================== Create a Text Input Problem -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +================================== To create a text input problem, follow these steps. @@ -337,4 +348,58 @@ following. the risk of malaria begins to fall for everyone – users and non-users alike. This can fall to such a low probability that malaria is effectively eradicated from the group (even when the group does not have 100% bednet coverage). - [explanation] \ No newline at end of file + [explanation] + +========================================= +Case Sensitivity and Text Input Problems +========================================= + +By default, text input problems do not require a case sensitive response. You can change this +and require a case sensitive answer. + +To make a text input response case sensitive, you must use the :ref:`Advanced Editor`. + +In the advanced editor, you see that the **type** attribute of the **stringresponse** +element equals **ci**, for *case insensitive*. For example: + +:: + + + + + +To make the response case sensitive, change the value of the **type** attribute to **cs**. + +:: + + + + + +========================================= +Response Field Length of Text Input Problems +========================================= + +By default, the response field for text input problems is 20 characters long. + +You should preview the unit to ensure that the length of the response input field +accommodates the correct answer, and provides extra space for possible incorrect answers. + +If the default response field length is not sufficient, you can change it using the :ref:`Advanced Editor`. + +In the advanced editor, in the XML block for the answer, you see that the **size** attribute of the **textline** +element equals **20**: + +:: + + + + + +To change the response field length, change the value of the **size** attribute: + +:: + + + + diff --git a/docs/course_authors/source/create_discussion.rst b/docs/course_authors/source/create_discussion.rst index 566ac1656b..62012e0220 100644 --- a/docs/course_authors/source/create_discussion.rst +++ b/docs/course_authors/source/create_discussion.rst @@ -10,6 +10,8 @@ Overview You can add a Discussion component to a Unit, to pose a question related to the Unit and give students a chance to respond and interact. +See the following topics: + * :ref:`Create a Discussion Component` * :ref:`A Student's View of the Discussion` * :ref:`Seed a Discussion Space in Your Course` diff --git a/docs/course_authors/source/create_html_component.rst b/docs/course_authors/source/create_html_component.rst index fba6a044ba..7b01b26e7f 100644 --- a/docs/course_authors/source/create_html_component.rst +++ b/docs/course_authors/source/create_html_component.rst @@ -12,6 +12,8 @@ Overview You use an HTML component to add and format text for your course. You can add text, lists, links and images in an HTML component. +See the following topics: + * :ref:`Create an HTML Component` * :ref:`Work with the Visual and HTML Editors` * :ref:`Use the Announcement Template` @@ -65,7 +67,7 @@ For more information, see: * :ref:`Add a Link in an HTML Component` * :ref:`Add an Image to an HTML Component` -ADD LINKS + .. _Work with the Visual and HTML Editors: diff --git a/docs/course_authors/source/create_problem_component.rst b/docs/course_authors/source/create_problem_component.rst index 37b244f61e..f1750875ab 100644 --- a/docs/course_authors/source/create_problem_component.rst +++ b/docs/course_authors/source/create_problem_component.rst @@ -17,6 +17,14 @@ toward a student's grade. If you want the problems to count toward the student's grade, change the assignment type of the subsection that contains the problems. +See the following topics: + +* :ref:`Components and the User Interface` +* :ref:`Problem Settings` +* :ref:`Multiple Problems in One Component` +* :ref:`Modifying a Released Problem` + + .. _Components and the User Interface: ************************************ @@ -92,10 +100,10 @@ All problems on the edX platform have several component parts. past due does not have a **Check** button. It also does not accept answers or provide feedback. -**Note** Problems can be **open** or **closed.** Closed problems do not -have a **Check** button. Students can still see questions, solutions, -and revealed explanations, but they cannot check their work, submit -responses, or change their stored score. +.. note:: Problems can be **open** or **closed.** Closed problems do not + have a **Check** button. Students can still see questions, solutions, + and revealed explanations, but they cannot check their work, submit + responses, or change their stored score. There are also some attributes of problems that are not immediately visible. @@ -118,10 +126,10 @@ Editor and the Advanced Editor. - The **Advanced Editor** converts the problem to edX’s XML standard and allows you to edit that XML directly. -**Note** You can switch at any time from the Simple Editor to the -Advanced Editor by clicking **Advanced Editor** in the top right corner -of the Simple Editor interface. However, it is not possible to switch from -the Advanced Editor to the Simple Editor. +.. note:: You can switch at any time from the Simple Editor to the + Advanced Editor by clicking **Advanced Editor** in the top right corner + of the Simple Editor interface. However, it is not possible to switch from + the Advanced Editor to the Simple Editor. The Simple Editor ~~~~~~~~~~~~~~~~~ @@ -146,6 +154,8 @@ The following image shows a multiple choice problem in the Simple Editor. .. image:: Images/MultipleChoice_SimpleEditor.gif +.. _Advanced Editor: + The Advanced Editor ~~~~~~~~~~~~~~~~~~~ The **Advanced Editor** opens a problem in XML. The Advanced Problem templates, @@ -198,9 +208,9 @@ the problem. By default, a student has an unlimited number of attempts. Problem Weight ============================== -**Note** Studio stores scores for all problems, but scores only count -toward a student’s final grade if they are in a subsection that is -graded. +.. note:: Studio stores scores for all problems, but scores only count + toward a student’s final grade if they are in a subsection that is + graded. This setting specifies the maximum number of points possible for the problem. The problem weight appears next to the problem title. @@ -333,6 +343,8 @@ page for the problem type. students in your course, or a computer algorithm to grade responses in the form of essays, files such as computer code, and images. +.. _Multiple Problems in One Component: + ************************************ Multiple Problems in One Component ************************************ @@ -360,12 +372,13 @@ attempts to answer each problem individually. If a student clicks If a student clicks **Show Answer**, the answers for all the problems in the component appear. +.. _Modifying a Released Problem: + ************************************ Modifying a Released Problem ************************************ -**WARNING: Be careful when you modify problems after they have been -released!** +.. warning:: Be careful when you modify problems after they have been released! After a student submits a response to a problem, Studio stores the student’s response, the score that the student received, and the maximum diff --git a/docs/course_authors/source/create_video.rst b/docs/course_authors/source/create_video.rst index d043c552fc..f71e9c40e9 100644 --- a/docs/course_authors/source/create_video.rst +++ b/docs/course_authors/source/create_video.rst @@ -14,6 +14,8 @@ You can also associate a timed transcript with your video, which students can re When you add a video to your course, you first post the video online, and then create a link to that video in the body of your course. +See the following topics: + * :ref:`Video Formats` * :ref:`Video Hosting` * :ref:`Create a Video Component` diff --git a/docs/course_authors/source/establish_grading_policy.rst b/docs/course_authors/source/establish_grading_policy.rst index d47fb9be6d..45e54a36f4 100644 --- a/docs/course_authors/source/establish_grading_policy.rst +++ b/docs/course_authors/source/establish_grading_policy.rst @@ -160,6 +160,7 @@ See :ref:`Working with Problem Components` for instructions on creating problems ************************** The Student View of Grades ************************** -Once a grading policy is in place, students can view both their problem scores and the percent completed and current grade at the top of their **Progress** tab for the course. +Once a grading policy is in place, students can view both their problem scores and the percent completed and current grade in the **Progress** tab for the course. -ADD IMAGE \ No newline at end of file + .. image:: Images/Progress_tab.png + :width: 800 \ No newline at end of file diff --git a/docs/course_authors/source/open_response_assessment.rst b/docs/course_authors/source/open_response_assessment.rst index ffceca0022..1bb323e3cc 100644 --- a/docs/course_authors/source/open_response_assessment.rst +++ b/docs/course_authors/source/open_response_assessment.rst @@ -631,8 +631,8 @@ graders. If you want to see the full rubric for either an AI or peer assessment, click **Toggle Full Rubric**. -**Note** For a peer assessment, if you haven't yet graded enough -problems to see your score, you receive a message that lets you know how -many problems you still need to grade. +.. note:: For a peer assessment, if you haven't yet graded enough + problems to see your score, you receive a message that lets you know how + many problems you still need to grade. .. image:: Images/FeedbackNotAvailable.gif diff --git a/docs/course_authors/source/organizing_course.rst b/docs/course_authors/source/organizing_course.rst index 73ef085cc1..9355db2faf 100644 --- a/docs/course_authors/source/organizing_course.rst +++ b/docs/course_authors/source/organizing_course.rst @@ -57,7 +57,7 @@ The new, empty Section is placed at the bottom of the course outline. You must now add Subsections to the Section. Whether or not students see the new Section depends on the release date. -See LINK for more information on releasing your course. +See :ref:`Publishing Your Course` for more information. .. _Subsections: diff --git a/docs/course_authors/source/set_content_releasedates.rst b/docs/course_authors/source/set_content_releasedates.rst index 24755e9abc..f334472472 100644 --- a/docs/course_authors/source/set_content_releasedates.rst +++ b/docs/course_authors/source/set_content_releasedates.rst @@ -137,6 +137,10 @@ Modifying Public Units ************************* To make revisions to a unit that has been published, you create and edit a draft of that unit. + +.. warning:: There are additional implications to modifying the public unit that has graded problem + components students may have already completed. See :ref:`Modifying a Released Problem` for more information. + To create a draft, go to the unit's page, and then click **edit a draft** in the right pane. .. image:: Images/Viz_Revise_EditDraft.png From c63dbe87ea48d7c4f52c661466f27614428d3e55 Mon Sep 17 00:00:00 2001 From: Mark Hoeber Date: Wed, 4 Dec 2013 10:42:44 -0500 Subject: [PATCH 102/110] Continued edits Worked through Andy's list --- docs/course_authors/source/create_new_course.rst | 2 +- docs/course_authors/source/create_video.rst | 2 +- docs/course_authors/source/organizing_course.rst | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/course_authors/source/create_new_course.rst b/docs/course_authors/source/create_new_course.rst index 07b72375bb..aef29211eb 100644 --- a/docs/course_authors/source/create_new_course.rst +++ b/docs/course_authors/source/create_new_course.rst @@ -63,7 +63,7 @@ Edit Your Course ************************ When you create a new course, the course opens in Studio automatically and you can begin editing. -If you come back to Studio later, your courses are listed on the Studio log in page. +If you come back to Studio later, your courses are listed on the Studio login page. .. image:: Images/open_course.png :width: 800 diff --git a/docs/course_authors/source/create_video.rst b/docs/course_authors/source/create_video.rst index f71e9c40e9..11ec23c687 100644 --- a/docs/course_authors/source/create_video.rst +++ b/docs/course_authors/source/create_video.rst @@ -79,7 +79,7 @@ After you have uploaded the video to YouTube: .. image:: Images/VideoComponent_Default.png -2. When the new video component appears, click **edit**.** The video editor opens and displays the Basic settings. +2. When the new video component appears, click **edit**. The video editor opens and displays the Basic settings. .. image:: Images/video-edit.png diff --git a/docs/course_authors/source/organizing_course.rst b/docs/course_authors/source/organizing_course.rst index 9355db2faf..852c6366f7 100644 --- a/docs/course_authors/source/organizing_course.rst +++ b/docs/course_authors/source/organizing_course.rst @@ -85,7 +85,7 @@ See LINK for more information on releasing your course. ================== -Edit a subsection +Edit a Subsection ================== You can add and delete Subsections, and select the grading policy, directly from the Course Outline. @@ -167,6 +167,7 @@ To create a Unit from the Course Outline or the Subsection page: #. Within the Subsection, click **New Unit**. #. Enter the Display Name that students will see. #. Click a Component type to add a the first Component in the Unit. + .. image:: Images/Unit_DisplayName_Studio.png #. Follow the instructions for the type of Component, listed below. From d95b46dd196c8aa3b37c2424c23ff8740ad64a70 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Mon, 25 Nov 2013 19:59:59 -0500 Subject: [PATCH 103/110] Studio: adding back in links (and supporting styling) to ToS and Privacy Policy to footer and sign up UI STUD-151 --- cms/envs/common.py | 9 +-------- cms/static/sass/_variables.scss | 9 +++++++++ cms/static/sass/elements/_footer.scss | 21 ++++----------------- cms/templates/signup.html | 4 +++- cms/templates/widgets/footer.html | 14 +++++++------- 5 files changed, 24 insertions(+), 33 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index a8c293b57f..0c9166a7f9 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -414,14 +414,7 @@ INSTALLED_APPS = ( EDXMKTG_COOKIE_NAME = 'edxloggedin' MKTG_URLS = {} MKTG_URL_LINK_MAP = { - 'ABOUT': 'about_edx', - 'CONTACT': 'contact', - 'FAQ': 'help_edx', - 'COURSES': 'courses', - 'ROOT': 'root', - 'TOS': 'tos', - 'HONOR': 'honor', - 'PRIVACY': 'privacy_edx', + } COURSES_WITH_UNSAFE_CODE = [] diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/_variables.scss index 093d3a957a..ecb63e1b85 100644 --- a/cms/static/sass/_variables.scss +++ b/cms/static/sass/_variables.scss @@ -172,6 +172,15 @@ $tmg-f3: 0.125s; // ==================== +// archetype UI +$ui-action-primary-color: $blue-u2; +$ui-action-primary-color-focus: $blue-s1; + +$ui-link-color: $blue-u2; +$ui-link-color-focus: $blue-s1; + +// ==================== + // specific UI $ui-notification-height: ($baseline*10); $ui-update-color: $blue-l4; diff --git a/cms/static/sass/elements/_footer.scss b/cms/static/sass/elements/_footer.scss index 94085ea7c1..268afc1a2e 100644 --- a/cms/static/sass/elements/_footer.scss +++ b/cms/static/sass/elements/_footer.scss @@ -23,10 +23,10 @@ } a { - color: $gray; + color: $ui-link-color; &:hover, &:active { - color: $gray-d2; + color: $ui-link-color-focus; } } @@ -37,7 +37,7 @@ .nav-item { display: inline-block; - margin-right: ($baseline/2); + margin-right: ($baseline/4); &:last-child { margin-right: 0; @@ -45,7 +45,7 @@ a { border-radius: 2px; - padding: ($baseline/2) ($baseline*0.75); + padding: ($baseline/2) ($baseline/2); background: transparent; [class^="icon-"] { @@ -54,19 +54,6 @@ display: inline-block; vertical-align: middle; margin-right: ($baseline/4); - color: $gray-l1; - } - - &:hover, &:active { - color: $gray-d2; - - [class^="icon-"] { - color: $gray-d2; - } - } - - &.is-active { - color: $gray-d2; } } } diff --git a/cms/templates/signup.html b/cms/templates/signup.html index d9a733ae47..422c152b50 100644 --- a/cms/templates/signup.html +++ b/cms/templates/signup.html @@ -60,7 +60,9 @@ diff --git a/cms/templates/widgets/footer.html b/cms/templates/widgets/footer.html index db7d5fb3f8..bd7d0c6073 100644 --- a/cms/templates/widgets/footer.html +++ b/cms/templates/widgets/footer.html @@ -1,25 +1,25 @@ <%! from django.core.urlresolvers import reverse %> <%! from django.utils.translation import ugettext as _ %> -
    14. - + '.format(marketing_link('TOS')), a_end="")} +
    15. diff --git a/lms/templates/main.html b/lms/templates/main.html index 2d001ffe65..a2395db4ed 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -62,7 +62,7 @@ - + % if not course: diff --git a/lms/templates/main_django.html b/lms/templates/main_django.html index a20d489d8b..9190ddab92 100644 --- a/lms/templates/main_django.html +++ b/lms/templates/main_django.html @@ -25,7 +25,7 @@ - + diff --git a/lms/templates/mktg_iframe.html b/lms/templates/mktg_iframe.html index 338f49240c..ea1b00f1ad 100644 --- a/lms/templates/mktg_iframe.html +++ b/lms/templates/mktg_iframe.html @@ -8,7 +8,7 @@ <%block name="title"> - + <%static:css group='style-vendor'/> diff --git a/lms/templates/stripped-main.html b/lms/templates/stripped-main.html index 28166dbd97..f2e8545e96 100644 --- a/lms/templates/stripped-main.html +++ b/lms/templates/stripped-main.html @@ -24,7 +24,7 @@ - + From f06f333b8a12c07f6c38172ce47e972e3e86a19b Mon Sep 17 00:00:00 2001 From: Julia Hansbrough Date: Wed, 4 Dec 2013 20:06:55 +0000 Subject: [PATCH 105/110] Added developer-specific settings to devstack --- lms/envs/devstack.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 5a75bd7e36..adec04ba3f 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -53,6 +53,11 @@ DEBUG_TOOLBAR_CONFIG = { PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT) +########################### VERIFIED CERTIFICATES ################################# + +FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True +FEATURES['ENABLE_PAYMENT_FAKE'] = True +CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = '/shoppingcart/payment_fake/' ##################################################################### # Lastly, see if the developer has any local overrides. From e26fc08d2b08efe7e6409159ad29db942eee63d1 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Mon, 2 Dec 2013 14:28:00 -0500 Subject: [PATCH 106/110] Update logging.getLogger() calls to use edx instead of mitx --- common/djangoapps/external_auth/views.py | 2 +- common/djangoapps/student/models.py | 2 +- common/djangoapps/student/views.py | 2 +- common/lib/xmodule/xmodule/capa_module.py | 2 +- common/lib/xmodule/xmodule/combined_open_ended_module.py | 2 +- common/lib/xmodule/xmodule/conditional_module.py | 2 +- common/lib/xmodule/xmodule/graders.py | 2 +- common/lib/xmodule/xmodule/html_module.py | 2 +- common/lib/xmodule/xmodule/modulestore/__init__.py | 2 +- .../open_ended_grading_classes/combined_open_ended_modulev1.py | 2 +- .../xmodule/open_ended_grading_classes/open_ended_module.py | 2 +- .../xmodule/open_ended_grading_classes/openendedchild.py | 3 +-- .../open_ended_grading_classes/self_assessment_module.py | 2 +- common/lib/xmodule/xmodule/randomize_module.py | 2 +- lms/djangoapps/courseware/grades.py | 2 +- lms/djangoapps/courseware/views.py | 2 +- lms/djangoapps/licenses/models.py | 2 +- lms/djangoapps/licenses/views.py | 2 +- lms/djangoapps/lms_migration/migrate.py | 2 +- lms/djangoapps/psychometrics/psychoanalyze.py | 2 +- 20 files changed, 20 insertions(+), 21 deletions(-) diff --git a/common/djangoapps/external_auth/views.py b/common/djangoapps/external_auth/views.py index 2784737eea..42d0f2bf89 100644 --- a/common/djangoapps/external_auth/views.py +++ b/common/djangoapps/external_auth/views.py @@ -50,7 +50,7 @@ from xmodule.course_module import CourseDescriptor from xmodule.modulestore import Location from xmodule.modulestore.exceptions import ItemNotFoundError -log = logging.getLogger("mitx.external_auth") +log = logging.getLogger("edx.external_auth") AUDIT_LOG = logging.getLogger("audit") SHIBBOLETH_DOMAIN_PREFIX = 'shib:' diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 1ed6126e9b..734b3a1ec4 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -702,7 +702,7 @@ def update_user_information(sender, instance, created, **kwargs): cc_user = cc.User.from_django_user(instance) cc_user.save() except Exception as e: - log = logging.getLogger("mitx.discussion") + log = logging.getLogger("edx.discussion") log.error(unicode(e)) log.error("update user info to discussion failed for user with id: " + str(instance.id)) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 96ccba46e6..878630272a 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -72,7 +72,7 @@ from pytz import UTC from util.json_request import JsonResponse -log = logging.getLogger("mitx.student") +log = logging.getLogger("edx.student") AUDIT_LOG = logging.getLogger("audit") Article = namedtuple('Article', 'title url author image deck publication publish_date') diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index cf6c2e3dce..b78e2a4a50 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -24,7 +24,7 @@ from .fields import Timedelta, Date from django.utils.timezone import UTC from django.utils.translation import ugettext as _ -log = logging.getLogger("mitx.courseware") +log = logging.getLogger("edx.courseware") # Generate this many different variants of problems with rerandomize=per_student diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 2eb8585d85..960db4db7d 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -11,7 +11,7 @@ from collections import namedtuple from .fields import Date, Timedelta import textwrap -log = logging.getLogger("mitx.courseware") +log = logging.getLogger("edx.courseware") V1_SETTINGS_ATTRIBUTES = [ "display_name", diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py index b7a48fb82a..8248343371 100644 --- a/common/lib/xmodule/xmodule/conditional_module.py +++ b/common/lib/xmodule/xmodule/conditional_module.py @@ -15,7 +15,7 @@ from xblock.fields import Scope, List from xmodule.modulestore.exceptions import ItemNotFoundError -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger('edx.' + __name__) class ConditionalFields(object): diff --git a/common/lib/xmodule/xmodule/graders.py b/common/lib/xmodule/xmodule/graders.py index 10ef86ebdc..d51e1414c3 100644 --- a/common/lib/xmodule/xmodule/graders.py +++ b/common/lib/xmodule/xmodule/graders.py @@ -6,7 +6,7 @@ import sys from collections import namedtuple -log = logging.getLogger("mitx.courseware") +log = logging.getLogger("edx.courseware") # This is a tuple for holding scores, either from problems or sections. # Section either indicates the name of the problem or the name of the section diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 25cebe8e42..73b5baf2d6 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -16,7 +16,7 @@ from xmodule.xml_module import XmlDescriptor, name_to_pathname import textwrap from xmodule.contentstore.content import StaticContent -log = logging.getLogger("mitx.courseware") +log = logging.getLogger("edx.courseware") class HtmlFields(object): diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index 7ed33e5673..ebec6d6ee8 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -13,7 +13,7 @@ from abc import ABCMeta, abstractmethod from .exceptions import InvalidLocationError, InsufficientSpecificationError from xmodule.errortracker import make_error_tracker -log = logging.getLogger('mitx.' + 'modulestore') +log = logging.getLogger('edx.modulestore') SPLIT_MONGO_MODULESTORE_TYPE = 'split' MONGO_MODULESTORE_TYPE = 'mongo' diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 72915eb7b3..2c02833baa 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -13,7 +13,7 @@ from .combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMA from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, MockPeerGradingService, GradingServiceError from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild -log = logging.getLogger("mitx.courseware") +log = logging.getLogger("edx.courseware") # Set the default number of max attempts. Should be 1 for production # Set higher for debugging/testing diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index d020987fdf..680ed031d1 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -23,7 +23,7 @@ from pytz import UTC from .combined_open_ended_rubric import CombinedOpenEndedRubric -log = logging.getLogger("mitx.courseware") +log = logging.getLogger("edx.courseware") class OpenEndedModule(openendedchild.OpenEndedChild): diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 6b98744ccd..2d6ba84118 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -11,11 +11,10 @@ import controller_query_service from datetime import datetime from pytz import UTC -import requests from boto.s3.connection import S3Connection from boto.s3.key import Key -log = logging.getLogger("mitx.courseware") +log = logging.getLogger("edx.courseware") # Set the default number of max attempts. Should be 1 for production # Set higher for debugging/testing diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py index 45295fd09f..cb9d1e72c5 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py @@ -9,7 +9,7 @@ import openendedchild from .combined_open_ended_rubric import CombinedOpenEndedRubric -log = logging.getLogger("mitx.courseware") +log = logging.getLogger("edx.courseware") class SelfAssessmentModule(openendedchild.OpenEndedChild): diff --git a/common/lib/xmodule/xmodule/randomize_module.py b/common/lib/xmodule/xmodule/randomize_module.py index 71d23012d1..56b7c079aa 100644 --- a/common/lib/xmodule/xmodule/randomize_module.py +++ b/common/lib/xmodule/xmodule/randomize_module.py @@ -9,7 +9,7 @@ from lxml import etree from xblock.fields import Scope, Integer from xblock.fragment import Fragment -log = logging.getLogger('mitx.' + __name__) +log = logging.getLogger('edx.' + __name__) class RandomizeFields(object): diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index f17a3486d5..49b67ecc40 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -22,7 +22,7 @@ from xmodule.graders import Score from .models import StudentModule from .module_render import get_module, get_module_for_descriptor -log = logging.getLogger("mitx.courseware") +log = logging.getLogger("edx.courseware") def yield_module_descendents(module): diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 0608e2c759..fcf1226295 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -38,7 +38,7 @@ from xmodule.modulestore.search import path_to_location from xmodule.course_module import CourseDescriptor import shoppingcart -log = logging.getLogger("mitx.courseware") +log = logging.getLogger("edx.courseware") template_imports = {'urllib': urllib} diff --git a/lms/djangoapps/licenses/models.py b/lms/djangoapps/licenses/models.py index db24126a8e..f91048d269 100644 --- a/lms/djangoapps/licenses/models.py +++ b/lms/djangoapps/licenses/models.py @@ -4,7 +4,7 @@ from django.db import models, transaction from student.models import User -log = logging.getLogger("mitx.licenses") +log = logging.getLogger("edx.licenses") class CourseSoftware(models.Model): diff --git a/lms/djangoapps/licenses/views.py b/lms/djangoapps/licenses/views.py index 42ecb9ecf1..657d6cd0c7 100644 --- a/lms/djangoapps/licenses/views.py +++ b/lms/djangoapps/licenses/views.py @@ -16,7 +16,7 @@ from licenses.models import CourseSoftware from licenses.models import get_courses_licenses, get_or_create_license, get_license -log = logging.getLogger("mitx.licenses") +log = logging.getLogger("edx.licenses") License = namedtuple('License', 'software serial') diff --git a/lms/djangoapps/lms_migration/migrate.py b/lms/djangoapps/lms_migration/migrate.py index 18d9a51017..d00030bd43 100644 --- a/lms/djangoapps/lms_migration/migrate.py +++ b/lms/djangoapps/lms_migration/migrate.py @@ -17,7 +17,7 @@ try: except ImportError: from django.contrib.csrf.middleware import csrf_exempt -log = logging.getLogger("mitx.lms_migrate") +log = logging.getLogger("edx.lms_migrate") LOCAL_DEBUG = True ALLOWED_IPS = settings.LMS_MIGRATION_ALLOWED_IPS diff --git a/lms/djangoapps/psychometrics/psychoanalyze.py b/lms/djangoapps/psychometrics/psychoanalyze.py index c6e66445a4..dac10e0b07 100644 --- a/lms/djangoapps/psychometrics/psychoanalyze.py +++ b/lms/djangoapps/psychometrics/psychoanalyze.py @@ -18,7 +18,7 @@ from psychometrics.models import PsychometricData from courseware.models import StudentModule from pytz import UTC -log = logging.getLogger("mitx.psychometrics") +log = logging.getLogger("edx.psychometrics") #db = "ocwtutor" # for debugging #db = "default" From 6baf195a4a2ed02233521d8b580c63cf595cfffe Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 4 Dec 2013 15:37:18 -0500 Subject: [PATCH 107/110] Removed unused rake tasks --- rakelib/deploy.rake | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 rakelib/deploy.rake diff --git a/rakelib/deploy.rake b/rakelib/deploy.rake deleted file mode 100644 index 1d0a1b2c4f..0000000000 --- a/rakelib/deploy.rake +++ /dev/null @@ -1,15 +0,0 @@ - -# Packaging constants -COMMIT = (ENV["GIT_COMMIT"] || `git rev-parse HEAD`).chomp()[0, 10] -PACKAGE_NAME = "mitx" -BRANCH = (ENV["GIT_BRANCH"] || `git symbolic-ref -q HEAD`).chomp().gsub('refs/heads/', '').gsub('origin/', '') - -desc "Build a properties file used to trigger autodeploy builds" -task :autodeploy_properties do - File.open("autodeploy.properties", "w") do |file| - file.puts("UPSTREAM_NOOP=false") - file.puts("UPSTREAM_BRANCH=#{BRANCH}") - file.puts("UPSTREAM_JOB=#{PACKAGE_NAME}") - file.puts("UPSTREAM_REVISION=#{COMMIT}") - end -end \ No newline at end of file From b4f82b38379ff2b5d0218239e43667b2ff7eb3e5 Mon Sep 17 00:00:00 2001 From: polesye Date: Sun, 1 Dec 2013 12:01:58 +0200 Subject: [PATCH 108/110] BLD-438: Fix clear and download buttons. --- CHANGELOG.rst | 2 + .../contentstore/features/transcripts.feature | 22 +++++ .../contentstore/features/transcripts.py | 83 ++++++++++--------- .../contentstore/transcripts_utils.py | 8 +- cms/static/js/views/transcripts/editor.js | 12 ++- .../messages/transcripts-uploaded.underscore | 2 +- 6 files changed, 86 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a375086412..b54d0b622f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Blades: Video Transcripts: Fix clear and download buttons. BLD-438. + Common: Switch over from MITX_FEATURES to just FEATURES. To override items in the FEATURES dict, the environment variable you must set to do so is also now called FEATURES instead of MITX_FEATURES. diff --git a/cms/djangoapps/contentstore/features/transcripts.feature b/cms/djangoapps/contentstore/features/transcripts.feature index 159c8a3c5a..bf5022dd8a 100644 --- a/cms/djangoapps/contentstore/features/transcripts.feature +++ b/cms/djangoapps/contentstore/features/transcripts.feature @@ -653,3 +653,25 @@ Feature: Video Component Editor Then when I view the video it does show the captions And I see "LILA FISHER: Hi, welcome to Edx." text in the captions + #35 + Scenario: After reverting Transcripts field in the Advanced tab "not found" message should be visible + Given I have created a Video component + And I edit the component + + And I enter a "t_not_exist.mp4" source to field number 1 + Then I see status message "not found" + And I upload the transcripts file "chinese_transcripts.srt" + Then I see status message "uploaded_successfully" + + And I save changes + Then I see "好 各位同学" text in the captions + And I edit the component + + And I open tab "Advanced" + And I revert the transcript field"HTML5 Transcript" + + And I save changes + Then when I view the video it does not show the captions + And I edit the component + Then I see status message "not found" + diff --git a/cms/djangoapps/contentstore/features/transcripts.py b/cms/djangoapps/contentstore/features/transcripts.py index 4fac5e5b93..c6060803ba 100644 --- a/cms/djangoapps/contentstore/features/transcripts.py +++ b/cms/djangoapps/contentstore/features/transcripts.py @@ -9,7 +9,7 @@ from django.conf import settings from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore from xmodule.exceptions import NotFoundError - +from splinter.request_handler.request_handler import RequestHandler TEST_ROOT = settings.COMMON_TEST_DATA_ROOT @@ -49,6 +49,13 @@ TRANSCRIPTS_BUTTONS = { } +def _clear_field(index): + world.css_fill(SELECTORS['url_inputs'], '', index) + # In some reason chromeDriver doesn't trigger 'input' event after filling + # field by an empty value. That's why we trigger it manually via jQuery. + world.trigger_event(SELECTORS['url_inputs'], event='input', index=index) + + @step('I clear fields$') def clear_fields(_step): js_str = ''' @@ -60,16 +67,18 @@ def clear_fields(_step): for index in range(1, 4): js = js_str.format(selector=SELECTORS['url_inputs'], index=index - 1) world.browser.execute_script(js) - _step.given('I clear field number {0}'.format(index)) + _clear_field(index) + + world.wait(DELAY) + world.wait_for_ajax_complete() @step('I clear field number (.+)$') def clear_field(_step, index): index = int(index) - 1 - world.css_fill(SELECTORS['url_inputs'], '', index) - # In some reason chromeDriver doesn't trigger 'input' event after filling - # field by an empty value. That's why we trigger it manually via jQuery. - world.trigger_event(SELECTORS['url_inputs'], event='input', index=index) + _clear_field(index) + world.wait(DELAY) + world.wait_for_ajax_complete() @step('I expect (.+) inputs are disabled$') @@ -91,40 +100,32 @@ def inputs_are_enabled(_step): @step('I do not see error message$') def i_do_not_see_error_message(_step): - world.wait(DELAY) - assert not world.css_visible(SELECTORS['error_bar']) @step('I see error message "([^"]*)"$') def i_see_error_message(_step, error): - world.wait(DELAY) - assert world.css_has_text(SELECTORS['error_bar'], ERROR_MESSAGES[error.strip()]) @step('I do not see status message$') def i_do_not_see_status_message(_step): - world.wait(DELAY) - world.wait_for_ajax_complete() - assert not world.css_visible(SELECTORS['status_bar']) @step('I see status message "([^"]*)"$') def i_see_status_message(_step, status): - world.wait(DELAY) - world.wait_for_ajax_complete() - assert not world.css_visible(SELECTORS['error_bar']) assert world.css_has_text(SELECTORS['status_bar'], STATUSES[status.strip()]) + DOWNLOAD_BUTTON = TRANSCRIPTS_BUTTONS["download_to_edit"][0] + if world.is_css_present(DOWNLOAD_BUTTON, wait_time=1) \ + and not world.css_find(DOWNLOAD_BUTTON)[0].has_class('is-disabled'): + assert _transcripts_are_downloaded() + @step('I (.*)see button "([^"]*)"$') def i_see_button(_step, not_see, button_type): - world.wait(DELAY) - world.wait_for_ajax_complete() - button = button_type.strip() if not_see.strip(): @@ -135,9 +136,6 @@ def i_see_button(_step, not_see, button_type): @step('I (.*)see (.*)button "([^"]*)" number (\d+)$') def i_see_button_with_custom_text(_step, not_see, button_type, custom_text, index): - world.wait(DELAY) - world.wait_for_ajax_complete() - button = button_type.strip() custom_text = custom_text.strip() index = int(index.strip()) - 1 @@ -150,22 +148,18 @@ def i_see_button_with_custom_text(_step, not_see, button_type, custom_text, inde @step('I click transcript button "([^"]*)"$') def click_button_transcripts_variant(_step, button_type): - world.wait(DELAY) - world.wait_for_ajax_complete() - button = button_type.strip() world.css_click(TRANSCRIPTS_BUTTONS[button][0]) + world.wait_for_ajax_complete() @step('I click transcript button "([^"]*)" number (\d+)$') def click_button_index(_step, button_type, index): - world.wait(DELAY) - world.wait_for_ajax_complete() - button = button_type.strip() index = int(index.strip()) - 1 world.css_click(TRANSCRIPTS_BUTTONS[button][0], index) + world.wait_for_ajax_complete() @step('I remove "([^"]+)" transcripts id from store') @@ -187,9 +181,6 @@ def remove_transcripts_from_store(_step, subs_id): @step('I enter a "([^"]+)" source to field number (\d+)$') def i_enter_a_source(_step, link, index): - world.wait(DELAY) - world.wait_for_ajax_complete() - index = int(index) - 1 if index is not 0 and not world.css_visible(SELECTORS['collapse_bar']): @@ -198,6 +189,8 @@ def i_enter_a_source(_step, link, index): assert world.css_visible(SELECTORS['collapse_bar']) world.css_fill(SELECTORS['url_inputs'], link, index) + world.wait(DELAY) + world.wait_for_ajax_complete() @step('I upload the transcripts file "([^"]*)"$') @@ -205,6 +198,7 @@ def upload_file(_step, file_name): path = os.path.join(TEST_ROOT, 'uploads/', file_name.strip()) world.browser.execute_script("$('form.file-chooser').show()") world.browser.attach_file('file', os.path.abspath(path)) + world.wait_for_ajax_complete() @step('I see "([^"]*)" text in the captions') @@ -214,9 +208,6 @@ def check_text_in_the_captions(_step, text): @step('I see value "([^"]*)" in the field "([^"]*)"$') def check_transcripts_field(_step, values, field_name): - world.wait(DELAY) - world.wait_for_ajax_complete() - world.click_link_by_text('Advanced') field_id = '#' + world.browser.find_by_xpath('//label[text()="%s"]' % field_name.strip())[0]['for'] values_list = [i.strip() == world.css_value(field_id) for i in values.split('|')] @@ -226,22 +217,34 @@ def check_transcripts_field(_step, values, field_name): @step('I save changes$') def save_changes(_step): - world.wait(DELAY) - world.wait_for_ajax_complete() - save_css = 'a.save-button' world.css_click(save_css) + world.wait_for_ajax_complete() @step('I open tab "([^"]*)"$') def open_tab(_step, tab_name): world.click_link_by_text(tab_name.strip()) + world.wait_for_ajax_complete() @step('I set value "([^"]*)" to the field "([^"]*)"$') def set_value_transcripts_field(_step, value, field_name): - world.wait(DELAY) - world.wait_for_ajax_complete() - field_id = '#' + world.browser.find_by_xpath('//label[text()="%s"]' % field_name.strip())[0]['for'] world.css_fill(field_id, value.strip()) + world.wait_for_ajax_complete() + + +@step('I revert the transcript field "([^"]*)"$') +def revert_transcripts_field(_step, field_name): + world.revert_setting_entry(field_name) + + +def _transcripts_are_downloaded(): + world.wait_for_ajax_complete() + request = RequestHandler() + DOWNLOAD_BUTTON = world.css_find(TRANSCRIPTS_BUTTONS["download_to_edit"][0]).first + url = DOWNLOAD_BUTTON['href'] + request.connect(url) + + return request.status_code.is_success() diff --git a/cms/djangoapps/contentstore/transcripts_utils.py b/cms/djangoapps/contentstore/transcripts_utils.py index 3e66d2db43..7ff0999f64 100644 --- a/cms/djangoapps/contentstore/transcripts_utils.py +++ b/cms/djangoapps/contentstore/transcripts_utils.py @@ -196,6 +196,7 @@ def remove_subs_from_store(subs_id, item): try: content = contentstore().find(content_location) contentstore().delete(content.get_id()) + del_cached_content(content.location) log.info("Removed subs %s from store", subs_id) except NotFoundError: pass @@ -310,7 +311,9 @@ def manage_video_subtitles_save(old_item, new_item): Video player item has some video fields: HTML5 ones and Youtube one. - 1. If value of `sub` field of `new_item` is different from values of video fields of `new_item`, + If value of `sub` field of `new_item` is cleared, transcripts should be removed. + + If value of `sub` field of `new_item` is different from values of video fields of `new_item`, and `new_item.sub` file is present, then code in this function creates copies of `new_item.sub` file with new names. That names are equal to values of video fields of `new_item` After that `sub` field of `new_item` is changed to one of values of video fields. @@ -328,6 +331,9 @@ def manage_video_subtitles_save(old_item, new_item): for video_id in possible_video_id_list: if not video_id: continue + if not sub_name: + remove_subs_from_store(video_id, new_item) + continue # copy_or_rename_transcript changes item.sub of module try: # updates item.sub with `video_id`, if it is successful. diff --git a/cms/static/js/views/transcripts/editor.js b/cms/static/js/views/transcripts/editor.js index 78280a0d66..7fd53dcaaf 100644 --- a/cms/static/js/views/transcripts/editor.js +++ b/cms/static/js/views/transcripts/editor.js @@ -90,7 +90,17 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) { var isSubsModified = (function (values) { var isSubsChanged = subs.hasChanged("value"); - return Boolean(isSubsChanged && _.isString(values.sub)); + return Boolean( + isSubsChanged && + ( + // If the user changes the field, `values.sub` contains + // string value; + // If the user clicks `clear` button, the field contains + // null value. + // Otherwise, undefined. + _.isString(values.sub) || _.isNull(subs.getValue()) + ) + ); }(modifiedValues)); // When we change value of `sub` field in the `Advanced`, diff --git a/cms/templates/js/transcripts/messages/transcripts-uploaded.underscore b/cms/templates/js/transcripts/messages/transcripts-uploaded.underscore index c17d83f710..9895d7fea8 100644 --- a/cms/templates/js/transcripts/messages/transcripts-uploaded.underscore +++ b/cms/templates/js/transcripts/messages/transcripts-uploaded.underscore @@ -10,7 +10,7 @@ - "> + "> <%= gettext("Download to Edit") %> From 88610cb8fb0f7b85c05601f48f3bd7c499d31f42 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 4 Dec 2013 15:33:49 -0500 Subject: [PATCH 109/110] Change forum role granted to staff on enrollment This applies to global staff (is_staff=True), not course staff. Previously, staff were granted the Moderator role but not the Student role upon enrolling in a course. If the Moderator role were later revoked, then the user would have no role and be unable to post in the forums, which is confusing for the user. edX staff indicated they would prefer to not automatically receive the Moderator role, so the Student role is granted instead. Note that staff will still be able to grant themselves Moderator privileges through the instructor dashboard if they wish. JIRA: FOR-338 --- CHANGELOG.rst | 4 ++++ common/djangoapps/django_comment_common/models.py | 9 ++------- common/djangoapps/django_comment_common/tests.py | 10 +++------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b54d0b622f..a62b0fb009 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,10 @@ Common: Switch over from MITX_FEATURES to just FEATURES. To override items in the FEATURES dict, the environment variable you must set to do so is also now called FEATURES instead of MITX_FEATURES. +LMS: Change the forum role granted to global staff on enrollment in a +course. Previously, staff were given the Moderator role; now, they are +given the Student role. + Blades: Fix Numerical input to support mathematical operations. BLD-525. Blades: Improve calculator's tooltip accessibility. Add possibility to navigate diff --git a/common/djangoapps/django_comment_common/models.py b/common/djangoapps/django_comment_common/models.py index 7878f1b453..c345f26919 100644 --- a/common/djangoapps/django_comment_common/models.py +++ b/common/djangoapps/django_comment_common/models.py @@ -32,13 +32,8 @@ def assign_default_role(sender, instance, **kwargs): # instance.user.roles.remove(*course_roles) # return - # We've enrolled the student, so make sure they have a default role - if instance.user.is_staff: - role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0] - else: - role = Role.objects.get_or_create(course_id=instance.course_id, name="Student")[0] - - logging.info("assign_default_role: adding %s as %s" % (instance.user, role)) + # We've enrolled the student, so make sure they have the Student role + role = Role.objects.get_or_create(course_id=instance.course_id, name="Student")[0] instance.user.roles.add(role) diff --git a/common/djangoapps/django_comment_common/tests.py b/common/djangoapps/django_comment_common/tests.py index 47790f1e1e..fd776c75d3 100644 --- a/common/djangoapps/django_comment_common/tests.py +++ b/common/djangoapps/django_comment_common/tests.py @@ -10,6 +10,7 @@ class RoleAssignmentTest(TestCase): """ def setUp(self): + # Check a staff account because those used to get the Moderator role self.staff_user = User.objects.create_user( "patty", "patty@fake.edx.org", @@ -25,18 +26,13 @@ class RoleAssignmentTest(TestCase): CourseEnrollment.enroll(self.student_user, self.course_id) def test_enrollment_auto_role_creation(self): - moderator_role = Role.objects.get( - course_id=self.course_id, - name="Moderator" - ) student_role = Role.objects.get( course_id=self.course_id, name="Student" ) - self.assertIn(moderator_role, self.staff_user.roles.all()) - self.assertIn(student_role, self.student_user.roles.all()) - self.assertNotIn(moderator_role, self.student_user.roles.all()) + self.assertEqual([student_role], list(self.staff_user.roles.all())) + self.assertEqual([student_role], list(self.student_user.roles.all())) # The following was written on the assumption that unenrolling from a course # should remove all forum Roles for that student for that course. This is From e0fe8bcdc78b57f5a878566c2b26a9eaa5156854 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Sat, 23 Nov 2013 10:53:28 -0500 Subject: [PATCH 110/110] Removed extra trailing slash from STATIC_URL in devstack Let staticfiles determine the footer URLs --- cms/envs/aws.py | 5 ++++- lms/envs/aws.py | 4 +++- lms/templates/footer.html | 10 +++++----- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 7d596194f4..9a37a44a92 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -90,7 +90,10 @@ with open(CONFIG_ROOT / CONFIG_PREFIX + "env.json") as env_file: STATIC_URL_BASE = ENV_TOKENS.get('STATIC_URL_BASE', None) if STATIC_URL_BASE: # collectstatic will fail if STATIC_URL is a unicode string - STATIC_URL = STATIC_URL_BASE.encode('ascii') + "/" + git.revision + "/" + STATIC_URL = STATIC_URL_BASE.encode('ascii') + if not STATIC_URL.endswith("/"): + STATIC_URL += "/" + STATIC_URL += git.revision + "/" # GITHUB_REPO_ROOT is the base directory # for course data diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 2b7f309ea8..0e3e87b299 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -124,7 +124,9 @@ if STATIC_ROOT_BASE: STATIC_URL_BASE = ENV_TOKENS.get('STATIC_URL_BASE', None) if STATIC_URL_BASE: # collectstatic will fail if STATIC_URL is a unicode string - STATIC_URL = STATIC_URL_BASE.encode('ascii') + "/" + STATIC_URL = STATIC_URL_BASE.encode('ascii') + if not STATIC_URL.endswith("/"): + STATIC_URL += "/" PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', PLATFORM_NAME) # For displaying on the receipt. At Stanford PLATFORM_NAME != MERCHANT_NAME, but PLATFORM_NAME is a fine default diff --git a/lms/templates/footer.html b/lms/templates/footer.html index caff83bace..3e8e993891 100644 --- a/lms/templates/footer.html +++ b/lms/templates/footer.html @@ -48,27 +48,27 @@