From a566a5d6395366df475d15df76969fcc14f0c0ba Mon Sep 17 00:00:00 2001 From: Miguel Barão Date: Tue, 24 Mar 2020 13:24:48 +0000 Subject: [PATCH] refactoring. split serve.py into serve.py and main.py commandline options: --version and --port --- BUGS.md | 6 ++++++ README.md | 2 +- demo/demo.yaml | 19 ++++++++++++++++++- demo/questions/questions-tutorial.yaml | 7 ++++--- perguntations/app.py | 18 ++++++++++++++---- perguntations/main.py | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ perguntations/serve.py | 147 +++++++++++++++++---------------------------------------------------------------------------------------------------------------------------------- perguntations/test.py | 43 +++++++++++++++++++++++++++---------------- setup.py | 12 +++++++++--- 9 files changed, 240 insertions(+), 158 deletions(-) create mode 100644 perguntations/main.py diff --git a/BUGS.md b/BUGS.md index fcf4399..856980e 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,6 +1,11 @@ # BUGS +- quando scale_max não é 20, as cores das barras continuam a reflectir a escala 0,20 +- servidor ntpd para configurar a data/hora dos portateis dell +- link na pagina com a nota para voltar ao principio. +- CRITICAL se answer for `i que não preserva whitespace. Necessario adicionar
.
 - teste nao esta a mostrar imagens de vez em quando.
@@ -16,6 +21,7 @@ ou usar push (websockets?)
 - Test.reset_answers() unused.
 - mudar ref do test para test_id (ref já é usado nas perguntas)
 - incluir test_id na tabela questions (futuro semestre, pode quebrar compatibilidade).
+- na pagina admin, mostrar com cor vermelha as horas de entrada dos alunos que ja tenha excedido o tempo
 
 # TODO
 
diff --git a/README.md b/README.md
index 438ccd6..ac2c478 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ Download and install:
 git clone https://git.xdi.uevora.pt/perguntations.git
 cd perguntations
 npm install
-pip3 install .        # this must come last
+pip3 install .
 ```
 
 The command `npm` installs the javascript libraries and then `pip3` installs the python webserver. This will also install any required dependencies.
diff --git a/demo/demo.yaml b/demo/demo.yaml
index 3356dd4..28c9873 100644
--- a/demo/demo.yaml
+++ b/demo/demo.yaml
@@ -7,7 +7,7 @@ ref: tutorial
 
 # Database with student credentials and grades of all questions and tests done
 # The database is an sqlite3 file generate with the command initdb
-database: ../demo/students.db
+database: students.db
 
 # Directory where the tests including submitted answers and grades are stored.
 # The submitted tests and their corrections can be reviewed later.
@@ -64,3 +64,20 @@ questions:
   - tut-success
   - tut-warning
   - tut-alert
+
+
+# test:
+#   - ref1
+#   - block: a
+#   - block: [b, c]
+#   - ref2
+
+# blocks:
+#   a:
+#     - ref1
+#     - ref2
+#     - ref3
+#   b:
+#     - rr4
+#     - rr5
+#     - rr6
diff --git a/demo/questions/questions-tutorial.yaml b/demo/questions/questions-tutorial.yaml
index 81cb6b7..6d6428b 100644
--- a/demo/questions/questions-tutorial.yaml
+++ b/demo/questions/questions-tutorial.yaml
@@ -180,8 +180,8 @@
     A solução correcta é a **Opção 0** ou a **Opção 1**.
 
 # ----------------------------------------------------------------------------
-- ref: tut-checkbox
-  type: checkbox
+- type: checkbox
+  ref: tut-checkbox
   title: Escolha múltipla, várias opções correctas
   text: |
     As perguntas de escolha múltipla permitem apresentar um conjunto de opções
@@ -359,7 +359,7 @@
     ou em vírgula flutuante, como em `0.23`, `1e-3`.
   correct: [3.14, 3.15]
   solution: |
-    Sabems que $\pi\approx 3.14159265359$.
+    Sabemos que $\pi\approx 3.14159265359$.
     Portanto, um exemplo de uma resposta correcta é `3.1416`.
 
 # ---------------------------------------------------------------------------
@@ -556,6 +556,7 @@
     This question is not included in the test and will not shown up.
     It also lacks a "ref" and is automatically named
     `questions/questions-tutorial.yaml:0012`.
+    A warning is shown on the console about this.
     The number at the end is the index position of this question.
     Indices start at 0.
 
diff --git a/perguntations/app.py b/perguntations/app.py
index 6aefac4..548062d 100644
--- a/perguntations/app.py
+++ b/perguntations/app.py
@@ -43,6 +43,14 @@ async def hash_password(pw):
 
 # ============================================================================
 #   Application
+# state:
+#   self.Session
+#   self.online - {uid:
+#                      {'student':{...}, 'test': {...}},
+#                       ...
+#                  }
+#   self.allowd - {'123', '124', ...}
+#   self.testfactory - TestFactory
 # ============================================================================
 class App(object):
     # ------------------------------------------------------------------------
@@ -61,12 +69,12 @@ class App(object):
             session.close()
 
     # ------------------------------------------------------------------------
-    def __init__(self, conf={}):
+    def __init__(self, conf):
         self.online = dict()    # {uid: {'student':{...}, 'test': {...}}, ...}
         self.allowed = set([])  # '0' is hardcoded to allowed elsewhere
 
-        logger.info(f'Loading test configuration "{conf["filename"]}".')
-        testconf = load_yaml(conf['filename'])
+        logger.info(f'Loading test configuration "{conf["testfile"]}".')
+        testconf = load_yaml(conf['testfile'])
         testconf.update(conf)  # command line options override configuration
 
         # start test factory
@@ -89,13 +97,15 @@ class App(object):
         except Exception:
             raise AppException(f'Database unusable {dbfile}.')
         else:
-            logger.info(f'Database {dbfile} has {n} students.')
+            logger.info(f'Database "{dbfile}" has {n} students.')
 
         # command line option --allow-all
         if conf['allow_all']:
             logger.info('Allowing all students:')
             for student in self.get_all_students():
                 self.allow_student(student[0])
+        else:
+            logger.info('Students not yet allowed to login.')
 
     # ------------------------------------------------------------------------
     # FIXME unused???
diff --git a/perguntations/main.py b/perguntations/main.py
new file mode 100644
index 0000000..117b18e
--- /dev/null
+++ b/perguntations/main.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+
+# python standard library
+import argparse
+import logging
+import os
+from os import environ, path
+import ssl
+import sys
+# from typing import Any, Dict
+
+# this project
+from .app import App
+from .serve import run_webserver
+from .tools import load_yaml
+from . import APP_NAME, APP_VERSION
+
+
+# ----------------------------------------------------------------------------
+def parse_cmdline_arguments():
+    parser = argparse.ArgumentParser(
+        description='Server for online tests. Enrolled students and tests '
+        'have to be previously configured. Please read the documentation '
+        'included with this software before running the server.')
+    parser.add_argument('testfile',
+                        type=str, nargs='?',    # FIXME only one test supported
+                        help='tests in YAML format')
+    parser.add_argument('--allow-all',
+                        action='store_true',
+                        help='Allow all students to login immediately')
+    parser.add_argument('--debug',
+                        action='store_true',
+                        help='Enable debug messages')
+    parser.add_argument('--show-ref',
+                        action='store_true',
+                        help='Show question references')
+    parser.add_argument('--review',
+                        action='store_true',
+                        help='Review mode: doesn\'t generate test')
+    parser.add_argument('--port',
+                        type=int, default=8443,
+                        help='port for the HTTPS server (default: 8443)')
+    parser.add_argument('-v', '--version', action='store_true',
+                        help='Show version information and exit')
+    return parser.parse_args()
+
+
+# ----------------------------------------------------------------------------
+def get_logger_config(debug=False):
+    if debug:
+        filename = 'logger-debug.yaml'
+        level = 'DEBUG'
+    else:
+        filename = 'logger.yaml'
+        level = 'INFO'
+
+    config_dir = environ.get('XDG_CONFIG_HOME', '~/.config/')
+    config_file = path.join(path.expanduser(config_dir), APP_NAME, filename)
+
+    default_config = {
+        'version': 1,
+        'formatters': {
+            'standard': {
+                'format': '%(asctime)s %(levelname)-8s %(message)s',
+                'datefmt': '%H:%M',
+                },
+            },
+        'handlers': {
+            'default': {
+                'level': level,
+                'class': 'logging.StreamHandler',
+                'formatter': 'standard',
+                'stream': 'ext://sys.stdout',
+                },
+            },
+        'loggers': {
+            '': {  # configuration for serve.py
+                'handlers': ['default'],
+                'level': level,
+                },
+            },
+        }
+    default_config['loggers'].update({
+        APP_NAME+'.'+module: {
+            'handlers': ['default'],
+            'level': level,
+            'propagate': False,
+            } for module in ['app', 'models', 'factory', 'questions',
+                             'test', 'tools']})
+
+    return load_yaml(config_file, default=default_config)
+
+
+# ----------------------------------------------------------------------------
+# Tornado web server
+# ----------------------------------------------------------------------------
+def main():
+    args = parse_cmdline_arguments()
+
+    if args.version:
+        print(f'{APP_NAME} {APP_VERSION}\nPython {sys.version}')
+        sys.exit(0)
+
+    # --- Setup logging ------------------------------------------------------
+    logging.config.dictConfig(get_logger_config(args.debug))
+    logging.info('====================== Start Logging ======================')
+
+    # --- start application --------------------------------------------------
+    config = {
+        'testfile': args.testfile,
+        'debug':    args.debug,
+        'allow_all': args.allow_all,
+        'show_ref': args.show_ref,
+        'review':   args.review,
+        }
+
+    # testapp = App(config)
+    try:
+        testapp = App(config)
+    except Exception:
+        logging.critical('Failed to start application.')
+        sys.exit(-1)
+
+    # --- get SSL certificates -----------------------------------------------
+    if 'XDG_DATA_HOME' in os.environ:
+        certs_dir = path.join(os.environ['XDG_DATA_HOME'], 'certs')
+    else:
+        certs_dir = path.expanduser('~/.local/share/certs')
+
+    ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+    try:
+        ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'),
+                                path.join(certs_dir, 'privkey.pem'))
+    except FileNotFoundError:
+        logging.critical(f'SSL certificates missing in {certs_dir}')
+        sys.exit(-1)
+
+    # --- run webserver ----------------------------------------------------
+    run_webserver(app=testapp, ssl=ssl_ctx, port=args.port, debug=args.debug)
+
+
+# ----------------------------------------------------------------------------
+if __name__ == "__main__":
+    main()
diff --git a/perguntations/serve.py b/perguntations/serve.py
index 7589c9b..78ccede 100644
--- a/perguntations/serve.py
+++ b/perguntations/serve.py
@@ -1,7 +1,6 @@
 #!/usr/bin/env python3
 
 # python standard library
-import os
 from os import path
 import sys
 import base64
@@ -21,9 +20,6 @@ import tornado.web
 import tornado.httpserver
 
 # this project
-from perguntations.app import App, AppException
-from perguntations.tools import load_yaml
-from perguntations import APP_NAME
 from perguntations.parser_markdown import md_to_html
 
 
@@ -159,7 +155,7 @@ class AdminHandler(BaseHandler):
                 'data': {
                     'title': self.testapp.testfactory['title'],
                     'ref': self.testapp.testfactory['ref'],
-                    'filename': self.testapp.testfactory['filename'],
+                    'filename': self.testapp.testfactory['testfile'],
                     'database': self.testapp.testfactory['database'],
                     'answers_dir': self.testapp.testfactory['answers_dir'],
                     }
@@ -390,153 +386,44 @@ class ReviewHandler(BaseHandler):
                         templ=self._templates)
 
 
+
 # ----------------------------------------------------------------------------
 def signal_handler(signal, frame):
     r = input(' --> Stop webserver? (yes/no) ')
-    if r in ('yes', 'YES'):
+    if r.lower() == 'yes':
         tornado.ioloop.IOLoop.current().stop()
         logging.critical('Webserver stopped.')
         sys.exit(0)
 
-
 # ----------------------------------------------------------------------------
-def parse_cmdline_arguments():
-    parser = argparse.ArgumentParser(
-        description='Server for online tests. Enrolled students and tests '
-        'have to be previously configured. Please read the documentation '
-        'included with this software before running the server.')
-    parser.add_argument('testfile',
-                        type=str, nargs='+',    # FIXME only one test supported
-                        help='test configuration in YAML format')
-    parser.add_argument('--allow-all',
-                        action='store_true',
-                        help='Allow all students to login immediately')
-    parser.add_argument('--debug',
-                        action='store_true',
-                        help='Enable debug messages')
-    parser.add_argument('--show-ref',
-                        action='store_true',
-                        help='Show question references')
-    parser.add_argument('--review',
-                        action='store_true',
-                        help='Review mode: doesn\'t generate test')
-    return parser.parse_args()
-
-
-# ----------------------------------------------------------------------------
-def get_logger_config(debug=False):
-    if debug:
-        filename = 'logger-debug.yaml'
-        level = 'DEBUG'
-    else:
-        filename = 'logger.yaml'
-        level = 'INFO'
-
-    config_dir = os.environ.get('XDG_CONFIG_HOME', '~/.config/')
-    config_file = path.join(path.expanduser(config_dir), APP_NAME, filename)
-
-    default_config = {
-        'version': 1,
-        'formatters': {
-            'standard': {
-                'format': '%(asctime)s %(levelname)-8s %(message)s',
-                'datefmt': '%H:%M',
-                },
-            },
-        'handlers': {
-            'default': {
-                'level': level,
-                'class': 'logging.StreamHandler',
-                'formatter': 'standard',
-                'stream': 'ext://sys.stdout',
-                },
-            },
-        'loggers': {
-            '': {  # configuration for serve.py
-                'handlers': ['default'],
-                'level': level,
-                },
-            },
-        }
-    default_config['loggers'].update({
-        APP_NAME+'.'+module: {
-            'handlers': ['default'],
-            'level': level,
-            'propagate': False,
-            } for module in ['app', 'models', 'factory', 'questions',
-                             'test', 'tools']})
-
-    return load_yaml(config_file, default=default_config)
-
-
-# ----------------------------------------------------------------------------
-# Tornado web server
-# ----------------------------------------------------------------------------
-def main():
-    args = parse_cmdline_arguments()
-
-    # --- Setup logging
-    logging.config.dictConfig(get_logger_config(args.debug))
-    logging.info('====================== Start Logging ======================')
-
-    # --- start application
-    config = {
-        'filename': args.testfile[0] or '',
-        'debug':    args.debug,
-        'allow_all': args.allow_all,
-        'show_ref': args.show_ref,
-        'review':   args.review,
-        }
-
+def run_webserver(app, ssl, port, debug):
+    # --- create web application ---------------------------------------------
+    logging.info('Starting WebApplication (tornado)')
     try:
-        testapp = App(config)
-    except Exception:
-        logging.critical('Failed to start application.')
-        sys.exit(-1)
-
-    # --- create web application
-    logging.info('Starting Web App (tornado)')
-    try:
-        webapp = WebApplication(testapp, debug=args.debug)
+        webapp = WebApplication(app, debug=debug)
     except Exception:
         logging.critical('Failed to start web application.')
         raise
 
-    # --- get SSL certificates
-    if 'XDG_DATA_HOME' in os.environ:
-        certs_dir = path.join(os.environ['XDG_DATA_HOME'], 'certs')
-    else:
-        certs_dir = path.expanduser('~/.local/share/certs')
-
-    ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
     try:
-        ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'),
-                                path.join(certs_dir, 'privkey.pem'))
-    except FileNotFoundError:
-        logging.critical(f'SSL certificates missing in {certs_dir}')
-        sys.exit(-1)
-
-    # --- create webserver
-    try:
-        httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_ctx)
+        httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl)
     except ValueError:
         logging.critical('Certificates cert.pem, privkey.pem not found')
-        sys.exit(-1)
+        sys.exit(1)
 
-    httpserver.listen(8443)
+    try:
+        httpserver.listen(port)
+    except OSError:
+        logger.critical(f'Cannot bind port {port}. Already in use?')
+        sys.exit(1)
 
-    # --- run webserver
-    logging.info('Webserver running...  (Ctrl-C to stop)')
+    logging.info(f'Webserver listening on {port}...  (Ctrl-C to stop)')
     signal.signal(signal.SIGINT, signal_handler)
 
+    # --- run webserver
     try:
         tornado.ioloop.IOLoop.current().start()  # running...
     except Exception:
-        logging.critical('Webserver stopped.')
+        logging.critical('Webserver stopped!')
         tornado.ioloop.IOLoop.current().stop()
         raise
-
-
-# ----------------------------------------------------------------------------
-if __name__ == "__main__":
-    main()
diff --git a/perguntations/test.py b/perguntations/test.py
index 3990f47..7c675ff 100644
--- a/perguntations/test.py
+++ b/perguntations/test.py
@@ -49,7 +49,8 @@ class TestFactory(dict):
 
         # --- find refs of all questions used in the test
         qrefs = {r for qq in self['questions'] for r in qq['ref']}
-        logger.info(f'Declared {len(qrefs)} questions (each test uses {len(self["questions"])}).')
+        logger.info(f'Declared {len(qrefs)} questions '
+                    f'(each test uses {len(self["questions"])}).')
 
         # --- for review, we are done. no factories needed
         if self['review']:
@@ -70,7 +71,8 @@ class TestFactory(dict):
             for i, q in enumerate(questions):
                 # make sure every question in the file is a dictionary
                 if not isinstance(q, dict):
-                    raise TestFactoryException(f'Question {i} in {file} is not a dictionary!')
+                    msg = f'Question {i} in {file} is not a dictionary'
+                    raise TestFactoryException(msg)
 
                 # check if ref is missing, then set to '/path/file.yaml:3'
                 if 'ref' not in q:
@@ -80,9 +82,11 @@ class TestFactory(dict):
                 # check for duplicate refs
                 if q['ref'] in self.question_factory:
                     other = self.question_factory[q['ref']]
-                    otherfile = path.join(other.question['path'], other.question['filename'])
-                    raise TestFactoryException(f'Duplicate reference "{q["ref"]}" in files '
-                                               f'"{otherfile}" and "{fullpath}".')
+                    otherfile = path.join(other.question['path'],
+                                          other.question['filename'])
+                    msg = (f'Duplicate reference "{q["ref"]}" in files '
+                           f'"{otherfile}" and "{fullpath}".')
+                    raise TestFactoryException(msg)
 
                 # make factory only for the questions used in the test
                 if q['ref'] in qrefs:
@@ -98,8 +102,9 @@ class TestFactory(dict):
                     # check if all the questions can be correctly generated
                     try:
                         self.question_factory[q['ref']].generate()
-                    except Exception as e:
-                        raise TestFactoryException(f'Failed to generate "{q["ref"]}"')
+                    except Exception:
+                        msg = f'Failed to generate "{q["ref"]}"'
+                        raise TestFactoryException(msg)
                     else:
                         logger.info(f'{n:4}.  "{q["ref"]}" Ok.')
                     n += 1
@@ -108,7 +113,6 @@ class TestFactory(dict):
         if qmissing:
             raise TestFactoryException(f'Could not find questions {qmissing}.')
 
-
     # ------------------------------------------------------------------------
     # Checks for valid keys and sets default values.
     # Also checks if some files and directories exist
@@ -120,13 +124,15 @@ class TestFactory(dict):
 
         # --- check database
         if 'database' not in self:
-            raise TestFactoryException('Missing "database" in configuration!')
+            raise TestFactoryException('Missing "database" in configuration')
         elif not path.isfile(path.expanduser(self['database'])):
-            raise TestFactoryException(f'Database {self["database"]} not found!')
+            msg = f'Database "{self["database"]}" not found!'
+            raise TestFactoryException(msg)
 
         # --- check answers_dir
         if 'answers_dir' not in self:
-            raise TestFactoryException('Missing "answers_dir" in configuration!')
+            msg = 'Missing "answers_dir" in configuration'
+            raise TestFactoryException(msg)
 
         # --- check if answers_dir is a writable directory
         testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME')
@@ -134,16 +140,19 @@ class TestFactory(dict):
             with open(testfile, 'w') as f:
                 f.write('You can safely remove this file.')
         except OSError:
-            raise TestFactoryException(f'Cannot write answers to directory "{self["answers_dir"]}"!')
+            msg = f'Cannot write answers to directory "{self["answers_dir"]}"'
+            raise TestFactoryException(msg)
 
         # --- check title
         if not self['title']:
             logger.warning('Undefined title!')
 
         if self['scale_points']:
-            logger.info(f'Grades will be scaled to [{self["scale_min"]}, {self["scale_max"]}]')
+            smin, smax = self["scale_min"], self["scale_max"]
+            logger.info(f'Grades will be scaled to [{smin}, {smax}]')
         else:
-            logger.info('Grades are just the sum of points defined for the questions, not being scaled.')
+            logger.info('Grades are just the sum of points defined for the '
+                        'questions, not being scaled.')
 
         # --- questions_dir
         if 'questions_dir' not in self:
@@ -156,7 +165,9 @@ class TestFactory(dict):
 
         # --- files
         if 'files' not in self:
-            raise TestFactoryException('Missing "files" in configuration with the list of question files to import!')
+            msg = ('Missing "files" in configuration with the list of '
+                   'question files to import!')
+            raise TestFactoryException(msg)
             # FIXME allow no files and define the questions directly in the test
 
         if isinstance(self['files'], str):
@@ -164,7 +175,7 @@ class TestFactory(dict):
 
         # --- questions
         if 'questions' not in self:
-            raise TestFactoryException(f'Missing "questions" in Configuration!')
+            raise TestFactoryException(f'Missing "questions" in Configuration')
 
         for i, q in enumerate(self['questions']):
             # normalize question to a dict and ref to a list of references
diff --git a/setup.py b/setup.py
index 155c8ed..432960a 100644
--- a/setup.py
+++ b/setup.py
@@ -15,15 +15,21 @@ setup(
     description=APP_DESCRIPTION.split('\n')[0],
     long_description=APP_DESCRIPTION,
     long_description_content_type="text/markdown",
-    url="https:USERNAME//bitbucket.org/USERNAME/perguntations.git",
+    url="https://git.xdi.uevora.pt/mjsb/perguntations.git",
     packages=find_packages(),
     include_package_data=True,   # install files from MANIFEST.in
     python_requires='>=3.7.*',
     install_requires=[
-        'tornado', 'mistune', 'pyyaml', 'pygments', 'sqlalchemy', 'bcrypt'],
+        'tornado>=6.0',
+        'mistune',
+        'pyyaml>=5.1',
+        'pygments',
+        'sqlalchemy',
+        'bcrypt>=3.1'
+        ],
     entry_points={
         'console_scripts': [
-            'perguntations = perguntations.serve:main',
+            'perguntations = perguntations.main:main',
             'initdb = perguntations.initdb:main',
         ]
     },
--
libgit2 0.21.2