capa_module.py 14.4 KB
Newer Older
1 2
import StringIO
import datetime
3
import dateutil
4
import dateutil.parser
5
import json
6
import logging
7 8 9 10 11 12 13 14
import math
import numpy
import os
import random
import scipy
import struct
import sys
import traceback
15

16
from lxml import etree
Piotr Mitros committed
17

18
## TODO: Abstract out from Django
Lyla Fischer committed
19
from mitxmako.shortcuts import render_to_string
20 21

from x_module import XModule
22
from courseware.capa.capa_problem import LoncapaProblem, StudentInputError
23
import courseware.content_parser as content_parser
24
from multicourse import multicourse_settings
25

26 27
log = logging.getLogger("mitx.courseware")

28 29 30 31 32 33
class ComplexEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, complex):
            return "{real:.7g}{imag:+.7g}*j".format(real = obj.real,imag = obj.imag)
        return json.JSONEncoder.default(self, obj)

34
class Module(XModule):
Piotr Mitros committed
35 36 37 38 39
    ''' Interface between capa_problem and x_module. Originally a hack
    meant to be refactored out, but it seems to be serving a useful
    prupose now. We can e.g .destroy and create the capa_problem on a
    reset. 
    '''
40

41 42
    id_attribute = "filename"

43 44 45
    @classmethod
    def get_xml_tags(c):
        return ["problem"]
46

Piotr Mitros committed
47
    def get_state(self):
48 49 50
        state = self.lcp.get_state()
        state['attempts'] = self.attempts
        return json.dumps(state)
Piotr Mitros committed
51 52

    def get_score(self):
53
        return self.lcp.get_score()
Piotr Mitros committed
54 55

    def max_score(self):
56
        return self.lcp.get_max_score()
Piotr Mitros committed
57

58 59
    def get_html(self):
        return render_to_string('problem_ajax.html', 
60
                              {'id':self.item_id, 
61 62 63 64 65
                               'ajax_url':self.ajax_url,
                               })

    def get_init_js(self):
        return render_to_string('problem.js', 
66
                              {'id':self.item_id, 
67 68 69 70
                               'ajax_url':self.ajax_url,
                               })

    def get_problem_html(self, encapsulate=True):
Piotr Mitros committed
71
        html = self.lcp.get_html()
72
        content={'name':self.name, 
Lyla Fischer committed
73 74 75
                 'html':html, 
                 'weight': self.weight,
                 }
76
        
77 78 79
        # We using strings as truthy values, because the terminology of the check button
        # is context-specific.
        check_button = "Grade" if self.max_attempts else "Check"
80
        reset_button = True
81 82
        save_button = True

83 84 85
        # If we're after deadline, or user has exhuasted attempts, 
        # question is read-only. 
        if self.closed():
86
            check_button = False
87
            reset_button = False
88
            save_button = False
89 90 91 92
            

        # User submitted a problem, and hasn't reset. We don't want
        # more submissions. 
93
        if self.lcp.done and self.rerandomize == "always":
94
            check_button = False
95 96
            save_button = False
        
97 98 99 100
        # Only show the reset button if pressing it will show different values
        if self.rerandomize != 'always':
            reset_button = False

101 102 103
        # User hasn't submitted an answer yet -- we don't want resets
        if not self.lcp.done:
            reset_button = False
104

105
        # We don't need a "save" button if infinite number of attempts and non-randomized
106
        if self.max_attempts == None and self.rerandomize != "always":
107 108
            save_button = False

Piotr Mitros committed
109 110 111 112 113 114 115 116 117 118
        # Check if explanation is available, and if so, give a link
        explain=""
        if self.lcp.done and self.explain_available=='attempted':
            explain=self.explanation
        if self.closed() and self.explain_available=='closed':
            explain=self.explanation
        
        if len(explain) == 0:
            explain = False

119 120 121 122 123 124 125 126 127 128 129 130 131
        context = {'problem' : content, 
                   'id' : self.item_id, 
                   'check_button' : check_button,
                   'reset_button' : reset_button,
                   'save_button' : save_button,
                   'answer_available' : self.answer_available(),
                   'ajax_url' : self.ajax_url,
                   'attempts_used': self.attempts, 
                   'attempts_allowed': self.max_attempts, 
                   'explain': explain,
                   }

        html=render_to_string('problem.html', context)
Piotr Mitros committed
132 133
        if encapsulate:
            html = '<div id="main_{id}">'.format(id=self.item_id)+html+"</div>"
134
            
Piotr Mitros committed
135
        return html
Piotr Mitros committed
136

Lyla Fischer committed
137 138
    def __init__(self, system, xml, item_id, state=None):
        XModule.__init__(self, system, xml, item_id, state)
139 140 141

        self.attempts = 0
        self.max_attempts = None
142
        
143
        dom2 = etree.fromstring(xml)
Lyla Fischer committed
144
        
145 146
        self.explanation="problems/"+content_parser.item(dom2.xpath('/problem/@explain'), default="closed")
        # TODO: Should be converted to: self.explanation=content_parser.item(dom2.xpath('/problem/@explain'), default="closed")
Piotr Mitros committed
147 148
        self.explain_available=content_parser.item(dom2.xpath('/problem/@explain_available'))

149 150 151
        display_due_date_string=content_parser.item(dom2.xpath('/problem/@due'))
        if len(display_due_date_string)>0:
            self.display_due_date=dateutil.parser.parse(display_due_date_string)
Piotr Mitros committed
152
            #log.debug("Parsed " + display_due_date_string + " to " + str(self.display_due_date))
153 154 155 156 157 158 159 160
        else:
            self.display_due_date=None
        
        
        grace_period_string = content_parser.item(dom2.xpath('/problem/@graceperiod'))
        if len(grace_period_string)>0 and self.display_due_date:
            self.grace_period = content_parser.parse_timedelta(grace_period_string)
            self.close_date = self.display_due_date + self.grace_period
Piotr Mitros committed
161
            #log.debug("Then parsed " + grace_period_string + " to closing date" + str(self.close_date))
162
        else:
163 164
            self.grace_period = None
            self.close_date = self.display_due_date
165
            
166
        self.max_attempts=content_parser.item(dom2.xpath('/problem/@attempts'))
167 168 169 170 171
        if len(self.max_attempts)>0:
            self.max_attempts=int(self.max_attempts)
        else:
            self.max_attempts=None

172
        self.show_answer=content_parser.item(dom2.xpath('/problem/@showanswer'))
173

174 175 176
        if self.show_answer=="":
            self.show_answer="closed"

177
        self.rerandomize=content_parser.item(dom2.xpath('/problem/@rerandomize'))
178 179 180 181 182 183
        if self.rerandomize=="" or self.rerandomize=="always" or self.rerandomize=="true":
            self.rerandomize="always"
        elif self.rerandomize=="false" or self.rerandomize=="per_student":
            self.rerandomize="per_student"
        elif self.rerandomize=="never":
            self.rerandomize="never"
184
        else:
185
            raise Exception("Invalid rerandomize attribute "+self.rerandomize)
186

187 188 189 190 191
        if state!=None:
            state=json.loads(state)
        if state!=None and 'attempts' in state:
            self.attempts=state['attempts']

192 193
        # TODO: Should be: self.filename=content_parser.item(dom2.xpath('/problem/@filename')) 
        self.filename= "problems/"+content_parser.item(dom2.xpath('/problem/@filename'))+".xml"
194
        self.name=content_parser.item(dom2.xpath('/problem/@name'))
Lyla Fischer committed
195
        self.weight=content_parser.item(dom2.xpath('/problem/@weight'))
196
        if self.rerandomize == 'never':
197 198 199
            seed = 1
        else:
            seed = None
200 201 202 203 204
        try:
            fp = self.filestore.open(self.filename)
        except Exception,err:
            print '[courseware.capa.capa_module.Module.init] error %s: cannot open file %s' % (err,self.filename)
            raise Exception,err
205
        self.lcp=LoncapaProblem(fp, self.item_id, state, seed = seed, system=self.system)
Piotr Mitros committed
206

Piotr Mitros committed
207
    def handle_ajax(self, dispatch, get):
208 209 210
        '''
        This is called by courseware.module_render, to handle an AJAX call.  "get" is request.POST 
        '''
211 212
        if dispatch=='problem_get':
            response = self.get_problem(get)
213
        elif False: #self.close_date > 
214 215
            return json.dumps({"error":"Past due date"})
        elif dispatch=='problem_check': 
216
            response = self.check_problem(get)
Piotr Mitros committed
217
        elif dispatch=='problem_reset':
218
            response = self.reset_problem(get)
219 220
        elif dispatch=='problem_save':
            response = self.save_problem(get)
221
        elif dispatch=='problem_show':
222
            response = self.get_answer(get)
Piotr Mitros committed
223 224
        else: 
            return "Error"
225
        return response
Piotr Mitros committed
226

227 228 229 230
    def closed(self):
        ''' Is the student still allowed to submit answers? '''
        if self.attempts == self.max_attempts:
            return True
231
        if self.close_date != None and datetime.datetime.utcnow() > self.close_date:
232 233 234 235 236
            return True

        return False
        

237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255
    def answer_available(self):
        ''' Is the user allowed to see an answer? 
        ''' 
        if self.show_answer == '':
            return False
        if self.show_answer == "never":
            return False
        if self.show_answer == 'attempted' and self.attempts == 0:
            return False
        if self.show_answer == 'attempted' and self.attempts > 0:
            return True
        if self.show_answer == 'answered' and self.lcp.done:
            return True
        if self.show_answer == 'answered' and not self.lcp.done:
            return False
        if self.show_answer == 'closed' and self.closed():
            return True
        if self.show_answer == 'closed' and not self.closed():
            return False
256 257
        if self.show_answer == 'always':
            return True
Lyla Fischer committed
258
        raise self.system.exception404 #TODO: Not 404
259 260

    def get_answer(self, get):
261 262 263 264 265
        '''
        For the "show answer" button.

        TODO: show answer events should be logged here, not just in the problem.js
        '''
266
        if not self.answer_available():
Lyla Fischer committed
267
            raise self.system.exception404
268
        else: 
269 270
            answers = self.lcp.get_question_answers()
            return json.dumps(answers, 
271
                              cls=ComplexEncoder)
272

273 274
    # Figure out if we should move these to capa_problem?
    def get_problem(self, get):
275 276
        ''' Same as get_problem_html -- if we want to reconfirm we
            have the right thing e.g. after several AJAX calls.'''
277
        return self.get_problem_html(encapsulate=False)        
Piotr Mitros committed
278

279
    def check_problem(self, get):
280 281
        ''' Checks whether answers to a problem are correct, and
            returns a map of correct/incorrect answers'''
282 283 284 285
        event_info = dict()
        event_info['state'] = self.lcp.get_state()
        event_info['filename'] = self.filename

286
        # make a dict of all the student responses ("answers").
287 288 289 290 291 292 293
        answers=dict()
        # input_resistor_1 ==> resistor_1
        for key in get:
            answers['_'.join(key.split('_')[1:])]=get[key]

        event_info['answers']=answers

294
        # Too late. Cannot submit
295
        if self.closed():
296 297
            event_info['failure']='closed'
            self.tracker('save_problem_check_fail', event_info)
Lyla Fischer committed
298
            raise self.system.exception404
299
            
300 301
        # Problem submitted. Student should reset before checking
        # again.
302
        if self.lcp.done and self.rerandomize == "always":
303 304
            event_info['failure']='unreset'
            self.tracker('save_problem_check_fail', event_info)
Lyla Fischer committed
305
            raise self.system.exception404
306

307
        try:
308 309
            old_state = self.lcp.get_state()
            lcp_id = self.lcp.problem_id
310
            correct_map = self.lcp.grade_answers(answers)
311
        except StudentInputError as inst: 
312
            self.lcp = LoncapaProblem(self.filestore.open(self.filename), id=lcp_id, state=old_state, system=self.system)
313 314
            traceback.print_exc()
            return json.dumps({'success':inst.message})
315
        except: 
316
            self.lcp = LoncapaProblem(self.filestore.open(self.filename), id=lcp_id, state=old_state, system=self.system)
317
            traceback.print_exc()
318
            raise Exception,"error in capa_module"
319 320
            return json.dumps({'success':'Unknown Error'})
            
321 322
        self.attempts = self.attempts + 1
        self.lcp.done=True
323
        
324
        success = 'correct'
Piotr Mitros committed
325 326
        for i in correct_map:
            if correct_map[i]!='correct':
327
                success = 'incorrect'
328

329 330 331 332 333
        event_info['correct_map']=correct_map
        event_info['success']=success

        self.tracker('save_problem_check', event_info)

334 335
        return json.dumps({'success': success,
                           'contents': self.get_problem_html(encapsulate=False)})
Piotr Mitros committed
336

337
    def save_problem(self, get):
338 339 340 341 342 343 344 345 346
        event_info = dict()
        event_info['state'] = self.lcp.get_state()
        event_info['filename'] = self.filename

        answers=dict()
        for key in get:
            answers['_'.join(key.split('_')[1:])]=get[key]
        event_info['answers'] = answers

347
        # Too late. Cannot submit
348
        if self.closed():
349 350
            event_info['failure']='closed'
            self.tracker('save_problem_fail', event_info)
351
            return "Problem is closed"
352
            
353 354
        # Problem submitted. Student should reset before saving
        # again.
355
        if self.lcp.done and self.rerandomize == "always":
356 357
            event_info['failure']='done'
            self.tracker('save_problem_fail', event_info)
358 359
            return "Problem needs to be reset prior to save."

360
        self.lcp.student_answers=answers
361

362
        self.tracker('save_problem_fail', event_info)
363 364
        return json.dumps({'success':True})

Piotr Mitros committed
365
    def reset_problem(self, get):
366 367
        ''' Changes problem state to unfinished -- removes student answers, 
            and causes problem to rerender itself. '''
368 369 370 371
        event_info = dict()
        event_info['old_state']=self.lcp.get_state()
        event_info['filename']=self.filename

372
        if self.closed():
373 374
            event_info['failure']='closed'
            self.tracker('reset_problem_fail', event_info)
375
            return "Problem is closed"
376
            
377
        if not self.lcp.done:
378 379
            event_info['failure']='not_done'
            self.tracker('reset_problem_fail', event_info)
380 381
            return "Refresh the page and make an attempt before resetting."

Piotr Mitros committed
382
        self.lcp.done=False
Piotr Mitros committed
383 384
        self.lcp.answers=dict()
        self.lcp.correct_map=dict()
Piotr Mitros committed
385 386
        self.lcp.student_answers = dict()

387

388
        if self.rerandomize == "always":
389 390 391
            self.lcp.context=dict()
            self.lcp.questions=dict() # Detailed info about questions in problem instance. TODO: Should be by id and not lid. 
            self.lcp.seed=None
392

393
        self.lcp=LoncapaProblem(self.filestore.open(self.filename), self.item_id, self.lcp.get_state(), system=self.system)
394

395 396
        event_info['new_state']=self.lcp.get_state()
        self.tracker('reset_problem', event_info)
397

398
        return json.dumps(self.get_problem_html(encapsulate=False))