Compare View

switch
from
...
to
 
Commits (4)
.gitignore
  1 +# ignore virtual environment
  2 +.venv
  3 +
1 4 __pycache__/
2 5 .DS_Store
3 6 demo/ans
... ...
perguntations/TODO 0 → 100644
... ... @@ -0,0 +1,17 @@
  1 +# TODO
  2 +
  3 +- opcao correct de testes que nao foram corrigidos automaticamente.
  4 +- tests should store identification of the perguntations version.
  5 +- detailed CSV devia ter as refs das perguntas e so preencher as que o aluno respondeu.
  6 +- long pooling admin
  7 +- student sends updated answers
  8 +- questions with parts
  9 +
  10 +
  11 +---- Estados -----
  12 +
  13 +inicialização (sincrona)
  14 + test factory
  15 + database setup: define lista de estudantes com teste=None
  16 + login admin:
  17 + gera todos os testes para os alunos na lista
... ...
perguntations/__init__.py
... ... @@ -21,7 +21,7 @@
21 21 # SOFTWARE.
22 22  
23 23  
24   -"""A webserver for running tests in class
  24 +'''A webserver for running tests in class
25 25  
26 26 perguntations is a webserver that is intended to be run in an isolated
27 27 local network in classroom environment.
... ... @@ -29,13 +29,13 @@ The server will run a preconfigured test and serve the test as a web form.
29 29 The answers submitted by the students are immediatly checked and classified.
30 30 The grades are stored in a database and the tests are stored in JSON files as
31 31 proof of submission and for review.
32   -"""
  32 +'''
33 33  
34   -APP_NAME = "perguntations"
35   -APP_VERSION = "2022.04.dev1"
  34 +APP_NAME = 'perguntations'
  35 +APP_VERSION = '2024.07.dev1'
36 36 APP_DESCRIPTION = str(__doc__)
37 37  
38   -__author__ = "Miguel Barão"
39   -__copyright__ = "Copyright 2022, Miguel Barão"
40   -__license__ = "MIT license"
  38 +__author__ = 'Miguel Barão'
  39 +__copyright__ = 'Copyright 2024, Miguel Barão'
  40 +__license__ = 'MIT license'
41 41 __version__ = APP_VERSION
... ...
perguntations/app.py
... ... @@ -14,7 +14,7 @@ import os
14 14 from typing import Optional
15 15  
16 16 # installed packages
17   -import bcrypt
  17 +import argon2
18 18 from sqlalchemy import create_engine, select
19 19 from sqlalchemy.exc import OperationalError, NoResultFound, IntegrityError
20 20 from sqlalchemy.orm import Session
... ... @@ -30,21 +30,24 @@ from .questions import question_from
30 30 # setup logger for this module
31 31 logger = logging.getLogger(__name__)
32 32  
  33 +# ============================================================================
  34 +ph = argon2.PasswordHasher()
33 35  
34   -async def check_password(password: str, hashed: bytes) -> bool:
  36 +async def check_password(password: str, hashed: str) -> bool:
35 37 """check password in executor"""
36 38 loop = asyncio.get_running_loop()
37   - return await loop.run_in_executor(
38   - None, bcrypt.checkpw, password.encode("utf-8"), hashed
39   - )
40   -
41   -
42   -async def hash_password(password: str) -> bytes:
  39 + try:
  40 + return await loop.run_in_executor(None, ph.verify, hashed, password)
  41 + except argon2.exceptions.VerifyMismatchError:
  42 + return False
  43 + except Exception:
  44 + logger.error('Unkown error while verifying password')
  45 + return False
  46 +
  47 +async def hash_password(password: str) -> str:
43 48 """get hash for password"""
44 49 loop = asyncio.get_running_loop()
45   - return await loop.run_in_executor(
46   - None, bcrypt.hashpw, password.encode("utf-8"), bcrypt.gensalt()
47   - )
  50 + return await loop.run_in_executor(None, ph.hash, password)
48 51  
49 52  
50 53 # ============================================================================
... ... @@ -66,7 +69,7 @@ class App:
66 69 self._make_test_factory(config["testfile"])
67 70 self._db_setup() # setup engine and load all students
68 71  
69   - # FIXME get_event_loop will be deprecated in python3.10
  72 + # FIXME: get_event_loop will be deprecated in python3.
70 73 asyncio.get_event_loop().run_until_complete(self._assign_tests())
71 74  
72 75 # command line options: --allow-all, --allow-list filename
... ...
perguntations/initdb.py
... ... @@ -9,10 +9,9 @@ import csv
9 9 import argparse
10 10 import re
11 11 from string import capwords
12   -from concurrent.futures import ThreadPoolExecutor
13 12  
14 13 # installed packages
15   -import bcrypt
  14 +from argon2 import PasswordHasher
16 15 from sqlalchemy import create_engine, select
17 16 from sqlalchemy.orm import Session
18 17 from sqlalchemy.exc import IntegrityError
... ... @@ -80,7 +79,7 @@ def parse_commandline_arguments():
80 79  
81 80  
82 81 # ============================================================================
83   -def get_students_from_csv(filename):
  82 +def get_students_from_csv(filename: str):
84 83 """
85 84 SIIUE names have alien strings like "(TE)" and are sometimes capitalized
86 85 We remove them so that students dont keep asking what it means
... ... @@ -111,17 +110,6 @@ def get_students_from_csv(filename):
111 110 return students
112 111  
113 112  
114   -# ============================================================================
115   -def hashpw(student, password=None) -> None:
116   - """replace password by hash for a single student"""
117   - print(".", end="", flush=True)
118   - if password is None:
119   - student["pw"] = ""
120   - else:
121   - student["pw"] = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
122   -
123   -
124   -# ============================================================================
125 113 def insert_students_into_db(session, students) -> None:
126 114 """insert list of students into the database"""
127 115 try:
... ... @@ -129,7 +117,6 @@ def insert_students_into_db(session, students) -> None:
129 117 [Student(id=s["uid"], name=s["name"], password=s["pw"]) for s in students]
130 118 )
131 119 session.commit()
132   -
133 120 except IntegrityError:
134 121 print("!!! Integrity error. Users already in database. Aborted !!!\n")
135 122 session.rollback()
... ... @@ -139,7 +126,6 @@ def insert_students_into_db(session, students) -> None:
139 126 def show_students_in_database(session, verbose=False):
140 127 """get students from database"""
141 128 users = session.execute(select(Student)).scalars().all()
142   - # users = session.query(Student).all()
143 129 total = len(users)
144 130  
145 131 print("Registered users:")
... ... @@ -165,15 +151,16 @@ def show_students_in_database(session, verbose=False):
165 151 def main():
166 152 """insert, update, show students from database"""
167 153  
  154 + ph = PasswordHasher()
168 155 args = parse_commandline_arguments()
169 156  
170 157 # --- database
171 158 print(f"Database: {args.db}")
172 159 engine = create_engine(f"sqlite:///{args.db}", echo=False, future=True)
173 160 Base.metadata.create_all(engine) # Criates schema if needed
174   - session = Session(engine, future=True)
  161 + session = Session(engine, future=True) # FIXME: future?
175 162  
176   - # --- make list of students to insert
  163 + # --- build list of new students to insert
177 164 students = []
178 165  
179 166 if args.admin:
... ... @@ -191,35 +178,45 @@ def main():
191 178  
192 179 # --- insert new students
193 180 if students:
194   - print("Generating password hashes", end="")
195   - with ThreadPoolExecutor() as executor: # hashing
196   - executor.map(lambda s: hashpw(s, args.pw), students)
197   - print(f"\nInserting {len(students)}")
  181 + if args.pw is None:
  182 + print('Set passwords to empty')
  183 + for s in students:
  184 + s['pw'] = ''
  185 + else:
  186 + print('Generating password hashes')
  187 + for s in students:
  188 + s['pw'] = ph.hash(args.pw)
  189 + print('.', end='', flush=True)
  190 + print(f'\nInserting {len(students)}')
198 191 insert_students_into_db(session, students)
199 192  
200 193 # --- update all students
201 194 if args.update_all:
202   - all_students = (
203   - session.execute(select(Student).where(Student.id != "0")).scalars().all()
204   - )
205   -
206   - print(f"Updating password of {len(all_students)} users", end="")
207   - for student in all_students:
208   - password = (args.pw or student.id).encode("utf-8")
209   - student.password = bcrypt.hashpw(password, bcrypt.gensalt())
210   - print(".", end="", flush=True)
211   - print()
  195 + query = select(Student).where(Student.id != '0')
  196 + all_students = session.execute(query).scalars().all()
  197 + if args.pw is None:
  198 + print(f'Resetting password of {len(all_students)} users')
  199 + for student in all_students:
  200 + student.password = ''
  201 + else:
  202 + print(f'Updating password of {len(all_students)} users')
  203 + for student in all_students:
  204 + student.password = ph.hash(args.pw)
  205 + print('.', end='', flush=True)
  206 + print()
212 207 session.commit()
213 208  
214   - # --- update some students
  209 + # --- update only specified students
215 210 else:
216 211 for student_id in args.update:
217   - print(f"Updating password of {student_id}")
218   - student = session.execute(
219   - select(Student).where(Student.id == student_id)
220   - ).scalar_one()
221   - new_password = (args.pw or student_id).encode("utf-8")
222   - student.password = bcrypt.hashpw(new_password, bcrypt.gensalt())
  212 + query = select(Student).where(Student.id == student_id)
  213 + student = session.execute(query).scalar_one()
  214 + if args.pw is None:
  215 + print(f'Resetting password of user {student_id}')
  216 + student.password = ''
  217 + else:
  218 + print(f'Updating password of user {student_id}')
  219 + student.password = ph.hash(args.pw)
223 220 session.commit()
224 221  
225 222 show_students_in_database(session, args.verbose)
... ...
perguntations/models.py
... ... @@ -3,100 +3,59 @@ perguntations/models.py
3 3 SQLAlchemy ORM
4 4 """
5 5  
6   -from typing import Any
  6 +from typing import List
7 7  
8   -from sqlalchemy import Column, ForeignKey, Integer, Float, String
9   -from sqlalchemy.orm import declarative_base, relationship
  8 +from sqlalchemy import ForeignKey, Integer, String
  9 +from sqlalchemy.orm import DeclarativeBase, Mapped, relationship, mapped_column
10 10  
11 11  
12   -# FIXME Any is a workaround for static type checking
13   -# (https://github.com/python/mypy/issues/6372)
14   -Base: Any = declarative_base()
  12 +class Base(DeclarativeBase):
  13 + pass
15 14  
16 15  
  16 +# [Student] ---1:N--- [Test] ---1:N--- [Question]
  17 +
17 18 # ----------------------------------------------------------------------------
18 19 class Student(Base):
19   - """Student table"""
20   -
21   - __tablename__ = "students"
22   - id = Column(String, primary_key=True)
23   - name = Column(String)
24   - password = Column(String)
  20 + __tablename__ = 'students'
25 21  
  22 + id = mapped_column(String, primary_key=True)
  23 + name: Mapped[str]
  24 + password: Mapped[str]
26 25 # ---
27   - tests = relationship("Test", back_populates="student")
28   -
29   - def __repr__(self):
30   - return (
31   - f"Student("
32   - f"id={self.id!r}, "
33   - f"name={self.name!r}, "
34   - f"password={self.password!r})"
35   - )
36   -
  26 + tests: Mapped[List['Test']] = relationship(back_populates='student')
37 27  
38 28 # ----------------------------------------------------------------------------
39 29 class Test(Base):
40   - """Test table"""
41   -
42   - __tablename__ = "tests"
43   - id = Column(Integer, primary_key=True) # auto_increment
44   - ref = Column(String)
45   - title = Column(String)
46   - grade = Column(Float)
47   - state = Column(String) # ACTIVE, SUBMITTED, CORRECTED, QUIT, NULL
48   - comment = Column(String)
49   - starttime = Column(String)
50   - finishtime = Column(String)
51   - filename = Column(String)
52   - student_id = Column(String, ForeignKey("students.id"))
53   -
  30 + __tablename__ = 'tests'
  31 +
  32 + id = mapped_column(Integer, primary_key=True) # auto_increment
  33 + ref: Mapped[str]
  34 + title: Mapped[str]
  35 + grade: Mapped[float]
  36 + state: Mapped[str] # ACTIVE, SUBMITTED, CORRECTED, QUIT, NULL
  37 + comment: Mapped[str]
  38 + starttime: Mapped[str]
  39 + finishtime: Mapped[str]
  40 + filename: Mapped[str]
  41 + student_id = mapped_column(String, ForeignKey('students.id'))
54 42 # ---
55   - student = relationship("Student", back_populates="tests")
56   - questions = relationship("Question", back_populates="test")
57   -
58   - def __repr__(self):
59   - return (
60   - f"Test("
61   - f"id={self.id!r}, "
62   - f"ref={self.ref!r}, "
63   - f"title={self.title!r}, "
64   - f"grade={self.grade!r}, "
65   - f"state={self.state!r}, "
66   - f"comment={self.comment!r}, "
67   - f"starttime={self.starttime!r}, "
68   - f"finishtime={self.finishtime!r}, "
69   - f"filename={self.filename!r}, "
70   - f"student_id={self.student_id!r})"
71   - )
72 43  
  44 + student: Mapped['Student'] = relationship(back_populates='tests')
  45 + questions: Mapped[List['Question']] = relationship(back_populates='test')
73 46  
74 47 # ---------------------------------------------------------------------------
75 48 class Question(Base):
76   - """Question table"""
77   -
78   - __tablename__ = "questions"
79   - id = Column(Integer, primary_key=True) # auto_increment
80   - number = Column(Integer) # question number (ref may be not be unique)
81   - ref = Column(String)
82   - grade = Column(Float)
83   - comment = Column(String)
84   - starttime = Column(String)
85   - finishtime = Column(String)
86   - test_id = Column(String, ForeignKey("tests.id"))
87   -
  49 + __tablename__ = 'questions'
  50 +
  51 + id = mapped_column(Integer, primary_key=True) # auto_increment
  52 + number: Mapped[int] # question number (ref may be repeated in the same test)
  53 + ref: Mapped[str]
  54 + grade: Mapped[float]
  55 + comment: Mapped[str]
  56 + starttime: Mapped[str]
  57 + finishtime: Mapped[str]
  58 + test_id = mapped_column(String, ForeignKey('tests.id'))
88 59 # ---
89   - test = relationship("Test", back_populates="questions")
90 60  
91   - def __repr__(self):
92   - return (
93   - f"Question("
94   - f"id={self.id!r}, "
95   - f"number={self.number!r}, "
96   - f"ref={self.ref!r}, "
97   - f"grade={self.grade!r}, "
98   - f"comment={self.comment!r}, "
99   - f"starttime={self.starttime!r}, "
100   - f"finishtime={self.finishtime!r}, "
101   - f"test_id={self.test_id!r})"
102   - )
  61 + test: Mapped['Test'] = relationship(back_populates='questions')
... ...
perguntations/serve.py
1   -#!/usr/bin/env python3
2 1  
3 2 """
4 3 Handles the web, http & html part of the application interface.
... ... @@ -21,9 +20,7 @@ from typing import Dict, Tuple
21 20 import uuid
22 21  
23 22 # user installed libraries
24   -import tornado.ioloop
25   -import tornado.web
26   -import tornado.httpserver
  23 +import tornado
27 24  
28 25 # this project
29 26 from .parser_markdown import md_to_html
... ... @@ -262,7 +259,7 @@ class RootHandler(BaseHandler):
262 259  
263 260 # ----------------------------------------------------------------------------
264 261 # pylint: disable=abstract-method
265   -# FIXME also to update answers
  262 +# FIXME: also to update answers
266 263 class StudentWebservice(BaseHandler):
267 264 """
268 265 Receive ajax from students during the test in response to the events
... ...
perguntations/testfactory.py
... ... @@ -66,7 +66,7 @@ test_schema = schema.Schema(
66 66 },
67 67 ignore_extra_keys=True,
68 68 )
69   -# FIXME schema error with 'testfile' which is added in the code
  69 +# FIXME: schema error with 'testfile' which is added in the code
70 70  
71 71 # ============================================================================
72 72 class TestFactoryException(Exception):
... ... @@ -105,7 +105,7 @@ class TestFactory(dict):
105 105 normalize_question_list(self["questions"])
106 106  
107 107 # --- for review, we are done. no factories needed
108   - # if self['review']: FIXME
  108 + # if self['review']: FIXME:
109 109 # logger.info('Review mode. No questions loaded. No factories.')
110 110 # return
111 111  
... ... @@ -267,7 +267,7 @@ class TestFactory(dict):
267 267 checks if questions can be correctly generated and corrected
268 268 """
269 269 logger.info("Checking questions...")
270   - # FIXME get_event_loop will be deprecated in python3.10
  270 + # FIXME: get_event_loop will be deprecated in python3.10
271 271 loop = asyncio.get_event_loop()
272 272 for i, (qref, qfact) in enumerate(self["question_factory"].items()):
273 273 try:
... ...
setup.py
... ... @@ -22,15 +22,15 @@ setup(
22 22 url="https://git.xdi.uevora.pt/mjsb/perguntations.git",
23 23 packages=find_packages(),
24 24 include_package_data=True, # install files from MANIFEST.in
25   - python_requires='>=3.8.*',
  25 + python_requires='>=3.9',
26 26 install_requires=[
27   - 'bcrypt>=3.1',
  27 + 'argon2-cffi>=23.1',
28 28 'mistune<2.0',
29 29 'pyyaml>=5.1',
30 30 'pygments',
31 31 'schema>=0.7.5',
32   - 'sqlalchemy>=1.4',
33   - 'tornado>=6.1',
  32 + 'sqlalchemy>=2.0',
  33 + 'tornado>=6.4',
34 34 ],
35 35 entry_points={
36 36 'console_scripts': [
... ...