From aec7789965f7720768e481b53bc8e6333d8d252c Mon Sep 17 00:00:00 2001 From: Miguel Barão Date: Thu, 7 May 2020 12:26:23 +0100 Subject: [PATCH] - fix bug in password reset. - new information question in the demo suggesting use of yamllint. - satisfy pylint recommendations against logging f-strings - handle JSON exception - show file and ref both in the test and review - add button to get detailed grades of all questions in a test - version bump to 2020.05.dev3 --- BUGS.md | 14 +++++++------- demo/demo.yaml | 10 ++++++---- demo/questions/generators/generate-question.py | 17 +++++++---------- demo/questions/questions-tutorial.yaml | 22 ++++++++++++++++++++++ perguntations/__init__.py | 2 +- perguntations/app.py | 41 ++++++++++++++++++++++++++++++++++++++--- perguntations/models.py | 52 +++++++++++++++++++++++++++------------------------- perguntations/serve.py | 48 +++++++++++++++++++++++++++++++----------------- perguntations/templates/admin.html | 3 ++- perguntations/templates/question-information.html | 3 +-- perguntations/templates/question.html | 3 +-- perguntations/templates/review-question-information.html | 5 +++++ perguntations/templates/review-question.html | 8 ++++++-- 13 files changed, 154 insertions(+), 74 deletions(-) diff --git a/BUGS.md b/BUGS.md index e60b1de..c2ca992 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,22 +1,22 @@ # BUGS -- retornar None quando nao ha alteracoes relativamente à última vez. -ou usar push (websockets?) -- quando scale_max não é 20, as cores das barras continuam a reflectir a escala 0,20 - CRITICAL se answer for `i que não preserva whitespace. Necessario adicionar
.
-- teste nao esta a mostrar imagens de vez em quando.
 - mensagems de erro do assembler aparecem na mesma linha na correcao e nao fazerm rendering do `$t`, ver se servidor faz parse do markdown dessas mensagens.
 - impedir os eventos copy/paste. alunos usam isso para trazer codigo ja feito nos computadores. Obrigar a fazer reset? fazer um copy automaticamente?
 - a revisao do teste não mostra as imagens.
 - Test.reset_answers() unused.
-- 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 tenham excedido o tempo
+- teste nao esta a mostrar imagens de vez em quando.???
 
 # TODO
 
+- na pagina admin, mostrar com cor vermelha as horas de entrada dos alunos que ja tenham excedido o tempo
+- retornar None quando nao ha alteracoes relativamente à última vez.
+ou usar push (websockets?)
 - mudar ref do test para test_id (ref já é usado nas perguntas)
 - servidor ntpd no x220 para configurar a data/hora dos portateis dell
 - autorização dada, mas teste não disponível até que seja dada ordem para começar.
diff --git a/demo/demo.yaml b/demo/demo.yaml
index 7ca8ce9..7f74340 100644
--- a/demo/demo.yaml
+++ b/demo/demo.yaml
@@ -1,8 +1,10 @@
 ---
 # ============================================================================
 # Unique identifier of the test.
+# Valid names can only include letters, digits, dash and underscore,
+# e.g.  asc1-test3
 # Database queries can be done in the terminal with
-#     sqlite3 students.db "select * from tests where ref='tutorial'"
+#     sqlite3 students.db "select * from tests where ref='asc1-test3'"
 ref: tutorial
 
 # Database file that includes student credentials, tests and questions grades.
@@ -20,9 +22,9 @@ title: Teste de demonstração (tutorial)
 
 # Duration in minutes.
 # (0 or undefined means infinite time)
-duration: 2
+duration: 0
 
-# Automatic test submission after the timeout 'duration'?
+# Automatic test submission after the given 'duration' timeout
 # (default: false)
 autosubmit: true
 
@@ -72,7 +74,7 @@ questions:
   - tut-warning
   - [tut-alert1, tut-alert2]
   - tut-generator
-
+  - tut-yamllint
 
 # test:
 #   - ref1
diff --git a/demo/questions/generators/generate-question.py b/demo/questions/generators/generate-question.py
index 3161ce4..604889c 100755
--- a/demo/questions/generators/generate-question.py
+++ b/demo/questions/generators/generate-question.py
@@ -18,11 +18,10 @@ print(f"""---
 type: text
 title: Geradores de perguntas
 text: |
-  Existe a possibilidade da pergunta ser gerada por um programa externo. Este
-  programa deve escrever no `stdout` uma pergunta em formato `yaml` como nos
+  Existe a possibilidade da pergunta ser gerada por um programa externo. O
+  programa deve escrever no `stdout` uma pergunta em formato `yaml` tal como os
   exemplos anteriores. Pode também receber argumentos para parametrizar a
-  geração da pergunta. Aqui está um exemplo de uma pergunta gerada por um
-  script python:
+  pergunta. Aqui está um exemplo de uma pergunta gerada por um script python:
 
   ```python
       #!/usr/bin/env python3
@@ -46,9 +45,7 @@ text: |
         A solução é {{r}}.''')
   ```
 
-  Este script deve ter permissões para poder ser executado no terminal. Dá
-  jeito usar o comando `gen-somar.py 1 100 | yamllint -` para validar o `yaml`
-  gerado.
+  Este script deve ter permissões para poder ser executado no terminal.
 
   Para indicar que uma pergunta é gerada externamente, esta é declarada com
 
@@ -56,12 +53,12 @@ text: |
       - type: generator
         ref: gen-somar
         script: gen-somar.py
-        # opcional
+        # argumentos opcionais
         args: [1, 100]
   ```
 
-  Os argumentos `args` são opcionais e são passados para o programa como
-  argumentos da linha de comando.
+  Opcionalmente, o programa pode receber uma lista de argumentos declarados em
+  `args`.
 
   ---
 
diff --git a/demo/questions/questions-tutorial.yaml b/demo/questions/questions-tutorial.yaml
index b033145..993d8b8 100644
--- a/demo/questions/questions-tutorial.yaml
+++ b/demo/questions/questions-tutorial.yaml
@@ -581,3 +581,25 @@
   ref: tut-generator
   script: generators/generate-question.py
   args: [1, 100]
+
+# ----------------------------------------------------------------------------
+- type: information
+  ref: tut-yamllint
+  title: Sugestões para validar yaml
+  text: |
+    Como os testes e perguntas são ficheiros `yaml`, é conveniente validar se
+    estão correctamente definitos. Um *linter* recomendado é o `yamllint`. Pode
+    ser instalado com `pip install yamllint` e usado do seguinte modo:
+
+    ```sh
+        yamllint test.yaml
+        yamllint questions.yaml
+    ```
+
+    No caso de programas geradores de perguntas e programas de correcção de
+    respostas pode usar-se um *pipe*:
+
+    ```sh
+        generate-question | yamllint -
+        correct-answer | yamllint -
+    ```
diff --git a/perguntations/__init__.py b/perguntations/__init__.py
index a928eb1..ce1af60 100644
--- a/perguntations/__init__.py
+++ b/perguntations/__init__.py
@@ -32,7 +32,7 @@ proof of submission and for review.
 '''
 
 APP_NAME = 'perguntations'
-APP_VERSION = '2020.05.dev2'
+APP_VERSION = '2020.05.dev3'
 APP_DESCRIPTION = __doc__
 
 __author__ = 'Miguel Barão'
diff --git a/perguntations/app.py b/perguntations/app.py
index c4c229e..3ece150 100644
--- a/perguntations/app.py
+++ b/perguntations/app.py
@@ -276,11 +276,43 @@ class App():
                         uid, area, win_x, win_y, scr_x, scr_y)
 
     # ------------------------------------------------------------------------
+    # --- GETTERS
+    # ------------------------------------------------------------------------
 
-    # --- helpers (getters)
     # def get_student_name(self, uid):
     #     return self.online[uid]['student']['name']
 
+    def get_questions_csv(self):
+        '''generates a CSV with the grades of the test'''
+        test_id = self.testfactory['ref']
+
+        with self.db_session() as sess:
+            grades = sess.query(Question.student_id, Question.starttime,
+                                Question.ref, Question.grade)\
+                         .filter(Question.test_id == test_id)\
+                         .order_by(Question.student_id)\
+                         .all()
+
+        cols = ['Aluno', 'Início'] + \
+               [r for question in self.testfactory['questions']
+                  for r in question['ref']]
+
+        tests = {}
+        for q in grades:
+            student, qref, qgrade = q[:2], q[2], q[3]
+            tests.setdefault(student, {})[qref] = qgrade
+
+        rows = [{'Aluno': test[0], 'Início': test[1], **q}
+                for test, q in tests.items()]
+
+        csvstr = io.StringIO()
+        writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None,
+                                delimiter=';', quoting=csv.QUOTE_ALL)
+        writer.writeheader()
+        writer.writerows(rows)
+        return test_id, csvstr.getvalue()
+
+
     def get_test_csv(self):
         '''generates a CSV with the grades of the test'''
         with self.db_session() as sess:
@@ -292,7 +324,7 @@ class App():
 
         csvstr = io.StringIO()
         writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL)
-        writer.writerow(('Número', 'Nota', 'Início', 'Fim'))
+        writer.writerow(('Aluno', 'Nota', 'Início', 'Fim'))
         writer.writerows(grades)
         return self.testfactory['ref'], csvstr.getvalue()
 
@@ -357,7 +389,10 @@ class App():
     #         if q['ref'] == ref and key in q['files']:
     #             return path.abspath(path.join(q['path'], q['files'][key]))
 
-    # --- helpers (change state)
+    # ------------------------------------------------------------------------
+    # --- SETTERS
+    # ------------------------------------------------------------------------
+
     def allow_student(self, uid):
         '''allow a single student to login'''
         self.allowed.add(uid)
diff --git a/perguntations/models.py b/perguntations/models.py
index c02275a..7fc416a 100644
--- a/perguntations/models.py
+++ b/perguntations/models.py
@@ -1,5 +1,7 @@
 '''
-Database tables
+SQLAlchemy ORM
+
+The classes below correspond to database tables
 '''
 
 
@@ -26,10 +28,10 @@ class Student(Base):
     questions = relationship('Question', back_populates='student')
 
     def __repr__(self):
-        return f'Student:\n\
-        id: "{self.id}"\n\
-        name: "{self.name}"\n\
-        password: "{self.password}"'
+        return (f'Student:\n'
+                f'  id:         "{self.id}"\n'
+                f'  name:       "{self.name}"\n'
+                f'  password:   "{self.password}"\n')
 
 
 # ----------------------------------------------------------------------------
@@ -38,7 +40,7 @@ class Test(Base):
     __tablename__ = 'tests'
     id = Column(Integer, primary_key=True)  # auto_increment
     ref = Column(String)
-    title = Column(String)  # FIXME depends on ref and should come from another table...
+    title = Column(String)
     grade = Column(Float)
     state = Column(String)  # ACTIVE, FINISHED, QUIT, NULL
     comment = Column(String)
@@ -52,17 +54,17 @@ class Test(Base):
     questions = relationship('Question', back_populates='test')
 
     def __repr__(self):
-        return f'Test:\n\
-        id: "{self.id}"\n\
-        ref: "{self.ref}"\n\
-        title: "{self.title}"\n\
-        grade: "{self.grade}"\n\
-        state: "{self.state}"\n\
-        comment: "{self.comment}"\n\
-        starttime: "{self.starttime}"\n\
-        finishtime: "{self.finishtime}"\n\
-        filename: "{self.filename}"\n\
-        student_id: "{self.student_id}"\n'
+        return (f'Test:\n'
+                f'  id:         "{self.id}"\n'
+                f'  ref:        "{self.ref}"\n'
+                f'  title:      "{self.title}"\n'
+                f'  grade:      "{self.grade}"\n'
+                f'  state:      "{self.state}"\n'
+                f'  comment:    "{self.comment}"\n'
+                f'  starttime:  "{self.starttime}"\n'
+                f'  finishtime: "{self.finishtime}"\n'
+                f'  filename:   "{self.filename}"\n'
+                f'  student_id: "{self.student_id}"\n')
 
 
 # ---------------------------------------------------------------------------
@@ -82,11 +84,11 @@ class Question(Base):
     test = relationship('Test', back_populates='questions')
 
     def __repr__(self):
-        return f'Question:\n\
-        id: "{self.id}"\n\
-        ref: "{self.ref}"\n\
-        grade: "{self.grade}"\n\
-        starttime: "{self.starttime}"\n\
-        finishtime: "{self.finishtime}"\n\
-        student_id: "{self.student_id}"\n\
-        test_id: "{self.test_id}"\n'
+        return (f'Question:\n'
+                f'  id:         "{self.id}"\n'
+                f'  ref:        "{self.ref}"\n'
+                f'  grade:      "{self.grade}"\n'
+                f'  starttime:  "{self.starttime}"\n'
+                f'  finishtime: "{self.finishtime}"\n'
+                f'  student_id: "{self.student_id}"\n'  # FIXME normal form
+                f'  test_id:    "{self.test_id}"\n')
diff --git a/perguntations/serve.py b/perguntations/serve.py
index 2f2e703..c0dc987 100644
--- a/perguntations/serve.py
+++ b/perguntations/serve.py
@@ -63,7 +63,8 @@ class WebApplication(tornado.web.Application):
 # ----------------------------------------------------------------------------
 def admin_only(func):
     '''
-    Decorator used to restrict access to the administrator. For example:
+    Decorator used to restrict access to the administrator.
+    Example:
 
         @admin_only()
         def get(self): ...
@@ -91,7 +92,7 @@ class BaseHandler(tornado.web.RequestHandler):
 
     def get_current_user(self):
         '''
-        HTML is stateless, so a cookie is used to identify the user.
+        Since HTTP is stateless, a cookie is used to identify the user.
         This function returns the cookie for the current user.
         '''
         cookie = self.get_secure_cookie('user')
@@ -191,6 +192,15 @@ class AdminWebservice(BaseHandler):
             self.write(data)
             await self.flush()
 
+        if cmd == 'questionscsv':
+            test_ref, data = self.testapp.get_questions_csv()
+            self.set_header('Content-Type', 'text/csv')
+            self.set_header('content-Disposition',
+                            f'attachment; filename={test_ref}-detailed.csv')
+            self.write(data)
+            await self.flush()
+
+
 # ----------------------------------------------------------------------------
 class AdminHandler(BaseHandler):
     '''Handle /admin'''
@@ -236,7 +246,7 @@ class AdminHandler(BaseHandler):
             self.testapp.deny_student(value)
 
         elif cmd == 'reset_password':
-            await self.testapp.update_student_password(uid=value, pw='')
+            await self.testapp.update_student_password(uid=value, password='')
 
         elif cmd == 'insert_student':
             student = json.loads(value)
@@ -244,7 +254,7 @@ class AdminHandler(BaseHandler):
                                             name=student['name'])
 
         else:
-            logging.error(f'Unknown command: "{cmd}"')
+            logging.error('Unknown command: "%s"', cmd)
 
 
 # ----------------------------------------------------------------------------
@@ -337,11 +347,11 @@ class FileHandler(BaseHandler):
                 try:
                     file = open(filepath, 'rb')
                 except FileNotFoundError:
-                    logging.error(f'File not found: {filepath}')
+                    logging.error('File not found: %s', filepath)
                 except PermissionError:
-                    logging.error(f'No permission: {filepath}')
+                    logging.error('No permission: %s', filepath)
                 except OSError:
-                    logging.error(f'Error opening file: {filepath}')
+                    logging.error('Error opening file: %s', filepath)
                 else:
                     data = file.read()
                     file.close()
@@ -466,21 +476,25 @@ class ReviewHandler(BaseHandler):
         Opens JSON file with a given corrected test and renders it
         '''
         test_id = self.get_query_argument('test_id', None)
-        logging.info(f'Review test {test_id}.')
+        logging.info('Review test %s.', test_id)
         fname = self.testapp.get_json_filename_of_test(test_id)
 
         if fname is None:
             raise tornado.web.HTTPError(404)  # Not Found
 
         try:
-            jsonfile = open(path.expanduser(fname))
-        except OSError:
-            logging.error(f'Cannot open "{fname}" for review.')
-        else:
-            with jsonfile:
+            with open(path.expanduser(fname)) as jsonfile:
                 test = json.load(jsonfile)
-            self.render('review.html', t=test, md=md_to_html,
-                        templ=self._templates)
+        except OSError:
+            logging.error('Cannot open "%s" for review.', fname)
+            raise tornado.web.HTTPError(404)  # Not Found
+        except json.JSONDecodeError as exc:
+            logging.error('JSON error in "%s": %s', fname, exc)
+            raise tornado.web.HTTPError(404)  # Not Found
+
+        print(test['show_ref'])
+        self.render('review.html', t=test, md=md_to_html,
+                templ=self._templates)
 
 
 # ----------------------------------------------------------------------------
@@ -517,10 +531,10 @@ def run_webserver(app, ssl_opt, port, debug):
     try:
         httpserver.listen(port)
     except OSError:
-        logging.critical(f'Cannot bind port {port}. Already in use?')
+        logging.critical('Cannot bind port %d. Already in use?', port)
         sys.exit(1)
 
-    logging.info(f'Webserver listening on {port}...  (Ctrl-C to stop)')
+    logging.info('Webserver listening on %d...  (Ctrl-C to stop)', port)
     signal.signal(signal.SIGINT, signal_handler)
 
     # --- run webserver
diff --git a/perguntations/templates/admin.html b/perguntations/templates/admin.html
index 1468366..fd09824 100644
--- a/perguntations/templates/admin.html
+++ b/perguntations/templates/admin.html
@@ -76,7 +76,8 @@
       Base de dados: --

- Obter CSV com as notas + Obter CSV das notas + Obter CSV detalhado

diff --git a/perguntations/templates/question-information.html b/perguntations/templates/question-information.html index bc24acb..8d059d6 100644 --- a/perguntations/templates/question-information.html +++ b/perguntations/templates/question-information.html @@ -19,8 +19,7 @@ {% if show_ref %}
- path: {{ q['path'] }}
- file: {{ q['filename'] }}
+ file: {{ q['path'] }}/{{ q['filename'] }}
ref: {{ q['ref'] }} {% end %} \ No newline at end of file diff --git a/perguntations/templates/question.html b/perguntations/templates/question.html index 3482416..acb9471 100644 --- a/perguntations/templates/question.html +++ b/perguntations/templates/question.html @@ -31,8 +31,7 @@ {% if show_ref %} {% end %} diff --git a/perguntations/templates/review-question-information.html b/perguntations/templates/review-question-information.html index b35dbaf..a17a93c 100644 --- a/perguntations/templates/review-question-information.html +++ b/perguntations/templates/review-question-information.html @@ -16,4 +16,9 @@
{{ md(q['text']) }}
+ {% if t['show_ref'] %} +
+ file: {{ q['path'] }}/{{ q['filename'] }}
+ ref: {{ q['ref'] }} + {% end %} \ No newline at end of file diff --git a/perguntations/templates/review-question.html b/perguntations/templates/review-question.html index 13bcb05..db2d4ce 100644 --- a/perguntations/templates/review-question.html +++ b/perguntations/templates/review-question.html @@ -65,7 +65,9 @@ {% end %} {% if t['show_ref'] %} - {{q['ref']}} +
+ file: {{ q['path'] }}/{{ q['filename'] }}
+ ref: {{ q['ref'] }} {% end %} @@ -107,7 +109,9 @@

{% if t['show_ref'] %} - {{ q['ref'] }} +
+ file: {{ q['path'] }}/{{ q['filename'] }}
+ ref: {{ q['ref'] }} {% end %} -- libgit2 0.21.2