411 lines
14 KiB
Python
411 lines
14 KiB
Python
import copy
|
|
from fs.errors import ResourceNotFoundError
|
|
import itertools
|
|
import json
|
|
import logging
|
|
from lxml import etree
|
|
from lxml.html import rewrite_links
|
|
from lxml.html.clean import Cleaner, autolink_html
|
|
from path import path
|
|
import os
|
|
import sys
|
|
import hashlib
|
|
import capa.xqueue_interface as xqueue_interface
|
|
import re
|
|
|
|
from pkg_resources import resource_string
|
|
|
|
from .capa_module import only_one, ComplexEncoder
|
|
from .editing_module import EditingDescriptor
|
|
from .html_checker import check_html
|
|
from progress import Progress
|
|
from .stringify import stringify_children
|
|
from .xml_module import XmlDescriptor
|
|
from xmodule.modulestore import Location
|
|
from capa.util import *
|
|
import open_ended_image_submission
|
|
|
|
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': 'Not started',
|
|
'assessing': 'In progress',
|
|
'post_assessment': 'Done',
|
|
'done': 'Done',
|
|
}
|
|
|
|
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']
|
|
self.display_name = static_data['display_name']
|
|
self.accept_file_upload = static_data['accept_file_upload']
|
|
self.close_date = static_data['close_date']
|
|
|
|
# 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 closed(self):
|
|
if self.close_date is not None and datetime.utcnow() > self.close_date:
|
|
return True
|
|
return False
|
|
|
|
def check_if_closed(self):
|
|
if self.closed():
|
|
return True, {
|
|
'success': False,
|
|
'error': 'This problem is now closed.'
|
|
}
|
|
elif self.attempts > self.max_attempts:
|
|
return True, {
|
|
'success': False,
|
|
'error': 'Too many attempts.'
|
|
}
|
|
else:
|
|
return False, {}
|
|
|
|
def latest_answer(self):
|
|
"""Empty string 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):
|
|
"""Empty string if not available"""
|
|
if not self.history:
|
|
return ""
|
|
return self.history[-1].get('post_assessment', "")
|
|
|
|
@staticmethod
|
|
def sanitize_html(answer):
|
|
try:
|
|
answer = autolink_html(answer)
|
|
cleaner = Cleaner(style=True, links=True, add_nofollow=False, page_structure=True, safe_attrs_only=True,
|
|
host_whitelist=open_ended_image_submission.TRUSTED_IMAGE_DOMAINS,
|
|
whitelist_tags=set(['embed', 'iframe', 'a', 'img']))
|
|
clean_html = cleaner.clean_html(answer)
|
|
clean_html = re.sub(r'</p>$', '', re.sub(r'^<p>', '', clean_html))
|
|
except:
|
|
clean_html = answer
|
|
return clean_html
|
|
|
|
def new_history_entry(self, answer):
|
|
"""
|
|
Adds a new entry to the history dictionary
|
|
@param answer: The student supplied answer
|
|
@return: None
|
|
"""
|
|
answer = OpenEndedChild.sanitize_html(answer)
|
|
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
|
|
|
|
def upload_image_to_s3(self, image_data):
|
|
"""
|
|
Uploads an image to S3
|
|
Image_data: InMemoryUploadedFileObject that responds to read() and seek()
|
|
@return:Success and a URL corresponding to the uploaded object
|
|
"""
|
|
success = False
|
|
s3_public_url = ""
|
|
image_ok = False
|
|
try:
|
|
image_data.seek(0)
|
|
image_ok = open_ended_image_submission.run_image_tests(image_data)
|
|
except:
|
|
log.exception("Could not create image and check it.")
|
|
|
|
if image_ok:
|
|
image_key = image_data.name + datetime.now().strftime("%Y%m%d%H%M%S")
|
|
|
|
try:
|
|
image_data.seek(0)
|
|
success, s3_public_url = open_ended_image_submission.upload_to_s3(image_data, image_key)
|
|
except:
|
|
log.exception("Could not upload image to S3.")
|
|
|
|
return success, image_ok, s3_public_url
|
|
|
|
def check_for_image_and_upload(self, get_data):
|
|
"""
|
|
Checks to see if an image was passed back in the AJAX query. If so, it will upload it to S3
|
|
@param get_data: AJAX get data
|
|
@return: Success, whether or not a file was in the get dictionary,
|
|
and the html corresponding to the uploaded image
|
|
"""
|
|
has_file_to_upload = False
|
|
uploaded_to_s3 = False
|
|
image_tag = ""
|
|
image_ok = False
|
|
if 'can_upload_files' in get_data:
|
|
if get_data['can_upload_files'] in ['true', '1']:
|
|
has_file_to_upload = True
|
|
file = get_data['student_file'][0]
|
|
uploaded_to_s3, image_ok, s3_public_url = self.upload_image_to_s3(file)
|
|
if uploaded_to_s3:
|
|
image_tag = self.generate_image_tag_from_url(s3_public_url, file.name)
|
|
|
|
return has_file_to_upload, uploaded_to_s3, image_ok, image_tag
|
|
|
|
def generate_image_tag_from_url(self, s3_public_url, image_name):
|
|
"""
|
|
Makes an image tag from a given URL
|
|
@param s3_public_url: URL of the image
|
|
@param image_name: Name of the image
|
|
@return: Boolean success, updated AJAX get data
|
|
"""
|
|
image_template = """
|
|
<a href="{0}" target="_blank">{1}</a>
|
|
""".format(s3_public_url, image_name)
|
|
return image_template
|
|
|
|
def append_image_to_student_answer(self, get_data):
|
|
"""
|
|
Adds an image to a student answer after uploading it to S3
|
|
@param get_data: AJAx get data
|
|
@return: Boolean success, updated AJAX get data
|
|
"""
|
|
overall_success = False
|
|
if not self.accept_file_upload:
|
|
#If the question does not accept file uploads, do not do anything
|
|
return True, get_data
|
|
|
|
has_file_to_upload, uploaded_to_s3, image_ok, image_tag = self.check_for_image_and_upload(get_data)
|
|
if uploaded_to_s3 and has_file_to_upload and image_ok:
|
|
get_data['student_answer'] += image_tag
|
|
overall_success = True
|
|
elif has_file_to_upload and not uploaded_to_s3 and image_ok:
|
|
#In this case, an image was submitted by the student, but the image could not be uploaded to S3. Likely
|
|
#a config issue (development vs deployment). For now, just treat this as a "success"
|
|
log.exception("Student AJAX post to combined open ended xmodule indicated that it contained an image, "
|
|
"but the image was not able to be uploaded to S3. This could indicate a config"
|
|
"issue with this deployment, but it could also indicate a problem with S3 or with the"
|
|
"student image itself.")
|
|
overall_success = True
|
|
elif not has_file_to_upload:
|
|
#If there is no file to upload, probably the student has embedded the link in the answer text
|
|
success, get_data['student_answer'] = self.check_for_url_in_text(get_data['student_answer'])
|
|
overall_success = success
|
|
|
|
#log.debug("Has file: {0} Uploaded: {1} Image Ok: {2}".format(has_file_to_upload, uploaded_to_s3, image_ok))
|
|
|
|
return overall_success, get_data
|
|
|
|
def check_for_url_in_text(self, string):
|
|
"""
|
|
Checks for urls in a string
|
|
@param string: Arbitrary string
|
|
@return: Boolean success, the edited string
|
|
"""
|
|
success = False
|
|
links = re.findall(r'(https?://\S+)', string)
|
|
if len(links) > 0:
|
|
for link in links:
|
|
success = open_ended_image_submission.run_url_tests(link)
|
|
if not success:
|
|
string = re.sub(link, '', string)
|
|
else:
|
|
string = re.sub(link, self.generate_image_tag_from_url(link, link), string)
|
|
success = True
|
|
|
|
return success, string
|