Commit a203d3ccc52a4cd6d46aeff2efc28ebd161b1f87
1 parent
9161ff75
Exists in
master
and in
1 other branch
- 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.
Showing
6 changed files
with
228 additions
and
126 deletions
Show diff stats
README.md
... | ... | @@ -5,23 +5,23 @@ |
5 | 5 | |
6 | 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 | 10 | This can be done using the system package management, downloaded from [http://www.python.org](), or compiled from sources. |
11 | 11 | |
12 | 12 | - Installing from the system package manager: |
13 | 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 | 16 | sudo apt install ?not available yet? # Linux, install from source |
17 | 17 | ``` |
18 | 18 | - Installing from source: |
19 | 19 | Download from [http://www.python.org]() and |
20 | 20 | |
21 | 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 | 25 | ./configure --prefix=$HOME/.local/bin |
26 | 26 | make && make install |
27 | 27 | ``` |
... | ... | @@ -33,7 +33,7 @@ This can be done using the system package management, downloaded from [http://ww |
33 | 33 | If the `pip` command is not yet installed, run |
34 | 34 | |
35 | 35 | ```sh |
36 | -python3.6 -m ensurepip --user | |
36 | +python3.7 -m ensurepip --user | |
37 | 37 | ``` |
38 | 38 | |
39 | 39 | This will install pip in your user account under `~/.local/bin`. |
... | ... | @@ -54,15 +54,15 @@ pip3 install --user \ |
54 | 54 | sqlalchemy \ |
55 | 55 | pyyaml \ |
56 | 56 | pygments \ |
57 | - markdown \ | |
57 | + mistune \ | |
58 | 58 | bcrypt \ |
59 | 59 | networkx |
60 | 60 | ``` |
61 | 61 | |
62 | 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 | 67 | ## Installation |
68 | 68 | |
... | ... | @@ -82,18 +82,19 @@ The user data is maintained in a sqlite3 database file. We first need to create |
82 | 82 | |
83 | 83 | ```sh |
84 | 84 | cd aprendizations |
85 | -./initdb.py --help # for the available options | |
86 | 85 | ./initdb.py # show current database or initialize empty if nonexisting |
87 | 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 | 92 | ### SSL Certificates |
93 | 93 | |
94 | 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 | 99 | #### Selfsigned |
99 | 100 | |
... | ... | @@ -109,7 +110,7 @@ openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 - |
109 | 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 | 115 | ```sh |
115 | 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 | 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 | 151 | ### Firewall configuration |
... | ... | @@ -178,6 +179,7 @@ pflog_logfile="/var/log/pflog" |
178 | 179 | |
179 | 180 | Reboot or `sudo service pf start`. |
180 | 181 | |
182 | + | |
181 | 183 | ## Troubleshooting |
182 | 184 | |
183 | 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 | 1 | title: Example |
2 | 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 | 7 | file: questions.yaml |
6 | 8 | shuffle: True |
7 | 9 | choose: 6 |
8 | - | |
9 | -# path prefix applied to the topics | |
10 | -path: ./demo | |
10 | +forgetting_factor: 0.99 | |
11 | 11 | |
12 | 12 | # Representation of the edges of the dependency graph. |
13 | 13 | # Example: A depends on B and C | ... | ... |
factory.py
... | ... | @@ -38,6 +38,14 @@ from questions import QuestionInformation, QuestionRadio, QuestionCheckbox, Ques |
38 | 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 | 51 | # Question Factory |
... | ... | @@ -59,7 +67,7 @@ class QFactory(object): |
59 | 67 | 'success' : QuestionInformation, |
60 | 68 | } |
61 | 69 | |
62 | - def __init__(self, question_dict): | |
70 | + def __init__(self, question_dict={}): | |
63 | 71 | self.question = question_dict |
64 | 72 | |
65 | 73 | # ----------------------------------------------------------------------- |
... | ... | @@ -86,7 +94,7 @@ class QFactory(object): |
86 | 94 | try: |
87 | 95 | qinstance = self._types[q['type']](q) # instance with correct class |
88 | 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 | 98 | raise e |
91 | 99 | else: |
92 | 100 | return qinstance | ... | ... |
... | ... | @@ -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() | ... | ... |
knowledge.py
... | ... | @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) |
17 | 17 | # kowledge state of each student....?? |
18 | 18 | # Contains: |
19 | 19 | # state - dict of topics with state of unlocked topics |
20 | -# deps - dependency graph | |
20 | +# deps - access to dependency graph shared between students | |
21 | 21 | # topic_sequence - list with the order of recommended topics |
22 | 22 | # ---------------------------------------------------------------------------- |
23 | 23 | class StudentKnowledge(object): |
... | ... | @@ -28,12 +28,11 @@ class StudentKnowledge(object): |
28 | 28 | self.deps = deps # dependency graph shared among students |
29 | 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 | 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 | 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 | 38 | # Updates the proficiency levels of the topics, with forgetting factor |
... | ... | @@ -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 | 70 | # questions: list of generated questions to do in the topic |
72 | 71 | # current_question: the current question to be presented |
73 | 72 | # ------------------------------------------------------------------------ |
74 | - def init_topic(self, topic=''): | |
73 | + def init_topic(self, topic): | |
75 | 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 | 76 | # do not allow locked topics |
83 | 77 | if self.is_locked(topic): |
84 | 78 | logger.debug(f'Topic {topic} is locked') |
... | ... | @@ -105,23 +99,6 @@ class StudentKnowledge(object): |
105 | 99 | # get first question |
106 | 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 | 103 | # The topic has finished and there are no more questions. |
127 | 104 | # The topic level is updated in state and unlocks are performed. |
... | ... | @@ -204,6 +181,11 @@ class StudentKnowledge(object): |
204 | 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 | 191 | # compute recommended sequence of topics ['a', 'b', ...] |
... | ... | @@ -251,6 +233,6 @@ class StudentKnowledge(object): |
251 | 233 | # ------------------------------------------------------------------------ |
252 | 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 | ... | ... |
learnapp.py
... | ... | @@ -57,12 +57,14 @@ class LearnApp(object): |
57 | 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 | 68 | self.db_add_missing_topics(self.deps.nodes()) |
67 | 69 | |
68 | 70 | # ------------------------------------------------------------------------ |
... | ... | @@ -190,7 +192,6 @@ class LearnApp(object): |
190 | 192 | loop = asyncio.get_running_loop() |
191 | 193 | try: |
192 | 194 | await loop.run_in_executor(None, self.online[uid]['state'].init_topic, topic) |
193 | - # self.online[uid]['state'].init_topic(topic) | |
194 | 195 | except KeyError as e: |
195 | 196 | logger.warning(f'User "{uid}" tried to open nonexistent topic: "{topic}"') |
196 | 197 | raise e |
... | ... | @@ -200,10 +201,10 @@ class LearnApp(object): |
200 | 201 | # ------------------------------------------------------------------------ |
201 | 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 | 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 | 208 | if missing_topics: |
208 | 209 | s.add_all(missing_topics) |
209 | 210 | logger.info(f'Added {len(missing_topics)} new topics to the database') |
... | ... | @@ -211,7 +212,7 @@ class LearnApp(object): |
211 | 212 | # ------------------------------------------------------------------------ |
212 | 213 | # setup and check database |
213 | 214 | # ------------------------------------------------------------------------ |
214 | - def db_setup(self, db): | |
215 | + def db_setup(self, db='students.db'): | |
215 | 216 | logger.info(f'Checking database "{db}":') |
216 | 217 | engine = create_engine(f'sqlite:///{db}', echo=False) |
217 | 218 | self.Session = sessionmaker(bind=engine) |
... | ... | @@ -260,7 +261,7 @@ class LearnApp(object): |
260 | 261 | |
261 | 262 | # ------------------------------------------------------------------------ |
262 | 263 | def get_title(self): |
263 | - return self.deps.graph['title'] | |
264 | + return self.deps.graph.get('title', '') # FIXME | |
264 | 265 | |
265 | 266 | # ------------------------------------------------------------------------ |
266 | 267 | def get_topic_name(self, ref): |
... | ... | @@ -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 | + | ... | ... |