Compare View

switch
from
...
to
 
Commits (4)
  1 +# ignore virtual environment
  2 +.venv
  3 +
1 __pycache__/ 4 __pycache__/
2 .DS_Store 5 .DS_Store
3 demo/ans 6 demo/ans
perguntations/TODO 0 → 100644
@@ -0,0 +1,17 @@ @@ -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,7 +21,7 @@
21 # SOFTWARE. 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 perguntations is a webserver that is intended to be run in an isolated 26 perguntations is a webserver that is intended to be run in an isolated
27 local network in classroom environment. 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,13 +29,13 @@ The server will run a preconfigured test and serve the test as a web form.
29 The answers submitted by the students are immediatly checked and classified. 29 The answers submitted by the students are immediatly checked and classified.
30 The grades are stored in a database and the tests are stored in JSON files as 30 The grades are stored in a database and the tests are stored in JSON files as
31 proof of submission and for review. 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 APP_DESCRIPTION = str(__doc__) 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 __version__ = APP_VERSION 41 __version__ = APP_VERSION
perguntations/app.py
@@ -14,7 +14,7 @@ import os @@ -14,7 +14,7 @@ import os
14 from typing import Optional 14 from typing import Optional
15 15
16 # installed packages 16 # installed packages
17 -import bcrypt 17 +import argon2
18 from sqlalchemy import create_engine, select 18 from sqlalchemy import create_engine, select
19 from sqlalchemy.exc import OperationalError, NoResultFound, IntegrityError 19 from sqlalchemy.exc import OperationalError, NoResultFound, IntegrityError
20 from sqlalchemy.orm import Session 20 from sqlalchemy.orm import Session
@@ -30,21 +30,24 @@ from .questions import question_from @@ -30,21 +30,24 @@ from .questions import question_from
30 # setup logger for this module 30 # setup logger for this module
31 logger = logging.getLogger(__name__) 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 """check password in executor""" 37 """check password in executor"""
36 loop = asyncio.get_running_loop() 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 """get hash for password""" 48 """get hash for password"""
44 loop = asyncio.get_running_loop() 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,7 +69,7 @@ class App:
66 self._make_test_factory(config["testfile"]) 69 self._make_test_factory(config["testfile"])
67 self._db_setup() # setup engine and load all students 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 asyncio.get_event_loop().run_until_complete(self._assign_tests()) 73 asyncio.get_event_loop().run_until_complete(self._assign_tests())
71 74
72 # command line options: --allow-all, --allow-list filename 75 # command line options: --allow-all, --allow-list filename
perguntations/initdb.py
@@ -9,10 +9,9 @@ import csv @@ -9,10 +9,9 @@ import csv
9 import argparse 9 import argparse
10 import re 10 import re
11 from string import capwords 11 from string import capwords
12 -from concurrent.futures import ThreadPoolExecutor  
13 12
14 # installed packages 13 # installed packages
15 -import bcrypt 14 +from argon2 import PasswordHasher
16 from sqlalchemy import create_engine, select 15 from sqlalchemy import create_engine, select
17 from sqlalchemy.orm import Session 16 from sqlalchemy.orm import Session
18 from sqlalchemy.exc import IntegrityError 17 from sqlalchemy.exc import IntegrityError
@@ -80,7 +79,7 @@ def parse_commandline_arguments(): @@ -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 SIIUE names have alien strings like "(TE)" and are sometimes capitalized 84 SIIUE names have alien strings like "(TE)" and are sometimes capitalized
86 We remove them so that students dont keep asking what it means 85 We remove them so that students dont keep asking what it means
@@ -111,17 +110,6 @@ def get_students_from_csv(filename): @@ -111,17 +110,6 @@ def get_students_from_csv(filename):
111 return students 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 def insert_students_into_db(session, students) -> None: 113 def insert_students_into_db(session, students) -> None:
126 """insert list of students into the database""" 114 """insert list of students into the database"""
127 try: 115 try:
@@ -129,7 +117,6 @@ def insert_students_into_db(session, students) -> None: @@ -129,7 +117,6 @@ def insert_students_into_db(session, students) -> None:
129 [Student(id=s["uid"], name=s["name"], password=s["pw"]) for s in students] 117 [Student(id=s["uid"], name=s["name"], password=s["pw"]) for s in students]
130 ) 118 )
131 session.commit() 119 session.commit()
132 -  
133 except IntegrityError: 120 except IntegrityError:
134 print("!!! Integrity error. Users already in database. Aborted !!!\n") 121 print("!!! Integrity error. Users already in database. Aborted !!!\n")
135 session.rollback() 122 session.rollback()
@@ -139,7 +126,6 @@ def insert_students_into_db(session, students) -> None: @@ -139,7 +126,6 @@ def insert_students_into_db(session, students) -> None:
139 def show_students_in_database(session, verbose=False): 126 def show_students_in_database(session, verbose=False):
140 """get students from database""" 127 """get students from database"""
141 users = session.execute(select(Student)).scalars().all() 128 users = session.execute(select(Student)).scalars().all()
142 - # users = session.query(Student).all()  
143 total = len(users) 129 total = len(users)
144 130
145 print("Registered users:") 131 print("Registered users:")
@@ -165,15 +151,16 @@ def show_students_in_database(session, verbose=False): @@ -165,15 +151,16 @@ def show_students_in_database(session, verbose=False):
165 def main(): 151 def main():
166 """insert, update, show students from database""" 152 """insert, update, show students from database"""
167 153
  154 + ph = PasswordHasher()
168 args = parse_commandline_arguments() 155 args = parse_commandline_arguments()
169 156
170 # --- database 157 # --- database
171 print(f"Database: {args.db}") 158 print(f"Database: {args.db}")
172 engine = create_engine(f"sqlite:///{args.db}", echo=False, future=True) 159 engine = create_engine(f"sqlite:///{args.db}", echo=False, future=True)
173 Base.metadata.create_all(engine) # Criates schema if needed 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 students = [] 164 students = []
178 165
179 if args.admin: 166 if args.admin:
@@ -191,35 +178,45 @@ def main(): @@ -191,35 +178,45 @@ def main():
191 178
192 # --- insert new students 179 # --- insert new students
193 if students: 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 insert_students_into_db(session, students) 191 insert_students_into_db(session, students)
199 192
200 # --- update all students 193 # --- update all students
201 if args.update_all: 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 session.commit() 207 session.commit()
213 208
214 - # --- update some students 209 + # --- update only specified students
215 else: 210 else:
216 for student_id in args.update: 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 session.commit() 220 session.commit()
224 221
225 show_students_in_database(session, args.verbose) 222 show_students_in_database(session, args.verbose)
perguntations/models.py
@@ -3,100 +3,59 @@ perguntations/models.py @@ -3,100 +3,59 @@ perguntations/models.py
3 SQLAlchemy ORM 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 class Student(Base): 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 class Test(Base): 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 class Question(Base): 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 Handles the web, http & html part of the application interface. 3 Handles the web, http & html part of the application interface.
@@ -21,9 +20,7 @@ from typing import Dict, Tuple @@ -21,9 +20,7 @@ from typing import Dict, Tuple
21 import uuid 20 import uuid
22 21
23 # user installed libraries 22 # user installed libraries
24 -import tornado.ioloop  
25 -import tornado.web  
26 -import tornado.httpserver 23 +import tornado
27 24
28 # this project 25 # this project
29 from .parser_markdown import md_to_html 26 from .parser_markdown import md_to_html
@@ -262,7 +259,7 @@ class RootHandler(BaseHandler): @@ -262,7 +259,7 @@ class RootHandler(BaseHandler):
262 259
263 # ---------------------------------------------------------------------------- 260 # ----------------------------------------------------------------------------
264 # pylint: disable=abstract-method 261 # pylint: disable=abstract-method
265 -# FIXME also to update answers 262 +# FIXME: also to update answers
266 class StudentWebservice(BaseHandler): 263 class StudentWebservice(BaseHandler):
267 """ 264 """
268 Receive ajax from students during the test in response to the events 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,7 +66,7 @@ test_schema = schema.Schema(
66 }, 66 },
67 ignore_extra_keys=True, 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 class TestFactoryException(Exception): 72 class TestFactoryException(Exception):
@@ -105,7 +105,7 @@ class TestFactory(dict): @@ -105,7 +105,7 @@ class TestFactory(dict):
105 normalize_question_list(self["questions"]) 105 normalize_question_list(self["questions"])
106 106
107 # --- for review, we are done. no factories needed 107 # --- for review, we are done. no factories needed
108 - # if self['review']: FIXME 108 + # if self['review']: FIXME:
109 # logger.info('Review mode. No questions loaded. No factories.') 109 # logger.info('Review mode. No questions loaded. No factories.')
110 # return 110 # return
111 111
@@ -267,7 +267,7 @@ class TestFactory(dict): @@ -267,7 +267,7 @@ class TestFactory(dict):
267 checks if questions can be correctly generated and corrected 267 checks if questions can be correctly generated and corrected
268 """ 268 """
269 logger.info("Checking questions...") 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 loop = asyncio.get_event_loop() 271 loop = asyncio.get_event_loop()
272 for i, (qref, qfact) in enumerate(self["question_factory"].items()): 272 for i, (qref, qfact) in enumerate(self["question_factory"].items()):
273 try: 273 try:
@@ -22,15 +22,15 @@ setup( @@ -22,15 +22,15 @@ setup(
22 url="https://git.xdi.uevora.pt/mjsb/perguntations.git", 22 url="https://git.xdi.uevora.pt/mjsb/perguntations.git",
23 packages=find_packages(), 23 packages=find_packages(),
24 include_package_data=True, # install files from MANIFEST.in 24 include_package_data=True, # install files from MANIFEST.in
25 - python_requires='>=3.8.*', 25 + python_requires='>=3.9',
26 install_requires=[ 26 install_requires=[
27 - 'bcrypt>=3.1', 27 + 'argon2-cffi>=23.1',
28 'mistune<2.0', 28 'mistune<2.0',
29 'pyyaml>=5.1', 29 'pyyaml>=5.1',
30 'pygments', 30 'pygments',
31 'schema>=0.7.5', 31 'schema>=0.7.5',
32 - 'sqlalchemy>=1.4',  
33 - 'tornado>=6.1', 32 + 'sqlalchemy>=2.0',
  33 + 'tornado>=6.4',
34 ], 34 ],
35 entry_points={ 35 entry_points={
36 'console_scripts': [ 36 'console_scripts': [