Compare View
Commits (4)
-
move from bcrypt to argon2 ignore .venv directory update sqlalchemy models to 2.0
Showing
9 changed files
Show diff stats
.gitignore
... | ... | @@ -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': [ | ... | ... |