Commit bbfa03c996c64fca5d26a41e50006ba0bd22a5ed

Authored by Miguel Barão
1 parent 8ce81519
Exists in master and in 1 other branch dev

- fix README.md error

- refactor several functions in app.py and rename some as private
- add pylint commands
README.md
... ... @@ -39,7 +39,7 @@ This file is usually in `~/.config/pip/` in Linux and FreeBSD. In MacOS it's in
39 39 Download and install:
40 40  
41 41 ```sh
42   -git clone https://git.xdi.uevora.pt/perguntations.git
  42 +git clone https://git.xdi.uevora.pt/mjsb/perguntations.git
43 43 cd perguntations
44 44 npm install
45 45 pip3 install .
... ... @@ -225,7 +225,7 @@ Python packages can be upgraded independently of the rest using pip:
225 225  
226 226 ```sh
227 227 pip list --outdated # lists upgradable packages
228   -pip install -U something # upgrade something
  228 +pip install -U something # upgrade something
229 229 ```
230 230  
231 231 To upgrade perguntations and javascript libraries do:
... ...
package.json
... ... @@ -2,13 +2,13 @@
2 2 "description": "Javascript libraries required to run the server",
3 3 "email": "mjsb@uevora.pt",
4 4 "dependencies": {
5   - "@fortawesome/fontawesome-free": "^5.13.0",
6   - "bootstrap": "^4.4.1",
7   - "codemirror": "^5.53.2",
  5 + "@fortawesome/fontawesome-free": "^5.15.1",
  6 + "bootstrap": "^4.5.3",
  7 + "codemirror": "^5.58.1",
8 8 "datatables": "^1.10",
9 9 "jquery": "^3.5.1",
10   - "mathjax": "^3.0.5",
  10 + "mathjax": "^3.1.2",
11 11 "popper.js": "^1.16.1",
12   - "underscore": "^1.10"
  12 + "underscore": "^1.11.0"
13 13 }
14 14 }
... ...
perguntations/app.py
... ... @@ -89,23 +89,7 @@ class App():
89 89 self.unfocus = set() # set of students that have no browser focus
90 90 self.area = dict() # {uid: percent_area}
91 91  
92   - logger.info('Loading test configuration "%s".', conf["testfile"])
93   - try:
94   - testconf = load_yaml(conf['testfile'])
95   - except Exception as exc:
96   - logger.critical('Error loading test configuration YAML.')
97   - raise AppException(exc)
98   -
99   - testconf.update(conf) # command line options override configuration
100   -
101   - # start test factory
102   - try:
103   - self.testfactory = TestFactory(testconf)
104   - except TestFactoryException as exc:
105   - logger.critical(exc)
106   - raise AppException('Failed to create test factory!')
107   - else:
108   - logger.info('No errors found. Test factory ready.')
  92 + self._make_test_factory(conf)
109 93  
110 94 # connect to database and check registered students
111 95 dbfile = self.testfactory['database']
... ... @@ -117,26 +101,16 @@ class App():
117 101 num = sess.query(Student).filter(Student.id != '0').count()
118 102 except Exception:
119 103 raise AppException(f'Database unusable {dbfile}.')
120   - else:
121   - logger.info('Database "%s" has %s students.', dbfile, num)
  104 +
  105 + logger.info('Database "%s" has %s students.', dbfile, num)
122 106  
123 107 # command line option --allow-all
124 108 if conf['allow_all']:
125   - logger.info('Allowing all students:')
126   - for student in self.get_all_students():
127   - self.allow_student(student[0])
  109 + self.allow_all_students()
128 110 else:
129 111 logger.info('Students not yet allowed to login.')
130 112  
131 113 # ------------------------------------------------------------------------
132   - # FIXME unused???
133   - # def exit(self):
134   - # if len(self.online) > 1:
135   - # online_students = ', '.join(self.online)
136   - # logger.warning(f'Students still online: {online_students}')
137   - # logger.critical('----------- !!! Server terminated !!! -----------')
138   -
139   - # ------------------------------------------------------------------------
140 114 async def login(self, uid, try_pw):
141 115 '''login authentication'''
142 116 if uid not in self.allowed and uid != '0': # not allowed
... ... @@ -175,6 +149,32 @@ class App():
175 149 logger.info('"%s" logged out.', uid)
176 150  
177 151 # ------------------------------------------------------------------------
  152 + def _make_test_factory(self, conf):
  153 + '''
  154 + Setup a factory for the test
  155 + '''
  156 +
  157 + # load configuration from yaml file
  158 + logger.info('Loading test configuration "%s".', conf["testfile"])
  159 + try:
  160 + testconf = load_yaml(conf['testfile'])
  161 + except Exception as exc:
  162 + logger.critical('Error loading test configuration YAML.')
  163 + raise AppException(exc)
  164 +
  165 + testconf.update(conf) # command line options override configuration
  166 +
  167 + # start test factory
  168 + logger.info('Making test factory...')
  169 + try:
  170 + self.testfactory = TestFactory(testconf)
  171 + except TestFactoryException as exc:
  172 + logger.critical(exc)
  173 + raise AppException('Failed to create test factory!')
  174 +
  175 + logger.info('Test factory ready. No errors found.')
  176 +
  177 + # ------------------------------------------------------------------------
178 178 async def generate_test(self, uid):
179 179 '''generate a test for a given student'''
180 180 if uid in self.online:
... ... @@ -271,11 +271,11 @@ class App():
271 271 '''handles browser events the occur during the test'''
272 272 if cmd == 'focus':
273 273 if value:
274   - self.focus_student(uid)
  274 + self._focus_student(uid)
275 275 else:
276   - self.unfocus_student(uid)
  276 + self._unfocus_student(uid)
277 277 elif cmd == 'size':
278   - self.set_screen_area(uid, value)
  278 + self._set_screen_area(uid, value)
279 279  
280 280 # ------------------------------------------------------------------------
281 281 # --- GETTERS
... ... @@ -297,11 +297,11 @@ class App():
297 297  
298 298 cols = ['Aluno', 'Início'] + \
299 299 [r for question in self.testfactory['questions']
300   - for r in question['ref']]
  300 + for r in question['ref']]
301 301  
302 302 tests = {}
303   - for q in grades:
304   - student, qref, qgrade = q[:2], q[2], q[3]
  303 + for question in grades:
  304 + student, qref, qgrade = question[:2], *question[2:]
305 305 tests.setdefault(student, {})[qref] = qgrade
306 306  
307 307 rows = [{'Aluno': test[0], 'Início': test[1], **q}
... ... @@ -351,13 +351,6 @@ class App():
351 351 .filter_by(id=test_id)\
352 352 .scalar()
353 353  
354   - def get_all_students(self):
355   - '''get all students from database'''
356   - with self.db_session() as sess:
357   - return sess.query(Student.id, Student.name, Student.password)\
358   - .filter(Student.id != '0')\
359   - .order_by(Student.id)
360   -
361 354 def get_student_grades_from_test(self, uid, testid):
362 355 '''get grades of student for a given testid'''
363 356 with self.db_session() as sess:
... ... @@ -380,7 +373,15 @@ class App():
380 373 'area': self.area.get(uid, None),
381 374 'grades': self.get_student_grades_from_test(
382 375 uid, self.testfactory['ref'])
383   - } for uid, name, pw in self.get_all_students()]
  376 + } for uid, name, pw in self._get_all_students()]
  377 +
  378 + # --- private methods ----------------------------------------------------
  379 + def _get_all_students(self):
  380 + '''get all students from database'''
  381 + with self.db_session() as sess:
  382 + return sess.query(Student.id, Student.name, Student.password)\
  383 + .filter(Student.id != '0')\
  384 + .order_by(Student.id)
384 385  
385 386 # def get_allowed_students(self):
386 387 # # set of 'uid' allowed to login
... ... @@ -409,30 +410,30 @@ class App():
409 410  
410 411 def allow_all_students(self):
411 412 '''allow all students to login'''
412   - logger.info('Allowing all students...')
413   - self.allowed.update(s[0] for s in self.get_all_students())
  413 + self.allowed.update(s[0] for s in self._get_all_students())
  414 + logger.info('Allowed all students.')
414 415  
415 416 def deny_all_students(self):
416 417 '''deny all students to login'''
417 418 logger.info('Denying all students...')
418 419 self.allowed.clear()
419 420  
420   - def focus_student(self, uid):
  421 + def _focus_student(self, uid):
421 422 '''set student in focus state'''
422 423 self.unfocus.discard(uid)
423 424 logger.info('"%s" focus', uid)
424 425  
425   - def unfocus_student(self, uid):
  426 + def _unfocus_student(self, uid):
426 427 '''set student in unfocus state'''
427 428 self.unfocus.add(uid)
428 429 logger.info('"%s" unfocus', uid)
429 430  
430   - def set_screen_area(self, uid, sizes):
  431 + def _set_screen_area(self, uid, sizes):
431 432 '''set current browser area as detected in resize event'''
432 433 scr_y, scr_x, win_y, win_x = sizes
433 434 area = win_x * win_y / (scr_x * scr_y) * 100
434 435 self.area[uid] = area
435   - logger.info('"%s": area=%g%%, window=%dx%d, screen=%dx%d',
  436 + logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d',
436 437 uid, area, win_x, win_y, scr_x, scr_y)
437 438  
438 439 async def update_student_password(self, uid, password=''):
... ...
perguntations/initdb.py
1 1 #!/usr/bin/env python3
2 2  
3 3 '''
4   -Commandline utilizty to initialize and update student database
  4 +Commandline utility to initialize and update student database
5 5 '''
6 6  
7 7 # base
... ...
perguntations/main.py
... ... @@ -15,10 +15,10 @@ import sys
15 15 # from typing import Any, Dict
16 16  
17 17 # this project
18   -from .app import App, AppException
19   -from .serve import run_webserver
20   -from .tools import load_yaml
21   -from . import APP_NAME, APP_VERSION
  18 +from perguntations.app import App, AppException
  19 +from perguntations.serve import run_webserver
  20 +from perguntations.tools import load_yaml
  21 +from perguntations import APP_NAME, APP_VERSION
22 22  
23 23  
24 24 # ----------------------------------------------------------------------------
... ... @@ -123,9 +123,8 @@ def main():
123 123 'review': args.review,
124 124 }
125 125  
126   - # testapp = App(config)
127 126 try:
128   - testapp = App(config)
  127 + app = App(config)
129 128 except AppException:
130 129 logging.critical('Failed to start application.')
131 130 sys.exit(-1)
... ... @@ -145,8 +144,7 @@ def main():
145 144 sys.exit(-1)
146 145  
147 146 # --- run webserver ----------------------------------------------------
148   - run_webserver(app=testapp, ssl_opt=ssl_opt, port=args.port,
149   - debug=args.debug)
  147 + run_webserver(app=app, ssl_opt=ssl_opt, port=args.port, debug=args.debug)
150 148  
151 149  
152 150 # ----------------------------------------------------------------------------
... ...
perguntations/parser_markdown.py
  1 +
1 2 '''
2 3 Parse markdown and generate HTML
3 4 Includes support for LaTeX formulas
... ... @@ -25,12 +26,19 @@ logger = logging.getLogger(__name__)
25 26 # Block math: $$x$$ or \begin{equation}x\end{equation}
26 27 # -------------------------------------------------------------------------
27 28 class MathBlockGrammar(mistune.BlockGrammar):
  29 + '''
  30 + match block math $$x$$ and math environments begin{} end{}
  31 + '''
  32 + # pylint: disable=too-few-public-methods
28 33 block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL)
29 34 latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}",
30 35 re.DOTALL)
31 36  
32 37  
33 38 class MathBlockLexer(mistune.BlockLexer):
  39 + '''
  40 + parser for block math and latex environment
  41 + '''
34 42 default_rules = ['block_math', 'latex_environment'] \
35 43 + mistune.BlockLexer.default_rules
36 44  
... ... @@ -56,12 +64,19 @@ class MathBlockLexer(mistune.BlockLexer):
56 64  
57 65  
58 66 class MathInlineGrammar(mistune.InlineGrammar):
  67 + '''
  68 + match inline math $x$, block math $$x$$ and text
  69 + '''
  70 + # pylint: disable=too-few-public-methods
59 71 math = re.compile(r"^\$(.+?)\$", re.DOTALL)
60 72 block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL)
61 73 text = re.compile(r'^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)')
62 74  
63 75  
64 76 class MathInlineLexer(mistune.InlineLexer):
  77 + '''
  78 + render output math
  79 + '''
65 80 default_rules = ['block_math', 'math'] + mistune.InlineLexer.default_rules
66 81  
67 82 def __init__(self, renderer, rules=None, **kwargs):
... ... @@ -70,13 +85,18 @@ class MathInlineLexer(mistune.InlineLexer):
70 85 super().__init__(renderer, rules, **kwargs)
71 86  
72 87 def output_math(self, math):
  88 + '''render inline math'''
73 89 return self.renderer.inline_math(math.group(1))
74 90  
75 91 def output_block_math(self, math):
  92 + '''render block math'''
76 93 return self.renderer.block_math(math.group(1))
77 94  
78 95  
79 96 class MarkdownWithMath(mistune.Markdown):
  97 + '''
  98 + render ouput latex
  99 + '''
80 100 def __init__(self, renderer, **kwargs):
81 101 if 'inline' not in kwargs:
82 102 kwargs['inline'] = MathInlineLexer
... ... @@ -85,19 +105,25 @@ class MarkdownWithMath(mistune.Markdown):
85 105 super().__init__(renderer, **kwargs)
86 106  
87 107 def output_block_math(self):
  108 + '''render block math'''
88 109 return self.renderer.block_math(self.token['text'])
89 110  
90 111 def output_latex_environment(self):
  112 + '''render latex environment'''
91 113 return self.renderer.latex_environment(self.token['name'],
92 114 self.token['text'])
93 115  
94 116  
95 117 class HighlightRenderer(mistune.Renderer):
  118 + '''
  119 + images, tables, block code
  120 + '''
96 121 def __init__(self, qref='.'):
97 122 super().__init__(escape=True)
98 123 self.qref = qref
99 124  
100 125 def block_code(self, code, lang='text'):
  126 + '''render code block with syntax highlight'''
101 127 try:
102 128 lexer = get_lexer_by_name(lang, stripall=False)
103 129 except Exception:
... ... @@ -107,6 +133,7 @@ class HighlightRenderer(mistune.Renderer):
107 133 return highlight(code, lexer, formatter)
108 134  
109 135 def table(self, header, body):
  136 + '''render table'''
110 137 return '<table class="table table-sm"><thead class="thead-light">' \
111 138 + header + '</thead><tbody>' + body + '</tbody></table>'
112 139  
... ... @@ -141,14 +168,17 @@ class HighlightRenderer(mistune.Renderer):
141 168 # Pass math through unaltered - mathjax does the rendering in the browser
142 169 def block_math(self, text):
143 170 '''bypass block math'''
  171 + # pylint: disable=no-self-use
144 172 return fr'$$ {text} $$'
145 173  
146 174 def latex_environment(self, name, text):
147 175 '''bypass latex environment'''
  176 + # pylint: disable=no-self-use
148 177 return fr'\begin{{{name}}} {text} \end{{{name}}}'
149 178  
150 179 def inline_math(self, text):
151 180 '''bypass inline math'''
  181 + # pylint: disable=no-self-use
152 182 return fr'$$$ {text} $$$'
153 183  
154 184  
... ...
perguntations/questions.py
... ... @@ -13,7 +13,7 @@ from typing import Any, Dict, NewType
13 13 import uuid
14 14  
15 15 # this project
16   -from .tools import run_script, run_script_async
  16 +from perguntations.tools import run_script, run_script_async
17 17  
18 18 # setup logger for this module
19 19 logger = logging.getLogger(__name__)
... ...
perguntations/serve.py
1 1 #!/usr/bin/env python3
2 2  
3 3 '''
4   -Handles the web and html part of the application interface.
5   -The tornadoweb framework is used.
  4 +Handles the web, http & html part of the application interface.
  5 +Uses the tornadoweb framework.
6 6 '''
7 7  
8 8  
... ... @@ -40,8 +40,8 @@ class WebApplication(tornado.web.Application):
40 40 (r'/review', ReviewHandler),
41 41 (r'/admin', AdminHandler),
42 42 (r'/file', FileHandler),
43   - # (r'/root', MainHandler), # FIXME
44   - # (r'/ws', AdminSocketHandler),
  43 + # (r'/root', MainHandler),
  44 + # (r'/ws', AdminSocketHandler),
45 45 (r'/adminwebservice', AdminWebservice),
46 46 (r'/studentwebservice', StudentWebservice),
47 47 (r'/', RootHandler),
... ... @@ -66,7 +66,7 @@ def admin_only(func):
66 66 Decorator used to restrict access to the administrator.
67 67 Example:
68 68  
69   - @admin_only()
  69 + @admin_only
70 70 def get(self): ...
71 71 '''
72 72 @functools.wraps(func)
... ... @@ -78,6 +78,7 @@ def admin_only(func):
78 78  
79 79  
80 80 # ----------------------------------------------------------------------------
  81 +# pylint: disable=abstract-method
81 82 class BaseHandler(tornado.web.RequestHandler):
82 83 '''
83 84 Handlers should inherit this one instead of tornado.web.RequestHandler.
... ... @@ -87,7 +88,7 @@ class BaseHandler(tornado.web.RequestHandler):
87 88  
88 89 @property
89 90 def testapp(self):
90   - '''simplifies access to the application'''
  91 + '''simplifies access to the application a little bit'''
91 92 return self.application.testapp
92 93  
93 94 def get_current_user(self):
... ... @@ -158,6 +159,8 @@ class BaseHandler(tornado.web.RequestHandler):
158 159 # AdminSocketHandler.update_cache(chat) # store msgs
159 160 # AdminSocketHandler.send_updates(chat) # send to clients
160 161  
  162 +# ----------------------------------------------------------------------------
  163 +# pylint: disable=abstract-method
161 164 class StudentWebservice(BaseHandler):
162 165 '''
163 166 Receive ajax from students in the test in response from focus, unfocus and
... ... @@ -174,6 +177,7 @@ class StudentWebservice(BaseHandler):
174 177  
175 178  
176 179 # ----------------------------------------------------------------------------
  180 +# pylint: disable=abstract-method
177 181 class AdminWebservice(BaseHandler):
178 182 '''
179 183 Receive ajax requests from admin
... ... @@ -202,6 +206,7 @@ class AdminWebservice(BaseHandler):
202 206  
203 207  
204 208 # ----------------------------------------------------------------------------
  209 +# pylint: disable=abstract-method
205 210 class AdminHandler(BaseHandler):
206 211 '''Handle /admin'''
207 212  
... ... @@ -260,6 +265,7 @@ class AdminHandler(BaseHandler):
260 265  
261 266  
262 267 # ----------------------------------------------------------------------------
  268 +# pylint: disable=abstract-method
263 269 class LoginHandler(BaseHandler):
264 270 '''Handle /login'''
265 271  
... ... @@ -282,6 +288,7 @@ class LoginHandler(BaseHandler):
282 288  
283 289  
284 290 # ----------------------------------------------------------------------------
  291 +# pylint: disable=abstract-method
285 292 class LogoutHandler(BaseHandler):
286 293 '''Handle /logout'''
287 294  
... ... @@ -296,6 +303,7 @@ class LogoutHandler(BaseHandler):
296 303  
297 304  
298 305 # ----------------------------------------------------------------------------
  306 +# pylint: disable=abstract-method
299 307 class RootHandler(BaseHandler):
300 308 '''
301 309 Handles / to redirect students and admin to /test and /admin, resp.
... ... @@ -315,6 +323,7 @@ class RootHandler(BaseHandler):
315 323 # ----------------------------------------------------------------------------
316 324 # Serves files from the /public subdir of the topics.
317 325 # ----------------------------------------------------------------------------
  326 +# pylint: disable=abstract-method
318 327 class FileHandler(BaseHandler):
319 328 '''
320 329 Handles static files from questions like images, etc.
... ... @@ -366,6 +375,7 @@ class FileHandler(BaseHandler):
366 375 # ----------------------------------------------------------------------------
367 376 # Test shown to students
368 377 # ----------------------------------------------------------------------------
  378 +# pylint: disable=abstract-method
369 379 class TestHandler(BaseHandler):
370 380 '''
371 381 Generates test to student.
... ... @@ -438,20 +448,8 @@ class TestHandler(BaseHandler):
438 448 self.render('grade.html', t=test, allgrades=allgrades)
439 449  
440 450  
441   -# ----------------------------------------------------------------------------
442   -# FIXME should be a post in the test with command giveup instead of correct...
443   -# class GiveupHandler(BaseHandler):
444   -# @tornado.web.authenticated
445   -# def get(self):
446   -# uid = self.current_user
447   -# t = self.testapp.giveup_test(uid)
448   -# self.testapp.logout(uid)
449   -
450   -# # --- Show result to student
451   -# self.render('grade.html', t=t, allgrades=self.testapp.get_student_grades_from_all_tests(uid))
452   -
453   -
454 451 # --- REVIEW -----------------------------------------------------------------
  452 +# pylint: disable=abstract-method
455 453 class ReviewHandler(BaseHandler):
456 454 '''
457 455 Show test for review
... ... @@ -488,18 +486,20 @@ class ReviewHandler(BaseHandler):
488 486 with open(path.expanduser(fname)) as jsonfile:
489 487 test = json.load(jsonfile)
490 488 except OSError:
491   - logging.error('Cannot open "%s" for review.', fname)
492   - raise tornado.web.HTTPError(404) # Not Found
  489 + msg = f'Cannot open "{fname}" for review.'
  490 + logging.error(msg)
  491 + raise tornado.web.HTTPError(status_code=404, reason=msg) from None
493 492 except json.JSONDecodeError as exc:
494   - logging.error('JSON error in "%s": %s', fname, exc)
495   - raise tornado.web.HTTPError(404) # Not Found
  493 + msg = f'JSON error in "{fname}": {exc}'
  494 + logging.error(msg)
  495 + raise tornado.web.HTTPError(status_code=404, reason=msg)
496 496  
497 497 self.render('review.html', t=test, md=md_to_html,
498   - templ=self._templates)
  498 + templ=self._templates)
499 499  
500 500  
501 501 # ----------------------------------------------------------------------------
502   -def signal_handler(sig, frame):
  502 +def signal_handler(*_):
503 503 '''
504 504 Catches Ctrl-C and stops webserver
505 505 '''
... ...
perguntations/static/js/admin.js
... ... @@ -119,7 +119,7 @@ $(document).ready(function() {
119 119 var checked = d['allowed'] ? 'checked' : '';
120 120 var password_defined = d['password_defined'] ? ' <span class="badge badge-secondary"><i class="fa fa-key" aria-hidden="true"></i></span>' : '';
121 121 var hora_inicio = d['start_time'] ? ' <span class="badge badge-success"><i class="fas fa-hourglass-start"></i> ' + d['start_time'].slice(11,16) + '</span>': '';
122   - var unfocus = d['unfocus']? ' <span class="badge badge-danger">unfocus</span>' : '';
  122 + var unfocus = d['unfocus'] ? ' <span class="badge badge-danger">unfocus</span>' : '';
123 123 var area = '';
124 124 if (d['start_time'] ) {
125 125 if (d['area'] > 75)
... ...
perguntations/templates/test.html
... ... @@ -44,7 +44,7 @@
44 44 <!-- ===================================================================== -->
45 45 <body>
46 46 <!-- ===================================================================== -->
47   -<div class="progress fixed-top" style="height: 60px; border-radius: 0px;">
  47 +<div class="progress fixed-top" style="height: 61px; border-radius: 0px;">
48 48 <div class="progress-bar bg-secondary" role="progressbar" style="width: 100%" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div>
49 49 </div>
50 50  
... ...