diff --git a/.buildkite/build_pex.sh b/.buildkite/build_pex.sh new file mode 100755 index 00000000000..68ee1067f51 --- /dev/null +++ b/.buildkite/build_pex.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +pip install pex # pex is really the only thing we need here. +buildkite-agent artifact download 'dist/*.whl' dist/ +make pex +buildkite-agent artifact upload 'dist/*.pex' diff --git a/.buildkite/build_whl.sh b/.buildkite/build_whl.sh new file mode 100755 index 00000000000..244e3500d9d --- /dev/null +++ b/.buildkite/build_whl.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +make dockerenvdist +buildkite-agent artifact upload 'dist/*.whl' +buildkite-agent artifact upload 'dist/*.zip' +buildkite-agent artifact upload 'dist/*.tar.gz' diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml new file mode 100644 index 00000000000..3e8f4be38ca --- /dev/null +++ b/.buildkite/pipeline.yml @@ -0,0 +1,13 @@ +steps: + - label: Build the docker environment + command: make dockerenvbuild + + - wait + + - label: Build the python packages + command: mkdir -p dist && .buildkite/build_whl.sh + + - wait + + - label: Build the pex file + command: mkdir -p dist && .buildkite/build_pex.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..3659f1ad7dc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +node_modules/* +dist/* diff --git a/.gitignore b/.gitignore index 8d25e7c4ebb..23a1dae0088 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,6 @@ Icon #ignore added files with DS_Store .DS_Store /crowdin.yaml + +# ignore the VERSION file we use to track the version for whl files +VERSION \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 8f3e5969151..a3eb47281fc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ services: sudo: false cache: + yarn: true directories: - node_modules - $HOME/.cache/pip @@ -78,6 +79,8 @@ before_install: - . $HOME/.nvm/nvm.sh - nvm install $TRAVIS_NODE_VERSION - nvm use $TRAVIS_NODE_VERSION + - curl -o- -L https://yarnpkg.com/install.sh | bash + - export PATH=$HOME/.yarn/bin:$PATH before_script: - psql -c 'create database travis_ci_test;' -U postgres diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 812158bb4a6..ac9c9812537 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,39 +3,98 @@ Release Notes ============= -All notable changes to this project will be documented in this file. -This project adheres to `Semantic Versioning `_. - -*Don’t let your friends dump git logs into CHANGELOGs™* - -`http://keepachangelog.com/ `_ - -[0.0.1] - UNRELEASED --------------------- - -.. note :: - Please add new stuff chronologically, we should try chunking up the - list into components at some point - -Added -^^^^^ - - - Begin development of core auth app. - - Add core user types (BaseUser, FacilityUser, DeviceOwner) - - Add Collections and Roles, implemented using a special tree structure for efficient querying - - Add authentication & authorization backends - - Implement permissions for FacilityUsers by checking hierarchy relationships - - Adds pipelining and integration for building frontend assets with webpack and dynamically serving them in Django. - - Updates to - Users, Collections, and Roles, mostly to account for multiple facilities in one database - - Plugin API with hooks, documented and implemented - - Adds Django JS Reverse and loads into kolibriGlobal object - - Creates kolibri.auth API endpoint filtering - - Adds management plugin for managing learners, classrooms, and groups - - Automatic inclusion of requirements in a static build - - Linting of client-side assets during webpack build process +0.2.0 +----- + + - HTML5 app renderer + - Vue2 refactor + - Webpack build process compatible with plugins outside the kolibri directory + - ES2015 transpilation now Bublé instead of Babel + - Pin dependencies with Yarn + - Improved docs + - Log filtering based on users and collections + - Wrap all user-facing strings for I18N + - Allow plugins to override core components + - Client heartbeat for usage tracking + - Versioning based on git tags + - User sign-up and profile-editing functionality + - New log-in page + - Update primary layout and navigation + - Begin using some keen-ui components + - Replace jeet grids with pure.css grids + - Add JS-based 'responsive mixin' as alternative to media queries + - Rename 'Learn/Explore' to 'Recommended/Topics' + - Temporarily remove 'search' functionality + - Refactor content renderer API interface + - Add authentication for tasks API + + +0.1.1 +----- + + - Coach reports + - Perseus exercise renderer + - Exercise mastery visualization + - SVG inlining -Changed -^^^^^^^ - - Webpack build process now compatible with plugins housed outside the kolibri directory. - - ES2015 transpilation now uses Bublé instead of Babel. +0.1.0 - MVP +----------- + + - Content downloading + - Documentation updates + - Setup wizard plugin + - Channel switching + - Session state and login widget + - Content import/export + - Task management + - User management UI + - Learn app styling changes + - Tab focus highlights + - A11Y updates + - Modal popups + - Channel switching bug fixes + - I18N string extraction + - Content interaction logging + - Drive enumeration + - Usage data export + - Fuzzy searching + - Make modals accessible + - Loading 'spinner' + - Resource layer smart cache busting + - Client-side router bug fixes + - Make plugins more self-contained + - Case-insensitive usernames + - Endpoint indexing into zip files + - Asset bundling performance improvements + - Conditional (cancelable) JS promises + - Improved documentation + + +0.0.1 - MMVP +------------ + + - Users, Collections, and Roles + - Authentication, authorization, permissions + - Webpack build pipeline, including linting + - Python plugin API with hooks + - Django JS Reverse with urls representation in kolibriGlobal object + - Automatic inclusion of requirements in a static build + - Content endpoints + - Learn app and content browsing + - Vue.js integration + - User management API + - Initial VueIntl integration + - Video, Document, and MP3 content renderers + - Content search + - Client-side routing + - Content recommendation endpoints + - API resource retrieval and caching + - Support for multiple content DBs + - Stylus/Jeet-based grids + - Vuex integration + - Cherrypy server + - A11Y updates + - Responsiveness updates + - Javascript logging module + - Page titles diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..3d0b2545123 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM ubuntu:xenial + +# install latest python and nodejs +RUN apt-get -y update +RUN apt-get install -y software-properties-common curl +RUN add-apt-repository ppa:voronov84/andreyv +RUN curl -sL https://deb.nodesource.com/setup_6.x | bash - + +# add yarn ppa +RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - +RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list + +RUN apt-get -y update +RUN apt-get install -y python2.7 python3.6 python-pip git nodejs yarn gettext python-sphinx +COPY . /kolibri + +VOLUME /kolibridist/ # for mounting the whl files into other docker containers +CMD cd /kolibri && pip install -r requirements/dev.txt && pip install -r requirements/build.txt && yarn install && make dist && cp /kolibri/dist/* /kolibridist/ + diff --git a/MANIFEST.in b/MANIFEST.in index 1c18be2f9e3..3bde4fabfeb 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,7 @@ include CHANGELOG.rst include LICENSE include README.rst include requirements.txt +include kolibri/VERSION recursive-include requirements *.txt recursive-include kolibri/locale *.mo recursive-include kolibri/auth * @@ -16,11 +17,11 @@ recursive-include kolibri/plugins * recursive-include kolibri/test * recursive-include kolibri/utils * recursive-include kolibri/tasks * -recursive-exclude kolibri/* *pyc recursive-include kolibri/*/static *.* recursive-include kolibri/*/build/ *.json recursive-include kolibri/plugins/*/build *.json +recursive-exclude kolibri/* *pyc recursive-exclude kolibri/core/assets * recursive-exclude kolibri/plugins/*/assets * recursive-exclude kolibri/plugins/*/node_modules * diff --git a/Makefile b/Makefile index 6e33f3d1f35..69e3c08037d 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ help: clean: clean-build clean-pyc clean-docs clean-static clean-static: - npm run clean + yarn run clean clean-build: rm -fr build/ @@ -24,7 +24,8 @@ clean-build: rm -fr *.egg-info rm -fr .eggs rm -fr .cache - git clean -X -d -f kolibri/dist + rm -r kolibri/dist/* || true # remove everything + git checkout -- kolibri/dist # restore __init__.py clean-pyc: find . -name '*.pyc' -exec rm -f {} + @@ -48,7 +49,7 @@ test-all: tox assets: staticdeps - npm run build + yarn run build coverage: coverage run --source kolibri setup.py test @@ -63,15 +64,21 @@ release: clean assets python setup.py bdist_wheel upload staticdeps: clean - DISABLE_SQLALCHEMY_CEXT=1 pip install -t kolibri/dist/ -r requirements.txt + pip install -t kolibri/dist -r requirements.txt + rm -r kolibri/dist/*.dist-info # pip installs from PyPI will complain if we have more than one dist-info directory. -dist: staticdeps assets compilemessages +writeversion: + git describe --tags > kolibri/VERSION + +dist: writeversion staticdeps assets compilemessages pip install -r requirements/build.txt python setup.py sdist --format=gztar,zip --static > /dev/null # silence the sdist output! Too noisy! python setup.py bdist_wheel --static - pex . --disable-cache -o dist/`python setup.py --fullname`.pex -m kolibri --python-shebang=/usr/bin/python ls -l dist +pex: + ls dist/*.whl | while read whlfile; do pex $$whlfile --disable-cache -o dist/kolibri-`unzip -p $$whlfile kolibri/VERSION`.pex -m kolibri --python-shebang=/usr/bin/python; done + makedocsmessages: make -C docs/ gettext cd docs && sphinx-intl update -p _build/locale -l en @@ -95,3 +102,31 @@ downloadmessages: distributefrontendmessages: python ./utils/distribute_frontend_messages.py + +dockerenvclean: + docker container prune -f + docker image prune -f + +dockerenvbuild: writeversion + docker image build -t learningequality/kolibri:$$(cat kolibri/VERSION) -t learningequality/kolibri:latest . + +dockerenvdist: writeversion + docker run -v $$PWD/dist:/kolibridist learningequality/kolibri:$$(cat kolibri/VERSION) + +BUMPVERSION_CMD = bumpversion --current-version `python -m kolibri --version` $(PART_INCREMENT) --allow-dirty -m "new version" --no-commit --list + +minor_increment: + $(eval PART_INCREMENT = minor) + $(BUMPVERSION_CMD) + +patch_increment: + $(eval PART_INCREMENT = patch) + $(BUMPVERSION_CMD) + +release_phase_increment: + $(eval PART_INCREMENT = release_phase) + $(BUMPVERSION_CMD) + +release_number_increment: + $(eval PART_INCREMENT = release_number) + $(BUMPVERSION_CMD) diff --git a/README.rst b/README.rst index 09a5e7ea3e6..68321339599 100644 --- a/README.rst +++ b/README.rst @@ -40,6 +40,9 @@ Kolibri is under active development and is not yet ready to be used. In the mean How can I contribute? --------------------- - * `Documentation `_ is available online, and in the ``docs/`` directory. - * Mailing list: `Google groups `_. - * IRC: #kolibri on Freenode +.. warning:: + We welcome new contributors but since **Kolibri** is still in development, the API is not yet completely ready to integrate external plugins. Please start by: + +* Reading our `Developer Documentation `_ available online, and in the ``docs/`` directory. +* Contacting us on the Mailing list: `Google groups `_. +* or via IRC: #kolibri on Freenode. diff --git a/docs/dev/conventions.rst b/docs/dev/conventions.rst index 54a6902f5d7..47271b764d2 100644 --- a/docs/dev/conventions.rst +++ b/docs/dev/conventions.rst @@ -48,7 +48,7 @@ Note that the top-level tags of `Vue.js components `_. +Dependencies are tracked using ``yarn`` - `see the docs here `_. -We distinguish development dependencies from runtime dependencies, and these should be installed as such using ``npm install --save-dev [dep]`` or ``npm install --save [dep]``, respectively. Then you'll need to run ``npm shrinkwrap``. Your new dependency should now be recorded in *package.json*, and all of its dependencies should be recorded in *npm-shrinkwrap.json*. +We distinguish development dependencies from runtime dependencies, and these should be installed as such using ``yarn add --dev [dep]`` or ``yarn add [dep]``, respectively. Your new dependency should now be recorded in *package.json*, and all of its dependencies should be recorded in *yarn.lock*. -Note that we currently don't have a way of mapping dependencies to plugins - dependencies are installed globally. +Individual plugins can also have their own package.json and yarn.lock for their own dependencies. Running ``yarn install`` will also install all the dependencies for each activated plugin (inside a node_modules folder inside the plugin itself). These dependencies will only be available to that plugin at build time. Dependencies for individual plugins should be added from within the root directory of that particular plugin. -To assist in tracking the source of bloat in our codebase, the command ``npm run bundle-stats`` is available to give a full readout of the size that uglified packages take up in the final Javascript code. - -Individual plugins can also have their own package.json for their own dependencies. Running ``npm install`` will also install all the dependencies for each activated plugin (inside a node_modules folder inside the plugin itself). These dependencies will only be available to that plugin at build time. +To assist in tracking the source of bloat in our codebase, the command ``yarn run bundle-stats`` is available to give a full readout of the size that uglified packages take up in the final Javascript code. In addition, a plugin can have its own webpack.config.js for plugin specific webpack configuration (loaders, plugins, etc.). These options will be merged with the base options using ``webpack-merge``. diff --git a/docs/dev/getting_started.rst b/docs/dev/getting_started.rst index b74fee87019..efb615aadcf 100644 --- a/docs/dev/getting_started.rst +++ b/docs/dev/getting_started.rst @@ -1,107 +1,166 @@ Getting started =============== -Basic Setup -------------- +First of all, thank you for your interest in contributing to Kolibri! The project was founded by volunteers dedicated to helping make educational materials more accessible to those in need, and every contribution makes a difference. The instructions below should get you up and running the code in no time! -This is how we typically set up a development environment. +Setting up Kolibri for development +---------------------------------- -Note that most of the steps that follow require entering commands into your terminal, so you should be comfortable with that. +Most of the steps below require entering commands into your Terminal (Linux, Mac) or command prompt (``cmd.exe`` on Windows) that you will learn how to use and become more comfortable with. + +.. tip:: + In case you run into any problems during these steps, searching online is usually the fastest way out: whatever error you are seeing, chances are good that somebody alredy had it in the past and posted a solution somewhere... ;) + +Git & GitHub +~~~~~~~~~~~~ + +#. Install and set-up `Git `_ on your computer. Try this `tutorial `_ if you need more practice with Git! +#. `Sign up and configure your GitHub account `_ if you don't have one already. +#. `Fork the main Kolibri repository `_. This will make it easier to `submit pull requests `_. Read more details `about forking `_ from GitHub. Install Environment Dependencies ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -You'll need to install the following dependencies: +#. Install `Python `_ if you are on Windows, on Linux and OSX Python is preinstalled (recommended versions 2.7+ or 3.4+). +#. Install `pip `_ package installer. +#. Install `Node `_ (recommended version 4+). +#. Install Yarn according the `instructions specific for your OS `_. -- Python (including pip) - recommended version 2.7+ or 3.4+ -- Node.js - recommended version 4+ -- git + .. note:: + * On Ubuntu install Node.js via `nvm `_ to avoid build issues. + * On a Mac, you may want to consider using the `Homebrew `_ package manager. -The process for installing these depends on your operating system. +Ready for the fun part in the Terminal? Here we go! -.. note:: - - On Ubuntu, it is recommended to install Node.js via `nvm `_ to avoid build issues. - - On a Mac, you may want to consider using the `Homebrew `_ package manager. +Checking out the code +~~~~~~~~~~~~~~~~~~~~~ +#. Make sure you `registered your SSH keys on GitHub `_. +#. **Clone** your Kolibri fork to your local computer. In the following commands replace ``$USERNAME`` with your own GitHub username: + .. code-block:: bash -Clone the Repository -~~~~~~~~~~~~~~~~~~~~ + # using SSH + git clone git@github.com:$USERNAME/kolibri.git + # using HTTPS + git clone https://github.com/$USERNAME/kolibri.git -First clone the repo: +#. Enable syncing your local repository with **upstream**, which refers to the Kolibri source from where you cloned your fork. That way you can keep it updated with the changes from the rest of Kolibri team contributors: -.. code-block:: bash + .. code-block:: bash + + cd kolibri # Change into the newly cloned directory + git remote add upstream git@github.com:learningequality/kolibri.git # Add the upstream + git fetch # Check if there are changes upstream + git checkout develop + +.. warning:: + ``develop`` is the active development branch - do not target the ``master`` branch. - git clone git@github.com:learningequality/kolibri.git -Then, ``cd`` into the new directory: +Virtual environment +~~~~~~~~~~~~~~~~~~~ + +It is best practice to use `Python virtual environment `_ to isolate the dependencies of your Python projects from each other. This also allows you to avoid using ``sudo`` with ``pip``, which is not recommended. + +You can learn more about using `virtualenv `_, or follow these basic instructions: + +Initial setup, performed once: .. code-block:: bash - cd kolibri + $ sudo pip install virtualenv # install virtualenv globally + $ mkdir ~/.venvs # create a common directory for multiple virtual environments + $ virtualenv ~/.venvs/kolibri # create a new virtualenv for Kolibri dependencies .. note:: - If you plan on contributing code, you may want to `fork our github repo `_ and clone from your repo, rather than cloning from the learningequality repo. That will make it easier to `submit pull requests `_. + We create the virtualenv `outside` of the Kolibri project folder. You can choose another location than ``~/.venvs/kolibri`` if desired. + +To activate the virtualenv in a standard Bash shell: + +.. code-block:: bash + + $ source ~/.venvs/kolibri/bin/activate # activate the venv + +Now, any commands run with ``pip`` will target your virtualenv rather than the global Python installation. + +To deactivate the virtualenv, run the command below. Note, you'll want to leave it activated for the remainder of project setup! + +.. code-block:: bash + + $ deactivate + + +.. tip:: + + * Users of Windows and other shells such as Fish should read the `guide `_ for instructions on activating. + * If you set the ``PIP_REQUIRE_VIRTUALENV`` environment variable to ``true``, pip will only install packages when a virtualenv is active. This can help prevent mistakes. + * Bash users might also consider using `virtualenvwrapper `_, which simplifies the process somewhat. + Install Project Dependencies ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Install project-specific development dependencies. - .. note:: - It is considered best-practice to use a `Python virtual environment `_ to isolate your Python dependencies during development. You may also want to consider using `virtualenvwrapper `_. + Make sure your virtualenv is active! - If you're *not* using a Python virtual environment, you may need to use ``sudo`` with the ``pip install`` commands below. +To install Kolibri project-specific dependencies make sure you're in the ``kolibri`` directory and run: - (``npm install`` automatically isolates project dependencies and works without ``sudo``.) + .. code-block:: bash + # Python requirements + (kolibri)$ pip install -r requirements.txt + (kolibri)$ pip install -r requirements/dev.txt -Run the following commands: - -.. code-block:: bash + # Kolibri Python package in 'editable' mode, so your installation points to your git checkout: + (kolibri)$ pip install -e . - # Python requirements - pip install -r requirements.txt - pip install -r requirements/dev.txt + # Javascript dependencies + (kolibri)$ yarn install - # Node.js dependencies - npm install - # Kolibri Python package in 'editable' mode - pip install -e . +Running Kolibri server +---------------------- -Running the Development Server -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Development server +~~~~~~~~~~~~~~~~~~ -To start up the development server and build the client-side dependencies, the following commands are used: +To start up the development server and build the client-side dependencies, use the following commands: Linux and Mac: .. code-block:: bash - kolibri manage devserver --debug -- --webpack --qcluster + (kolibri)$ kolibri manage devserver --debug -- --webpack --qcluster Windows: .. code-block:: bash - kolibri manage devserver --debug -- --webpack + (kolibri)$ kolibri manage devserver --debug -- --webpack Wait for the build process to complete. This takes a while the first time, will complete faster as you make edits and the assets are automatically re-built. Now you should be able to access the server at ``http://127.0.0.1:8000/``. -.. note:: +.. tip:: + + If you need to make the development server available through the LAN, you must leave out the ``--webpack`` flag, and use the following command: + + .. code-block:: bash - Most functionality works fine in the devserver, but some issues exist with streaming media such as videos and audio. + (kolibri)$ yarn run build + (kolibri)$ kolibri manage devserver --debug -- 0.0.0.0:8000 --qcluster + + Now you can simply use your server's IP from another device in the local network through the port 8000, for example ``http://192.168.1.38:8000/``. Running the Production Server @@ -111,12 +170,53 @@ In production, content is served through CherryPy. Static assets must be pre-bui .. code-block:: bash - npm run build + yarn run build kolibri start Now you should be able to access the server at ``http://127.0.0.1:8080/``. +Contributing code to Kolibri +---------------------------- + +* Once you've toyed around with things, read through the rest of the :doc:`index`, especially topics in :ref:`architecture` and :ref:`themes` to understand more about the Kolibri structure. +* When you're up to speed with that, you're probably itching to make some contributions! Head over to the `issues page on GitHub `_ and take a look at the current project priorities. Try filtering by milestone. If you find a bug in your testing, please `submit your own issue `_ +* Once you've identified an issue and you're ready to start hacking on a solution, get ready to :ref:`pull_request`! + +Branching and Release Process +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``develop`` branch is reserved for active development. When we get close to releasing a new stable version/release of Kolibri, we generally fork the develop branch into a new branch (like ``release-0.1.x``). If you're working on an issue tagged for example with the ``release-0.1.x`` milestone, then you should target changes to that branch. Changes to those branches will later be pulled into ``develop`` again. If you're not sure which branch to target, ask the dev team! + + +.. note:: + At a high level, we follow the 'Gitflow' model. Some helpful references: + * http://nvie.com/posts/a-successful-git-branching-model/ + * https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow/ + +.. _pull_request: + +Submit Pull Requests +~~~~~~~~~~~~~~~~~~~~~ + +The most common situation is working off of ``develop`` branch so we'll take it as an example: + +.. code-block:: bash + + $ git checkout upstream/develop + $ git checkout -b name-of-your-bugfix-or-feature + +After making changes to the code, commit and push them to a branch on your fork: + +.. code-block:: bash + + $ git add -A # Add all changed and new files to the commit + $ git commit -m "Write here the commit message" + $ git push origin name-of-your-bugfix-or-feature + +Go to `Kolibri GitHub page `_, and if you are logged-in you will see the link to compare your branch and and create the new pull request. **Please fill in all the aplicable sections in the PR template and DELETE unecessary headings**. Another member of the team will review your code, and either ask for updates on your part or merge your PR to Kolibri codebase. Until the PR is merged you can push new commits to your branch and add updates to it. + + Additional Recommended Setup ---------------------------- @@ -205,13 +305,13 @@ Kolibri comes with a Javascript test suite based on ``mocha``. To run all tests: .. code-block:: bash - npm test + yarn test This includes tests of the bundling functions that are used in creating front end assets. To do continuous unit testing for code, and jshint running: .. code-block:: bash - npm run test-karma:watch + yarn run test-karma:watch Alternatively, this can be run as a subprocess in the development server with the following flag: @@ -240,14 +340,15 @@ First, install some additional dependencies related to building documentation ou .. code-block:: bash pip install -r requirements/docs.txt + pip install -r requirements/build.txt -To make changes to documentation, make an edit and then run: +To make changes to documentation, edit the ``rst`` files in the ``kolibri/docs`` directory and then run: .. code-block:: bash make docs -You can also ``cd`` into the docs directory and run the auto-build for faster editing: +You can also run the auto-build for faster editing from the ``docs`` directory: .. code-block:: bash @@ -255,15 +356,6 @@ You can also ``cd`` into the docs directory and run the auto-build for faster ed sphinx-autobuild . _build -Branching and Release Process -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -At a high level, we follow the 'Gitflow' model. Some helpful references: - - * http://nvie.com/posts/a-successful-git-branching-model/ - * https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow/ - - Manual Testing ~~~~~~~~~~~~~~ @@ -276,5 +368,3 @@ All changes should be thoroughly tested and vetted before being merged in. Our p * Consistency For more information, see the next section on :doc:`manual_testing`. - - diff --git a/docs/dev/index.rst b/docs/dev/index.rst index d9cd27b9a07..f122b016cb7 100644 --- a/docs/dev/index.rst +++ b/docs/dev/index.rst @@ -7,6 +7,7 @@ Developer Guide getting_started manual_testing +.. _architecture: Architecture ------------ @@ -22,6 +23,8 @@ Architecture frontend i18n +.. _themes: + Themes ------ diff --git a/docs/dev/plugins.rst b/docs/dev/plugins.rst index b53e70e31f3..28baec4350c 100644 --- a/docs/dev/plugins.rst +++ b/docs/dev/plugins.rst @@ -9,7 +9,7 @@ to developing plugins. Enabling and disabling plugins ------------------------------ -Non-core plugins can be enabled or disabled using the ``kolibri plugin`` command. See :doc:`../cli`. +Non-core plugins can be enabled or disabled using the ``kolibri plugin`` command. See :doc:`../user/cli`. .. automodule:: kolibri.plugins.registry diff --git a/docs/dev/stack.rst b/docs/dev/stack.rst index 3d8c382daa7..71d1b07a3a1 100644 --- a/docs/dev/stack.rst +++ b/docs/dev/stack.rst @@ -119,6 +119,6 @@ We use a number of mechanisms to help encourage code quality and consistency. Th Helper Scripts --------------- -*TODO: introduce stack (kolibri command, setup.py, makefiles, npm commands, sphinx auto-build, etc)* +*TODO: introduce stack (kolibri command, setup.py, makefiles, yarn commands, sphinx auto-build, etc)* diff --git a/docs/user/admin.rst b/docs/user/admin.rst index e2668daefb6..60ca9b6136d 100644 --- a/docs/user/admin.rst +++ b/docs/user/admin.rst @@ -66,14 +66,20 @@ Kolibri users can have different roles with respective access to features: * **Learners** can: * View content and have their progress tracked +* **Coaches** can: + + * View content and have their progress tracked + * View *Coach Reports* to track progress of other users and usage stats for individual exercises * **Admins** can: - * View content + * View content and have their progress tracked + * View *Coach Reports* to track progress of other users and usage stats for individual exercises * Create/Edit/Delete other **Admins** and **Learners** * Export *Detail* and *Summary* logs usage data * **Device Owners** can: * View content + * View *Coach Reports* to track progress of other users and usage stats for individual exercises * Create/Edit/Delete other **Admins** and **Learners** * Export *Detail* and *Summary* logs usage data * Import/Export content @@ -90,7 +96,7 @@ To create a new user account, follow these steps. #. Click **Add New** button. #. Fill in the required information (name, username, password). -#. Select user profile (*Admin* or *Learner*). +#. Select user profile (*Admin*, *Coach* or *Learner*). #. Click **Create Account** to add the new user. .. image:: img/add_new_account.png @@ -234,4 +240,4 @@ Once you register on our forums, please read the the first two pinned topics (*W You can add the new topic with the **+ New Topic** button on the right. Make sure to select the **Kolibri** category in the **Create a New Topic** window so it’s easier to classify and respond to. .. image:: img/community_forums.png - :alt: add new topic on community forums \ No newline at end of file + :alt: add new topic on community forums diff --git a/docs/user/img/add_new_account.png b/docs/user/img/add_new_account.png index f8594d3c495..36770613c68 100644 Binary files a/docs/user/img/add_new_account.png and b/docs/user/img/add_new_account.png differ diff --git a/docs/user/img/edit_account_info.png b/docs/user/img/edit_account_info.png index 48f63b1a768..67b9d06842f 100644 Binary files a/docs/user/img/edit_account_info.png and b/docs/user/img/edit_account_info.png differ diff --git a/docs/user/img/manage_users.png b/docs/user/img/manage_users.png index 14ff9d96f93..536709acd73 100644 Binary files a/docs/user/img/manage_users.png and b/docs/user/img/manage_users.png differ diff --git a/docs/user/img/select_users.png b/docs/user/img/select_users.png index 0a06287185d..c71175e0c8c 100644 Binary files a/docs/user/img/select_users.png and b/docs/user/img/select_users.png differ diff --git a/frontend_build/src/apiSpecExportTools.js b/frontend_build/src/apiSpecExportTools.js index 43a59a84192..2f05e173174 100644 --- a/frontend_build/src/apiSpecExportTools.js +++ b/frontend_build/src/apiSpecExportTools.js @@ -10,15 +10,25 @@ var path = require("path"); // Find the API specification file relative to this file. var specFilePath = path.resolve(path.join(__dirname, '../../kolibri/core/assets/src/core-app/apiSpec.js')) -// Read the spec file and do a regex replace to change all instances of 'require('...')' -// to just be the string of the require path. -// Our strict linting rules should ensure that this regex suffices. -var apiSpecFile = fs.readFileSync(specFilePath, 'utf-8').replace(/require\(('\S+')\)/g, '$1'); +function specModule(filePath) { + var rootPath = path.dirname(filePath); + function newPath(match, p1) { + return "'" + path.join(rootPath, p1) + "'"; + } -// Invoke the module constructor to compile a module from this altered representation. -var Module = module.constructor; -var m = new Module(specFilePath, module.parent); -m._compile(apiSpecFile, specFilePath); + // Read the spec file and do a regex replace to change all instances of 'require('...')' + // to just be the string of the require path. + // Our strict linting rules should ensure that this regex suffices. + var apiSpecFile = fs.readFileSync(filePath, 'utf-8').replace(/require\('(\S+)'\)/g, newPath); + + // Invoke the module constructor to compile a module from this altered representation. + var Module = module.constructor; + var mod = new Module(filePath, module.parent); + mod._compile(apiSpecFile, filePath); + return mod; +} + +var m = specModule(specFilePath); // Tada! The apiSpec object is now exported without doing any of the internal requires. var apiSpec = m.exports.apiSpec; @@ -45,7 +55,7 @@ function coreExternals(kolibri_name) { // the top namespace, as, logically, that would overwrite the global object. if (pathArray.length > 1 && obj.module) { // Check if this is a global import (i.e. from node_modules) - if (obj.module.indexOf('.') !== 0) { + if (!obj.module.startsWith('.')) { externalsObj[obj.module] = pathArray.join('.'); } externalsObj[requireName(pathArray)] = pathArray.join('.'); @@ -55,11 +65,14 @@ function coreExternals(kolibri_name) { return externalsObj; } -function coreAliases() { +function coreAliases(localAPISpec) { /* * Function for creating a hash of aliases for modules that are exposed on the core kolibri object. */ - var aliasesObj = {}; + var aliasesObj = { + kolibri_module: path.resolve(__dirname, '../../kolibri/core/assets/src/kolibri_module'), + content_renderer_module: path.resolve(__dirname, '../../kolibri/core/assets/src/content_renderer_module'), + }; function recurseObjectKeysAndAlias (obj, pathArray) { Object.keys(obj).forEach(function (key) { if (keys.indexOf(key) === -1 && obj[key] && typeof obj[key] === 'object') { @@ -70,12 +83,19 @@ function coreAliases() { // the top namespace, as, logically, that would overwrite the global object. // We only want to include modules that are using relative imports, so as to exclude // modules that are already in node_modules. - if (pathArray.length > 1 && obj.module && obj.module.indexOf('.') === 0) { + if (pathArray.length > 1 && obj.module && obj.module.startsWith('.')) { // Map from the requireName to a resolved path (relative to the apiSpecFile) to the module in question. aliasesObj[requireName(pathArray)] = path.resolve(path.join(path.dirname(specFilePath), obj.module)); + } else if (pathArray.length > 1 && obj.module && !obj.module.startsWith('.')) { + aliasesObj[requireName(pathArray)] = obj.module; } }; recurseObjectKeysAndAlias(apiSpec, ['kolibri']); + if (localAPISpec) { + // If there is a local API spec being injected, just overwrite previous aliases. + var localSpec = specModule(localAPISpec).exports; + recurseObjectKeysAndAlias(localSpec, ['kolibri']); + } return aliasesObj; } diff --git a/frontend_build/src/extract_$trs.js b/frontend_build/src/extract_$trs.js index 7e084e9521c..55ff25a183a 100644 --- a/frontend_build/src/extract_$trs.js +++ b/frontend_build/src/extract_$trs.js @@ -25,7 +25,7 @@ extract$trs.prototype.apply = function(compiler) { var messageNameSpace; var messages = {}; // Parse the AST for the Vue file. - var ast = esprima.parse(module._source.source()); + var ast = esprima.parse(module._source.source(), { sourceType: 'module'} ); ast.body.forEach(function (node) { // Look through each top level node until we find the module.exports // N.B. this relies on our convention of directly exporting the Vue component diff --git a/frontend_build/src/install_dependencies.js b/frontend_build/src/install_dependencies.js index ff218cad55d..ff7b37b2bab 100644 --- a/frontend_build/src/install_dependencies.js +++ b/frontend_build/src/install_dependencies.js @@ -1,4 +1,3 @@ -var recursiveInstall = require('recursive-install'); var readWebpackJson = require('./read_webpack_json'); var path = require('path'); var fs = require('fs'); @@ -7,12 +6,24 @@ var shell = require('shelljs') var plugins = readWebpackJson(); var cwd = path.resolve(process.cwd()); +function yarnInstall (dir) { + shell.cd(dir) + console.log('Installing ' + dir + '/package.json...') + var result = shell.exec('yarn install') + console.log('') + + return { + dirname: dir, + exitCode: result.code + } +} + plugins.map(function(plugin) { shell.cd(cwd); // make sure to reset current working directory var packageJson = path.join(plugin.plugin_path, 'package.json'); try { fs.lstatSync(packageJson); - return recursiveInstall.npmInstall(plugin.plugin_path); + return yarnInstall(plugin.plugin_path); } catch (e) { return { exitCode: 0 diff --git a/frontend_build/src/npm_deprecation_warning.js b/frontend_build/src/npm_deprecation_warning.js new file mode 100644 index 00000000000..e799f49d4ad --- /dev/null +++ b/frontend_build/src/npm_deprecation_warning.js @@ -0,0 +1,4 @@ +if (process.env.npm_execpath.indexOf('npm') > -1) { + console.error('ERROR: Please use yarn to manage frontend dependencies, see Kolibri documentation for details'); + process.exit(1); +} diff --git a/frontend_build/src/parse_bundle_plugin.js b/frontend_build/src/parse_bundle_plugin.js index 3b2d9ebab28..25c8c5efc76 100644 --- a/frontend_build/src/parse_bundle_plugin.js +++ b/frontend_build/src/parse_bundle_plugin.js @@ -57,7 +57,10 @@ var parseBundlePlugin = function(data, base_dir) { local_config = {}; } - bundle = merge.smart(bundle, local_config); + if (local_config.coreAPISpec) { + // Resolve this path now so that it can be unproblematically resolved later. + local_config.coreAPISpec = path.resolve(path.join(data.plugin_path, local_config.coreAPISpec)); + } // This might be non-standard use of the entry option? It seems to // interact with read_bundle_plugins.js @@ -72,11 +75,11 @@ var parseBundlePlugin = function(data, base_dir) { } // Add local resolution paths - bundle.resolve.root = [path.join(data.plugin_path, 'node_modules'), base_dir, path.join(base_dir, 'node_modules')]; + bundle.resolve.modules = [path.join(data.plugin_path, 'node_modules'), base_dir, path.join(base_dir, 'node_modules')]; // Add local and global resolution paths for loaders to allow any plugin to // access kolibri/node_modules loaders during bundling. bundle["resolveLoader"] = { - root: [path.join(data.plugin_path, 'node_modules'), base_dir, path.join(base_dir, 'node_modules')] + modules: [path.join(data.plugin_path, 'node_modules'), base_dir, path.join(base_dir, 'node_modules')] }; bundle.plugins = bundle.plugins.concat([ @@ -93,11 +96,14 @@ var parseBundlePlugin = function(data, base_dir) { new webpack.DefinePlugin({ __kolibriModuleName: JSON.stringify(data.name), __events: JSON.stringify(data.events || {}), - __once: JSON.stringify(data.once || {}) + __once: JSON.stringify(data.once || {}), + __version: JSON.stringify(data.version) }), new extract$trs(data.locale_data_folder, data.name) ]); + bundle = merge.smart(bundle, local_config); + var publicPath, outputPath; if (process.env.DEV_SERVER) { @@ -121,8 +127,6 @@ var parseBundlePlugin = function(data, base_dir) { library: library }; - bundle.async_file = data.async_file; - return [bundle, external]; }; diff --git a/frontend_build/src/read_bundle_plugins.js b/frontend_build/src/read_bundle_plugins.js index aeb95dca452..cb7b11140ae 100644 --- a/frontend_build/src/read_bundle_plugins.js +++ b/frontend_build/src/read_bundle_plugins.js @@ -1,7 +1,7 @@ 'use strict'; /** * Bundle plugin Python config reader module. - * @module readBundlePlugin + * @module readBundlePlugins */ var readWebpackJson = require('./read_webpack_json'); @@ -10,11 +10,14 @@ var _ = require("lodash"); var path = require('path'); var fs = require('fs'); var mkdirp = require('mkdirp'); +var webpack = require('webpack'); var parseBundlePlugin = require('./parse_bundle_plugin'); var coreExternals = require('./apiSpecExportTools').coreExternals; +var coreAliases = require('./apiSpecExportTools').coreAliases; + /** * Take a Python plugin file name as input, and extract the information regarding front end plugin configuration from it * using a Python script to import the relevant plugins and then run methods against them to retrieve the config data. @@ -22,7 +25,7 @@ var coreExternals = require('./apiSpecExportTools').coreExternals; * module names to the global namespace at which those modules can be accessed. * @returns {Array} bundles - An array containing webpack config objects. */ -var readBundlePlugin = function(base_dir) { +var readBundlePlugins = function(base_dir) { // Takes a module file path and turns it into a Python module path. var bundles = []; @@ -63,20 +66,48 @@ var readBundlePlugin = function(base_dir) { } } + // A bundle can specify a modification to the coreAPI. + var coreAPISpec = (_.find(bundles, function(bundle) {return bundle.coreAPISpec;}) || {}).coreAPISpec; + + // Check that there is only one bundle modifying the coreAPI spec. + if (_.filter(bundles, function(bundle) {return bundle.coreAPISpec;}).length > 1) { + throw new RangeError('You have more than one coreAPISpec modification specified.'); + } + // One bundle is special - that is the one for the core bundle. var core_bundle = _.find(bundles, function(bundle) {return bundle.core_name && bundle.core_name !== null;}); + // Check that there is only one core bundle and throw an error if there is more than one. + if (_.filter(bundles, function(bundle) {return bundle.core_name && bundle.core_name !== null;}).length > 1) { + throw new RangeError('You have more than one core bundle specified.'); + } + // For that bundle, we replace all references to library modules (like Backbone) that we bundle into the core app // with references to the core app itself, so if someone does `var Backbone = require('backbone');` webpack // will replace it with a reference to Bacbkone bundled into the core Kolibri app. var core_externals = core_bundle ? coreExternals(core_bundle.output.library) : {}; bundles.forEach(function(bundle) { + bundle.resolve.alias = coreAliases(coreAPISpec); if (bundle.core_name === null || typeof bundle.core_name === "undefined") { // If this is not the core bundle, then we need to add the external library mappings. bundle.externals = _.extend({}, externals, core_externals); } else { - bundle.externals = externals; + bundle.externals = _.extend({kolibri: core_bundle.output.library}, externals); + if (coreAPISpec) { + bundle.plugins.push( + new webpack.ProvidePlugin({ + __coreAPISpec: coreAPISpec + }) + ); + } else { + bundle.plugins.push( + new webpack.DefinePlugin({ + __coreAPISpec: "{}" + }) + ); + } + } }); @@ -99,8 +130,14 @@ var readBundlePlugin = function(base_dir) { fs.writeFileSync(path.join(locale_dir, 'pathMapping.json'), JSON.stringify(namePathMapping)); + // We add some custom configuration options to the bundles that webpack 2 dislikes, clean them up here. + bundles.forEach(function (bundle) { + delete bundle.core_name; + delete bundle.coreAPISpec; + }); + return bundles; }; -module.exports = readBundlePlugin; +module.exports = readBundlePlugins; diff --git a/frontend_build/src/webpack.config.base.js b/frontend_build/src/webpack.config.base.js index 518d4f63c15..0d629c4a7cc 100644 --- a/frontend_build/src/webpack.config.base.js +++ b/frontend_build/src/webpack.config.base.js @@ -29,59 +29,91 @@ process.env.NODE_PATH = path.resolve(path.join(__dirname, '..', '..', 'node_modu require('module').Module._initPaths(); var fs = require('fs'); +var path = require('path'); var webpack = require('webpack'); -var jeet = require('jeet'); -var autoprefixer = require('autoprefixer'); var merge = require('webpack-merge'); -var aliases = require('./apiSpecExportTools').coreAliases(); +var production = process.env.NODE_ENV === 'production'; +var lint = (process.env.LINT || production); + +// helps convert to older string syntax for vue-loader +var combineLoaders = require('webpack-combine-loaders'); -aliases['kolibri_module']= path.resolve('kolibri/core/assets/src/kolibri_module'); -aliases['content_renderer_module'] = path.resolve('kolibri/core/assets/src/content_renderer_module'); +var postCSSLoader = { + loader: 'postcss-loader', + options: { config: path.resolve(__dirname, '../../postcss.config.js') } +}; -require('./htmlhint_custom'); // adds custom rules +var cssLoader = { + loader: 'css-loader', + options: { minimize: production, sourceMap: !production } +}; +// for stylus blocks in vue files. +// note: vue-style-loader includes postcss processing +var vueStylusLoaders = [ 'vue-style-loader', cssLoader, 'stylus-loader' ]; +if (lint) { + vueStylusLoaders.push('stylint-loader') +} + +// for scss blocks in vue files (e.g. Keen-UI files) +var vueSassLoaders = [ + 'vue-style-loader', // includes postcss processing + cssLoader, + { + loader: 'sass-loader', + // prepends these variable override values to every parsed vue SASS block + options: { data: '@import "~kolibri.styles.keenVars";' } + } +]; + +// primary webpack config var config = { module: { - loaders: [ + rules: [ { test: /\.vue$/, - loader: 'vue' + loader: 'vue-loader', + options: { + loaders: { + js: 'buble-loader', + stylus: combineLoaders(vueStylusLoaders), + scss: combineLoaders(vueSassLoaders), + }, + // handles , , , and svg inlining + preLoaders: { html: 'svg-icon-inline-loader' } + } }, { test: /\.js$/, - loader: 'buble', - exclude: /node_modules/ - }, - { - test: /\.json$/, - loader: 'json', - exclude: /node_modules/ + loader: 'buble-loader', + exclude: /node_modules\/(?!(keen-ui)\/).*/ }, { test: /\.css$/, - loader: 'style-loader!css-loader!postcss-loader' + use: [ 'style-loader', cssLoader, postCSSLoader ] }, { test: /\.styl$/, - loader: 'style-loader!css-loader?sourceMap!postcss-loader!stylus-loader' + use: [ 'style-loader', cssLoader, postCSSLoader, 'stylus-loader' ] + }, + { + test: /\.s[a|c]ss$/, + use: [ 'style-loader', cssLoader, postCSSLoader, 'sass-loader' ] }, - // moved from parse_bundle_plugin.js { test: /\.(png|jpe?g|gif|svg)$/, - loader: 'url', - query: { - limit: 10000, - name: '[name].[ext]?[hash]' + use: { + loader: 'url-loader', + options: { limit: 10000, name: '[name].[ext]?[hash]' } } }, - // Usage of file loader allows referencing a local vtt file without in-lining it. - // Can be removed once the local en.vtt test file is removed. + // Use file loader to load font files. { - test: /\.(vtt|eot|woff|ttf|woff2)$/, - loader: 'file', - query: { - name: '[name].[ext]?[hash]' + test: /\.(eot|woff|ttf|woff2)$/, + use: { + loader: 'file-loader', + options: { name: '[name].[ext]?[hash]' } } }, // Hack to make the onloadCSS node module properly export-able. @@ -89,69 +121,54 @@ var config = { // deprecate our custom KolibriModule async css loading functionality. { test: /fg-loadcss\/src\/onloadCSS/, - loader: 'exports?onloadCSS' + use: 'exports-loader?onloadCSS' } ] }, - plugins: [ - ], + plugins: [], resolve: { - alias: aliases, - extensions: ["", ".vue", ".js"], - }, - eslint: { - failOnError: true - }, - htmlhint: { - failOnError: true, - emitAs: "error" - }, - vue: { - loaders: { - js: 'buble-loader', - stylus: 'vue-style-loader!css-loader?sourceMap!postcss-loader!stylus-loader', - html: 'vue-loader/lib/template-compiler!svg-inline', // inlines SVGs - } - }, - stylus: { - use: [jeet()] - }, - postcss: function () { - return [autoprefixer]; + extensions: [ ".js", ".vue", ".styl" ], }, node: { __filename: true } }; -if (process.env.LINT || process.env.NODE_ENV === 'production') { - // Only lint in dev mode if LINT env is set. Always lint in production. + +// Only lint in dev mode if LINT env is set. Always lint in production. +if (lint) { + + // adds custom rules + require('./htmlhint_custom'); + var lintConfig = { module: { - preLoaders: [ + rules: [ { test: /\.(vue|js)$/, - loader: 'eslint', + enforce: 'pre', + use: { + loader: 'eslint-loader', + options: { failOnError: true } + }, exclude: /node_modules/ }, { test: /\.(vue|html)/, - loader: 'htmlhint', + enforce: 'pre', + use: { + loader: 'htmlhint-loader', + options: { failOnError: true, emitAs: "error" } + }, exclude: /node_modules/ - } - ], - loaders: [ + }, { test: /\.styl$/, - loader: 'style-loader!css-loader?sourceMap!postcss-loader!stylus-loader!stylint' + enforce: 'pre', + loader: 'stylint-loader' } - ], - }, - vue: { - loaders: { - stylus: 'vue-style-loader!css-loader?sourceMap!postcss-loader!stylus-loader!stylint' - } - }, + ] + } }; config = merge.smart(config, lintConfig); } diff --git a/frontend_build/src/webpack.config.prod.js b/frontend_build/src/webpack.config.prod.js index 988474bd024..8e89e1cc187 100644 --- a/frontend_build/src/webpack.config.prod.js +++ b/frontend_build/src/webpack.config.prod.js @@ -23,7 +23,7 @@ for (var i=0; i < bundles.length; i++) { } }), // optimize module ids by occurence count - new webpack.optimize.OccurenceOrderPlugin() + new webpack.optimize.OccurrenceOrderPlugin() ]); } diff --git a/frontend_build/src/webpackdevserver.js b/frontend_build/src/webpackdevserver.js index 4fd09306268..dc2c06a9ead 100644 --- a/frontend_build/src/webpackdevserver.js +++ b/frontend_build/src/webpackdevserver.js @@ -27,7 +27,7 @@ var server = new WebpackDevServer(compiler, { // webpack-dev-middleware options quiet: false, - noInfo: true, + noInfo: false, watchOptions: { aggregateTimeout: 300, poll: 1000 diff --git a/frontend_build/test/test_apiSpecExportTools.js b/frontend_build/test/test_apiSpecExportTools.js index 10b08268fda..45be0f36a0b 100644 --- a/frontend_build/test/test_apiSpecExportTools.js +++ b/frontend_build/test/test_apiSpecExportTools.js @@ -1,5 +1,6 @@ var assert = require('assert'); var rewire = require('rewire'); +var _ = require('lodash'); var apiSpecExportTools = rewire('../src/apiSpecExportTools'); @@ -70,47 +71,47 @@ describe('coreExternals', function() { describe('coreAliases', function() { describe('top level with special keys no local import', function() { - it('should have no entries', function (done) { + it('should have two entries', function (done) { apiSpecExportTools.__set__("apiSpec", testSpec); - assert(Object.keys(apiSpecExportTools.coreAliases()).length === 0); + assert(Object.keys(apiSpecExportTools.coreAliases()).length === 2); done(); }); }); - describe('1 nested valid spec no local import', function() { - it('should have no entries', function (done) { + describe('1 deep nested valid spec no local import', function() { + it('should have 3 entries', function (done) { apiSpecExportTools.__set__("apiSpec", oneDeepSpec); - assert(Object.keys(apiSpecExportTools.coreAliases()).length === 0); + assert(Object.keys(apiSpecExportTools.coreAliases()).length === 3); done(); }); }); - describe('2 nested valid spec no local import', function() { - it('should have no entries', function (done) { + describe('2 deep nested valid spec no local import', function() { + it('should have 3 entries', function (done) { apiSpecExportTools.__set__("apiSpec", twoDeepSpec); - assert(Object.keys(apiSpecExportTools.coreAliases()).length === 0); + assert(Object.keys(apiSpecExportTools.coreAliases()).length === 3); done(); }); }); describe('1 nested valid spec with local import', function() { - it('should have one entry', function (done) { + it('should have 3 entries', function (done) { apiSpecExportTools.__set__("apiSpec", oneDeepLocal); - assert(Object.keys(apiSpecExportTools.coreAliases()).length === 1); + assert(Object.keys(apiSpecExportTools.coreAliases()).length === 3); done(); }); it('should have a path of kolibri.test', function (done) { apiSpecExportTools.__set__("apiSpec", oneDeepLocal); - assert(Object.keys(apiSpecExportTools.coreAliases())[0] === "kolibri.test"); + assert(_.some(Object.keys(apiSpecExportTools.coreAliases()), function (key) { return key === "kolibri.test";})); done(); }); }); describe('2 nested valid spec with local import', function() { - it('should have one entry', function (done) { + it('should have 3 entries', function (done) { apiSpecExportTools.__set__("apiSpec", twoDeepLocal); - assert(Object.keys(apiSpecExportTools.coreAliases()).length === 1); + assert(Object.keys(apiSpecExportTools.coreAliases()).length === 3); done(); }); it('should have a path of kolibri.test.test', function (done) { apiSpecExportTools.__set__("apiSpec", twoDeepLocal); - assert(Object.keys(apiSpecExportTools.coreAliases())[0] === "kolibri.test.test"); + assert(_.some(Object.keys(apiSpecExportTools.coreAliases()), function (key) { return key === "kolibri.test.test";})); done(); }); }); diff --git a/frontend_build/test/test_bundle_parse.js b/frontend_build/test/test_bundle_parse.js index 0cee1e90e69..be1698a6fba 100644 --- a/frontend_build/test/test_bundle_parse.js +++ b/frontend_build/test/test_bundle_parse.js @@ -151,7 +151,7 @@ describe('readBundlePlugins', function() { }); }); describe('two external flags on inputs, one with core_name value, externals output', function() { - it('should have one entry', function (done) { + it('should have two entries', function (done) { var coreData = _.clone(baseData); coreData.external = true; coreData.core_name = "test_global"; @@ -161,12 +161,12 @@ describe('readBundlePlugins', function() { coreData, coreData1 ]; - assert(Object.keys(readBundlePlugins("", function(){return {};})[0].externals).length === 1); + assert(Object.keys(readBundlePlugins("", function(){return {};})[0].externals).length === 2); done(); }); }); - describe('two identically named external flags on inputs, externals output', function() { - it('should have one entry', function (done) { + describe('two core bundles specified', function() { + it('should throw an error', function (done) { var coreData = _.clone(baseData); coreData.external = true; coreData.core_name = "test_global"; @@ -178,7 +178,7 @@ describe('readBundlePlugins', function() { coreData, coreData1 ]; - assert(Object.keys(readBundlePlugins("", function(){return {};})[0].externals).length === 1); + assert.throws(function() {readBundlePlugins("", function(){return {};});}); done(); }); }); diff --git a/karma_config/karma.conf.js b/karma_config/karma.conf.js index 566f906a3c4..c2aa8a51eaf 100644 --- a/karma_config/karma.conf.js +++ b/karma_config/karma.conf.js @@ -1,13 +1,22 @@ // Karma configuration -// Generated on Thu Feb 11 2016 12:59:11 GMT-0800 (PST) var RewirePlugin = require("rewire-webpack"); var _ = require("lodash"); var webpack_config = _.clone(require("../frontend_build/src/webpack.config.base")); var path = require('path'); +var webpack = require('webpack'); webpack_config.plugins.push(new RewirePlugin()); +webpack_config.plugins.push( + new webpack.DefinePlugin({ + __coreAPISpec: "{}" + }) +); webpack_config.devtool = '#inline-source-map'; -webpack_config.resolve.alias['kolibri'] = path.resolve('kolibri/core/assets/src/core_app_instance'); +var aliases = require('../frontend_build/src/apiSpecExportTools').coreAliases(); +aliases['kolibri'] = path.resolve(__dirname, './kolibriGlobalMock'); +aliases['vue-test'] = path.resolve(__dirname, './vueLocal'); + +webpack_config.resolve.alias = aliases; module.exports = function(config) { config.set({ @@ -15,24 +24,21 @@ module.exports = function(config) { // base path that will be used to resolve all patterns (eg. files, exclude) basePath: '../', - // frameworks to use // available frameworks: https://npmjs.org/browse/keyword/karma-adapter - frameworks: ['mocha', 'es6-shim'], - + frameworks: ['mocha'], // list of files / patterns to load files: [ + // Detailed pattern to include a file. Similarly other options can be used + { pattern: './node_modules/core-js/client/core.js', watched: false }, './node_modules/phantomjs-polyfill-find/find-polyfill.js', 'kolibri/**/assets/test/*.js', {pattern: 'kolibri/**/assets/src/**/*.js', included: false} // load these, but not in the browser, just for linting ], - // list of files to exclude - exclude: [ - ], - + exclude: [], // preprocess matching files before serving them to the browser // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor @@ -42,30 +48,24 @@ module.exports = function(config) { 'kolibri/**/assets/src/**/*.js': ['eslint'] }, - // test results reporter to use // possible values: 'dots', 'progress' // available reporters: https://npmjs.org/browse/keyword/karma-reporter reporters: ['progress'], - // web server port port: 9876, - // enable / disable colors in the output (reporters and logs) colors: true, - // level of logging // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG logLevel: config.LOG_INFO, - // enable / disable watching file and executing tests whenever any file changes autoWatch: true, - // start these browsers // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher browsers: ['PhantomJS'], @@ -84,7 +84,6 @@ module.exports = function(config) { stopOnError: false }, - // Continuous Integration mode // if true, Karma captures browsers, runs the tests and exits singleRun: false, diff --git a/karma_config/kolibriGlobalMock.js b/karma_config/kolibriGlobalMock.js new file mode 100644 index 00000000000..62b1c05e847 --- /dev/null +++ b/karma_config/kolibriGlobalMock.js @@ -0,0 +1 @@ +module.exports = global.kolibriGlobal = {}; diff --git a/karma_config/vueLocal.js b/karma_config/vueLocal.js new file mode 100644 index 00000000000..c0f54565b20 --- /dev/null +++ b/karma_config/vueLocal.js @@ -0,0 +1,40 @@ +const vue = require('../node_modules/vue'); +const vuex = require('vuex'); +const router = require('vue-router'); +const vueintl = require('vue-intl'); + +vue.prototype.Kolibri = require('kolibri'); +vue.config.silent = true; +vue.use(vuex); +vue.use(router); +require('intl'); +require('intl/locale-data/jsonp/en.js'); +vue.use(vueintl, { defaultLocale: 'en-us' }); + +vue.mixin({ + store: new vuex.Store({}), +}); + +function $trWrapper(formatter, messageId, args) { + if (args) { + if (!Array.isArray(args) && typeof args !== 'object') { + logging.error(`The $tr functions take either an array of positional + arguments or an object of named options.`); + } + } + const defaultMessageText = this.$options.$trs[messageId]; + const message = { + id: `${this.$options.$trNameSpace}.${messageId}`, + defaultMessage: defaultMessageText, + }; + return formatter(message, args); +} + +vue.prototype.$tr = function $tr(messageId, args) { + return $trWrapper.call(this, this.$formatMessage, messageId, args); +}; +vue.prototype.$trHtml = function $trHtml(messageId, args) { + return $trWrapper.call(this, this.$formatHTMLMessage, messageId, args); +}; + +module.exports = vue; diff --git a/kolibri/__init__.py b/kolibri/__init__.py index ecd62df8eda..10b61b9ea8a 100755 --- a/kolibri/__init__.py +++ b/kolibri/__init__.py @@ -4,12 +4,6 @@ # tracking of releases once we start doing lots of pre-releases is essential. from .utils.version import get_version -# 'alpha' will automatically switch on the data-post fixing mechanism when -# building straight from a git repo- -# Example: -# 0.0.1.dev20160511132442 -VERSION = (0, 2, 0, 'rc', 1) - __author__ = 'Learning Equality' __email__ = 'info@learningequality.org' -__version__ = get_version(VERSION) +__version__ = str(get_version('kolibri', __file__)) diff --git a/kolibri/auth/api.py b/kolibri/auth/api.py index dbc94a28e7c..6ed10191360 100644 --- a/kolibri/auth/api.py +++ b/kolibri/auth/api.py @@ -5,9 +5,10 @@ from rest_framework import filters, permissions, status, viewsets from rest_framework.response import Response -from .models import Classroom, DeviceOwner, Facility, FacilityUser, LearnerGroup, Membership, Role +from .models import Classroom, DeviceOwner, Facility, FacilityDataset, FacilityUser, LearnerGroup, Membership, Role from .serializers import ( - ClassroomSerializer, DeviceOwnerSerializer, FacilitySerializer, FacilityUserSerializer, LearnerGroupSerializer, MembershipSerializer, RoleSerializer + ClassroomSerializer, DeviceOwnerSerializer, FacilityDatasetSerializer, FacilitySerializer, FacilityUserSerializer, LearnerGroupSerializer, + MembershipSerializer, RoleSerializer ) @@ -63,6 +64,13 @@ def has_object_permission(self, request, view, obj): return False +class FacilityDatasetViewSet(viewsets.ModelViewSet): + permissions_classes = (KolibriAuthPermissions,) + filter_backends = (KolibriAuthPermissionsFilter,) + queryset = FacilityDataset.objects.all() + serializer_class = FacilityDatasetSerializer + + class FacilityUserViewSet(viewsets.ModelViewSet): permission_classes = (KolibriAuthPermissions,) filter_backends = (KolibriAuthPermissionsFilter,) @@ -125,6 +133,33 @@ class LearnerGroupViewSet(viewsets.ModelViewSet): filter_fields = ('parent',) +class SignUpViewSet(viewsets.ViewSet): + + def extract_request_data(self, request): + return { + "username": request.data.get('username', ''), + "full_name": request.data.get('full_name', ''), + "password": request.data.get('password', ''), + "facility": Facility.get_default_facility().id, + } + + def create(self, request): + + data = self.extract_request_data(request) + + # we validate the user's input, and if valid, login as user + serialized_user = FacilityUserSerializer(data=data) + if serialized_user.is_valid(): + serialized_user.save() + authenticated_user = authenticate(username=data['username'], password=data['password'], facility=data['facility']) + login(request, authenticated_user) + return Response(serialized_user.data, status=status.HTTP_201_CREATED) + else: + # grab error if related to username + error = serialized_user.errors.get('username', None) + return Response(error, status=status.HTTP_400_BAD_REQUEST) + + class SessionViewSet(viewsets.ViewSet): def create(self, request): diff --git a/kolibri/auth/api_urls.py b/kolibri/auth/api_urls.py index ac23b1980e8..fb4254fd338 100644 --- a/kolibri/auth/api_urls.py +++ b/kolibri/auth/api_urls.py @@ -1,12 +1,13 @@ from rest_framework import routers from .api import ( - ClassroomViewSet, CurrentFacilityViewSet, DeviceOwnerViewSet, FacilityUserViewSet, FacilityViewSet, LearnerGroupViewSet, MembershipViewSet, RoleViewSet, - SessionViewSet + ClassroomViewSet, CurrentFacilityViewSet, DeviceOwnerViewSet, FacilityDatasetViewSet, FacilityUserViewSet, + FacilityViewSet, LearnerGroupViewSet, MembershipViewSet, RoleViewSet, SessionViewSet, SignUpViewSet ) router = routers.SimpleRouter() +router.register(r'facilitydataset', FacilityDatasetViewSet) router.register(r'facilityuser', FacilityUserViewSet) router.register(r'deviceowner', DeviceOwnerViewSet) router.register(r'membership', MembershipViewSet) @@ -16,5 +17,6 @@ router.register(r'session', SessionViewSet, base_name='session') router.register(r'classroom', ClassroomViewSet) router.register(r'learnergroup', LearnerGroupViewSet) +router.register(r'signup', SignUpViewSet, base_name='signup') urlpatterns = router.urls diff --git a/kolibri/auth/constants/collection_kinds.py b/kolibri/auth/constants/collection_kinds.py index 2353517047c..96fd37d2acc 100644 --- a/kolibri/auth/constants/collection_kinds.py +++ b/kolibri/auth/constants/collection_kinds.py @@ -11,5 +11,5 @@ choices = ( (FACILITY, _("Facility")), (CLASSROOM, _("Classroom")), - (LEARNERGROUP, _("LearnerGroup")), + (LEARNERGROUP, _("Learner group")), ) diff --git a/kolibri/auth/migrations/0002_auto_20170208_0110.py b/kolibri/auth/migrations/0002_auto_20170208_0110.py new file mode 100644 index 00000000000..a55fe9762ee --- /dev/null +++ b/kolibri/auth/migrations/0002_auto_20170208_0110.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2017-02-08 01:10 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kolibriauth', '0001_initial_redone'), + ] + + operations = [ + migrations.RemoveField( + model_name='facilitydataset', + name='allow_signups', + ), + migrations.AddField( + model_name='facilitydataset', + name='learner_can_delete_account', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='facilitydataset', + name='learner_can_edit_name', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='facilitydataset', + name='learner_can_edit_password', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='facilitydataset', + name='learner_can_edit_username', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='facilitydataset', + name='learner_can_sign_up', + field=models.BooleanField(default=False), + ), + ] diff --git a/kolibri/auth/models.py b/kolibri/auth/models.py index f98df1f1bf3..d4d04dde8cb 100644 --- a/kolibri/auth/models.py +++ b/kolibri/auth/models.py @@ -44,7 +44,10 @@ UserIsNotFacilityUser, UserIsNotMemberError ) from .filters import HierarchyRelationsFilter -from .permissions.auth import AnybodyCanCreateIfNoDeviceOwner, AnybodyCanCreateIfNoFacility, CollectionSpecificRoleBasedPermissions +from .permissions.auth import ( + AnybodyCanCreateIfNoDeviceOwner, AnybodyCanCreateIfNoFacility, CollectionSpecificRoleBasedPermissions, + AnonUserCanReadFacilitiesThatAllowSignUps, IsAdminForOwnFacilityDataset +) from .permissions.base import BasePermissions, RoleBasedPermissions from .permissions.general import IsAdminForOwnFacility, IsFromSameFacility, IsOwn, IsSelf @@ -63,10 +66,17 @@ class FacilityDataset(models.Model): from ``AbstractFacilityDataModel``) foreign key onto, to indicate that they belong to this particular ``Facility``. """ + permissions = IsAdminForOwnFacilityDataset() + description = models.TextField(blank=True) location = models.CharField(max_length=200, blank=True) - allow_signups = models.BooleanField(default=True) + # Facility specific configuration settings + learner_can_edit_username = models.BooleanField(default=False) + learner_can_edit_name = models.BooleanField(default=False) + learner_can_edit_password = models.BooleanField(default=False) + learner_can_sign_up = models.BooleanField(default=False) + learner_can_delete_account = models.BooleanField(default=False) def __str__(self): facilities = self.collection_set.filter(kind=collection_kinds.FACILITY) @@ -75,6 +85,7 @@ def __str__(self): else: return "FacilityDataset (no associated Facility)" + class AbstractFacilityDataModel(models.Model): """ Base model for Kolibri "Facility Data", which is data that is specific to a particular ``Facility``, @@ -145,7 +156,7 @@ class Meta: username = models.CharField( _('username'), max_length=30, - help_text=_('Required. 30 characters or fewer. Letters and digits only.'), + help_text=_('Required. 30 characters or fewer. Letters and digits only'), validators=[ validators.RegexValidator( r'^\w+$', @@ -599,7 +610,12 @@ class Collection(MPTTModel, AbstractFacilityDataModel): # Collection can be read by anybody from the facility; writing is only allowed by an admin for the collection. # Furthermore, no FacilityUser can create or delete a Facility. Permission to create a collection is governed # by roles in relation to the new collection's parent collection (see CollectionSpecificRoleBasedPermissions). - permissions = IsFromSameFacility(read_only=True) | CollectionSpecificRoleBasedPermissions() | AnybodyCanCreateIfNoFacility() + permissions = ( + IsFromSameFacility(read_only=True) | + CollectionSpecificRoleBasedPermissions() | + AnybodyCanCreateIfNoFacility() | + AnonUserCanReadFacilitiesThatAllowSignUps() + ) _KIND = None # Should be overridden in subclasses to specify what "kind" they are diff --git a/kolibri/auth/permissions/auth.py b/kolibri/auth/permissions/auth.py index 1385f98ce0c..a28f3a7aa7b 100644 --- a/kolibri/auth/permissions/auth.py +++ b/kolibri/auth/permissions/auth.py @@ -2,9 +2,11 @@ The permissions classes in this module define the specific permissions that govern access to the models in the auth app. """ +from django.contrib.auth.models import AnonymousUser + from ..constants.collection_kinds import FACILITY from ..constants.role_kinds import ADMIN, COACH -from .base import RoleBasedPermissions +from .base import BasePermissions, RoleBasedPermissions from .general import DenyAll @@ -45,12 +47,81 @@ def user_can_delete_object(self, user, obj): # for non-Facility Collections, defer to the roles to determine delete permissions return super(CollectionSpecificRoleBasedPermissions, self).user_can_update_object(user, obj.parent) + class AnybodyCanCreateIfNoDeviceOwner(DenyAll): + """ + Permissions class that allows anyone to create a DeviceOwner if one does not already exist. + """ + def user_can_create_object(self, user, obj): from ..models import DeviceOwner return DeviceOwner.objects.count() < 1 + class AnybodyCanCreateIfNoFacility(DenyAll): + """ + Permissions class that allows anyone to create a Facility if one does not already exist. + """ + def user_can_create_object(self, user, obj): from ..models import Facility return Facility.objects.count() < 1 + + +class AnonUserCanReadFacilitiesThatAllowSignUps(DenyAll): + """ + Permissions class that allows reading the object if user is anonymous and facility settings allows learner sign ups. + """ + + def user_can_read_object(self, user, obj): + if obj.kind == FACILITY: + return isinstance(user, AnonymousUser) and obj.dataset.learner_can_sign_up + else: + return False + + def readable_by_user_filter(self, user, queryset): + if isinstance(user, AnonymousUser): + return queryset.filter(dataset__learner_can_sign_up=True, kind=FACILITY) + return queryset.none() + + +class IsAdminForOwnFacilityDataset(BasePermissions): + """ + Permission class that allows access to dataset settings if they are admin for facility. + """ + + def _user_is_admin_for_related_facility(self, user, obj=None): + + # import here to avoid circular imports + from ..models import FacilityDataset + + if not hasattr(user, "dataset"): + return False + + # if we've been given an object, make sure it too is from the same dataset (facility) + if obj: + if not user.dataset_id == obj.id: + return False + else: + obj = FacilityDataset.objects.get(id=user.dataset_id) + + facility = obj.collection_set.first() + return user.has_role_for_collection(ADMIN, facility) + + def user_can_create_object(self, user, obj): + return self._user_is_admin_for_related_facility(user, obj) + + def user_can_read_object(self, user, obj): + return self._user_is_admin_for_related_facility(user, obj) + + def user_can_update_object(self, user, obj): + return self._user_is_admin_for_related_facility(user, obj) + + def user_can_delete_object(self, user, obj): + return False + + def readable_by_user_filter(self, user, queryset): + if self._user_is_admin_for_related_facility(user): + return queryset.filter(id=user.dataset_id) + else: + return queryset.none() diff --git a/kolibri/auth/serializers.py b/kolibri/auth/serializers.py index f5e05aebb3b..192785eda3c 100644 --- a/kolibri/auth/serializers.py +++ b/kolibri/auth/serializers.py @@ -1,8 +1,9 @@ from __future__ import absolute_import, print_function, unicode_literals +from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers -from .models import Classroom, DeviceOwner, Facility, FacilityUser, LearnerGroup, Membership, Role +from .models import Classroom, DeviceOwner, Facility, FacilityDataset, FacilityUser, LearnerGroup, Membership, Role class RoleSerializer(serializers.ModelSerializer): @@ -10,20 +11,7 @@ class Meta: model = Role exclude = ("dataset",) - -class FacilityUserSerializer(serializers.ModelSerializer): - roles = RoleSerializer(many=True, read_only=True) - - class Meta: - model = FacilityUser - extra_kwargs = {'password': {'write_only': True}} - fields = ('id', 'username', 'full_name', 'password', 'facility', 'roles') - - def create(self, validated_data): - user = FacilityUser(**validated_data) - user.set_password(validated_data['password']) - user.save() - return user +class BaseKolibriUserSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): if 'password' in validated_data: @@ -32,40 +20,35 @@ def update(self, instance, validated_data): instance.save() return instance else: - return super(FacilityUserSerializer, self).update(instance, validated_data) + return super(BaseKolibriUserSerializer, self).update(instance, validated_data) def validate_username(self, value): - if FacilityUser.objects.filter(username__iexact=value).exists(): - raise serializers.ValidationError('An account with that username already exists.') + if FacilityUser.objects.filter(username__iexact=value).exists() | DeviceOwner.objects.filter(username__iexact=value).exists(): + raise serializers.ValidationError(_('An account with that username already exists')) return value + def create(self, validated_data): + user = self.Meta.model(**validated_data) + user.set_password(validated_data['password']) + user.save() + return user -class DeviceOwnerSerializer(serializers.ModelSerializer): + +class FacilityUserSerializer(BaseKolibriUserSerializer): + roles = RoleSerializer(many=True, read_only=True) class Meta: - model = DeviceOwner - exclude = ("last_login",) + model = FacilityUser extra_kwargs = {'password': {'write_only': True}} + fields = ('id', 'username', 'full_name', 'password', 'facility', 'roles') - def create(self, validated_data): - user = DeviceOwner(**validated_data) - user.set_password(validated_data['password']) - user.save() - return user - def update(self, instance, validated_data): - if 'password' in validated_data: - serializers.raise_errors_on_nested_writes('update', self, validated_data) - instance.set_password(validated_data['password']) - instance.save() - return instance - else: - return super(DeviceOwnerSerializer, self).update(instance, validated_data) +class DeviceOwnerSerializer(BaseKolibriUserSerializer): - def validate_username(self, value): - if DeviceOwner.objects.filter(username__iexact=value).exists(): - raise serializers.ValidationError('An account with that username already exists.') - return value + class Meta: + model = DeviceOwner + exclude = ("last_login",) + extra_kwargs = {'password': {'write_only': True}} class MembershipSerializer(serializers.ModelSerializer): @@ -75,6 +58,14 @@ class Meta: exclude = ("dataset",) +class FacilityDatasetSerializer(serializers.ModelSerializer): + + class Meta: + model = FacilityDataset + fields = ('id', 'learner_can_edit_username', 'learner_can_edit_name', 'learner_can_edit_password', + 'learner_can_sign_up', 'learner_can_delete_account', 'description', 'location') + + class FacilitySerializer(serializers.ModelSerializer): class Meta: diff --git a/kolibri/auth/test/helpers.py b/kolibri/auth/test/helpers.py index 31c473cf853..1f3cf17305b 100644 --- a/kolibri/auth/test/helpers.py +++ b/kolibri/auth/test/helpers.py @@ -4,7 +4,7 @@ from ..models import FacilityUser, Facility, Classroom, LearnerGroup, FacilityDataset -def create_dummy_facility_data(classroom_count=2, learnergroup_count=2): +def create_dummy_facility_data(allow_sign_ups=False, classroom_count=2, learnergroup_count=2): """ Helper to bootstrap facility data for use in role/permission scenarios (collections, users, and roles). This can be called multiple times to create parallel facilities/datasets in the same database, to test @@ -19,7 +19,7 @@ def create_dummy_facility_data(classroom_count=2, learnergroup_count=2): data = {} # create the dataset object with which this data will be associated - dataset = data["dataset"] = FacilityDataset.objects.create() + dataset = data["dataset"] = FacilityDataset.objects.create(learner_can_sign_up=allow_sign_ups) # create the Collection hierarchy facility = data["facility"] = Facility.objects.create(dataset=dataset) diff --git a/kolibri/auth/test/test_api.py b/kolibri/auth/test/test_api.py index f3c0be9522c..47bb0bec732 100644 --- a/kolibri/auth/test/test_api.py +++ b/kolibri/auth/test/test_api.py @@ -319,3 +319,64 @@ def test_session_return_admin_and_coach_kind(self): def test_session_return_anon_kind(self): response = self.client.get(reverse('session-detail', kwargs={'pk': 'current'})) self.assertTrue(response.data['kind'][0], 'anonymous') + + +class AnonSignUpTestCase(APITestCase): + + def setUp(self): + self.device_owner = DeviceOwnerFactory.create() + self.facility = FacilityFactory.create() + + def test_anon_sign_up_creates_user(self): + response = self.client.post(reverse('signup-list'), data={"username": "user", "password": DUMMY_PASSWORD}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(models.FacilityUser.objects.all()) + + def test_anon_sign_up_returns_user(self): + full_name = "Bob Lee" + response = self.client.post(reverse('signup-list'), data={"full_name": full_name, "username": "user", "password": DUMMY_PASSWORD}) + self.assertEqual(response.data['username'], 'user') + self.assertEqual(response.data['full_name'], full_name) + + def test_create_user_with_same_username_fails(self): + FacilityUserFactory.create(username='bob') + response = self.client.post(reverse('signup-list'), data={"username": "bob", "password": DUMMY_PASSWORD}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(len(models.FacilityUser.objects.all()), 1) + + def test_create_bad_username_fails(self): + response = self.client.post(reverse('signup-list'), data={"username": "(***)", "password": DUMMY_PASSWORD}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(models.FacilityUser.objects.all()) + + def test_sign_up_also_logs_in_user(self): + self.assertFalse(Session.objects.all()) + self.client.post(reverse('signup-list'), data={"username": "user", "password": DUMMY_PASSWORD}) + self.assertTrue(Session.objects.all()) + + +class FacilityDatasetAPITestCase(APITestCase): + + def setUp(self): + self.device_owner = DeviceOwnerFactory.create() + self.facility = FacilityFactory.create() + FacilityFactory.create(name='extra') + self.admin = FacilityUserFactory.create(facility=self.facility) + self.user = FacilityUserFactory.create(facility=self.facility) + self.facility.add_admin(self.admin) + + def test_return_dataset_that_user_is_an_admin_for(self): + self.client.login(username=self.admin.username, password=DUMMY_PASSWORD) + response = self.client.get(reverse('facilitydataset-list')) + self.assertEqual(len(response.data), 1) + self.assertEqual(self.admin.dataset_id, response.data[0]['id']) + + def test_return_all_datasets_for_device_owner(self): + self.client.login(username=self.device_owner.username, password=DUMMY_PASSWORD) + response = self.client.get(reverse('facilitydataset-list')) + self.assertEqual(len(response.data), len(models.FacilityDataset.objects.all())) + + def test_return_nothing_for_facility_user(self): + self.client.login(username=self.user.username, password=DUMMY_PASSWORD) + response = self.client.get(reverse('facilitydataset-list')) + self.assertEqual(len(response.data), 0) diff --git a/kolibri/auth/test/test_permissions.py b/kolibri/auth/test/test_permissions.py index 833b0073fd0..aba928937e4 100644 --- a/kolibri/auth/test/test_permissions.py +++ b/kolibri/auth/test/test_permissions.py @@ -11,7 +11,8 @@ from ..constants import role_kinds from ..errors import InvalidHierarchyRelationsArgument from ..filters import HierarchyRelationsFilter -from ..models import DeviceOwner, Facility, Classroom, LearnerGroup, Role, Membership, FacilityUser, KolibriAnonymousUser +from ..models import DeviceOwner, Facility, FacilityDataset, Classroom, LearnerGroup, Role, Membership, FacilityUser, KolibriAnonymousUser + class ImproperUsageIsProperlyHandledTestCase(TestCase): """ @@ -52,6 +53,71 @@ def test_that_invalid_references_to_hierarchyrelationsfilter_throw_errors(self): HierarchyRelationsFilter(Facility).filter_by_hierarchy(target_user=["test"]) +class FacilityDatasetPermissionsTestCase(TestCase): + """ + Tests of permissions for reading/modifying FacilityData instances + """ + + def setUp(self): + self.data1 = create_dummy_facility_data() + self.data2 = create_dummy_facility_data() + self.device_owner = DeviceOwner.objects.create(username="boss") + self.anon_user = KolibriAnonymousUser() + + def test_facility_users_and_anon_users_cannot_create_facility_dataset(self): + """ FacilityUsers can't create new Facilities, regardless of their roles """ + new_facility_dataset = {} + self.assertFalse(self.data1["facility_admin"].can_create(FacilityDataset, new_facility_dataset)) + self.assertFalse(self.data1["classroom_coaches"][0].can_create(FacilityDataset, new_facility_dataset)) + self.assertFalse(self.data1["learners_one_group"][0][0].can_create(FacilityDataset, new_facility_dataset)) + self.assertFalse(self.data1["unattached_users"][0].can_create(FacilityDataset, new_facility_dataset)) + self.assertFalse(self.data1["unattached_users"][0].can_create(FacilityDataset, new_facility_dataset)) + + def test_anon_users_cannot_read_facility_dataset(self): + """ KolibriAnonymousUser cannot read Facility objects """ + self.assertFalse(self.anon_user.can_read(self.data1["dataset"])) + self.assertNotIn(self.data1["dataset"], self.anon_user.filter_readable(FacilityDataset.objects.all())) + + def test_only_facility_admins_can_update_own_facility_dataset(self): + """ The only FacilityUser who can update a FacilityDataset is a facility admin for that FacilityDataset """ + own_dataset = self.data1["dataset"] + self.assertTrue(self.data1["facility_admin"].can_update(own_dataset)) + self.assertFalse(self.data1["classroom_coaches"][0].can_update(own_dataset)) + self.assertFalse(self.data1["learners_one_group"][0][0].can_update(own_dataset)) + self.assertFalse(self.data1["unattached_users"][0].can_update(own_dataset)) + self.assertFalse(self.anon_user.can_update(own_dataset)) + + def test_facility_users_and_anon_users_cannot_delete_own_facility_dataset(self): + """ FacilityUsers can't delete own FacilityDataset, regardless of their roles """ + own_dataset = self.data1["dataset"] + self.assertFalse(self.data1["facility_admin"].can_delete(own_dataset)) + self.assertFalse(self.data1["classroom_coaches"][0].can_delete(own_dataset)) + self.assertFalse(self.data1["learners_one_group"][0][0].can_delete(own_dataset)) + self.assertFalse(self.data1["unattached_users"][0].can_delete(own_dataset)) + self.assertFalse(self.anon_user.can_delete(own_dataset)) + + def test_facility_users_cannot_delete_other_facility_dataset(self): + """ FacilityUsers can't delete other FacilityDataset, regardless of their roles """ + other_facility_dataset = self.data2["dataset"] + self.assertFalse(self.data1["facility_admin"].can_delete(other_facility_dataset)) + self.assertFalse(self.data1["classroom_coaches"][0].can_delete(other_facility_dataset)) + self.assertFalse(self.data1["learners_one_group"][0][0].can_delete(other_facility_dataset)) + self.assertFalse(self.data1["unattached_users"][0].can_delete(other_facility_dataset)) + + def test_device_owner_can_do_anything_to_a_facility_dataset(self): + """ DeviceOwner can do anything to a FacilityDataset """ + + new_facility_data = {} + self.assertTrue(self.device_owner.can_create(FacilityDataset, new_facility_data)) + + facility_dataset = self.data1["dataset"] + self.assertTrue(self.device_owner.can_read(facility_dataset)) + self.assertTrue(self.device_owner.can_update(facility_dataset)) + self.assertTrue(self.device_owner.can_delete(facility_dataset)) + + self.assertSetEqual(set(FacilityDataset.objects.all()), set(self.device_owner.filter_readable(FacilityDataset.objects.all()))) + + class FacilityPermissionsTestCase(TestCase): """ Tests of permissions for reading/modifying Facility instances @@ -59,7 +125,7 @@ class FacilityPermissionsTestCase(TestCase): def setUp(self): self.data1 = create_dummy_facility_data() - self.data2 = create_dummy_facility_data() + self.data2 = create_dummy_facility_data(allow_sign_ups=True) self.device_owner = DeviceOwner.objects.create(username="boss") self.anon_user = KolibriAnonymousUser() @@ -140,6 +206,22 @@ def test_device_owner_can_do_anything_to_a_facility(self): self.assertSetEqual(set(Facility.objects.all()), set(self.device_owner.filter_readable(Facility.objects.all()))) + def test_anon_user_can_read_facilities_that_allow_sign_ups(self): + can_not_sign_up_facility = self.data1['facility'] + can_sign_up_facility = self.data2['facility'] + + self.assertFalse(self.anon_user.can_read(can_not_sign_up_facility)) + self.assertTrue(self.anon_user.can_read(can_sign_up_facility)) + + def test_anon_user_filters_facility_datasets_that_allow_sign_ups(self): + sign_ups = Facility.objects.filter(dataset__learner_can_sign_up=True) + filtered = self.anon_user.filter_readable(Facility.objects.all()) + self.assertEqual(set(sign_ups), set(filtered)) + + def test_anon_user_can_only_read_facilities_that_allow_sign_ups(self): + self.assertFalse(self.anon_user.can_read(self.data2['classrooms'][0])) + self.assertFalse(self.anon_user.can_read(self.data2['learnergroups'][0][0])) + class ClassroomPermissionsTestCase(TestCase): """ diff --git a/kolibri/content/migrations/0005_auto_20170207_1158.py b/kolibri/content/migrations/0005_auto_20170207_1158.py new file mode 100644 index 00000000000..706670c1a23 --- /dev/null +++ b/kolibri/content/migrations/0005_auto_20170207_1158.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2017-02-07 19:58 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0004_auto_20161222_1953'), + ] + + operations = [ + migrations.AlterField( + model_name='language', + name='id', + field=models.CharField(max_length=7, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='language', + name='lang_code', + field=models.CharField(db_index=True, max_length=3), + ), + migrations.AlterField( + model_name='language', + name='lang_subcode', + field=models.CharField(blank=True, db_index=True, max_length=3, null=True), + ), + ] diff --git a/kolibri/content/models.py b/kolibri/content/models.py index c36d3daa962..be034cdf293 100644 --- a/kolibri/content/models.py +++ b/kolibri/content/models.py @@ -156,8 +156,9 @@ def get_descendant_kind_counts(self): @python_2_unicode_compatible class Language(ContentDatabaseModel): - lang_code = models.CharField(max_length=2, db_index=True) - lang_subcode = models.CharField(max_length=2, db_index=True) + id = models.CharField(max_length=7, primary_key=True) + lang_code = models.CharField(max_length=3, db_index=True) + lang_subcode = models.CharField(max_length=3, db_index=True, blank=True, null=True) objects = ContentQuerySet.as_manager() @@ -211,7 +212,7 @@ def get_preset(self): """ Return the preset. """ - return PRESET_LOOKUP.get(self.preset, _('Unknown Format')) + return PRESET_LOOKUP.get(self.preset, _('Unknown format')) def get_download_filename(self): """ diff --git a/kolibri/content/serializers.py b/kolibri/content/serializers.py index 7902dd07221..5dee6ccf65a 100644 --- a/kolibri/content/serializers.py +++ b/kolibri/content/serializers.py @@ -118,5 +118,5 @@ class Meta: model = ContentNode fields = ( 'pk', 'content_id', 'title', 'description', 'kind', 'available', 'tags', 'sort_order', 'license_owner', - 'license', 'files', 'ancestors', 'parent', 'thumbnail', 'progress_fraction', 'next_content' + 'license', 'files', 'ancestors', 'parent', 'thumbnail', 'progress_fraction', 'next_content', 'author' ) diff --git a/kolibri/content/test/test_downloadcontent.py b/kolibri/content/test/test_downloadcontent.py index 68702594868..6fc8f58e2c3 100644 --- a/kolibri/content/test/test_downloadcontent.py +++ b/kolibri/content/test/test_downloadcontent.py @@ -26,7 +26,7 @@ def setUp(self): self.client = Client() self.hash = hashlib.md5("DUMMYDATA".encode()).hexdigest() - self.extension = dict(file_formats.choices).get("pdf") + self.extension = file_formats.PDF self.filename = "{}.{}".format(self.hash, self.extension) self.title = "abc123!@#$%^&*();'[],./?><" self.contentnode = ContentNode(title=self.title) diff --git a/kolibri/core/assets/src/api-resource.js b/kolibri/core/assets/src/api-resource.js index 0e32b275b69..bc64dbb5859 100644 --- a/kolibri/core/assets/src/api-resource.js +++ b/kolibri/core/assets/src/api-resource.js @@ -17,6 +17,18 @@ class Model { throw new TypeError('resource must be defined'); } + if (!data) { + throw new TypeError('data must be defined'); + } + + if (typeof data !== 'object') { + throw new TypeError('data must be an object'); + } + + if (Object.keys(data).length === 0) { + throw new TypeError('data must be instantiated with some data'); + } + // Assign any data to the attributes property of the Model. this.attributes = {}; this.set(data); @@ -237,33 +249,32 @@ class Collection { } else { this.synced = false; this.resource.client({ path: this.url, params }).then((response) => { - // Reset current models to only include ones from this fetch. - this.models = []; - this._model_map = {}; // Set response object - an Array - on the Collection to record the data. // First check that the response *is* an Array if (Array.isArray(response.entity)) { + this.clearCache(); this.set(response.entity); + // Mark that the fetch has completed. + this.synced = true; } else { // If it's not, there are two possibilities - something is awry, or we have received // paginated data! Check to see if it is paginated. - if (typeof response.entity.results !== 'undefined') { + if (typeof (response.entity || {}).results !== 'undefined') { + this.clearCache(); // Paginated objects have 'results' as their results object so interpret this as // such. this.set(response.entity.results); this.pageCount = Math.ceil(response.entity.count / this.pageSize); this.hasNext = Boolean(response.entity.next); this.hasPrev = Boolean(response.entity.previous); + // Mark that the fetch has completed. + this.synced = true; } else { // It's all gone a bit Pete Tong. logging.debug('Data appears to be malformed', response.entity); + reject(response); } } - // Mark that the fetch has completed. - this.synced = true; - this.models.forEach((model) => { - model.synced = true; // eslint-disable-line no-param-reassign - }); // Return the data from the models, not the models themselves. resolve(this.data); // Clean up the reference to this promise @@ -288,6 +299,15 @@ class Collection { return this.resource.collectionUrl(); } + /** + * Clear this Collection's cache of models. + */ + clearCache() { + // Reset current models. + this.models = []; + this._model_map = {}; + } + /** * Make a model a member of the collection - record in the models Array, and in the mapping * from id to model. Will automatically instantiate Models for data passed in as objects, and @@ -324,9 +344,16 @@ class Collection { return this.models.reduce((synced, model) => synced && model.synced, this._synced); } + /** + * Set this Collection as synced or not, for true, will also set all models cached in it + * as synced. + * @param {Boolean} value Is this Collection synced or not? + */ set synced(value) { this._synced = value; - this.models.forEach((model) => { model.synced = true; }); + if (value) { + this.models.forEach((model) => { model.synced = true; }); + } } static key(params) { diff --git a/kolibri/core/assets/src/api-resources/index.js b/kolibri/core/assets/src/api-resources/index.js index 91fe8be4d43..172e9520909 100644 --- a/kolibri/core/assets/src/api-resources/index.js +++ b/kolibri/core/assets/src/api-resources/index.js @@ -14,4 +14,5 @@ module.exports = { ChannelResource: require('./channel'), MasteryLog: require('./masteryLog'), AttemptLog: require('./attemptLog'), + SignUpResource: require('./signUp'), }; diff --git a/kolibri/core/assets/src/api-resources/signUp.js b/kolibri/core/assets/src/api-resources/signUp.js new file mode 100644 index 00000000000..f1bff159f78 --- /dev/null +++ b/kolibri/core/assets/src/api-resources/signUp.js @@ -0,0 +1,9 @@ +const Resource = require('../api-resource').Resource; + +class SignUpResource extends Resource { + static resourceName() { + return 'signup'; + } +} + +module.exports = SignUpResource; diff --git a/kolibri/core/assets/src/constants.js b/kolibri/core/assets/src/constants.js index be3491fd0b6..9a841385344 100644 --- a/kolibri/core/assets/src/constants.js +++ b/kolibri/core/assets/src/constants.js @@ -64,10 +64,12 @@ widgets they supply to core and in what order, this is a work-around. */ const TopLevelPageNames = { - LEARN_LEARN: 'LEARN_LEARN', - LEARN_EXPLORE: 'LEARN_EXPLORE', + LEARN: 'LEARN', COACH: 'COACH', MANAGE: 'MANAGE', + USER: 'USER', + ABOUT: 'ABOUT', + PROFILE: 'PROFILE', }; module.exports = { diff --git a/kolibri/core/assets/src/core-actions.js b/kolibri/core/assets/src/core-actions.js index 3407f451390..f3879714cd0 100644 --- a/kolibri/core/assets/src/core-actions.js +++ b/kolibri/core/assets/src/core-actions.js @@ -1,10 +1,8 @@ - const cookiejs = require('js-cookie'); const UserKinds = require('./constants').UserKinds; const MasteryLoggingMap = require('./constants').MasteryLoggingMap; const AttemptLoggingMap = require('./constants').AttemptLoggingMap; -const throttle = require('lodash.throttle'); const getDefaultChannelId = require('kolibri.coreVue.vuex.getters').getDefaultChannelId; const intervalTimer = require('./timer'); @@ -163,18 +161,8 @@ function handleApiError(store, errorObject) { handleError(store, JSON.stringify(errorObject, null, '\t')); } -const debouncedSetWindowInfo = throttle((store) => { - // http://stackoverflow.com/a/8876069 - const w = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); - const h = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); - store.dispatch('SET_VIEWPORT_SIZE', w, h); -}, 33); - -function handleResize(store, event) { - debouncedSetWindowInfo(store); -} - -function kolibriLogin(store, coreApp, sessionPayload) { +function kolibriLogin(store, sessionPayload) { + const coreApp = require('kolibri'); const SessionResource = coreApp.resources.SessionResource; const sessionModel = SessionResource.createModel(sessionPayload); const sessionPromise = sessionModel.save(sessionPayload); @@ -185,7 +173,7 @@ function kolibriLogin(store, coreApp, sessionPayload) { const manageURL = coreApp.urls['kolibri:managementplugin:management'](); window.location.href = window.location.origin + manageURL; } else { - location.reload(true); + window.location.href = window.location.origin; } }).catch(error => { if (error.status.code === 401) { @@ -196,7 +184,8 @@ function kolibriLogin(store, coreApp, sessionPayload) { }); } -function kolibriLogout(store, coreApp) { +function kolibriLogout(store) { + const coreApp = require('kolibri'); const SessionResource = coreApp.resources.SessionResource; const id = 'current'; const sessionModel = SessionResource.getModel(id); @@ -209,12 +198,13 @@ function kolibriLogout(store, coreApp) { }).catch(error => { handleApiError(store, error); }); } -function getCurrentSession(store, coreApp) { +function getCurrentSession(store) { + const coreApp = require('kolibri'); const SessionResource = coreApp.resources.SessionResource; const id = 'current'; const sessionModel = SessionResource.getModel(id); const sessionPromise = sessionModel.fetch({}); - sessionPromise.then((session) => { + return sessionPromise.then((session) => { store.dispatch('CORE_SET_SESSION', _sessionState(session)); }).catch(error => { handleApiError(store, error); }); } @@ -234,7 +224,8 @@ function cancelLoginModal(store, bool) { * Create models to store logging information * To be called on page load for content renderers */ -function initContentSession(store, coreApp, channelId, contentId, contentKind) { +function initContentSession(store, channelId, contentId, contentKind) { + const coreApp = require('kolibri'); const ContentSessionLogResource = coreApp.resources.ContentSessionLogResource; const ContentSummaryLogResource = coreApp.resources.ContentSummaryLogResource; @@ -345,7 +336,8 @@ function initContentSession(store, coreApp, channelId, contentId, contentKind) { /* * Set channel state info. */ -function _setChannelState(store, coreApp, currentChannelId, channelList) { +function _setChannelState(store, currentChannelId, channelList) { + const coreApp = require('kolibri'); store.dispatch('SET_CORE_CHANNEL_LIST', channelList); store.dispatch('SET_CORE_CURRENT_CHANNEL', currentChannelId); coreApp.resources.ContentNodeResource.setChannel(currentChannelId); @@ -360,12 +352,13 @@ function _setChannelState(store, coreApp, currentChannelId, channelList) { /* * If channelId is null, choose it automatically */ -function setChannelInfo(store, coreApp, channelId = null) { +function setChannelInfo(store, channelId = null) { + const coreApp = require('kolibri'); return coreApp.resources.ChannelResource.getCollection({}, true).fetch().then( channelsData => { const channelList = _channelListState(channelsData); const thisChannelId = channelId || getDefaultChannelId(channelList); - _setChannelState(store, coreApp, thisChannelId, channelList); + _setChannelState(store, thisChannelId, channelList); }, error => { handleApiError(store, error); } ); @@ -376,7 +369,8 @@ function setChannelInfo(store, coreApp, channelId = null) { * Do a PATCH to update existing logging models * Must be called after initContentSession */ -function saveLogs(store, coreApp) { +function saveLogs(store) { + const coreApp = require('kolibri'); const ContentSessionLogResource = coreApp.resources.ContentSessionLogResource; const ContentSummaryLogResource = coreApp.resources.ContentSummaryLogResource; /* Create aliases for logs */ @@ -407,7 +401,7 @@ function saveLogs(store, coreApp) { /** summary and session log progress update for exercise **/ -function updateExerciseProgress(store, coreApp, progressPercent, forceSave = false) { +function updateExerciseProgress(store, progressPercent, forceSave = false) { /* Update the logging state with new progress information */ store.dispatch('SET_LOGGING_PROGRESS', progressPercent, progressPercent); @@ -418,7 +412,7 @@ function updateExerciseProgress(store, coreApp, progressPercent, forceSave = fal /* Save models if needed */ if (forceSave || progressPercent === 1) { - saveLogs(store, coreApp); + saveLogs(store); } } @@ -430,7 +424,7 @@ function updateExerciseProgress(store, coreApp, progressPercent, forceSave = fal * @param {float} progressPercent * @param {boolean} forceSave */ -function updateProgress(store, coreApp, progressPercent, forceSave = false) { +function updateProgress(store, progressPercent, forceSave = false) { /* Create aliases for logs */ const summaryLog = store.state.core.logging.summary; const sessionLog = store.state.core.logging.session; @@ -458,7 +452,7 @@ function updateProgress(store, coreApp, progressPercent, forceSave = false) { /* Save models if needed */ if (forceSave || completedContent || progressThresholdMet) { - saveLogs(store, coreApp); + saveLogs(store); } } @@ -469,7 +463,7 @@ function updateProgress(store, coreApp, progressPercent, forceSave = false) { * Must be called after initContentSession * @param {boolean} forceSave */ -function updateTimeSpent(store, coreApp, forceSave = false) { +function updateTimeSpent(store, forceSave = false) { /* Create aliases for logs */ const summaryLog = store.state.core.logging.summary; const sessionLog = store.state.core.logging.session; @@ -488,7 +482,7 @@ function updateTimeSpent(store, coreApp, forceSave = false) { /* Save models if needed */ if (forceSave || timeThresholdMet) { - saveLogs(store, coreApp); + saveLogs(store); } } @@ -497,9 +491,9 @@ function updateTimeSpent(store, coreApp, forceSave = false) { * Start interval timer and set start time * @param {int} interval */ -function startTrackingProgress(store, coreApp, interval = intervalTime) { +function startTrackingProgress(store, interval = intervalTime) { intervalTimer.startTimer(interval, () => { - updateTimeSpent(store, coreApp, false); + updateTimeSpent(store, false); }); } @@ -521,12 +515,13 @@ function samePageCheckGenerator(store) { * Stop interval timer and update latest times * Must be called after startTrackingProgress */ -function stopTrackingProgress(store, coreApp) { +function stopTrackingProgress(store) { intervalTimer.stopTimer(); - updateTimeSpent(store, coreApp, true); + updateTimeSpent(store, true); } -function saveMasteryLog(store, coreApp) { +function saveMasteryLog(store) { + const coreApp = require('kolibri'); const masteryLogModel = coreApp.resources.MasteryLog.getModel( store.state.core.logging.mastery.id); masteryLogModel.save(_masteryLogModel(store)).only( @@ -542,7 +537,8 @@ function setMasteryLogComplete(store, completetime) { store.dispatch('SET_LOGGING_MASTERY_COMPLETE', completetime); } -function createMasteryLog(store, coreApp, masteryLevel, masteryCriterion) { +function createMasteryLog(store, masteryLevel, masteryCriterion) { + const coreApp = require('kolibri'); const masteryLogModel = coreApp.resources.MasteryLog.createModel({ id: null, summarylog: store.state.core.logging.summary.id, @@ -565,11 +561,12 @@ function createMasteryLog(store, coreApp, masteryLevel, masteryCriterion) { ); } -function createDummyMasteryLog(store, coreApp) { +function createDummyMasteryLog(store) { /* Create a client side masterylog for anonymous user for tracking attempt-progress. This masterylog will never be saved in the database. */ + const coreApp = require('kolibri'); const masteryLogModel = coreApp.resources.MasteryLog.createModel({ id: null, summarylog: null, @@ -586,7 +583,8 @@ function createDummyMasteryLog(store, coreApp) { store.dispatch('SET_LOGGING_MASTERY_STATE', masteryLogModel.attributes); } -function saveAttemptLog(store, coreApp) { +function saveAttemptLog(store) { + const coreApp = require('kolibri'); const attemptLogModel = coreApp.resources.AttemptLog.getModel( store.state.core.logging.attempt.id); const promise = attemptLogModel.save(_attemptLogModel(store)); @@ -597,7 +595,8 @@ function saveAttemptLog(store, coreApp) { return promise; } -function createAttemptLog(store, coreApp, itemId) { +function createAttemptLog(store, itemId) { + const coreApp = require('kolibri'); const attemptLogModel = coreApp.resources.AttemptLog.createModel({ id: null, masterylog: store.state.core.logging.mastery.id || null, @@ -624,11 +623,11 @@ function updateAttemptLogInteractionHistory(store, interaction) { /** * Initialize assessment mastery log */ -function initMasteryLog(store, coreApp, masterySpacingTime, masteryCriterion) { +function initMasteryLog(store, masterySpacingTime, masteryCriterion) { if (!store.state.core.logging.mastery.id) { // id has not been set on the masterylog state, so this is undefined. // Either way, we need to create a new masterylog, with a masterylevel of 1! - createMasteryLog(store, coreApp, 1, masteryCriterion); + createMasteryLog(store, 1, masteryCriterion); } else if (store.state.core.logging.mastery.complete && ((new Date() - new Date(store.state.core.logging.mastery.completion_timestamp)) > masterySpacingTime)) { @@ -636,7 +635,7 @@ function initMasteryLog(store, coreApp, masterySpacingTime, masteryCriterion) { // masterySpacingTime time ago! // This means we need to level the user up. createMasteryLog( - store, coreApp, store.state.core.logging.mastery.mastery_level + 1, masteryCriterion); + store, store.state.core.logging.mastery.mastery_level + 1, masteryCriterion); } } @@ -645,11 +644,9 @@ function updateMasteryAttemptState(store, currentTime, correct, complete, firstA store.dispatch('UPDATE_LOGGING_ATTEMPT', currentTime, correct, complete, hinted); } - module.exports = { handleError, handleApiError, - handleResize, kolibriLogin, kolibriLogout, getCurrentSession, diff --git a/kolibri/core/assets/src/core-app/apiSpec.js b/kolibri/core/assets/src/core-app/apiSpec.js index 214c3d32e4b..7edb3dad30b 100644 --- a/kolibri/core/assets/src/core-app/apiSpec.js +++ b/kolibri/core/assets/src/core-app/apiSpec.js @@ -84,27 +84,47 @@ module.exports = { coreModal: { module: require('../vue/core-modal'), }, - navBarItem: { - module: require('../vue/nav-bar/nav-bar-item'), + navBar: { + module: require('../vue/nav-bar'), }, iconButton: { module: require('../vue/icon-button'), }, + textbox: { + module: require('../vue/textbox'), + }, channelSwitcher: { module: require('../vue/channel-switcher'), }, + tabs: { + module: require('../vue/tabs'), + }, + logo: { + module: require('../vue/logo'), + }, }, router: { module: require('../router'), }, + mixins: { + responsiveWindow: { + module: require('../mixins/responsive-window'), + }, + responsiveElement: { + module: require('../mixins/responsive-element'), + }, + }, }, styles: { - navBarItem: { - module: require('../vue/nav-bar/nav-bar-item.styl'), - }, - coreTheme: { + theme: { module: require('../styles/core-theme.styl'), }, + definitions: { + module: require('../styles/definitions.styl'), + }, + keenVars: { + module: require('../keen-config/variables.scss'), + }, }, }, }; diff --git a/kolibri/core/assets/src/core-app/constructor.js b/kolibri/core/assets/src/core-app/constructor.js index 9eb1deab675..cfad5627455 100644 --- a/kolibri/core/assets/src/core-app/constructor.js +++ b/kolibri/core/assets/src/core-app/constructor.js @@ -58,20 +58,6 @@ module.exports = class CoreApp { vue.use(vuex); vue.use(router); - // Register global components - vue.component('content-renderer', require('../vue/content-renderer')); - vue.component('assessment-wrapper', require('../vue/assessment-wrapper')); - vue.component('exercise-attempts', require('../vue/exercise-attempts')); - vue.component('download-button', require('../vue/content-renderer/download-button')); - vue.component('loading-spinner', require('../vue/loading-spinner')); - vue.component('core-modal', require('../vue/core-modal')); - vue.component('progress-bar', require('../vue/progress-bar')); - vue.component('icon-button', require('../vue/icon-button')); - vue.component('channel-switcher', require('../vue/channel-switcher')); - vue.component('content-icon', require('../vue/content-icon')); - vue.component('progress-icon', require('../vue/progress-icon')); - vue.component('core-base', require('../vue/core-base')); - this.i18n = { reversed: false, }; @@ -159,7 +145,17 @@ module.exports = class CoreApp { } get client() { - return rest.wrap(mime, { mime: 'application/json' }).wrap(csrf, { name: 'X-CSRFToken', - token: cookiejs.get('csrftoken') }).wrap(errorCode); + return (options) => { + if ((options && typeof options === 'object' && !Array.isArray(options)) && + (!options.method || options.method === 'GET')) { + if (!options.params) { + options.params = {}; + } + const cacheBust = new Date().getTime(); + options.params[cacheBust] = cacheBust; + } + return rest.wrap(mime, { mime: 'application/json' }).wrap(csrf, { name: 'X-CSRFToken', + token: cookiejs.get('csrftoken') }).wrap(errorCode)(options); + }; } }; diff --git a/kolibri/core/assets/src/core-app/constructorExport.js b/kolibri/core/assets/src/core-app/constructorExport.js index 6d742b53149..fc996c52883 100644 --- a/kolibri/core/assets/src/core-app/constructorExport.js +++ b/kolibri/core/assets/src/core-app/constructorExport.js @@ -5,7 +5,6 @@ const apiSpec = require('./apiSpec').apiSpec; const keys = require('./apiSpec').keys; - const constructorExport = () => { /* * Function for building the object that populates the kolibri global object API. @@ -34,6 +33,7 @@ const constructorExport = () => { } }; recurseObjectKeysAndImport(apiSpec); + recurseObjectKeysAndImport(__coreAPISpec); // eslint-disable-line no-undef return exportObj; }; diff --git a/kolibri/core/assets/src/core-app/index.js b/kolibri/core/assets/src/core-app/index.js index 820309c5ea3..e452b00b349 100644 --- a/kolibri/core/assets/src/core-app/index.js +++ b/kolibri/core/assets/src/core-app/index.js @@ -1,8 +1,17 @@ // include global styles -require('normalize.css'); +require('purecss/build/base-min.css'); +require('purecss/build/grids-min.css'); require('../styles/font-NotoSans.css'); -require('../styles/core-global.styl'); +require('../styles/main.styl'); + +// Required to setup Keen UI, should be imported only once in your project +require('keen-ui/src/bootstrap'); + +// configure Keen +const KeenUiConfig = require('keen-ui/src/config').default; +KeenUiConfig.set(require('../keen-config/options.json')); + // polyfill for older browsers // TODO: rtibbles whittle down these polyfills to only what is needed for the application diff --git a/kolibri/core/assets/src/core-getters.js b/kolibri/core/assets/src/core-getters.js index 022c105fc8f..1bdf71d1437 100644 --- a/kolibri/core/assets/src/core-getters.js +++ b/kolibri/core/assets/src/core-getters.js @@ -1,6 +1,9 @@ const UserKinds = require('./constants').UserKinds; const cookiejs = require('js-cookie'); +function isUserLoggedIn(state) { + return state.core.session.kind[0] !== UserKinds.ANONYMOUS; +} function isAdminOrSuperuser(state) { const kind = state.core.session.kind; @@ -43,6 +46,7 @@ function getCurrentChannelObject(state) { module.exports = { + isUserLoggedIn, isAdminOrSuperuser, isCoachAdminOrSuperuser, getDefaultChannelId, diff --git a/kolibri/core/assets/src/core-store.js b/kolibri/core/assets/src/core-store.js index eeffe00fc4c..6247c4fc3ba 100644 --- a/kolibri/core/assets/src/core-store.js +++ b/kolibri/core/assets/src/core-store.js @@ -27,11 +27,20 @@ const initialState = { loginModalVisible: false, loginError: null, logging: baseLoggingState, - viewport: { width: 0, height: 0 }, channels: { list: [], currentId: null, }, + // Hardcoded for now. Privileges set according to Zero Rating conf + learnerPrivileges: { + username: true, + name: true, + password: true, + signup: true, + delete: false, + // classActivation: false, + loginRequired: false, + }, }, }; @@ -149,10 +158,6 @@ const mutations = { state.core.logging.mastery = {}; state.core.logging.attempt = {}; }, - SET_VIEWPORT_SIZE(state, width, height) { - state.core.viewport.width = width; - state.core.viewport.height = height; - }, SET_CORE_CURRENT_CHANNEL(state, channelId) { state.core.channels.currentId = channelId; }, diff --git a/kolibri/core/assets/src/keen-config/options.json b/kolibri/core/assets/src/keen-config/options.json new file mode 100644 index 00000000000..cd959322395 --- /dev/null +++ b/kolibri/core/assets/src/keen-config/options.json @@ -0,0 +1,3 @@ +{ + "disableRipple": true +} diff --git a/kolibri/core/assets/src/keen-config/variables.scss b/kolibri/core/assets/src/keen-config/variables.scss new file mode 100644 index 00000000000..a668367df3d --- /dev/null +++ b/kolibri/core/assets/src/keen-config/variables.scss @@ -0,0 +1,3 @@ + +$brand-primary-color: #996189; +$font-stack: 'NotoSans'; diff --git a/kolibri/core/assets/src/mixins/responsive-element.js b/kolibri/core/assets/src/mixins/responsive-element.js new file mode 100644 index 00000000000..31358747312 --- /dev/null +++ b/kolibri/core/assets/src/mixins/responsive-element.js @@ -0,0 +1,49 @@ + +/* + Apply this mixin to your vue components to get reactive information + about the component's size. + + For example: + + + + + diff --git a/kolibri/core/assets/src/vue/assessment-wrapper/index.vue b/kolibri/core/assets/src/vue/assessment-wrapper/index.vue index 9b634fe9647..d0d14d514bf 100644 --- a/kolibri/core/assets/src/vue/assessment-wrapper/index.vue +++ b/kolibri/core/assets/src/vue/assessment-wrapper/index.vue @@ -45,7 +45,7 @@ oriented data synchronization. this.initMasteryLog(); } else { // if userKind is anonymous user or deviceOwner. - this.createDummyMasteryLogAction(this.Kolibri); + this.createDummyMasteryLogAction(); } this.$on('updateAMLogs', (correct, complete, firstAttempt, hinted) => { @@ -64,10 +64,10 @@ oriented data synchronization. this.updateMasteryAttemptStateAction(new Date(), correct, complete, firstAttempt, hinted); }, saveAttemptLogMasterLog(exercisePassed) { - this.saveAttemptLogAction(this.Kolibri).then(() => { + this.saveAttemptLogAction().then(() => { if (this.isFacilityUser && exercisePassed) { this.setMasteryLogCompleteAction(new Date()); - this.saveMasteryLogAction(this.Kolibri); + this.saveMasteryLogAction(); } }); }, @@ -86,7 +86,7 @@ oriented data synchronization. }); }, initMasteryLog() { - this.initMasteryLogAction(this.Kolibri, this.masterySpacingTime, this.masteryCriterion); + this.initMasteryLogAction(this.masterySpacingTime, this.masteryCriterion); }, createAttemptLog() { return new Promise((resolve, reject) => { @@ -94,13 +94,13 @@ oriented data synchronization. if (!this.itemId) { const watchRevoke = this.$watch('itemId', () => { if (this.itemId) { - this.createAttemptLogAction(this.Kolibri, this.itemId, this.newAttemptlogReady); + this.createAttemptLogAction(this.itemId, this.newAttemptlogReady); resolve(); watchRevoke(); } }); } else { - this.createAttemptLogAction(this.Kolibri, this.itemId, this.newAttemptlogReady); + this.createAttemptLogAction(this.itemId, this.newAttemptlogReady); resolve(); } }); @@ -134,6 +134,6 @@ oriented data synchronization. diff --git a/kolibri/core/assets/src/vue/channel-switcher/index.vue b/kolibri/core/assets/src/vue/channel-switcher/index.vue index c8e617e3524..cb93ba4c1d9 100644 --- a/kolibri/core/assets/src/vue/channel-switcher/index.vue +++ b/kolibri/core/assets/src/vue/channel-switcher/index.vue @@ -1,44 +1,85 @@ - diff --git a/kolibri/core/assets/src/vue/content-icon/content-icons/audio.svg b/kolibri/core/assets/src/vue/content-icon/content-icons/audio.svg deleted file mode 100644 index 77fc9c310c8..00000000000 --- a/kolibri/core/assets/src/vue/content-icon/content-icons/audio.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/kolibri/core/assets/src/vue/content-icon/content-icons/document.svg b/kolibri/core/assets/src/vue/content-icon/content-icons/document.svg deleted file mode 100644 index 6ae8360e9b8..00000000000 --- a/kolibri/core/assets/src/vue/content-icon/content-icons/document.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/kolibri/core/assets/src/vue/content-icon/content-icons/exercise.svg b/kolibri/core/assets/src/vue/content-icon/content-icons/exercise.svg deleted file mode 100644 index 742e71af5c7..00000000000 --- a/kolibri/core/assets/src/vue/content-icon/content-icons/exercise.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/kolibri/core/assets/src/vue/content-icon/content-icons/topic.svg b/kolibri/core/assets/src/vue/content-icon/content-icons/topic.svg deleted file mode 100644 index 91729fed1a2..00000000000 --- a/kolibri/core/assets/src/vue/content-icon/content-icons/topic.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/kolibri/core/assets/src/vue/content-icon/content-icons/user.svg b/kolibri/core/assets/src/vue/content-icon/content-icons/user.svg deleted file mode 100644 index 5fbd2b29e44..00000000000 --- a/kolibri/core/assets/src/vue/content-icon/content-icons/user.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/kolibri/core/assets/src/vue/content-icon/content-icons/video.svg b/kolibri/core/assets/src/vue/content-icon/content-icons/video.svg deleted file mode 100644 index f8bb1780172..00000000000 --- a/kolibri/core/assets/src/vue/content-icon/content-icons/video.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/kolibri/core/assets/src/vue/content-icon/index.vue b/kolibri/core/assets/src/vue/content-icon/index.vue index 7d6bfd766a3..2e13e1224eb 100644 --- a/kolibri/core/assets/src/vue/content-icon/index.vue +++ b/kolibri/core/assets/src/vue/content-icon/index.vue @@ -1,31 +1,43 @@ @@ -59,6 +71,9 @@ return `color-${this.colorStyle}`; }, }, + components: { + 'ui-icon': require('keen-ui/src/UiIcon'), + }, methods: { is(kind) { return this.kind === kind; @@ -71,19 +86,9 @@ diff --git a/kolibri/core/assets/src/vue/content-renderer/download-button.vue b/kolibri/core/assets/src/vue/content-renderer/download-button.vue index d29a477fd50..b7fe9855733 100644 --- a/kolibri/core/assets/src/vue/content-renderer/download-button.vue +++ b/kolibri/core/assets/src/vue/content-renderer/download-button.vue @@ -6,16 +6,16 @@ class="dropdown-button" @click="toggleDropdown" aria-haspopup="true"> - + {{ $tr('downloadContent') }}