Commit a203d3ccc52a4cd6d46aeff2efc28ebd161b1f87

Authored by Miguel Barão
1 parent 9161ff75
Exists in master and in 1 other branch dev

- new http server redirects all traffic to a given (https) server.

- documentation updates to python3.7.
- started to make changes to allow multiple configuration files.
@@ -5,23 +5,23 @@ @@ -5,23 +5,23 @@
5 5
6 Before installing the server, we will need to install python with some additional packages. 6 Before installing the server, we will need to install python with some additional packages.
7 7
8 -### Install python3.6 with sqlite3 support 8 +### Install python3.7 with sqlite3 support
9 9
10 This can be done using the system package management, downloaded from [http://www.python.org](), or compiled from sources. 10 This can be done using the system package management, downloaded from [http://www.python.org](), or compiled from sources.
11 11
12 - Installing from the system package manager: 12 - Installing from the system package manager:
13 ```sh 13 ```sh
14 - sudo port install python36 # MacOS  
15 - sudo pkg install python36 py36-sqlite3 # FreeBSD 14 + sudo port install python37 # MacOS
  15 + sudo pkg install python37 py37-sqlite3 # FreeBSD
16 sudo apt install ?not available yet? # Linux, install from source 16 sudo apt install ?not available yet? # Linux, install from source
17 ``` 17 ```
18 - Installing from source: 18 - Installing from source:
19 Download from [http://www.python.org]() and 19 Download from [http://www.python.org]() and
20 20
21 ```sh 21 ```sh
22 - unxz Python-3.6.tar.xz  
23 - tar xvf Python-3.6.tar  
24 - cd Python-3.6 22 + unxz Python-3.7.tar.xz
  23 + tar xvf Python-3.7.tar
  24 + cd Python-3.7
25 ./configure --prefix=$HOME/.local/bin 25 ./configure --prefix=$HOME/.local/bin
26 make && make install 26 make && make install
27 ``` 27 ```
@@ -33,7 +33,7 @@ This can be done using the system package management, downloaded from [http://ww @@ -33,7 +33,7 @@ This can be done using the system package management, downloaded from [http://ww
33 If the `pip` command is not yet installed, run 33 If the `pip` command is not yet installed, run
34 34
35 ```sh 35 ```sh
36 -python3.6 -m ensurepip --user 36 +python3.7 -m ensurepip --user
37 ``` 37 ```
38 38
39 This will install pip in your user account under `~/.local/bin`. 39 This will install pip in your user account under `~/.local/bin`.
@@ -54,15 +54,15 @@ pip3 install --user \ @@ -54,15 +54,15 @@ pip3 install --user \
54 sqlalchemy \ 54 sqlalchemy \
55 pyyaml \ 55 pyyaml \
56 pygments \ 56 pygments \
57 - markdown \ 57 + mistune \
58 bcrypt \ 58 bcrypt \
59 networkx 59 networkx
60 ``` 60 ```
61 61
62 These are usually installed under 62 These are usually installed under
63 63
64 -- `~/.local/lib/python3.6/site-packages/` in Linux/FreeBSD.  
65 -- `~/Library/python/3.6/lib/python/site-packages/` in MacOS. 64 +- `~/.local/lib/python3.7/site-packages/` in Linux/FreeBSD.
  65 +- `~/Library/python/3.7/lib/python/site-packages/` in MacOS.
66 66
67 ## Installation 67 ## Installation
68 68
@@ -82,18 +82,19 @@ The user data is maintained in a sqlite3 database file. We first need to create @@ -82,18 +82,19 @@ The user data is maintained in a sqlite3 database file. We first need to create
82 82
83 ```sh 83 ```sh
84 cd aprendizations 84 cd aprendizations
85 -./initdb.py --help # for the available options  
86 ./initdb.py # show current database or initialize empty if nonexisting 85 ./initdb.py # show current database or initialize empty if nonexisting
87 ./initdb.py inscricoes.csv # add students from CSV, passwords are the numbers 86 ./initdb.py inscricoes.csv # add students from CSV, passwords are the numbers
88 -./initdb.py --add 1184 "Ana Bola" # add new user (password=1184)  
89 -./initdb.py --update 1184 --pw alibaba # update password 87 +./initdb.py --add 1184 "Aladino da Silva" # add new user (default password=1184)
  88 +./initdb.py --update 1184 --pw alibaba # update password of given student
  89 +./initdb.py --help # for the available options
90 ``` 90 ```
91 91
92 ### SSL Certificates 92 ### SSL Certificates
93 93
94 We need certificates for https. Certificates can be self-signed or validated by a trusted authority. 94 We need certificates for https. Certificates can be self-signed or validated by a trusted authority.
95 95
96 -Self-signed can be used for testing, but browsers will complain. LetsEncrypt issues trusted and free certificates, but the server must have a fixed IP and a registered domain name in the DNS (dynamic DNS does not work). 96 +Self-signed can be used locally for development and testing, but browsers will complain.
  97 +LetsEncrypt issues trusted and free certificates, but the server must have a registered publicly accessible domain name.
97 98
98 #### Selfsigned 99 #### Selfsigned
99 100
@@ -109,7 +110,7 @@ openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 - @@ -109,7 +110,7 @@ openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 -
109 sudo pkg install py27-certbot # FreeBSD 110 sudo pkg install py27-certbot # FreeBSD
110 ``` 111 ```
111 112
112 -Shutdown the firewall and any server running. Then run the script to generate the certificate: 113 +Shutdown the firewall and any web server that might be running. Then run the script to generate the certificate:
113 114
114 ```sh 115 ```sh
115 sudo service pf stop # disable pf firewall (FreeBSD) 116 sudo service pf stop # disable pf firewall (FreeBSD)
@@ -144,7 +145,7 @@ The application includes a small example in `demo/demo.yaml`. Run it with @@ -144,7 +145,7 @@ The application includes a small example in `demo/demo.yaml`. Run it with
144 ./serve.py demo/demo.yaml 145 ./serve.py demo/demo.yaml
145 ``` 146 ```
146 147
147 -and open a browser at [https://127.0.0.1:8443](). If it everything looks good, check at the correct address `https://www.example.com` (requires port forward in the firewall). 148 +and open a browser at [https://127.0.0.1:8443](). If it everything looks good, check at the correct address `https://www.example.com` (requires port forward in the firewall). The option `--debug` provides more verbose logging and might be useful during testing.
148 149
149 150
150 ### Firewall configuration 151 ### Firewall configuration
@@ -178,6 +179,7 @@ pflog_logfile="/var/log/pflog" @@ -178,6 +179,7 @@ pflog_logfile="/var/log/pflog"
178 179
179 Reboot or `sudo service pf start`. 180 Reboot or `sudo service pf start`.
180 181
  182 +
181 ## Troubleshooting 183 ## Troubleshooting
182 184
183 To help with troubleshooting, use the option `--debug` when running the server. This will increase logs in the terminal and will present the python exception errors in the browser. 185 To help with troubleshooting, use the option `--debug` when running the server. This will increase logs in the terminal and will present the python exception errors in the browser.
demo/demo.yaml
1 title: Example 1 title: Example
2 database: students.db 2 database: students.db
  3 +path: ./demo
3 4
4 -# default global values 5 +# values applie to each topic, if undefined there
  6 +# default values are: file=question.yaml, shuffle=True, choose: all
5 file: questions.yaml 7 file: questions.yaml
6 shuffle: True 8 shuffle: True
7 choose: 6 9 choose: 6
8 -  
9 -# path prefix applied to the topics  
10 -path: ./demo 10 +forgetting_factor: 0.99
11 11
12 # Representation of the edges of the dependency graph. 12 # Representation of the edges of the dependency graph.
13 # Example: A depends on B and C 13 # Example: A depends on B and C
@@ -38,6 +38,14 @@ from questions import QuestionInformation, QuestionRadio, QuestionCheckbox, Ques @@ -38,6 +38,14 @@ from questions import QuestionInformation, QuestionRadio, QuestionCheckbox, Ques
38 logger = logging.getLogger(__name__) 38 logger = logging.getLogger(__name__)
39 39
40 40
  41 +# Topic Factory
  42 +# Generates a list
  43 +# class RunningTopic(object):
  44 +# def __init__(self, graph, topic):
  45 +
  46 +
  47 +
  48 +
41 49
42 # =========================================================================== 50 # ===========================================================================
43 # Question Factory 51 # Question Factory
@@ -59,7 +67,7 @@ class QFactory(object): @@ -59,7 +67,7 @@ class QFactory(object):
59 'success' : QuestionInformation, 67 'success' : QuestionInformation,
60 } 68 }
61 69
62 - def __init__(self, question_dict): 70 + def __init__(self, question_dict={}):
63 self.question = question_dict 71 self.question = question_dict
64 72
65 # ----------------------------------------------------------------------- 73 # -----------------------------------------------------------------------
@@ -86,7 +94,7 @@ class QFactory(object): @@ -86,7 +94,7 @@ class QFactory(object):
86 try: 94 try:
87 qinstance = self._types[q['type']](q) # instance with correct class 95 qinstance = self._types[q['type']](q) # instance with correct class
88 except KeyError as e: 96 except KeyError as e:
89 - logger.error(f'Failed to generate question "{q["ref"]}"') 97 + logger.error(f'Unknown type "{q["type"]}" in question "{q["ref"]}"')
90 raise e 98 raise e
91 else: 99 else:
92 return qinstance 100 return qinstance
http-redirect.py 0 → 100755
@@ -0,0 +1,87 @@ @@ -0,0 +1,87 @@
  1 +#!/usr/bin/env python3
  2 +
  3 +# python standard library
  4 +from os import path
  5 +import sys
  6 +import argparse
  7 +import signal
  8 +
  9 +# user installed libraries
  10 +from tornado import ioloop, web, httpserver
  11 +
  12 +
  13 +# ============================================================================
  14 +# WebApplication - Tornado Web Server
  15 +# ============================================================================
  16 +class WebRedirectApplication(web.Application):
  17 + def __init__(self, target='https://localhost'):
  18 + handlers = [
  19 + (r'/*', RootHandler), # redirect to https
  20 + ]
  21 + super().__init__(handlers)
  22 + self.target = target
  23 +
  24 +# ============================================================================
  25 +# Handlers
  26 +# ============================================================================
  27 +class RootHandler(web.RequestHandler):
  28 + SUPPORTED_METHODS = ['GET']
  29 +
  30 + def get(self):
  31 + print('Redirecting...')
  32 + self.redirect(self.application.target)
  33 +
  34 +
  35 +# -------------------------------------------------------------------------
  36 +def signal_handler(signal, frame):
  37 + r = input(' --> Stop webserver? (yes/no) ').lower()
  38 + if r == 'yes':
  39 + ioloop.IOLoop.current().stop()
  40 + print('Webserver stopped.')
  41 + sys.exit(0)
  42 +
  43 +
  44 +# -------------------------------------------------------------------------
  45 +# Tornado web server
  46 +# ----------------------------------------------------------------------------
  47 +def main():
  48 + # --- Commandline argument parsing
  49 + argparser = argparse.ArgumentParser(
  50 + description='Redirection server. Any request to http will be redirected to a target server provided in the command line argument.')
  51 + argparser.add_argument('target', type=str,
  52 + help='Target https server address, e.g. https://www.example.com.')
  53 + argparser.add_argument('--port', type=int, default=8080,
  54 + help='Port to which this server will listen to, e.g. 8080')
  55 + arg = argparser.parse_args()
  56 +
  57 + # --- create web redirect application
  58 + try:
  59 + redirectapp = WebRedirectApplication(target=arg.target)
  60 + except Exception as e:
  61 + print('Failed to start web redirect application.')
  62 + raise e
  63 +
  64 + # --- create webserver
  65 + try:
  66 + http_server = httpserver.HTTPServer(redirectapp)
  67 + except Exception as e:
  68 + print('Failed to create HTTP server.')
  69 + raise e
  70 + else:
  71 + http_server.listen(8080)
  72 +
  73 + # --- run webserver
  74 + print(f'Redirecting port {arg.port} to {arg.target} (Ctrl-C to stop)')
  75 + signal.signal(signal.SIGINT, signal_handler)
  76 +
  77 + try:
  78 + ioloop.IOLoop.current().start() # running...
  79 + except Exception as e:
  80 + print('Redirection stopped!')
  81 + ioloop.IOLoop.current().stop()
  82 + raise e
  83 +
  84 +
  85 +# ----------------------------------------------------------------------------
  86 +if __name__ == "__main__":
  87 + main()
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
17 # kowledge state of each student....?? 17 # kowledge state of each student....??
18 # Contains: 18 # Contains:
19 # state - dict of topics with state of unlocked topics 19 # state - dict of topics with state of unlocked topics
20 -# deps - dependency graph 20 +# deps - access to dependency graph shared between students
21 # topic_sequence - list with the order of recommended topics 21 # topic_sequence - list with the order of recommended topics
22 # ---------------------------------------------------------------------------- 22 # ----------------------------------------------------------------------------
23 class StudentKnowledge(object): 23 class StudentKnowledge(object):
@@ -28,12 +28,11 @@ class StudentKnowledge(object): @@ -28,12 +28,11 @@ class StudentKnowledge(object):
28 self.deps = deps # dependency graph shared among students 28 self.deps = deps # dependency graph shared among students
29 self.state = state # {'topic': {'level':0.5, 'date': datetime}, ...} 29 self.state = state # {'topic': {'level':0.5, 'date': datetime}, ...}
30 30
31 - self.update_topic_levels() # forgetting factor 31 + self.update_topic_levels() # applies forgetting factor
32 self.topic_sequence = self.recommend_topic_sequence() # ['a', 'b', ...] 32 self.topic_sequence = self.recommend_topic_sequence() # ['a', 'b', ...]
33 - self.unlock_topics() # whose dependencies have been done 33 + self.unlock_topics() # whose dependencies have been completed
34 self.current_topic = None 34 self.current_topic = None
35 - print(deps.graph)  
36 - self.MAX_QUESTIONS = deps.graph['config'].get('choose', 10) 35 + # self.MAX_QUESTIONS = deps.graph['config'].get('choose', None)
37 36
38 # ------------------------------------------------------------------------ 37 # ------------------------------------------------------------------------
39 # Updates the proficiency levels of the topics, with forgetting factor 38 # Updates the proficiency levels of the topics, with forgetting factor
@@ -67,18 +66,13 @@ class StudentKnowledge(object): @@ -67,18 +66,13 @@ class StudentKnowledge(object):
67 66
68 67
69 # ------------------------------------------------------------------------ 68 # ------------------------------------------------------------------------
70 - # Start a new topic. If not provided, gets a recommendation. 69 + # Start a new topic.
71 # questions: list of generated questions to do in the topic 70 # questions: list of generated questions to do in the topic
72 # current_question: the current question to be presented 71 # current_question: the current question to be presented
73 # ------------------------------------------------------------------------ 72 # ------------------------------------------------------------------------
74 - def init_topic(self, topic=''): 73 + def init_topic(self, topic):
75 logger.debug(f'StudentKnowledge.init_topic({topic})') 74 logger.debug(f'StudentKnowledge.init_topic({topic})')
76 75
77 - # maybe get topic recommendation  
78 - if not topic:  
79 - topic = self.get_recommended_topic()  
80 - logger.debug(f'Recommended topic is {topic}')  
81 -  
82 # do not allow locked topics 76 # do not allow locked topics
83 if self.is_locked(topic): 77 if self.is_locked(topic):
84 logger.debug(f'Topic {topic} is locked') 78 logger.debug(f'Topic {topic} is locked')
@@ -105,23 +99,6 @@ class StudentKnowledge(object): @@ -105,23 +99,6 @@ class StudentKnowledge(object):
105 # get first question 99 # get first question
106 self.next_question() 100 self.next_question()
107 101
108 -  
109 - # def init_learning(self, topic=''):  
110 - # logger.debug(f'StudentKnowledge.init_learning({topic})')  
111 -  
112 - # if self.is_locked(topic):  
113 - # logger.debug(f'Topic {topic} is locked')  
114 - # return False  
115 -  
116 - # self.current_topic = topic  
117 - # factory = self.deps.node[topic]['factory']  
118 - # lesson = self.deps.node[topic]['lesson']  
119 -  
120 - # self.questions = [factory[qref].generate() for qref in lesson]  
121 - # logger.debug(f'Total: {len(self.questions)} questions')  
122 -  
123 - # self.next_question_in_lesson()  
124 -  
125 # ------------------------------------------------------------------------ 102 # ------------------------------------------------------------------------
126 # The topic has finished and there are no more questions. 103 # The topic has finished and there are no more questions.
127 # The topic level is updated in state and unlocks are performed. 104 # The topic level is updated in state and unlocks are performed.
@@ -204,6 +181,11 @@ class StudentKnowledge(object): @@ -204,6 +181,11 @@ class StudentKnowledge(object):
204 # pure functions of the state (no side effects) 181 # pure functions of the state (no side effects)
205 # ======================================================================== 182 # ========================================================================
206 183
  184 + def get_max_questions(self, topic=None):
  185 + if topic is not None:
  186 + node = self.deps.nodes[topic]
  187 + max_questions = node.get('choose', len(node['questions']))
  188 +
207 189
208 # ------------------------------------------------------------------------ 190 # ------------------------------------------------------------------------
209 # compute recommended sequence of topics ['a', 'b', ...] 191 # compute recommended sequence of topics ['a', 'b', ...]
@@ -251,6 +233,6 @@ class StudentKnowledge(object): @@ -251,6 +233,6 @@ class StudentKnowledge(object):
251 # ------------------------------------------------------------------------ 233 # ------------------------------------------------------------------------
252 # Recommends a topic to practice/learn from the state. 234 # Recommends a topic to practice/learn from the state.
253 # ------------------------------------------------------------------------ 235 # ------------------------------------------------------------------------
254 - def get_recommended_topic(self): # FIXME untested  
255 - return min(self.state.items(), key=lambda x: x[1]['level'])[0] 236 + # def get_recommended_topic(self): # FIXME untested
  237 + # return min(self.state.items(), key=lambda x: x[1]['level'])[0]
256 238
@@ -57,12 +57,14 @@ class LearnApp(object): @@ -57,12 +57,14 @@ class LearnApp(object):
57 session.close() 57 session.close()
58 58
59 # ------------------------------------------------------------------------ 59 # ------------------------------------------------------------------------
60 - def __init__(self, config_file):  
61 - # state of online students  
62 - self.online = {}  
63 - config = load_yaml(config_file[0])  
64 - self.db_setup(config['database']) # setup and check students  
65 - self.deps = build_dependency_graph(config) 60 + def __init__(self, config_files):
  61 + self.db_setup() # setup database and check students
  62 + self.online = dict() # online students
  63 +
  64 + self.deps = nx.DiGraph()
  65 + for c in config_files:
  66 + self.populate_graph(c)
  67 +
66 self.db_add_missing_topics(self.deps.nodes()) 68 self.db_add_missing_topics(self.deps.nodes())
67 69
68 # ------------------------------------------------------------------------ 70 # ------------------------------------------------------------------------
@@ -190,7 +192,6 @@ class LearnApp(object): @@ -190,7 +192,6 @@ class LearnApp(object):
190 loop = asyncio.get_running_loop() 192 loop = asyncio.get_running_loop()
191 try: 193 try:
192 await loop.run_in_executor(None, self.online[uid]['state'].init_topic, topic) 194 await loop.run_in_executor(None, self.online[uid]['state'].init_topic, topic)
193 - # self.online[uid]['state'].init_topic(topic)  
194 except KeyError as e: 195 except KeyError as e:
195 logger.warning(f'User "{uid}" tried to open nonexistent topic: "{topic}"') 196 logger.warning(f'User "{uid}" tried to open nonexistent topic: "{topic}"')
196 raise e 197 raise e
@@ -200,10 +201,10 @@ class LearnApp(object): @@ -200,10 +201,10 @@ class LearnApp(object):
200 # ------------------------------------------------------------------------ 201 # ------------------------------------------------------------------------
201 # Fill db table 'Topic' with topics from the graph if not already there. 202 # Fill db table 'Topic' with topics from the graph if not already there.
202 # ------------------------------------------------------------------------ 203 # ------------------------------------------------------------------------
203 - def db_add_missing_topics(self, nn): 204 + def db_add_missing_topics(self, topics):
204 with self.db_session() as s: 205 with self.db_session() as s:
205 - tt = [t[0] for t in s.query(Topic.id)] # db list of topics  
206 - missing_topics = [Topic(id=n) for n in nn if n not in tt] 206 + dbtopics = [t[0] for t in s.query(Topic.id)] # get topics from DB
  207 + missing_topics = [Topic(id=t) for t in topics if t not in dbtopics]
207 if missing_topics: 208 if missing_topics:
208 s.add_all(missing_topics) 209 s.add_all(missing_topics)
209 logger.info(f'Added {len(missing_topics)} new topics to the database') 210 logger.info(f'Added {len(missing_topics)} new topics to the database')
@@ -211,7 +212,7 @@ class LearnApp(object): @@ -211,7 +212,7 @@ class LearnApp(object):
211 # ------------------------------------------------------------------------ 212 # ------------------------------------------------------------------------
212 # setup and check database 213 # setup and check database
213 # ------------------------------------------------------------------------ 214 # ------------------------------------------------------------------------
214 - def db_setup(self, db): 215 + def db_setup(self, db='students.db'):
215 logger.info(f'Checking database "{db}":') 216 logger.info(f'Checking database "{db}":')
216 engine = create_engine(f'sqlite:///{db}', echo=False) 217 engine = create_engine(f'sqlite:///{db}', echo=False)
217 self.Session = sessionmaker(bind=engine) 218 self.Session = sessionmaker(bind=engine)
@@ -260,7 +261,7 @@ class LearnApp(object): @@ -260,7 +261,7 @@ class LearnApp(object):
260 261
261 # ------------------------------------------------------------------------ 262 # ------------------------------------------------------------------------
262 def get_title(self): 263 def get_title(self):
263 - return self.deps.graph['title'] 264 + return self.deps.graph.get('title', '') # FIXME
264 265
265 # ------------------------------------------------------------------------ 266 # ------------------------------------------------------------------------
266 def get_topic_name(self, ref): 267 def get_topic_name(self, ref):
@@ -278,64 +279,86 @@ class LearnApp(object): @@ -278,64 +279,86 @@ class LearnApp(object):
278 279
279 280
280 281
281 -# ============================================================================  
282 -# Builds a digraph.  
283 -#  
284 -# First, topics such as `computer/mips/exceptions` are added as nodes  
285 -# together with dependencies. Then, questions are loaded to a factory.  
286 -#  
287 -# g.graph['path'] base path where topic directories are located  
288 -# g.graph['title'] title defined in the configuration YAML  
289 -# g.graph['database'] sqlite3 database file to use  
290 -#  
291 -# Nodes are the topic references e.g. 'my/topic'  
292 -# g.node['my/topic']['name'] name of the topic  
293 -# g.node['my/topic']['questions'] list of question refs defined in YAML  
294 -# g.node['my/topic']['factory'] dict with question factories  
295 -#  
296 -# Edges are obtained from the deps defined in the YAML file for each topic.  
297 -# ----------------------------------------------------------------------------  
298 -def build_dependency_graph(config={}):  
299 - logger.info('Building graph and loading questions:')  
300 -  
301 - # create graph  
302 - prefix = config.get('path', '.')  
303 - title = config.get('title', '')  
304 - database = config.get('database', 'students.db')  
305 - g = nx.DiGraph(path=prefix, title=title, database=database, config=config)  
306 -  
307 - # iterate over topics and build graph  
308 - topics = config.get('topics', {})  
309 - tcount = qcount = 0 # topic and question counters  
310 - for ref,attr in topics.items():  
311 - g.add_node(ref)  
312 - tnode = g.node[ref] # current node (topic)  
313 -  
314 - if isinstance(attr, dict):  
315 - tnode['type'] = attr.get('type', 'topic')  
316 - tnode['name'] = attr.get('name', ref)  
317 - tnode['questions'] = attr.get('questions', [])  
318 - g.add_edges_from((d,ref) for d in attr.get('deps', []))  
319 -  
320 - fullpath = path.expanduser(path.join(prefix, ref))  
321 -  
322 - # load questions  
323 - filename = path.join(fullpath, 'questions.yaml')  
324 - loaded_questions = load_yaml(filename, default=[]) # list  
325 - if not tnode['questions']:  
326 - # if questions not in configuration then load all, preserving order  
327 - tnode['questions'] = [q.setdefault('ref', f'{ref}:{i}') for i,q in enumerate(loaded_questions)]  
328 -  
329 - # make questions factory (without repeating same question)  
330 - tnode['factory'] = {}  
331 - for q in loaded_questions:  
332 - if q['ref'] in tnode['questions']:  
333 - q['path'] = fullpath # fullpath added to each question  
334 - tnode['factory'][q['ref']] = QFactory(q)  
335 -  
336 - logger.info(f'{len(tnode["questions"]):6} {ref}')  
337 - qcount += len(tnode["questions"]) # count total questions  
338 - tcount += 1  
339 -  
340 - logger.info(f'Total loaded: {tcount} topics, {qcount} questions')  
341 - return g 282 + # ============================================================================
  283 + # Populates a digraph.
  284 + #
  285 + # First, topics such as `computer/mips/exceptions` are added as nodes
  286 + # together with dependencies. Then, questions are loaded to a factory.
  287 + #
  288 + # g.graph['path'] base path where topic directories are located
  289 + # g.graph['title'] title defined in the configuration YAML
  290 + # g.graph['database'] sqlite3 database file to use
  291 + #
  292 + # Nodes are the topic references e.g. 'my/topic'
  293 + # g.node['my/topic']['name'] name of the topic
  294 + # g.node['my/topic']['questions'] list of question refs defined in YAML
  295 + # g.node['my/topic']['factory'] dict with question factories
  296 + #
  297 + # Edges are obtained from the deps defined in the YAML file for each topic.
  298 + # ----------------------------------------------------------------------------
  299 + def populate_graph(self, conffile):
  300 + logger.info(f'Loading {conffile} and populating graph:')
  301 + g = self.deps # the graph
  302 + config = load_yaml(conffile) # course configuration
  303 +
  304 + # set attributes of the graph
  305 + prefix = path.expanduser(config.get('path', '.'))
  306 + # title = config.get('title', '')
  307 + # database = config.get('database', 'students.db')
  308 +
  309 + # default attributes that apply to the topics
  310 + default_file = config.get('file', 'questions.yaml')
  311 + default_shuffle = config.get('shuffle', True)
  312 + default_choose = config.get('choose', 9999)
  313 + default_forgetting_factor = config.get('forgetting_factor', 1.0)
  314 +
  315 + # create graph
  316 + # g = nx.DiGraph(path=prefix, title=title, database=database, config=config)
  317 +
  318 +
  319 + # iterate over topics and populate graph
  320 + topics = config.get('topics', {})
  321 + tcount = qcount = 0 # topic and question counters
  322 + for tref, attr in topics.items():
  323 + if tref in g:
  324 + logger.error(f'--> Topic {tref} already exists. Skipped.')
  325 + continue
  326 +
  327 + # add topic to the graph
  328 + g.add_node(tref)
  329 + t = g.node[tref] # current topic node
  330 +
  331 + topicpath = path.join(prefix, tref)
  332 +
  333 + t['type'] = attr.get('type', 'topic')
  334 + t['name'] = attr.get('name', tref)
  335 + t['path'] = topicpath
  336 + t['file'] = attr.get('file', default_file)
  337 + t['shuffle'] = attr.get('shuffle', default_shuffle)
  338 + t['forgetting_factor'] = attr.get('forgetting_factor', default_forgetting_factor)
  339 + g.add_edges_from((d,tref) for d in attr.get('deps', []))
  340 +
  341 + # load questions as list of dicts
  342 + questions = load_yaml(path.join(topicpath, t['file']), default=[])
  343 +
  344 + # if questions are left undefined, include all.
  345 + # refs undefined in questions.yaml are set to topic:n
  346 + t['questions'] = attr.get('questions',
  347 + [q.setdefault('ref', f'{tref}:{i}') for i, q in enumerate(questions)])
  348 +
  349 + # topic will generate a certain amount of questions
  350 + t['choose'] = min(attr.get('choose', default_choose), len(t['questions']))
  351 +
  352 + # make questions factory (without repeating same question) FIXME move to somewhere else?
  353 + t['factory'] = {}
  354 + for q in questions:
  355 + if q['ref'] in t['questions']:
  356 + q['path'] = topicpath # fullpath added to each question
  357 + t['factory'][q['ref']] = QFactory(q)
  358 +
  359 + logger.info(f'{len(t["questions"]):6} {tref}')
  360 + qcount += len(t["questions"]) # count total questions
  361 + tcount += 1
  362 +
  363 + logger.info(f'Total loaded: {tcount} topics, {qcount} questions')
  364 +