Commit 37855b6ae069b2df800aeaa66e6fd35c6f9dffdf
1 parent
1323bb4d
Exists in
master
and in
1 other branch
- new generator question in demo.yaml
- fix grade format in logs - fix many pyyaml warnings - rewrite run_script and run_script_async
Showing
9 changed files
with
263 additions
and
171 deletions
Show diff stats
demo/demo.yaml
demo/questions/generators/generate-question.py
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | ||
| 3 | +''' | ||
| 4 | +Example of a question generator. | ||
| 5 | +Arguments are read from stdin. | ||
| 6 | +''' | ||
| 7 | + | ||
| 3 | from random import randint | 8 | from random import randint |
| 4 | import sys | 9 | import sys |
| 5 | 10 | ||
| 6 | -arg = sys.stdin.read() # read arguments | 11 | +a, b = (int(n) for n in sys.argv[1:]) |
| 7 | 12 | ||
| 8 | -a, b = (int(n) for n in arg.split(',')) | 13 | +x = randint(a, b) |
| 14 | +y = randint(a, b) | ||
| 15 | +r = x + y | ||
| 9 | 16 | ||
| 10 | -q = f'''--- | ||
| 11 | -type: checkbox | 17 | +print(f"""--- |
| 18 | +type: text | ||
| 19 | +title: Geradores de perguntas | ||
| 12 | text: | | 20 | text: | |
| 13 | - Indique quais das seguintes adições resultam em overflow quando se considera | ||
| 14 | - a adição de números com sinal (complemento para 2) em registos de 8 bits. | 21 | + Existe a possibilidade da pergunta ser gerada por um programa externo. |
| 22 | + Este programa deve escrever no `stdout` uma pergunta em formato `yaml` como | ||
| 23 | + os anteriores. Pode também receber argumentos para parametrizar a geração da | ||
| 24 | + pergunta. Aqui está um exemplo de uma pergunta gerada por um script python: | ||
| 15 | 25 | ||
| 16 | - Os números foram gerados aleatoriamente no intervalo de {a} a {b}. | ||
| 17 | -options: | ||
| 18 | -''' | 26 | + ```python |
| 27 | + #!/usr/bin/env python3 | ||
| 28 | + | ||
| 29 | + from random import randint | ||
| 30 | + import sys | ||
| 31 | + | ||
| 32 | + a, b = (int(n) for n in sys.argv[1:]) # argumentos da linha de comando | ||
| 19 | 33 | ||
| 20 | -correct = [] | ||
| 21 | -for i in range(5): | ||
| 22 | - x = randint(a, b) | ||
| 23 | - y = randint(a, b) | ||
| 24 | - q += f' - "`{x} + {y}`"\n' | ||
| 25 | - correct.append(1 if x + y > 127 else -1) | 34 | + x = randint(a, b) |
| 35 | + y = randint(a, b) | ||
| 36 | + r = x + y | ||
| 26 | 37 | ||
| 27 | -q += 'correct: ' + str(correct) | 38 | + print(f'''--- |
| 39 | + type: text | ||
| 40 | + title: Contas de somar | ||
| 41 | + text: | | ||
| 42 | + bla bla bla | ||
| 43 | + correct: '{{r}}' | ||
| 44 | + solution: | | ||
| 45 | + A solução é {{r}}.''') | ||
| 46 | + ``` | ||
| 28 | 47 | ||
| 29 | -print(q) | 48 | + Este script deve ter permissões para poder ser executado no terminal. Dá |
| 49 | + jeito usar o comando `gen-somar.py 1 100 | yamllint -` para validar o `yaml` | ||
| 50 | + gerado. | ||
| 51 | + | ||
| 52 | + Para indicar que uma pergunta é gerada externamente, esta é declarada com | ||
| 53 | + | ||
| 54 | + ```yaml | ||
| 55 | + - type: generator | ||
| 56 | + ref: gen-somar | ||
| 57 | + script: gen-somar.py | ||
| 58 | + # opcional | ||
| 59 | + args: [1, 100] | ||
| 60 | + ``` | ||
| 61 | + | ||
| 62 | + Os argumentos `args` são opcionais e são passados para o programa como | ||
| 63 | + argumentos da linha de comando. | ||
| 64 | + | ||
| 65 | + --- | ||
| 66 | + | ||
| 67 | + Calcule o resultado de ${x} + {y}$. | ||
| 68 | + | ||
| 69 | + Os números foram gerados aleatoriamente no intervalo de {a} a {b}. | ||
| 70 | +correct: '{r}' | ||
| 71 | +solution: | | ||
| 72 | + A solução é {r}.""") |
demo/questions/questions-tutorial.yaml
| @@ -22,6 +22,7 @@ | @@ -22,6 +22,7 @@ | ||
| 22 | 22 | ||
| 23 | # opcional | 23 | # opcional |
| 24 | duration: 60 # duração da prova em minutos (default: inf) | 24 | duration: 60 # duração da prova em minutos (default: inf) |
| 25 | + autosubmit: true # submissão automática (default: false) | ||
| 25 | show_points: true # mostra cotação das perguntas (default: true) | 26 | show_points: true # mostra cotação das perguntas (default: true) |
| 26 | scale_points: true # recalcula cotações para [scale_min, scale_max] | 27 | scale_points: true # recalcula cotações para [scale_min, scale_max] |
| 27 | scale_max: 20 # limite superior da escala (default: 20) | 28 | scale_max: 20 # limite superior da escala (default: 20) |
| @@ -159,11 +160,10 @@ | @@ -159,11 +160,10 @@ | ||
| 159 | shuffle: false | 160 | shuffle: false |
| 160 | ``` | 161 | ``` |
| 161 | 162 | ||
| 162 | - Por defeito, as respostas erradas descontam, tendo uma cotação de -1/(n-1) | ||
| 163 | - do valor da pergunta, onde n é o número de opções apresentadas ao aluno | ||
| 164 | - (a ideia é o valor esperado ser zero quando as respostas são aleatórias e | ||
| 165 | - uniformemente distribuídas). | ||
| 166 | - Para não descontar acrescenta-se: | 163 | + Por defeito, as respostas erradas descontam, tendo uma cotação de |
| 164 | + $-1/(n-1)$ do valor da pergunta, onde $n$ é o número de opções apresentadas | ||
| 165 | + ao aluno (a ideia é o valor esperado ser zero quando as respostas são | ||
| 166 | + aleatórias e uniformemente distribuídas). Para não descontar acrescenta-se: | ||
| 167 | 167 | ||
| 168 | ```yaml | 168 | ```yaml |
| 169 | discount: false | 169 | discount: false |
| @@ -561,3 +561,7 @@ | @@ -561,3 +561,7 @@ | ||
| 561 | Indices start at 0. | 561 | Indices start at 0. |
| 562 | 562 | ||
| 563 | # ---------------------------------------------------------------------------- | 563 | # ---------------------------------------------------------------------------- |
| 564 | +- type: generator | ||
| 565 | + ref: tut-generator | ||
| 566 | + script: generators/generate-question.py | ||
| 567 | + args: [1, 100] |
perguntations/app.py
| @@ -193,7 +193,7 @@ class App(): | @@ -193,7 +193,7 @@ class App(): | ||
| 193 | logger.info('Student %s: %d answers submitted.', uid, len(ans)) | 193 | logger.info('Student %s: %d answers submitted.', uid, len(ans)) |
| 194 | 194 | ||
| 195 | grade = await test.correct() | 195 | grade = await test.correct() |
| 196 | - logger.info('Student %s: grade = %.1g points.', uid, grade) | 196 | + logger.info('Student %s: grade = %g points.', uid, grade) |
| 197 | 197 | ||
| 198 | # --- save test in JSON format | 198 | # --- save test in JSON format |
| 199 | fields = (uid, test['ref'], str(test['finish_time'])) | 199 | fields = (uid, test['ref'], str(test['finish_time'])) |
perguntations/initdb.py
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | ||
| 3 | +''' | ||
| 4 | +Commandline utilizty to initialize and update student database | ||
| 5 | +''' | ||
| 6 | + | ||
| 3 | # base | 7 | # base |
| 4 | import csv | 8 | import csv |
| 5 | import argparse | 9 | import argparse |
| @@ -16,8 +20,8 @@ from perguntations.models import Base, Student | @@ -16,8 +20,8 @@ from perguntations.models import Base, Student | ||
| 16 | 20 | ||
| 17 | 21 | ||
| 18 | # =========================================================================== | 22 | # =========================================================================== |
| 19 | -# Parse command line options | ||
| 20 | def parse_commandline_arguments(): | 23 | def parse_commandline_arguments(): |
| 24 | + '''Parse command line options''' | ||
| 21 | parser = argparse.ArgumentParser( | 25 | parser = argparse.ArgumentParser( |
| 22 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, | 26 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, |
| 23 | description='Insert new users into a database. Users can be imported ' | 27 | description='Insert new users into a database. Users can be imported ' |
| @@ -65,9 +69,11 @@ def parse_commandline_arguments(): | @@ -65,9 +69,11 @@ def parse_commandline_arguments(): | ||
| 65 | 69 | ||
| 66 | 70 | ||
| 67 | # =========================================================================== | 71 | # =========================================================================== |
| 68 | -# SIIUE names have alien strings like "(TE)" and are sometimes capitalized | ||
| 69 | -# We remove them so that students dont keep asking what it means | ||
| 70 | def get_students_from_csv(filename): | 72 | def get_students_from_csv(filename): |
| 73 | + ''' | ||
| 74 | + SIIUE names have alien strings like "(TE)" and are sometimes capitalized | ||
| 75 | + We remove them so that students dont keep asking what it means | ||
| 76 | + ''' | ||
| 71 | csv_settings = { | 77 | csv_settings = { |
| 72 | 'delimiter': ';', | 78 | 'delimiter': ';', |
| 73 | 'quotechar': '"', | 79 | 'quotechar': '"', |
| @@ -75,8 +81,8 @@ def get_students_from_csv(filename): | @@ -75,8 +81,8 @@ def get_students_from_csv(filename): | ||
| 75 | } | 81 | } |
| 76 | 82 | ||
| 77 | try: | 83 | try: |
| 78 | - with open(filename, encoding='iso-8859-1') as f: | ||
| 79 | - csvreader = csv.DictReader(f, **csv_settings) | 84 | + with open(filename, encoding='iso-8859-1') as file: |
| 85 | + csvreader = csv.DictReader(file, **csv_settings) | ||
| 80 | students = [{ | 86 | students = [{ |
| 81 | 'uid': s['N.º'], | 87 | 'uid': s['N.º'], |
| 82 | 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip()) | 88 | 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip()) |
| @@ -92,20 +98,22 @@ def get_students_from_csv(filename): | @@ -92,20 +98,22 @@ def get_students_from_csv(filename): | ||
| 92 | 98 | ||
| 93 | 99 | ||
| 94 | # =========================================================================== | 100 | # =========================================================================== |
| 95 | -# replace password by hash for a single student | ||
| 96 | -def hashpw(student, pw=None): | 101 | +def hashpw(student, password=None): |
| 102 | + '''replace password by hash for a single student''' | ||
| 97 | print('.', end='', flush=True) | 103 | print('.', end='', flush=True) |
| 98 | - if pw is None: | 104 | + if password is None: |
| 99 | student['pw'] = '' | 105 | student['pw'] = '' |
| 100 | else: | 106 | else: |
| 101 | - student['pw'] = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt()) | 107 | + student['pw'] = bcrypt.hashpw(password.encode('utf-8'), |
| 108 | + bcrypt.gensalt()) | ||
| 102 | 109 | ||
| 103 | 110 | ||
| 104 | # =========================================================================== | 111 | # =========================================================================== |
| 105 | def insert_students_into_db(session, students): | 112 | def insert_students_into_db(session, students): |
| 113 | + '''insert list of students into the database''' | ||
| 106 | try: | 114 | try: |
| 107 | session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw']) | 115 | session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw']) |
| 108 | - for s in students]) | 116 | + for s in students]) |
| 109 | session.commit() | 117 | session.commit() |
| 110 | 118 | ||
| 111 | except sa.exc.IntegrityError: | 119 | except sa.exc.IntegrityError: |
| @@ -115,33 +123,33 @@ def insert_students_into_db(session, students): | @@ -115,33 +123,33 @@ def insert_students_into_db(session, students): | ||
| 115 | 123 | ||
| 116 | # ============================================================================ | 124 | # ============================================================================ |
| 117 | def show_students_in_database(session, verbose=False): | 125 | def show_students_in_database(session, verbose=False): |
| 118 | - try: | ||
| 119 | - users = session.query(Student).all() | ||
| 120 | - except Exception: | ||
| 121 | - raise | 126 | + '''get students from database''' |
| 127 | + users = session.query(Student).all() | ||
| 128 | + | ||
| 129 | + total_users = len(users) | ||
| 130 | + print('Registered users:') | ||
| 131 | + if total_users == 0: | ||
| 132 | + print(' -- none --') | ||
| 122 | else: | 133 | else: |
| 123 | - n = len(users) | ||
| 124 | - print(f'Registered users:') | ||
| 125 | - if n == 0: | ||
| 126 | - print(' -- none --') | 134 | + users.sort(key=lambda u: f'{u.id:>12}') # sort by number |
| 135 | + if verbose: | ||
| 136 | + for user in users: | ||
| 137 | + print(f'{user.id:>12} {user.name}') | ||
| 127 | else: | 138 | else: |
| 128 | - users.sort(key=lambda u: f'{u.id:>12}') # sort by number | ||
| 129 | - if verbose: | ||
| 130 | - for u in users: | ||
| 131 | - print(f'{u.id:>12} {u.name}') | ||
| 132 | - else: | ||
| 133 | - print(f'{users[0].id:>12} {users[0].name}') | ||
| 134 | - if n > 1: | ||
| 135 | - print(f'{users[1].id:>12} {users[1].name}') | ||
| 136 | - if n > 3: | ||
| 137 | - print(' | |') | ||
| 138 | - if n > 2: | ||
| 139 | - print(f'{users[-1].id:>12} {users[-1].name}') | ||
| 140 | - print(f'Total: {n}.') | 139 | + print(f'{users[0].id:>12} {users[0].name}') |
| 140 | + if total_users > 1: | ||
| 141 | + print(f'{users[1].id:>12} {users[1].name}') | ||
| 142 | + if total_users > 3: | ||
| 143 | + print(' | |') | ||
| 144 | + if total_users > 2: | ||
| 145 | + print(f'{users[-1].id:>12} {users[-1].name}') | ||
| 146 | + print(f'Total: {total_users}.') | ||
| 141 | 147 | ||
| 142 | 148 | ||
| 143 | # ============================================================================ | 149 | # ============================================================================ |
| 144 | def main(): | 150 | def main(): |
| 151 | + '''insert, update, show students from database''' | ||
| 152 | + | ||
| 145 | args = parse_commandline_arguments() | 153 | args = parse_commandline_arguments() |
| 146 | 154 | ||
| 147 | # --- make list of students to insert/update | 155 | # --- make list of students to insert/update |
| @@ -162,27 +170,27 @@ def main(): | @@ -162,27 +170,27 @@ def main(): | ||
| 162 | 170 | ||
| 163 | # --- password hashing | 171 | # --- password hashing |
| 164 | if students: | 172 | if students: |
| 165 | - print(f'Generating password hashes', end='') | 173 | + print('Generating password hashes', end='') |
| 166 | with ThreadPoolExecutor() as executor: # hashing in parallel | 174 | with ThreadPoolExecutor() as executor: # hashing in parallel |
| 167 | executor.map(lambda s: hashpw(s, args.pw), students) | 175 | executor.map(lambda s: hashpw(s, args.pw), students) |
| 168 | print() | 176 | print() |
| 169 | 177 | ||
| 170 | # --- database stuff | 178 | # --- database stuff |
| 171 | - print(f'Using database: ', args.db) | 179 | + print(f'Using database: {args.db}') |
| 172 | engine = sa.create_engine(f'sqlite:///{args.db}', echo=False) | 180 | engine = sa.create_engine(f'sqlite:///{args.db}', echo=False) |
| 173 | Base.metadata.create_all(engine) # Criates schema if needed | 181 | Base.metadata.create_all(engine) # Criates schema if needed |
| 174 | - Session = sa.orm.sessionmaker(bind=engine) | ||
| 175 | - session = Session() | 182 | + SessionMaker = sa.orm.sessionmaker(bind=engine) |
| 183 | + session = SessionMaker() | ||
| 176 | 184 | ||
| 177 | if students: | 185 | if students: |
| 178 | print(f'Inserting {len(students)}') | 186 | print(f'Inserting {len(students)}') |
| 179 | insert_students_into_db(session, students) | 187 | insert_students_into_db(session, students) |
| 180 | 188 | ||
| 181 | - for s in args.update: | ||
| 182 | - print(f'Updating password of: {s}') | ||
| 183 | - u = session.query(Student).get(s) | ||
| 184 | - pw = (args.pw or s).encode('utf-8') | ||
| 185 | - u.password = bcrypt.hashpw(pw, bcrypt.gensalt()) | 189 | + for student_id in args.update: |
| 190 | + print(f'Updating password of: {student_id}') | ||
| 191 | + student = session.query(Student).get(student_id) | ||
| 192 | + password = (args.pw or student_id).encode('utf-8') | ||
| 193 | + student.password = bcrypt.hashpw(password, bcrypt.gensalt()) | ||
| 186 | session.commit() | 194 | session.commit() |
| 187 | 195 | ||
| 188 | show_students_in_database(session, args.verbose) | 196 | show_students_in_database(session, args.verbose) |
perguntations/models.py
| 1 | +''' | ||
| 2 | +Database tables | ||
| 3 | +''' | ||
| 4 | + | ||
| 1 | 5 | ||
| 2 | from sqlalchemy import Column, ForeignKey, Integer, Float, String | 6 | from sqlalchemy import Column, ForeignKey, Integer, Float, String |
| 3 | from sqlalchemy.ext.declarative import declarative_base | 7 | from sqlalchemy.ext.declarative import declarative_base |
| @@ -11,6 +15,7 @@ Base = declarative_base() | @@ -11,6 +15,7 @@ Base = declarative_base() | ||
| 11 | 15 | ||
| 12 | # ---------------------------------------------------------------------------- | 16 | # ---------------------------------------------------------------------------- |
| 13 | class Student(Base): | 17 | class Student(Base): |
| 18 | + '''Student table''' | ||
| 14 | __tablename__ = 'students' | 19 | __tablename__ = 'students' |
| 15 | id = Column(String, primary_key=True) | 20 | id = Column(String, primary_key=True) |
| 16 | name = Column(String) | 21 | name = Column(String) |
| @@ -29,6 +34,7 @@ class Student(Base): | @@ -29,6 +34,7 @@ class Student(Base): | ||
| 29 | 34 | ||
| 30 | # ---------------------------------------------------------------------------- | 35 | # ---------------------------------------------------------------------------- |
| 31 | class Test(Base): | 36 | class Test(Base): |
| 37 | + '''Test table''' | ||
| 32 | __tablename__ = 'tests' | 38 | __tablename__ = 'tests' |
| 33 | id = Column(Integer, primary_key=True) # auto_increment | 39 | id = Column(Integer, primary_key=True) # auto_increment |
| 34 | ref = Column(String) | 40 | ref = Column(String) |
| @@ -61,6 +67,7 @@ class Test(Base): | @@ -61,6 +67,7 @@ class Test(Base): | ||
| 61 | 67 | ||
| 62 | # --------------------------------------------------------------------------- | 68 | # --------------------------------------------------------------------------- |
| 63 | class Question(Base): | 69 | class Question(Base): |
| 70 | + '''Question table''' | ||
| 64 | __tablename__ = 'questions' | 71 | __tablename__ = 'questions' |
| 65 | id = Column(Integer, primary_key=True) # auto_increment | 72 | id = Column(Integer, primary_key=True) # auto_increment |
| 66 | ref = Column(String) | 73 | ref = Column(String) |
perguntations/parser_markdown.py
| 1 | +''' | ||
| 2 | +Parse markdown and generate HTML | ||
| 3 | +Includes support for LaTeX formulas | ||
| 4 | +''' | ||
| 5 | + | ||
| 6 | + | ||
| 1 | # python standard library | 7 | # python standard library |
| 2 | import logging | 8 | import logging |
| 3 | import re | 9 | import re |
| @@ -33,18 +39,19 @@ class MathBlockLexer(mistune.BlockLexer): | @@ -33,18 +39,19 @@ class MathBlockLexer(mistune.BlockLexer): | ||
| 33 | rules = MathBlockGrammar() | 39 | rules = MathBlockGrammar() |
| 34 | super().__init__(rules, **kwargs) | 40 | super().__init__(rules, **kwargs) |
| 35 | 41 | ||
| 36 | - def parse_block_math(self, m): | ||
| 37 | - """Parse a $$math$$ block""" | 42 | + def parse_block_math(self, math): |
| 43 | + '''Parse a $$math$$ block''' | ||
| 38 | self.tokens.append({ | 44 | self.tokens.append({ |
| 39 | 'type': 'block_math', | 45 | 'type': 'block_math', |
| 40 | - 'text': m.group(1) | 46 | + 'text': math.group(1) |
| 41 | }) | 47 | }) |
| 42 | 48 | ||
| 43 | - def parse_latex_environment(self, m): | 49 | + def parse_latex_environment(self, math): |
| 50 | + '''Parse latex environment in formula''' | ||
| 44 | self.tokens.append({ | 51 | self.tokens.append({ |
| 45 | 'type': 'latex_environment', | 52 | 'type': 'latex_environment', |
| 46 | - 'name': m.group(1), | ||
| 47 | - 'text': m.group(2) | 53 | + 'name': math.group(1), |
| 54 | + 'text': math.group(2) | ||
| 48 | }) | 55 | }) |
| 49 | 56 | ||
| 50 | 57 | ||
| @@ -62,11 +69,11 @@ class MathInlineLexer(mistune.InlineLexer): | @@ -62,11 +69,11 @@ class MathInlineLexer(mistune.InlineLexer): | ||
| 62 | rules = MathInlineGrammar() | 69 | rules = MathInlineGrammar() |
| 63 | super().__init__(renderer, rules, **kwargs) | 70 | super().__init__(renderer, rules, **kwargs) |
| 64 | 71 | ||
| 65 | - def output_math(self, m): | ||
| 66 | - return self.renderer.inline_math(m.group(1)) | 72 | + def output_math(self, math): |
| 73 | + return self.renderer.inline_math(math.group(1)) | ||
| 67 | 74 | ||
| 68 | - def output_block_math(self, m): | ||
| 69 | - return self.renderer.block_math(m.group(1)) | 75 | + def output_block_math(self, math): |
| 76 | + return self.renderer.block_math(math.group(1)) | ||
| 70 | 77 | ||
| 71 | 78 | ||
| 72 | class MarkdownWithMath(mistune.Markdown): | 79 | class MarkdownWithMath(mistune.Markdown): |
| @@ -104,6 +111,7 @@ class HighlightRenderer(mistune.Renderer): | @@ -104,6 +111,7 @@ class HighlightRenderer(mistune.Renderer): | ||
| 104 | + header + '</thead><tbody>' + body + '</tbody></table>' | 111 | + header + '</thead><tbody>' + body + '</tbody></table>' |
| 105 | 112 | ||
| 106 | def image(self, src, title, alt): | 113 | def image(self, src, title, alt): |
| 114 | + '''render image''' | ||
| 107 | alt = mistune.escape(alt, quote=True) | 115 | alt = mistune.escape(alt, quote=True) |
| 108 | if title is not None: | 116 | if title is not None: |
| 109 | if title: # not empty string, show as caption | 117 | if title: # not empty string, show as caption |
| @@ -124,22 +132,26 @@ class HighlightRenderer(mistune.Renderer): | @@ -124,22 +132,26 @@ class HighlightRenderer(mistune.Renderer): | ||
| 124 | </div> | 132 | </div> |
| 125 | ''' | 133 | ''' |
| 126 | 134 | ||
| 127 | - else: # title indefined, show as inline image | ||
| 128 | - return f''' | ||
| 129 | - <img src="/file?ref={self.qref}&image={src}" | ||
| 130 | - class="figure-img img-fluid" alt="{alt}" title="{title}"> | ||
| 131 | - ''' | 135 | + # title indefined, show as inline image |
| 136 | + return f''' | ||
| 137 | + <img src="/file?ref={self.qref}&image={src}" | ||
| 138 | + class="figure-img img-fluid" alt="{alt}" title="{title}"> | ||
| 139 | + ''' | ||
| 132 | 140 | ||
| 133 | # Pass math through unaltered - mathjax does the rendering in the browser | 141 | # Pass math through unaltered - mathjax does the rendering in the browser |
| 134 | def block_math(self, text): | 142 | def block_math(self, text): |
| 143 | + '''bypass block math''' | ||
| 135 | return fr'$$ {text} $$' | 144 | return fr'$$ {text} $$' |
| 136 | 145 | ||
| 137 | def latex_environment(self, name, text): | 146 | def latex_environment(self, name, text): |
| 147 | + '''bypass latex environment''' | ||
| 138 | return fr'\begin{{{name}}} {text} \end{{{name}}}' | 148 | return fr'\begin{{{name}}} {text} \end{{{name}}}' |
| 139 | 149 | ||
| 140 | def inline_math(self, text): | 150 | def inline_math(self, text): |
| 151 | + '''bypass inline math''' | ||
| 141 | return fr'$$$ {text} $$$' | 152 | return fr'$$$ {text} $$$' |
| 142 | 153 | ||
| 143 | 154 | ||
| 144 | def md_to_html(qref='.'): | 155 | def md_to_html(qref='.'): |
| 156 | + '''markdown to html interface''' | ||
| 145 | return MarkdownWithMath(HighlightRenderer(qref=qref)) | 157 | return MarkdownWithMath(HighlightRenderer(qref=qref)) |
perguntations/serve.py
| @@ -12,12 +12,10 @@ import sys | @@ -12,12 +12,10 @@ import sys | ||
| 12 | import base64 | 12 | import base64 |
| 13 | import uuid | 13 | import uuid |
| 14 | import logging.config | 14 | import logging.config |
| 15 | -# import argparse | ||
| 16 | import mimetypes | 15 | import mimetypes |
| 17 | import signal | 16 | import signal |
| 18 | import functools | 17 | import functools |
| 19 | import json | 18 | import json |
| 20 | -# import ssl | ||
| 21 | 19 | ||
| 22 | # user installed libraries | 20 | # user installed libraries |
| 23 | import tornado.ioloop | 21 | import tornado.ioloop |
| @@ -30,9 +28,10 @@ from perguntations.parser_markdown import md_to_html | @@ -30,9 +28,10 @@ from perguntations.parser_markdown import md_to_html | ||
| 30 | 28 | ||
| 31 | 29 | ||
| 32 | # ---------------------------------------------------------------------------- | 30 | # ---------------------------------------------------------------------------- |
| 33 | -# Web Application. Routes to handler classes. | ||
| 34 | -# ---------------------------------------------------------------------------- | ||
| 35 | class WebApplication(tornado.web.Application): | 31 | class WebApplication(tornado.web.Application): |
| 32 | + ''' | ||
| 33 | + Web Application. Routes to handler classes. | ||
| 34 | + ''' | ||
| 36 | def __init__(self, testapp, debug=False): | 35 | def __init__(self, testapp, debug=False): |
| 37 | handlers = [ | 36 | handlers = [ |
| 38 | (r'/login', LoginHandler), | 37 | (r'/login', LoginHandler), |
| @@ -73,9 +72,11 @@ def admin_only(func): | @@ -73,9 +72,11 @@ def admin_only(func): | ||
| 73 | 72 | ||
| 74 | 73 | ||
| 75 | # ---------------------------------------------------------------------------- | 74 | # ---------------------------------------------------------------------------- |
| 76 | -# Base handler. Other handlers will inherit this one. | ||
| 77 | -# ---------------------------------------------------------------------------- | ||
| 78 | class BaseHandler(tornado.web.RequestHandler): | 75 | class BaseHandler(tornado.web.RequestHandler): |
| 76 | + ''' | ||
| 77 | + Base handler. Other handlers will inherit this one. | ||
| 78 | + ''' | ||
| 79 | + | ||
| 79 | @property | 80 | @property |
| 80 | def testapp(self): | 81 | def testapp(self): |
| 81 | ''' | 82 | ''' |
| @@ -152,8 +153,10 @@ class BaseHandler(tornado.web.RequestHandler): | @@ -152,8 +153,10 @@ class BaseHandler(tornado.web.RequestHandler): | ||
| 152 | # AdminSocketHandler.send_updates(chat) # send to clients | 153 | # AdminSocketHandler.send_updates(chat) # send to clients |
| 153 | 154 | ||
| 154 | 155 | ||
| 155 | -# --- ADMIN ------------------------------------------------------------------ | 156 | +# ---------------------------------------------------------------------------- |
| 156 | class AdminHandler(BaseHandler): | 157 | class AdminHandler(BaseHandler): |
| 158 | + '''Handle /admin''' | ||
| 159 | + | ||
| 157 | # SUPPORTED_METHODS = ['GET', 'POST'] | 160 | # SUPPORTED_METHODS = ['GET', 'POST'] |
| 158 | 161 | ||
| 159 | @tornado.web.authenticated | 162 | @tornado.web.authenticated |
| @@ -209,19 +212,15 @@ class AdminHandler(BaseHandler): | @@ -209,19 +212,15 @@ class AdminHandler(BaseHandler): | ||
| 209 | 212 | ||
| 210 | 213 | ||
| 211 | # ---------------------------------------------------------------------------- | 214 | # ---------------------------------------------------------------------------- |
| 212 | -# /login | ||
| 213 | -# ---------------------------------------------------------------------------- | ||
| 214 | class LoginHandler(BaseHandler): | 215 | class LoginHandler(BaseHandler): |
| 216 | + '''Handle /login''' | ||
| 217 | + | ||
| 215 | def get(self): | 218 | def get(self): |
| 216 | - ''' | ||
| 217 | - Render login page. | ||
| 218 | - ''' | 219 | + '''Render login page.''' |
| 219 | self.render('login.html', error='') | 220 | self.render('login.html', error='') |
| 220 | 221 | ||
| 221 | async def post(self): | 222 | async def post(self): |
| 222 | - ''' | ||
| 223 | - Authenticates student (prefix 'l' are removed) and login. | ||
| 224 | - ''' | 223 | + '''Authenticates student (prefix 'l' are removed) and login.''' |
| 225 | 224 | ||
| 226 | uid = self.get_body_argument('uid').lstrip('l') | 225 | uid = self.get_body_argument('uid').lstrip('l') |
| 227 | password = self.get_body_argument('pw') | 226 | password = self.get_body_argument('pw') |
| @@ -235,14 +234,12 @@ class LoginHandler(BaseHandler): | @@ -235,14 +234,12 @@ class LoginHandler(BaseHandler): | ||
| 235 | 234 | ||
| 236 | 235 | ||
| 237 | # ---------------------------------------------------------------------------- | 236 | # ---------------------------------------------------------------------------- |
| 238 | -# /logout | ||
| 239 | -# ---------------------------------------------------------------------------- | ||
| 240 | class LogoutHandler(BaseHandler): | 237 | class LogoutHandler(BaseHandler): |
| 238 | + '''Handle /logout''' | ||
| 239 | + | ||
| 241 | @tornado.web.authenticated | 240 | @tornado.web.authenticated |
| 242 | def get(self): | 241 | def get(self): |
| 243 | - ''' | ||
| 244 | - Logs out a user. | ||
| 245 | - ''' | 242 | + '''Logs out a user.''' |
| 246 | self.clear_cookie('user') | 243 | self.clear_cookie('user') |
| 247 | self.redirect('/') | 244 | self.redirect('/') |
| 248 | 245 | ||
| @@ -251,9 +248,10 @@ class LogoutHandler(BaseHandler): | @@ -251,9 +248,10 @@ class LogoutHandler(BaseHandler): | ||
| 251 | 248 | ||
| 252 | 249 | ||
| 253 | # ---------------------------------------------------------------------------- | 250 | # ---------------------------------------------------------------------------- |
| 254 | -# handles root / to redirect students to /test and admininistrator to /admin | ||
| 255 | -# ---------------------------------------------------------------------------- | ||
| 256 | class RootHandler(BaseHandler): | 251 | class RootHandler(BaseHandler): |
| 252 | + ''' | ||
| 253 | + Handles / to redirect students and admin to /test and /admin, resp. | ||
| 254 | + ''' | ||
| 257 | 255 | ||
| 258 | @tornado.web.authenticated | 256 | @tornado.web.authenticated |
| 259 | def get(self): | 257 | def get(self): |
perguntations/tools.py
| 1 | +''' | ||
| 2 | +This module contains helper functions to: | ||
| 3 | +- load yaml files and report errors | ||
| 4 | +- run external programs (sync and async) | ||
| 5 | +''' | ||
| 6 | + | ||
| 1 | 7 | ||
| 2 | # python standard library | 8 | # python standard library |
| 3 | import asyncio | 9 | import asyncio |
| @@ -15,105 +21,119 @@ logger = logging.getLogger(__name__) | @@ -15,105 +21,119 @@ logger = logging.getLogger(__name__) | ||
| 15 | 21 | ||
| 16 | 22 | ||
| 17 | # --------------------------------------------------------------------------- | 23 | # --------------------------------------------------------------------------- |
| 18 | -# load data from yaml file | ||
| 19 | -# --------------------------------------------------------------------------- | ||
| 20 | def load_yaml(filename: str, default: Any = None) -> Any: | 24 | def load_yaml(filename: str, default: Any = None) -> Any: |
| 25 | + '''load data from yaml file''' | ||
| 26 | + | ||
| 21 | filename = path.expanduser(filename) | 27 | filename = path.expanduser(filename) |
| 22 | try: | 28 | try: |
| 23 | - f = open(filename, 'r', encoding='utf-8') | ||
| 24 | - except Exception as e: | ||
| 25 | - logger.error(e) | 29 | + file = open(filename, 'r', encoding='utf-8') |
| 30 | + except Exception as exc: | ||
| 31 | + logger.error(exc) | ||
| 26 | if default is not None: | 32 | if default is not None: |
| 27 | return default | 33 | return default |
| 28 | - else: | ||
| 29 | - raise | 34 | + raise |
| 30 | 35 | ||
| 31 | - with f: | 36 | + with file: |
| 32 | try: | 37 | try: |
| 33 | - return yaml.safe_load(f) | ||
| 34 | - except yaml.YAMLError as e: | ||
| 35 | - logger.error(str(e).replace('\n', ' ')) | 38 | + return yaml.safe_load(file) |
| 39 | + except yaml.YAMLError as exc: | ||
| 40 | + logger.error(str(exc).replace('\n', ' ')) | ||
| 36 | if default is not None: | 41 | if default is not None: |
| 37 | return default | 42 | return default |
| 38 | - else: | ||
| 39 | - raise | 43 | + raise |
| 40 | 44 | ||
| 41 | 45 | ||
| 42 | # --------------------------------------------------------------------------- | 46 | # --------------------------------------------------------------------------- |
| 43 | -# Runs a script and returns its stdout parsed as yaml, or None on error. | ||
| 44 | -# The script is run in another process but this function blocks waiting | ||
| 45 | -# for its termination. | ||
| 46 | -# --------------------------------------------------------------------------- | ||
| 47 | def run_script(script: str, | 47 | def run_script(script: str, |
| 48 | - args: List[str] = [], | 48 | + args: List[str], |
| 49 | stdin: str = '', | 49 | stdin: str = '', |
| 50 | - timeout: int = 2) -> Any: | ||
| 51 | - | 50 | + timeout: int = 3) -> Any: |
| 51 | + ''' | ||
| 52 | + Runs a script and returns its stdout parsed as yaml, or None on error. | ||
| 53 | + The script is run in another process but this function blocks waiting | ||
| 54 | + for its termination. | ||
| 55 | + ''' | ||
| 56 | + logger.info('run_script "%s"', script) | ||
| 57 | + | ||
| 58 | + output = None | ||
| 52 | script = path.expanduser(script) | 59 | script = path.expanduser(script) |
| 60 | + cmd = [script] + [str(a) for a in args] | ||
| 61 | + | ||
| 62 | + # --- run process | ||
| 53 | try: | 63 | try: |
| 54 | - cmd = [script] + [str(a) for a in args] | ||
| 55 | - p = subprocess.run(cmd, | ||
| 56 | - input=stdin, | ||
| 57 | - stdout=subprocess.PIPE, | ||
| 58 | - stderr=subprocess.STDOUT, | ||
| 59 | - universal_newlines=True, | ||
| 60 | - timeout=timeout, | ||
| 61 | - ) | ||
| 62 | - except FileNotFoundError: | ||
| 63 | - logger.error(f'Can not execute script "{script}": not found.') | ||
| 64 | - except PermissionError: | ||
| 65 | - logger.error(f'Can not execute script "{script}": wrong permissions.') | 64 | + proc = subprocess.run(cmd, |
| 65 | + input=stdin, | ||
| 66 | + stdout=subprocess.PIPE, | ||
| 67 | + stderr=subprocess.STDOUT, | ||
| 68 | + universal_newlines=True, | ||
| 69 | + timeout=timeout, | ||
| 70 | + check=False, | ||
| 71 | + ) | ||
| 66 | except OSError: | 72 | except OSError: |
| 67 | - logger.error(f'Can not execute script "{script}": unknown reason.') | 73 | + logger.error('Can not execute script "%s".', script) |
| 74 | + return output | ||
| 68 | except subprocess.TimeoutExpired: | 75 | except subprocess.TimeoutExpired: |
| 69 | - logger.error(f'Timeout {timeout}s exceeded while running "{script}".') | 76 | + logger.error('Timeout %ds exceeded running "%s".', timeout, script) |
| 77 | + return output | ||
| 70 | except Exception: | 78 | except Exception: |
| 71 | - logger.error(f'An Exception ocurred running {script}.') | ||
| 72 | - else: | ||
| 73 | - if p.returncode != 0: | ||
| 74 | - logger.error(f'Return code {p.returncode} running "{script}".') | ||
| 75 | - else: | ||
| 76 | - try: | ||
| 77 | - output = yaml.safe_load(p.stdout) | ||
| 78 | - except Exception: | ||
| 79 | - logger.error(f'Error parsing yaml output of "{script}"') | ||
| 80 | - else: | ||
| 81 | - return output | 79 | + logger.error('An Exception ocurred running "%s".', script) |
| 80 | + return output | ||
| 81 | + | ||
| 82 | + # --- check return code | ||
| 83 | + if proc.returncode != 0: | ||
| 84 | + logger.error('Return code %d running "%s".', proc.returncode, script) | ||
| 85 | + return output | ||
| 86 | + | ||
| 87 | + # --- parse yaml | ||
| 88 | + try: | ||
| 89 | + output = yaml.safe_load(proc.stdout) | ||
| 90 | + except yaml.YAMLError: | ||
| 91 | + logger.error('Error parsing yaml output of "%s".', script) | ||
| 92 | + | ||
| 93 | + return output | ||
| 82 | 94 | ||
| 83 | 95 | ||
| 84 | -# ---------------------------------------------------------------------------- | ||
| 85 | -# Same as above, but asynchronous | ||
| 86 | # ---------------------------------------------------------------------------- | 96 | # ---------------------------------------------------------------------------- |
| 87 | async def run_script_async(script: str, | 97 | async def run_script_async(script: str, |
| 88 | - args: List[str] = [], | 98 | + args: List[str], |
| 89 | stdin: str = '', | 99 | stdin: str = '', |
| 90 | - timeout: int = 2) -> Any: | 100 | + timeout: int = 3) -> Any: |
| 101 | + '''Same as above, but asynchronous''' | ||
| 91 | 102 | ||
| 92 | script = path.expanduser(script) | 103 | script = path.expanduser(script) |
| 93 | args = [str(a) for a in args] | 104 | args = [str(a) for a in args] |
| 105 | + output = None | ||
| 94 | 106 | ||
| 95 | - p = await asyncio.create_subprocess_exec( | ||
| 96 | - script, *args, | ||
| 97 | - stdin=asyncio.subprocess.PIPE, | ||
| 98 | - stdout=asyncio.subprocess.PIPE, | ||
| 99 | - stderr=asyncio.subprocess.DEVNULL, | ||
| 100 | - ) | ||
| 101 | - | 107 | + # --- start process |
| 102 | try: | 108 | try: |
| 103 | - stdout, stderr = await asyncio.wait_for( | ||
| 104 | - p.communicate(input=stdin.encode('utf-8')), | ||
| 105 | - timeout=timeout | 109 | + proc = await asyncio.create_subprocess_exec( |
| 110 | + script, *args, | ||
| 111 | + stdin=asyncio.subprocess.PIPE, | ||
| 112 | + stdout=asyncio.subprocess.PIPE, | ||
| 113 | + stderr=asyncio.subprocess.DEVNULL, | ||
| 106 | ) | 114 | ) |
| 115 | + except OSError: | ||
| 116 | + logger.error('Can not execute script "%s".', script) | ||
| 117 | + return output | ||
| 118 | + | ||
| 119 | + # --- send input and wait for termination | ||
| 120 | + try: | ||
| 121 | + stdout, _ = await asyncio.wait_for( | ||
| 122 | + proc.communicate(input=stdin.encode('utf-8')), | ||
| 123 | + timeout=timeout) | ||
| 107 | except asyncio.TimeoutError: | 124 | except asyncio.TimeoutError: |
| 108 | - logger.warning(f'Timeout {timeout}s running script "{script}".') | ||
| 109 | - return | 125 | + logger.warning('Timeout %ds running script "%s".', timeout, script) |
| 126 | + return output | ||
| 110 | 127 | ||
| 111 | - if p.returncode != 0: | ||
| 112 | - logger.error(f'Return code {p.returncode} running "{script}".') | ||
| 113 | - else: | ||
| 114 | - try: | ||
| 115 | - output = yaml.safe_load(stdout.decode('utf-8', 'ignore')) | ||
| 116 | - except Exception: | ||
| 117 | - logger.error(f'Error parsing yaml output of "{script}"') | ||
| 118 | - else: | ||
| 119 | - return output | 128 | + # --- check return code |
| 129 | + if proc.returncode != 0: | ||
| 130 | + logger.error('Return code %d running "%s".', proc.returncode, script) | ||
| 131 | + return output | ||
| 132 | + | ||
| 133 | + # --- parse yaml | ||
| 134 | + try: | ||
| 135 | + output = yaml.safe_load(stdout.decode('utf-8', 'ignore')) | ||
| 136 | + except yaml.YAMLError: | ||
| 137 | + logger.error('Error parsing yaml output of "%s"', script) | ||
| 138 | + | ||
| 139 | + return output |