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
@@ -2,19 +2,18 @@ BUGS: @@ -2,19 +2,18 @@ BUGS:
2 2
3 - questions hardcoded in LearnApp. 3 - questions hardcoded in LearnApp.
4 - database hardcoded in LearnApp. 4 - database hardcoded in LearnApp.
5 -- como gerar key para secure cookie.  
6 - implementar xsrf. Ver [http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection]() 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 TODO: 7 TODO:
10 8
11 -- gravar answers -> db  
12 - como gerar uma sequencia de perguntas? 9 - como gerar uma sequencia de perguntas?
13 - generators not working: bcrypt (ver blog) 10 - generators not working: bcrypt (ver blog)
14 - implementar navegacao radio/checkbox. cursor cima/baixo, espaco selecciona, enter submete. 11 - implementar navegacao radio/checkbox. cursor cima/baixo, espaco selecciona, enter submete.
15 12
16 SOLVED: 13 SOLVED:
17 14
  15 +- gravar answers -> db
  16 +- como gerar key para secure cookie.
18 - https. certificados selfsigned, no-ip nao suporta certificados 17 - https. certificados selfsigned, no-ip nao suporta certificados
19 - reset ao servidor mantem cookie no broser e rebenta. necessario fazer logout. 18 - reset ao servidor mantem cookie no broser e rebenta. necessario fazer logout.
20 - models.py tabela de testes não faz sentido. 19 - models.py tabela de testes não faz sentido.
1 1
2 import random 2 import random
3 from contextlib import contextmanager # `with` statement in db sessions 3 from contextlib import contextmanager # `with` statement in db sessions
  4 +from datetime import datetime
4 5
5 # libs 6 # libs
6 import bcrypt 7 import bcrypt
7 from sqlalchemy import create_engine 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 # this project 11 # this project
11 import questions 12 import questions
12 -from models import Student  
13 - 13 +from models import Student, Answer
14 14
15 # ============================================================================ 15 # ============================================================================
16 # LearnApp - application logic 16 # LearnApp - application logic
@@ -22,11 +22,13 @@ class LearnApp(object): @@ -22,11 +22,13 @@ class LearnApp(object):
22 self.online = {} 22 self.online = {}
23 23
24 # connect to database and check registered students 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 try: 29 try:
28 with self.db_session() as s: 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 except Exception as e: 32 except Exception as e:
31 print('Database not usable.') 33 print('Database not usable.')
32 raise e 34 raise e
@@ -34,32 +36,25 @@ class LearnApp(object): @@ -34,32 +36,25 @@ class LearnApp(object):
34 print('Database has {} students registered.'.format(n)) 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 with self.db_session() as s: 40 with self.db_session() as s:
41 student = s.query(Student).filter(Student.id == uid).one_or_none() 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 # logout 60 # logout
@@ -67,14 +62,6 @@ class LearnApp(object): @@ -67,14 +62,6 @@ class LearnApp(object):
67 del self.online[uid] # FIXME save current question? 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 def get_current_question(self, uid): 65 def get_current_question(self, uid):
79 return self.online[uid].get('current', None) 66 return self.online[uid].get('current', None)
80 67
@@ -83,32 +70,61 @@ class LearnApp(object): @@ -83,32 +70,61 @@ class LearnApp(object):
83 return self.online[uid].get('name', '') 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 # check answer and if correct returns new question, otherise returns None 83 # check answer and if correct returns new question, otherise returns None
87 def check_answer(self, uid, answer): 84 def check_answer(self, uid, answer):
88 question = self.get_current_question(uid) 85 question = self.get_current_question(uid)
89 - print('------------------------------')  
90 print(question) 86 print(question)
91 print(answer) 87 print(answer)
92 88
93 if question is not None: 89 if question is not None:
  90 + question['finish_time'] = datetime.now()
94 grade = question.correct(answer) # correct answer 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 else: 111 else:
103 print('FIRST QUESTION') 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 # helper to manage db sessions using the `with` statement, for example 118 # helper to manage db sessions using the `with` statement, for example
108 # with self.db_session() as s: s.query(...) 119 # with self.db_session() as s: s.query(...)
109 @contextmanager 120 @contextmanager
110 def db_session(self): 121 def db_session(self):
  122 + session = self.Session()
111 try: 123 try:
112 - yield self.Session() 124 + yield session
  125 + session.commit()
  126 + except:
  127 + session.rollback()
  128 + raise
113 finally: 129 finally:
114 - self.Session.remove() 130 + session.close()
@@ -38,131 +38,17 @@ logger = logging.getLogger(__name__) @@ -38,131 +38,17 @@ logger = logging.getLogger(__name__)
38 38
39 try: 39 try:
40 import yaml 40 import yaml
41 - # import markdown  
42 except ImportError: 41 except ImportError:
43 logger.critical('Python package missing. See README.md for instructions.') 42 logger.critical('Python package missing. See README.md for instructions.')
44 sys.exit(1) 43 sys.exit(1)
45 else: 44 else:
46 - # all regular expressions in yaml files, for example 45 + # allow regular expressions in yaml files, for example
47 # correct: !regex '[aA]zul' 46 # correct: !regex '[aA]zul'
48 yaml.add_constructor('!regex', lambda l, n: re.compile(l.construct_scalar(n))) 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,3 +378,120 @@ class QuestionInformation(Question):
492 super().correct(answer) 378 super().correct(answer)
493 self['grade'] = 1.0 # always "correct" but points should be zero! 379 self['grade'] = 1.0 # always "correct" but points should be zero!
494 return self['grade'] 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 +
@@ -48,7 +48,7 @@ class WebApplication(tornado.web.Application): @@ -48,7 +48,7 @@ class WebApplication(tornado.web.Application):
48 'static_path': os.path.join(os.path.dirname(__file__), 'static'), 48 'static_path': os.path.join(os.path.dirname(__file__), 'static'),
49 'static_url_prefix': '/static/', # this is the default 49 'static_url_prefix': '/static/', # this is the default
50 'xsrf_cookies': False, # FIXME see how to do it... 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 'login_url': '/login', 52 'login_url': '/login',
53 'debug': True, 53 'debug': True,
54 } 54 }
@@ -88,7 +88,7 @@ class LoginHandler(BaseHandler): @@ -88,7 +88,7 @@ class LoginHandler(BaseHandler):
88 pw = self.get_body_argument('pw') 88 pw = self.get_body_argument('pw')
89 # print(f'login.post: user={uid}, pw={pw}') 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 print('login ok') 92 print('login ok')
93 self.set_secure_cookie("user", str(uid), expires_days=30) 93 self.set_secure_cookie("user", str(uid), expires_days=30)
94 self.redirect(self.get_argument("next", "/")) 94 self.redirect(self.get_argument("next", "/"))
@@ -175,4 +175,4 @@ def main(): @@ -175,4 +175,4 @@ def main():
175 175
176 # ---------------------------------------------------------------------------- 176 # ----------------------------------------------------------------------------
177 if __name__ == "__main__": 177 if __name__ == "__main__":
178 - main()  
179 \ No newline at end of file 178 \ No newline at end of file
  179 + main()