Blame view

aprendizations/learnapp.py 26.4 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 contextlib import contextmanager  # `with` statement in db sessions
d187aad4   Miguel Barão   - adds courses
10
from datetime import datetime
3259fc7c   Miguel Barão   - Modified login ...
11
import logging
443a1eea   Miguel Barão   Update to latest ...
12
from random import random
a6b50da0   Miguel Barão   fixes error where...
13
from os.path import join, exists
894bdd05   Miguel Barão   - moved all appli...
14
from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict
3259fc7c   Miguel Barão   - Modified login ...
15

13773677   Miguel Barão   under development...
16
# third party libraries
13773677   Miguel Barão   under development...
17
import bcrypt
443a1eea   Miguel Barão   Update to latest ...
18
19
20
import networkx as nx
import sqlalchemy as sa

894bdd05   Miguel Barão   - moved all appli...
21
22
# this project
from aprendizations.models import Student, Answer, Topic, StudentTopic
443a1eea   Miguel Barão   Update to latest ...
23
24
25
26
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
# setup logger for this module
a96cd2c7   Miguel Barão   - added logging s...
29
30
31
logger = logging.getLogger(__name__)


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

e02d725b   Miguel Barão   Check if dependen...
36
37

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

93e13002   Miguel Barão   updates README.md.
40
41

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

      self.deps - networkx topic dependencies
65a5ad4a   Miguel Barão   Small fixes
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
      self.courses - dict {course_id: {'title': ...,
                                       'description': ...,
                                       'goals': ...,}, ...}
      self.factory = dict {qref: QFactory()}
      self.online - dict {student_id: {'number': ...,
                                       'name': ...,
                                       'state': StudentState(),
                                       'counter': ...}, ...}
    '''


    # ------------------------------------------------------------------------
    @contextmanager
    def _db_session(self, **kw):
        '''
        helper to manage db sessions using the `with` statement, for example
          with self._db_session() as s:  s.query(...)
443a1eea   Miguel Barão   Update to latest ...
64
        '''
3586cfab   Miguel Barao   fixed async login
65
        session = self.Session(**kw)
5f3daeeb   Miguel Barão   add lots of type ...
66
        try:
d187aad4   Miguel Barão   - adds courses
67
            yield session
5f3daeeb   Miguel Barão   add lots of type ...
68
            session.commit()
fc533376   Miguel Barão   Fix pylint warnin...
69
        except Exception:
5f3daeeb   Miguel Barão   add lots of type ...
70
71
            logger.error('!!! Database rollback !!!')
            session.rollback()
fc533376   Miguel Barão   Fix pylint warnin...
72
73
            raise
        finally:
a203d3cc   Miguel Barão   - new http server...
74
            session.close()
a9131008   Miguel Barão   - allow chapters ...
75
76

    # ------------------------------------------------------------------------
443a1eea   Miguel Barão   Update to latest ...
77
    def __init__(self,
a9131008   Miguel Barão   - allow chapters ...
78
79
                 courses: str,  # filename with course configurations
                 prefix: str,   # path to topics
443a1eea   Miguel Barão   Update to latest ...
80
                 db: str,       # database filename
d187aad4   Miguel Barão   - adds courses
81
                 check: bool = False) -> None:
289991dc   Miguel Barão   - several fixes, ...
82

2b30310a   Miguel Barão   - path prefix is ...
83
        self._db_setup(db)       # setup database and check students
d823a4d8   Miguel Barão   Lots of changes:
84
        self.online: Dict[str, Dict] = dict()    # online students
289991dc   Miguel Barão   - several fixes, ...
85

443a1eea   Miguel Barão   Update to latest ...
86
87
88
89
90
91
92
93
        try:
            config: Dict[str, Any] = load_yaml(courses)
        except Exception as exc:
            msg = f'Failed to load yaml file "{courses}"'
            logger.error(msg)
            raise LearnException(msg) from exc

        # --- topic dependencies are shared between all courses
39126690   Miguel Barão   removes npm and n...
94
        self.deps = nx.DiGraph(prefix=prefix)
443a1eea   Miguel Barão   Update to latest ...
95
96
97
        logger.info('Populating topic graph:')

        # topics defined directly in the courses file, usually empty
a203d3cc   Miguel Barão   - new http server...
98
        base_topics = config.get('topics', {})
d823a4d8   Miguel Barão   Lots of changes:
99
100
        self._populate_graph(base_topics)
        logger.info('%6d topics in %s', len(base_topics), courses)
443a1eea   Miguel Barão   Update to latest ...
101
102
103
104

        # 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
d823a4d8   Miguel Barão   Lots of changes:
105
            # FIXME set defaults??
443a1eea   Miguel Barão   Update to latest ...
106
            logger.info('%6d topics imported from %s',
a9131008   Miguel Barão   - allow chapters ...
107
108
                        len(course_conf["topics"]), course_file)
            self._populate_graph(course_conf)
443a1eea   Miguel Barão   Update to latest ...
109
110
111
        logger.info('Graph has %d topics', len(self.deps))

        # --- courses dict
d823a4d8   Miguel Barão   Lots of changes:
112
        self.courses = config['courses']
289991dc   Miguel Barão   - several fixes, ...
113
        logger.info('Courses:  %s', ', '.join(self.courses.keys()))
443a1eea   Miguel Barão   Update to latest ...
114
        for cid, course in self.courses.items():
b141e132   Miguel Barão   adds some type an...
115
116
            course.setdefault('title', '')  # course title undefined
            for goal in course['goals']:
443a1eea   Miguel Barão   Update to latest ...
117
                if goal not in self.deps.nodes():
1a34901f   Miguel Barão   - add topics from...
118
                    msg = f'Goal "{goal}" from course "{cid}" does not exist'
ab185529   Miguel Barão   Changes:
119
                    logger.error(msg)
443a1eea   Miguel Barão   Update to latest ...
120
                    raise LearnException(msg)
ab185529   Miguel Barão   Changes:
121
                if self.deps.nodes[goal]['type'] == 'chapter':
443a1eea   Miguel Barão   Update to latest ...
122
123
124
125
126
127
                    course['goals'] += [g for g in self.deps.predecessors(goal)
                                        if g not in course['goals']]

        # --- factory is a dict with question generators for all topics
        self.factory: Dict[str, QFactory] = self._make_factory()

3259fc7c   Miguel Barão   - Modified login ...
128
        # if graph has topics that are not in the database, add them
ab185529   Miguel Barão   Changes:
129
        self._add_missing_topics(self.deps.nodes())
5f3daeeb   Miguel Barão   add lots of type ...
130

fc533376   Miguel Barão   Fix pylint warnin...
131
        if check:
443a1eea   Miguel Barão   Update to latest ...
132
            self._sanity_check_questions()
3259fc7c   Miguel Barão   - Modified login ...
133

fc533376   Miguel Barão   Fix pylint warnin...
134
    # ------------------------------------------------------------------------
443a1eea   Miguel Barão   Update to latest ...
135
136
    def _sanity_check_questions(self) -> None:
        '''
3259fc7c   Miguel Barão   - Modified login ...
137
        Unity tests for all questions
1841cc32   Miguel Barão   - fix desynchroni...
138

3259fc7c   Miguel Barão   - Modified login ...
139
        Generates all questions, give right and wrong answers and corrects.
443a1eea   Miguel Barão   Update to latest ...
140
141
142
143
144
145
        '''
        logger.info('Starting sanity checks (may take a while...)')

        errors: int = 0
        for qref in self.factory:
            logger.debug('checking %s...', qref)
3259fc7c   Miguel Barão   - Modified login ...
146
            try:
1841cc32   Miguel Barão   - fix desynchroni...
147
                question = self.factory[qref].generate()
443a1eea   Miguel Barão   Update to latest ...
148
149
150
151
152
153
154
155
156
157
            except QuestionException as exc:
                logger.error(exc)
                errors += 1
                continue  # to next question

            if 'tests_right' in question:
                for right_answer in question['tests_right']:
                    question['answer'] = right_answer
                    question.correct()
                    if question['grade'] < 1.0:
3259fc7c   Miguel Barão   - Modified login ...
158
                        logger.error('Failed right answer in "%s".', qref)
1841cc32   Miguel Barão   - fix desynchroni...
159
                        errors += 1
3259fc7c   Miguel Barão   - Modified login ...
160
161
                        continue  # to next test
            elif question['type'] == 'textarea':
443a1eea   Miguel Barão   Update to latest ...
162
                msg = f'- consider adding tests to {question["ref"]}'
a333dc72   Miguel Barão   Add type annotati...
163
                logger.warning(msg)
443a1eea   Miguel Barão   Update to latest ...
164

b141e132   Miguel Barão   adds some type an...
165
            if 'tests_wrong' in question:
443a1eea   Miguel Barão   Update to latest ...
166
167
                for wrong_answer in question['tests_wrong']:
                    question['answer'] = wrong_answer
3259fc7c   Miguel Barão   - Modified login ...
168
169
170
171
                    question.correct()
                    if question['grade'] >= 1.0:
                        logger.error('Failed wrong answer in "%s".', qref)
                        errors += 1
443a1eea   Miguel Barão   Update to latest ...
172
173
174
175
176
177
                        continue  # to next test

        if errors > 0:
            logger.error('%6d error(s) found.', errors)  # {errors:>6}
            raise LearnException('Sanity checks')
        logger.info('     0 errors found.')
3259fc7c   Miguel Barão   - Modified login ...
178

1cdce3ee   Miguel Barão   - get login state...
179
    # ------------------------------------------------------------------------
443a1eea   Miguel Barão   Update to latest ...
180
181
182
183
184
    async def login(self, uid: str, password: str) -> bool:
        '''user login'''

        with self._db_session() as sess:
            found = sess.query(Student.name, Student.password) \
2285f4a5   Miguel Barão   - fixed finished ...
185
                        .filter_by(id=uid) \
3586cfab   Miguel Barao   fixed async login
186
187
                        .one_or_none()

443a1eea   Miguel Barão   Update to latest ...
188
        # wait random time to minimize timing attacks
3259fc7c   Miguel Barão   - Modified login ...
189
        await asyncio.sleep(random())
d4ab394c   Miguel Barão   - removed unused ...
190

443a1eea   Miguel Barão   Update to latest ...
191
        loop = asyncio.get_running_loop()
e36dfd6c   Miguel Barão   - Fixed simultane...
192
        if found is None:
3586cfab   Miguel Barao   fixed async login
193
            logger.info('User "%s" does not exist', uid)
443a1eea   Miguel Barão   Update to latest ...
194
195
196
197
            await loop.run_in_executor(None, bcrypt.hashpw, b'',
                                       bcrypt.gensalt())  # just spend time
            return False

3586cfab   Miguel Barao   fixed async login
198
        name, hashed_pw = found
9161ff75   Miguel Barão   choose number of ...
199
        pw_ok: bool = await loop.run_in_executor(None,
3586cfab   Miguel Barao   fixed async login
200
201
                                                 bcrypt.checkpw,
                                                 password.encode('utf-8'),
443a1eea   Miguel Barão   Update to latest ...
202
                                                 hashed_pw)
3586cfab   Miguel Barao   fixed async login
203
204
205

        if pw_ok:
            if uid in self.online:
443a1eea   Miguel Barão   Update to latest ...
206
                logger.warning('User "%s" already logged in', uid)
d187aad4   Miguel Barão   - adds courses
207
208
209
                counter = self.online[uid]['counter']
            else:
                logger.info('User "%s" logged in', uid)
e36dfd6c   Miguel Barão   - Fixed simultane...
210
                counter = 0
9161ff75   Miguel Barão   choose number of ...
211

3586cfab   Miguel Barao   fixed async login
212
213
            # get topics of this student and set its current state
            with self._db_session() as sess:
443a1eea   Miguel Barão   Update to latest ...
214
                student_topics = sess.query(StudentTopic) \
3586cfab   Miguel Barao   fixed async login
215
216
                                     .filter_by(student_id=uid)

894bdd05   Miguel Barão   - moved all appli...
217
            state = {t.topic_id: {
5f3daeeb   Miguel Barão   add lots of type ...
218
                'level': t.level,
443a1eea   Miguel Barão   Update to latest ...
219
                'date': datetime.strptime(t.date, "%Y-%m-%d %H:%M:%S.%f")
ed34db4c   Miguel Barão   - save topic stat...
220
                } for t in student_topics}
443a1eea   Miguel Barão   Update to latest ...
221

894bdd05   Miguel Barão   - moved all appli...
222
            self.online[uid] = {
443a1eea   Miguel Barão   Update to latest ...
223
224
225
226
227
228
                'number': uid,
                'name': name,
                'state': StudentState(uid=uid, state=state,
                                      courses=self.courses, deps=self.deps,
                                      factory=self.factory),
                'counter': counter + 1,  # counts simultaneous logins
c3bb2c29   Miguel Barão   - new: change pas...
229
230
                }

3259fc7c   Miguel Barão   - Modified login ...
231
        else:
443a1eea   Miguel Barão   Update to latest ...
232
233
234
235
236
237
238
239
240
241
            logger.info('User "%s" wrong password', uid)

        return pw_ok

    # ------------------------------------------------------------------------
    def logout(self, uid: str) -> None:
        '''User logout'''
        del self.online[uid]
        logger.info('User "%s" logged out', uid)

ae2dc2b8   Miguel Barão   - finished topic ...
242
    # ------------------------------------------------------------------------
443a1eea   Miguel Barão   Update to latest ...
243
    async def change_password(self, uid: str, password: str) -> bool:
c3bb2c29   Miguel Barão   - new: change pas...
244
245
        '''
        Change user Password.
d823a4d8   Miguel Barão   Lots of changes:
246
        Returns True if password is successfully changed
443a1eea   Miguel Barão   Update to latest ...
247
248
249
250
        '''
        if not password:
            return False

d187aad4   Miguel Barão   - adds courses
251
        loop = asyncio.get_running_loop()
a6b50da0   Miguel Barão   fixes error where...
252
        password = await loop.run_in_executor(None,
5f3daeeb   Miguel Barão   add lots of type ...
253
                                              bcrypt.hashpw,
443a1eea   Miguel Barão   Update to latest ...
254
255
256
257
258
259
                                              password.encode('utf-8'),
                                              bcrypt.gensalt())

        with self._db_session() as sess:
            user = sess.query(Student).get(uid)
            user.password = password
b44bf1c0   Miguel Barão   - changed the way...
260
261

        logger.info('User "%s" changed password', uid)
443a1eea   Miguel Barão   Update to latest ...
262
263
264
265
266
267
268
269
270
        return True

    # ------------------------------------------------------------------------
    async def check_answer(self, uid: str, answer) -> Question:
        '''
        Checks answer and update database.
        Returns corrected question.
        '''
        student = self.online[uid]['state']
a6b50da0   Miguel Barão   fixes error where...
271
        await student.check_answer(answer)
443a1eea   Miguel Barão   Update to latest ...
272

a6b50da0   Miguel Barão   fixes error where...
273
        topic_id = student.get_current_topic()
a6b50da0   Miguel Barão   fixes error where...
274
        question: Question = student.get_current_question()
443a1eea   Miguel Barão   Update to latest ...
275
276
277
278
279
280
        grade = question["grade"]
        ref = question["ref"]

        logger.info('User "%s" got %.2f in "%s"', uid, grade, ref)

        # always save grade of answered question
b44bf1c0   Miguel Barão   - changed the way...
281
        with self._db_session() as sess:
a6b50da0   Miguel Barão   fixes error where...
282
            sess.add(Answer(ref=ref,
443a1eea   Miguel Barão   Update to latest ...
283
284
285
286
287
288
289
                            grade=grade,
                            starttime=str(question['start_time']),
                            finishtime=str(question['finish_time']),
                            student_id=uid,
                            topic_id=topic_id))

        return question
1d65b0b7   Miguel Barão   Merge branch 'dev'
290
291
292

    # ------------------------------------------------------------------------
    async def get_question(self, uid: str) -> Optional[Question]:
443a1eea   Miguel Barão   Update to latest ...
293
294
295
296
        '''
        Get the question to show (current or new one)
        If no more questions, save/update level in database
        '''
ae2dc2b8   Miguel Barão   - finished topic ...
297
        student = self.online[uid]['state']
2b709b19   Miguel Barão   fixed several err...
298
        question: Optional[Question] = await student.get_question()
443a1eea   Miguel Barão   Update to latest ...
299
300
301
302

        # save topic to database if finished
        if student.topic_has_finished():
            topic: str = student.get_previous_topic()
ed9d2f21   Miguel Barão   Reorganized proje...
303
            level: float = student.get_topic_level(topic)
443a1eea   Miguel Barão   Update to latest ...
304
305
306
307
308
            date: str = str(student.get_topic_date(topic))
            logger.info('User "%s" finished "%s" (level=%.2f)',
                        uid, topic, level)

            with self._db_session() as sess:
ae2dc2b8   Miguel Barão   - finished topic ...
309
310
                student_topic = sess.query(StudentTopic) \
                                    .filter_by(student_id=uid, topic_id=topic)\
443a1eea   Miguel Barão   Update to latest ...
311
312
313
                                    .one_or_none()

                if student_topic is None:
efdbe121   Miguel Barão   - added database ...
314
                    # insert new studenttopic into database
443a1eea   Miguel Barão   Update to latest ...
315
316
                    logger.debug('db insert studenttopic')
                    tid = sess.query(Topic).get(topic)
b44bf1c0   Miguel Barão   - changed the way...
317
                    uid = sess.query(Student).get(uid)
443a1eea   Miguel Barão   Update to latest ...
318
                    # association object
d823a4d8   Miguel Barão   Lots of changes:
319
                    student_topic = StudentTopic(level=level, date=date,
a9131008   Miguel Barão   - allow chapters ...
320
                                                 topic=tid, student=uid)
443a1eea   Miguel Barão   Update to latest ...
321
322
323
                    uid.topics.append(student_topic)
                else:
                    # update studenttopic in database
d187aad4   Miguel Barão   - adds courses
324
                    logger.debug('db update studenttopic to level %f', level)
443a1eea   Miguel Barão   Update to latest ...
325
326
327
328
                    student_topic.level = level
                    student_topic.date = date

                sess.add(student_topic)
d187aad4   Miguel Barão   - adds courses
329

39126690   Miguel Barão   removes npm and n...
330
        return question
d187aad4   Miguel Barão   - adds courses
331

5f3daeeb   Miguel Barão   add lots of type ...
332
    # ------------------------------------------------------------------------
443a1eea   Miguel Barão   Update to latest ...
333
334
    def start_course(self, uid: str, course_id: str) -> None:
        '''Start course'''
2b30310a   Miguel Barão   - path prefix is ...
335

a9131008   Miguel Barão   - allow chapters ...
336
        student = self.online[uid]['state']
bbdca9b8   Miguel Barão   - fixed error whe...
337
        try:
dbdd58fe   Miguel Barão   - added option 'a...
338
            student.start_course(course_id)
443a1eea   Miguel Barão   Update to latest ...
339
340
341
        except Exception as exc:
            logger.warning('"%s" could not start course "%s"', uid, course_id)
            raise LearnException() from exc
bbdca9b8   Miguel Barão   - fixed error whe...
342
        else:
443a1eea   Miguel Barão   Update to latest ...
343
            logger.info('User "%s" started course "%s"', uid, course_id)
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
    # ------------------------------------------------------------------------
    #
    # ------------------------------------------------------------------------
    async def start_topic(self, uid: str, topic: str) -> None:
        '''Start new topic'''

        student = self.online[uid]['state']
        # if uid == '0':
        #     logger.warning('Reloading "%s"', topic)  # FIXME should be an option
        #     self.factory.update(self._factory_for(topic))

        try:
443a1eea   Miguel Barão   Update to latest ...
357
358
359
360
361
362
363
            await student.start_topic(topic)
        except Exception as exc:
            logger.warning('User "%s" could not start "%s": %s',
                           uid, topic, str(exc))
        else:
            logger.info('User "%s" started topic "%s"', uid, topic)

a881e511   Miguel Barão   - missing databas...
364
365
366
    # ------------------------------------------------------------------------
    #
    # ------------------------------------------------------------------------
d823a4d8   Miguel Barão   Lots of changes:
367
    def _add_missing_topics(self, topics: List[str]) -> None:
443a1eea   Miguel Barão   Update to latest ...
368
369
        '''
        Fill db table 'Topic' with topics from the graph, if new
fcdedf5a   Miguel Barão   - Reads config.ya...
370
        '''
443a1eea   Miguel Barão   Update to latest ...
371
372
373
374
375
376
377
378
379
380
        with self._db_session() as sess:
            new = [Topic(id=t) for t in topics
                   if (t,) not in sess.query(Topic.id)]

            if new:
                sess.add_all(new)
                logger.info('Added %d new topic(s) to the database', len(new))

    # ------------------------------------------------------------------------
    def _db_setup(self, database: str) -> None:
fcdedf5a   Miguel Barão   - Reads config.ya...
381
        '''setup and check database contents'''
443a1eea   Miguel Barão   Update to latest ...
382
383
384

        logger.info('Checking database "%s":', database)
        if not exists(database):
fcdedf5a   Miguel Barão   - Reads config.ya...
385
            raise LearnException('Database does not exist. '
443a1eea   Miguel Barão   Update to latest ...
386
387
388
389
390
391
392
393
394
395
396
                                 'Use "initdb-aprendizations" to create')

        engine = sa.create_engine(f'sqlite:///{database}', echo=False)
        self.Session = sa.orm.sessionmaker(bind=engine)
        try:
            with self._db_session() as sess:
                count_students: int = sess.query(Student).count()
                count_topics: int = sess.query(Topic).count()
                count_answers: int = sess.query(Answer).count()
        except Exception as exc:
            logger.error('Database "%s" not usable!', database)
289991dc   Miguel Barão   - several fixes, ...
397
            raise DatabaseUnusableError() from exc
443a1eea   Miguel Barão   Update to latest ...
398
        else:
289991dc   Miguel Barão   - several fixes, ...
399
400
            logger.info('%6d students', count_students)
            logger.info('%6d topics', count_topics)
443a1eea   Miguel Barão   Update to latest ...
401
            logger.info('%6d answers', count_answers)
289991dc   Miguel Barão   - several fixes, ...
402
403
404
405
406
407

    # ------------------------------------------------------------------------
    def _populate_graph(self, config: Dict[str, Any]) -> None:
        '''
        Populates a digraph.

d5cd0d10   Miguel Barão   - added options '...
408
409
        Nodes are the topic references e.g. 'my/topic'
          g.nodes['my/topic']['name']      name of the topic
5f3daeeb   Miguel Barão   add lots of type ...
410
          g.nodes['my/topic']['questions'] list of question refs
443a1eea   Miguel Barão   Update to latest ...
411

d5cd0d10   Miguel Barão   - added options '...
412
        Edges are obtained from the deps defined in the YAML file for each topic.
443a1eea   Miguel Barão   Update to latest ...
413
414
415
        '''

        defaults = {
d5cd0d10   Miguel Barão   - added options '...
416
            'type': 'topic',  # chapter
443a1eea   Miguel Barão   Update to latest ...
417
418
            'file': 'questions.yaml',
            'shuffle_questions': True,
fc533376   Miguel Barão   Fix pylint warnin...
419
            'choose': 99,
d5cd0d10   Miguel Barão   - added options '...
420
            'forgetting_factor': 1.0,  # no forgetting
289991dc   Miguel Barão   - several fixes, ...
421
            'max_tries': 1,            # in every question
443a1eea   Miguel Barão   Update to latest ...
422
            'append_wrong': True,
289991dc   Miguel Barão   - several fixes, ...
423
            'min_level': 0.01,         # to unlock topic
443a1eea   Miguel Barão   Update to latest ...
424
425
        }
        defaults.update(config.get('defaults', {}))
289991dc   Miguel Barão   - several fixes, ...
426

65a5ad4a   Miguel Barão   Small fixes
427
        # iterate over topics and populate graph
ab185529   Miguel Barão   Changes:
428
        topics: Dict[str, Dict] = config.get('topics', {})
d5cd0d10   Miguel Barão   - added options '...
429
        self.deps.add_nodes_from(topics.keys())
65a5ad4a   Miguel Barão   Small fixes
430
        for tref, attr in topics.items():
443a1eea   Miguel Barão   Update to latest ...
431
432
433
434
435
436
            logger.debug('       + %s', tref)
            for dep in attr.get('deps', []):
                self.deps.add_edge(dep, tref)

            topic = self.deps.nodes[tref]  # get current topic node
            topic['name'] = attr.get('name', tref)
2265a158   Miguel Barão   new transform opt...
437
            topic['questions'] = attr.get('questions', [])  # FIXME unused??
2b709b19   Miguel Barão   fixed several err...
438

fc533376   Miguel Barão   Fix pylint warnin...
439
            for k, default in defaults.items():
443a1eea   Miguel Barão   Update to latest ...
440
441
                topic[k] = attr.get(k, default)

d5cd0d10   Miguel Barão   - added options '...
442
            # prefix/topic
443a1eea   Miguel Barão   Update to latest ...
443
            topic['path'] = join(self.deps.graph['prefix'], tref)
a9131008   Miguel Barão   - allow chapters ...
444

5f3daeeb   Miguel Barão   add lots of type ...
445

a9131008   Miguel Barão   - allow chapters ...
446
447
448
    # ========================================================================
    # methods that do not change state (pure functions)
    # ========================================================================
443a1eea   Miguel Barão   Update to latest ...
449

fc533376   Miguel Barão   Fix pylint warnin...
450
    # ------------------------------------------------------------------------
443a1eea   Miguel Barão   Update to latest ...
451
    def _make_factory(self) -> Dict[str, QFactory]:
a9131008   Miguel Barão   - allow chapters ...
452
        '''
a9131008   Miguel Barão   - allow chapters ...
453
        Buils dictionary of question factories
443a1eea   Miguel Barão   Update to latest ...
454
455
456
457
          - visits each topic in the graph,
          - adds factory for each topic.
        '''

62736e37   Miguel Barão   fixed some errors...
458
        logger.info('Building questions factory:')
443a1eea   Miguel Barão   Update to latest ...
459
460
        factory = dict()
        for tref in self.deps.nodes():
a9131008   Miguel Barão   - allow chapters ...
461
462
            factory.update(self._factory_for(tref))

443a1eea   Miguel Barão   Update to latest ...
463
464
        logger.info('Factory has %s questions', len(factory))
        return factory
a9131008   Miguel Barão   - allow chapters ...
465

443a1eea   Miguel Barão   Update to latest ...
466
467
468
469
    # ------------------------------------------------------------------------
    # makes factory for a single topic
    # ------------------------------------------------------------------------
    def _factory_for(self, tref: str) -> Dict[str, QFactory]:
a9131008   Miguel Barão   - allow chapters ...
470
471
        factory: Dict[str, QFactory] = dict()
        topic = self.deps.nodes[tref]  # get node
62736e37   Miguel Barão   fixed some errors...
472
        # load questions as list of dicts
a9131008   Miguel Barão   - allow chapters ...
473
474
475
476
477
478
479
        try:
            fullpath: str = join(topic['path'], topic['file'])
        except Exception as exc:
            msg = f'Invalid topic "{tref}". Check dependencies of: ' + \
                    ', '.join(self.deps.successors(tref))
            logger.error(msg)
            raise LearnException(msg) from exc
443a1eea   Miguel Barão   Update to latest ...
480
481
        logger.debug('  Loading %s', fullpath)
        try:
a9131008   Miguel Barão   - allow chapters ...
482
            questions: List[QDict] = load_yaml(fullpath)
443a1eea   Miguel Barão   Update to latest ...
483
484
        except Exception as exc:
            if topic['type'] == 'chapter':
a6b50da0   Miguel Barão   fixes error where...
485
                return factory  # chapters may have no "questions"
a9131008   Miguel Barão   - allow chapters ...
486
            msg = f'Failed to load "{fullpath}"'
d5cd0d10   Miguel Barão   - added options '...
487
            logger.error(msg)
443a1eea   Miguel Barão   Update to latest ...
488
489
490
            raise LearnException(msg) from exc

        if not isinstance(questions, list):
d5cd0d10   Miguel Barão   - added options '...
491
            msg = f'File "{fullpath}" must be a list of questions'
a9131008   Miguel Barão   - allow chapters ...
492
            logger.error(msg)
443a1eea   Miguel Barão   Update to latest ...
493
494
            raise LearnException(msg)

d5cd0d10   Miguel Barão   - added options '...
495
        # update refs to include topic as prefix.
443a1eea   Miguel Barão   Update to latest ...
496
        # refs are required to be unique only within the file.
d5cd0d10   Miguel Barão   - added options '...
497
        # undefined are set to topic:n, where n is the question number
443a1eea   Miguel Barão   Update to latest ...
498
499
500
501
        # within the file
        localrefs: Set[str] = set()  # refs in current file
        for i, question in enumerate(questions):
            qref = question.get('ref', str(i))  # ref or number
d5cd0d10   Miguel Barão   - added options '...
502
            if qref in localrefs:
443a1eea   Miguel Barão   Update to latest ...
503
                msg = f'Duplicate ref "{qref}" in "{topic["path"]}"'
d5cd0d10   Miguel Barão   - added options '...
504
                raise LearnException(msg)
ab185529   Miguel Barão   Changes:
505
            localrefs.add(qref)
065611f7   Miguel Barão   large ammount of ...
506

a333dc72   Miguel Barão   Add type annotati...
507
            question['ref'] = f'{tref}:{qref}'
fc533376   Miguel Barão   Fix pylint warnin...
508
            question['path'] = topic['path']
a6b50da0   Miguel Barão   fixes error where...
509
            question.setdefault('append_wrong', topic['append_wrong'])
e36dfd6c   Miguel Barão   - Fixed simultane...
510

a333dc72   Miguel Barão   Add type annotati...
511
        # if questions are left undefined, include all.
443a1eea   Miguel Barão   Update to latest ...
512
        if not topic['questions']:
065611f7   Miguel Barão   large ammount of ...
513
514
            topic['questions'] = [q['ref'] for q in questions]

5f3daeeb   Miguel Barão   add lots of type ...
515
        topic['choose'] = min(topic['choose'], len(topic['questions']))
443a1eea   Miguel Barão   Update to latest ...
516

065611f7   Miguel Barão   large ammount of ...
517
518
        for question in questions:
            if question['ref'] in topic['questions']:
5f3daeeb   Miguel Barão   add lots of type ...
519
                factory[question['ref']] = QFactory(question)
443a1eea   Miguel Barão   Update to latest ...
520
                logger.debug('       + %s', question["ref"])
a9131008   Miguel Barão   - allow chapters ...
521

065611f7   Miguel Barão   large ammount of ...
522
        logger.info('%6d questions in %s', len(topic["questions"]), tref)
5f3daeeb   Miguel Barão   add lots of type ...
523

443a1eea   Miguel Barão   Update to latest ...
524
525
526
        return factory

    # ------------------------------------------------------------------------
065611f7   Miguel Barão   large ammount of ...
527
    def get_login_counter(self, uid: str) -> int:
1841cc32   Miguel Barão   - fix desynchroni...
528
        '''login counter''' # FIXME
443a1eea   Miguel Barão   Update to latest ...
529
        return int(self.online[uid]['counter'])
a9131008   Miguel Barão   - allow chapters ...
530

1841cc32   Miguel Barão   - fix desynchroni...
531
    # ------------------------------------------------------------------------
a333dc72   Miguel Barão   Add type annotati...
532
    def get_student_name(self, uid: str) -> str:
443a1eea   Miguel Barão   Update to latest ...
533
        '''Get the username'''
a9131008   Miguel Barão   - allow chapters ...
534
        return self.online[uid].get('name', '')
dda6f838   Miguel Barão   - fixed error on ...
535
536

    # ------------------------------------------------------------------------
443a1eea   Miguel Barão   Update to latest ...
537
538
    def get_student_state(self, uid: str) -> List[Dict[str, Any]]:
        '''Get the knowledge state of a given user'''
065611f7   Miguel Barão   large ammount of ...
539
        return self.online[uid]['state'].get_knowledge_state()
d187aad4   Miguel Barão   - adds courses
540

443a1eea   Miguel Barão   Update to latest ...
541
    # ------------------------------------------------------------------------
a9131008   Miguel Barão   - allow chapters ...
542
    def get_student_progress(self, uid: str) -> float:
d187aad4   Miguel Barão   - adds courses
543
        '''Get the current topic progress of a given user'''
a9131008   Miguel Barão   - allow chapters ...
544
        return float(self.online[uid]['state'].get_topic_progress())
443a1eea   Miguel Barão   Update to latest ...
545

a9131008   Miguel Barão   - allow chapters ...
546
547
    # ------------------------------------------------------------------------
    def get_current_question(self, uid: str) -> Optional[Question]:
8b4ac80a   Miguel Barão   - fixed menus
548
549
        '''Get the current question of a given user'''
        question: Optional[Question] = self.online[uid]['state'].get_current_question()
443a1eea   Miguel Barão   Update to latest ...
550
551
        return question

065611f7   Miguel Barão   large ammount of ...
552
    # ------------------------------------------------------------------------
5f3daeeb   Miguel Barão   add lots of type ...
553
    def get_current_question_id(self, uid: str) -> str:
443a1eea   Miguel Barão   Update to latest ...
554
555
556
557
558
559
        '''Get id of the current question for a given user'''
        return str(self.online[uid]['state'].get_current_question()['qid'])

    # ------------------------------------------------------------------------
    def get_student_question_type(self, uid: str) -> str:
        '''Get type of the current question for a given user'''
a9131008   Miguel Barão   - allow chapters ...
560
        return str(self.online[uid]['state'].get_current_question()['type'])
5f3daeeb   Miguel Barão   add lots of type ...
561

443a1eea   Miguel Barão   Update to latest ...
562
    # ------------------------------------------------------------------------
353ca74c   Miguel Barão   New rankings page
563
    # def get_student_topic(self, uid: str) -> str:
a9131008   Miguel Barão   - allow chapters ...
564
    #     return str(self.online[uid]['state'].get_current_topic())
443a1eea   Miguel Barão   Update to latest ...
565
566
567

    # ------------------------------------------------------------------------
    def get_student_course_title(self, uid: str) -> str:
d187aad4   Miguel Barão   - adds courses
568
569
        '''get the title of the current course for a given user'''
        return str(self.online[uid]['state'].get_current_course_title())
a9131008   Miguel Barão   - allow chapters ...
570

443a1eea   Miguel Barão   Update to latest ...
571
572
573
    # ------------------------------------------------------------------------
    def get_current_course_id(self, uid: str) -> Optional[str]:
        '''get the current course (id) of a given user'''
a9131008   Miguel Barão   - allow chapters ...
574
575
        cid: Optional[str] = self.online[uid]['state'].get_current_course_id()
        return cid
1d65b0b7   Miguel Barão   Merge branch 'dev'
576

443a1eea   Miguel Barão   Update to latest ...
577
    # ------------------------------------------------------------------------
6c5a961a   Miguel Barão   minor mods
578
    # def get_topic_name(self, ref: str) -> str:
443a1eea   Miguel Barão   Update to latest ...
579
580
581
582
583
584
585
    #     return str(self.deps.nodes[ref]['name'])

    # ------------------------------------------------------------------------
    def get_current_public_dir(self, uid: str) -> str:
        '''
        Get the path for the 'public' directory of the current topic of the
        given user.
6c5a961a   Miguel Barão   minor mods
586
        E.g. if the user has the active topic 'xpto',
e23fb122   Miguel Barão   removes the thumb...
587
588
589
590
591
        then returns 'path/to/xpto/public'.
        '''
        topic: str = self.online[uid]['state'].get_current_topic()
        prefix: str = self.deps.graph['prefix']
        return join(prefix, topic, 'public')
443a1eea   Miguel Barão   Update to latest ...
592

39126690   Miguel Barão   removes npm and n...
593
    # ------------------------------------------------------------------------
c4200a77   Miguel Barão   changed FIXME to ...
594
    def get_courses(self) -> Dict[str, Dict[str, Any]]:
443a1eea   Miguel Barão   Update to latest ...
595
596
        '''
        Get dictionary with all courses {'course1': {...}, 'course2': {...}}
c4200a77   Miguel Barão   changed FIXME to ...
597
        '''
443a1eea   Miguel Barão   Update to latest ...
598
        return self.courses
353ca74c   Miguel Barão   New rankings page
599

861d9ae5   Miguel Barão   Much faster rankings
600
    # ------------------------------------------------------------------------
443a1eea   Miguel Barão   Update to latest ...
601
    def get_course(self, course_id: str) -> Dict[str, Any]:
fc533376   Miguel Barão   Fix pylint warnin...
602
603
604
        '''
        Get dictionary {'title': ..., 'description':..., 'goals':...}
        '''
9a20acc8   Miguel Barão   rankings:
605
        return self.courses[course_id]
1d65b0b7   Miguel Barão   Merge branch 'dev'
606

9a20acc8   Miguel Barão   rankings:
607
    # ------------------------------------------------------------------------
1d65b0b7   Miguel Barão   Merge branch 'dev'
608
    def get_rankings(self, uid: str, course_id: str) -> Iterable[Tuple[str, str, float, float]]:
fc533376   Miguel Barão   Fix pylint warnin...
609
610
        '''
        Returns rankings for a certain course_id.
443a1eea   Miguel Barão   Update to latest ...
611
        User where uid have <=2 chars are considered ghosts are hidden from
e23fb122   Miguel Barão   removes the thumb...
612
        the rankings. This is so that there can be users for development or
443a1eea   Miguel Barão   Update to latest ...
613
614
615
        testing purposes, which are not real users.
        The user_id of real students must have >2 chars.
        '''