diff --git a/BUGS.md b/BUGS.md index 05cfa53..4ce9b04 100644 --- a/BUGS.md +++ b/BUGS.md @@ -3,7 +3,7 @@ - quando termina topico devia apagar as perguntas todas (se falhar a gerar novo topico, aparecem perguntas do antigo) - apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande) -- devia mostrar timout para o aluno saber a razao. +- devia mostrar timeout para o aluno saber a razao. - permitir configuracao para escolher entre static files locais ou remotos - sqlalchemy.pool.impl.NullPool: Exception during reset or similar sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread. @@ -40,6 +40,7 @@ sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in # FIXED +- add aprendizatons --version - se aluno abre dois tabs no browser, conseque navegar em simultaneo para perguntas diferentes. quando submete uma delas dá asneira. Tem de haver um campo hidden que tenha um céodigo único que indique qual a pergunta. do lado do servidor apnas há o codigo da pergunta corrente, se forem diferentes faz redirect para /. - nos topicos learn.yaml, qd falha acrescenta no fim. nao faz sentido. - não esta a fazer render correcto de tabelas nas opcoes checkbox. e.g. information-theory/source-coding-theory/block-codes diff --git a/README.md b/README.md index f723737..734e25d 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,69 @@ # Getting Started +To complete the installation we will need to perform the following steps: -## Requirements +1. install python3.7, pip and npm +1. download aprendizations from the repository +1. install javascript libraries (with npm) +1. install aprendizations (with pip) +1. initialize database +1. generate SSL certificates +1. configure the firewall (optional) +1. try running `aprendizations demo.yaml` -This application requires python3.7+ and a few additional python packages. -It also uses npm (Node package management) to install javascript libraries. +These steps are explained next in detail. ### Install python3.7 with sqlite3 support and npm -This can be done using the system package management, downloaded from [http://www.python.org](), or compiled from sources. +Python can be installed either from the system package management or compiled from sources. -- Installing from the system package manager: +#### Installing from the system package manager ```sh -sudo port install python37 npm5 # MacOS +sudo apt install python3.7 npm # Linux (Ubuntu) sudo pkg install python37 py37-sqlite3 npm # FreeBSD -sudo apt install python3.7 npm # Linux +sudo port install python37 npm6 # MacOS ``` -- Installing from source: +#### Installing from source -Download from [http://www.python.org]() and +Make sure that the build tools and libraries are installed: ```sh -unxz Python-3.7.tar.xz -tar xvf Python-3.7.tar +# Ubuntu: +sudo apt install build-essential libssl-dev zlib1g-dev libncurses5-dev libncursesw5-dev libreadline-dev libsqlite3-dev libgdbm-dev libdb5.3-dev libbz2-dev libexpat1-dev liblzma-dev tk-dev libffi-dev +``` + +Download python from [http://www.python.org]() and + +```sh +tar xvfJ Python-3.7.tar.xz cd Python-3.7 -./configure --prefix=$HOME/.local/bin +./configure --prefix=$HOME/.local --enable-optimizations make && make install ``` -This will install python locally under `~/.local/bin`. Make sure to add it to your `PATH` (edit `~/.profile` in MacOS or FreeBSD). - +This will install python locally under `~/.local/bin`. Make sure to add it to your `PATH` in `~/.profile`. If `~/bin` is already in the path, you may just make a symbolic link `ln -s ~/.local/bin ~/bin`. ### Install pip +Python usually includes pip which is accessible through `python -m pip install something`, but it's also convenient to have the `pip` command installed. If the `pip` command is not yet installed, run one of these: ```sh -sudo apt install python3.7-pip # Ubuntu +sudo apt install python3.7-pip # Ubuntu 19.04+ +python3.7 -m pip install pip # Ubuntu 18.04 sudo pkg py37-pip # FreeBSD sudo port install py37-pip # MacOS -python3.7 -m ensurepip --user # otherwise ``` The latter will install `pip` in your user account under `~/.local/bin`. -In the end you should be able to run `pip3 --version` and -`python3 -c "import sqlite3"` without errors (sometimes `pip3` is `pip`, -`pip3.7` or `pip-3.7`). +In the end you should be able to run `pip --version` and +`python3 -c "import sqlite3"` without errors. +Sometimes the `pip` command is named `pip3`, +`pip3.7` or `pip-3.7`. -If you want to always install python modules on the user account (recommended), -edit the pip configuration file `~/.config/pip/pip.conf` (FreeBSD, Linux) or +Edit the configuration file `~/.config/pip/pip.conf` (FreeBSD, Linux) or `Library/Application Support/pip/pip.conf` (MacOS) and add the lines ```ini @@ -58,69 +71,72 @@ edit the pip configuration file `~/.config/pip/pip.conf` (FreeBSD, Linux) or user = yes ``` -### Install python packages and javascript libraries: +This will set pip to install modules in the user area (recommended). -Replace USER by your bitbucket username: +### Install aprendizations and dependencies: ```sh -cd somewhere git clone https://git.xdi.uevora.pt/mjsb/aprendizations.git cd aprendizations npm install # install javascript libraries pip install . # install aprendizations and dependencies ``` +Javascript libraries are initially installed in `aprendizations/node_modules` directory. +These libraries already have symbolic links from `aprendizations/aprendizations/static`. + Python packages are usually installed in: - `~/.local/lib/python3.7/site-packages/` in Linux/FreeBSD. - `~/Library/python/3.7/lib/python/site-packages/` in MacOS. -Javascript libraries are installed in `aprendizations/node_modules` directory. -This libraries have symbolic links from `aprendizations/aprendizations/static`. +When aprendizations is installed with pip, all the dependencies are also installed. The javascript libraries previously installed with npm are copied to the above directory and the cloned repository is no longer needed. -At this point aprendizations is installed in +At this point, aprendizations is installed in ```sh +~/.local/bin # Linux/FreeBSD ~/Library/Python/3.7/bin # MacOS -~/.local/bin # FreeBSD/Linux ``` -Make sure this directory is in your `$PATH`. +and can be run from the terminal: -The server can be run with the command `aprendizations` from the terminal. +```sh +aprendizations --version +aprendizations --help +``` ## Configuration ### Database -The user data is maintained in a sqlite3 database file. We first need to create -the database. -At the moment, the database should be located in the same directory as the main -configuration file (see below). As an example, do +User data is maintained in a sqlite3 database which has to be created manually using the command `initdb-aprendizations`. +The database file should be located in the same directory as the main +YAML configuration file. + +For example, to run the included demo do: ```sh cd demo # contains a small example initdb-aprendizations # show or initialize database initdb-aprendizations --admin # add admin user initdb-aprendizations inscricoes.csv # add students from CSV -initdb-aprendizations --add 1184 "Aladino da Silva" # add user +initdb-aprendizations --add 1184 "Aladino da Silva" # add new user initdb-aprendizations --update 1184 --pw alibaba # update password -initdb-aprendizations --help # for the available options +initdb-aprendizations --help # for available options ``` -The default password is equal to the user name used to login. +The default password is equal to the user name, if left undefined. ### SSL Certificates -We need certificates for https. Certificates can be self-signed or validated by -a trusted authority. +We need certificates for https. Certificates can be self-signed or validated by a trusted authority. Self-signed can be used locally for development and testing, but browsers will -complain. LetsEncrypt issues trusted and free certificates, but the server must -have a registered publicly accessible domain name. +complain. LetsEncrypt issues trusted and free certificates, but the server must have a registered publicly accessible domain name. -#### Selfsigned +#### Generating selfsigned certificates Generate a selfsigned certificate and place it in `~/.local/share/certs`. @@ -128,18 +144,20 @@ Generate a selfsigned certificate and place it in `~/.local/share/certs`. openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 -nodes ``` -#### LetsEncrypt +#### LetsEncrypt certificates + +Install the certbot from LetsEncrypt: ```sh -sudo pkg install py27-certbot # FreeBSD +sudo pkg install py36-certbot # FreeBSD +sudo apt install certbot # Linux Ubuntu ``` -Shutdown the firewall and any web server that might be running. Then run the script to generate the certificate: +To generate or renew the certificates, ports 80 and 443 have to be accessible. The firewall and webserver have to be stopped. ```sh -sudo service pf stop # disable pf firewall (FreeBSD) -sudo certbot certonly --standalone -d www.example.com -sudo service pf start # enable pf firewall +sudo certbot certonly --standalone -d www.example.com # first time +sudo certbot renew # renew ``` Certificates are saved under `/usr/local/etc/letsencrypt/live/www.example.com/`. Copy them to `~/.local/share/certs` and change permissions to be readable: @@ -150,47 +168,32 @@ sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem . chmod 400 cert.pem privkey.pem ``` -Renews can be done as follows: - -```sh -sudo service pf stop # shutdown firewall -sudo certbot renew -sudo service pf start # start firewall -``` - -and then copy the `cert.pem` and `privkey.pem` files to `~/.local/share/certs` directory. Change permissions and ownership as appropriate. +### Running the demo - -### Testing - -The application includes a small example in `demo/demo.yaml`. Run it with +The application includes a small example in `demo/demo.yaml` that can be used for initial testing. Run it with ```sh cd demo aprendizations demo.yaml ``` -and open a browser at [https://127.0.0.1:8443](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. The option `--check` generates all the questions once -before running the server to check for any obvious syntax error. - +Open the browser at [https://127.0.0.1:8443](https://127.0.0.1:8443). +If it everything looks good, check at the correct address +`https://www.example.com:8443`. +The option `--debug` provides more verbose logging and might +be useful during testing. ### Firewall configuration -Ports 80 and 443 are only usable by root. For security reasons it is better to -run the server as an unprivileged user on higher ports like 8080 for http and -8443 for https. For this, we can configure port forwarding in the firewall to -redirect incoming tcp traffic from 80 to 8080 and 443 to 8443. +Ports 80 and 443 are only usable by root. For security reasons the server runs as an unprivileged user on port 8443 for https. +To access the server in the default https port (443), port forwarding can be configured in the firewall. #### FreeBSD and pf Edit `/etc/pf.conf`: ```sh -ext_if="em0" # this should be the correct network interface +ext_if="em0" # change em0 to the correct network interface rdr on $ext_if proto tcp from any to any port 80 -> 127.0.0.1 port 8080 rdr on $ext_if proto tcp from any to any port 443 -> 127.0.0.1 port 8443 ``` @@ -212,6 +215,18 @@ pflog_logfile="/var/log/pflog" Reboot or `sudo service pf start`. +### Testing the system + +Make sure the following steps have been done: + +- installed python3.7, pip and npm +- git-cloned the aprendizations from the main repository +- installed javascript libraries with npm +- installed aprendizations with pip +- initialized database with at least 1 user +- generate and copy certificates to the appropriate place +- (optional) configure the firewall to do port forwarding +- run `aprendizations demo.yaml --check` ## Troubleshooting @@ -243,20 +258,23 @@ me:\ ## FAQ -- Which students did at least one topic? +Common database manipulations: ```sh -sqlite3 students.db "select distinct student_id from studenttopic" +initdb-aprendizations -u 12345 --pw alibaba # reset student password +initdb-aprendizations -a 12345 --pw alibaba # add new student ``` -- How many topics has each student done? +Common database queries: ```sh -sqlite3 students.db "select student_id, count(topic_id) from studenttopic group by student_id order by count(topic_id) desc" -``` +# Which students did at least one topic? +sqlite3 students.db "select distinct student_id from studenttopic" -- Which questions have more wrong answers? +# How many topics has each student done? +sqlite3 students.db "select student_id, count(topic_id) from studenttopic group by student_id order by count(topic_id) desc" -```sh +# Which questions have more wrong answers? sqlite3 students.db "select count(ref), ref from answers where grade<1.0 group by ref order by count(ref) desc" -``` \ No newline at end of file +``` + diff --git a/aprendizations/__init__.py b/aprendizations/__init__.py index a84bdd0..467d912 100644 --- a/aprendizations/__init__.py +++ b/aprendizations/__init__.py @@ -30,7 +30,7 @@ are progressively uncovered as the students progress. ''' APP_NAME = 'aprendizations' -APP_VERSION = '2019.05.dev3' +APP_VERSION = '2019.07.dev1' APP_DESCRIPTION = __doc__ __author__ = 'Miguel Barão' diff --git a/aprendizations/knowledge.py b/aprendizations/knowledge.py deleted file mode 100644 index ced124b..0000000 --- a/aprendizations/knowledge.py +++ /dev/null @@ -1,237 +0,0 @@ - -# python standard library -import random -from datetime import datetime -import logging -import asyncio - -# third party libraries -import networkx as nx - -# setup logger for this module -logger = logging.getLogger(__name__) - - -# ---------------------------------------------------------------------------- -# kowledge state of each student....?? -# Contains: -# state - dict of topics with state of unlocked topics -# deps - access to dependency graph shared between students -# topic_sequence - list with the order of recommended topics -# ---------------------------------------------------------------------------- -class StudentKnowledge(object): - # ======================================================================= - # methods that update state - # ======================================================================= - def __init__(self, deps, factory, state={}): - self.deps = deps # shared dependency graph - self.factory = factory # question factory - self.state = state # {'topic': {'level': 0.5, 'date': datetime}, ...} - - self.update_topic_levels() # applies forgetting factor - self.unlock_topics() # whose dependencies have been completed - self.topic_sequence = self.recommend_topic_sequence() # ['ref1', ...] - self.current_topic = None - - # ------------------------------------------------------------------------ - # Updates the proficiency levels of the topics, with forgetting factor - # FIXME no dependencies are considered yet... - # ------------------------------------------------------------------------ - def update_topic_levels(self): - now = datetime.now() - for tref, s in self.state.items(): - dt = now - s['date'] - s['level'] *= 0.98 ** dt.days # forgetting factor 0.95 FIXME - - # ------------------------------------------------------------------------ - # Unlock topics whose dependencies are satisfied (> min_level) - # ------------------------------------------------------------------------ - def unlock_topics(self): - for topic in self.deps.nodes(): - if topic not in self.state: # if locked - pred = self.deps.predecessors(topic) - min_level = self.deps.node[topic]['min_level'] - if all(d in self.state and self.state[d]['level'] > min_level - for d in pred): # all deps are greater than min_level - - self.state[topic] = { - 'level': 0.0, # unlocked - 'date': datetime.now() - } - logger.debug(f'[unlock_topics] Unlocked "{topic}".') - # else: # lock this topic if deps do not satisfy min_level - # del self.state[topic] - - # ------------------------------------------------------------------------ - # Start a new topic. - # questions: list of generated questions to do in the topic - # current_question: the current question to be presented - # ------------------------------------------------------------------------ - async def start_topic(self, topic): - logger.debug(f'[start_topic] topic "{topic}"') - - if self.current_topic == topic: - logger.info('Restarting current topic is not allowed.') - return False - - # do not allow locked topics - if self.is_locked(topic): - logger.debug(f'[start_topic] topic "{topic}" is locked') - return False - - # starting new topic - self.current_topic = topic - self.correct_answers = 0 - self.wrong_answers = 0 - - t = self.deps.node[topic] - k = t['choose'] - if t['shuffle_questions']: - questions = random.sample(t['questions'], k=k) - else: - questions = t['questions'][:k] - logger.debug(f'[start_topic] questions: {", ".join(questions)}') - - # synchronous - # self.questions = [self.factory[ref].generate() - # for ref in questions] - - # asynchronous: - self.questions = [await self.factory[ref].generate_async() - for ref in questions] - - # get first question - self.next_question() - - logger.debug(f'[start_topic] generated {len(self.questions)} questions') - return True - - # ------------------------------------------------------------------------ - # The topic has finished and there are no more questions. - # The topic level is updated in state and unlocks are performed. - # The current topic is unchanged. - # ------------------------------------------------------------------------ - def finish_topic(self): - logger.debug(f'[finish_topic] current_topic {self.current_topic}') - - self.state[self.current_topic] = { - 'date': datetime.now(), - 'level': self.correct_answers / (self.correct_answers + - self.wrong_answers) - } - # self.current_topic = None - self.unlock_topics() - - # ------------------------------------------------------------------------ - # corrects current question with provided answer. - # implements the logic: - # - if answer ok, goes to next question - # - if wrong, counts number of tries. If exceeded, moves on. - # ------------------------------------------------------------------------ - async def check_answer(self, answer): - logger.debug('[check_answer]') - - q = self.current_question - q['answer'] = answer - q['finish_time'] = datetime.now() - await q.correct_async() - logger.debug(f'[check_answer] Grade {q["grade"]:.2} in {q["ref"]}') - - if q['grade'] > 0.999: - self.correct_answers += 1 - self.next_question() - action = 'right' - - else: - self.wrong_answers += 1 - self.current_question['tries'] -= 1 - - if self.current_question['tries'] > 0: - action = 'try_again' - else: - action = 'wrong' - if self.current_question['append_wrong']: - logger.debug('[check_answer] Wrong, append new instance') - self.questions.append(self.factory[q['ref']].generate()) - self.next_question() - - # returns corrected question (not new one) which might include comments - return q, action - - # ------------------------------------------------------------------------ - # Move to next question - # ------------------------------------------------------------------------ - def next_question(self): - try: - self.current_question = self.questions.pop(0) - except IndexError: - self.current_question = None - self.finish_topic() - else: - self.current_question['start_time'] = datetime.now() - default_maxtries = self.deps.nodes[self.current_topic]['max_tries'] - maxtries = self.current_question.get('max_tries', default_maxtries) - self.current_question['tries'] = maxtries - logger.debug(f'[next_question] "{self.current_question["ref"]}"') - - return self.current_question # question or None - - # ======================================================================== - # pure functions of the state (no side effects) - # ======================================================================== - - def topic_has_finished(self): - return self.current_question is None - - # ------------------------------------------------------------------------ - # compute recommended sequence of topics ['a', 'b', ...] - # ------------------------------------------------------------------------ - def recommend_topic_sequence(self, target=None): - tt = list(nx.topological_sort(self.deps)) - unlocked = [t for t in tt if t in self.state] - locked = [t for t in tt if t not in unlocked] - return unlocked + locked - - # ------------------------------------------------------------------------ - def get_current_question(self): - return self.current_question - - # ------------------------------------------------------------------------ - def get_current_topic(self): - return self.current_topic - - # ------------------------------------------------------------------------ - def is_locked(self, topic): - return topic not in self.state - - # ------------------------------------------------------------------------ - # Return list of {ref: 'xpto', name: 'long name', leve: 0.5} - # Levels are in the interval [0, 1] if unlocked or None if locked. - # Topics unlocked but not yet done have level 0.0. - # ------------------------------------------------------------------------ - def get_knowledge_state(self): - return [{ - 'ref': ref, - 'type': self.deps.nodes[ref]['type'], - 'name': self.deps.nodes[ref]['name'], - 'level': self.state[ref]['level'] if ref in self.state else None - } for ref in self.topic_sequence] - - # ------------------------------------------------------------------------ - def get_topic_progress(self): - return self.correct_answers / (1 + self.correct_answers + - len(self.questions)) - - # ------------------------------------------------------------------------ - def get_topic_level(self, topic): - return self.state[topic]['level'] - - # ------------------------------------------------------------------------ - def get_topic_date(self, topic): - return self.state[topic]['date'] - - # ------------------------------------------------------------------------ - # Recommends a topic to practice/learn from the state. - # ------------------------------------------------------------------------ - # def get_recommended_topic(self): # FIXME untested - # return min(self.state.items(), key=lambda x: x[1]['level'])[0] diff --git a/aprendizations/learnapp.py b/aprendizations/learnapp.py index d3abc98..9991be3 100644 --- a/aprendizations/learnapp.py +++ b/aprendizations/learnapp.py @@ -10,13 +10,12 @@ from typing import Dict # third party libraries import bcrypt -from sqlalchemy import create_engine, func -from sqlalchemy.orm import sessionmaker +import sqlalchemy as sa import networkx as nx # this project from .models import Student, Answer, Topic, StudentTopic -from .knowledge import StudentKnowledge +from .student import StudentState from .questions import QFactory from .tools import load_yaml @@ -29,6 +28,10 @@ class LearnException(Exception): pass +class DatabaseUnusableError(LearnException): + pass + + # ============================================================================ # LearnApp - application logic # ============================================================================ @@ -44,8 +47,9 @@ class LearnApp(object): yield session session.commit() except Exception: - logger.error('DB rollback!!!') + logger.error('!!! Database rollback !!!') session.rollback() + raise finally: session.close() @@ -105,7 +109,7 @@ class LearnApp(object): logger.error(f'{errors:>6} errors found.') raise LearnException('Sanity checks') else: - logger.info('No errors found.') + logger.info(' 0 errors found.') # ------------------------------------------------------------------------ # login @@ -151,8 +155,8 @@ class LearnApp(object): self.online[uid] = { 'number': uid, 'name': name, - 'state': StudentKnowledge(deps=self.deps, factory=self.factory, - state=state), + 'state': StudentState(deps=self.deps, factory=self.factory, + state=state), 'counter': counter + 1, # counts simultaneous logins } @@ -263,20 +267,20 @@ class LearnApp(object): f'database') # ------------------------------------------------------------------------ - # setup and check database + # setup and check database contents # ------------------------------------------------------------------------ def db_setup(self, db): logger.info(f'Checking database "{db}":') - engine = create_engine(f'sqlite:///{db}', echo=False) - self.Session = sessionmaker(bind=engine) + engine = sa.create_engine(f'sqlite:///{db}', echo=False) + self.Session = sa.orm.sessionmaker(bind=engine) try: with self.db_session() as s: n = s.query(Student).count() m = s.query(Topic).count() q = s.query(Answer).count() - except Exception as e: - logger.critical(f'Database "{db}" not usable!') - raise e + except Exception: + logger.error(f'Database "{db}" not usable!') + raise DatabaseUnusableError() else: logger.info(f'{n:6} students') logger.info(f'{m:6} topics') @@ -306,7 +310,7 @@ class LearnApp(object): # iterate over topics and populate graph topics = config.get('topics', {}) - g = self.deps # the dependency graph + g = self.deps # dependency graph g.add_nodes_from(topics.keys()) for tref, attr in topics.items(): @@ -437,16 +441,16 @@ class LearnApp(object): total_topics = s.query(Topic).count() # answer performance - totalans = dict(s.query(Answer.student_id, func.count(Answer.ref)). - group_by(Answer.student_id). - all()) - rightans = dict(s.query(Answer.student_id, func.count(Answer.ref)). - filter(Answer.grade == 1.0). - group_by(Answer.student_id). - all()) + total = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)). + group_by(Answer.student_id). + all()) + right = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)). + filter(Answer.grade == 1.0). + group_by(Answer.student_id). + all()) # compute percentage of right answers - perf = {uid: rightans.get(uid, 0.0)/totalans[uid] for uid in totalans} + perf = {uid: right.get(uid, 0.0)/total[uid] for uid in total} # compute topic progress prog = {s[0]: 0.0 for s in students} diff --git a/aprendizations/main.py b/aprendizations/main.py new file mode 100644 index 0000000..a2ee135 --- /dev/null +++ b/aprendizations/main.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 + +# python standard library +import argparse +import logging +from os import environ, path +import signal +import ssl +import sys + +# third party libraries +import tornado + +# this project +from .learnapp import LearnApp, DatabaseUnusableError +from .serve import WebApplication +from .tools import load_yaml +from . import APP_NAME, APP_VERSION + + +# ---------------------------------------------------------------------------- +# Signal handler to catch Ctrl-C and abort server +# ---------------------------------------------------------------------------- +def signal_handler(signal, frame): + r = input(' --> Stop webserver? (yes/no) ').lower() + if r == 'yes': + tornado.ioloop.IOLoop.current().stop() + logging.critical('Webserver stopped.') + sys.exit(0) + else: + logging.info('Abort canceled...') + + +# ---------------------------------------------------------------------------- +def parse_cmdline_arguments(): + argparser = argparse.ArgumentParser( + description='Server for online learning. Students and topics ' + 'have to be previously configured. Please read the documentation ' + 'included with this software before running the server.' + ) + + argparser.add_argument( + 'conffile', type=str, nargs='*', + help='Topics configuration file in YAML format.' + ) + + argparser.add_argument( + '--prefix', type=str, default='.', + help='Path where the topic directories can be found (default: .)' + ) + + argparser.add_argument( + '--port', type=int, default=8443, + help='Port to be used by the HTTPS server (default: 8443)' + ) + + argparser.add_argument( + '--db', type=str, default='students.db', + help='SQLite3 database file (default: students.db)' + ) + + argparser.add_argument( + '--check', action='store_true', + help='Sanity check questions (can take awhile)' + ) + + argparser.add_argument( + '--debug', action='store_true', + help='Enable debug mode' + ) + + argparser.add_argument( + '--version', action='store_true', + help='Print version information' + ) + + return argparser.parse_args() + + +# ---------------------------------------------------------------------------- +def get_logger_config(debug=False): + if debug: + filename, level = 'logger-debug.yaml', 'DEBUG' + else: + filename, level = 'logger.yaml', 'INFO' + + config_dir = environ.get('XDG_CONFIG_HOME', '~/.config/') + config_file = path.join(path.expanduser(config_dir), APP_NAME, filename) + + default_config = { + 'version': 1, + 'formatters': { + 'standard': { + 'format': '%(asctime)s | %(levelname)-10s | %(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S', + }, + }, + 'handlers': { + 'default': { + 'level': level, + 'class': 'logging.StreamHandler', + 'formatter': 'standard', + 'stream': 'ext://sys.stdout', + }, + }, + 'loggers': { + '': { # configuration for serve.py + 'handlers': ['default'], + 'level': level, + }, + }, + } + default_config['loggers'].update({ + APP_NAME+'.'+module: { + 'handlers': ['default'], + 'level': level, + 'propagate': False, + } for module in ['learnapp', 'models', 'factory', 'questions', + 'knowledge', 'tools']}) + + return load_yaml(config_file, default=default_config) + + +# ---------------------------------------------------------------------------- +# Tornado web server +# ---------------------------------------------------------------------------- +def main(): + # --- Commandline argument parsing + arg = parse_cmdline_arguments() + + if arg.version: + print(f'{APP_NAME} - {APP_VERSION}\nPython {sys.version}') + sys.exit(0) + + # --- Setup logging + logger_config = get_logger_config(arg.debug) + logging.config.dictConfig(logger_config) + + try: + logging.config.dictConfig(logger_config) + except Exception: + print('An error ocurred while setting up the logging system.') + sys.exit(1) + + logging.info('====================== Start Logging ======================') + + # --- start application + logging.info('Starting App...') + try: + learnapp = LearnApp(arg.conffile, prefix=arg.prefix, db=arg.db, + check=arg.check) + except DatabaseUnusableError: + logging.critical('Failed to start application.') + print('--------------------------------------------------------------') + print('Could not find a usable database. Use one of the follwing ') + print('commands to initialize: ') + print(' ') + print(' initdb-aprendizations --admin # add admin ') + print(' initdb-aprendizations -a 86 "Max Smart" # add student ') + print(' initdb-aprendizations students.csv # add many students') + print('--------------------------------------------------------------') + sys.exit(1) + except Exception: + logging.critical('Failed to start application.') + sys.exit(1) + + # --- create web application + logging.info('Starting Web App (tornado)...') + try: + webapp = WebApplication(learnapp, debug=arg.debug) + except Exception: + logging.critical('Failed to start web application.') + sys.exit(1) + + # --- get SSL certificates + if 'XDG_DATA_HOME' in environ: + certs_dir = path.join(environ['XDG_DATA_HOME'], 'certs') + else: + certs_dir = path.expanduser('~/.local/share/certs') + + ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + try: + ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'), + path.join(certs_dir, 'privkey.pem')) + except FileNotFoundError: + logging.critical(f'SSL certificates missing in {certs_dir}') + print('--------------------------------------------------------------') + print('Certificates should be issued by a certificate authority (CA),') + print('such as https://letsencrypt.org, and then copied to: ') + print(' ') + print(f' {certs_dir:<62}') + print(' ') + print('For testing purposes a selfsigned certificate can be generated') + print('locally by running: ') + print(' ') + print(' openssl req -x509 -newkey rsa:4096 -keyout privkey.pem \\ ') + print(' -out cert.pem -days 365 -nodes ') + print(' ') + print('--------------------------------------------------------------') + sys.exit(1) + + # --- create webserver + try: + httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_ctx) + except ValueError: + logging.critical('Certificates cert.pem and privkey.pem not found') + sys.exit(1) + + try: + httpserver.listen(arg.port) + except OSError: + logging.critical(f'Cannot bind port {arg.port}. Already in use?') + sys.exit(1) + + logging.info(f'Listening on port {arg.port}.') + + # --- run webserver + signal.signal(signal.SIGINT, signal_handler) + logging.info('Webserver running. (Ctrl-C to stop)') + + try: + tornado.ioloop.IOLoop.current().start() # running... + except Exception: + logging.critical('Webserver stopped.') + tornado.ioloop.IOLoop.current().stop() + raise + + +# ---------------------------------------------------------------------------- +if __name__ == "__main__": + main() diff --git a/aprendizations/serve.py b/aprendizations/serve.py index 7e0601d..cd8a59c 100644 --- a/aprendizations/serve.py +++ b/aprendizations/serve.py @@ -1,28 +1,19 @@ -#!/usr/bin/env python3 # python standard library -import argparse import asyncio import base64 import functools import logging.config import mimetypes -from os import path, environ -import signal -import ssl -import sys +from os import path import uuid - # third party libraries -import tornado.ioloop -import tornado.httpserver import tornado.web from tornado.escape import to_unicode # this project -from .learnapp import LearnApp -from .tools import load_yaml, md_to_html +from .tools import md_to_html from . import APP_NAME @@ -187,7 +178,6 @@ class RootHandler(BaseHandler): # ---------------------------------------------------------------------------- # /topic/... # Start a given topic -# FIXME should not change state... # ---------------------------------------------------------------------------- class TopicHandler(BaseHandler): @tornado.web.authenticated @@ -198,12 +188,12 @@ class TopicHandler(BaseHandler): await self.learn.start_topic(uid, topic) except KeyError: self.redirect('/') - else: - self.render('topic.html', - appname=APP_NAME, - uid=uid, - name=self.learn.get_student_name(uid), - ) + + self.render('topic.html', + appname=APP_NAME, + uid=uid, + name=self.learn.get_student_name(uid), + ) # ---------------------------------------------------------------------------- @@ -218,16 +208,16 @@ class FileHandler(BaseHandler): content_type = mimetypes.guess_type(filename)[0] try: - f = open(filepath, 'rb') + with open(filepath, 'rb') as f: + data = f.read() except FileNotFoundError: logging.error(f'File not found: {filepath}') except PermissionError: logging.error(f'No permission: {filepath}') - except Exception as e: - raise e + except Exception: + logging.error(f'Error reading: {filepath}') + raise else: - data = f.read() - f.close() self.set_header("Content-Type", content_type) self.write(data) await self.flush() @@ -362,186 +352,3 @@ class QuestionHandler(BaseHandler): logging.error(f'Unknown action: {action}') self.write(response) - - -# ---------------------------------------------------------------------------- -# Signal handler to catch Ctrl-C and abort server -# ---------------------------------------------------------------------------- -def signal_handler(signal, frame): - r = input(' --> Stop webserver? (yes/no) ').lower() - if r == 'yes': - tornado.ioloop.IOLoop.current().stop() - logging.critical('Webserver stopped.') - sys.exit(0) - else: - logging.info('Abort canceled...') - - -# ---------------------------------------------------------------------------- -def parse_cmdline_arguments(): - argparser = argparse.ArgumentParser( - description='Server for online learning. Enrolled students and topics ' - 'have to be previously configured. Please read the documentation ' - 'included with this software before running the server.' - ) - - argparser.add_argument( - 'conffile', type=str, nargs='+', - help='Topics configuration file in YAML format.' - ) - - argparser.add_argument( - '--prefix', type=str, default='.', - help='Path where the topic directories can be found, e.g. ~/topics' - ) - - argparser.add_argument( - '--port', type=int, default=8443, - help='Port to be used by the HTTPS server, e.g. 8443' - ) - - argparser.add_argument( - '--db', type=str, default='students.db', - help='SQLite3 database file, e.g. students.db' - ) - - argparser.add_argument( - '--check', action='store_true', - help='Sanity check all questions' - ) - - argparser.add_argument( - '--debug', action='store_true', - help='Enable debug messages' - ) - - return argparser.parse_args() - - -# ---------------------------------------------------------------------------- -def get_logger_config(debug=False): - if debug: - filename = 'logger-debug.yaml' - level = 'DEBUG' - else: - filename = 'logger.yaml' - level = 'INFO' - - config_dir = environ.get('XDG_CONFIG_HOME', '~/.config/') - config_file = path.join(path.expanduser(config_dir), APP_NAME, filename) - - default_config = { - 'version': 1, - 'formatters': { - 'standard': { - 'format': '%(asctime)s %(name)-24s %(levelname)-10s - ' - '%(message)s', - 'datefmt': '%Y-%m-%d %H:%M:%S', - }, - }, - 'handlers': { - 'default': { - 'level': level, - 'class': 'logging.StreamHandler', - 'formatter': 'standard', - 'stream': 'ext://sys.stdout', - }, - }, - 'loggers': { - '': { # configuration for serve.py - 'handlers': ['default'], - 'level': level, - }, - }, - } - default_config['loggers'].update({ - APP_NAME+'.'+module: { - 'handlers': ['default'], - 'level': level, - 'propagate': False, - } for module in ['learnapp', 'models', 'factory', 'questions', - 'knowledge', 'tools']}) - - return load_yaml(config_file, default=default_config) - - -# ---------------------------------------------------------------------------- -# Tornado web server -# ---------------------------------------------------------------------------- -def main(): - # --- Commandline argument parsing - arg = parse_cmdline_arguments() - - # --- Setup logging - logger_config = get_logger_config(arg.debug) - logging.config.dictConfig(logger_config) - - try: - logging.config.dictConfig(logger_config) - except Exception: - print('An error ocurred while setting up the logging system.') - sys.exit(1) - - logging.info('====================== Start Logging ======================') - - # --- start application - logging.info('Starting App...') - try: - learnapp = LearnApp(arg.conffile, prefix=arg.prefix, db=arg.db, - check=arg.check) - except Exception: - logging.critical('Failed to start application.') - sys.exit(1) - - # --- create web application - logging.info('Starting Web App (tornado)...') - try: - webapp = WebApplication(learnapp, debug=arg.debug) - except Exception: - logging.critical('Failed to start web application.') - sys.exit(1) - - # --- get SSL certificates - if 'XDG_DATA_HOME' in environ: - certs_dir = path.join(environ['XDG_DATA_HOME'], 'certs') - else: - certs_dir = path.expanduser('~/.local/share/certs') - - ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - try: - ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'), - path.join(certs_dir, 'privkey.pem')) - except FileNotFoundError: - logging.critical(f'SSL certificates missing in {certs_dir}') - sys.exit(1) - - # --- create webserver - try: - httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_ctx) - except ValueError: - logging.critical('Certificates cert.pem and privkey.pem not found') - sys.exit(1) - - try: - httpserver.listen(arg.port) - except OSError: - logging.critical(f'Cannot bind port {arg.port}. Already in use?') - sys.exit(1) - - logging.info(f'Listening on port {arg.port}.') - - # --- run webserver - signal.signal(signal.SIGINT, signal_handler) - logging.info('Webserver running. (Ctrl-C to stop)') - - try: - tornado.ioloop.IOLoop.current().start() # running... - except Exception: - logging.critical('Webserver stopped.') - tornado.ioloop.IOLoop.current().stop() - raise - - -# ---------------------------------------------------------------------------- -if __name__ == "__main__": - main() diff --git a/aprendizations/student.py b/aprendizations/student.py new file mode 100644 index 0000000..3989467 --- /dev/null +++ b/aprendizations/student.py @@ -0,0 +1,236 @@ + +# python standard library +import random +from datetime import datetime +import logging + +# third party libraries +import networkx as nx + +# setup logger for this module +logger = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------------- +# kowledge state of each student....?? +# Contains: +# state - dict of topics with state of unlocked topics +# deps - access to dependency graph shared between students +# topic_sequence - list with the order of recommended topics +# ---------------------------------------------------------------------------- +class StudentState(object): + # ======================================================================= + # methods that update state + # ======================================================================= + def __init__(self, deps, factory, state={}): + self.deps = deps # shared dependency graph + self.factory = factory # question factory + self.state = state # {'topic': {'level': 0.5, 'date': datetime}, ...} + + self.update_topic_levels() # applies forgetting factor + self.unlock_topics() # whose dependencies have been completed + self.topic_sequence = self.recommend_topic_sequence() # ['ref1', ...] + self.current_topic = None + + # ------------------------------------------------------------------------ + # Updates the proficiency levels of the topics, with forgetting factor + # FIXME no dependencies are considered yet... + # ------------------------------------------------------------------------ + def update_topic_levels(self): + now = datetime.now() + for tref, s in self.state.items(): + dt = now - s['date'] + s['level'] *= 0.98 ** dt.days # forgetting factor + + # ------------------------------------------------------------------------ + # Unlock topics whose dependencies are satisfied (> min_level) + # ------------------------------------------------------------------------ + def unlock_topics(self): + for topic in self.deps.nodes(): + if topic not in self.state: # if locked + pred = self.deps.predecessors(topic) + min_level = self.deps.node[topic]['min_level'] + if all(d in self.state and self.state[d]['level'] > min_level + for d in pred): # all deps are greater than min_level + + self.state[topic] = { + 'level': 0.0, # unlocked + 'date': datetime.now() + } + logger.debug(f'[unlock_topics] Unlocked "{topic}".') + # else: # lock this topic if deps do not satisfy min_level + # del self.state[topic] + + # ------------------------------------------------------------------------ + # Start a new topic. + # questions: list of generated questions to do in the topic + # current_question: the current question to be presented + # ------------------------------------------------------------------------ + async def start_topic(self, topic): + logger.debug(f'[start_topic] topic "{topic}"') + + if self.current_topic == topic: + logger.info('Restarting current topic is not allowed.') + return + + # do not allow locked topics + if self.is_locked(topic): + logger.debug(f'[start_topic] topic "{topic}" is locked') + return + + # starting new topic + self.current_topic = topic + self.correct_answers = 0 + self.wrong_answers = 0 + + t = self.deps.node[topic] + k = t['choose'] + if t['shuffle_questions']: + questions = random.sample(t['questions'], k=k) + else: + questions = t['questions'][:k] + logger.debug(f'[start_topic] questions: {", ".join(questions)}') + + # synchronous + # self.questions = [self.factory[ref].generate() + # for ref in questions] + + # asynchronous: + self.questions = [await self.factory[ref].generate_async() + for ref in questions] + + n = len(self.questions) + logger.debug(f'[start_topic] generated {n} questions') + + # get first question + self.next_question() + + # ------------------------------------------------------------------------ + # The topic has finished and there are no more questions. + # The topic level is updated in state and unlocks are performed. + # The current topic is unchanged. + # ------------------------------------------------------------------------ + def finish_topic(self): + logger.debug(f'[finish_topic] current_topic {self.current_topic}') + + self.state[self.current_topic] = { + 'date': datetime.now(), + 'level': self.correct_answers / (self.correct_answers + + self.wrong_answers) + } + # self.current_topic = None + self.unlock_topics() + + # ------------------------------------------------------------------------ + # corrects current question with provided answer. + # implements the logic: + # - if answer ok, goes to next question + # - if wrong, counts number of tries. If exceeded, moves on. + # ------------------------------------------------------------------------ + async def check_answer(self, answer): + logger.debug('[check_answer]') + + q = self.current_question + q['answer'] = answer + q['finish_time'] = datetime.now() + await q.correct_async() + logger.debug(f'[check_answer] Grade {q["grade"]:.2} in {q["ref"]}') + + if q['grade'] > 0.999: + self.correct_answers += 1 + self.next_question() + action = 'right' + + else: + self.wrong_answers += 1 + self.current_question['tries'] -= 1 + + if self.current_question['tries'] > 0: + action = 'try_again' + else: + action = 'wrong' + if self.current_question['append_wrong']: + logger.debug('[check_answer] Wrong, append new instance') + self.questions.append(self.factory[q['ref']].generate()) + self.next_question() + + # returns corrected question (not new one) which might include comments + return q, action + + # ------------------------------------------------------------------------ + # Move to next question + # ------------------------------------------------------------------------ + def next_question(self): + try: + self.current_question = self.questions.pop(0) + except IndexError: + self.current_question = None + self.finish_topic() + else: + self.current_question['start_time'] = datetime.now() + default_maxtries = self.deps.nodes[self.current_topic]['max_tries'] + maxtries = self.current_question.get('max_tries', default_maxtries) + self.current_question['tries'] = maxtries + logger.debug(f'[next_question] "{self.current_question["ref"]}"') + + return self.current_question # question or None + + # ======================================================================== + # pure functions of the state (no side effects) + # ======================================================================== + + def topic_has_finished(self): + return self.current_question is None + + # ------------------------------------------------------------------------ + # compute recommended sequence of topics ['a', 'b', ...] + # ------------------------------------------------------------------------ + def recommend_topic_sequence(self, target=None): + tt = list(nx.topological_sort(self.deps)) + unlocked = [t for t in tt if t in self.state] + locked = [t for t in tt if t not in unlocked] + return unlocked + locked + + # ------------------------------------------------------------------------ + def get_current_question(self): + return self.current_question + + # ------------------------------------------------------------------------ + def get_current_topic(self): + return self.current_topic + + # ------------------------------------------------------------------------ + def is_locked(self, topic): + return topic not in self.state + + # ------------------------------------------------------------------------ + # Return list of {ref: 'xpto', name: 'long name', leve: 0.5} + # Levels are in the interval [0, 1] if unlocked or None if locked. + # Topics unlocked but not yet done have level 0.0. + # ------------------------------------------------------------------------ + def get_knowledge_state(self): + return [{ + 'ref': ref, + 'type': self.deps.nodes[ref]['type'], + 'name': self.deps.nodes[ref]['name'], + 'level': self.state[ref]['level'] if ref in self.state else None + } for ref in self.topic_sequence] + + # ------------------------------------------------------------------------ + def get_topic_progress(self): + return self.correct_answers / (1 + self.correct_answers + + len(self.questions)) + + # ------------------------------------------------------------------------ + def get_topic_level(self, topic): + return self.state[topic]['level'] + + # ------------------------------------------------------------------------ + def get_topic_date(self, topic): + return self.state[topic]['date'] + + # ------------------------------------------------------------------------ + # Recommends a topic to practice/learn from the state. + # ------------------------------------------------------------------------ + # def get_recommended_topic(self): # FIXME untested + # return min(self.state.items(), key=lambda x: x[1]['level'])[0] diff --git a/package-lock.json b/package-lock.json index de928cd..b60722b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,15 +2,233 @@ "requires": true, "lockfileVersion": 1, "dependencies": { + "@babel/code-frame": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz", + "integrity": "sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==", + "requires": { + "@babel/highlight": "^7.0.0" + } + }, + "@babel/core": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.5.4.tgz", + "integrity": "sha512-+DaeBEpYq6b2+ZmHx3tHspC+ZRflrvLqwfv8E3hNr5LVQoyBnL8RPKSBCg+rK2W2My9PWlujBiqd0ZPsR9Q6zQ==", + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/generator": "^7.5.0", + "@babel/helpers": "^7.5.4", + "@babel/parser": "^7.5.0", + "@babel/template": "^7.4.4", + "@babel/traverse": "^7.5.0", + "@babel/types": "^7.5.0", + "convert-source-map": "^1.1.0", + "debug": "^4.1.0", + "json5": "^2.1.0", + "lodash": "^4.17.11", + "resolve": "^1.3.2", + "semver": "^5.4.1", + "source-map": "^0.5.0" + } + }, + "@babel/generator": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.5.0.tgz", + "integrity": "sha512-1TTVrt7J9rcG5PMjvO7VEG3FrEoEJNHxumRq66GemPmzboLWtIjjcJgk8rokuAS7IiRSpgVSu5Vb9lc99iJkOA==", + "requires": { + "@babel/types": "^7.5.0", + "jsesc": "^2.5.1", + "lodash": "^4.17.11", + "source-map": "^0.5.0", + "trim-right": "^1.0.1" + } + }, + "@babel/helper-function-name": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz", + "integrity": "sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==", + "requires": { + "@babel/helper-get-function-arity": "^7.0.0", + "@babel/template": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz", + "integrity": "sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==", + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz", + "integrity": "sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q==", + "requires": { + "@babel/types": "^7.4.4" + } + }, + "@babel/helpers": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.5.4.tgz", + "integrity": "sha512-6LJ6xwUEJP51w0sIgKyfvFMJvIb9mWAfohJp0+m6eHJigkFdcH8duZ1sfhn0ltJRzwUIT/yqqhdSfRpCpL7oow==", + "requires": { + "@babel/template": "^7.4.4", + "@babel/traverse": "^7.5.0", + "@babel/types": "^7.5.0" + } + }, + "@babel/highlight": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz", + "integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==", + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.5.0.tgz", + "integrity": "sha512-I5nW8AhGpOXGCCNYGc+p7ExQIBxRFnS2fd/d862bNOKvmoEPjYPcfIjsfdy0ujagYOIYPczKgD9l3FsgTkAzKA==" + }, + "@babel/template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.4.4.tgz", + "integrity": "sha512-CiGzLN9KgAvgZsnivND7rkA+AeJ9JB0ciPOD4U59GKbQP2iQl+olF1l76kJOupqidozfZ32ghwBEJDhnk9MEcw==", + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.4.4", + "@babel/types": "^7.4.4" + } + }, + "@babel/traverse": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.5.0.tgz", + "integrity": "sha512-SnA9aLbyOCcnnbQEGwdfBggnc142h/rbqqsXcaATj2hZcegCl903pUD/lfpsNBlBSuWow/YDfRyJuWi2EPR5cg==", + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/generator": "^7.5.0", + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-split-export-declaration": "^7.4.4", + "@babel/parser": "^7.5.0", + "@babel/types": "^7.5.0", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.11" + } + }, + "@babel/types": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.5.0.tgz", + "integrity": "sha512-UFpDVqRABKsW01bvw7/wSUe56uy6RXM5+VJibVVAybDGxEW25jdwiFJEf7ASvSaC7sN7rbE/l3cLp2izav+CtQ==", + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.11", + "to-fast-properties": "^2.0.0" + } + }, "@fortawesome/fontawesome-free": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.8.1.tgz", - "integrity": "sha512-GJtx6e55qLEOy2gPOsok2lohjpdWNGrYGtQx0FFT/++K4SYx+Z8LlPHdQBaFzKEwH5IbBB4fNgb//uyZjgYXoA==" + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.9.0.tgz", + "integrity": "sha512-g795BBEzM/Hq2SYNPm/NQTIp3IWd4eXSH0ds87Na2jnrAUFX3wkyZAI4Gwj9DOaWMuz2/01i8oWI7P7T/XLkhg==" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } }, "codemirror": { - "version": "5.45.0", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.45.0.tgz", - "integrity": "sha512-c19j644usCE8gQaXa0jqn2B/HN9MnB2u6qPIrrhrMkB+QAP42y8G4QnTwuwbVSoUS1jEl7JU9HZMGhCDL0nsAw==" + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.48.0.tgz", + "integrity": "sha512-3Ter+tYtRlTNtxtYdYNPxGxBL/b3cMcvPdPm70gvmcOO2Rauv/fUEewWa0tT596Hosv6ea2mtpx28OXBy1mQCg==" + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "convert-source-map": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.6.0.tgz", + "integrity": "sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A==", + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=" + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" + }, + "json5": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.0.tgz", + "integrity": "sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ==", + "requires": { + "minimist": "^1.2.0" + } + }, + "lodash": { + "version": "4.17.14", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.14.tgz", + "integrity": "sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==" }, "mathjax": { "version": "2.7.5", @@ -18,9 +236,68 @@ "integrity": "sha512-OzsJNitEHAJB3y4IIlPCAvS0yoXwYjlo2Y4kmm9KQzyIBZt2d8yKRalby3uTRNN4fZQiGL2iMXjpdP1u2Rq2DQ==" }, "mdbootstrap": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.7.6.tgz", - "integrity": "sha512-b5Dgg/DQon8E3F/oIKJsCiFN1E5kgQBlndQ8vNzDnGcQXo2ruVKZA6Z3cvIJrv2IKS1kPBWOUwDedJiCLoAZxA==" + "version": "4.8.5", + "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.8.5.tgz", + "integrity": "sha512-e4YGrdyb5dUlkLu0OdH5UKi6ZZEAC1YrfE8T8oWY+GWCbLjr1C0sI5ZEYAZrN8VI6acKN5tboq6lbK/8nWXG6g==", + "requires": { + "@babel/core": "^7.3.3" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" + }, + "resolve": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.11.1.tgz", + "integrity": "sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw==", + "requires": { + "path-parse": "^1.0.6" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==" + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=" + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" + }, + "trim-right": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=" } } } diff --git a/package.json b/package.json index f20381a..38032c0 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,10 @@ "description": "Javascript libraries required to run the server", "email": "mjsb@uevora.pt", "dependencies": { - "@fortawesome/fontawesome-free": "^5.8.1", - "codemirror": "^5.45.0", + "@fortawesome/fontawesome-free": "^5.9.0", + "codemirror": "^5.48.0", "mathjax": "^2.7.5", - "mdbootstrap": "^4.7.6" + "mdbootstrap": "^4.8.5" }, "private": true } diff --git a/setup.py b/setup.py index 3e0a043..27480d7 100644 --- a/setup.py +++ b/setup.py @@ -25,9 +25,9 @@ setup( ], entry_points={ 'console_scripts': [ - 'aprendizations = aprendizations.serve:main', + 'aprendizations = aprendizations.main:main', 'initdb-aprendizations = aprendizations.initdb:main', - 'redirect = aprendizations.redirect:main', + # 'redirect = aprendizations.redirect:main', ] }, classifiers=[ -- libgit2 0.21.2