Commit 714897fd3ab14495c18ef8ac8a65a0aec0567e2f

Authored by Miguel Barão
1 parent 7731e737
Exists in master and in 1 other branch dev

- generated random key for secure cookies

- all answers saved in the database
- sqlalchemy no longer uses scoped_session. it could cause problems mixing threading and asyncronous calls.
Showing 4 changed files with 186 additions and 168 deletions   Show diff stats
BUGS.md
... ... @@ -2,19 +2,18 @@ BUGS:
2 2  
3 3 - questions hardcoded in LearnApp.
4 4 - database hardcoded in LearnApp.
5   -- como gerar key para secure cookie.
6 5 - implementar xsrf. Ver [http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection]()
7   -- verificar se ha questoes
8 6  
9 7 TODO:
10 8  
11   -- gravar answers -> db
12 9 - como gerar uma sequencia de perguntas?
13 10 - generators not working: bcrypt (ver blog)
14 11 - implementar navegacao radio/checkbox. cursor cima/baixo, espaco selecciona, enter submete.
15 12  
16 13 SOLVED:
17 14  
  15 +- gravar answers -> db
  16 +- como gerar key para secure cookie.
18 17 - https. certificados selfsigned, no-ip nao suporta certificados
19 18 - reset ao servidor mantem cookie no broser e rebenta. necessario fazer logout.
20 19 - models.py tabela de testes não faz sentido.
... ...
app.py
1 1  
2 2 import random
3 3 from contextlib import contextmanager # `with` statement in db sessions
  4 +from datetime import datetime
4 5  
5 6 # libs
6 7 import bcrypt
7 8 from sqlalchemy import create_engine
8   -from sqlalchemy.orm import sessionmaker, scoped_session
  9 +from sqlalchemy.orm import sessionmaker #, scoped_session
9 10  
10 11 # this project
11 12 import questions
12   -from models import Student
13   -
  13 +from models import Student, Answer
14 14  
15 15 # ============================================================================
16 16 # LearnApp - application logic
... ... @@ -22,11 +22,13 @@ class LearnApp(object):
22 22 self.online = {}
23 23  
24 24 # connect to database and check registered students
25   - engine = create_engine('sqlite:///{}'.format('students.db'), echo=False)
26   - self.Session = scoped_session(sessionmaker(bind=engine))
  25 + db = 'students.db' # FIXME
  26 + engine = create_engine(f'sqlite:///{db}', echo=False)
  27 + # self.Session = scoped_session(sessionmaker(bind=engine))
  28 + self.Session = sessionmaker(bind=engine)
27 29 try:
28 30 with self.db_session() as s:
29   - n = s.query(Student).filter(Student.id != '0').count()
  31 + n = s.query(Student).count() # filter(Student.id != '0').
30 32 except Exception as e:
31 33 print('Database not usable.')
32 34 raise e
... ... @@ -34,32 +36,25 @@ class LearnApp(object):
34 36 print('Database has {} students registered.'.format(n))
35 37  
36 38 # ------------------------------------------------------------------------
37   - def login_ok(self, uid, try_pw):
38   - print('LearnApp.login')
39   -
  39 + def login(self, uid, try_pw):
40 40 with self.db_session() as s:
41 41 student = s.query(Student).filter(Student.id == uid).one_or_none()
42 42  
43   - if student is None or student in self.online:
44   - # student does not exist
45   - return False
46   -
47   - # hashedtry = yield executor.submit(bcrypt.hashpw,
48   - # try_pw.encode('utf-8'), student.password)
49   - hashedtry = bcrypt.hashpw(try_pw.encode('utf-8'), student.password)
  43 + if student is None or student in self.online: # FIXME
  44 + return False # student does not exist or already loggeg in
50 45  
51   - if hashedtry != student.password:
52   - # wrong password
53   - return False
  46 + hashedtry = bcrypt.hashpw(try_pw.encode('utf-8'), student.password)
  47 + if hashedtry != student.password:
  48 + return False # wrong password
54 49  
55   - # success
56   - self.online[uid] = {
57   - 'name': student.name,
58   - 'number': student.id,
59   - 'current': None,
60   - }
61   - print(self.online)
62   - return True
  50 + # success
  51 + self.online[uid] = {
  52 + 'name': student.name,
  53 + 'number': student.id,
  54 + 'current': None,
  55 + }
  56 + print(self.online)
  57 + return True
63 58  
64 59 # ------------------------------------------------------------------------
65 60 # logout
... ... @@ -67,14 +62,6 @@ class LearnApp(object):
67 62 del self.online[uid] # FIXME save current question?
68 63  
69 64 # ------------------------------------------------------------------------
70   - # given the currect state, generates a new question for the student
71   - def new_question_for(self, uid):
72   - questions = list(self.factory)
73   - nextquestion = self.factory.generate(random.choice(questions))
74   - self.online[uid]['current'] = nextquestion
75   - return nextquestion
76   -
77   - # ------------------------------------------------------------------------
78 65 def get_current_question(self, uid):
79 66 return self.online[uid].get('current', None)
80 67  
... ... @@ -83,32 +70,61 @@ class LearnApp(object):
83 70 return self.online[uid].get('name', '')
84 71  
85 72 # ------------------------------------------------------------------------
  73 + # given the currect state, generates a new question for the student
  74 + def new_question_for(self, uid):
  75 + # FIXME
  76 + questions = list(self.factory)
  77 + nextquestion = self.factory.generate(random.choice(questions))
  78 + print(nextquestion)
  79 + self.online[uid]['current'] = nextquestion
  80 + return nextquestion
  81 +
  82 + # ------------------------------------------------------------------------
86 83 # check answer and if correct returns new question, otherise returns None
87 84 def check_answer(self, uid, answer):
88 85 question = self.get_current_question(uid)
89   - print('------------------------------')
90 86 print(question)
91 87 print(answer)
92 88  
93 89 if question is not None:
  90 + question['finish_time'] = datetime.now()
94 91 grade = question.correct(answer) # correct answer
95   - correct = grade > 0.99999
96   - if correct:
97   - print('CORRECT')
98   - return self.new_question_for(uid)
99   - else:
100   - print('WRONG')
101   - return None
  92 +
  93 + with self.db_session() as s:
  94 + s.add(Answer(
  95 + ref=question['ref'],
  96 + grade=question['grade'],
  97 + starttime=str(question['start_time']),
  98 + finishtime=str(question['finish_time']),
  99 + student_id=uid))
  100 + s.commit()
  101 +
  102 + correct = grade > 0.99999
  103 + if correct:
  104 + print('CORRECT')
  105 + question = self.new_question_for(uid)
  106 + question['start_time'] = datetime.now()
  107 + return question
  108 + else:
  109 + print('WRONG')
  110 + return None
102 111 else:
103 112 print('FIRST QUESTION')
104   - return self.new_question_for(uid)
  113 + question = self.new_question_for(uid)
  114 + question['start_time'] = datetime.now()
  115 + return question
105 116  
106 117 # ------------------------------------------------------------------------
107 118 # helper to manage db sessions using the `with` statement, for example
108 119 # with self.db_session() as s: s.query(...)
109 120 @contextmanager
110 121 def db_session(self):
  122 + session = self.Session()
111 123 try:
112   - yield self.Session()
  124 + yield session
  125 + session.commit()
  126 + except:
  127 + session.rollback()
  128 + raise
113 129 finally:
114   - self.Session.remove()
  130 + session.close()
... ...
questions.py
... ... @@ -38,131 +38,17 @@ logger = logging.getLogger(__name__)
38 38  
39 39 try:
40 40 import yaml
41   - # import markdown
42 41 except ImportError:
43 42 logger.critical('Python package missing. See README.md for instructions.')
44 43 sys.exit(1)
45 44 else:
46   - # all regular expressions in yaml files, for example
  45 + # allow regular expressions in yaml files, for example
47 46 # correct: !regex '[aA]zul'
48 47 yaml.add_constructor('!regex', lambda l, n: re.compile(l.construct_scalar(n)))
49 48  
50   -from tools import load_yaml, run_script #, md_to_html
  49 +from tools import load_yaml, run_script
51 50  
52 51  
53   -# ===========================================================================
54   -# This class contains a pool of questions generators from which particular
55   -# Question() instances are generated using QuestionsFactory.generate(ref).
56   -# ===========================================================================
57   -class QuestionFactory(dict):
58   - # -----------------------------------------------------------------------
59   - def __init__(self):
60   - super().__init__()
61   -
62   - # -----------------------------------------------------------------------
63   - # Add single question provided in a dictionary.
64   - # After this, each question will have at least 'ref' and 'type' keys.
65   - # -----------------------------------------------------------------------
66   - def add(self, question):
67   - # if ref missing try ref='/path/file.yaml:3'
68   - try:
69   - question.setdefault('ref', question['filename'] + ':' + str(question['index']))
70   - except KeyError:
71   - logger.error('Missing "ref". Cannot add question to the pool.')
72   - return
73   -
74   - # check duplicate references
75   - if question['ref'] in self:
76   - logger.error('Duplicate reference "{0}". Replacing the original one!'.format(question['ref']))
77   -
78   - question.setdefault('type', 'information')
79   -
80   - self[question['ref']] = question
81   - logger.debug('Added question "{0}" to the pool.'.format(question['ref']))
82   -
83   - # -----------------------------------------------------------------------
84   - # load single YAML questions file
85   - # -----------------------------------------------------------------------
86   - def load_file(self, filename, questions_dir=''):
87   - f = path.normpath(path.join(questions_dir, filename))
88   - questions = load_yaml(f, default=[])
89   -
90   - n = 0
91   - for i, q in enumerate(questions):
92   - if isinstance(q, dict):
93   - q.update({
94   - 'filename': filename,
95   - 'path': questions_dir,
96   - 'index': i # position in the file, 0 based
97   - })
98   - self.add(q) # add question
99   - n += 1 # counter
100   - else:
101   - logger.error('Question index {0} from file {1} is not a dictionary. Skipped!'.format(i, filename))
102   -
103   - logger.info('Loaded {0} questions from "{1}".'.format(n, filename))
104   -
105   - # -----------------------------------------------------------------------
106   - # load multiple YAML question files
107   - # -----------------------------------------------------------------------
108   - def load_files(self, files, questions_dir=''):
109   - for filename in files:
110   - self.load_file(filename, questions_dir)
111   -
112   - # -----------------------------------------------------------------------
113   - # Given a ref returns an instance of a descendent of Question(),
114   - # i.e. a question object (radio, checkbox, ...).
115   - # -----------------------------------------------------------------------
116   - def generate(self, ref):
117   -
118   - # Depending on the type of question, a different question class will be
119   - # instantiated. All these classes derive from the base class `Question`.
120   - types = {
121   - 'radio' : QuestionRadio,
122   - 'checkbox' : QuestionCheckbox,
123   - 'text' : QuestionText,
124   - 'text_regex': QuestionTextRegex,
125   - 'text_numeric': QuestionTextNumeric,
126   - 'textarea' : QuestionTextArea,
127   - # informative panels
128   - 'information': QuestionInformation,
129   - 'warning' : QuestionInformation,
130   - 'alert' : QuestionInformation,
131   - }
132   -
133   - # Shallow copy so that script generated questions will not replace
134   - # the original generators
135   - q = self[ref].copy()
136   -
137   - # If question is of generator type, an external program will be run
138   - # which will print a valid question in yaml format to stdout. This
139   - # output is then converted to a dictionary and `q` becomes that dict.
140   - if q['type'] == 'generator':
141   - logger.debug('Running script to generate question "{0}".'.format(q['ref']))
142   - q.setdefault('arg', '') # optional arguments will be sent to stdin
143   - script = path.normpath(path.join(q['path'], q['script']))
144   - out = run_script(script=script, stdin=q['arg'])
145   - try:
146   - q.update(out)
147   - except:
148   - q.update({
149   - 'type': 'alert',
150   - 'title': 'Erro interno',
151   - 'text': 'Ocorreu um erro a gerar esta pergunta.'
152   - })
153   - # The generator was replaced by a question but not yet instantiated
154   -
155   - # Finally we create an instance of Question()
156   - try:
157   - qinstance = types[q['type']](q) # instance with correct class
158   - except KeyError as e:
159   - logger.error('Unknown question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref']))
160   - raise e
161   - except:
162   - logger.error('Failed to create question "{0}" from file "{1}".'.format(q['ref'], q['filename']))
163   - else:
164   - logger.debug('Generated question "{}".'.format(ref))
165   - return qinstance
166 52  
167 53  
168 54 # ===========================================================================
... ... @@ -492,3 +378,120 @@ class QuestionInformation(Question):
492 378 super().correct(answer)
493 379 self['grade'] = 1.0 # always "correct" but points should be zero!
494 380 return self['grade']
  381 +
  382 +
  383 +
  384 +# ===========================================================================
  385 +# This class contains a pool of questions generators from which particular
  386 +# Question() instances are generated using QuestionsFactory.generate(ref).
  387 +# ===========================================================================
  388 +class QuestionFactory(dict):
  389 + # Depending on the type of question, a different question class will be
  390 + # instantiated. All these classes derive from the base class `Question`.
  391 + types = {
  392 + 'radio' : QuestionRadio,
  393 + 'checkbox' : QuestionCheckbox,
  394 + 'text' : QuestionText,
  395 + 'text_regex': QuestionTextRegex,
  396 + 'text_numeric': QuestionTextNumeric,
  397 + 'textarea' : QuestionTextArea,
  398 + # informative panels
  399 + 'information': QuestionInformation,
  400 + 'warning' : QuestionInformation,
  401 + 'alert' : QuestionInformation,
  402 + }
  403 +
  404 + # -----------------------------------------------------------------------
  405 + def __init__(self):
  406 + super().__init__()
  407 +
  408 + # -----------------------------------------------------------------------
  409 + # Add single question provided in a dictionary.
  410 + # After this, each question will have at least 'ref' and 'type' keys.
  411 + # -----------------------------------------------------------------------
  412 + def add(self, question):
  413 + # if ref missing try ref='/path/file.yaml:3'
  414 + try:
  415 + question.setdefault('ref', question['filename'] + ':' + str(question['index']))
  416 + except KeyError:
  417 + logger.error('Missing "ref". Cannot add question to the pool.')
  418 + return
  419 +
  420 + # check duplicate references
  421 + if question['ref'] in self:
  422 + logger.error('Duplicate reference "{0}". Replacing the original one!'.format(question['ref']))
  423 +
  424 + question.setdefault('type', 'information')
  425 +
  426 + self[question['ref']] = question
  427 + logger.debug('Added question "{0}" to the pool.'.format(question['ref']))
  428 +
  429 + # -----------------------------------------------------------------------
  430 + # load single YAML questions file
  431 + # -----------------------------------------------------------------------
  432 + def load_file(self, filename, questions_dir=''):
  433 + f = path.normpath(path.join(questions_dir, filename))
  434 + questions = load_yaml(f, default=[])
  435 +
  436 + n = 0
  437 + for i, q in enumerate(questions):
  438 + if isinstance(q, dict):
  439 + q.update({
  440 + 'filename': filename,
  441 + 'path': questions_dir,
  442 + 'index': i # position in the file, 0 based
  443 + })
  444 + self.add(q) # add question
  445 + n += 1 # counter
  446 + else:
  447 + logger.error('Question index {0} from file {1} is not a dictionary. Skipped!'.format(i, filename))
  448 +
  449 + logger.info('Loaded {0} questions from "{1}".'.format(n, filename))
  450 +
  451 + # -----------------------------------------------------------------------
  452 + # load multiple YAML question files
  453 + # -----------------------------------------------------------------------
  454 + def load_files(self, files, questions_dir=''):
  455 + for filename in files:
  456 + self.load_file(filename, questions_dir)
  457 +
  458 + # -----------------------------------------------------------------------
  459 + # Given a ref returns an instance of a descendent of Question(),
  460 + # i.e. a question object (radio, checkbox, ...).
  461 + # -----------------------------------------------------------------------
  462 + def generate(self, ref):
  463 +
  464 + # Shallow copy so that script generated questions will not replace
  465 + # the original generators
  466 + q = self[ref].copy()
  467 +
  468 + # If question is of generator type, an external program will be run
  469 + # which will print a valid question in yaml format to stdout. This
  470 + # output is then converted to a dictionary and `q` becomes that dict.
  471 + if q['type'] == 'generator':
  472 + logger.debug('Running script to generate question "{0}".'.format(q['ref']))
  473 + q.setdefault('arg', '') # optional arguments will be sent to stdin
  474 + script = path.normpath(path.join(q['path'], q['script']))
  475 + out = run_script(script=script, stdin=q['arg'])
  476 + try:
  477 + q.update(out)
  478 + except:
  479 + q.update({
  480 + 'type': 'alert',
  481 + 'title': 'Erro interno',
  482 + 'text': 'Ocorreu um erro a gerar esta pergunta.'
  483 + })
  484 + # The generator was replaced by a question but not yet instantiated
  485 +
  486 + # Finally we create an instance of Question()
  487 + try:
  488 + qinstance = self.types[q['type']](q) # instance with correct class
  489 + except KeyError as e:
  490 + logger.error('Unknown question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref']))
  491 + raise e
  492 + except:
  493 + logger.error('Failed to create question "{0}" from file "{1}".'.format(q['ref'], q['filename']))
  494 + else:
  495 + logger.debug('Generated question "{}".'.format(ref))
  496 + return qinstance
  497 +
... ...
serve.py
... ... @@ -48,7 +48,7 @@ class WebApplication(tornado.web.Application):
48 48 'static_path': os.path.join(os.path.dirname(__file__), 'static'),
49 49 'static_url_prefix': '/static/', # this is the default
50 50 'xsrf_cookies': False, # FIXME see how to do it...
51   - 'cookie_secret': base64.b64encode(uuid.uuid4().bytes), # FIXME improve!
  51 + 'cookie_secret': base64.b64encode(uuid.uuid4().bytes),
52 52 'login_url': '/login',
53 53 'debug': True,
54 54 }
... ... @@ -88,7 +88,7 @@ class LoginHandler(BaseHandler):
88 88 pw = self.get_body_argument('pw')
89 89 # print(f'login.post: user={uid}, pw={pw}')
90 90  
91   - if self.learn.login_ok(uid, pw):
  91 + if self.learn.login(uid, pw):
92 92 print('login ok')
93 93 self.set_secure_cookie("user", str(uid), expires_days=30)
94 94 self.redirect(self.get_argument("next", "/"))
... ... @@ -175,4 +175,4 @@ def main():
175 175  
176 176 # ----------------------------------------------------------------------------
177 177 if __name__ == "__main__":
178   - main()
179 178 \ No newline at end of file
  179 + main()
... ...