#!/usr/bin/env python # -*- coding: utf-8 -*- # The MIT License (MIT) # Copyright (c) 2014 Karanveer Mohan # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. import random from random import shuffle from xml.dom import minidom import sys import re import glob import itertools """ Parse the Quiz to create a python dict """ class QuizParser(): """Parses the quiz and returns it in a python dict""" def __init__(self, filename): if '.quiz' in filename: self.filename = filename else: self.filename = '%s.quiz' % filename def get_filename(self): return self.filename def _make_backwards_compatible(self, lines): """ Takes care of case where there is an empty line between question and options that existed in EE364A. """ i = 0 while True: if i == len(lines) - 1: break if not lines[i] and lines[i + 1].startswith('*'): del lines[i] i += 1 return lines def _jump_to_non_blank_line(self, lines): """ Returns the list of lines starting from the first line that is not empty. Returns [] if no lines remain """ while lines and not lines[0]: lines = lines[1:] return lines def _parse_title(self, lines): """ The quiz must begin with a "== " followed by an optional title. If the title exists, it returns the title, empty string otherwise """ first_line = lines[0] if first_line.startswith('=='): return first_line[2:].lstrip() raise Exception('Invalid format. Quiz must start with "==" followed by an optional title.') def _parse_new_problem_group(self, line_group): """ Parses a new problem group, i.e. the title of the main problem and the statement. Each problem group can have multiple questions associated with it. """ title_line = line_group[0] if title_line.rfind(']') == -1: raise Exception('Problem title must be in the form [TITLE] with the square brackets') problem_intro = line_group[1:] return { # [Sensitivity Analysis] ==> 'Sensitivity Analysis' 'problem_title': title_line[1:title_line.rfind(']')], 'problem_intro': '\n'.join(problem_intro), 'questions': [] } def _parse_explanation_and_description(self, line): """ Find the description for an option, and an explanation (if any) """ if line.find('::') != -1: # We have an explanation for this option explanation = line[line.find('::') + 2:] description = line[line.find(' ') + 1:line.find('::')] has_explanation = True else: explanation = '' description = line[line.find(' ') + 1:] has_explanation = False return has_explanation, explanation, description def _parse_new_question(self, line_group): """ Parses a new question for a given problem group. A question consists of a description and a number of options. A correct option begins with *= and an incorrect option begins with *. An explanation begins with :: """ question = { 'description': '', 'options': [] } try: while not line_group[0].startswith('*'): question['description'] += '\n' + line_group[0] line_group = line_group[1:] except: raise Exception('ERROR: Options for question not found. \n \ Perhaps you put a blank line in your problem group?') for line in line_group: if line[0] == '*': # New option has_explanation, explanation, description = self._parse_explanation_and_description(line) option = { 'explanation': explanation, 'description': description, 'correct': True if line.startswith('*=') else False } question['options'].append(option) else: # Continuation of the description or explanation of the previous option if has_explanation: question['options'][-1]['explanation'] += '\n' + line else: has_explanation, explanation, description = self._parse_explanation_and_description(line) question['options'][-1]['description'] += '\n' + description question['options'][-1]['explanation'] += '\n' + explanation return question def parse(self): # Open the file and read in the lines try: quizfile = open(self.filename, 'r',encoding="utf8") except IOError: raise Exception('No file named %s found' % self.filename) lines = quizfile.readlines() lines = [line.strip() for line in lines] lines = self._make_backwards_compatible(lines) quiz = { 'title': self._parse_title(lines), 'problem_groups': [] } lines = self._jump_to_non_blank_line(lines[1:]) for is_empty_line, line_group in itertools.groupby(lines, lambda line: not line): if is_empty_line: continue line_group = list(line_group) if line_group[0].startswith('[') and any(line.startswith('*') for line in line_group): # Grotesque code needed for backwards compatibility # Problem groups need not have intros so a [TITLE] can be immediately followed by # a question. This takes care of that case problem_group = self._parse_new_problem_group(line_group) problem_group['problem_intro'] = '' quiz['problem_groups'].append(problem_group) line_group = line_group[1:] if line_group[0].startswith('['): # Marks the beginning of a new problem group problem_group = self._parse_new_problem_group(line_group) quiz['problem_groups'].append(problem_group) else: # This is a single question that corresponds to the last problem_group in the line_group question = self._parse_new_question(line_group) try: quiz['problem_groups'][-1]['questions'].append(question) except: raise Exception('ERROR. Are you sure you started every problem group with "[]"?') for pg in quiz["problem_groups"]: random.shuffle(pg["questions"]) for ql in pg["questions"]: random.shuffle(ql["options"]) return quiz def create_single_choice_dom_from_option(option): """ Creates dom look for a specific option of a question """ doc = minidom.Document() li = doc.createElement('li') li.attributes['class'] = 'choice' # Each list element has a 'selector', i.e the option that can be selected # and a 'response', i.e the response to be shown when that option is selected selector_div = doc.createElement('div') selector_div.attributes['class'] = 'selection' selector_div.appendChild(doc.createTextNode(option['description'])) response_div = doc.createElement('div') # response_div.attributes['class'] = 'response' span = doc.createElement('span') if option['correct']: span.attributes['class'] = 'right' response_div.attributes['class'] = 'response right' span.appendChild(doc.createTextNode('Correct! ')) else: span.attributes['class'] = 'wrong' response_div.attributes['class'] = 'response wrong' span.appendChild(doc.createTextNode('Incorrect. ')) response_div.appendChild(span) response_div.appendChild(doc.createTextNode(option['explanation'])) li.appendChild(selector_div) li.appendChild(response_div) return li def create_single_choice_dom_from_question(question): """ If a question only has one option correct, creates the dom look for that question """ doc = minidom.Document() ol = doc.createElement('ol') ol.attributes['type'] = 'a' for option in question['options']: elem = create_single_choice_dom_from_option(option) ol.appendChild(elem) return ol def create_multiple_choice_dom_from_option(option): """ Creates options with checkboxes for multiple choice responses """ doc = minidom.Document() label = doc.createElement('label') checkbox = doc.createElement('input') checkbox.attributes['type'] = 'checkbox' # Each list element has a 'selector', i.e the option that can be selected # and a 'response', i.e the response to be shown when that option is selected selector_span = doc.createElement('span') selector_span.attributes['class'] = 'multiple-selection' selector_span.appendChild(doc.createTextNode(option['description'])) # Create nodes to show the checkmark and cross mark when students get options # in an MCQ right/wrong. correct_span = doc.createElement('span') correct_span.attributes['class'] = 'correct-checkbox' correct_span.appendChild(doc.createTextNode('✓')) incorrect_span = doc.createElement('span') incorrect_span.attributes['class'] = 'incorrect-checkbox' incorrect_span.appendChild(doc.createTextNode('✗')) response_div = doc.createElement('div') explanation = '' if option['correct']: response_div.attributes['class'] = 'response right' explanation = 'This option is correct. ' else: response_div.attributes['class'] = 'response wrong' explanation = 'This option is incorrect. ' response_div.appendChild(doc.createTextNode(explanation + option['explanation'])) label.appendChild(checkbox) label.appendChild(selector_span) label.appendChild(correct_span) label.appendChild(incorrect_span) label.appendChild(response_div) return label def create_multiple_choice_dom_from_question(question): """ If a question only has multiple options correct, creates the dom look for that question. Puts a checkbox next to each option and adds a button allowing you to see the answer """ doc = minidom.Document() div = doc.createElement('div') div.attributes['class'] = 'mcq' for option in question['options']: elem = create_multiple_choice_dom_from_option(option) div.appendChild(elem) button = doc.createElement('button') button.appendChild(doc.createTextNode('Submit')) div.appendChild(button) return div def create_dom_from_question(question): """ Creates dom look for question """ doc = minidom.Document() wrapper = doc.createElement('div') div = doc.createElement('div') div.attributes['class'] = 'description' div.appendChild(doc.createTextNode(question['description'])) wrapper.appendChild(div) num_correct = sum(1 for option in question['options'] if option['correct']) if num_correct == 1: el = create_single_choice_dom_from_question(question) else: el = create_multiple_choice_dom_from_question(question) wrapper.appendChild(el) return wrapper def create_dom_from_problem_group(problem_group): """ Creates the dom look for a problem group """ doc = minidom.Document() fieldset = doc.createElement('fieldset') if problem_group['problem_title']: legend = doc.createElement('legend') legend.appendChild(doc.createTextNode(problem_group['problem_title'])) fieldset.appendChild(legend) if problem_group['problem_intro']: div = doc.createElement('div') div.attributes['class'] = 'intro' div.appendChild(doc.createTextNode(problem_group['problem_intro'])) fieldset.appendChild(div) first_question = True for question in problem_group['questions']: hr = doc.createElement('hr') if not first_question or problem_group['problem_intro']: fieldset.appendChild(hr) first_question = False elem = create_dom_from_question(question) fieldset.appendChild(elem) return fieldset def create_dom_from_quiz(quiz): """ Given a quiz dictionary, generates its DOM look. """ doc = minidom.Document() wrapper = doc.createElement('div') header = doc.createElement('h1') header.appendChild(doc.createTextNode(quiz['title'])) wrapper.appendChild(header) for problem_group in quiz['problem_groups']: elem = create_dom_from_problem_group(problem_group) wrapper.appendChild(elem) wrapper.appendChild(doc.createElement('br')) return wrapper def add_dom_to_template(dom, html_file_name, quiz): """ Expects a template called 'template.html' with a [BODY] holder where the body will be added and a [TITLE] holder for title. """ generated_file = open(html_file_name, 'w+') try: template_file = open('template.html') content = template_file.read() except IOError: content = HTML # Add the header. By replacing this early, we allow the header to # contain IMG and LINK tags (or even CODE), though it would typically # be pure HTML content = content.replace('[HEADER]', get_header()) content = content.replace('[TITLE]', quiz['title']) content = content.replace('[BODY]', dom.toprettyxml()) # Add the footer. By replacing this early, we allow the footer to # contain IMG and LINK tags (or even CODE), though it would typically # be pure HTML content = content.replace('[FOOTER]', get_footer()) # For the images content = re.sub('\|\|IMG:\s?(\S+)\|\|', r'<div><img src="\1"></div>', content) # For the links content = re.sub('\|\|LINK:\s?(\S+)\|\|', r'<a href="\1">\1</a>', content) # For code blocks content = re.sub('\|\|CODE:(\S+):\s?(.*?)\|\|', r'<pre><code class="\1">\2</pre></code>', content, flags=re.DOTALL) generated_file.write(content) generated_file.close() # Create the CSS file if it does not exist try: css_file = open('quiz.css') except IOError: print ('No CSS file called quiz.css found in directory. Using default CSS.') generated_css_file = open('quiz.css', 'w+') generated_css_file.write(CSS) generated_css_file.close() def usage(): print (""" Usage: python quizgen.py SOURCE_QUIZ_FILE. You may like to: sudo cp quizgen.py /usr/bin/quizgen so that you can simply type quizgen. Remember to sudo chmod +x /usr/bin/quizgen to make the file executable. You can use quizgen simply by typing: quizgen index which will product an index.html from index.quiz file. In case no CSS styling is provided, it will also create a quiz.css file. If you want to create sample.quiz to get started, just type: python quizgen.py -c and a file called sample.quiz will be created. This file shows all the features of quizgen along with the format. You may provide a footer and/or header to appear on your quizzes by creating a file named footer.html and/or header.html that contains an html fragment. More information and a lot of sample quizzes file can be found on: https://github.com/karanveerm/quizgen """) def create_sample(): try: sample_quiz_file = open('sample.quiz') except IOError: sample_quiz_file = open('sample.quiz', 'w+') sample_quiz_file.write(SAMPLE) sample_quiz_file.close() print ('A file called sample.quiz has been added to your directory.') print ('Please take a look at it to see how to create a quiz.') else: print ('A file called sample.quiz already exists in your current directory!') # Should a header file exist return the content of that file # otherwise return an empty string def get_header(): try: header_file = open('header.html') except IOError: header = '' else: header = header_file.read() return header # Should a footer file exist return the content of that file # otherwise return the standard footer def get_footer(): try: footer_file = open('footer.html') except IOError: footer = 'Page generated using <a href=\"https://github.com/karanveerm/quizgen\">Quizgen</a>' else: footer = footer_file.read() return footer def main(): # TODO: Stop being lazy and use optparse. if len(sys.argv) < 2 or '-h' in sys.argv[1]: usage() elif '-c' in sys.argv[1]: create_sample() else: for filename in sys.argv[1:]: quiz_parser = QuizParser(filename) quiz = quiz_parser.parse() dom = create_dom_from_quiz(quiz) html_file_name = quiz_parser.get_filename().replace('.quiz', '.html') add_dom_to_template(dom, html_file_name, quiz) SAMPLE = """== Sample Quiz Title [Problem Group 1 Title] This is the statement for problem group one. You can add a link to websites like this: ||LINK: http://www.google.com||. You can add images like this: ||IMG:http://upload.wikimedia.org/wikipedia/commons/9/9b/Carl_Friedrich_Gauss.jpg|| The image source can either be a local path, or some web URL. Images can be embedded anywhere: within problem groups, problems or options. Quizgen supports LaTeX: \[ a = \\begin{pmatrix} 1 \\\\ 3 \\\\ 2 \end{pmatrix} , \quad b = \\begin{pmatrix} 2 \\\\ 6 \\\\ 4 \end{pmatrix} , \quad c = \\begin{pmatrix} 1 \\\\ 3 \\\\ 0 \end{pmatrix} . \] A blank line marks the end of this problem group introduction text and the beginning of the first problem. This is the first problem in the problem group with some LaTeX: $a_3$. * This is an option. :: You can add an explanation for an option after the double colon to explain why it is correct/incorrect. * This is another option. Observe that explanations are optional. *= This option is the correct option since it is marked with an equal to sign. Again, a blank line marks the end of the options. Here's another problem in this problem group with more inline latex $c$ and $d$. Quizgen supports displayed equations as well: \[ x=Zy + a - c. \] * Yes. *= No. You can also have problems with multiple correct responses. Students will be asked to select all that apply, and then submit their responses. *= Option 1. :: This option is correct. * Option 2. :: This option is not correct. * Option 3. *= Option 4. :: This is another correct option. [] This problem group has no title and has no introduction. When the text following the start of a new problem group is immediately followed by the options, it is inferred to be a problem. *= Yes I understand. :: Great! Here's some latex $\|a + b + c\|.$ * No. :: Please email me and I'll try to help! This is another problem in this problem group. * This is a great tutorial! *= This tutorial can be improved. I'm going to email you with suggestions so you can do a better job. """ # HTML template # TODO: Don't judge, I didn't want to do this HTML = r""" <!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <title>Quiz</title> <link href='http://fonts.googleapis.com/css?family=Josefin+Sans|Alike' rel='stylesheet' type='text/css'> <link rel="stylesheet" href="quiz.css" type="text/css" /> <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min.js"></script> <script type="text/x-mathjax-config"> MathJax.Hub.Config({ tex2jax: { inlineMath: [ ['$','$'], ["\\(","\\)"] ], processEscapes: true } }); </script> <script type="text/javascript" src="http://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS_HTML.js"></script> <link rel="stylesheet" href="http://cdnjs.cloudflare.com/ajax/libs/highlight.js/8.2/styles/default.min.css"> <script src="http://cdnjs.cloudflare.com/ajax/libs/highlight.js/8.2/highlight.min.js"></script> <script>hljs.initHighlightingOnLoad();</script> <script type="text/javascript"> $(document).ready(function(){ //close all the content divs on page load $('.response').hide(); // toggle slide $('.selection').click(function(){ // by calling sibling, we can use same div for all demos $(this).siblings('.response').slideToggle('fast'); }); $('button').click(function(event){ var $target = $(event.target); var $checkboxes = $target.parent('.mcq').find('input'); for (var i = 0; i < $checkboxes.length; i++) { var $checkbox = $checkboxes.eq(i); a = $checkbox; if ($checkbox[0].checked && $checkbox.nextAll('.response').hasClass('right')) { $checkbox.nextAll('.correct-checkbox').show(); } else if ($checkbox[0].checked && $checkbox.nextAll('.response').hasClass('wrong')) { $checkbox.nextAll('.incorrect-checkbox').show(); } else if (!$checkbox[0].checked && $checkbox.nextAll('.response').hasClass('right')) { $checkbox.nextAll('.incorrect-checkbox').show(); } else { $checkbox.nextAll('.correct-checkbox').show(); } } $target.parent('.mcq').find('.response').slideToggle('fast'); if ($target.text() == 'Submit') { $target.text('Hide'); } else { $checkboxes.nextAll('.incorrect-checkbox').hide(); $checkboxes.nextAll('.correct-checkbox').hide(); $target.text('Submit'); } }); }); </script> </head> <body> $\newcommand{\ones}{\mathbf 1}$ [HEADER] [BODY] <footer> [FOOTER] </footer> </body> </html> """ # CSS template # TODO: This is not something I'm proud of CSS = """html { min-height: 100%; } body { margin-left: auto; margin-right: auto; padding: 0; font-family: 'PT Sans', 'Helvetica Neue', Helvetica, Arial, Sans-serif; font-size: 16px; position: relative; min-height: 100%; width: 680px; background-color: rgb(250, 250, 250); color: #333; } img { height: 400px; display: block; margin-left: auto; margin-right: auto; padding: 20px; } div .description { margin-top: 30px; } a { color: #000066; } div .selection { color: #000066; text-decoration: none; } div .selection:hover { text-decoration: underline; } span.right { color:#008800; } hr { width: 60%; margin-left: 0px; border: 0px; border-bottom: 1px solid #ccc; } fieldset { border: 0px; border-bottom: 2.5px solid #aaa; padding: 10px 20px; padding-left: 0px; margin-bottom: 0px; margin-top: 0px; } ol ul { padding-top: 10px; padding-bottom: 10px; } .selection { cursor: pointer; margin-top: 5px; } .multiple-selection { cursor: pointer; margin-top: 5px; } .correct-checkbox { color: #4F8A10; display: none; } .incorrect-checkbox { color: #D8000C; display: none; } legend { font-family: Alike, 'Josefin Sans'; font-size: 19px; padding-left: 0px; } .response.wrong{ padding: 8px 10px; color: #D8000C; background-color: #FFBABA; } .response.right{ padding: 8px 10px; color: #4F8A10; background-color: #DFF2BF; } footer { font-size: 12px; margin-top: 0px; margin-bottom: 5px; bottom: 0; width: 100%; } label { display: block; padding-top: 5px; padding-bottom: 5px; } button { background: #bbb; border: 0px; color: #ffffff; font-size: 14px; padding: 6px 12px 6px 12px; text-decoration: none; } button:hover{ background: #ccc; } button:focus { outline: none; } """ if __name__ == '__main__': main()