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,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 |
factory.py
@@ -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 |
@@ -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() |
knowledge.py
@@ -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 |
learnapp.py
@@ -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 | + |