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 | + |