Commit 2767610e by chrisndodge

Merge pull request #1317 from MITx/feature/cdodge/cms-master-merge4

Feature/cdodge/cms master merge4
parents 381ede20 0efd6e6a
...@@ -12,6 +12,8 @@ admin.site.register(UserTestGroup) ...@@ -12,6 +12,8 @@ admin.site.register(UserTestGroup)
admin.site.register(CourseEnrollment) admin.site.register(CourseEnrollment)
admin.site.register(CourseEnrollmentAllowed)
admin.site.register(Registration) admin.site.register(Registration)
admin.site.register(PendingNameChange) admin.site.register(PendingNameChange)
import csv import csv
import uuid from collections import OrderedDict
from collections import defaultdict, OrderedDict
from datetime import datetime from datetime import datetime
from os.path import isdir
from optparse import make_option
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand
from student.models import TestCenterUser from student.models import TestCenterUser
class Command(BaseCommand): class Command(BaseCommand):
CSV_TO_MODEL_FIELDS = OrderedDict([ CSV_TO_MODEL_FIELDS = OrderedDict([
# Skipping optional field CandidateID
("ClientCandidateID", "client_candidate_id"), ("ClientCandidateID", "client_candidate_id"),
("FirstName", "first_name"), ("FirstName", "first_name"),
("LastName", "last_name"), ("LastName", "last_name"),
...@@ -34,9 +37,17 @@ class Command(BaseCommand): ...@@ -34,9 +37,17 @@ class Command(BaseCommand):
("LastUpdate", "user_updated_at"), # in UTC, so same as what we store ("LastUpdate", "user_updated_at"), # in UTC, so same as what we store
]) ])
args = '<output_file>' option_list = BaseCommand.option_list + (
make_option(
'--dump_all',
action='store_true',
dest='dump_all',
),
)
args = '<output_file_or_dir>'
help = """ help = """
Export user information from TestCenterUser model into a tab delimited Export user demographic information from TestCenterUser model into a tab delimited
text file with a format that Pearson expects. text file with a format that Pearson expects.
""" """
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
...@@ -44,9 +55,33 @@ class Command(BaseCommand): ...@@ -44,9 +55,33 @@ class Command(BaseCommand):
print Command.help print Command.help
return return
self.reset_sample_data() # update time should use UTC in order to be comparable to the user_updated_at
# field
uploaded_at = datetime.utcnow()
with open(args[0], "wb") as outfile: # if specified destination is an existing directory, then
# create a filename for it automatically. If it doesn't exist,
# or exists as a file, then we will just write to it.
# 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.
dest = args[0]
if isdir(dest):
destfile = os.path.join(dest, uploaded_at.strftime("cdd-%Y%m%d-%H%M%S.dat"))
else:
destfile = dest
# 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 = kwargs['dump_all']
with open(destfile, "wb") as outfile:
writer = csv.DictWriter(outfile, writer = csv.DictWriter(outfile,
Command.CSV_TO_MODEL_FIELDS, Command.CSV_TO_MODEL_FIELDS,
delimiter="\t", delimiter="\t",
...@@ -54,103 +89,14 @@ class Command(BaseCommand): ...@@ -54,103 +89,14 @@ class Command(BaseCommand):
extrasaction='ignore') extrasaction='ignore')
writer.writeheader() writer.writeheader()
for tcu in TestCenterUser.objects.order_by('id'): for tcu in TestCenterUser.objects.order_by('id'):
record = dict((csv_field, getattr(tcu, model_field)) if dump_all or tcu.needs_uploading:
for csv_field, model_field record = dict((csv_field, ensure_encoding(getattr(tcu, model_field)))
in Command.CSV_TO_MODEL_FIELDS.items()) for csv_field, model_field
record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S") in Command.CSV_TO_MODEL_FIELDS.items())
writer.writerow(record) record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S")
writer.writerow(record)
tcu.uploaded_at = uploaded_at
tcu.save()
def reset_sample_data(self):
def make_sample(**kwargs):
data = dict((model_field, kwargs.get(model_field, ""))
for model_field in Command.CSV_TO_MODEL_FIELDS.values())
return TestCenterUser(**data)
def generate_id():
return "edX{:012}".format(uuid.uuid4().int % (10**12))
# TestCenterUser.objects.all().delete()
samples = [
make_sample(
client_candidate_id=generate_id(),
first_name="Jack",
last_name="Doe",
middle_name="C",
address_1="11 Cambridge Center",
address_2="Suite 101",
city="Cambridge",
state="MA",
postal_code="02140",
country="USA",
phone="(617)555-5555",
phone_country_code="1",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Clyde",
last_name="Smith",
middle_name="J",
suffix="Jr.",
salutation="Mr.",
address_1="1 Penny Lane",
city="Honolulu",
state="HI",
postal_code="96792",
country="USA",
phone="555-555-5555",
phone_country_code="1",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Patty",
last_name="Lee",
salutation="Dr.",
address_1="P.O. Box 555",
city="Honolulu",
state="HI",
postal_code="96792",
country="USA",
phone="808-555-5555",
phone_country_code="1",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Jimmy",
last_name="James",
address_1="2020 Palmer Blvd.",
city="Springfield",
state="MA",
postal_code="96792",
country="USA",
phone="917-555-5555",
phone_country_code="1",
extension="2039",
fax="917-555-5556",
fax_country_code="1",
company_name="ACME Traps",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Yeong-Un",
last_name="Seo",
address_1="Duryu, Lotte 101",
address_2="Apt 55",
city="Daegu",
country="KOR",
phone="917-555-5555",
phone_country_code="011",
user_updated_at=datetime.utcnow()
),
]
for tcu in samples:
tcu.save()
\ No newline at end of file
import csv import csv
import uuid from collections import OrderedDict
from collections import defaultdict, OrderedDict
from datetime import datetime from datetime import datetime
from os.path import isdir, join
from optparse import make_option
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand
from student.models import TestCenterUser from student.models import TestCenterRegistration
def generate_id():
return "{:012}".format(uuid.uuid4().int % (10**12))
class Command(BaseCommand): class Command(BaseCommand):
args = '<output_file>'
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
])
args = '<output_file_or_dir>'
help = """ help = """
Export user information from TestCenterUser model into a tab delimited Export user registration information from TestCenterRegistration model into a tab delimited
text file with a format that Pearson expects. text file with a format that Pearson expects.
""" """
FIELDS = [
'AuthorizationTransactionType', option_list = BaseCommand.option_list + (
'AuthorizationID', make_option(
'ClientAuthorizationID', '--dump_all',
'ClientCandidateID', action='store_true',
'ExamAuthorizationCount', dest='dump_all',
'ExamSeriesCode', ),
'EligibilityApptDateFirst', make_option(
'EligibilityApptDateLast', '--force_add',
'LastUpdate', action='store_true',
] dest='force_add',
),
)
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
if len(args) < 1: if len(args) < 1:
print Command.help print Command.help
return return
# self.reset_sample_data() # update time should use UTC in order to be comparable to the user_updated_at
# field
uploaded_at = datetime.utcnow()
# if specified destination is an existing directory, then
# create a filename for it automatically. If it doesn't exist,
# or exists as a file, then we will just write to it.
# 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.
dest = args[0]
if isdir(dest):
destfile = join(dest, uploaded_at.strftime("ead-%Y%m%d-%H%M%S.dat"))
else:
destfile = dest
with open(args[0], "wb") as outfile: dump_all = kwargs['dump_all']
with open(destfile, "wb") as outfile:
writer = csv.DictWriter(outfile, writer = csv.DictWriter(outfile,
Command.FIELDS, Command.CSV_TO_MODEL_FIELDS,
delimiter="\t", delimiter="\t",
quoting=csv.QUOTE_MINIMAL, quoting=csv.QUOTE_MINIMAL,
extrasaction='ignore') extrasaction='ignore')
writer.writeheader() writer.writeheader()
for tcu in TestCenterUser.objects.order_by('id')[:5]: for tcr in TestCenterRegistration.objects.order_by('id'):
record = defaultdict( if dump_all or tcr.needs_uploading:
lambda: "", record = dict((csv_field, getattr(tcr, model_field))
AuthorizationTransactionType="Add", for csv_field, model_field
ClientAuthorizationID=generate_id(), in Command.CSV_TO_MODEL_FIELDS.items())
ClientCandidateID=tcu.client_candidate_id, record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S")
ExamAuthorizationCount="1", record["EligibilityApptDateFirst"] = record["EligibilityApptDateFirst"].strftime("%Y/%m/%d")
ExamSeriesCode="6002x001", record["EligibilityApptDateLast"] = record["EligibilityApptDateLast"].strftime("%Y/%m/%d")
EligibilityApptDateFirst="2012/12/15", if kwargs['force_add']:
EligibilityApptDateLast="2012/12/30", record['AuthorizationTransactionType'] = 'Add'
LastUpdate=datetime.utcnow().strftime("%Y/%m/%d %H:%M:%S")
)
writer.writerow(record)
writer.writerow(record)
tcr.uploaded_at = uploaded_at
tcr.save()
def reset_sample_data(self):
def make_sample(**kwargs):
data = dict((model_field, kwargs.get(model_field, ""))
for model_field in Command.CSV_TO_MODEL_FIELDS.values())
return TestCenterUser(**data)
# TestCenterUser.objects.all().delete()
samples = [
make_sample(
client_candidate_id=generate_id(),
first_name="Jack",
last_name="Doe",
middle_name="C",
address_1="11 Cambridge Center",
address_2="Suite 101",
city="Cambridge",
state="MA",
postal_code="02140",
country="USA",
phone="(617)555-5555",
phone_country_code="1",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Clyde",
last_name="Smith",
middle_name="J",
suffix="Jr.",
salutation="Mr.",
address_1="1 Penny Lane",
city="Honolulu",
state="HI",
postal_code="96792",
country="USA",
phone="555-555-5555",
phone_country_code="1",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Patty",
last_name="Lee",
salutation="Dr.",
address_1="P.O. Box 555",
city="Honolulu",
state="HI",
postal_code="96792",
country="USA",
phone="808-555-5555",
phone_country_code="1",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Jimmy",
last_name="James",
address_1="2020 Palmer Blvd.",
city="Springfield",
state="MA",
postal_code="96792",
country="USA",
phone="917-555-5555",
phone_country_code="1",
extension="2039",
fax="917-555-5556",
fax_country_code="1",
company_name="ACME Traps",
user_updated_at=datetime.utcnow()
),
make_sample(
client_candidate_id=generate_id(),
first_name="Yeong-Un",
last_name="Seo",
address_1="Duryu, Lotte 101",
address_2="Apt 55",
city="Daegu",
country="KOR",
phone="917-555-5555",
phone_country_code="011",
user_updated_at=datetime.utcnow()
),
]
for tcu in samples:
tcu.save()
\ No newline at end of file
from optparse import make_option
from time import strftime
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.'
),
)
args = "<student_username course_id>"
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))
# check to see if a course_id was specified, and use information from that:
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:
# 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'] = strftime("%Y-%m-%d", exam.first_eligible_appointment_date)
our_options['eligibility_appointment_date_last'] = strftime("%Y-%m-%d", exam.last_eligible_appointment_date)
if exam is None:
raise CommandError("Exam for course_id {%s} 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:
print "Field Form Error: %s" % fielderror
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."
import uuid
from datetime import datetime
from optparse import make_option from optparse import make_option
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand
from student.models import TestCenterUser from student.models import TestCenterUser, TestCenterUserForm
class Command(BaseCommand): class Command(BaseCommand):
option_list = BaseCommand.option_list + ( option_list = BaseCommand.option_list + (
# demographics:
make_option( make_option(
'--client_candidate_id', '--first_name',
action='store', action='store',
dest='client_candidate_id', dest='first_name',
help='ID we assign a user to identify them to Pearson'
), ),
make_option( make_option(
'--first_name', '--middle_name',
action='store', action='store',
dest='first_name', dest='middle_name',
), ),
make_option( make_option(
'--last_name', '--last_name',
...@@ -26,11 +24,31 @@ class Command(BaseCommand): ...@@ -26,11 +24,31 @@ class Command(BaseCommand):
dest='last_name', dest='last_name',
), ),
make_option( make_option(
'--suffix',
action='store',
dest='suffix',
),
make_option(
'--salutation',
action='store',
dest='salutation',
),
make_option(
'--address_1', '--address_1',
action='store', action='store',
dest='address_1', dest='address_1',
), ),
make_option( make_option(
'--address_2',
action='store',
dest='address_2',
),
make_option(
'--address_3',
action='store',
dest='address_3',
),
make_option(
'--city', '--city',
action='store', action='store',
dest='city', dest='city',
...@@ -59,14 +77,55 @@ class Command(BaseCommand): ...@@ -59,14 +77,55 @@ class Command(BaseCommand):
help='Pretty free-form (parens, spaces, dashes), but no country code' help='Pretty free-form (parens, spaces, dashes), but no country code'
), ),
make_option( make_option(
'--extension',
action='store',
dest='extension',
),
make_option(
'--phone_country_code', '--phone_country_code',
action='store', action='store',
dest='phone_country_code', dest='phone_country_code',
help='Phone country code, just "1" for the USA' 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 = "<student_username>" args = "<student_username>"
help = "Create a TestCenterUser entry for a given Student" help = "Create or modify a TestCenterUser entry for a given Student"
@staticmethod @staticmethod
def is_valid_option(option_name): def is_valid_option(option_name):
...@@ -79,7 +138,52 @@ class Command(BaseCommand): ...@@ -79,7 +138,52 @@ class Command(BaseCommand):
print username print username
our_options = dict((k, v) for k, v in options.items() our_options = dict((k, v) for k, v in options.items()
if Command.is_valid_option(k)) if Command.is_valid_option(k) and v is not None)
student = User.objects.get(username=username) student = User.objects.get(username=username)
student.test_center_user = TestCenterUser(**our_options) try:
student.test_center_user.save() 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:
if (len(form.errors) > 0):
print "Field Form errors encountered:"
for fielderror in form.errors:
print "Field Form Error: %s" % fielderror
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 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."
...@@ -26,14 +26,17 @@ class Migration(SchemaMigration): ...@@ -26,14 +26,17 @@ class Migration(SchemaMigration):
def forwards(self, orm): def forwards(self, orm):
"Kill the askbot" "Kill the askbot"
# For MySQL, we're batching the alters together for performance reasons try:
if db.backend_name == 'mysql': # For MySQL, we're batching the alters together for performance reasons
drops = ["drop `{0}`".format(col) for col in ASKBOT_AUTH_USER_COLUMNS] if db.backend_name == 'mysql':
statement = "alter table `auth_user` {0};".format(", ".join(drops)) drops = ["drop `{0}`".format(col) for col in ASKBOT_AUTH_USER_COLUMNS]
db.execute(statement) statement = "alter table `auth_user` {0};".format(", ".join(drops))
else: db.execute(statement)
for column in ASKBOT_AUTH_USER_COLUMNS: else:
db.delete_column('auth_user', column) for column in ASKBOT_AUTH_USER_COLUMNS:
db.delete_column('auth_user', column)
except Exception as ex:
print "Couldn't remove askbot because of {0} -- it was probably never here to begin with.".format(ex)
def backwards(self, orm): def backwards(self, orm):
raise RuntimeError("Cannot reverse this migration: there's no going back to Askbot.") raise RuntimeError("Cannot reverse this migration: there's no going back to Askbot.")
......
...@@ -186,24 +186,6 @@ class LoncapaProblem(object): ...@@ -186,24 +186,6 @@ class LoncapaProblem(object):
maxscore += responder.get_max_score() maxscore += responder.get_max_score()
return maxscore return maxscore
def message_post(self,event_info):
"""
Handle an ajax post that contains feedback on feedback
Returns a boolean success variable
Note: This only allows for feedback to be posted back to the grading controller for the first
open ended response problem on each page. Multiple problems will cause some sync issues.
TODO: Handle multiple problems on one page sync issues.
"""
success=False
message = "Could not find a valid responder."
log.debug("in lcp")
for responder in self.responders.values():
if hasattr(responder, 'handle_message_post'):
success, message = responder.handle_message_post(event_info)
if success:
break
return success, message
def get_score(self): def get_score(self):
""" """
Compute score for this problem. The score is the number of points awarded. Compute score for this problem. The score is the number of points awarded.
......
...@@ -735,51 +735,3 @@ class ChemicalEquationInput(InputTypeBase): ...@@ -735,51 +735,3 @@ class ChemicalEquationInput(InputTypeBase):
registry.register(ChemicalEquationInput) registry.register(ChemicalEquationInput)
#----------------------------------------------------------------------------- #-----------------------------------------------------------------------------
class OpenEndedInput(InputTypeBase):
"""
A text area input for code--uses codemirror, does syntax highlighting, special tab handling,
etc.
"""
template = "openendedinput.html"
tags = ['openendedinput']
# pulled out for testing
submitted_msg = ("Feedback not yet available. Reload to check again. "
"Once the problem is graded, this message will be "
"replaced with the grader's feedback.")
@classmethod
def get_attributes(cls):
"""
Convert options to a convenient format.
"""
return [Attribute('rows', '30'),
Attribute('cols', '80'),
Attribute('hidden', ''),
]
def setup(self):
"""
Implement special logic: handle queueing state, and default input.
"""
# if no student input yet, then use the default input given by the problem
if not self.value:
self.value = self.xml.text
# Check if problem has been queued
self.queue_len = 0
# Flag indicating that the problem has been queued, 'msg' is length of queue
if self.status == 'incomplete':
self.status = 'queued'
self.queue_len = self.msg
self.msg = self.submitted_msg
def _extra_context(self):
"""Defined queue_len, add it """
return {'queue_len': self.queue_len,}
registry.register(OpenEndedInput)
#-----------------------------------------------------------------------------
...@@ -19,6 +19,7 @@ setup( ...@@ -19,6 +19,7 @@ setup(
"abtest = xmodule.abtest_module:ABTestDescriptor", "abtest = xmodule.abtest_module:ABTestDescriptor",
"book = xmodule.backcompat_module:TranslateCustomTagDescriptor", "book = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"chapter = xmodule.seq_module:SequenceDescriptor", "chapter = xmodule.seq_module:SequenceDescriptor",
"combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor",
"course = xmodule.course_module:CourseDescriptor", "course = xmodule.course_module:CourseDescriptor",
"customtag = xmodule.template_module:CustomTagDescriptor", "customtag = xmodule.template_module:CustomTagDescriptor",
"discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor", "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor",
...@@ -28,7 +29,6 @@ setup( ...@@ -28,7 +29,6 @@ setup(
"problem = xmodule.capa_module:CapaDescriptor", "problem = xmodule.capa_module:CapaDescriptor",
"problemset = xmodule.seq_module:SequenceDescriptor", "problemset = xmodule.seq_module:SequenceDescriptor",
"section = xmodule.backcompat_module:SemanticSectionDescriptor", "section = xmodule.backcompat_module:SemanticSectionDescriptor",
"selfassessment = xmodule.self_assessment_module:SelfAssessmentDescriptor",
"sequential = xmodule.seq_module:SequenceDescriptor", "sequential = xmodule.seq_module:SequenceDescriptor",
"slides = xmodule.backcompat_module:TranslateCustomTagDescriptor", "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"vertical = xmodule.vertical_module:VerticalDescriptor", "vertical = xmodule.vertical_module:VerticalDescriptor",
...@@ -39,7 +39,8 @@ setup( ...@@ -39,7 +39,8 @@ setup(
"course_info = xmodule.html_module:CourseInfoDescriptor", "course_info = xmodule.html_module:CourseInfoDescriptor",
"static_tab = xmodule.html_module:StaticTabDescriptor", "static_tab = xmodule.html_module:StaticTabDescriptor",
"custom_tag_template = xmodule.raw_module:RawDescriptor", "custom_tag_template = xmodule.raw_module:RawDescriptor",
"about = xmodule.html_module:AboutDescriptor" "about = xmodule.html_module:AboutDescriptor",
"graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor"
] ]
} }
) )
...@@ -371,7 +371,6 @@ class CapaModule(XModule): ...@@ -371,7 +371,6 @@ class CapaModule(XModule):
'problem_save': self.save_problem, 'problem_save': self.save_problem,
'problem_show': self.get_answer, 'problem_show': self.get_answer,
'score_update': self.update_score, 'score_update': self.update_score,
'message_post' : self.message_post,
} }
if dispatch not in handlers: if dispatch not in handlers:
...@@ -386,20 +385,6 @@ class CapaModule(XModule): ...@@ -386,20 +385,6 @@ class CapaModule(XModule):
}) })
return json.dumps(d, cls=ComplexEncoder) return json.dumps(d, cls=ComplexEncoder)
def message_post(self, get):
"""
Posts a message from a form to an appropriate location
"""
event_info = dict()
event_info['state'] = self.lcp.get_state()
event_info['problem_id'] = self.location.url()
event_info['student_id'] = self.system.anonymous_student_id
event_info['survey_responses']= get
success, message = self.lcp.message_post(event_info)
return {'success' : success, 'message' : message}
def closed(self): def closed(self):
''' Is the student still allowed to submit answers? ''' ''' Is the student still allowed to submit answers? '''
if self.attempts == self.max_attempts: if self.attempts == self.max_attempts:
...@@ -436,6 +421,7 @@ class CapaModule(XModule): ...@@ -436,6 +421,7 @@ class CapaModule(XModule):
return False return False
def update_score(self, get): def update_score(self, get):
""" """
Delivers grading response (e.g. from asynchronous code checking) to Delivers grading response (e.g. from asynchronous code checking) to
......
import logging
from lxml import etree
log=logging.getLogger(__name__)
class CombinedOpenEndedRubric(object):
@staticmethod
def render_rubric(rubric_xml, system):
try:
rubric_categories = CombinedOpenEndedRubric.extract_rubric_categories(rubric_xml)
html = system.render_template('open_ended_rubric.html', {'rubric_categories' : rubric_categories})
except:
log.exception("Could not parse the rubric.")
html = rubric_xml
return html
@staticmethod
def extract_rubric_categories(element):
'''
Contstruct a list of categories such that the structure looks like:
[ { category: "Category 1 Name",
options: [{text: "Option 1 Name", points: 0}, {text:"Option 2 Name", points: 5}]
},
{ category: "Category 2 Name",
options: [{text: "Option 1 Name", points: 0},
{text: "Option 2 Name", points: 1},
{text: "Option 3 Name", points: 2]}]
'''
element = etree.fromstring(element)
categories = []
for category in element:
if category.tag != 'category':
raise Exception("[capa.inputtypes.extract_categories] Expected a <category> tag: got {0} instead".format(category.tag))
else:
categories.append(CombinedOpenEndedRubric.extract_category(category))
return categories
@staticmethod
def extract_category(category):
'''
construct an individual category
{category: "Category 1 Name",
options: [{text: "Option 1 text", points: 1},
{text: "Option 2 text", points: 2}]}
all sorting and auto-point generation occurs in this function
'''
has_score=False
descriptionxml = category[0]
scorexml = category[1]
if scorexml.tag == "option":
optionsxml = category[1:]
else:
optionsxml = category[2:]
has_score=True
# parse description
if descriptionxml.tag != 'description':
raise Exception("[extract_category]: expected description tag, got {0} instead".format(descriptionxml.tag))
if has_score:
if scorexml.tag != 'score':
raise Exception("[extract_category]: expected score tag, got {0} instead".format(scorexml.tag))
for option in optionsxml:
if option.tag != "option":
raise Exception("[extract_category]: expected option tag, got {0} instead".format(option.tag))
description = descriptionxml.text
if has_score:
score = int(scorexml.text)
else:
score = 0
cur_points = 0
options = []
autonumbering = True
# parse options
for option in optionsxml:
if option.tag != 'option':
raise Exception("[extract_category]: expected option tag, got {0} instead".format(option.tag))
else:
pointstr = option.get("points")
if pointstr:
autonumbering = False
# try to parse this into an int
try:
points = int(pointstr)
except ValueError:
raise Exception("[extract_category]: expected points to have int, got {0} instead".format(pointstr))
elif autonumbering:
# use the generated one if we're in the right mode
points = cur_points
cur_points = cur_points + 1
else:
raise Exception("[extract_category]: missing points attribute. Cannot continue to auto-create points values after a points value is explicitly dfined.")
optiontext = option.text
selected = False
if has_score:
if points == score:
selected = True
options.append({'text': option.text, 'points': points, 'selected' : selected})
# sort and check for duplicates
options = sorted(options, key=lambda option: option['points'])
CombinedOpenEndedRubric.validate_options(options)
return {'description': description, 'options': options, 'score' : score, 'has_score' : has_score}
@staticmethod
def validate_options(options):
'''
Validates a set of options. This can and should be extended to filter out other bad edge cases
'''
if len(options) == 0:
raise Exception("[extract_category]: no options associated with this category")
if len(options) == 1:
return
prev = options[0]['points']
for option in options[1:]:
if prev == option['points']:
raise Exception("[extract_category]: found duplicate point values between two different options")
else:
prev = option['points']
\ No newline at end of file
import logging import logging
from cStringIO import StringIO from cStringIO import StringIO
from math import exp, erf
from lxml import etree from lxml import etree
from path import path # NOTE (THK): Only used for detecting presence of syllabus from path import path # NOTE (THK): Only used for detecting presence of syllabus
import requests import requests
...@@ -128,6 +129,20 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -128,6 +129,20 @@ class CourseDescriptor(SequenceDescriptor):
self.syllabus_present = self.system.resources_fs.exists(path('syllabus')) self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
self.set_grading_policy(self.definition['data'].get('grading_policy', None)) self.set_grading_policy(self.definition['data'].get('grading_policy', None))
self.test_center_exams = []
test_center_info = self.metadata.get('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
def defaut_grading_policy(self): def defaut_grading_policy(self):
""" """
Return a dict which is a copy of the default grading policy Return a dict which is a copy of the default grading policy
...@@ -347,35 +362,66 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -347,35 +362,66 @@ class CourseDescriptor(SequenceDescriptor):
@property @property
def is_new(self): def is_new(self):
# The course is "new" if either if the metadata flag is_new is """
# true or if the course has not started yet Returns if the course has been flagged as new in the metadata. If
there is no flag, return a heuristic value considering the
announcement and the start dates.
"""
flag = self.metadata.get('is_new', None) flag = self.metadata.get('is_new', None)
if flag is None: if flag is None:
return self.days_until_start > 1 # Use a heuristic if the course has not been flagged
announcement, start, now = self._sorting_dates()
if announcement and (now - announcement).days < 30:
# The course has been announced for less that month
return True
elif (now - start).days < 1:
# The course has not started yet
return True
else:
return False
elif isinstance(flag, basestring): elif isinstance(flag, basestring):
return flag.lower() in ['true', 'yes', 'y'] return flag.lower() in ['true', 'yes', 'y']
else: else:
return bool(flag) return bool(flag)
@property @property
def days_until_start(self): def sorting_score(self):
def convert_to_datetime(timestamp): """
Returns a number that can be used to sort the courses according
the how "new"" they are. The "newness"" score is computed using a
heuristic that takes into account the announcement and
(advertized) start dates of the course if available.
The lower the number the "newer" the course.
"""
# Make courses that have an announcement date shave a lower
# score than courses than don't, older courses should have a
# higher score.
announcement, start, now = self._sorting_dates()
scale = 300.0 # about a year
if announcement:
days = (now - announcement).days
score = -exp(-days/scale)
else:
days = (now - start).days
score = exp(days/scale)
return score
def _sorting_dates(self):
# utility function to get datetime objects for dates used to
# compute the is_new flag and the sorting_score
def to_datetime(timestamp):
return datetime.fromtimestamp(time.mktime(timestamp)) return datetime.fromtimestamp(time.mktime(timestamp))
start_date = convert_to_datetime(self.start) def get_date(field):
timetuple = self._try_parse_time(field)
return to_datetime(timetuple) if timetuple else None
# Try to use course advertised date if we can parse it announcement = get_date('announcement')
advertised_start = self.metadata.get('advertised_start', None) start = get_date('advertised_start') or to_datetime(self.start)
if advertised_start: now = to_datetime(time.gmtime())
try:
start_date = datetime.strptime(advertised_start,
"%Y-%m-%dT%H:%M")
except ValueError:
pass # Invalid date, keep using 'start''
now = convert_to_datetime(time.gmtime()) return announcement, start, now
days_until_start = (start_date - now).days
return days_until_start
@lazyproperty @lazyproperty
def grading_context(self): def grading_context(self):
...@@ -541,6 +587,88 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -541,6 +587,88 @@ class CourseDescriptor(SequenceDescriptor):
""" """
return self.metadata.get('end_of_course_survey_url') return self.metadata.get('end_of_course_survey_url')
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 time.gmtime(0)
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")
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 parse_time(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 time.gmtime() > self.first_eligible_appointment_date
def has_ended(self):
return time.gmtime() > self.last_eligible_appointment_date
def has_started_registration(self):
return time.gmtime() > self.registration_start_date
def has_ended_registration(self):
return time.gmtime() > self.registration_end_date
def is_registering(self):
now = time.gmtime()
return now >= self.registration_start_date and now <= self.registration_end_date
@property
def first_eligible_appointment_date_text(self):
return time.strftime("%b %d, %Y", self.first_eligible_appointment_date)
@property
def last_eligible_appointment_date_text(self):
return time.strftime("%b %d, %Y", self.last_eligible_appointment_date)
@property
def registration_end_date_text(self):
return time.strftime("%b %d, %Y", 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
@property @property
def title(self): def title(self):
return self.display_name return self.display_name
......
"""
Graphical slider tool module is ungraded xmodule used by students to
understand functional dependencies.
"""
import json
import logging
from lxml import etree
from lxml import html
import xmltodict
from xmodule.mako_module import MakoModuleDescriptor
from xmodule.xml_module import XmlDescriptor
from xmodule.x_module import XModule
from xmodule.stringify import stringify_children
from pkg_resources import resource_string
log = logging.getLogger(__name__)
class GraphicalSliderToolModule(XModule):
''' Graphical-Slider-Tool Module
'''
js = {
'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee')],
'js': [
# 3rd party libraries used by graphic slider tool.
# TODO - where to store them - outside xmodule?
resource_string(__name__, 'js/src/graphical_slider_tool/gst_main.js'),
resource_string(__name__, 'js/src/graphical_slider_tool/state.js'),
resource_string(__name__, 'js/src/graphical_slider_tool/logme.js'),
resource_string(__name__, 'js/src/graphical_slider_tool/general_methods.js'),
resource_string(__name__, 'js/src/graphical_slider_tool/sliders.js'),
resource_string(__name__, 'js/src/graphical_slider_tool/inputs.js'),
resource_string(__name__, 'js/src/graphical_slider_tool/graph.js'),
resource_string(__name__, 'js/src/graphical_slider_tool/el_output.js'),
resource_string(__name__, 'js/src/graphical_slider_tool/g_label_el_output.js'),
resource_string(__name__, 'js/src/graphical_slider_tool/gst.js')
]
}
js_module_name = "GraphicalSliderTool"
def __init__(self, system, location, definition, descriptor, instance_state=None,
shared_state=None, **kwargs):
"""
For XML file format please look at documentation. TODO - receive
information where to store XML documentation.
"""
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
def get_html(self):
""" Renders parameters to template. """
# these 3 will be used in class methods
self.html_id = self.location.html_id()
self.html_class = self.location.category
self.configuration_json = self.build_configuration_json()
params = {
'gst_html': self.substitute_controls(self.definition['render']),
'element_id': self.html_id,
'element_class': self.html_class,
'configuration_json': self.configuration_json
}
self.content = self.system.render_template(
'graphical_slider_tool.html', params)
return self.content
def substitute_controls(self, html_string):
""" Substitutes control elements (slider, textbox and plot) in
html_string with their divs. Html_string is content of <render> tag
inside <graphical_slider_tool> tag. Documentation on how information in
<render> tag is organized and processed is located in:
mitx/docs/build/html/graphical_slider_tool.html.
Args:
html_string: content of <render> tag, with controls as xml tags,
e.g. <slider var="a"/>.
Returns:
html_string with control tags replaced by proper divs
(<slider var="a"/> -> <div class="....slider" > </div>)
"""
xml = html.fromstring(html_string)
#substitute plot, if presented
plot_div = '<div class="{element_class}_plot" id="{element_id}_plot" \
style="{style}"></div>'
plot_el = xml.xpath('//plot')
if plot_el:
plot_el = plot_el[0]
plot_el.getparent().replace(plot_el, html.fromstring(
plot_div.format(element_class=self.html_class,
element_id=self.html_id,
style=plot_el.get('style', ""))))
#substitute sliders
slider_div = '<div class="{element_class}_slider" \
id="{element_id}_slider_{var}" \
data-var="{var}" \
style="{style}">\
</div>'
slider_els = xml.xpath('//slider')
for slider_el in slider_els:
slider_el.getparent().replace(slider_el, html.fromstring(
slider_div.format(element_class=self.html_class,
element_id=self.html_id,
var=slider_el.get('var', ""),
style=slider_el.get('style', ""))))
# substitute inputs aka textboxes
input_div = '<input class="{element_class}_input" \
id="{element_id}_input_{var}_{input_index}" \
data-var="{var}" style="{style}"/>'
input_els = xml.xpath('//textbox')
for input_index, input_el in enumerate(input_els):
input_el.getparent().replace(input_el, html.fromstring(
input_div.format(element_class=self.html_class,
element_id=self.html_id,
var=input_el.get('var', ""),
style=input_el.get('style', ""),
input_index=input_index)))
return html.tostring(xml)
def build_configuration_json(self):
"""Creates json element from xml element (with aim to transfer later
directly to javascript via hidden field in template). Steps:
1. Convert xml tree to python dict.
2. Dump dict to json.
"""
# <root> added for interface compatibility with xmltodict.parse
# class added for javascript's part purposes
return json.dumps(xmltodict.parse('<root class="' + self.html_class +
'">' + self.definition['configuration'] + '</root>'))
class GraphicalSliderToolDescriptor(MakoModuleDescriptor, XmlDescriptor):
module_class = GraphicalSliderToolModule
template_dir_name = 'graphical_slider_tool'
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
Pull out the data into dictionary.
Args:
xml_object: xml from file.
Returns:
dict
"""
# check for presense of required tags in xml
expected_children_level_0 = ['render', 'configuration']
for child in expected_children_level_0:
if len(xml_object.xpath(child)) != 1:
raise ValueError("Graphical Slider Tool definition must include \
exactly one '{0}' tag".format(child))
expected_children_level_1 = ['functions']
for child in expected_children_level_1:
if len(xml_object.xpath('configuration')[0].xpath(child)) != 1:
raise ValueError("Graphical Slider Tool definition must include \
exactly one '{0}' tag".format(child))
# finished
def parse(k):
"""Assumes that xml_object has child k"""
return stringify_children(xml_object.xpath(k)[0])
return {
'render': parse('render'),
'configuration': parse('configuration')
}
def definition_to_xml(self, resource_fs):
'''Return an xml element representing this definition.'''
xml_object = etree.Element('graphical_slider_tool')
def add_child(k):
child_str = '<{tag}>{body}</{tag}>'.format(tag=k, body=self.definition[k])
child_node = etree.fromstring(child_str)
xml_object.append(child_node)
for child in ['render', 'configuration']:
add_child(child)
return xml_object
...@@ -25,7 +25,6 @@ class @Problem ...@@ -25,7 +25,6 @@ class @Problem
@$('section.action input.reset').click @reset @$('section.action input.reset').click @reset
@$('section.action input.show').click @show @$('section.action input.show').click @show
@$('section.action input.save').click @save @$('section.action input.save').click @save
@$('section.evaluation input.submit-message').click @message_post
# Collapsibles # Collapsibles
Collapsible.setCollapsibles(@el) Collapsible.setCollapsibles(@el)
...@@ -198,35 +197,6 @@ class @Problem ...@@ -198,35 +197,6 @@ class @Problem
else else
@gentle_alert response.success @gentle_alert response.success
message_post: =>
Logger.log 'message_post', @answers
fd = new FormData()
feedback = @$('section.evaluation textarea.feedback-on-feedback')[0].value
submission_id = $('div.external-grader-message div.submission_id')[0].innerHTML
grader_id = $('div.external-grader-message div.grader_id')[0].innerHTML
score = $(".evaluation-scoring input:radio[name='evaluation-score']:checked").val()
fd.append('feedback', feedback)
fd.append('submission_id', submission_id)
fd.append('grader_id', grader_id)
if(!score)
@gentle_alert "You need to pick a rating before you can submit."
return
else
fd.append('score', score)
settings =
type: "POST"
data: fd
processData: false
contentType: false
success: (response) =>
@gentle_alert response.message
@$('section.evaluation').slideToggle()
$.ajaxWithPrefix("#{@url}/message_post", settings)
reset: => reset: =>
Logger.log 'problem_reset', @answers Logger.log 'problem_reset', @answers
$.postWithPrefix "#{@url}/problem_reset", id: @id, (response) => $.postWithPrefix "#{@url}/problem_reset", id: @id, (response) =>
......
...@@ -22,7 +22,7 @@ class @Collapsible ...@@ -22,7 +22,7 @@ class @Collapsible
if $(event.target).text() == 'See full output' if $(event.target).text() == 'See full output'
new_text = 'Hide output' new_text = 'Hide output'
else else
new_text = 'See full ouput' new_text = 'See full output'
$(event.target).text(new_text) $(event.target).text(new_text)
@toggleHint: (event) => @toggleHint: (event) =>
......
class @CombinedOpenEnded
constructor: (element) ->
@element=element
@reinitialize(element)
reinitialize: (element) ->
@wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule')
@el = $(element).find('section.combined-open-ended')
@combined_open_ended=$(element).find('section.combined-open-ended')
@id = @el.data('id')
@ajax_url = @el.data('ajax-url')
@state = @el.data('state')
@task_count = @el.data('task-count')
@task_number = @el.data('task-number')
@allow_reset = @el.data('allow_reset')
@reset_button = @$('.reset-button')
@reset_button.click @reset
@next_problem_button = @$('.next-step-button')
@next_problem_button.click @next_problem
@show_results_button=@$('.show-results-button')
@show_results_button.click @show_results
# valid states: 'initial', 'assessing', 'post_assessment', 'done'
Collapsible.setCollapsibles(@el)
@submit_evaluation_button = $('.submit-evaluation-button')
@submit_evaluation_button.click @message_post
@results_container = $('.result-container')
# Where to put the rubric once we load it
@el = $(element).find('section.open-ended-child')
@errors_area = @$('.error')
@answer_area = @$('textarea.answer')
@rubric_wrapper = @$('.rubric-wrapper')
@hint_wrapper = @$('.hint-wrapper')
@message_wrapper = @$('.message-wrapper')
@submit_button = @$('.submit-button')
@child_state = @el.data('state')
@child_type = @el.data('child-type')
if @child_type=="openended"
@skip_button = @$('.skip-button')
@skip_button.click @skip_post_assessment
@open_ended_child= @$('.open-ended-child')
@find_assessment_elements()
@find_hint_elements()
@rebind()
# locally scoped jquery.
$: (selector) ->
$(selector, @el)
show_results: (event) =>
status_item = $(event.target).parent().parent()
status_number = status_item.data('status-number')
data = {'task_number' : status_number}
$.postWithPrefix "#{@ajax_url}/get_results", data, (response) =>
if response.success
@results_container.after(response.html).remove()
@results_container = $('div.result-container')
@submit_evaluation_button = $('.submit-evaluation-button')
@submit_evaluation_button.click @message_post
Collapsible.setCollapsibles(@results_container)
else
@errors_area.html(response.error)
message_post: (event)=>
Logger.log 'message_post', @answers
external_grader_message=$(event.target).parent().parent().parent()
evaluation_scoring = $(event.target).parent()
fd = new FormData()
feedback = evaluation_scoring.find('textarea.feedback-on-feedback')[0].value
submission_id = external_grader_message.find('input.submission_id')[0].value
grader_id = external_grader_message.find('input.grader_id')[0].value
score = evaluation_scoring.find("input:radio[name='evaluation-score']:checked").val()
fd.append('feedback', feedback)
fd.append('submission_id', submission_id)
fd.append('grader_id', grader_id)
if(!score)
@gentle_alert "You need to pick a rating before you can submit."
return
else
fd.append('score', score)
settings =
type: "POST"
data: fd
processData: false
contentType: false
success: (response) =>
@gentle_alert response.msg
$('section.evaluation').slideToggle()
@message_wrapper.html(response.message_html)
$.ajaxWithPrefix("#{@ajax_url}/save_post_assessment", settings)
rebind: () =>
# rebind to the appropriate function for the current state
@submit_button.unbind('click')
@submit_button.show()
@reset_button.hide()
@next_problem_button.hide()
@hint_area.attr('disabled', false)
if @child_type=="openended"
@skip_button.hide()
if @allow_reset=="True"
@reset_button.show()
@submit_button.hide()
@answer_area.attr("disabled", true)
@hint_area.attr('disabled', true)
else if @child_state == 'initial'
@answer_area.attr("disabled", false)
@submit_button.prop('value', 'Submit')
@submit_button.click @save_answer
else if @child_state == 'assessing'
@answer_area.attr("disabled", true)
@submit_button.prop('value', 'Submit assessment')
@submit_button.click @save_assessment
if @child_type == "openended"
@submit_button.hide()
@queueing()
else if @child_state == 'post_assessment'
if @child_type=="openended"
@skip_button.show()
@skip_post_assessment()
@answer_area.attr("disabled", true)
@submit_button.prop('value', 'Submit post-assessment')
if @child_type=="selfassessment"
@submit_button.click @save_hint
else
@submit_button.click @message_post
else if @child_state == 'done'
@answer_area.attr("disabled", true)
@hint_area.attr('disabled', true)
@submit_button.hide()
if @child_type=="openended"
@skip_button.hide()
if @task_number<@task_count
@next_problem()
else
@reset_button.show()
find_assessment_elements: ->
@assessment = @$('select.assessment')
find_hint_elements: ->
@hint_area = @$('textarea.post_assessment')
save_answer: (event) =>
event.preventDefault()
if @child_state == 'initial'
data = {'student_answer' : @answer_area.val()}
$.postWithPrefix "#{@ajax_url}/save_answer", data, (response) =>
if response.success
@rubric_wrapper.html(response.rubric_html)
@child_state = 'assessing'
@find_assessment_elements()
@rebind()
else
@errors_area.html(response.error)
else
@errors_area.html('Problem state got out of sync. Try reloading the page.')
save_assessment: (event) =>
event.preventDefault()
if @child_state == 'assessing'
data = {'assessment' : @assessment.find(':selected').text()}
$.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) =>
if response.success
@child_state = response.state
if @child_state == 'post_assessment'
@hint_wrapper.html(response.hint_html)
@find_hint_elements()
else if @child_state == 'done'
@message_wrapper.html(response.message_html)
@rebind()
else
@errors_area.html(response.error)
else
@errors_area.html('Problem state got out of sync. Try reloading the page.')
save_hint: (event) =>
event.preventDefault()
if @child_state == 'post_assessment'
data = {'hint' : @hint_area.val()}
$.postWithPrefix "#{@ajax_url}/save_post_assessment", data, (response) =>
if response.success
@message_wrapper.html(response.message_html)
@child_state = 'done'
@rebind()
else
@errors_area.html(response.error)
else
@errors_area.html('Problem state got out of sync. Try reloading the page.')
skip_post_assessment: =>
if @child_state == 'post_assessment'
$.postWithPrefix "#{@ajax_url}/skip_post_assessment", {}, (response) =>
if response.success
@child_state = 'done'
@rebind()
else
@errors_area.html(response.error)
else
@errors_area.html('Problem state got out of sync. Try reloading the page.')
reset: (event) =>
event.preventDefault()
if @child_state == 'done' or @allow_reset=="True"
$.postWithPrefix "#{@ajax_url}/reset", {}, (response) =>
if response.success
@answer_area.val('')
@rubric_wrapper.html('')
@hint_wrapper.html('')
@message_wrapper.html('')
@child_state = 'initial'
@combined_open_ended.after(response.html).remove()
@allow_reset="False"
@reinitialize(@element)
@rebind()
@reset_button.hide()
else
@errors_area.html(response.error)
else
@errors_area.html('Problem state got out of sync. Try reloading the page.')
next_problem: =>
if @child_state == 'done'
$.postWithPrefix "#{@ajax_url}/next_problem", {}, (response) =>
if response.success
@answer_area.val('')
@rubric_wrapper.html('')
@hint_wrapper.html('')
@message_wrapper.html('')
@child_state = 'initial'
@combined_open_ended.after(response.html).remove()
@reinitialize(@element)
@rebind()
@next_problem_button.hide()
if !response.allow_reset
@gentle_alert "Moved to next step."
else
@gentle_alert "Your score did not meet the criteria to move to the next step."
else
@errors_area.html(response.error)
else
@errors_area.html('Problem state got out of sync. Try reloading the page.')
gentle_alert: (msg) =>
if @el.find('.open-ended-alert').length
@el.find('.open-ended-alert').remove()
alert_elem = "<div class='open-ended-alert'>" + msg + "</div>"
@el.find('.open-ended-action').after(alert_elem)
@el.find('.open-ended-alert').css(opacity: 0).animate(opacity: 1, 700)
queueing: =>
if @child_state=="assessing" and @child_type=="openended"
if window.queuePollerID # Only one poller 'thread' per Problem
window.clearTimeout(window.queuePollerID)
window.queuePollerID = window.setTimeout(@poll, 10000)
poll: =>
$.postWithPrefix "#{@ajax_url}/check_for_score", (response) =>
if response.state == "done" or response.state=="post_assessment"
delete window.queuePollerID
location.reload()
else
window.queuePollerID = window.setTimeout(@poll, 10000)
\ No newline at end of file
// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
// define() functions from Require JS available inside the anonymous function.
(function (requirejs, require, define) {
define('ElOutput', ['logme'], function (logme) {
return ElOutput;
function ElOutput(config, state) {
if ($.isPlainObject(config.functions.function)) {
processFuncObj(config.functions.function);
} else if ($.isArray(config.functions.function)) {
(function (c1) {
while (c1 < config.functions.function.length) {
if ($.isPlainObject(config.functions.function[c1])) {
processFuncObj(config.functions.function[c1]);
}
c1 += 1;
}
}(0));
}
return;
function processFuncObj(obj) {
var paramNames, funcString, func, el, disableAutoReturn, updateOnEvent;
// We are only interested in functions that are meant for output to an
// element.
if (
(typeof obj['@output'] !== 'string') ||
((obj['@output'].toLowerCase() !== 'element') && (obj['@output'].toLowerCase() !== 'none'))
) {
return;
}
if (typeof obj['@el_id'] !== 'string') {
logme('ERROR: You specified "output" as "element", but did not spify "el_id".');
return;
}
if (typeof obj['#text'] !== 'string') {
logme('ERROR: Function body is not defined.');
return;
}
updateOnEvent = 'slide';
if (
(obj.hasOwnProperty('@update_on') === true) &&
(typeof obj['@update_on'] === 'string') &&
((obj['@update_on'].toLowerCase() === 'slide') || (obj['@update_on'].toLowerCase() === 'change'))
) {
updateOnEvent = obj['@update_on'].toLowerCase();
}
disableAutoReturn = obj['@disable_auto_return'];
funcString = obj['#text'];
if (
(disableAutoReturn === undefined) ||
(
(typeof disableAutoReturn === 'string') &&
(disableAutoReturn.toLowerCase() !== 'true')
)
) {
if (funcString.search(/return/i) === -1) {
funcString = 'return ' + funcString;
}
} else {
if (funcString.search(/return/i) === -1) {
logme(
'ERROR: You have specified a JavaScript ' +
'function without a "return" statemnt. Your ' +
'function will return "undefined" by default.'
);
}
}
// Make sure that all HTML entities are converted to their proper
// ASCII text equivalents.
funcString = $('<div>').html(funcString).text();
paramNames = state.getAllParameterNames();
paramNames.push(funcString);
try {
func = Function.apply(null, paramNames);
} catch (err) {
logme(
'ERROR: The function body "' +
funcString +
'" was not converted by the Function constructor.'
);
logme('Error message: "' + err.message + '".');
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not create a function from string "' + funcString + '".' + '</div>');
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
paramNames.pop();
return;
}
paramNames.pop();
if (obj['@output'].toLowerCase() !== 'none') {
el = $('#' + obj['@el_id']);
if (el.length !== 1) {
logme(
'ERROR: DOM element with ID "' + obj['@el_id'] + '" ' +
'not found. Dynamic element not created.'
);
return;
}
el.html(func.apply(window, state.getAllParameterValues()));
} else {
el = null;
func.apply(window, state.getAllParameterValues());
}
state.addDynamicEl(el, func, obj['@el_id'], updateOnEvent);
}
}
});
// End of wrapper for RequireJS. As you can see, we are passing
// namespaced Require JS variables to an anonymous function. Within
// it, you can use the standard requirejs(), require(), and define()
// functions as if they were in the global namespace.
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
// define() functions from Require JS available inside the anonymous function.
(function (requirejs, require, define) {
define('GLabelElOutput', ['logme'], function (logme) {
return GLabelElOutput;
function GLabelElOutput(config, state) {
if ($.isPlainObject(config.functions.function)) {
processFuncObj(config.functions.function);
} else if ($.isArray(config.functions.function)) {
(function (c1) {
while (c1 < config.functions.function.length) {
if ($.isPlainObject(config.functions.function[c1])) {
processFuncObj(config.functions.function[c1]);
}
c1 += 1;
}
}(0));
}
return;
function processFuncObj(obj) {
var paramNames, funcString, func, disableAutoReturn;
// We are only interested in functions that are meant for output to an
// element.
if (
(typeof obj['@output'] !== 'string') ||
(obj['@output'].toLowerCase() !== 'plot_label')
) {
return;
}
if (typeof obj['@el_id'] !== 'string') {
logme('ERROR: You specified "output" as "plot_label", but did not spify "el_id".');
return;
}
if (typeof obj['#text'] !== 'string') {
logme('ERROR: Function body is not defined.');
return;
}
disableAutoReturn = obj['@disable_auto_return'];
funcString = obj['#text'];
if (
(disableAutoReturn === undefined) ||
(
(typeof disableAutoReturn === 'string') &&
(disableAutoReturn.toLowerCase() !== 'true')
)
) {
if (funcString.search(/return/i) === -1) {
funcString = 'return ' + funcString;
}
} else {
if (funcString.search(/return/i) === -1) {
logme(
'ERROR: You have specified a JavaScript ' +
'function without a "return" statemnt. Your ' +
'function will return "undefined" by default.'
);
}
}
// Make sure that all HTML entities are converted to their proper
// ASCII text equivalents.
funcString = $('<div>').html(funcString).text();
paramNames = state.getAllParameterNames();
paramNames.push(funcString);
try {
func = Function.apply(null, paramNames);
} catch (err) {
logme(
'ERROR: The function body "' +
funcString +
'" was not converted by the Function constructor.'
);
logme('Error message: "' + err.message + '".');
$('#' + gstId).html('<div style="color: red;">' + 'ERROR IN XML: Could not create a function from string "' + funcString + '".' + '</div>');
$('#' + gstId).append('<div style="color: red;">' + 'Error message: "' + err.message + '".' + '</div>');
paramNames.pop();
return;
}
paramNames.pop();
state.plde.push({
'elId': obj['@el_id'],
'func': func
});
}
}
});
// End of wrapper for RequireJS. As you can see, we are passing
// namespaced Require JS variables to an anonymous function. Within
// it, you can use the standard requirejs(), require(), and define()
// functions as if they were in the global namespace.
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
// define() functions from Require JS available inside the anonymous function.
(function (requirejs, require, define) {
define('GeneralMethods', [], function () {
if (!String.prototype.trim) {
// http://blog.stevenlevithan.com/archives/faster-trim-javascript
String.prototype.trim = function trim(str) {
return str.replace(/^\s\s*/, '').replace(/\s\s*$/, '');
};
}
return {
'module_name': 'GeneralMethods',
'module_status': 'OK'
};
});
// End of wrapper for RequireJS. As you can see, we are passing
// namespaced Require JS variables to an anonymous function. Within
// it, you can use the standard requirejs(), require(), and define()
// functions as if they were in the global namespace.
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
/*
* We will add a function that will be called for all GraphicalSliderTool
* xmodule module instances. It must be available globally by design of
* xmodule.
*/
window.GraphicalSliderTool = function (el) {
// All the work will be performed by the GstMain module. We will get access
// to it, and all it's dependencies, via Require JS. Currently Require JS
// is namespaced and is available via a global object RequireJS.
RequireJS.require(['GstMain'], function (GstMain) {
// The GstMain module expects the DOM ID of a Graphical Slider Tool
// element. Since we are given a <section> element which might in
// theory contain multiple graphical_slider_tool <div> elements (each
// with a unique DOM ID), we will iterate over all children, and for
// each match, we will call GstMain module.
$(el).children('.graphical_slider_tool').each(function (index, value) {
JavascriptLoader.executeModuleScripts($(value), function(){
GstMain($(value).attr('id'));
});
});
});
};
// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
// define() functions from Require JS available inside the anonymous function.
(function (requirejs, require, define) {
define(
'GstMain',
// Even though it is not explicitly in this module, we have to specify
// 'GeneralMethods' as a dependency. It expands some of the core JS objects
// with additional useful methods that are used in other modules.
['State', 'GeneralMethods', 'Sliders', 'Inputs', 'Graph', 'ElOutput', 'GLabelElOutput', 'logme'],
function (State, GeneralMethods, Sliders, Inputs, Graph, ElOutput, GLabelElOutput, logme) {
return GstMain;
function GstMain(gstId) {
var config, gstClass, state;
if ($('#' + gstId).attr('data-processed') !== 'processed') {
$('#' + gstId).attr('data-processed', 'processed');
} else {
// logme('MESSAGE: Already processed GST with ID ' + gstId + '. Skipping.');
return;
}
// Get the JSON configuration, parse it, and store as an object.
try {
config = JSON.parse($('#' + gstId + '_json').html()).root;
} catch (err) {
logme('ERROR: could not parse config JSON.');
logme('$("#" + gstId + "_json").html() = ', $('#' + gstId + '_json').html());
logme('JSON.parse(...) = ', JSON.parse($('#' + gstId + '_json').html()));
logme('config = ', config);
return;
}
// Get the class name of the GST. All elements are assigned a class
// name that is based on the class name of the GST. For example, inputs
// are assigned a class name '{GST class name}_input'.
if (typeof config['@class'] !== 'string') {
logme('ERROR: Could not get the class name of GST.');
logme('config["@class"] = ', config['@class']);
return;
}
gstClass = config['@class'];
// Parse the configuration settings for parameters, and store them in a
// state object.
state = State(gstId, config);
// It is possible that something goes wrong while extracting parameters
// from the JSON config object. In this case, we will not continue.
if (state === undefined) {
logme('ERROR: The state object was not initialized properly.');
return;
}
// Create the sliders and the text inputs, attaching them to
// appropriate parameters.
Sliders(gstId, state);
Inputs(gstId, gstClass, state);
// Configure functions that output to an element instead of the graph.
ElOutput(config, state);
// Configure functions that output to an element instead of the graph
// label.
GLabelElOutput(config, state);
// Configure and display the graph. Attach event for the graph to be
// updated on any change of a slider or a text input.
Graph(gstId, config, state);
}
});
// End of wrapper for RequireJS. As you can see, we are passing
// namespaced Require JS variables to an anonymous function. Within
// it, you can use the standard requirejs(), require(), and define()
// functions as if they were in the global namespace.
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
// define() functions from Require JS available inside the anonymous function.
(function (requirejs, require, define) {
define('Inputs', ['logme'], function (logme) {
return Inputs;
function Inputs(gstId, gstClass, state) {
var c1, paramName, allParamNames;
allParamNames = state.getAllParameterNames();
for (c1 = 0; c1 < allParamNames.length; c1 += 1) {
$('#' + gstId).find('.' + gstClass + '_input').each(function (index, value) {
var inputDiv, paramName;
paramName = allParamNames[c1];
inputDiv = $(value);
if (paramName === inputDiv.data('var')) {
createInput(inputDiv, paramName);
}
});
}
return;
function createInput(inputDiv, paramName) {
var paramObj;
paramObj = state.getParamObj(paramName);
// Check that the retrieval went OK.
if (paramObj === undefined) {
logme('ERROR: Could not get a paramObj for parameter "' + paramName + '".');
return;
}
// Bind a function to the 'change' event. Whenever the user changes
// the value of this text input, and presses 'enter' (or clicks
// somewhere else on the page), this event will be triggered, and
// our callback will be called.
inputDiv.bind('change', inputOnChange);
inputDiv.val(paramObj.value);
// Lets style the input element nicely. We will use the button()
// widget for this since there is no native widget for the text
// input.
inputDiv.button().css({
'font': 'inherit',
'color': 'inherit',
'text-align': 'left',
'outline': 'none',
'cursor': 'text',
'height': '15px'
});
// Tell the parameter object from state that we are attaching a
// text input to it. Next time the parameter will be updated with
// a new value, tis input will also be updated.
paramObj.inputDivs.push(inputDiv);
return;
// Update the 'state' - i.e. set the value of the parameter this
// input is attached to to a new value.
//
// This will cause the plot to be redrawn each time after the user
// changes the value in the input. Note that he has to either press
// 'Enter', or click somewhere else on the page in order for the
// 'change' event to be tiggered.
function inputOnChange(event) {
var inputDiv;
inputDiv = $(this);
state.setParameterValue(paramName, inputDiv.val(), inputDiv);
}
}
}
});
// End of wrapper for RequireJS. As you can see, we are passing
// namespaced Require JS variables to an anonymous function. Within
// it, you can use the standard requirejs(), require(), and define()
// functions as if they were in the global namespace.
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
// define() functions from Require JS available inside the anonymous function.
(function (requirejs, require, define) {
define('logme', [], function () {
var debugMode;
// debugMode can be one of the following:
//
// true - All messages passed to logme will be written to the internal
// browser console.
// false - Suppress all output to the internal browser console.
//
// Obviously, if anywhere there is a direct console.log() call, we can't do
// anything about it. That's why use logme() - it will allow to turn off
// the output of debug information with a single change to a variable.
debugMode = true;
return logme;
/*
* function: logme
*
* A helper function that provides logging facilities. We don't want
* to call console.log() directly, because sometimes it is not supported
* by the browser. Also when everything is routed through this function.
* the logging output can be easily turned off.
*
* logme() supports multiple parameters. Each parameter will be passed to
* console.log() function separately.
*
*/
function logme() {
var i;
if (
(typeof debugMode === 'undefined') ||
(debugMode !== true) ||
(typeof window.console === 'undefined')
) {
return;
}
for (i = 0; i < arguments.length; i++) {
window.console.log(arguments[i]);
}
} // End-of: function logme
});
// End of wrapper for RequireJS. As you can see, we are passing
// namespaced Require JS variables to an anonymous function. Within
// it, you can use the standard requirejs(), require(), and define()
// functions as if they were in the global namespace.
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
// define() functions from Require JS available inside the anonymous function.
(function (requirejs, require, define) {
define('Sliders', ['logme'], function (logme) {
return Sliders;
function Sliders(gstId, state) {
var c1, paramName, allParamNames, sliderDiv;
allParamNames = state.getAllParameterNames();
for (c1 = 0; c1 < allParamNames.length; c1 += 1) {
paramName = allParamNames[c1];
sliderDiv = $('#' + gstId + '_slider_' + paramName);
if (sliderDiv.length === 1) {
createSlider(sliderDiv, paramName);
} else if (sliderDiv.length > 1) {
logme('ERROR: Found more than one slider for the parameter "' + paramName + '".');
logme('sliderDiv.length = ', sliderDiv.length);
} // else {
// logme('MESSAGE: Did not find a slider for the parameter "' + paramName + '".');
// }
}
function createSlider(sliderDiv, paramName) {
var paramObj;
paramObj = state.getParamObj(paramName);
// Check that the retrieval went OK.
if (paramObj === undefined) {
logme('ERROR: Could not get a paramObj for parameter "' + paramName + '".');
return;
}
// Create a jQuery UI slider from the slider DIV. We will set
// starting parameters, and will also attach a handler to update
// the 'state' on the 'slide' event.
sliderDiv.slider({
'min': paramObj.min,
'max': paramObj.max,
'value': paramObj.value,
'step': paramObj.step
});
// Tell the parameter object stored in state that we have a slider
// that is attached to it. Next time when the parameter changes, it
// will also update the value of this slider.
paramObj.sliderDiv = sliderDiv;
// Atach callbacks to update the slider's parameter.
paramObj.sliderDiv.on('slide', sliderOnSlide);
paramObj.sliderDiv.on('slidechange', sliderOnChange);
return;
// Update the 'state' - i.e. set the value of the parameter this
// slider is attached to to a new value.
//
// This will cause the plot to be redrawn each time after the user
// drags the slider handle and releases it.
function sliderOnSlide(event, ui) {
// Last parameter passed to setParameterValue() will be 'true'
// so that the function knows we are a slider, and it can
// change the our value back in the case when the new value is
// invalid for some reason.
if (state.setParameterValue(paramName, ui.value, sliderDiv, true, 'slide') === undefined) {
logme('ERROR: Could not update the parameter named "' + paramName + '" with the value "' + ui.value + '".');
}
}
function sliderOnChange(event, ui) {
if (state.setParameterValue(paramName, ui.value, sliderDiv, true, 'change') === undefined) {
logme('ERROR: Could not update the parameter named "' + paramName + '" with the value "' + ui.value + '".');
}
}
}
}
});
// End of wrapper for RequireJS. As you can see, we are passing
// namespaced Require JS variables to an anonymous function. Within
// it, you can use the standard requirejs(), require(), and define()
// functions as if they were in the global namespace.
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
class @SelfAssessment
constructor: (element) ->
@el = $(element).find('section.self-assessment')
@id = @el.data('id')
@ajax_url = @el.data('ajax-url')
@state = @el.data('state')
@allow_reset = @el.data('allow_reset')
# valid states: 'initial', 'assessing', 'request_hint', 'done'
# Where to put the rubric once we load it
@errors_area = @$('.error')
@answer_area = @$('textarea.answer')
@rubric_wrapper = @$('.rubric-wrapper')
@hint_wrapper = @$('.hint-wrapper')
@message_wrapper = @$('.message-wrapper')
@submit_button = @$('.submit-button')
@reset_button = @$('.reset-button')
@reset_button.click @reset
@find_assessment_elements()
@find_hint_elements()
@rebind()
# locally scoped jquery.
$: (selector) ->
$(selector, @el)
rebind: () =>
# rebind to the appropriate function for the current state
@submit_button.unbind('click')
@submit_button.show()
@reset_button.hide()
@hint_area.attr('disabled', false)
if @state == 'initial'
@answer_area.attr("disabled", false)
@submit_button.prop('value', 'Submit')
@submit_button.click @save_answer
else if @state == 'assessing'
@answer_area.attr("disabled", true)
@submit_button.prop('value', 'Submit assessment')
@submit_button.click @save_assessment
else if @state == 'request_hint'
@answer_area.attr("disabled", true)
@submit_button.prop('value', 'Submit hint')
@submit_button.click @save_hint
else if @state == 'done'
@answer_area.attr("disabled", true)
@hint_area.attr('disabled', true)
@submit_button.hide()
if @allow_reset
@reset_button.show()
else
@reset_button.hide()
find_assessment_elements: ->
@assessment = @$('select.assessment')
find_hint_elements: ->
@hint_area = @$('textarea.hint')
save_answer: (event) =>
event.preventDefault()
if @state == 'initial'
data = {'student_answer' : @answer_area.val()}
$.postWithPrefix "#{@ajax_url}/save_answer", data, (response) =>
if response.success
@rubric_wrapper.html(response.rubric_html)
@state = 'assessing'
@find_assessment_elements()
@rebind()
else
@errors_area.html(response.error)
else
@errors_area.html('Problem state got out of sync. Try reloading the page.')
save_assessment: (event) =>
event.preventDefault()
if @state == 'assessing'
data = {'assessment' : @assessment.find(':selected').text()}
$.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) =>
if response.success
@state = response.state
if @state == 'request_hint'
@hint_wrapper.html(response.hint_html)
@find_hint_elements()
else if @state == 'done'
@message_wrapper.html(response.message_html)
@allow_reset = response.allow_reset
@rebind()
else
@errors_area.html(response.error)
else
@errors_area.html('Problem state got out of sync. Try reloading the page.')
save_hint: (event) =>
event.preventDefault()
if @state == 'request_hint'
data = {'hint' : @hint_area.val()}
$.postWithPrefix "#{@ajax_url}/save_hint", data, (response) =>
if response.success
@message_wrapper.html(response.message_html)
@state = 'done'
@allow_reset = response.allow_reset
@rebind()
else
@errors_area.html(response.error)
else
@errors_area.html('Problem state got out of sync. Try reloading the page.')
reset: (event) =>
event.preventDefault()
if @state == 'done'
$.postWithPrefix "#{@ajax_url}/reset", {}, (response) =>
if response.success
@answer_area.val('')
@rubric_wrapper.html('')
@hint_wrapper.html('')
@message_wrapper.html('')
@state = 'initial'
@rebind()
@reset_button.hide()
else
@errors_area.html(response.error)
else
@errors_area.html('Problem state got out of sync. Try reloading the page.')
import copy
from fs.errors import ResourceNotFoundError
import itertools
import json
import logging
from lxml import etree
from lxml.html import rewrite_links
from path import path
import os
import sys
import hashlib
import capa.xqueue_interface as xqueue_interface
from pkg_resources import resource_string
from .capa_module import only_one, ComplexEncoder
from .editing_module import EditingDescriptor
from .html_checker import check_html
from progress import Progress
from .stringify import stringify_children
from .xml_module import XmlDescriptor
from xmodule.modulestore import Location
from capa.util import *
from datetime import datetime
log = logging.getLogger("mitx.courseware")
# Set the default number of max attempts. Should be 1 for production
# Set higher for debugging/testing
# attempts specified in xml definition overrides this.
MAX_ATTEMPTS = 1
# Set maximum available number of points.
# Overriden by max_score specified in xml.
MAX_SCORE = 1
class OpenEndedChild(object):
"""
States:
initial (prompt, textbox shown)
|
assessing (read-only textbox, rubric + assessment input shown for self assessment, response queued for open ended)
|
post_assessment (read-only textbox, read-only rubric and assessment, hint input box shown)
|
done (submitted msg, green checkmark, everything else read-only. If attempts < max, shows
a reset button that goes back to initial state. Saves previous
submissions too.)
"""
DEFAULT_QUEUE = 'open-ended'
DEFAULT_MESSAGE_QUEUE = 'open-ended-message'
max_inputfields = 1
STATE_VERSION = 1
# states
INITIAL = 'initial'
ASSESSING = 'assessing'
POST_ASSESSMENT = 'post_assessment'
DONE = 'done'
#This is used to tell students where they are at in the module
HUMAN_NAMES = {
'initial': 'Started',
'assessing': 'Being scored',
'post_assessment': 'Scoring finished',
'done': 'Problem complete',
}
def __init__(self, system, location, definition, descriptor, static_data,
instance_state=None, shared_state=None, **kwargs):
# Load instance state
if instance_state is not None:
instance_state = json.loads(instance_state)
else:
instance_state = {}
# History is a list of tuples of (answer, score, hint), where hint may be
# None for any element, and score and hint can be None for the last (current)
# element.
# Scores are on scale from 0 to max_score
self.history = instance_state.get('history', [])
self.state = instance_state.get('state', self.INITIAL)
self.created = instance_state.get('created', False)
self.attempts = instance_state.get('attempts', 0)
self.max_attempts = static_data['max_attempts']
self.prompt = static_data['prompt']
self.rubric = static_data['rubric']
# Used for progress / grading. Currently get credit just for
# completion (doesn't matter if you self-assessed correct/incorrect).
self._max_score = static_data['max_score']
self.setup_response(system, location, definition, descriptor)
def setup_response(self, system, location, definition, descriptor):
"""
Needs to be implemented by the inheritors of this module. Sets up additional fields used by the child modules.
@param system: Modulesystem
@param location: Module location
@param definition: XML definition
@param descriptor: Descriptor of the module
@return: None
"""
pass
def latest_answer(self):
"""None if not available"""
if not self.history:
return ""
return self.history[-1].get('answer', "")
def latest_score(self):
"""None if not available"""
if not self.history:
return None
return self.history[-1].get('score')
def latest_post_assessment(self, system):
"""None if not available"""
if not self.history:
return ""
return self.history[-1].get('post_assessment', "")
def new_history_entry(self, answer):
"""
Adds a new entry to the history dictionary
@param answer: The student supplied answer
@return: None
"""
self.history.append({'answer': answer})
def record_latest_score(self, score):
"""Assumes that state is right, so we're adding a score to the latest
history element"""
self.history[-1]['score'] = score
def record_latest_post_assessment(self, post_assessment):
"""Assumes that state is right, so we're adding a score to the latest
history element"""
self.history[-1]['post_assessment'] = post_assessment
def change_state(self, new_state):
"""
A centralized place for state changes--allows for hooks. If the
current state matches the old state, don't run any hooks.
"""
if self.state == new_state:
return
self.state = new_state
if self.state == self.DONE:
self.attempts += 1
def get_instance_state(self):
"""
Get the current score and state
"""
state = {
'version': self.STATE_VERSION,
'history': self.history,
'state': self.state,
'max_score': self._max_score,
'attempts': self.attempts,
'created': False,
}
return json.dumps(state)
def _allow_reset(self):
"""Can the module be reset?"""
return (self.state == self.DONE and self.attempts < self.max_attempts)
def max_score(self):
"""
Return max_score
"""
return self._max_score
def get_score(self):
"""
Returns the last score in the list
"""
score = self.latest_score()
return {'score': score if score is not None else 0,
'total': self._max_score}
def reset(self, system):
"""
If resetting is allowed, reset the state.
Returns {'success': bool, 'error': msg}
(error only present if not success)
"""
self.change_state(self.INITIAL)
return {'success': True}
def get_progress(self):
'''
For now, just return last score / max_score
'''
if self._max_score > 0:
try:
return Progress(self.get_score()['score'], self._max_score)
except Exception as err:
log.exception("Got bad progress")
return None
return None
def out_of_sync_error(self, get, msg=''):
"""
return dict out-of-sync error message, and also log.
"""
log.warning("Assessment module state out sync. state: %r, get: %r. %s",
self.state, get, msg)
return {'success': False,
'error': 'The problem state got out-of-sync'}
def get_html(self):
"""
Needs to be implemented by inheritors. Renders the HTML that students see.
@return:
"""
pass
def handle_ajax(self):
"""
Needs to be implemented by child modules. Handles AJAX events.
@return:
"""
pass
def is_submission_correct(self, score):
"""
Checks to see if a given score makes the answer correct. Very naive right now (>66% is correct)
@param score: Numeric score.
@return: Boolean correct.
"""
correct = False
if(isinstance(score, (int, long, float, complex))):
score_ratio = int(score) / float(self.max_score())
correct = (score_ratio >= 0.66)
return correct
def is_last_response_correct(self):
"""
Checks to see if the last response in the module is correct.
@return: 'correct' if correct, otherwise 'incorrect'
"""
score = self.get_score()['score']
correctness = 'correct' if self.is_submission_correct(score) else 'incorrect'
return correctness
import unittest import unittest
from time import strptime, gmtime from time import strptime
from fs.memoryfs import MemoryFS from fs.memoryfs import MemoryFS
from mock import Mock, patch from mock import Mock, patch
...@@ -39,52 +39,81 @@ class DummySystem(ImportSystem): ...@@ -39,52 +39,81 @@ class DummySystem(ImportSystem):
class IsNewCourseTestCase(unittest.TestCase): class IsNewCourseTestCase(unittest.TestCase):
"""Make sure the property is_new works on courses""" """Make sure the property is_new works on courses"""
@staticmethod @staticmethod
def get_dummy_course(start, is_new=None, load_error_modules=True): def get_dummy_course(start, announcement=None, is_new=None):
"""Get a dummy course""" """Get a dummy course"""
system = DummySystem(load_error_modules) system = DummySystem(load_error_modules=True)
is_new = '' if is_new is None else 'is_new="{0}"'.format(is_new).lower()
def to_attrb(n, v):
return '' if v is None else '{0}="{1}"'.format(n, v).lower()
is_new = to_attrb('is_new', is_new)
announcement = to_attrb('announcement', announcement)
start_xml = ''' start_xml = '''
<course org="{org}" course="{course}" <course org="{org}" course="{course}"
graceperiod="1 day" url_name="test" graceperiod="1 day" url_name="test"
start="{start}" start="{start}"
{announcement}
{is_new}> {is_new}>
<chapter url="hi" url_name="ch" display_name="CH"> <chapter url="hi" url_name="ch" display_name="CH">
<html url_name="h" display_name="H">Two houses, ...</html> <html url_name="h" display_name="H">Two houses, ...</html>
</chapter> </chapter>
</course> </course>
'''.format(org=ORG, course=COURSE, start=start, is_new=is_new) '''.format(org=ORG, course=COURSE, start=start, is_new=is_new,
announcement=announcement)
return system.process_xml(start_xml) return system.process_xml(start_xml)
@patch('xmodule.course_module.time.gmtime') @patch('xmodule.course_module.time.gmtime')
def test_non_started_yet(self, gmtime_mock): def test_sorting_score(self, gmtime_mock):
descriptor = self.get_dummy_course(start='2013-01-05T12:00')
gmtime_mock.return_value = NOW
assert(descriptor.is_new == True)
assert(descriptor.days_until_start == 4)
@patch('xmodule.course_module.time.gmtime')
def test_already_started(self, gmtime_mock):
gmtime_mock.return_value = NOW gmtime_mock.return_value = NOW
dates = [('2012-10-01T12:00', '2012-09-01T12:00'), # 0
('2012-12-01T12:00', '2012-11-01T12:00'), # 1
('2013-02-01T12:00', '2012-12-01T12:00'), # 2
('2013-02-01T12:00', '2012-11-10T12:00'), # 3
('2013-02-01T12:00', None), # 4
('2013-03-01T12:00', None), # 5
('2013-04-01T12:00', None), # 6
('2012-11-01T12:00', None), # 7
('2012-09-01T12:00', None), # 8
('1990-01-01T12:00', None), # 9
('2013-01-02T12:00', None), # 10
('2013-01-10T12:00', '2012-12-31T12:00'), # 11
('2013-01-10T12:00', '2013-01-01T12:00'), # 12
]
data = []
for i, d in enumerate(dates):
descriptor = self.get_dummy_course(start=d[0], announcement=d[1])
score = descriptor.sorting_score
data.append((score, i))
result = [d[1] for d in sorted(data)]
assert(result == [12, 11, 2, 3, 1, 0, 6, 5, 4, 10, 7, 8, 9])
descriptor = self.get_dummy_course(start='2012-12-02T12:00')
assert(descriptor.is_new == False)
assert(descriptor.days_until_start < 0)
@patch('xmodule.course_module.time.gmtime') @patch('xmodule.course_module.time.gmtime')
def test_is_new_set(self, gmtime_mock): def test_is_new(self, gmtime_mock):
gmtime_mock.return_value = NOW gmtime_mock.return_value = NOW
descriptor = self.get_dummy_course(start='2012-12-02T12:00', is_new=True) descriptor = self.get_dummy_course(start='2012-12-02T12:00', is_new=True)
assert(descriptor.is_new == True) assert(descriptor.is_new is True)
assert(descriptor.days_until_start < 0)
descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=False) descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=False)
assert(descriptor.is_new == False) assert(descriptor.is_new is False)
assert(descriptor.days_until_start > 0)
descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=True) descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=True)
assert(descriptor.is_new == True) assert(descriptor.is_new is True)
assert(descriptor.days_until_start > 0)
descriptor = self.get_dummy_course(start='2013-01-15T12:00')
assert(descriptor.is_new is True)
descriptor = self.get_dummy_course(start='2013-03-00T12:00')
assert(descriptor.is_new is True)
descriptor = self.get_dummy_course(start='2012-10-15T12:00')
assert(descriptor.is_new is False)
descriptor = self.get_dummy_course(start='2012-12-31T12:00')
assert(descriptor.is_new is True)
...@@ -39,9 +39,12 @@ def strip_filenames(descriptor): ...@@ -39,9 +39,12 @@ def strip_filenames(descriptor):
class RoundTripTestCase(unittest.TestCase): class RoundTripTestCase(unittest.TestCase):
'''Check that our test courses roundtrip properly''' ''' Check that our test courses roundtrip properly.
Same course imported , than exported, then imported again.
And we compare original import with second import (after export).
Thus we make sure that export and import work properly.
'''
def check_export_roundtrip(self, data_dir, course_dir): def check_export_roundtrip(self, data_dir, course_dir):
root_dir = path(mkdtemp()) root_dir = path(mkdtemp())
print "Copying test course to temp dir {0}".format(root_dir) print "Copying test course to temp dir {0}".format(root_dir)
...@@ -117,3 +120,11 @@ class RoundTripTestCase(unittest.TestCase): ...@@ -117,3 +120,11 @@ class RoundTripTestCase(unittest.TestCase):
def test_selfassessment_roundtrip(self): def test_selfassessment_roundtrip(self):
#Test selfassessment xmodule to see if it exports correctly #Test selfassessment xmodule to see if it exports correctly
self.check_export_roundtrip(DATA_DIR,"self_assessment") self.check_export_roundtrip(DATA_DIR,"self_assessment")
def test_graphicslidertool_roundtrip(self):
#Test graphicslidertool xmodule to see if it exports correctly
self.check_export_roundtrip(DATA_DIR,"graphic_slider_tool")
def test_exam_registration_roundtrip(self):
# Test exam_registration xmodule to see if it exports correctly
self.check_export_roundtrip(DATA_DIR,"test_exam_registration")
...@@ -339,16 +339,19 @@ class ImportTestCase(unittest.TestCase): ...@@ -339,16 +339,19 @@ class ImportTestCase(unittest.TestCase):
self.assertRaises(etree.XMLSyntaxError, system.process_xml, bad_xml) self.assertRaises(etree.XMLSyntaxError, system.process_xml, bad_xml)
def test_selfassessment_import(self):
def test_graphicslidertool_import(self):
''' '''
Check to see if definition_from_xml in self_assessment_module.py Check to see if definition_from_xml in gst_module.py
works properly. Pulls data from the self_assessment directory in the test data directory. works properly. Pulls data from the graphic_slider_tool directory
in the test data directory.
''' '''
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['graphic_slider_tool'])
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['self_assessment'])
sa_id = "edX/gst_test/2012_Fall"
sa_id = "edX/sa_test/2012_Fall" location = Location(["i4x", "edX", "gst_test", "graphical_slider_tool", "sample_gst"])
location = Location(["i4x", "edX", "sa_test", "selfassessment", "SampleQuestion"]) gst_sample = modulestore.get_instance(sa_id, location)
sa_sample = modulestore.get_instance(sa_id, location) render_string_from_sample_gst_xml = """
#10 attempts is hard coded into SampleQuestion, which is the url_name of a selfassessment xml tag <slider var="a" style="width:400px;float:left;"/>\
self.assertEqual(sa_sample.metadata['attempts'], '10') <plot style="margin-top:15px;margin-bottom:15px;"/>""".strip()
self.assertEqual(gst_sample.definition['render'], render_string_from_sample_gst_xml)
...@@ -4,6 +4,7 @@ import unittest ...@@ -4,6 +4,7 @@ import unittest
from xmodule.self_assessment_module import SelfAssessmentModule from xmodule.self_assessment_module import SelfAssessmentModule
from xmodule.modulestore import Location from xmodule.modulestore import Location
from lxml import etree
from . import test_system from . import test_system
...@@ -26,22 +27,37 @@ class SelfAssessmentTest(unittest.TestCase): ...@@ -26,22 +27,37 @@ class SelfAssessmentTest(unittest.TestCase):
state = json.dumps({'student_answers': ["Answer 1", "answer 2", "answer 3"], state = json.dumps({'student_answers': ["Answer 1", "answer 2", "answer 3"],
'scores': [0, 1], 'scores': [0, 1],
'hints': ['o hai'], 'hints': ['o hai'],
'state': SelfAssessmentModule.ASSESSING, 'state': SelfAssessmentModule.INITIAL,
'attempts': 2}) 'attempts': 2})
rubric = '''<rubric><rubric>
<category>
<description>Response Quality</description>
<option>The response is not a satisfactory answer to the question. It either fails to address the question or does so in a limited way, with no evidence of higher-order thinking.</option>
</category>
</rubric></rubric>'''
prompt = etree.XML("<prompt>Text</prompt>")
static_data = {
'max_attempts': 10,
'rubric': etree.XML(rubric),
'prompt': prompt,
'max_score': 1
}
module = SelfAssessmentModule(test_system, self.location, module = SelfAssessmentModule(test_system, self.location,
self.definition, self.descriptor, self.definition, self.descriptor,
state, {}, metadata=self.metadata) static_data, state, metadata=self.metadata)
self.assertEqual(module.get_score()['score'], 0) self.assertEqual(module.get_score()['score'], 0)
self.assertTrue('answer 3' in module.get_html())
self.assertFalse('answer 2' in module.get_html())
module.save_assessment({'assessment': '0'}) module.save_answer({'student_answer': "I am an answer"}, test_system)
self.assertEqual(module.state, module.REQUEST_HINT) self.assertEqual(module.state, module.ASSESSING)
module.save_hint({'hint': 'hint for ans 3'}) module.save_assessment({'assessment': '0'}, test_system)
self.assertEqual(module.state, module.POST_ASSESSMENT)
module.save_hint({'hint': 'this is a hint'}, test_system)
self.assertEqual(module.state, module.DONE) self.assertEqual(module.state, module.DONE)
d = module.reset({}) d = module.reset({})
...@@ -49,6 +65,6 @@ class SelfAssessmentTest(unittest.TestCase): ...@@ -49,6 +65,6 @@ class SelfAssessmentTest(unittest.TestCase):
self.assertEqual(module.state, module.INITIAL) self.assertEqual(module.state, module.INITIAL)
# if we now assess as right, skip the REQUEST_HINT state # if we now assess as right, skip the REQUEST_HINT state
module.save_answer({'student_answer': 'answer 4'}) module.save_answer({'student_answer': 'answer 4'}, test_system)
module.save_assessment({'assessment': '1'}) module.save_assessment({'assessment': '1'}, test_system)
self.assertEqual(module.state, module.DONE) self.assertEqual(module.state, module.DONE)
...@@ -7,8 +7,11 @@ TIME_FORMAT = "%Y-%m-%dT%H:%M" ...@@ -7,8 +7,11 @@ TIME_FORMAT = "%Y-%m-%dT%H:%M"
def parse_time(time_str): def parse_time(time_str):
""" """
Takes a time string in TIME_FORMAT, returns Takes a time string in TIME_FORMAT
it as a time_struct. Raises ValueError if the string is not in the right format.
Returns it as a time_struct.
Raises ValueError if the string is not in the right format.
""" """
return time.strptime(time_str, TIME_FORMAT) return time.strptime(time_str, TIME_FORMAT)
......
...@@ -440,7 +440,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): ...@@ -440,7 +440,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
'xqa_key', 'xqa_key',
# TODO: This is used by the XMLModuleStore to provide for locations for # TODO: This is used by the XMLModuleStore to provide for locations for
# static files, and will need to be removed when that code is removed # static files, and will need to be removed when that code is removed
'data_dir' 'data_dir',
# How many days early to show a course element to beta testers (float)
# intended to be set per-course, but can be overridden in for specific
# elements. Can be a float.
'days_early_for_beta'
) )
# cdodge: this is a list of metadata names which are 'system' metadata # cdodge: this is a list of metadata names which are 'system' metadata
...@@ -523,7 +527,8 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): ...@@ -523,7 +527,8 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
@property @property
def start(self): def start(self):
""" """
If self.metadata contains start, return it. Else return None. If self.metadata contains a valid start time, return it as a time struct.
Else return None.
""" """
if 'start' not in self.metadata: if 'start' not in self.metadata:
return None return None
...@@ -535,6 +540,19 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): ...@@ -535,6 +540,19 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
self.metadata['start'] = stringify_time(value) self.metadata['start'] = stringify_time(value)
@property @property
def days_early_for_beta(self):
"""
If self.metadata contains start, return the number, as a float. Else return None.
"""
if 'days_early_for_beta' not in self.metadata:
return None
try:
return float(self.metadata['days_early_for_beta'])
except ValueError:
return None
@property
def own_metadata(self): def own_metadata(self):
""" """
Return the metadata that is not inherited, but was defined on this module. Return the metadata that is not inherited, but was defined on this module.
...@@ -750,7 +768,8 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): ...@@ -750,7 +768,8 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
""" """
Parse an optional metadata key containing a time: if present, complain Parse an optional metadata key containing a time: if present, complain
if it doesn't parse. if it doesn't parse.
Return None if not present or invalid.
Returns a time_struct, or None if metadata key is not present or is invalid.
""" """
if key in self.metadata: if key in self.metadata:
try: try:
......
...@@ -94,13 +94,16 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -94,13 +94,16 @@ class XmlDescriptor(XModuleDescriptor):
'start', 'due', 'graded', 'display_name', 'url_name', 'hide_from_toc', 'start', 'due', 'graded', 'display_name', 'url_name', 'hide_from_toc',
'ispublic', # if True, then course is listed for all users; see 'ispublic', # if True, then course is listed for all users; see
'xqa_key', # for xqaa server access 'xqa_key', # for xqaa server access
# 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. # VS[compat] Remove once unused.
'name', 'slug') 'name', 'slug')
metadata_to_strip = ('data_dir', metadata_to_strip = ('data_dir',
# cdodge: @TODO: We need to figure out a way to export out 'tabs' and 'grading_policy' which is on the course # cdodge: @TODO: We need to figure out a way to export out 'tabs' and 'grading_policy' which is on the course
'tabs', 'grading_policy', 'is_draft', 'published_by', 'published_date', 'tabs', 'grading_policy', 'is_draft', 'published_by', 'published_date',
'discussion_blackouts', 'discussion_blackouts', 'testcenter_info',
# VS[compat] -- remove the below attrs once everything is in the CMS # VS[compat] -- remove the below attrs once everything is in the CMS
'course', 'org', 'url_name', 'filename') 'course', 'org', 'url_name', 'filename')
......
This is a very very simple course, useful for debugging graphical slider tool
code.
roots/2012_Fall.xml
\ No newline at end of file
<course>
<chapter url_name="Overview">
<graphical_slider_tool url_name="sample_gst"/>
</chapter>
</course>
<graphical_slider_tool>
<render>
<slider var='a' style="width:400px;float:left;"/><plot style="margin-top:15px;margin-bottom:15px;"/>
</render>
<configuration>
<parameters>
<param var="a" min="5" max="25" step="0.5" initial="12.5" />
</parameters>
<functions>
<function color="red">return Math.sqrt(a * a - x * x);</function>
<function color="red">return -Math.sqrt(a * a - x * x);</function>
</functions>
<plot>
<xrange>
<!-- dynamic range -->
<min>
return -a;
</min>
<max>
return a;
</max>
</xrange>
<num_points>1000</num_points>
<xticks>-30, 6, 30</xticks>
<yticks>-30, 6, 30</yticks>
</plot>
</configuration>
</graphical_slider_tool>
{
"course/2012_Fall": {
"graceperiod": "2 days 5 hours 59 minutes 59 seconds",
"start": "2015-07-17T12:00",
"display_name": "GST Test",
"graded": "false"
},
"chapter/Overview": {
"display_name": "Overview"
},
"graphical_slider_tool/sample_gst": {
"display_name": "Sample GST",
},
}
<course org="edX" course="gst_test" url_name="2012_Fall"/>
Simple course with test center exam information included in policy.json.
roots/2012_Fall.xml
\ No newline at end of file
<course>
<chapter url_name="Overview">
<videosequence url_name="Toy_Videos">
<html url_name="toylab"/>
<video url_name="Video_Resources" youtube="1.0:1bK-WdDi6Qw"/>
</videosequence>
<video url_name="Welcome" youtube="1.0:p2Q6BrNhdh8"/>
</chapter>
<chapter url_name="Ch2">
<html url_name="test_html">
<h2>Welcome</h2>
</html>
</chapter>
</course>
<b>Lab 2A: Superposition Experiment</b>
<p>Isn't the toy course great?</p>
<html filename="toylab.html"/>
\ No newline at end of file
{
"course/2012_Fall": {
"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"
},
"chapter/Ch2": {
"display_name": "Chapter 2",
"start": "2015-07-17T12:00"
},
"videosequence/Toy_Videos": {
"display_name": "Toy Videos",
"format": "Lecture Sequence"
},
"html/toylab": {
"display_name": "Toy lab"
},
"video/Video_Resources": {
"display_name": "Video Resources"
},
"video/Welcome": {
"display_name": "Welcome"
}
}
<course org="edX" course="test_start_date" url_name="2012_Fall"/>
\ No newline at end of file
...@@ -35,6 +35,43 @@ weights of 30, 10, 10, and 10 to the 4 problems, respectively. ...@@ -35,6 +35,43 @@ weights of 30, 10, 10, and 10 to the 4 problems, respectively.
Note that the default weight of a problem **is not 1.** The default weight of a Note that the default weight of a problem **is not 1.** The default weight of a
problem is the module's max_grade. problem is the module's max_grade.
If weighting is set, each problem is worth the number of points assigned, regardless of the number of responses it contains.
Consider a Homework section that contains two problems.
<problem display_name=”Problem 1”>
<numericalresponse> ... </numericalreponse>
</problem>
and
<problem display_name=”Problem 2”>
<numericalresponse> ... </numericalreponse>
<numericalresponse> ... </numericalreponse>
<numericalresponse> ... </numericalreponse>
</problem>
Without weighting, Problem 1 is worth 25% of the assignment, and Problem 2 is worth 75% of the assignment.
Weighting for the problems can be set in the policy.json file.
"problem/problem1": {
"weight": 2
},
"problem/problem2": {
"weight": 2
},
With the above weighting, Problems 1 and 2 are each worth 50% of the assignment.
Please note: When problems have weight, the point value is automatically included in the display name *except* when “weight”: 1.When “weight”: 1, no visual change occurs in the display name, leaving the point value open to interpretation to the student.
## Section Weighting ## Section Weighting
Once each section has a percentage score, we must total those sections into a Once each section has a percentage score, we must total those sections into a
......
Grades can be pushed to a remote gradebook, and course enrollment membership can be pulled from a remote gradebook. This file documents how to setup such a remote gradebook, and what the API should be for writing new remote gradebook "xservers".
1. Definitions
An "xserver" is a web-based server that is part of the MITx eco system. There are a number of "xserver" programs, including one which does python code grading, an xqueue server, and graders for other coding languages.
"Stellar" is the MIT on-campus gradebook system.
2. Setup
The remote gradebook xserver should be specified in the lms.envs configuration using
MITX_FEATURES[REMOTE_GRADEBOOK_URL]
Each course, in addition, should define the name of the gradebook being used. A class "section" may also be specified. This goes in the policy.json file, eg:
"remote_gradebook": {
"name" : "STELLAR:/project/mitxdemosite",
"section" : "r01"
},
3. The API for the remote gradebook xserver is an almost RESTful service model, which only employs POSTs, to the xserver url, with form data for the fields:
- submit: get-assignments, get-membership, post-grades, or get-sections
- gradebook: name of gradebook
- user: username of staff person initiating the request (for logging)
- section: (optional) name of section
The return body content should be a JSON string, of the format {'msg': message, 'data': data}. The message is displayed in the instructor dashboard.
The data is a list of dicts (associative arrays). Each dict should be key:value.
## For submit=post-grades:
A file is also posted, with the field name "datafile". This file is CSV format, with two columns, one being "External email" and the other being the name of the assignment (that column contains the grades for the assignment).
## For submit=get-assignments
data keys = "AssignmentName"
## For submit=get-membership
data keys = "email", "name", "section"
## For submit=get-sections
data keys = "SectionName"
...@@ -257,6 +257,7 @@ Supported fields at the course level: ...@@ -257,6 +257,7 @@ Supported fields at the course level:
* "tabs" -- have custom tabs in the courseware. See below for details on config. * "tabs" -- have custom tabs in the courseware. See below for details on config.
* "discussion_blackouts" -- An array of time intervals during which you want to disable a student's ability to create or edit posts in the forum. Moderators, Community TAs, and Admins are unaffected. You might use this during exam periods, but please be aware that the forum is often a very good place to catch mistakes and clarify points to students. The better long term solution would be to have better flagging/moderation mechanisms, but this is the hammer we have today. Format by example: [["2012-10-29T04:00", "2012-11-03T04:00"], ["2012-12-30T04:00", "2013-01-02T04:00"]] * "discussion_blackouts" -- An array of time intervals during which you want to disable a student's ability to create or edit posts in the forum. Moderators, Community TAs, and Admins are unaffected. You might use this during exam periods, but please be aware that the forum is often a very good place to catch mistakes and clarify points to students. The better long term solution would be to have better flagging/moderation mechanisms, but this is the hammer we have today. Format by example: [["2012-10-29T04:00", "2012-11-03T04:00"], ["2012-12-30T04:00", "2013-01-02T04:00"]]
* "show_calculator" (value "Yes" if desired) * "show_calculator" (value "Yes" if desired)
* "days_early_for_beta" -- number of days (floating point ok) early that students in the beta-testers group get to see course content. Can also be specified for any other course element, and overrides values set at higher levels.
* TODO: there are others * TODO: there are others
### Grading policy file contents ### Grading policy file contents
......
<vertical>
<graphical_slider_tool>
<render>
<h2>Graphic slider tool: Bar graph example.</h2>
<p>We can request the API to plot us a bar graph.</p>
<div style="clear:both">
<p style="width:60px;float:left;">a</p>
<slider var='a' style="width:400px;float:left;"/>
<textbox var='a' style="width:50px;float:left;margin-left:15px;"/>
<br /><br /><br />
<p style="width:60px;float:left;">b</p>
<slider var='b' style="width:400px;float:left;"/>
<textbox var='b' style="width:50px;float:left;margin-left:15px;"/>
</div>
<plot style="clear:left;"/>
</render>
<configuration>
<parameters>
<param var="a" min="-100" max="100" step="5" initial="25" />
<param var="b" min="-100" max="100" step="5" initial="50" />
</parameters>
<functions>
<function bar="true" color="blue" label="Men">
<![CDATA[if (((x>0.9) && (x<1.1)) || ((x>4.9) && (x<5.1))) { return Math.sin(a * 0.01 * Math.PI + 2.952 * x); }
else {return undefined;}]]>
</function>
<function bar="true" color="red" label="Women">
<![CDATA[if (((x>1.9) && (x<2.1)) || ((x>3.9) && (x<4.1))) { return Math.cos(b * 0.01 * Math.PI + 3.432 * x); }
else {return undefined;}]]>
</function>
<function bar="true" color="green" label="Other 1">
<![CDATA[if (((x>1.9) && (x<2.1)) || ((x>3.9) && (x<4.1))) { return Math.cos((b - 10 * a) * 0.01 * Math.PI + 3.432 * x); }
else {return undefined;}]]>
</function>
<function bar="true" color="yellow" label="Other 2">
<![CDATA[if (((x>1.9) && (x<2.1)) || ((x>3.9) && (x<4.1))) { return Math.cos((b + 7 * a) * 0.01 * Math.PI + 3.432 * x); }
else {return undefined;}]]>
</function>
</functions>
<plot>
<xrange><min>1</min><max>5</max></xrange>
<num_points>5</num_points>
<xticks>0, 0.5, 6</xticks>
<yticks>-1.5, 0.1, 1.5</yticks>
<xticks_names>
<![CDATA[
{
"1.5": "Single", "4.5": "Married"
}
]]>
</xticks_names>
<yticks_names>
<![CDATA[
{
"-1.0": "-100%", "-0.5": "-50%", "0.0": "0%", "0.5": "50%", "1.0": "100%"
}
]]>
</yticks_names>
<bar_width>0.4</bar_width>
</plot>
</configuration>
</graphical_slider_tool>
</vertical>
<vertical>
<graphical_slider_tool>
<render>
<h1>Graphic slider tool: Dynamic labels.</h1>
<p>There are two kinds of dynamic lables.
1) Dynamic changing values in graph legends.
2) Dynamic labels, which coordinates depend on parameters </p>
<p>a: <slider var="a"/></p>
<br/>
<p>b: <slider var="b"/></p>
<br/><br/>
<plot style="width:400px; height:400px;"/>
</render>
<configuration>
<parameters>
<param var="a" min="-10" max="10" step="1" initial="0" />
<param var="b" min="0" max="10" step="0.5" initial="5" />
</parameters>
<functions>
<function label="Value of a is: dyn_val_1">a * x + b</function>
<!-- dynamic values in legend -->
<function output="plot_label" el_id="dyn_val_1">a</function>
</functions>
<plot>
<xrange><min>0</min><max>30</max></xrange>
<num_points>10</num_points>
<xticks>0, 6, 30</xticks>
<yticks>-9, 1, 9</yticks>
<!-- custom labels with coordinates as any function of parameter -->
<moving_label text="Dynam_lbl 1" weight="bold">
<![CDATA[ {'x': 10, 'y': a};]]>
</moving_label>
<moving_label text="Dynam lbl 2" weight="bold">
<![CDATA[ {'x': -6, 'y': b};]]>
</moving_label>
</plot>
</configuration>
</graphical_slider_tool>
</vertical>
\ No newline at end of file
<vertical>
<graphical_slider_tool>
<render>
<h2>Graphic slider tool: Dynamic range and implicit functions.</h2>
<p>You can make x range (not ticks of x axis) of functions to depend on
parameter value. This can be useful when function domain depends
on parameter.</p>
<p>Also implicit functons like circle can be plotted as 2 separate
functions of same color.</p>
<div style="height:50px;">
<slider var='a' style="width:400px;float:left;"/>
<textbox var='a' style="float:left;width:60px;margin-left:15px;"/>
</div>
<plot style="margin-top:15px;margin-bottom:15px;"/>
</render>
<configuration>
<parameters>
<param var="a" min="5" max="25" step="0.5" initial="12.5" />
</parameters>
<functions>
<function color="red">Math.sqrt(a * a - x * x)</function>
<function color="red">-Math.sqrt(a * a - x * x)</function>
</functions>
<plot>
<xrange>
<!-- dynamic range -->
<min>-a</min>
<max>a</max>
</xrange>
<num_points>1000</num_points>
<xticks>-30, 6, 30</xticks>
<yticks>-30, 6, 30</yticks>
</plot>
</configuration>
</graphical_slider_tool>
</vertical>
<vertical>
<graphical_slider_tool>
<render>
<h2>Graphic slider tool: Output to DOM element.</h2>
<p>a + b = <span id="answer_span_1"></span></p>
<div style="clear:both">
<p style="float:left;margin-right:10px;">a</p>
<slider var='a' style="width:400px;float:left;"/>
<textbox var='a' style="width:50px;float:left;margin-left:15px;"/>
</div>
<div style="clear:both">
<p style="float:left;margin-right:10px;">b</p>
<slider var='b' style="width:400px;float:left;"/>
<textbox var='b' style="width:50px;float:left;margin-left:15px;"/>
</div>
<br/><br/><br/>
<plot/>
</render>
<configuration>
<parameters>
<param var="a" min="-10.0" max="10.0" step="0.1" initial="0" />
<param var="b" min="-10.0" max="10.0" step="0.1" initial="0" />
</parameters>
<functions>
<function output="element" el_id="answer_span_1">
function add(a, b, precision) {
var x = Math.pow(10, precision || 2);
return (Math.round(a * x) + Math.round(b * x)) / x;
}
return add(a, b, 5);
</function>
</functions>
</configuration>
</graphical_slider_tool>
</vertical>
<vertical>
<graphical_slider_tool>
<render>
<h2>Graphic slider tool: full example.</h2>
<p>
A simple equation
\(
y_1 = 10 \times b \times \frac{sin(a \times x) \times sin(b \times x)}{cos(b \times x) + 10}
\)
can be plotted.
</p>
<!-- make text and input or slider at the same line -->
<div>
<p style="float:left;"> Currently \(a\) is</p>
<!-- readonly input for a -->
<span id="a_readonly" style="width:50px; float:left; margin-left:10px;"/>
</div>
<!-- clear:left will make next text to begin from new line -->
<p style="clear:left"> This one
\(
y_2 = sin(a \times x)
\)
will be overlayed on top.
</p>
<div>
<p style="float:left;">Currently \(b\) is </p>
<textbox var="b" style="width:50px; float:left; margin-left:10px;"/>
</div>
<div style="clear:left;">
<p style="float:left;">To change \(a\) use:</p>
<slider var="a" style="width:400px;float:left;margin-left:10px;"/>
</div>
<div style="clear:left;">
<p style="float:left;">To change \(b\) use:</p>
<slider var="b" style="width:400px;float:left;margin-left:10px;"/>
</div>
<plot style='clear:left;width:600px;padding-top:15px;padding-bottom:20px;'/>
<div style="clear:left;height:50px;">
<p style="float:left;">Second input for b:</p>
<!-- editable input for b -->
<textbox var="b" style="color:red;width:60px;float:left;margin-left:10px;"/>
</div >
</render>
<configuration>
<parameters>
<param var="a" min="90" max="120" step="10" initial="100" />
<param var="b" min="120" max="200" step="2.3" initial="120" />
</parameters>
<functions>
<function color="#0000FF" line="false" dot="true" label="\(y_1\)">
return 10.0 * b * Math.sin(a * x) * Math.sin(b * x) / (Math.cos(b * x) + 10);
</function>
<function color="red" line="true" dot="false" label="\(y_2\)">
<!-- works w/o return, if function is single line -->
Math.sin(a * x);
</function>
<function color="#FFFF00" line="false" dot="false" label="unknown">
function helperFunc(c1) {
return c1 * c1 - a;
}
return helperFunc(x + 10 * a * b) + Math.sin(a - x);
</function>
<function output="element" el_id="a_readonly">a</function>
</functions>
<plot>
<xrange>
<min>return 0;</min>
<!-- works w/o return -->
<max>30</max>
</xrange>
<num_points>120</num_points>
<xticks>0, 3, 30</xticks>
<yticks>-1.5, 1.5, 13.5</yticks>
<xunits>\(cm\)</xunits>
<yunits>\(m\)</yunits>
</plot>
</configuration>
</graphical_slider_tool>
</vertical>
...@@ -14,7 +14,7 @@ Contents: ...@@ -14,7 +14,7 @@ Contents:
overview.rst overview.rst
common-lib.rst common-lib.rst
djangoapps.rst djangoapps.rst
xml_formats.rst
Indices and tables Indices and tables
================== ==================
......
XML formats of Inputtypes and Xmodule
=====================================
Contents:
.. toctree::
:maxdepth: 2
graphical_slider_tool.rst
\ No newline at end of file
...@@ -46,6 +46,7 @@ rake test_common/lib/xmodule || TESTS_FAILED=1 ...@@ -46,6 +46,7 @@ rake test_common/lib/xmodule || TESTS_FAILED=1
rake phantomjs_jasmine_lms || true rake phantomjs_jasmine_lms || true
rake phantomjs_jasmine_cms || TESTS_FAILED=1 rake phantomjs_jasmine_cms || TESTS_FAILED=1
rake phantomjs_jasmine_common/lib/xmodule || TESTS_FAILED=1 rake phantomjs_jasmine_common/lib/xmodule || TESTS_FAILED=1
rake coverage:xml coverage:html rake coverage:xml coverage:html
[ $TESTS_FAILED == '0' ] [ $TESTS_FAILED == '0' ]
......
...@@ -4,6 +4,7 @@ like DISABLE_START_DATES""" ...@@ -4,6 +4,7 @@ like DISABLE_START_DATES"""
import logging import logging
import time import time
from datetime import datetime, timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
...@@ -13,6 +14,8 @@ from xmodule.error_module import ErrorDescriptor ...@@ -13,6 +14,8 @@ from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.x_module import XModule, XModuleDescriptor from xmodule.x_module import XModule, XModuleDescriptor
from student.models import CourseEnrollmentAllowed
DEBUG_ACCESS = False DEBUG_ACCESS = False
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -138,6 +141,11 @@ def _has_access_course_desc(user, course, action): ...@@ -138,6 +141,11 @@ def _has_access_course_desc(user, course, action):
debug("Allow: in enrollment period") debug("Allow: in enrollment period")
return True return True
# if user is in CourseEnrollmentAllowed with right course_id then can also enroll
if user is not None and CourseEnrollmentAllowed:
if CourseEnrollmentAllowed.objects.filter(email=user.email, course_id=course.id):
return True
# otherwise, need staff access # otherwise, need staff access
return _has_staff_access_to_descriptor(user, course) return _has_staff_access_to_descriptor(user, course)
...@@ -176,11 +184,16 @@ def _has_access_course_desc(user, course, action): ...@@ -176,11 +184,16 @@ def _has_access_course_desc(user, course, action):
def _get_access_group_name_course_desc(course, action): def _get_access_group_name_course_desc(course, action):
''' '''
Return name of group which gives staff access to course. Only understands action = 'staff' Return name of group which gives staff access to course. Only understands action = 'staff' and 'instructor'
''' '''
if not action == 'staff': if action=='staff':
return [] return _course_staff_group_name(course.location)
return _course_staff_group_name(course.location) elif action=='instructor':
return _course_instructor_group_name(course.location)
return []
def _has_access_error_desc(user, descriptor, action, course_context): def _has_access_error_desc(user, descriptor, action, course_context):
...@@ -229,9 +242,10 @@ def _has_access_descriptor(user, descriptor, action, course_context=None): ...@@ -229,9 +242,10 @@ def _has_access_descriptor(user, descriptor, action, course_context=None):
# Check start date # Check start date
if descriptor.start is not None: if descriptor.start is not None:
now = time.gmtime() now = time.gmtime()
if now > descriptor.start: effective_start = _adjust_start_date_for_beta_testers(user, descriptor)
if now > effective_start:
# after start date, everyone can see it # after start date, everyone can see it
debug("Allow: now > start date") debug("Allow: now > effective start date")
return True return True
# otherwise, need staff access # otherwise, need staff access
return _has_staff_access_to_descriptor(user, descriptor, course_context) return _has_staff_access_to_descriptor(user, descriptor, course_context)
...@@ -353,6 +367,19 @@ def _course_staff_group_name(location, course_context=None): ...@@ -353,6 +367,19 @@ def _course_staff_group_name(location, course_context=None):
return 'staff_%s' % course_id return 'staff_%s' % course_id
def course_beta_test_group_name(location):
"""
Get the name of the beta tester group for a location. Right now, that's
beta_testers_COURSE.
location: something that can passed to Location.
"""
return 'beta_testers_{0}'.format(Location(location).course)
# nosetests thinks that anything with _test_ in the name is a test.
# Correct this (https://nose.readthedocs.org/en/latest/finding_tests.html)
course_beta_test_group_name.__test__ = False
def _course_instructor_group_name(location, course_context=None): def _course_instructor_group_name(location, course_context=None):
""" """
...@@ -390,6 +417,51 @@ def _has_global_staff_access(user): ...@@ -390,6 +417,51 @@ def _has_global_staff_access(user):
return False return False
def _adjust_start_date_for_beta_testers(user, descriptor):
"""
If user is in a beta test group, adjust the start date by the appropriate number of
days.
Arguments:
user: A django user. May be anonymous.
descriptor: the XModuleDescriptor the user is trying to get access to, with a
non-None start date.
Returns:
A time, in the same format as returned by time.gmtime(). Either the same as
start, or earlier for beta testers.
NOTE: number of days to adjust should be cached to avoid looking it up thousands of
times per query.
NOTE: For now, this function assumes that the descriptor's location is in the course
the user is looking at. Once we have proper usages and definitions per the XBlock
design, this should use the course the usage is in.
NOTE: If testing manually, make sure MITX_FEATURES['DISABLE_START_DATES'] = False
in envs/dev.py!
"""
if descriptor.days_early_for_beta is None:
# bail early if no beta testing is set up
return descriptor.start
user_groups = [g.name for g in user.groups.all()]
beta_group = course_beta_test_group_name(descriptor.location)
if beta_group in user_groups:
debug("Adjust start time: user in group %s", beta_group)
# time_structs don't support subtraction, so convert to datetimes,
# subtract, convert back.
# (fun fact: datetime(*a_time_struct[:6]) is the beautiful syntax for
# converting time_structs into datetimes)
start_as_datetime = datetime(*descriptor.start[:6])
delta = timedelta(descriptor.days_early_for_beta)
effective = start_as_datetime - delta
# ...and back to time_struct
return effective.timetuple()
return descriptor.start
def _has_instructor_access_to_location(user, location, course_context=None): def _has_instructor_access_to_location(user, location, course_context=None):
return _has_access_to_location(user, location, 'instructor', course_context) return _has_access_to_location(user, location, 'instructor', course_context)
......
...@@ -7,3 +7,8 @@ from django.contrib import admin ...@@ -7,3 +7,8 @@ from django.contrib import admin
from django.contrib.auth.models import User from django.contrib.auth.models import User
admin.site.register(StudentModule) admin.site.register(StudentModule)
admin.site.register(OfflineComputedGrade)
admin.site.register(OfflineComputedGradeLog)
...@@ -95,6 +95,7 @@ def course_image_url(course): ...@@ -95,6 +95,7 @@ def course_image_url(course):
path = StaticContent.get_url_path_from_location(loc) path = StaticContent.get_url_path_from_location(loc)
return path return path
def find_file(fs, dirs, filename): def find_file(fs, dirs, filename):
""" """
Looks for a filename in a list of dirs on a filesystem, in the specified order. Looks for a filename in a list of dirs on a filesystem, in the specified order.
...@@ -111,6 +112,7 @@ def find_file(fs, dirs, filename): ...@@ -111,6 +112,7 @@ def find_file(fs, dirs, filename):
return filepath return filepath
raise ResourceNotFoundError("Could not find {0}".format(filename)) raise ResourceNotFoundError("Could not find {0}".format(filename))
def get_course_about_section(course, section_key): def get_course_about_section(course, section_key):
""" """
This returns the snippet of html to be rendered on the course about page, This returns the snippet of html to be rendered on the course about page,
...@@ -256,4 +258,18 @@ def get_courses(user, domain=None): ...@@ -256,4 +258,18 @@ def get_courses(user, domain=None):
courses = [c for c in courses if has_access(user, c, 'see_exists')] courses = [c for c in courses if has_access(user, c, 'see_exists')]
courses = sorted(courses, key=lambda course:course.number) courses = sorted(courses, key=lambda course:course.number)
return courses
def sort_by_announcement(courses):
"""
Sorts a list of courses by their announcement date. If the date is
not available, sort them by their start date.
"""
# Sort courses by how far are they from they start day
key = lambda course: course.sorting_score
courses = sorted(courses, key=key)
return courses return courses
# -*- 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 'OfflineComputedGrade'
db.create_table('courseware_offlinecomputedgrade', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, db_index=True, blank=True)),
('updated', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)),
('gradeset', self.gf('django.db.models.fields.TextField')(null=True, blank=True)),
))
db.send_create_signal('courseware', ['OfflineComputedGrade'])
# Adding unique constraint on 'OfflineComputedGrade', fields ['user', 'course_id']
db.create_unique('courseware_offlinecomputedgrade', ['user_id', 'course_id'])
# Adding model 'OfflineComputedGradeLog'
db.create_table('courseware_offlinecomputedgradelog', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, db_index=True, blank=True)),
('seconds', self.gf('django.db.models.fields.IntegerField')(default=0)),
('nstudents', self.gf('django.db.models.fields.IntegerField')(default=0)),
))
db.send_create_signal('courseware', ['OfflineComputedGradeLog'])
def backwards(self, orm):
# Removing unique constraint on 'OfflineComputedGrade', fields ['user', 'course_id']
db.delete_unique('courseware_offlinecomputedgrade', ['user_id', 'course_id'])
# Deleting model 'OfflineComputedGrade'
db.delete_table('courseware_offlinecomputedgrade')
# Deleting model 'OfflineComputedGradeLog'
db.delete_table('courseware_offlinecomputedgradelog')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'courseware.offlinecomputedgrade': {
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'},
'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'}),
'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
},
'courseware.offlinecomputedgradelog': {
'Meta': {'object_name': 'OfflineComputedGradeLog'},
'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'}),
'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'})
},
'courseware.studentmodule': {
'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}),
'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}),
'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}),
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
}
}
complete_apps = ['courseware']
\ No newline at end of file
...@@ -177,3 +177,40 @@ class StudentModuleCache(object): ...@@ -177,3 +177,40 @@ class StudentModuleCache(object):
def append(self, student_module): def append(self, student_module):
self.cache.append(student_module) self.cache.append(student_module)
class OfflineComputedGrade(models.Model):
"""
Table of grades computed offline for a given user and course.
"""
user = models.ForeignKey(User, db_index=True)
course_id = models.CharField(max_length=255, db_index=True)
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
updated = models.DateTimeField(auto_now=True, db_index=True)
gradeset = models.TextField(null=True, blank=True) # grades, stored as JSON
class Meta:
unique_together = (('user', 'course_id'), )
def __unicode__(self):
return "[OfflineComputedGrade] %s: %s (%s) = %s" % (self.user, self.course_id, self.created, self.gradeset)
class OfflineComputedGradeLog(models.Model):
"""
Log of when offline grades are computed.
Use this to be able to show instructor when the last computed grades were done.
"""
class Meta:
ordering = ["-created"]
get_latest_by = "created"
course_id = models.CharField(max_length=255, db_index=True)
created = models.DateTimeField(auto_now_add=True, null=True, db_index=True)
seconds = models.IntegerField(default=0) # seconds elapsed for computation
nstudents = models.IntegerField(default=0)
def __unicode__(self):
return "[OCGLog] %s: %s" % (self.course_id, self.created)
...@@ -11,6 +11,7 @@ actually generates the CourseTab. ...@@ -11,6 +11,7 @@ actually generates the CourseTab.
from collections import namedtuple from collections import namedtuple
import logging import logging
import json
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -29,6 +30,10 @@ from xmodule.x_module import XModule ...@@ -29,6 +30,10 @@ from xmodule.x_module import XModule
from open_ended_grading.peer_grading_service import PeerGradingService
from open_ended_grading.staff_grading_service import StaffGradingService
from student.models import unique_id_for_user
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class InvalidTabsException(Exception): class InvalidTabsException(Exception):
...@@ -37,7 +42,10 @@ class InvalidTabsException(Exception): ...@@ -37,7 +42,10 @@ class InvalidTabsException(Exception):
""" """
pass pass
CourseTab = namedtuple('CourseTab', 'name link is_active') CourseTabBase = namedtuple('CourseTab', 'name link is_active has_img img')
def CourseTab(name, link, is_active, has_img=False, img=""):
return CourseTabBase(name, link, is_active, has_img, img)
# encapsulate implementation for a tab: # encapsulate implementation for a tab:
# - a validation function: takes the config dict and raises # - a validation function: takes the config dict and raises
...@@ -110,9 +118,48 @@ def _textbooks(tab, user, course, active_page): ...@@ -110,9 +118,48 @@ def _textbooks(tab, user, course, active_page):
def _staff_grading(tab, user, course, active_page): def _staff_grading(tab, user, course, active_page):
if has_access(user, course, 'staff'): if has_access(user, course, 'staff'):
link = reverse('staff_grading', args=[course.id]) link = reverse('staff_grading', args=[course.id])
return [CourseTab('Staff grading', link, active_page == "staff_grading")] staff_gs = StaffGradingService(settings.STAFF_GRADING_INTERFACE)
pending_grading=False
tab_name = "Staff grading"
img_path= ""
try:
notifications = json.loads(staff_gs.get_notifications(course.id))
if notifications['success']:
if notifications['staff_needs_to_grade']:
pending_grading=True
except:
#Non catastrophic error, so no real action
log.info("Problem with getting notifications from staff grading service.")
if pending_grading:
img_path = "/static/images/slider-handle.png"
tab = [CourseTab(tab_name, link, active_page == "staff_grading", pending_grading, img_path)]
return tab
return [] return []
def _peer_grading(tab, user, course, active_page):
if user.is_authenticated():
link = reverse('peer_grading', args=[course.id])
peer_gs = PeerGradingService(settings.PEER_GRADING_INTERFACE)
pending_grading=False
tab_name = "Peer grading"
img_path= ""
try:
notifications = json.loads(peer_gs.get_notifications(course.id,unique_id_for_user(user)))
if notifications['success']:
if notifications['student_needs_to_peer_grade']:
pending_grading=True
except:
#Non catastrophic error, so no real action
log.info("Problem with getting notifications from peer grading service.")
if pending_grading:
img_path = "/static/images/slider-handle.png"
tab = [CourseTab(tab_name, link, active_page == "peer_grading", pending_grading, img_path)]
return tab
return []
#### Validators #### Validators
...@@ -149,6 +196,7 @@ VALID_TAB_TYPES = { ...@@ -149,6 +196,7 @@ VALID_TAB_TYPES = {
'textbooks': TabImpl(null_validator, _textbooks), 'textbooks': TabImpl(null_validator, _textbooks),
'progress': TabImpl(need_name, _progress), 'progress': TabImpl(need_name, _progress),
'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab), 'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab),
'peer_grading': TabImpl(null_validator, _peer_grading),
'staff_grading': TabImpl(null_validator, _staff_grading), 'staff_grading': TabImpl(null_validator, _staff_grading),
} }
......
...@@ -19,7 +19,8 @@ from xmodule.modulestore.mongo import MongoModuleStore ...@@ -19,7 +19,8 @@ from xmodule.modulestore.mongo import MongoModuleStore
# Need access to internal func to put users in the right group # Need access to internal func to put users in the right group
from courseware import grades from courseware import grades
from courseware.access import _course_staff_group_name from courseware.access import (has_access, _course_staff_group_name,
course_beta_test_group_name)
from courseware.models import StudentModuleCache from courseware.models import StudentModuleCache
from student.models import Registration from student.models import Registration
...@@ -252,6 +253,7 @@ class PageLoader(ActivateLoginTestCase): ...@@ -252,6 +253,7 @@ class PageLoader(ActivateLoginTestCase):
n = 0 n = 0
num_bad = 0 num_bad = 0
all_ok = True all_ok = True
for descriptor in module_store.get_items( for descriptor in module_store.get_items(
Location(None, None, None, None, None)): Location(None, None, None, None, None)):
...@@ -289,13 +291,30 @@ class PageLoader(ActivateLoginTestCase): ...@@ -289,13 +291,30 @@ class PageLoader(ActivateLoginTestCase):
#print descriptor.__class__, descriptor.location #print descriptor.__class__, descriptor.location
resp = self.client.get(reverse('jump_to', resp = self.client.get(reverse('jump_to',
kwargs={'course_id': course_id, kwargs={'course_id': course_id,
'location': descriptor.location.url()})) 'location': descriptor.location.url()}), follow=True)
msg = str(resp.status_code) msg = str(resp.status_code)
if resp.status_code != 302: if resp.status_code != 200:
msg = "ERROR " + msg msg = "ERROR " + msg + ": " + descriptor.location.url()
all_ok = False
num_bad += 1
elif resp.redirect_chain[0][1] != 302:
msg = "ERROR on redirect from " + descriptor.location.url()
all_ok = False
num_bad += 1
# check content to make sure there were no rendering failures
content = resp.content
if content.find("this module is temporarily unavailable")>=0:
msg = "ERROR unavailable module "
all_ok = False all_ok = False
num_bad += 1 num_bad += 1
elif isinstance(descriptor, ErrorDescriptor):
msg = "ERROR error descriptor loaded: "
msg = msg + descriptor.definition['data']['error_msg']
all_ok = False
num_bad += 1
print msg print msg
self.assertTrue(all_ok) # fail fast self.assertTrue(all_ok) # fail fast
...@@ -512,6 +531,9 @@ class TestViewAuth(PageLoader): ...@@ -512,6 +531,9 @@ class TestViewAuth(PageLoader):
"""Check that enrollment periods work""" """Check that enrollment periods work"""
self.run_wrapped(self._do_test_enrollment_period) self.run_wrapped(self._do_test_enrollment_period)
def test_beta_period(self):
"""Check that beta-test access works"""
self.run_wrapped(self._do_test_beta_period)
def _do_test_dark_launch(self): def _do_test_dark_launch(self):
"""Actually do the test, relying on settings to be right.""" """Actually do the test, relying on settings to be right."""
...@@ -677,6 +699,38 @@ class TestViewAuth(PageLoader): ...@@ -677,6 +699,38 @@ class TestViewAuth(PageLoader):
self.unenroll(self.toy) self.unenroll(self.toy)
self.assertTrue(self.try_enroll(self.toy)) self.assertTrue(self.try_enroll(self.toy))
def _do_test_beta_period(self):
"""Actually test beta periods, relying on settings to be right."""
# trust, but verify :)
self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES'])
# Make courses start in the future
tomorrow = time.time() + 24 * 3600
nextday = tomorrow + 24 * 3600
yesterday = time.time() - 24 * 3600
# toy course's hasn't started
self.toy.metadata['start'] = stringify_time(time.gmtime(tomorrow))
self.assertFalse(self.toy.has_started())
# but should be accessible for beta testers
self.toy.metadata['days_early_for_beta'] = '2'
# student user shouldn't see it
student_user = user(self.student)
self.assertFalse(has_access(student_user, self.toy, 'load'))
# now add the student to the beta test group
group_name = course_beta_test_group_name(self.toy.location)
g = Group.objects.create(name=group_name)
g.user_set.add(student_user)
# now the student should see it
self.assertTrue(has_access(student_user, self.toy, 'load'))
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
class TestCourseGrader(PageLoader): class TestCourseGrader(PageLoader):
"""Check that a course gets graded properly""" """Check that a course gets graded properly"""
......
...@@ -17,7 +17,8 @@ from django.views.decorators.cache import cache_control ...@@ -17,7 +17,8 @@ from django.views.decorators.cache import cache_control
from courseware import grades from courseware import grades
from courseware.access import has_access from courseware.access import has_access
from courseware.courses import (get_courses, get_course_with_access, get_courses_by_university) from courseware.courses import (get_courses, get_course_with_access,
get_courses_by_university, sort_by_announcement)
import courseware.tabs as tabs import courseware.tabs as tabs
from courseware.models import StudentModuleCache from courseware.models import StudentModuleCache
from module_render import toc_for_course, get_module, get_instance_module, get_module_for_descriptor from module_render import toc_for_course, get_module, get_instance_module, get_module_for_descriptor
...@@ -67,11 +68,8 @@ def courses(request): ...@@ -67,11 +68,8 @@ def courses(request):
''' '''
Render "find courses" page. The course selection work is done in courseware.courses. Render "find courses" page. The course selection work is done in courseware.courses.
''' '''
courses = get_courses(request.user, domain=request.META.get('HTTP_HOST')) courses = get_courses(request.user, request.META.get('HTTP_HOST'))
courses = sort_by_announcement(courses)
# Sort courses by how far are they from they start day
key = lambda course: course.days_until_start
courses = sorted(courses, key=key, reverse=True)
return render_to_response("courseware/courses.html", {'courses': courses}) return render_to_response("courseware/courses.html", {'courses': courses})
...@@ -437,10 +435,7 @@ def university_profile(request, org_id): ...@@ -437,10 +435,7 @@ def university_profile(request, org_id):
# Only grab courses for this org... # Only grab courses for this org...
courses = get_courses_by_university(request.user, courses = get_courses_by_university(request.user,
domain=request.META.get('HTTP_HOST'))[org_id] domain=request.META.get('HTTP_HOST'))[org_id]
courses = sort_by_announcement(courses)
# Sort courses by how far are they from they start day
key = lambda course: course.days_until_start
courses = sorted(courses, key=key, reverse=True)
context = dict(courses=courses, org_id=org_id) context = dict(courses=courses, org_id=org_id)
template_file = "university_profile/{0}.html".format(org_id).lower() template_file = "university_profile/{0}.html".format(org_id).lower()
......
...@@ -2,9 +2,11 @@ import logging ...@@ -2,9 +2,11 @@ import logging
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from student.models import CourseEnrollment
from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.db.models.signals import post_save
from student.models import CourseEnrollment
from courseware.courses import get_course_by_id from courseware.courses import get_course_by_id
...@@ -60,3 +62,14 @@ class Permission(models.Model): ...@@ -60,3 +62,14 @@ class Permission(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
@receiver(post_save, sender=CourseEnrollment)
def assign_default_role(sender, instance, **kwargs):
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))
instance.user.roles.add(role)
#!/usr/bin/python
#
# django management command: dump grades to csv files
# for use by batch processes
import os, sys, string
import datetime
import json
#import student.models
from instructor.offline_gradecalc import *
from courseware.courses import get_course_by_id
from xmodule.modulestore.django import modulestore
from django.conf import settings
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Compute grades for all students in a course, and store result in DB.\n"
help += "Usage: compute_grades course_id_or_dir \n"
help += " course_id_or_dir: either course_id or course_dir\n"
help += 'Example course_id: MITx/8.01rq_MW/Classical_Mechanics_Reading_Questions_Fall_2012_MW_Section'
def handle(self, *args, **options):
print "args = ", args
if len(args)>0:
course_id = args[0]
else:
print self.help
return
try:
course = get_course_by_id(course_id)
except Exception as err:
if course_id in modulestore().courses:
course = modulestore().courses[course_id]
else:
print "-----------------------------------------------------------------------------"
print "Sorry, cannot find course %s" % course_id
print "Please provide a course ID or course data directory name, eg content-mit-801rq"
return
print "-----------------------------------------------------------------------------"
print "Computing grades for %s" % (course.id)
offline_grade_calculation(course.id)
# ======== Offline calculation of grades =============================================================================
#
# Computing grades of a large number of students can take a long time. These routines allow grades to
# be computed offline, by a batch process (eg cronjob).
#
# The grades are stored in the OfflineComputedGrade table of the courseware model.
import json
import logging
import time
import courseware.models
from collections import namedtuple
from json import JSONEncoder
from courseware import grades, models
from courseware.courses import get_course_by_id
from django.contrib.auth.models import User, Group
class MyEncoder(JSONEncoder):
def _iterencode(self, obj, markers=None):
if isinstance(obj, tuple) and hasattr(obj, '_asdict'):
gen = self._iterencode_dict(obj._asdict(), markers)
else:
gen = JSONEncoder._iterencode(self, obj, markers)
for chunk in gen:
yield chunk
def offline_grade_calculation(course_id):
'''
Compute grades for all students for a specified course, and save results to the DB.
'''
tstart = time.time()
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).prefetch_related("groups").order_by('username')
enc = MyEncoder()
class DummyRequest(object):
META = {}
def __init__(self):
return
def get_host(self):
return 'edx.mit.edu'
def is_secure(self):
return False
request = DummyRequest()
print "%d enrolled students" % len(enrolled_students)
course = get_course_by_id(course_id)
for student in enrolled_students:
gradeset = grades.grade(student, request, course, keep_raw_scores=True)
gs = enc.encode(gradeset)
ocg, created = models.OfflineComputedGrade.objects.get_or_create(user=student, course_id=course_id)
ocg.gradeset = gs
ocg.save()
print "%s done" % student # print statement used because this is run by a management command
tend = time.time()
dt = tend - tstart
ocgl = models.OfflineComputedGradeLog(course_id=course_id, seconds=dt, nstudents=len(enrolled_students))
ocgl.save()
print ocgl
print "All Done!"
def offline_grades_available(course_id):
'''
Returns False if no offline grades available for specified course.
Otherwise returns latest log field entry about the available pre-computed grades.
'''
ocgl = models.OfflineComputedGradeLog.objects.filter(course_id=course_id)
if not ocgl:
return False
return ocgl.latest('created')
def student_grades(student, request, course, keep_raw_scores=False, use_offline=False):
'''
This is the main interface to get grades. It has the same parameters as grades.grade, as well
as use_offline. If use_offline is True then this will look for an offline computed gradeset in the DB.
'''
if not use_offline:
return grades.grade(student, request, course, keep_raw_scores=keep_raw_scores)
try:
ocg = models.OfflineComputedGrade.objects.get(user=student, course_id=course.id)
except models.OfflineComputedGrade.DoesNotExist:
return dict(raw_scores=[], section_breakdown=[],
msg='Error: no offline gradeset available for %s, %s' % (student, course.id))
return json.loads(ocg.gradeset)
...@@ -180,7 +180,7 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader): ...@@ -180,7 +180,7 @@ class TestInstructorDashboardForumAdmin(ct.PageLoader):
self.assertTrue(response.content.find('Removed "{0}" from "{1}" forum role = "{2}"'.format(username, course.id, rolename))>=0) self.assertTrue(response.content.find('Removed "{0}" from "{1}" forum role = "{2}"'.format(username, course.id, rolename))>=0)
self.assertFalse(has_forum_access(username, course.id, rolename)) self.assertFalse(has_forum_access(username, course.id, rolename))
def test_add_and_readd_forum_admin_users(self): def test_add_and_read_forum_admin_users(self):
course = self.toy course = self.toy
self.initialize_roles(course.id) self.initialize_roles(course.id)
url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
......
...@@ -62,6 +62,7 @@ class GradingService(object): ...@@ -62,6 +62,7 @@ class GradingService(object):
""" """
Make a get request to the grading controller Make a get request to the grading controller
""" """
log.debug(params)
op = lambda: self.session.get(url, op = lambda: self.session.get(url,
allow_redirects=allow_redirects, allow_redirects=allow_redirects,
params=params) params=params)
......
...@@ -79,9 +79,10 @@ class PeerGradingService(GradingService): ...@@ -79,9 +79,10 @@ class PeerGradingService(GradingService):
self.show_calibration_essay_url = self.url + '/show_calibration_essay/' self.show_calibration_essay_url = self.url + '/show_calibration_essay/'
self.save_calibration_essay_url = self.url + '/save_calibration_essay/' self.save_calibration_essay_url = self.url + '/save_calibration_essay/'
self.get_problem_list_url = self.url + '/get_problem_list/' self.get_problem_list_url = self.url + '/get_problem_list/'
self.get_notifications_url = self.url + '/get_notifications/'
def get_next_submission(self, problem_location, grader_id): def get_next_submission(self, problem_location, grader_id):
response = self.get(self.get_next_submission_url, False, response = self.get(self.get_next_submission_url,
{'location': problem_location, 'grader_id': grader_id}) {'location': problem_location, 'grader_id': grader_id})
return response return response
...@@ -116,6 +117,11 @@ class PeerGradingService(GradingService): ...@@ -116,6 +117,11 @@ class PeerGradingService(GradingService):
response = self.get(self.get_problem_list_url, params) response = self.get(self.get_problem_list_url, params)
return response return response
def get_notifications(self, course_id, grader_id):
params = {'course_id': course_id, 'student_id': grader_id}
response = self.get(self.get_notifications_url, params)
return response
_service = None _service = None
def peer_grading_service(): def peer_grading_service():
......
...@@ -66,6 +66,7 @@ class StaffGradingService(GradingService): ...@@ -66,6 +66,7 @@ class StaffGradingService(GradingService):
self.get_next_url = self.url + '/get_next_submission/' self.get_next_url = self.url + '/get_next_submission/'
self.save_grade_url = self.url + '/save_grade/' self.save_grade_url = self.url + '/save_grade/'
self.get_problem_list_url = self.url + '/get_problem_list/' self.get_problem_list_url = self.url + '/get_problem_list/'
self.get_notifications_url = self.url + "/get_notifications/"
def get_problem_list(self, course_id, grader_id): def get_problem_list(self, course_id, grader_id):
...@@ -132,6 +133,12 @@ class StaffGradingService(GradingService): ...@@ -132,6 +133,12 @@ class StaffGradingService(GradingService):
return self.post(self.save_grade_url, data=data) return self.post(self.save_grade_url, data=data)
def get_notifications(self, course_id):
params = {'course_id': course_id}
response = self.get(self.get_notifications_url, params)
return response
# don't initialize until staff_grading_service() is called--means that just # don't initialize until staff_grading_service() is called--means that just
# importing this file doesn't create objects that may not have the right config # importing this file doesn't create objects that may not have the right config
_service = None _service = None
......
...@@ -339,6 +339,11 @@ STAFF_GRADING_INTERFACE = { ...@@ -339,6 +339,11 @@ STAFF_GRADING_INTERFACE = {
# Used for testing, debugging # Used for testing, debugging
MOCK_STAFF_GRADING = False MOCK_STAFF_GRADING = False
################################# Pearson TestCenter config ################
PEARSONVUE_SIGNINPAGE_URL = "https://www1.pearsonvue.com/testtaker/signin/SignInPage/EDX"
# TESTCENTER_ACCOMMODATION_REQUEST_EMAIL = "exam-help@edx.org"
################################# Peer grading config ##################### ################################# Peer grading config #####################
#By setting up the default settings with an incorrect user name and password, #By setting up the default settings with an incorrect user name and password,
...@@ -420,6 +425,7 @@ courseware_js = ( ...@@ -420,6 +425,7 @@ courseware_js = (
main_vendor_js = [ main_vendor_js = [
'js/vendor/RequireJS.js', 'js/vendor/RequireJS.js',
'js/vendor/json2.js', 'js/vendor/json2.js',
'js/vendor/RequireJS.js',
'js/vendor/jquery.min.js', 'js/vendor/jquery.min.js',
'js/vendor/jquery-ui.min.js', 'js/vendor/jquery-ui.min.js',
'js/vendor/jquery.cookie.js', 'js/vendor/jquery.cookie.js',
......
...@@ -106,6 +106,10 @@ VIRTUAL_UNIVERSITIES = [] ...@@ -106,6 +106,10 @@ VIRTUAL_UNIVERSITIES = []
COMMENTS_SERVICE_KEY = "PUT_YOUR_API_KEY_HERE" COMMENTS_SERVICE_KEY = "PUT_YOUR_API_KEY_HERE"
################################# mitx revision string #####################
MITX_VERSION_STRING = os.popen('cd %s; git describe' % REPO_ROOT).read().strip()
################################# Staff grading config ##################### ################################# Staff grading config #####################
STAFF_GRADING_INTERFACE = { STAFF_GRADING_INTERFACE = {
......
...@@ -318,7 +318,7 @@ class StaffGrading ...@@ -318,7 +318,7 @@ class StaffGrading
problem_link:(problem) -> problem_link:(problem) ->
link = $('<a>').attr('href', "javascript:void(0)").append( link = $('<a>').attr('href', "javascript:void(0)").append(
"#{problem.problem_name} (#{problem.num_graded} graded, #{problem.num_pending} pending)") "#{problem.problem_name} (#{problem.num_graded} graded, #{problem.num_pending} pending, required to grade #{problem.num_required} more)")
.click => .click =>
@get_next_submission problem.location @get_next_submission problem.location
......
$(document).ready(function() {
var open_question = "";
var question_id;
$('.response').click(function(){
$(this).toggleClass('opened');
answer = $(this).find(".answer");
answer.slideToggle('fast');
});
});
...@@ -19,6 +19,7 @@ ...@@ -19,6 +19,7 @@
@import 'multicourse/home'; @import 'multicourse/home';
@import 'multicourse/dashboard'; @import 'multicourse/dashboard';
@import 'multicourse/testcenter-register';
@import 'multicourse/courses'; @import 'multicourse/courses';
@import 'multicourse/course_about'; @import 'multicourse/course_about';
@import 'multicourse/jobs'; @import 'multicourse/jobs';
......
...@@ -267,13 +267,12 @@ ...@@ -267,13 +267,12 @@
} }
.my-course { .my-course {
@include border-radius(3px); clear: both;
@include box-shadow(0 1px 8px 0 rgba(0,0,0, 0.1), inset 0 -1px 0 0 rgba(255,255,255, 0.8), inset 0 1px 0 0 rgba(255,255,255, 0.8));
@include clearfix; @include clearfix;
height: 120px;
margin-right: flex-gutter(); margin-right: flex-gutter();
margin-bottom: 10px; margin-bottom: 50px;
overflow: hidden; padding-bottom: 50px;
border-bottom: 1px solid $light-gray;
position: relative; position: relative;
width: flex-grid(12); width: flex-grid(12);
z-index: 20; z-index: 20;
...@@ -283,13 +282,7 @@ ...@@ -283,13 +282,7 @@
margin-bottom: none; margin-bottom: none;
} }
.cover { .cover {
background: rgb(225,225,225);
background-size: cover;
background-position: center center;
border: 1px solid rgb(120,120,120);
@include border-left-radius(3px);
@include box-shadow(inset 0 0 0 1px rgba(255,255,255, 0.6), 1px 0 0 0 rgba(255,255,255, 0.8));
@include box-sizing(border-box); @include box-sizing(border-box);
float: left; float: left;
height: 100%; height: 100%;
...@@ -299,100 +292,51 @@ ...@@ -299,100 +292,51 @@
position: relative; position: relative;
@include transition(all, 0.15s, linear); @include transition(all, 0.15s, linear);
width: 200px; width: 200px;
height: 120px;
.shade { img {
@include background-image(linear-gradient(-90deg, rgba(255,255,255, 0.3) 0%,
rgba(0,0,0, 0.3) 100%));
bottom: 0px;
content: "";
display: block;
left: 0px;
position: absolute;
z-index: 50;
top: 0px;
@include transition(all, 0.15s, linear);
right: 0px;
}
.arrow {
position: absolute;
z-index: 100;
width: 100%; width: 100%;
font-size: 70px;
line-height: 110px;
text-align: center;
text-decoration: none;
color: rgba(0, 0, 0, .7);
opacity: 0;
@include transition(all, 0.15s, linear);
}
&:hover {
.shade {
background: rgba(255,255,255, 0.3);
@include background-image(linear-gradient(-90deg, rgba(255,255,255, 0.3) 0%,
rgba(0,0,0, 0.3) 100%));
}
} }
} }
.info { .info {
background: rgb(250,250,250); @include clearfix;
@include background-image(linear-gradient(-90deg, rgb(253,253,253), rgb(240,240,240))); padding: 0 10px 0 230px;
@include box-sizing(border-box);
border: 1px solid rgb(190,190,190);
border-left: none;
@include border-right-radius(3px);
left: 201px;
height: 100%;
max-height: 100%;
padding: 0px 10px;
position: absolute;
right: 0px;
top: 0px;
z-index: 2;
> hgroup { > hgroup {
@include clearfix; padding: 0;
border-bottom: 1px solid rgb(210,210,210);
@include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6));
padding: 12px 0px;
width: 100%; width: 100%;
.university { .university {
background: rgba(255,255,255, 1);
border: 1px solid rgb(180,180,180);
@include border-radius(3px);
@include box-shadow(inset 0 0 3px 0 rgba(0,0,0, 0.2), 0 1px 0 0 rgba(255,255,255, 0.6));
color: $lighter-base-font-color; color: $lighter-base-font-color;
display: block;
font-style: italic;
font-family: $sans-serif; font-family: $sans-serif;
font-size: 16px; font-size: 16px;
font-weight: 800; font-weight: 400;
@include inline-block; margin: 0 0 6px;
margin-right: 10px; text-transform: none;
margin-bottom: 0; letter-spacing: 0;
padding: 5px 10px;
float: left;
} }
h3 { .date-block {
display: block; position: absolute;
margin-bottom: 0px; top: 0;
overflow: hidden; right: 0;
padding-top: 2px; font-family: $sans-serif;
text-overflow: ellipsis; font-size: 13px;
white-space: nowrap; font-style: italic;
color: $lighter-base-font-color;
}
a { h3 a {
color: $base-font-color; display: block;
font-weight: 700; margin-bottom: 10px;
text-shadow: 0 1px rgba(255,255,255, 0.6); font-family: $sans-serif;
font-size: 34px;
line-height: 42px;
font-weight: 300;
&:hover { &:hover {
text-decoration: underline; text-decoration: none;
}
} }
} }
} }
...@@ -430,91 +374,83 @@ ...@@ -430,91 +374,83 @@
} }
.enter-course { .enter-course {
@include button(shiny, $blue); @include button(simple, $blue);
@include box-sizing(border-box); @include box-sizing(border-box);
@include border-radius(3px); @include border-radius(3px);
display: block; display: block;
float: left; float: left;
font: normal 1rem/1.6rem $sans-serif; font: normal 15px/1.6rem $sans-serif;
letter-spacing: 1px; letter-spacing: 0;
padding: 6px 0px; padding: 6px 32px 7px;
text-transform: uppercase;
text-align: center; text-align: center;
margin-top: 16px; margin-top: 16px;
width: flex-grid(4);
}
}
> a:hover {
.cover {
.shade {
background: rgba(255,255,255, 0.1);
@include background-image(linear-gradient(-90deg, rgba(255,255,255, 0.3) 0%,
rgba(0,0,0, 0.3) 100%));
}
.arrow {
opacity: 1;
}
}
.info { &.archived {
background: darken(rgb(250,250,250), 5%); @include button(simple, #eee);
@include background-image(linear-gradient(-90deg, darken(rgb(253,253,253), 3%), darken(rgb(240,240,240), 5%))); font: normal 15px/1.6rem $sans-serif;
border-color: darken(rgb(190,190,190), 10%); padding: 6px 32px 7px;
.course-status { &:hover {
background: darken($yellow, 3%); text-decoration: none;
border-color: darken(rgb(200,200,200), 3%); }
@include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6));
} }
.course-status-completed { &:hover {
background: #888; text-decoration: none;
color: #fff;
} }
} }
} }
} }
.message-status { .message-status {
@include clearfix;
@include border-radius(3px); @include border-radius(3px);
@include box-shadow(0 1px 4px 0 rgba(0,0,0, 0.1), inset 0 -1px 0 0 rgba(255,255,255, 0.8), inset 0 1px 0 0 rgba(255,255,255, 0.8));
display: none; display: none;
position: relative;
top: -15px;
z-index: 10; z-index: 10;
margin: 0 0 20px 0; margin: 20px 0 10px;
padding: 15px 20px; padding: 15px 20px;
font-family: "Open Sans", Verdana, Geneva, sans-serif; font-family: $sans-serif;
background: #fffcf0; background: tint($yellow,70%);
border: 1px solid #ccc; border: 1px solid #ccc;
.message-copy { .message-copy {
font-family: $sans-serif;
font-size: 13px;
margin: 0; margin: 0;
a {
font-family: $sans-serif;
}
.grade-value { .grade-value {
font-size: 1.4rem; font-size: 1.2rem;
font-weight: bold; font-weight: bold;
} }
strong {
font-weight: 700;
a {
font-weight: 700;
}
}
} }
.actions { .actions {
@include clearfix; @include clearfix;
list-style: none; list-style: none;
margin: 15px 0 0 0; margin: 0;
padding: 0; padding: 0;
.action { .action {
float: left; float: left;
margin:0 15px 10px 0; margin: 0 15px 0 0;
.btn, .cta { .btn, .cta {
display: inline-block; display: inline-block;
} }
.btn { .btn {
@include button(shiny, $blue);
@include box-sizing(border-box); @include box-sizing(border-box);
@include border-radius(3px); @include border-radius(3px);
float: left; float: left;
...@@ -524,7 +460,6 @@ ...@@ -524,7 +460,6 @@
text-align: center; text-align: center;
&.disabled { &.disabled {
@include button(shiny, #eee);
cursor: default !important; cursor: default !important;
&:hover { &:hover {
...@@ -539,7 +474,6 @@ ...@@ -539,7 +474,6 @@
} }
.cta { .cta {
@include button(shiny, #666);
float: left; float: left;
font: normal 0.8rem/1.2rem $sans-serif; font: normal 0.8rem/1.2rem $sans-serif;
letter-spacing: 1px; letter-spacing: 1px;
...@@ -549,6 +483,52 @@ ...@@ -549,6 +483,52 @@
} }
} }
.exam-registration-number {
font-family: $sans-serif;
font-size: 18px;
a {
font-family: $sans-serif;
}
}
&.exam-register {
.message-copy {
margin-top: 5px;
width: 55%;
}
}
&.exam-schedule {
.exam-button {
margin-top: 5px;
}
}
.exam-button {
@include button(simple, $pink);
margin-top: 0;
float: right;
}
.contact-button {
@include button(simple, $pink);
}
.button {
display: inline-block;
margin-top: 10px;
padding: 9px 18px 10px;
font-size: 13px;
font-weight: bold;
letter-spacing: 0;
&:hover {
text-decoration: none;
}
}
&.is-shown { &.is-shown {
display: block; display: block;
} }
...@@ -577,17 +557,16 @@ ...@@ -577,17 +557,16 @@
a.unenroll { a.unenroll {
float: right; float: right;
display: block;
font-style: italic; font-style: italic;
color: #a0a0a0; color: #a0a0a0;
text-decoration: underline; text-decoration: underline;
font-size: .8em; font-size: .8em;
@include inline-block; margin-top: 32px;
margin-bottom: 40px;
&:hover { &:hover {
color: #333; color: #333;
} }
} }
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment