Blame view

aprendizations/learnapp.py 23.6 KB
443a1eea   Miguel Barão   Update to latest ...
1
2
3
4
'''
Learn application.
This is the main controller of the application.
'''
894bdd05   Miguel Barão   - moved all appli...
5

84a1054c   Miguel Barão   - Each student is...
6
# python standard library
3586cfab   Miguel Barao   fixed async login
7
import asyncio
9a20acc8   Miguel Barão   rankings:
8
from collections import defaultdict
bbfe1f31   Miguel Barão   - the topic state...
9
from datetime import datetime
d187aad4   Miguel Barão   - adds courses
10
import logging
3259fc7c   Miguel Barão   - Modified login ...
11
from random import random
443a1eea   Miguel Barão   Update to latest ...
12
from os.path import join, exists
a6b50da0   Miguel Barão   fixes error where...
13
from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict
894bdd05   Miguel Barão   - moved all appli...
14

3259fc7c   Miguel Barão   - Modified login ...
15
# third party libraries
13773677   Miguel Barão   under development...
16
import bcrypt
13773677   Miguel Barão   under development...
17
import networkx as nx
443a1eea   Miguel Barão   Update to latest ...
18
19
20
from sqlalchemy import create_engine, select, func
from sqlalchemy.orm import Session
from sqlalchemy.exc import NoResultFound
894bdd05   Miguel Barão   - moved all appli...
21
22

# this project
443a1eea   Miguel Barão   Update to latest ...
23
24
25
26
from aprendizations.models import Student, Answer, Topic, StudentTopic
from aprendizations.questions import Question, QFactory, QDict, QuestionException
from aprendizations.student import StudentState
from aprendizations.tools import load_yaml
894bdd05   Miguel Barão   - moved all appli...
27

d823a4d8   Miguel Barão   Lots of changes:
28

a96cd2c7   Miguel Barão   - added logging s...
29
30
31
# setup logger for this module
logger = logging.getLogger(__name__)

efdbe121   Miguel Barão   - added database ...
32

894bdd05   Miguel Barão   - moved all appli...
33
# ============================================================================
e02d725b   Miguel Barão   Check if dependen...
34
class LearnException(Exception):
443a1eea   Miguel Barão   Update to latest ...
35
    '''Exceptions raised from the LearnApp class'''
e02d725b   Miguel Barão   Check if dependen...
36
37


2394fd9e   Miguel Barão   code cleaning
38
class DatabaseUnusableError(LearnException):
443a1eea   Miguel Barão   Update to latest ...
39
    '''Exception raised if the database fails in the initialization'''
93e13002   Miguel Barão   updates README.md.
40
41


e02d725b   Miguel Barão   Check if dependen...
42
# ============================================================================
443a1eea   Miguel Barão   Update to latest ...
43
44
45
46
class LearnApp():
    '''
    LearnApp - application logic

65a5ad4a   Miguel Barão   Small fixes
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
    self.deps = networkx topic dependencies
    self.courses = {
        course_id: {
            'title': ...,
            'description': ...,
            'goals': ...,
            }, ...
        }
    self.factory = { qref: QFactory() }
    self.online = {
        student_id: {
            'number': ...,
            'name': ...,
            'state': StudentState(),
            'counter': ...
            }, ...
        }
443a1eea   Miguel Barão   Update to latest ...
64
    '''
3586cfab   Miguel Barao   fixed async login
65

5f3daeeb   Miguel Barão   add lots of type ...
66
    def __init__(self,
d187aad4   Miguel Barão   - adds courses
67
                 courses: str,  # filename with course configurations
5f3daeeb   Miguel Barão   add lots of type ...
68
                 prefix: str,   # path to topics
fc533376   Miguel Barão   Fix pylint warnin...
69
                 dbase: str,    # database filename
5f3daeeb   Miguel Barão   add lots of type ...
70
71
                 check: bool = False) -> None:

fc533376   Miguel Barão   Fix pylint warnin...
72
73
        self._db_setup(dbase)       # setup database and check students
        self.online: Dict[str, Dict] = {}    # online students
a203d3cc   Miguel Barão   - new http server...
74

a9131008   Miguel Barão   - allow chapters ...
75
76
        try:
            config: Dict[str, Any] = load_yaml(courses)
443a1eea   Miguel Barão   Update to latest ...
77
        except Exception as exc:
a9131008   Miguel Barão   - allow chapters ...
78
79
            msg = f'Failed to load yaml file "{courses}"'
            logger.error(msg)
443a1eea   Miguel Barão   Update to latest ...
80
            raise LearnException(msg) from exc
d187aad4   Miguel Barão   - adds courses
81

289991dc   Miguel Barão   - several fixes, ...
82
        # --- topic dependencies are shared between all courses
2b30310a   Miguel Barão   - path prefix is ...
83
        self.deps = nx.DiGraph(prefix=prefix)
d823a4d8   Miguel Barão   Lots of changes:
84
        logger.info('Populating topic graph:')
289991dc   Miguel Barão   - several fixes, ...
85

443a1eea   Miguel Barão   Update to latest ...
86
87
88
89
90
91
92
93
        # topics defined directly in the courses file, usually empty
        base_topics = config.get('topics', {})
        self._populate_graph(base_topics)
        logger.info('%6d topics in %s', len(base_topics), courses)

        # load other course files with the topics the their deps
        for course_file in config.get('topics_from', []):
            course_conf = load_yaml(course_file)    # course configuration
39126690   Miguel Barão   removes npm and n...
94
            logger.info('%6d topics from %s',
443a1eea   Miguel Barão   Update to latest ...
95
96
97
                        len(course_conf["topics"]), course_file)
            self._populate_graph(course_conf)
        logger.info('Graph has %d topics', len(self.deps))
a203d3cc   Miguel Barão   - new http server...
98

d823a4d8   Miguel Barão   Lots of changes:
99
100
        # --- courses dict
        self.courses = config['courses']
443a1eea   Miguel Barão   Update to latest ...
101
102
103
104
        logger.info('Courses:  %s', ', '.join(self.courses.keys()))
        for cid, course in self.courses.items():
            course.setdefault('title', cid)  # course title undefined
            for goal in course['goals']:
d823a4d8   Miguel Barão   Lots of changes:
105
                if goal not in self.deps.nodes():
443a1eea   Miguel Barão   Update to latest ...
106
                    msg = f'Goal "{goal}" from course "{cid}" does not exist'
a9131008   Miguel Barão   - allow chapters ...
107
108
                    logger.error(msg)
                    raise LearnException(msg)
443a1eea   Miguel Barão   Update to latest ...
109
110
111
                if self.deps.nodes[goal]['type'] == 'chapter':
                    course['goals'] += [g for g in self.deps.predecessors(goal)
                                        if g not in course['goals']]
d823a4d8   Miguel Barão   Lots of changes:
112

289991dc   Miguel Barão   - several fixes, ...
113
        # --- factory is a dict with question generators for all topics
443a1eea   Miguel Barão   Update to latest ...
114
        self.factory: Dict[str, QFactory] = self._make_factory()
b141e132   Miguel Barão   adds some type an...
115
116

        # if graph has topics that are not in the database, add them
443a1eea   Miguel Barão   Update to latest ...
117
        self._add_missing_topics(self.deps.nodes())
1a34901f   Miguel Barão   - add topics from...
118

ab185529   Miguel Barão   Changes:
119
        if check:
443a1eea   Miguel Barão   Update to latest ...
120
            self._sanity_check_questions()
ab185529   Miguel Barão   Changes:
121

443a1eea   Miguel Barão   Update to latest ...
122
123
124
125
126
127
    def _sanity_check_questions(self) -> None:
        '''
        Unit tests for all questions

        Generates all questions, give right and wrong answers and corrects.
        '''
3259fc7c   Miguel Barão   - Modified login ...
128
        logger.info('Starting sanity checks (may take a while...)')
ab185529   Miguel Barão   Changes:
129

5f3daeeb   Miguel Barão   add lots of type ...
130
        errors: int = 0
fc533376   Miguel Barão   Fix pylint warnin...
131
        for qref, qfactory in self.factory.items():
443a1eea   Miguel Barão   Update to latest ...
132
            logger.debug('checking %s...', qref)
3259fc7c   Miguel Barão   - Modified login ...
133
            try:
fc533376   Miguel Barão   Fix pylint warnin...
134
                question = qfactory.generate()
443a1eea   Miguel Barão   Update to latest ...
135
136
            except QuestionException as exc:
                logger.error(exc)
3259fc7c   Miguel Barão   - Modified login ...
137
                errors += 1
1841cc32   Miguel Barão   - fix desynchroni...
138
                continue  # to next question
3259fc7c   Miguel Barão   - Modified login ...
139

443a1eea   Miguel Barão   Update to latest ...
140
141
142
143
144
145
            if 'tests_right' in question:
                for right_answer in question['tests_right']:
                    question['answer'] = right_answer
                    question.correct()
                    if question['grade'] < 1.0:
                        logger.error('Failed right answer in "%s".', qref)
3259fc7c   Miguel Barão   - Modified login ...
146
                        errors += 1
1841cc32   Miguel Barão   - fix desynchroni...
147
                        continue  # to next test
443a1eea   Miguel Barão   Update to latest ...
148
149
150
151
152
153
154
155
156
157
            elif question['type'] == 'textarea':
                msg = f'- consider adding tests to {question["ref"]}'
                logger.warning(msg)

            if 'tests_wrong' in question:
                for wrong_answer in question['tests_wrong']:
                    question['answer'] = wrong_answer
                    question.correct()
                    if question['grade'] >= 1.0:
                        logger.error('Failed wrong answer in "%s".', qref)
3259fc7c   Miguel Barão   - Modified login ...
158
                        errors += 1
1841cc32   Miguel Barão   - fix desynchroni...
159
                        continue  # to next test
3259fc7c   Miguel Barão   - Modified login ...
160
161

        if errors > 0:
443a1eea   Miguel Barão   Update to latest ...
162
            logger.error('%6d error(s) found.', errors)
a333dc72   Miguel Barão   Add type annotati...
163
            raise LearnException('Sanity checks')
443a1eea   Miguel Barão   Update to latest ...
164
        logger.info('     0 errors found.')
b141e132   Miguel Barão   adds some type an...
165

443a1eea   Miguel Barão   Update to latest ...
166
167
    async def login(self, uid: str, password: str) -> bool:
        '''user login'''
3259fc7c   Miguel Barão   - Modified login ...
168
169
170
171

        # wait random time to minimize timing attacks
        await asyncio.sleep(random())

443a1eea   Miguel Barão   Update to latest ...
172
173
174
175
176
177
        query = select(Student).where(Student.id == uid)
        try:
            with Session(self._engine, future=True) as session:
                student = session.execute(query).scalar_one()
        except NoResultFound:
            logger.info('User "%s" does not exist', uid)
3259fc7c   Miguel Barão   - Modified login ...
178
            return False
1cdce3ee   Miguel Barão   - get login state...
179

443a1eea   Miguel Barão   Update to latest ...
180
181
182
183
184
        loop = asyncio.get_running_loop()
        pw_ok: bool = await loop.run_in_executor(None,
                                                 bcrypt.checkpw,
                                                 password.encode('utf-8'),
                                                 student.password)
2285f4a5   Miguel Barão   - fixed finished ...
185

3586cfab   Miguel Barao   fixed async login
186
187
        if pw_ok:
            if uid in self.online:
443a1eea   Miguel Barão   Update to latest ...
188
                logger.warning('User "%s" already logged in', uid)
3259fc7c   Miguel Barão   - Modified login ...
189
                counter = self.online[uid]['counter']
d4ab394c   Miguel Barão   - removed unused ...
190
            else:
443a1eea   Miguel Barão   Update to latest ...
191
                logger.info('User "%s" logged in', uid)
e36dfd6c   Miguel Barão   - Fixed simultane...
192
                counter = 0
3586cfab   Miguel Barao   fixed async login
193

443a1eea   Miguel Barão   Update to latest ...
194
195
196
197
            # get topics for this student and set its current state
            query = select(StudentTopic).where(StudentTopic.student_id == uid)
            with Session(self._engine, future=True) as session:
                student_topics = session.execute(query).scalars().all()
3586cfab   Miguel Barao   fixed async login
198

9161ff75   Miguel Barão   choose number of ...
199
            state = {t.topic_id: {
3586cfab   Miguel Barao   fixed async login
200
201
                'level': t.level,
                'date': datetime.strptime(t.date, "%Y-%m-%d %H:%M:%S.%f")
443a1eea   Miguel Barão   Update to latest ...
202
                } for t in student_topics}
3586cfab   Miguel Barao   fixed async login
203
204
205

            self.online[uid] = {
                'number': uid,
443a1eea   Miguel Barão   Update to latest ...
206
                'name': student.name,
d187aad4   Miguel Barão   - adds courses
207
208
209
                'state': StudentState(uid=uid, state=state,
                                      courses=self.courses, deps=self.deps,
                                      factory=self.factory),
e36dfd6c   Miguel Barão   - Fixed simultane...
210
                'counter': counter + 1,  # counts simultaneous logins
9161ff75   Miguel Barão   choose number of ...
211
                }
3586cfab   Miguel Barao   fixed async login
212
213

        else:
443a1eea   Miguel Barão   Update to latest ...
214
            logger.info('User "%s" wrong password', uid)
3586cfab   Miguel Barao   fixed async login
215
216

        return pw_ok
894bdd05   Miguel Barão   - moved all appli...
217

5f3daeeb   Miguel Barão   add lots of type ...
218
    def logout(self, uid: str) -> None:
443a1eea   Miguel Barão   Update to latest ...
219
        '''User logout'''
ed34db4c   Miguel Barão   - save topic stat...
220
        del self.online[uid]
443a1eea   Miguel Barão   Update to latest ...
221
        logger.info('User "%s" logged out', uid)
894bdd05   Miguel Barão   - moved all appli...
222

443a1eea   Miguel Barão   Update to latest ...
223
224
225
226
227
228
    async def change_password(self, uid: str, password: str) -> bool:
        '''
        Change user Password.
        Returns True if password is successfully changed
        '''
        if not password:
c3bb2c29   Miguel Barão   - new: change pas...
229
230
            return False

3259fc7c   Miguel Barão   - Modified login ...
231
        loop = asyncio.get_running_loop()
443a1eea   Miguel Barão   Update to latest ...
232
233
234
235
236
237
238
239
240
241
        hashed_pw = await loop.run_in_executor(None,
                                               bcrypt.hashpw,
                                               password.encode('utf-8'),
                                               bcrypt.gensalt())

        with Session(self._engine, future=True) as session:
            query = select(Student).where(Student.id == uid)
            user = session.execute(query).scalar_one()
            user.password = hashed_pw
            session.commit()
ae2dc2b8   Miguel Barão   - finished topic ...
242

443a1eea   Miguel Barão   Update to latest ...
243
        logger.info('User "%s" changed password', uid)
c3bb2c29   Miguel Barão   - new: change pas...
244
245
        return True

d823a4d8   Miguel Barão   Lots of changes:
246
    async def check_answer(self, uid: str, answer) -> Question:
443a1eea   Miguel Barão   Update to latest ...
247
248
249
250
        '''
        Checks answer and update database.
        Returns corrected question.
        '''
d187aad4   Miguel Barão   - adds courses
251
        student = self.online[uid]['state']
a6b50da0   Miguel Barão   fixes error where...
252
        await student.check_answer(answer)
5f3daeeb   Miguel Barão   add lots of type ...
253

443a1eea   Miguel Barão   Update to latest ...
254
255
256
257
258
259
        topic_id = student.get_current_topic()
        question: Question = student.get_current_question()
        grade = question["grade"]
        ref = question["ref"]

        logger.info('User "%s" got %.2f in "%s"', uid, grade, ref)
b44bf1c0   Miguel Barão   - changed the way...
260
261

        # always save grade of answered question
443a1eea   Miguel Barão   Update to latest ...
262
263
264
265
266
267
268
269
270
        answer = Answer(ref=ref,
                        grade=grade,
                        starttime=str(question['start_time']),
                        finishtime=str(question['finish_time']),
                        student_id=uid,
                        topic_id=topic_id)
        with Session(self._engine, future=True) as session:
            session.add(answer)
            session.commit()
a6b50da0   Miguel Barão   fixes error where...
271

443a1eea   Miguel Barão   Update to latest ...
272
        return question
a6b50da0   Miguel Barão   fixes error where...
273

a6b50da0   Miguel Barão   fixes error where...
274
    async def get_question(self, uid: str) -> Optional[Question]:
443a1eea   Miguel Barão   Update to latest ...
275
276
277
278
279
280
        '''
        Get the question to show (current or new one)
        If no more questions, save/update level in database
        '''
        student_state = self.online[uid]['state']
        question: Optional[Question] = await student_state.get_question()
b44bf1c0   Miguel Barão   - changed the way...
281

a6b50da0   Miguel Barão   fixes error where...
282
        # save topic to database if finished
443a1eea   Miguel Barão   Update to latest ...
283
284
285
286
287
288
289
        if student_state.topic_has_finished():
            topic_id: str = student_state.get_previous_topic()
            level: float = student_state.get_topic_level(topic_id)
            date: str = str(student_state.get_topic_date(topic_id))
            logger.info('User "%s" finished "%s" (level=%.2f)',
                        uid, topic_id, level)

1d65b0b7   Miguel Barão   Merge branch 'dev'
290
291
292
            query = select(StudentTopic) \
                    .where(StudentTopic.student_id == uid) \
                    .where(StudentTopic.topic_id == topic_id)
443a1eea   Miguel Barão   Update to latest ...
293
294
295
296
            with Session(self._engine, future=True) as session:
                student_topic = session.execute(query).scalar_one_or_none()

                if student_topic is None:
ae2dc2b8   Miguel Barão   - finished topic ...
297
                    # insert new studenttopic into database
2b709b19   Miguel Barão   fixed several err...
298
                    logger.debug('db insert studenttopic')
443a1eea   Miguel Barão   Update to latest ...
299
300
301
302
                    query_topic = select(Topic).where(Topic.id == topic_id)
                    query_student = select(Student).where(Student.id == uid)
                    topic = session.execute(query_topic).scalar_one()
                    student = session.execute(query_student).scalar_one()
ed9d2f21   Miguel Barão   Reorganized proje...
303
                    # association object
443a1eea   Miguel Barão   Update to latest ...
304
305
306
307
308
                    student_topic = StudentTopic(level=level,
                                                 date=date,
                                                 topic=topic,
                                                 student=student)
                    student.topics.append(student_topic)
ae2dc2b8   Miguel Barão   - finished topic ...
309
310
                else:
                    # update studenttopic in database
443a1eea   Miguel Barão   Update to latest ...
311
312
313
                    logger.debug('db update studenttopic to level %f', level)
                    student_topic.level = level
                    student_topic.date = date
efdbe121   Miguel Barão   - added database ...
314

443a1eea   Miguel Barão   Update to latest ...
315
316
                session.add(student_topic)
                session.commit()
b44bf1c0   Miguel Barão   - changed the way...
317

443a1eea   Miguel Barão   Update to latest ...
318
        return question
d823a4d8   Miguel Barão   Lots of changes:
319

a9131008   Miguel Barão   - allow chapters ...
320
    def start_course(self, uid: str, course_id: str) -> None:
443a1eea   Miguel Barão   Update to latest ...
321
322
323
        '''Start course'''

        student_state = self.online[uid]['state']
d187aad4   Miguel Barão   - adds courses
324
        try:
443a1eea   Miguel Barão   Update to latest ...
325
326
327
328
            student_state.start_course(course_id)
        except Exception as exc:
            logger.warning('"%s" could not start course "%s"', uid, course_id)
            raise LearnException() from exc
d187aad4   Miguel Barão   - adds courses
329
        else:
39126690   Miguel Barão   removes npm and n...
330
            logger.info('User "%s" course "%s"', uid, course_id)
d187aad4   Miguel Barão   - adds courses
331

5f3daeeb   Miguel Barão   add lots of type ...
332
    async def start_topic(self, uid: str, topic: str) -> None:
443a1eea   Miguel Barão   Update to latest ...
333
334
        '''Start new topic'''

2b30310a   Miguel Barão   - path prefix is ...
335
        student = self.online[uid]['state']
a9131008   Miguel Barão   - allow chapters ...
336

bbdca9b8   Miguel Barão   - fixed error whe...
337
        try:
dbdd58fe   Miguel Barão   - added option 'a...
338
            await student.start_topic(topic)
443a1eea   Miguel Barão   Update to latest ...
339
340
341
        except Exception as exc:
            logger.warning('User "%s" could not start "%s": %s',
                           uid, topic, str(exc))
bbdca9b8   Miguel Barão   - fixed error whe...
342
        else:
443a1eea   Miguel Barão   Update to latest ...
343
            logger.info('User "%s" started topic "%s"', uid, topic)
1a34901f   Miguel Barão   - add topics from...
344

443a1eea   Miguel Barão   Update to latest ...
345
346
347
348
349
350
351
352
353
354
355
356
    def _add_missing_topics(self, topics: Iterable[str]) -> None:
        '''
        Fill db table 'Topic' with topics from the graph, if new
        '''
        with Session(self._engine, future=True) as session:
            db_topics = session.execute(select(Topic.id)).scalars().all()
            new = [Topic(id=t) for t in topics if t not in db_topics]
            if new:
                session.add_all(new)
                session.commit()
                logger.info('Added %d new topic(s) to the database', len(new))

443a1eea   Miguel Barão   Update to latest ...
357
358
359
360
361
362
363
    def _db_setup(self, database: str) -> None:
        '''
        Setup and check database contents
        '''

        logger.info('Checking database "%s":', database)
        if not exists(database):
a881e511   Miguel Barão   - missing databas...
364
365
366
            msg = 'Database does not exist.'
            logger.error(msg)
            raise LearnException(msg)
d823a4d8   Miguel Barão   Lots of changes:
367

443a1eea   Miguel Barão   Update to latest ...
368
369
        self._engine = create_engine(f'sqlite:///{database}', future=True)

fcdedf5a   Miguel Barão   - Reads config.ya...
370
        try:
443a1eea   Miguel Barão   Update to latest ...
371
372
373
374
375
376
377
378
379
380
            query_students = select(func.count(Student.id))
            query_topics = select(func.count(Topic.id))
            query_answers = select(func.count(Answer.id))
            with Session(self._engine, future=True) as session:
                count_students = session.execute(query_students).scalar()
                count_topics = session.execute(query_topics).scalar()
                count_answers = session.execute(query_answers).scalar()
        except Exception as exc:
            logger.error('Database "%s" not usable!', database)
            raise DatabaseUnusableError() from exc
fcdedf5a   Miguel Barão   - Reads config.ya...
381
        else:
443a1eea   Miguel Barão   Update to latest ...
382
383
384
            logger.info('%6d students', count_students)
            logger.info('%6d topics', count_topics)
            logger.info('%6d answers', count_answers)
fcdedf5a   Miguel Barão   - Reads config.ya...
385

443a1eea   Miguel Barão   Update to latest ...
386
387
388
389
390
391
392
393
394
395
396
    def _populate_graph(self, config: Dict[str, Any]) -> None:
        '''
        Populates a digraph.

        Nodes are the topic references e.g. 'my/topic'
          g.nodes['my/topic']['name']      name of the topic
          g.nodes['my/topic']['questions'] list of question refs

        Edges are obtained from the deps defined in the YAML file for each topic.
        '''

289991dc   Miguel Barão   - several fixes, ...
397
        defaults = {
443a1eea   Miguel Barão   Update to latest ...
398
            'type': 'topic',  # chapter
289991dc   Miguel Barão   - several fixes, ...
399
400
            'file': 'questions.yaml',
            'shuffle_questions': True,
443a1eea   Miguel Barão   Update to latest ...
401
            'choose': 99,
289991dc   Miguel Barão   - several fixes, ...
402
403
404
405
406
407
            'forgetting_factor': 1.0,  # no forgetting
            'max_tries': 1,            # in every question
            'append_wrong': True,
            'min_level': 0.01,         # to unlock topic
        }
        defaults.update(config.get('defaults', {}))
d5cd0d10   Miguel Barão   - added options '...
408
409

        # iterate over topics and populate graph
5f3daeeb   Miguel Barão   add lots of type ...
410
        topics: Dict[str, Dict] = config.get('topics', {})
443a1eea   Miguel Barão   Update to latest ...
411
        self.deps.add_nodes_from(topics.keys())
d5cd0d10   Miguel Barão   - added options '...
412
        for tref, attr in topics.items():
443a1eea   Miguel Barão   Update to latest ...
413
414
415
            logger.debug('       + %s', tref)
            for dep in attr.get('deps', []):
                self.deps.add_edge(dep, tref)
d5cd0d10   Miguel Barão   - added options '...
416

443a1eea   Miguel Barão   Update to latest ...
417
418
            topic = self.deps.nodes[tref]  # get current topic node
            topic['name'] = attr.get('name', tref)
fc533376   Miguel Barão   Fix pylint warnin...
419
            topic['questions'] = attr.get('questions', [])
d5cd0d10   Miguel Barão   - added options '...
420

289991dc   Miguel Barão   - several fixes, ...
421
            for k, default in defaults.items():
443a1eea   Miguel Barão   Update to latest ...
422
                topic[k] = attr.get(k, default)
289991dc   Miguel Barão   - several fixes, ...
423

443a1eea   Miguel Barão   Update to latest ...
424
425
            # prefix/topic
            topic['path'] = join(self.deps.graph['prefix'], tref)
289991dc   Miguel Barão   - several fixes, ...
426

65a5ad4a   Miguel Barão   Small fixes
427
    # ------------------------------------------------------------------------
ab185529   Miguel Barão   Changes:
428
    # methods that do not change state (pure functions)
d5cd0d10   Miguel Barão   - added options '...
429
    # ------------------------------------------------------------------------
65a5ad4a   Miguel Barão   Small fixes
430

443a1eea   Miguel Barão   Update to latest ...
431
432
433
434
435
436
    def _make_factory(self) -> Dict[str, QFactory]:
        '''
        Buils dictionary of question factories
          - visits each topic in the graph,
          - adds factory for each topic.
        '''
2265a158   Miguel Barão   new transform opt...
437

2b709b19   Miguel Barão   fixed several err...
438
        logger.info('Building questions factory:')
fc533376   Miguel Barão   Fix pylint warnin...
439
        factory = {}
443a1eea   Miguel Barão   Update to latest ...
440
441
        for tref in self.deps.nodes:
            factory.update(self._factory_for(tref))
d5cd0d10   Miguel Barão   - added options '...
442

443a1eea   Miguel Barão   Update to latest ...
443
        logger.info('Factory has %s questions', len(factory))
a9131008   Miguel Barão   - allow chapters ...
444
        return factory
5f3daeeb   Miguel Barão   add lots of type ...
445

a9131008   Miguel Barão   - allow chapters ...
446
447
448
    # ------------------------------------------------------------------------
    # makes factory for a single topic
    # ------------------------------------------------------------------------
443a1eea   Miguel Barão   Update to latest ...
449
    def _factory_for(self, tref: str) -> Dict[str, QFactory]:
fc533376   Miguel Barão   Fix pylint warnin...
450
        factory: Dict[str, QFactory] = {}
443a1eea   Miguel Barão   Update to latest ...
451
        topic = self.deps.nodes[tref]  # get node
a9131008   Miguel Barão   - allow chapters ...
452
        # load questions as list of dicts
a9131008   Miguel Barão   - allow chapters ...
453
        try:
443a1eea   Miguel Barão   Update to latest ...
454
455
456
457
            fullpath: str = join(topic['path'], topic['file'])
        except Exception as exc:
            msg = f'Invalid topic "{tref}". Check dependencies of: ' + \
                    ', '.join(self.deps.successors(tref))
62736e37   Miguel Barão   fixed some errors...
458
            logger.error(msg)
443a1eea   Miguel Barão   Update to latest ...
459
460
            raise LearnException(msg) from exc
        logger.debug('  Loading %s', fullpath)
a9131008   Miguel Barão   - allow chapters ...
461
462
        try:
            questions: List[QDict] = load_yaml(fullpath)
443a1eea   Miguel Barão   Update to latest ...
463
464
        except Exception as exc:
            if topic['type'] == 'chapter':
a9131008   Miguel Barão   - allow chapters ...
465
                return factory  # chapters may have no "questions"
443a1eea   Miguel Barão   Update to latest ...
466
467
468
469
            msg = f'Failed to load "{fullpath}"'
            logger.error(msg)
            raise LearnException(msg) from exc

a9131008   Miguel Barão   - allow chapters ...
470
471
        if not isinstance(questions, list):
            msg = f'File "{fullpath}" must be a list of questions'
62736e37   Miguel Barão   fixed some errors...
472
            logger.error(msg)
a9131008   Miguel Barão   - allow chapters ...
473
474
475
476
477
478
479
            raise LearnException(msg)

        # update refs to include topic as prefix.
        # refs are required to be unique only within the file.
        # undefined are set to topic:n, where n is the question number
        # within the file
        localrefs: Set[str] = set()  # refs in current file
443a1eea   Miguel Barão   Update to latest ...
480
481
        for i, question in enumerate(questions):
            qref = question.get('ref', str(i))  # ref or number
a9131008   Miguel Barão   - allow chapters ...
482
            if qref in localrefs:
443a1eea   Miguel Barão   Update to latest ...
483
484
                msg = f'Duplicate ref "{qref}" in "{topic["path"]}"'
                logger.error(msg)
a6b50da0   Miguel Barão   fixes error where...
485
                raise LearnException(msg)
a9131008   Miguel Barão   - allow chapters ...
486
            localrefs.add(qref)
d5cd0d10   Miguel Barão   - added options '...
487

443a1eea   Miguel Barão   Update to latest ...
488
489
490
            question['ref'] = f'{tref}:{qref}'
            question['path'] = topic['path']
            question.setdefault('append_wrong', topic['append_wrong'])
d5cd0d10   Miguel Barão   - added options '...
491

a9131008   Miguel Barão   - allow chapters ...
492
        # if questions are left undefined, include all.
443a1eea   Miguel Barão   Update to latest ...
493
494
        if not topic['questions']:
            topic['questions'] = [q['ref'] for q in questions]
d5cd0d10   Miguel Barão   - added options '...
495

443a1eea   Miguel Barão   Update to latest ...
496
        topic['choose'] = min(topic['choose'], len(topic['questions']))
d5cd0d10   Miguel Barão   - added options '...
497

443a1eea   Miguel Barão   Update to latest ...
498
499
500
501
        for question in questions:
            if question['ref'] in topic['questions']:
                factory[question['ref']] = QFactory(question)
                logger.debug('       + %s', question["ref"])
d5cd0d10   Miguel Barão   - added options '...
502

443a1eea   Miguel Barão   Update to latest ...
503
        logger.info('%6d questions in %s', len(topic["questions"]), tref)
d5cd0d10   Miguel Barão   - added options '...
504

ab185529   Miguel Barão   Changes:
505
        return factory
065611f7   Miguel Barão   large ammount of ...
506

a333dc72   Miguel Barão   Add type annotati...
507
    def get_login_counter(self, uid: str) -> int:
fc533376   Miguel Barão   Fix pylint warnin...
508
        '''login counter'''
a6b50da0   Miguel Barão   fixes error where...
509
        return int(self.online[uid]['counter'])
e36dfd6c   Miguel Barão   - Fixed simultane...
510

a333dc72   Miguel Barão   Add type annotati...
511
    def get_student_name(self, uid: str) -> str:
443a1eea   Miguel Barão   Update to latest ...
512
        '''Get the username'''
065611f7   Miguel Barão   large ammount of ...
513
514
        return self.online[uid].get('name', '')

5f3daeeb   Miguel Barão   add lots of type ...
515
    def get_student_state(self, uid: str) -> List[Dict[str, Any]]:
443a1eea   Miguel Barão   Update to latest ...
516
        '''Get the knowledge state of a given user'''
065611f7   Miguel Barão   large ammount of ...
517
518
        return self.online[uid]['state'].get_knowledge_state()

5f3daeeb   Miguel Barão   add lots of type ...
519
    def get_student_progress(self, uid: str) -> float:
443a1eea   Miguel Barão   Update to latest ...
520
        '''Get the current topic progress of a given user'''
a9131008   Miguel Barão   - allow chapters ...
521
        return float(self.online[uid]['state'].get_topic_progress())
065611f7   Miguel Barão   large ammount of ...
522

5f3daeeb   Miguel Barão   add lots of type ...
523
    def get_current_question(self, uid: str) -> Optional[Question]:
443a1eea   Miguel Barão   Update to latest ...
524
525
526
        '''Get the current question of a given user'''
        question: Optional[Question] = self.online[uid]['state'].get_current_question()
        return question
065611f7   Miguel Barão   large ammount of ...
527

1841cc32   Miguel Barão   - fix desynchroni...
528
    def get_current_question_id(self, uid: str) -> str:
443a1eea   Miguel Barão   Update to latest ...
529
        '''Get id of the current question for a given user'''
a9131008   Miguel Barão   - allow chapters ...
530
        return str(self.online[uid]['state'].get_current_question()['qid'])
1841cc32   Miguel Barão   - fix desynchroni...
531

a333dc72   Miguel Barão   Add type annotati...
532
    def get_student_question_type(self, uid: str) -> str:
443a1eea   Miguel Barão   Update to latest ...
533
        '''Get type of the current question for a given user'''
a9131008   Miguel Barão   - allow chapters ...
534
        return str(self.online[uid]['state'].get_current_question()['type'])
dda6f838   Miguel Barão   - fixed error on ...
535
536

    # ------------------------------------------------------------------------
443a1eea   Miguel Barão   Update to latest ...
537
538
    # def get_student_topic(self, uid: str) -> str:
    #     return str(self.online[uid]['state'].get_current_topic())
065611f7   Miguel Barão   large ammount of ...
539

d187aad4   Miguel Barão   - adds courses
540
    def get_student_course_title(self, uid: str) -> str:
443a1eea   Miguel Barão   Update to latest ...
541
        '''get the title of the current course for a given user'''
a9131008   Miguel Barão   - allow chapters ...
542
        return str(self.online[uid]['state'].get_current_course_title())
d187aad4   Miguel Barão   - adds courses
543

a9131008   Miguel Barão   - allow chapters ...
544
    def get_current_course_id(self, uid: str) -> Optional[str]:
443a1eea   Miguel Barão   Update to latest ...
545
        '''get the current course (id) of a given user'''
a9131008   Miguel Barão   - allow chapters ...
546
547
        cid: Optional[str] = self.online[uid]['state'].get_current_course_id()
        return cid
8b4ac80a   Miguel Barão   - fixed menus
548
549

    # ------------------------------------------------------------------------
443a1eea   Miguel Barão   Update to latest ...
550
551
    # def get_topic_name(self, ref: str) -> str:
    #     return str(self.deps.nodes[ref]['name'])
065611f7   Miguel Barão   large ammount of ...
552

5f3daeeb   Miguel Barão   add lots of type ...
553
    def get_current_public_dir(self, uid: str) -> str:
443a1eea   Miguel Barão   Update to latest ...
554
555
556
557
558
559
        '''
        Get the path for the 'public' directory of the current topic of the
        given user.
        E.g. if the user has the active topic 'xpto',
        then returns 'path/to/xpto/public'.
        '''
a9131008   Miguel Barão   - allow chapters ...
560
        topic: str = self.online[uid]['state'].get_current_topic()
5f3daeeb   Miguel Barão   add lots of type ...
561
        prefix: str = self.deps.graph['prefix']
443a1eea   Miguel Barão   Update to latest ...
562
        return join(prefix, topic, 'public')
353ca74c   Miguel Barão   New rankings page
563

a9131008   Miguel Barão   - allow chapters ...
564
    def get_courses(self) -> Dict[str, Dict[str, Any]]:
443a1eea   Miguel Barão   Update to latest ...
565
566
567
        '''
        Get dictionary with all courses {'course1': {...}, 'course2': {...}}
        '''
d187aad4   Miguel Barão   - adds courses
568
569
        return self.courses

a9131008   Miguel Barão   - allow chapters ...
570
    def get_course(self, course_id: str) -> Dict[str, Any]:
443a1eea   Miguel Barão   Update to latest ...
571
572
573
        '''
        Get dictionary {'title': ..., 'description':..., 'goals':...}
        '''
a9131008   Miguel Barão   - allow chapters ...
574
575
        return self.courses[course_id]

1d65b0b7   Miguel Barão   Merge branch 'dev'
576
    def get_rankings(self, uid: str, cid: str) -> List[Tuple[str, str, float]]:
443a1eea   Miguel Barão   Update to latest ...
577
        '''
6c5a961a   Miguel Barão   minor mods
578
        Returns rankings for a certain cid (course_id).
443a1eea   Miguel Barão   Update to latest ...
579
580
581
582
583
584
585
        User where uid have <=2 chars are considered ghosts are hidden from
        the rankings. This is so that there can be users for development or
        testing purposes, which are not real users.
        The user_id of real students must have >2 chars.
        This should be modified to have a "visible" flag
        '''

6c5a961a   Miguel Barão   minor mods
586
        logger.info('User "%s" rankings for "%s"', uid, cid)
e23fb122   Miguel Barão   removes the thumb...
587
588
589
590
591
        query_students = select(Student.id, Student.name)
        query_student_topics = select(StudentTopic.student_id,
                                    StudentTopic.topic_id,
                                    StudentTopic.level,
                                    StudentTopic.date)
443a1eea   Miguel Barão   Update to latest ...
592
        with Session(self._engine, future=True) as session:
39126690   Miguel Barão   removes npm and n...
593

c4200a77   Miguel Barão   changed FIXME to ...
594
            # all students in the database  FIXME: only with answers of this course
443a1eea   Miguel Barão   Update to latest ...
595
596
            students = session.execute(query_students).all()

c4200a77   Miguel Barão   changed FIXME to ...
597
            # topic levels  FIXME: only topics of this course
443a1eea   Miguel Barão   Update to latest ...
598
            student_topics = session.execute(query_student_topics).all()
353ca74c   Miguel Barão   New rankings page
599

861d9ae5   Miguel Barão   Much faster rankings
600
        # compute topic progress
443a1eea   Miguel Barão   Update to latest ...
601
        progress: DefaultDict[str, float] = defaultdict(int)
fc533376   Miguel Barão   Fix pylint warnin...
602
603
604
        goals = self.courses[cid]['goals']
        num_goals = len(goals)
        now = datetime.now()
9a20acc8   Miguel Barão   rankings:
605

1d65b0b7   Miguel Barão   Merge branch 'dev'
606
        for student, topic, level, datestr in student_topics:
9a20acc8   Miguel Barão   rankings:
607
            if topic in goals:
1d65b0b7   Miguel Barão   Merge branch 'dev'
608
                date = datetime.strptime(datestr, "%Y-%m-%d %H:%M:%S.%f")
fc533376   Miguel Barão   Fix pylint warnin...
609
610
                elapsed_days = (now - date).days
                progress[student] += level**elapsed_days / num_goals
443a1eea   Miguel Barão   Update to latest ...
611

e23fb122   Miguel Barão   removes the thumb...
612
        return sorted(((u, name, progress[u])
443a1eea   Miguel Barão   Update to latest ...
613
614
615
                       for u, name in students
                       if u in progress and (len(u) > 2 or len(uid) <= 2)),
                       key=lambda x: x[2], reverse=True)