diff --git a/.buildkite/build_whl.sh b/.buildkite/build_whl.sh new file mode 100755 index 0000000000..71da2ed661 --- /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/*.tar.gz' +buildkite-agent artifact upload 'dist/*.pex' diff --git a/.buildkite/build_windows_installer.sh b/.buildkite/build_windows_installer.sh new file mode 100755 index 0000000000..6c148ee5f7 --- /dev/null +++ b/.buildkite/build_windows_installer.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PARENT_PATH=$(pwd) +KALITE_DOCKER_PATH="$PARENT_PATH/windows_installer_docker_build" +KALITE_WINDOWS_PATH="$KALITE_DOCKER_PATH/ka-lite-installers/windows" + +# Download artifacts to dist/ +mkdir -p dist +buildkite-agent artifact download 'dist/*.whl' dist/ +make dockerwriteversion + +# Download content pack +cd dist/ && wget http://pantry.learningequality.org/downloads/ka-lite/0.17/content/contentpacks/en.zip + +# Clone KA-Lite installers +cd $KALITE_DOCKER_PATH +git clone https://github.com/learningequality/ka-lite-installers.git && cd ka-lite-installers && git checkout 0.17.x +cd $KALITE_WINDOWS_PATH && wget http://pantry.learningequality.org/downloads/ka-lite/0.17/content/contentpacks/en.zip + +# Copy kalite whl files to kalite windows installer path +COPY_WHL_CMD="cp $PARENT_PATH/dist/*.whl $KALITE_WINDOWS_PATH" +$COPY_WHL_CMD + +# Copy en content pack to windows installer path +COPY_CONTENT_PACK_CMD="cp $PARENT_PATH/dist/en.zip $KALITE_WINDOWS_PATH" +$COPY_CONTENT_PACK_CMD + +# Build KA-Lite windows installer docker image +KALITE_BUILD_VERSION=$(cat $PARENT_PATH/kalite/VERSION) +cd $KALITE_DOCKER_PATH +DOCKER_BUILD_CMD="docker image build -t $KALITE_BUILD_VERSION-build ." +$DOCKER_BUILD_CMD + +# Create directory for the built installer +INSTALLER_PATH="$KALITE_DOCKER_PATH/installer" +mkdir -p $INSTALLER_PATH + +# Run KA-Lite windows installer docker image. +DOCKER_RUN_CMD="docker run -v $INSTALLER_PATH:/installer/ $KALITE_BUILD_VERSION-build" +$DOCKER_RUN_CMD + +cd $KALITE_DOCKER_PATH +buildkite-agent artifact upload './installer/*.exe' diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 9ace12ee41..9538968ff1 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -4,5 +4,15 @@ steps: - wait - - label: Build the python packages and windows installer - command: mkdir -p dist && .buildkite/build_and_upload_artifact.sh && docker container prune -f \ No newline at end of file + - label: Build python packages + command: mkdir -p dist && .buildkite/build_whl.sh && docker container prune -f + + - wait + + - label: Build windows installer + command: .buildkite/build_windows_installer.sh + + - wait + + - label: Upload artifacts + command: .buildkite/setup_and_upload_artifacts.sh && docker image prune -f diff --git a/.buildkite/setup_and_upload_artifacts.sh b/.buildkite/setup_and_upload_artifacts.sh new file mode 100755 index 0000000000..70f244786f --- /dev/null +++ b/.buildkite/setup_and_upload_artifacts.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPTPATH=$(pwd) +PIP="$SCRIPTPATH/env/bin/pip" +PYTHON="$SCRIPTPATH/env/bin/python" + +echo "Creating virtualenv..." +virtualenv -p python3 env + +echo "Installing requirements..." +$PIP install -r requirements_pipeline.txt + +echo "Preparing artifact directories" +mkdir -p dist +mkdir -p installer + +echo "Downloading artifacts..." +buildkite-agent artifact download 'dist/*.pex' dist/ +buildkite-agent artifact download 'dist/*.whl' dist/ +buildkite-agent artifact download 'dist/*.zip' dist/ +buildkite-agent artifact download 'dist/*.tar.gz' dist/ +buildkite-agent artifact download 'installer/*.exe' installer/ + +echo "Executing upload script..." +$PYTHON .buildkite/upload_artifacts.py diff --git a/.buildkite/upload_artifacts.py b/.buildkite/upload_artifacts.py new file mode 100644 index 0000000000..640a7348ee --- /dev/null +++ b/.buildkite/upload_artifacts.py @@ -0,0 +1,235 @@ +""" +# Requirements: + * Generate access token in your Github account, then create environment variable GITHUB_ACCESS_TOKEN. + - e.g export GITHUB_ACCESS_TOKEN=1ns3rt-my-t0k3n-h3re. + * Generate a service account key for your Google API credentials, then create environment variable GOOGLE_APPLICATION_CREDENTIALS. + - e.g export GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json. +# Environment Variable/s: + * IS_KALITE_RELEASE = Upload artifacts to the Google Cloud as a release candidate. + * GITHUB_ACCESS_TOKEN = Personal access token used to authenticate in your Github account via API. + * BUILDKITE_BUILD_NUMBER = Build identifier for each directory created. + * BUILDKITE_PULL_REQUEST = Pull request issue or the value is false. + * BUILDKITE_TAG = Tag identifier if this build was built from a tag. + * BUILDKITE_COMMIT = Git commit hash that the build was made from. + * GOOGLE_APPLICATION_CREDENTIALS = Your service account key. +""" + +import logging +import os +import sys + +import requests +from gcloud import storage +from github3 import login + +logging.getLogger().setLevel(logging.INFO) + +ACCESS_TOKEN = os.getenv("GITHUB_ACCESS_TOKEN") +REPO_OWNER = "learningequality" +REPO_NAME = "ka-lite" +ISSUE_ID = os.getenv("BUILDKITE_PULL_REQUEST") +BUILD_ID = os.getenv("BUILDKITE_BUILD_NUMBER") +TAG = os.getenv("BUILDKITE_TAG") +COMMIT = os.getenv("BUILDKITE_COMMIT") + +RELEASE_DIR = 'release' +PROJECT_PATH = os.path.join(os.getcwd()) + +# Python packages artifact location +DIST_DIR = os.path.join(PROJECT_PATH, "dist") +# Installer artifact location +INSTALLER_DIR = os.path.join(PROJECT_PATH, "installer") + +headers = {'Authorization': 'token %s' % ACCESS_TOKEN} + +INSTALLER_CAT = "Installers" +PYTHON_PKG_CAT = "Python Packages" + +# Manifest of files keyed by extension + +file_manifest = { + 'exe': { + 'extension': 'exe', + 'description': 'Windows Installer', + 'category': INSTALLER_CAT, + 'content_type': 'application/x-ms-dos-executable', + }, + 'pex': { + 'extension': 'pex', + 'description': 'Pex file', + 'category': PYTHON_PKG_CAT, + 'content_type': 'application/octet-stream', + }, + 'whl': { + 'extension': 'whl', + 'description': 'Whl file', + 'category': PYTHON_PKG_CAT, + 'content_type': 'application/zip', + }, + 'gz': { + 'extension': 'gz', + 'description': 'Tar file', + 'category': PYTHON_PKG_CAT, + 'content_type': 'application/gzip', + }, +} + +file_order = [ + 'exe', + 'pex', + 'whl', + 'gz', +] + +gh = login(token=ACCESS_TOKEN) +repository = gh.repository(REPO_OWNER, REPO_NAME) + +def create_status_report_html(artifacts): + """ + Create html page to list build artifacts for linking from github status. + """ + html = "\nKA-Lite Buildkite Assets – Build #{build_id}\n".format(build_id=BUILD_ID) + html += "\n

Build Artifacts

\n" + current_heading = None + + for ext in file_order: + artifacts_list = [] + + for artifact_dict in artifacts: + if artifact_dict['extension'] == ext: + artifacts_list.append(artifact_dict) + + for artifact in artifacts_list: + if artifact['category'] != current_heading: + current_heading = artifact['category'] + html += "

{heading}

\n".format(heading=current_heading) + html += "

{description}: {name}

\n".format( + **artifact) + html += "\n" + return html + +def create_github_status(report_url): + """ + Create a github status with a link to the report URL, + only do this once buildkite has been successful, so only report success here. + """ + status = repository.create_status( + COMMIT, + "success", + target_url=report_url, + description="KA-Lite Buildkite assets", + context="buildkite/kalite/assets" + ) + + if status: + logging.info("Successfully created GitHub status for commit {commit}.".format(commit=COMMIT)) + else: + logging.info("Error encountered. Now exiting!") + sys.exit(1) + +def collect_local_artifacts(): + """ + Create a list of artifacts + """ + + collected_artifacts = [] + + def create_artifact_data(artifact_dir): + for artifact in os.listdir(artifact_dir): + filename, file_extension = os.path.splitext(artifact) + file_extension = file_extension[1:] # Remove leading '.' + + if file_extension in file_manifest: + data = { + 'name': artifact, + 'file_location': "{artifact_dir}/{artifact}".format(artifact_dir=artifact_dir, artifact=artifact) + } + data.update(file_manifest[file_extension]) + logging.info("Collecting file data: {data}".format(data=data)) + collected_artifacts.append(data) + + create_artifact_data(DIST_DIR) + create_artifact_data(INSTALLER_DIR) + + return collected_artifacts + +def upload_artifacts(): + """ + Upload the artifacts to the Google Cloud Storage + Create a github status on the pull requester with the artifact media link. + """ + + client = storage.Client() + bucket = client.bucket("le-downloads") + artifacts = collect_local_artifacts() + is_release = os.getenv("IS_KALITE_RELEASE") + + for artifact in artifacts: + logging.info("Uploading file {filename}".format(filename=artifact.get("name"))) + + if is_release: + blob = bucket.blob("kalite/{release_dir}/{build_id}/{filename}".format( + release_dir=RELEASE_DIR, + build_id=BUILD_ID, + filename=artifact.get("name") + )) + else: + blob = bucket.blob("kalite/buildkite/build-{issue_id}/{build_id}/{filename}".format( + issue_id=ISSUE_ID, + build_id=BUILD_ID, + filename=artifact.get("name") + )) + + blob.upload_from_filename(filename=artifact.get("file_location")) + blob.make_public() + artifact.update({'media_url': blob.media_link}) + + html = create_status_report_html(artifacts) + blob = bucket.blob("kalite/{release_dir}/{build_id}/report.html".format(release_dir=RELEASE_DIR, build_id=BUILD_ID)) + blob.upload_from_string(html, content_type='text/html') + blob.make_public() + + logging.info("Status Report link: {}".format(blob.public_url)) + create_github_status(blob.public_url) + + if TAG: + # Building from a tag, this is probably a release! + get_release_asset_url = requests.get("https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}".format( + owner=REPO_OWNER, + repo=REPO_NAME, + tag=TAG + )) + + if get_release_asset_url.status_code == 200: + release_id = get_release_asset_url.json()['id'] + release_name = get_release_asset_url.json()['name'] + release = repository.release(id=release_id) + logging.info("Uploading build assets to GitHub Release: {release_name}".format(release_name=release_name)) + + for ext in file_order: + artifact_list = [] + for artifact_dict in artifacts: + if artifact_dict['extension'] == ext: + artifact_list.append(artifact_dict) + + for artifact in artifact_list: + logging.info("Uploading release asset: {artifact_name}".format(artifact.get('name'))) + # For some reason github3 does not let us set a label at initial upload + asset = release.upload_asset( + content_type=['content_type'], + name=artifact['name'], + asset=open(artifact['file_location'], 'rb') + ) + + if asset: + # So do it after the initial upload instead + asset.edit(artifact['name'], label=artifact['description']) + logging.info("Successfully uploaded release asset: {artifact}".format(artifact=artifact.get('name'))) + else: + logging.info("Error uploading release asset: {artifact}".format(artifact=artifact.get('name'))) + +def main(): + upload_artifacts() + +if __name__ == "__main__": + main() diff --git a/Dockerfile b/Dockerfile index 0f304b922c..0c7f985f66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,37 +5,25 @@ 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 - -RUN apt-get -y update && apt-get install -y python2.7 python-pip git nodejs gettext python-sphinx wget - -# Install wine and related packages -RUN dpkg --add-architecture i386 -RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates sudo software-properties-common -RUN add-apt-repository -y ppa:ubuntu-wine/ppa && apt-get -y update && apt-get install --no-install-recommends --assume-yes wine - +RUN apt-get -y update && apt-get install -y python2.7 python-pip git nodejs gettext wget COPY . /kalite VOLUME /kalitedist/ +# Use virtualenv's pip +ENV PIP=/kalite/kalite_env/bin/pip + # for mounting the whl files into other docker containers RUN pip install virtualenv && virtualenv /kalite/kalite_env --python=python2.7 -RUN /kalite/kalite_env/bin/pip install -r /kalite/requirements_dev.txt \ - && /kalite/kalite_env/bin/pip install -r /kalite/requirements_sphinx.txt \ - && /kalite/kalite_env/bin/pip install -e /kalite/. + +RUN $PIP install -r /kalite/requirements_dev.txt \ + && $PIP install -r /kalite/requirements_sphinx.txt \ + && $PIP install -e /kalite/. \ + && $PIP install pex # Override the PATH to add the path of our virtualenv python binaries first so it's python executes instead of # the system python. ENV PATH=/kalite/kalite_env/bin:$PATH ENV KALITE_PYTHON=/kalite/kalite_env/bin/python -# Installer dependencies -RUN cd /kalite/ && git clone https://github.com/learningequality/ka-lite-installers.git && cd /kalite/ka-lite-installers && git checkout 0.17.x -RUN cd /kalite/ka-lite-installers/windows && wget http://pantry.learningequality.org/downloads/ka-lite/0.17/content/contentpacks/en.zip - -# Build the python packages and the ka-lite windows installer -CMD cd /kalite && make dist \ - && cd /kalite/ka-lite-installers/windows \ - && cp -R /kalite/dist/ka_lite_static-*-py2-none-any.whl /kalite/ka-lite-installers/windows \ - && export KALITE_BUILD_VERSION=$(/kalite/kalite_env/bin/kalite --version) \ - && wine inno-compiler/ISCC.exe installer-source/KaliteSetupScript.iss \ - && cp /kalite/dist/* /kalitedist/ \ - && cp /kalite/ka-lite-installers/windows/KALiteSetup-$(/kalite/kalite_env/bin/kalite --version).exe /kalitedist/ +CMD cd /kalite && make dist pex && cp /kalite/dist/* /kalitedist/ diff --git a/Makefile b/Makefile index c276558876..0e455ccb61 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,9 @@ clean-test: rm -fr htmlcov/ clean-assets: - npm cache clean + # This is only relevant for npm<5 + # https://github.com/learningequality/ka-lite/issues/5519 + # npm cache clean rm -rf kalite/database/templates/ rm -rf .kalite_dist_tmp @@ -98,7 +100,7 @@ assets: bin/kalite manage retrievecontentpack empty en --foreground --template msgids: - export IGNORE_PATTERNS="*/kalite/static-libraries/*,*/LC_MESSAGES/*,*/kalite/packages/dist/*,*/kalite/packages/bundled/django/*,*/kalite/*bundle*.js,*/kalite/*/js/i18n/*.js" ;\ + export IGNORE_PATTERNS="*/kalite/static-libraries/*,*/LC_MESSAGES/*,*/kalite/packages/dist/*,*/kalite/packages/bundled/django/*,*/kalite/*/bundles/bundle*.js,*/kalite/*/js/i18n/*.js" ;\ cd kalite ;\ kalite manage makemessages -len --no-obsolete ;\ kalite manage makemessages -len --no-obsolete --domain=djangojs @@ -125,6 +127,10 @@ dist: clean docs assets python setup.py bdist_wheel --static --no-clean ls -l dist +pex: + pex -m kalite dist/ka_lite_static*.whl --disable-cache -o dist/ka-lite-static$$(git describe).pex + ls -l dist + install: clean python setup.py install diff --git a/README.rst b/README.rst index 5989be424a..224118a5cd 100644 --- a/README.rst +++ b/README.rst @@ -9,7 +9,7 @@ by `Learning Equality `__ :target: https://circleci.com/gh/learningequality/ka-lite/tree/develop .. |Coverage Status| image:: http://codecov.io/github/learningequality/ka-lite/coverage.svg?branch=develop - :target: http://codecov.io/github/learningequality/kolibri?branch=develop + :target: http://codecov.io/github/learningequality/ka-lite?branch=develop .. |Docs| image:: https://img.shields.io/badge/docs-latest-brightgreen.svg?style=flat :target: http://ka-lite.readthedocs.org/ diff --git a/docs/developer_docs/behave_testing.rst b/docs/developer_docs/behave_testing.rst index 9ea70b08a1..555901aa82 100644 --- a/docs/developer_docs/behave_testing.rst +++ b/docs/developer_docs/behave_testing.rst @@ -1,3 +1,5 @@ +.. _bdd: + Behavior-Driven Integration Tests ================================= diff --git a/docs/developer_docs/environment.rst b/docs/developer_docs/environment.rst index d9eee8d177..ed403a9430 100644 --- a/docs/developer_docs/environment.rst +++ b/docs/developer_docs/environment.rst @@ -1,7 +1,7 @@ .. _development-environment: -Setting up your development environment -======================================= +Getting started +=============== .. warning:: These directions may be out of date! This page needs to be consolidated with the `Getting Started page on our wiki `_. @@ -69,10 +69,10 @@ __________ You can install KA Lite in its very own separate environment that does not interfere with other Python software on your machine like this:: - $> pip install virtualenv virtualenvwrapper - $> mkvirtualenv my-kalite-env - $> workon my-kalite-env - $> pip install ka-lite + pip install virtualenv virtualenvwrapper + mkvirtualenv my-kalite-env + workon my-kalite-env + pip install ka-lite Running tests @@ -83,3 +83,20 @@ On Circle CI, we run Selenium 2.53.6 because it works in their environment. Howe for more recent versions of Firefox, you need to upgrade Selenium:: pip install selenium\<3.5 --upgrade + +To run all of the tests (this is slow):: + + kalite manage test + +To skip BDD tests (because they are slow):: + + kalite manage test --no-bdd + +To run a specific test (not a BDD test), add an argument ``.``:: + + kalite manage test updates.TestDownload --no-bdd + +To run a specific item from :ref:`bdd`, use ``.``:: + + kalite manage test distributed.content_rating --bdd-only + diff --git a/docs/developer_docs/index.rst b/docs/developer_docs/index.rst index 81a581ad98..6ccda5b104 100644 --- a/docs/developer_docs/index.rst +++ b/docs/developer_docs/index.rst @@ -4,7 +4,7 @@ Developer Docs Useful stuff our devs think that the rest of our devs ought to know about. .. toctree:: - Setting up your development environment + Getting started Front-End Code Javascript Unit Tests Behavior-Driven Integration Tests diff --git a/docs/installguide/release_notes.rst b/docs/installguide/release_notes.rst index 0935b24c20..9c33f65ff0 100644 --- a/docs/installguide/release_notes.rst +++ b/docs/installguide/release_notes.rst @@ -8,6 +8,49 @@ to read the release notes. upgrading from ``0.16.x`` to ``0.17.x`` is fine - but upgrading from ``0.15.x`` to ``0.17.x`` is not guaranteed to work. + +0.17.4 +------ + +Added +^^^^^ + +* Progress displayed while downloading content packs :url-issue:`5356` + +Bug fixes +^^^^^^^^^ + +* Video download retry upon connection establishment errors :url-issue:`5528` +* Resume download if unplugged connection while downloading :url-issue:`5545` +* Blank videos produced when ``kalite.learningequality.org`` server down :url-issue:`5538` +* Video download jobs hanging indefinitely after pressing "Cancel" :url-issue:`5545` +* Also delete content database when deleting a content pack :url-issue:`5545` +* Simplified login is now working when there are 1,000 or more users registered in a facility. :url-issue:`5523` + +New Features +^^^^^^^^^^^^ + +* Customizable welcome message setting ``KALITE_WELCOME_MESSAGE`` displayed to admin users on first login :url-issue:`5522` + +Developers +^^^^^^^^^^ + +* Do not use `npm clean`, now requires npm>=5 for building on unclean systems :url-issue:`5519` + + +Known issues +^^^^^^^^^^^^ + +* It isn't possible to cancel video downloads if a video is downloading while + the connection is switched off. +* **Chrome 55-56** has issues scrolling the menus on touch devices. Upgrading to Chrome 57 fixes this. :url-issue:`5407` +* **Windows** needs at least Python 2.7.11. The Windows installer for KA Lite will install the latest version of Python. If you installed KA Lite in another way, and your Python installation is more than a year old, you probably have to upgrade Python - you can fetch the latest 2.7.12 version `here `__. +* **Windows** installer tray application option "Run on start" does not work, see `learningequality/installers#106 `__ (also contains `a work-around `__) +* **Windows + IE9** One-Click device registration is broken. Work-around: Use a different browser or use manual device registration. :url-issue:`5409` +* **Firefox 47**: Subtitles are misaligned in the video player. This is fixed by upgrading Firefox. +* A limited number of exercises with radio buttons have problems displaying :url-issue:`5172` + + 0.17.3 ------ diff --git a/docs/usermanual/userman_admin.rst b/docs/usermanual/userman_admin.rst index 8074469769..10e08303e7 100644 --- a/docs/usermanual/userman_admin.rst +++ b/docs/usermanual/userman_admin.rst @@ -656,6 +656,9 @@ ____________________ service itself is running from. Setting this option will change certain system messages to use a different port. It does not affect the port that KA Lite is using. +* ``KALITE_WELCOME_MESSAGE = `` + HTML code to be rendered to admin users on their first login. Default text + links to offline documentation and online support forums. User restrictions @@ -674,7 +677,7 @@ _________________ In order to keep local data in the ``UserLog`` model, detailing usage, you can choose the number of ``UserLog`` objects that you wish to retain. These objects are not sync'ed. -Online Synchronization +Online synchronization ______________________ * ``USER_LOG_SUMMARY_FREQUENCY = `` @@ -688,6 +691,15 @@ ______________________ When you log in to our online server, you will see a *full* history of these records. +Other settings +______________ + +* ``KALITE_DOWNLOAD_RETRIES = `` + ``(default = 5)`` + If you are trying to download videos with a very unstable connection, this + setting will increase the number of retries for individual video downloads. + The grace period between each attempt automatically increases. + Environment variables _____________________ diff --git a/kalite/control_panel/templates/control_panel/base.html b/kalite/control_panel/templates/control_panel/base.html index cbc6a5995e..faa802f6ef 100644 --- a/kalite/control_panel/templates/control_panel/base.html +++ b/kalite/control_panel/templates/control_panel/base.html @@ -30,6 +30,9 @@ {% endblock headcss %} {% block content %} + {% if request.session.logged_in_first_time and request.is_admin %} + {% include "control_panel/partials/_welcome_message.html" %} + {% endif %} {% block subnavbar %}{{block.super}}{% endblock subnavbar %} diff --git a/kalite/control_panel/templates/control_panel/partials/_welcome_message.html b/kalite/control_panel/templates/control_panel/partials/_welcome_message.html new file mode 100644 index 0000000000..63d98d40b4 --- /dev/null +++ b/kalite/control_panel/templates/control_panel/partials/_welcome_message.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/kalite/distributed/custom_context_processors.py b/kalite/distributed/custom_context_processors.py index a32836cdcc..ecafa1fc5d 100644 --- a/kalite/distributed/custom_context_processors.py +++ b/kalite/distributed/custom_context_processors.py @@ -36,5 +36,6 @@ def custom(request): "False": False, "is_config_package_nalanda": getattr(settings, 'NALANDA', False), "HIDE_CONTENT_RATING": settings.HIDE_CONTENT_RATING, - "universal_js_user_error": settings.AJAX_ERROR + "universal_js_user_error": settings.AJAX_ERROR, + "welcome_message": settings.KALITE_WELCOME_MESSAGE, } diff --git a/kalite/distributed/management/commands/retrievecontentpack.py b/kalite/distributed/management/commands/retrievecontentpack.py index f879d57277..f936b75e37 100644 --- a/kalite/distributed/management/commands/retrievecontentpack.py +++ b/kalite/distributed/management/commands/retrievecontentpack.py @@ -1,5 +1,7 @@ +import logging import os import shutil +import socket import tempfile import zipfile @@ -20,8 +22,10 @@ from kalite.updates.management.utils import UpdatesStaticCommand from peewee import SqliteDatabase from kalite.topic_tools.content_models import Item, AssessmentItem +from requests.exceptions import ConnectionError, HTTPError -logging = django_settings.LOG + +logger = logging.getLogger(__name__) class Command(UpdatesStaticCommand): @@ -143,8 +147,22 @@ def download(self, *args, **options): lang = args[1] + def download_callback(fraction): + percent = int(fraction * 100) + self.update_stage( + fraction, + stage_status=_("Downloaded {pct}% of {lang} content pack").format( + pct=percent, + lang=lang, + ) + ) + with tempfile.NamedTemporaryFile() as f: - zf = download_content_pack(f, lang) + try: + zf = download_content_pack(f, lang, callback=download_callback) + except (socket.error, ConnectionError, HTTPError) as e: + self.cancel("Could not download content pack, unable to connect", str(e)) + return self.process_content_pack(zf, lang) zf.close() @@ -194,7 +212,7 @@ def extract_catalog_files(zf, lang): for zipmo, djangomo in filename_mapping.items(): zipmof = zf.open(zipmo) mopath = os.path.join(modir, djangomo) - logging.debug("writing to %s" % mopath) + logger.debug("writing to %s" % mopath) with open(mopath, "wb") as djangomof: shutil.copyfileobj(zipmof, djangomof) diff --git a/kalite/distributed/static/js/distributed/perseus/ke/package.json b/kalite/distributed/static/js/distributed/perseus/ke/package.json index 1ad124de01..782e1f0e99 100644 --- a/kalite/distributed/static/js/distributed/perseus/ke/package.json +++ b/kalite/distributed/static/js/distributed/perseus/ke/package.json @@ -23,7 +23,7 @@ "devDependencies": { "jison" : "0.4.13", "cheerio": "0.19.0", - "uglify-js": "2.4.20", + "uglify-js": "2.6.0", "jshint": "2.7.0" } } diff --git a/kalite/facility/api_resources.py b/kalite/facility/api_resources.py index e89fc4ec48..2a4b85cdaa 100644 --- a/kalite/facility/api_resources.py +++ b/kalite/facility/api_resources.py @@ -59,6 +59,9 @@ def flag_facility_cache(**kwargs): global FACILITY_LIST FACILITY_LIST = None +def logged_in_first_time(user): + return user.last_login == user.date_joined + post_save.connect(flag_facility_cache, sender=Facility) class FacilityUserResource(ModelResource): @@ -72,6 +75,7 @@ class Meta: 'facility': ALL_WITH_RELATIONS, 'is_teacher': ['exact'] } + max_limit = 0 exclude = ["password"] def prepend_urls(self): @@ -102,6 +106,7 @@ def login(self, request, **kwargs): if not settings.CENTRAL_SERVER: user = authenticate(username=username, password=password) if user: + request.session["logged_in_first_time"] = logged_in_first_time(user) login(request, user) return self.create_response(request, { 'success': True, diff --git a/kalite/i18n/base.py b/kalite/i18n/base.py index b13b326cc2..e344715faf 100644 --- a/kalite/i18n/base.py +++ b/kalite/i18n/base.py @@ -1,8 +1,8 @@ import errno +import logging import os import re import shutil -import urllib import zipfile from distutils.version import LooseVersion from fle_utils.internet.webcache import invalidate_web_cache @@ -19,17 +19,24 @@ from fle_utils.config.models import Settings from fle_utils.general import ensure_dir, softload_json +from fle_utils.internet.download import download_file +import sys +from kalite.topic_tools.settings import CONTENT_DATABASE_PATH -CONTENT_PACK_URL_TEMPLATE = ("http://pantry.learningequality.org/downloads" - "/ka-lite/{version}/content/contentpacks/{langcode}{suffix}.zip") +CONTENT_PACK_URL_TEMPLATE = ( + "http://pantry.learningequality.org/downloads" + "/ka-lite/{version}/content/contentpacks/{langcode}.zip" +) CACHE_VARS = [] from django.conf import settings -logging = settings.LOG + + +logger = logging.getLogger(__name__) class LanguageNotFoundError(Exception): @@ -98,7 +105,7 @@ def get_code2lang_map(lang_code=None, force=False): global CODE2LANG_MAP if force or not CODE2LANG_MAP: - lmap = softload_json(settings.LANG_LOOKUP_FILEPATH, logger=logging.debug) + lmap = softload_json(settings.LANG_LOOKUP_FILEPATH, logger=logger.debug) CODE2LANG_MAP = {} for lc, entry in lmap.iteritems(): @@ -228,12 +235,12 @@ def _get_installed_language_packs(): metadata_filepath = os.path.join(locale_dir, django_disk_code, "%s_metadata.json" % lcode_to_ietf(django_disk_code)) lang_meta = softload_json(metadata_filepath, raises=True) - logging.debug("Found language pack %s" % (django_disk_code)) + logger.debug("Found language pack %s" % (django_disk_code)) except IOError as e: if e.errno == errno.ENOENT: - logging.info("Ignoring non-language pack %s in %s" % (django_disk_code, locale_dir)) + logger.info("Ignoring non-language pack %s in %s" % (django_disk_code, locale_dir)) else: - logging.error("Error reading %s metadata (%s): %s" % (django_disk_code, metadata_filepath, e)) + logger.error("Error reading %s metadata (%s): %s" % (django_disk_code, metadata_filepath, e)) continue installed_language_packs.append(lang_meta) @@ -273,9 +280,9 @@ def update_jsi18n_file(code="en"): try: icu_js = open(os.path.join(path, code, "%s_icu.js" % code), "r").read() except IOError: - logging.warn("No {code}_icu.js file found in locale_path {path}".format(code=code, path=path)) + logger.warn("No {code}_icu.js file found in locale_path {path}".format(code=code, path=path)) output_js = response.content + "\n" + icu_js - logging.info("Writing i18nized js file to {0}".format(output_file)) + logger.info("Writing i18nized js file to {0}".format(output_file)) with open(output_file, "w") as fp: fp.write(output_js) translation.deactivate() @@ -309,7 +316,7 @@ def select_best_available_language(target_code, available_codes=None): store_cache = True available_codes = get_installed_language_packs().keys() - # logging.debug("choosing best language among %s" % (available_codes)) + # logger.debug("choosing best language among %s" % (available_codes)) # Make it a tuple so we can hash it available_codes = [lcode_to_django_lang(lc) for lc in available_codes if lc] @@ -329,7 +336,7 @@ def select_best_available_language(target_code, available_codes=None): raise RuntimeError("No languages found") # if actual_code != target_code: - # logging.debug("Requested code %s, got code %s" % (target_code, actual_code)) + # logger.debug("Requested code %s, got code %s" % (target_code, actual_code)) # Store in cache when available_codes are not set if store_cache: @@ -345,12 +352,21 @@ def delete_language(lang_code): for langpack_resource_path in langpack_resource_paths: try: shutil.rmtree(langpack_resource_path) - logging.info("Deleted language pack resource path: %s" % langpack_resource_path) + logger.info("Deleted language pack resource path: %s" % langpack_resource_path) except OSError as e: if e.errno != 2: # Only ignore error: No Such File or Directory raise else: - logging.debug("Not deleting missing language pack resource path: %s" % langpack_resource_path) + logger.debug("Not deleting missing language pack resource path: %s" % langpack_resource_path) + + # Delete the content db of particular language + content_db = CONTENT_DATABASE_PATH.format(channel="khan", language=lang_code) + if os.path.isfile(content_db): + try: + os.unlink(content_db) + except OSError as e: + if e.errno != 2: # Only ignore error: No Such File or Directory + raise invalidate_web_cache() @@ -361,7 +377,7 @@ def set_request_language(request, lang_code): lang_code = select_best_available_language(lang_code) # output is in django_lang format if lang_code != request.session.get(settings.LANGUAGE_COOKIE_NAME): - logging.debug("setting request language to %s (session language %s), from %s" % (lang_code, request.session.get("default_language"), request.session.get(settings.LANGUAGE_COOKIE_NAME))) + logger.debug("setting request language to %s (session language %s), from %s" % (lang_code, request.session.get("default_language"), request.session.get(settings.LANGUAGE_COOKIE_NAME))) # Just in case we have a db-backed session, don't write unless we have to. request.session[settings.LANGUAGE_COOKIE_NAME] = lang_code @@ -376,29 +392,33 @@ def translate_block(language): translation.deactivate() -def download_content_pack(fobj, lang, minimal=False): +def get_content_pack_url(lang): + return CONTENT_PACK_URL_TEMPLATE.format( + version=SHORTVERSION, + langcode=lang, + ) + +def download_content_pack(fobj, lang, callback=None): """Given a file object where the content pack lang will be stored, return a zipfile object pointing to the content pack. + """ + url = get_content_pack_url(lang) - If minimal is set to True, append the "-minimal" flag when downloading the - contentpack. + logger.info("Downloading content pack from {}".format(url)) - """ - url = CONTENT_PACK_URL_TEMPLATE.format( - version=SHORTVERSION, - langcode=lang, - suffix="-minimal" if minimal else "", - ) + def _callback(fraction): + sys.stdout.write("\rProgress: [{}{}] ({}%)".format( + "=" * (int(fraction * 100) / 2), + " " * (50 - int(fraction * 100) / 2), + int(fraction * 100), + )) + if callback: + callback(fraction) - logging.info("Downloading content pack from {}".format(url)) - httpf = urllib.urlopen(url) # returns a file-like object not exactly to zipfile's liking, so save first + download_file(url, fp=fobj, callback=_callback) - shutil.copyfileobj(httpf, fobj) - fobj.seek(0) zf = zipfile.ZipFile(fobj) - httpf.close() - return zf diff --git a/kalite/locale/en/LC_MESSAGES/django.po b/kalite/locale/en/LC_MESSAGES/django.po index 58f015b528..3c03c7ee86 100644 --- a/kalite/locale/en/LC_MESSAGES/django.po +++ b/kalite/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-08-01 11:18+0200\n" +"POT-Creation-Date: 2017-09-06 09:21+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" diff --git a/kalite/locale/en/LC_MESSAGES/djangojs.po b/kalite/locale/en/LC_MESSAGES/djangojs.po index cd891c6edb..52a812ce84 100644 --- a/kalite/locale/en/LC_MESSAGES/djangojs.po +++ b/kalite/locale/en/LC_MESSAGES/djangojs.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-08-01 11:18+0200\n" +"POT-Creation-Date: 2017-09-06 09:22+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -358,6 +358,48 @@ msgstr "" msgid "Learner" msgstr "" +#: kalite/control_panel/static/js/control_panel/bundle_modules/facility_management.js.c:72 +msgid "Please choose a group to move users to." +msgstr "" + +#: kalite/control_panel/static/js/control_panel/bundle_modules/facility_management.js.c:74 +#: kalite/control_panel/static/js/control_panel/bundle_modules/facility_management.js.c:113 +msgid "Please select users first." +msgstr "" + +#: kalite/control_panel/static/js/control_panel/bundle_modules/facility_management.js.c:75 +msgid "You are about to move selected users to another group." +msgstr "" + +#: kalite/control_panel/static/js/control_panel/bundle_modules/facility_management.js.c:114 +msgid "" +"You are about to delete selected users, they will be permanently deleted." +msgstr "" + +#: kalite/control_panel/static/js/control_panel/bundle_modules/facility_management.js.c:129 +msgid "Please select groups first." +msgstr "" + +#: kalite/control_panel/static/js/control_panel/bundle_modules/facility_management.js.c:130 +msgid "" +"You are about to permanently delete the selected group(s). Note that any " +"learners currently in this group will now be characterized as 'Ungrouped' " +"but their profiles will not be deleted." +msgstr "" + +#: kalite/control_panel/static/js/control_panel/bundle_modules/zone_management.js.c:15 +#, javascript-format +msgid "" +"Are you sure you want to delete '%s'? You will lose all associated learner, " +"group, and coach accounts. If you are sure, type the name of the facility " +"into the box below and press OK." +msgstr "" + +#: kalite/control_panel/static/js/control_panel/bundle_modules/zone_management.js.c:29 +msgid "" +"The facility has not been deleted. Did you spell the facility name correctly?" +msgstr "" + #: kalite/control_panel/static/js/control_panel/data_export/hbtemplates/data-export-container.handlebars.py:4 msgid "Resource" msgstr "" @@ -425,6 +467,10 @@ msgstr "" msgid "0:00" msgstr "" +#: kalite/distributed/static/js/distributed/bundle_modules/register_public_key_client.js.c:12 +msgid "Contacting central server to register; page will reload upon success." +msgstr "" + #: kalite/distributed/static/js/distributed/content/hbtemplates/content-wrapper.handlebars.py:10 msgid "Open PDF Reader" msgstr "" @@ -1075,10 +1121,14 @@ msgid "True" msgstr "" #: kalite/distributed/static/js/distributed/perseus/build/perseus-5.js.c:23512 +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:122 +#: kalite/updates/static/js/updates/bundle_modules/update_videos.js.c:291 msgid "No" msgstr "" #: kalite/distributed/static/js/distributed/perseus/build/perseus-5.js.c:23512 +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:108 +#: kalite/updates/static/js/updates/bundle_modules/update_videos.js.c:255 msgid "Yes" msgstr "" @@ -3716,6 +3766,142 @@ msgstr "" msgid "Show me how!" msgstr "" +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:81 +msgid "You have to download at least the English content pack." +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:92 +msgid "Set as default" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:95 +msgid "Default" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:97 +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:128 +msgid "Subtitles" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:98 +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:126 +msgid "Translated" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:124 +msgid "" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:134 +msgid "Up to Date" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:140 +msgid "Delete" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:159 +msgid "Are you sure you want to delete language pack '%(lang_code)s'" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:165 +msgid "Confirm Delete" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:210 +msgid "beta" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_languages.js.c:323 +msgid "" +"Could not connect to the central server; language packs cannot be downloaded " +"at this time." +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:32 +msgid "Installation finished." +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:39 +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:44 +msgid "Installation finished! Refreshing the page in %(sec)s seconds" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:60 +msgid "Remote version information unavailable." +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:77 +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:84 +msgid "None" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:84 +#, javascript-format +msgid "
  • (%s)
  • " +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:117 +msgid "Error starting update process %(status)s: %(responseText)s" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:127 +msgid "" +"Are you sure you want to update your installation of KA Lite? This process " +"is irreversible!" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:130 +msgid "Confirm update" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:150 +msgid "Your installation is offline, and therefore cannot access updates." +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_software.js.c:175 +msgid "" +"Could not connect to the central server; software cannot be updated at this " +"time." +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_videos.js.c:175 +msgid "Please select videos to download (below)" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_videos.js.c:177 +msgid "Download %(vid_count)d new selected video(s)" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_videos.js.c:186 +msgid "Please select videos to delete (below)" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_videos.js.c:188 +msgid "Delete %(vid_count)d selected video(s)" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_videos.js.c:207 +msgid "" +"Could not connect to the central server; videos cannot be downloaded at this " +"time." +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_videos.js.c:248 +msgid "Are you sure you want to delete?" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_videos.js.c:299 +msgid "Deleting the downloaded video(s) will lead to permanent loss of data" +msgstr "" + +#: kalite/updates/static/js/updates/bundle_modules/update_videos.js.c:343 +msgid "" +"Scanning for videos and updating your database - this can take several " +"minutes, depending on how many videos that are found. Please be patient and " +"stay on this page. Once completed, results will be shown on this page." +msgstr "" + #: kalite/updates/static/js/updates/updates/base.js.c:147 msgid "Completed update successfully." msgstr "" diff --git a/kalite/packages/bundled/fle_utils/internet/download.py b/kalite/packages/bundled/fle_utils/internet/download.py index f001b39a3d..a5577a8e72 100644 --- a/kalite/packages/bundled/fle_utils/internet/download.py +++ b/kalite/packages/bundled/fle_utils/internet/download.py @@ -1,21 +1,18 @@ +import logging import os import requests import socket -import sys import tempfile -from requests.utils import default_user_agent -socket.setdefaulttimeout(20) +from requests.packages.urllib3.util.retry import Retry +from kalite.updates.settings import DOWNLOAD_SOCKET_TIMEOUT -class DownloadCancelled(Exception): - def __str__(self): - return "Download has been cancelled" +socket.setdefaulttimeout(DOWNLOAD_SOCKET_TIMEOUT) -class URLNotFound(Exception): - pass +logger = logging.getLogger(__name__) def callback_percent_proxy(callback, start_percent=0, end_percent=100): @@ -28,54 +25,100 @@ def callback_percent_proxy_inner_fn(fraction): return callback_percent_proxy_inner_fn -def _reporthook(numblocks, blocksize, filesize, url=None): - base = os.path.basename(url) - if filesize <= 0: - filesize = blocksize - try: - percent = min((numblocks * blocksize * 100) / filesize, 100) - except: - percent = 100 - if numblocks != 0: - sys.stdout.write("\b" * 40) - sys.stdout.write("%-36s%3d%%" % (base, percent)) - if percent == 100: - sys.stdout.write("\n") - - def _nullhook(*args, **kwargs): pass -def download_file(url, dst=None, callback=None): - if sys.stdout.isatty(): - callback = callback or _reporthook - else: - callback = callback or _nullhook - dst = dst or tempfile.mkstemp()[1] +def download_file(url, dst=None, callback=None, fp=None, max_retries=5): + + assert not (dst and fp) + + callback = callback or _nullhook + + from requests.adapters import HTTPAdapter + + s = requests.Session() + + # Define the way that we do retries. + # retries = 5 + # backoff = 0.2 + # sum(b * (2 ^ (r - 1)) for r in range(1,6)) + # =2.4 seconds total retry backoff + # socket timeout is 20 (see above) + # = 102.4 seconds on an unconnected line + retries = Retry( + total=max_retries, + connect=max_retries, + read=max_retries, + backoff_factor=0.2, + ) + + s.mount('http://', HTTPAdapter(max_retries=retries)) # Assuming the KA Lite version is included in user agent because of an # intention to create stats on learningequality.org from kalite.version import user_agent - response = requests.get( + + # Notice that we deliberate aren't using the ``timeout`` kwarg here, we + # will allow the stream to hang forever when a connection is disrupted + # but a download has already started. This is to not have to write "resume" + # logic on top of our retry logic. + response = s.get( url, allow_redirects=True, stream=True, + # timeout=DOWNLOAD_SOCKET_TIMEOUT, headers={"user-agent": user_agent()} ) + + response.raise_for_status() + + # Don't do this things until passed the raise_for_status() point + # If not called with a file pointer or destination, create a new temporary + # file # If a destination is set, then we'll write a file and send back updates if dst: - chunk_size = 1024 - with open(dst, 'wb') as fd: - for chunk_number, chunk in enumerate(response.iter_content(chunk_size)): - fd.write(chunk) - bytes_fetched = chunk_number * chunk_size - if 'content-length' not in response.headers: - fraction = 0.0 - elif int(response.headers['content-length']) == 0: - fraction = 0.0 - else: - total_size = float(response.headers['content-length']) - fraction = min(float(bytes_fetched) / total_size, 1.0) - callback(fraction) + fp = open(dst, 'wb') + if not (dst or fp): + fp, dst = tempfile.mkstemp()[1] + + chunk_size = 1024 * 50 # 50 KB + for chunk_number, chunk in enumerate(response.iter_content(chunk_size)): + fp.write(chunk) + bytes_fetched = chunk_number * chunk_size + if 'content-length' not in response.headers: + fraction = 0.0 + elif int(response.headers['content-length']) == 0: + fraction = 0.0 + else: + total_size = float(response.headers['content-length']) + fraction = min(float(bytes_fetched) / total_size, 1.0) + callback(fraction) + + # Many operations expect a file pointer at 0 after having written the file + # successfully. For instance if it's passed on to a ZipFile object. + fp.seek(0) + + # Verify file existence + dst = fp.name or None + if dst: + if os.path.isfile(dst): + size_on_disk = os.path.getsize(dst) + else: + size_on_disk = 0 + if 'content-length' in response.headers: + size_header = int(response.headers['content-length']) + size_header = 0 + + if size_on_disk <=0 or (size_header and size_on_disk != size_header): + logger.error(( + "Error downloading {url}, incorrect file size, disk full? " + "Expected {header}, got {disk}").format( + url=url, + header=size_header, + disk=size_header, + ) + ) + raise RuntimeError("Download failed to write correct file.") + return response diff --git a/kalite/packages/bundled/fle_utils/videos.py b/kalite/packages/bundled/fle_utils/videos.py index c79717e5c2..6a2f86ca3c 100644 --- a/kalite/packages/bundled/fle_utils/videos.py +++ b/kalite/packages/bundled/fle_utils/videos.py @@ -1,80 +1,7 @@ """ +Legacy module, do not use """ -import glob -import logging -import os -import socket - -from general import ensure_dir -from internet.download import callback_percent_proxy, download_file, URLNotFound, DownloadCancelled +# This is used in the Central Server for redirects. OUTSIDE_DOWNLOAD_BASE_URL = "http://s3.amazonaws.com/KA-youtube-converted/" # needed for redirects OUTSIDE_DOWNLOAD_URL = OUTSIDE_DOWNLOAD_BASE_URL + "%s/%s" # needed for default behavior, below - -logger = logging.getLogger(__name__) - - -def get_outside_video_urls(youtube_id, download_url=OUTSIDE_DOWNLOAD_URL, format="mp4"): - - video_filename = "%(id)s.%(format)s" % {"id": youtube_id, "format": format} - url = download_url % (video_filename, video_filename) - - thumb_filename = "%(id)s.png" % {"id": youtube_id} - thumb_url = download_url % (video_filename, thumb_filename) - - return (url, thumb_url) - - -def download_video(youtube_id, download_path="../content/", download_url=OUTSIDE_DOWNLOAD_URL, format="mp4", callback=None): - """Downloads the video file to disk (note: this does NOT invalidate any of the cached html files in KA Lite)""" - - ensure_dir(download_path) - - url, thumb_url = get_outside_video_urls(youtube_id, download_url=download_url, format=format) - video_filename = "%(id)s.%(format)s" % {"id": youtube_id, "format": format} - filepath = os.path.join(download_path, video_filename) - - thumb_filename = "%(id)s.png" % {"id": youtube_id} - thumb_filepath = os.path.join(download_path, thumb_filename) - - try: - response = download_file(url, filepath, callback_percent_proxy(callback, end_percent=95)) - if ( - not os.path.isfile(filepath) or - "content-length" not in response.headers or - not os.path.getsize(filepath) == int(response.headers['content-length'])): - raise URLNotFound("Video was not found, tried: {}".format(url)) - - response = download_file(thumb_url, thumb_filepath, callback_percent_proxy(callback, start_percent=95, end_percent=100)) - if ( - not os.path.isfile(thumb_filepath) or - "content-length" not in response.headers or - not os.path.getsize(thumb_filepath) == int(response.headers['content-length'])): - raise URLNotFound("Thumbnail was not found, tried: {}".format(thumb_url)) - - except DownloadCancelled: - delete_downloaded_files(youtube_id, download_path) - raise - - except (socket.timeout, IOError) as e: - logging.exception(e) - logging.info("Timeout -- Network UnReachable") - delete_downloaded_files(youtube_id, download_path) - raise - - except Exception as e: - logging.exception(e) - delete_downloaded_files(youtube_id, download_path) - raise - - -def delete_downloaded_files(youtube_id, download_path): - files_deleted = 0 - for filepath in glob.glob(os.path.join(download_path, youtube_id + ".*")): - try: - os.remove(filepath) - files_deleted += 1 - except OSError: - pass - if files_deleted: - return True diff --git a/kalite/project/settings/base.py b/kalite/project/settings/base.py index da9e3db807..1bd7cac7a6 100644 --- a/kalite/project/settings/base.py +++ b/kalite/project/settings/base.py @@ -20,3 +20,26 @@ SOUTH_MIGRATION_MODULES = { 'tastypie': 'tastypie.south_migrations', } + +# Default welcome message +KALITE_WELCOME_MESSAGE = """ +

    Need help?

    +

    KA Lite is community-driven and relies on help and experience which you can both share and receive in our community.

    +
    +

    Offline help:

    +
      +
    • Use the "Docs" button in the top menu to get help.
    • +
    +

    Online help:

    + +""" diff --git a/kalite/testing/base.py b/kalite/testing/base.py index f56cc7ed43..143297cb8e 100644 --- a/kalite/testing/base.py +++ b/kalite/testing/base.py @@ -66,127 +66,126 @@ def teardown_content_db(instance, db): def setup_content_db(instance, db): # Setup the content.db (defaults to the en version) - with Using(db, [Item], with_transaction=False): - # Root node - instance.content_root = Item.create( - title="Khan Academy", - description="", - available=True, - files_complete=0, - total_files="1", - kind="Topic", - parent=None, - id="khan", - slug="khan", - path="khan/", - extra_fields="{}", - youtube_id=None, - remote_size=315846064333, - sort_order=0 + # Root node + instance.content_root = Item.create( + title="Khan Academy", + description="", + available=True, + files_complete=0, + total_files="1", + kind="Topic", + parent=None, + id="khan", + slug="khan", + path="khan/", + extra_fields="{}", + youtube_id=None, + remote_size=315846064333, + sort_order=0 + ) + for _i in range(4): + slug = "topic{}".format(_i) + instance.content_subtopics.append( + Item.create( + title="Subtopic {}".format(_i), + description="A subtopic", + available=True, + files_complete=0, + total_files="4", + kind="Topic", + parent=instance.content_root, + id=slug, + slug=slug, + path="khan/{}/".format(slug), + extra_fields="{}", + remote_size=1, + sort_order=_i, + ) ) + + # Parts of the content recommendation system currently is hard-coded + # to look for 3rd level recommendations only and so will fail if we + # don't have this level of lookup + for subtopic in instance.content_subtopics: for _i in range(4): - slug = "topic{}".format(_i) - instance.content_subtopics.append( + slug = "{}-{}".format(subtopic.id, _i) + instance.content_subsubtopics.append( Item.create( - title="Subtopic {}".format(_i), - description="A subtopic", + title="{} Subsubtopic {}".format(subtopic.title, _i), + description="A subsubtopic", available=True, - files_complete=0, + files_complete=4, total_files="4", kind="Topic", - parent=instance.content_root, + parent=subtopic, id=slug, slug=slug, - path="khan/{}/".format(slug), + path="{}{}/".format(subtopic.path, slug), + youtube_id=None, extra_fields="{}", remote_size=1, sort_order=_i, ) ) - - # Parts of the content recommendation system currently is hard-coded - # to look for 3rd level recommendations only and so will fail if we - # don't have this level of lookup - for subtopic in instance.content_subtopics: - for _i in range(4): - slug = "{}-{}".format(subtopic.id, _i) - instance.content_subsubtopics.append( - Item.create( - title="{} Subsubtopic {}".format(subtopic.title, _i), - description="A subsubtopic", - available=True, - files_complete=4, - total_files="4", - kind="Topic", - parent=subtopic, - id=slug, - slug=slug, - path="{}{}/".format(subtopic.path, slug), - youtube_id=None, - extra_fields="{}", - remote_size=1, - sort_order=_i, - ) - ) - # We need at least 10 exercises in some of the tests to generate enough - # data etc. - # ...and we need at least some exercises in each sub-subtopic - for parent in instance.content_subsubtopics: - # Make former created exercise the prerequisite of the next one - prerequisite = None - for _i in range(4): - slug = "{}-exercise-{}".format(parent.id, _i) - extra_fields = {} - if prerequisite: - extra_fields['prerequisites'] = [prerequisite.id] - new_exercise = Item.create( - title="Exercise {} in {}".format(_i, parent.title), - parent=parent, - description="Solve this", + # We need at least 10 exercises in some of the tests to generate enough + # data etc. + # ...and we need at least some exercises in each sub-subtopic + for parent in instance.content_subsubtopics: + # Make former created exercise the prerequisite of the next one + prerequisite = None + for _i in range(4): + slug = "{}-exercise-{}".format(parent.id, _i) + extra_fields = {} + if prerequisite: + extra_fields['prerequisites'] = [prerequisite.id] + new_exercise = Item.create( + title="Exercise {} in {}".format(_i, parent.title), + parent=parent, + description="Solve this", + available=True, + kind="Exercise", + id=slug, + slug=slug, + path="{}{}/".format(parent.path, slug), + sort_order=_i, + **extra_fields + ) + instance.content_exercises.append(new_exercise) + prerequisite = new_exercise + # Add some videos, too, even though files don't exist + for parent in instance.content_subsubtopics: + for _i in range(4): + slug = "{}-video-{}".format(parent.pk, _i) + instance.content_videos.append( + Item.create( + title="Video {} in {}".format(_i, parent.title), + parent=random.choice(instance.content_subsubtopics), + description="Watch this", available=True, - kind="Exercise", + kind="Video", id=slug, slug=slug, path="{}{}/".format(parent.path, slug), - sort_order=_i, - **extra_fields - ) - instance.content_exercises.append(new_exercise) - prerequisite = new_exercise - # Add some videos, too, even though files don't exist - for parent in instance.content_subsubtopics: - for _i in range(4): - slug = "{}-video-{}".format(parent.pk, _i) - instance.content_videos.append( - Item.create( - title="Video {} in {}".format(_i, parent.title), - parent=random.choice(instance.content_subsubtopics), - description="Watch this", - available=True, - kind="Video", - id=slug, - slug=slug, - path="{}{}/".format(parent.path, slug), - extra_fields={ - "subtitle_urls": [], - "content_urls": {"stream": "/foo", "stream_type": "video/mp4"}, - }, - sort_order=_i - ) + extra_fields={ + "subtitle_urls": [], + "content_urls": {"stream": "/foo", "stream_type": "video/mp4"}, + }, + sort_order=_i ) + ) - with Using(db, [Item], with_transaction=False): - instance.content_unavailable_item = Item.create( - title="Unavailable item", - description="baz", - available=False, - kind="Video", - id="unavail123", - slug="unavail", - path=instance.content_unavailable_content_path, - parent=random.choice(instance.content_subsubtopics).pk, - ) + instance.content_unavailable_item = Item.create( + title="Unavailable item", + description="baz", + available=False, + kind="Video", + id="00000000000", + youtube_id="00000000000", + slug="unavail", + path=instance.content_unavailable_content_path, + parent=random.choice(instance.content_subsubtopics).pk, + ) instance.content_available_content_path = random.choice(instance.content_exercises).path diff --git a/kalite/testing/behave_helpers.py b/kalite/testing/behave_helpers.py index 3d4b4b7d6b..1152fdbfec 100644 --- a/kalite/testing/behave_helpers.py +++ b/kalite/testing/behave_helpers.py @@ -415,10 +415,8 @@ def __init__(self): def logout(context): url = reverse("api_dispatch_list", kwargs={"resource_name": "user"}) + "logout/" - context.browser.get(build_url(context, url)) - pre_element = find_css_with_wait(context, "pre") - json_response_text = pre_element.text - assert "success" in json_response_text and "true" in json_response_text + response = urllib.urlopen(build_url(context, url)) + assert response.getcode() == 200 def post(context, url, data=""): diff --git a/kalite/topic_tools/content_models.py b/kalite/topic_tools/content_models.py index 101a334b62..0fddd5e6f7 100644 --- a/kalite/topic_tools/content_models.py +++ b/kalite/topic_tools/content_models.py @@ -59,6 +59,9 @@ def __init__(self, *args, **kwargs): kwargs = parse_model_data(kwargs) super(Item, self).__init__(*args, **kwargs) + def __repr__(self): + return "{}: {} (id: {})".format(self.kind, self.title, self.id) + class AssessmentItem(Model): id = CharField(max_length=50) @@ -130,8 +133,11 @@ def wrapper(*args, **kwargs): except DoesNotExist: output = None - except OperationalError: - logging.error("No content database file found") + except OperationalError as e: + logging.error( + "Content DB error: Perhaps content database file found? " + "Exception: {e}".format(e=str(e)) + ) raise db.close() @@ -158,8 +164,11 @@ def wrapper(*args, **kwargs): output = map(unparse_model_data, output.dicts()) else: output = [item for item in output.dicts()] - except (TypeError, OperationalError): - logging.warn("No content database file found") + except (TypeError, OperationalError) as e: + logging.error( + "Content DB error: Perhaps content database file found? " + "Exception: {e}".format(e=str(e)) + ) output = [] return output return wrapper diff --git a/kalite/updates/download_track.py b/kalite/updates/download_track.py index 7fa23da6fc..814e55ddd1 100644 --- a/kalite/updates/download_track.py +++ b/kalite/updates/download_track.py @@ -19,7 +19,7 @@ def __init__(self): def add_files(self, files, language=None): """ - Add files to the queue - this should be a list of youtube_ids + Add files to the queue - this should be a dict {youtube_id: title} and optionally, the language of the video. """ files = [{"youtube_id": key, "title": value, "language": language} for key, value in files.items()] diff --git a/kalite/updates/management/commands/videodownload.py b/kalite/updates/management/commands/videodownload.py index b02e6f549e..92ef285d30 100644 --- a/kalite/updates/management/commands/videodownload.py +++ b/kalite/updates/management/commands/videodownload.py @@ -1,40 +1,34 @@ """ """ import os -import youtube_dl -import time +import socket +import logging + from functools import partial from optparse import make_option from django.conf import settings -logging = settings.LOG +from requests.exceptions import HTTPError, ConnectionError + from django.utils.translation import ugettext as _ from kalite.updates.management.utils import UpdatesDynamicCommand from ...videos import download_video from ...download_track import VideoQueue from fle_utils import set_process_priority -from fle_utils.videos import DownloadCancelled, URLNotFound from fle_utils.chronograph.management.croncommand import CronCommand from kalite.topic_tools.content_models import get_video_from_youtube_id, annotate_content_models_by_youtube_id +import time +from kalite.updates.settings import DOWNLOAD_MAX_RETRIES -def scrape_video(youtube_id, format="mp4", force=False, quiet=False, callback=None): - """ - Assumes it's in the path; if not, we try to download & install. - Callback will be called back with a dictionary as the first arg with a bunch of - youtube-dl info in it, as specified in the youtube-dl docs. - """ - video_filename = "%(id)s.%(ext)s" % { 'id': youtube_id, 'ext': format } - video_file_download_path = os.path.join(settings.CONTENT_ROOT, video_filename) - if os.path.exists(video_file_download_path) and not force: - return +logger = logging.getLogger(__name__) - yt_dl = youtube_dl.YoutubeDL({'outtmpl': video_file_download_path, "quiet": quiet}) - yt_dl.add_default_info_extractors() - if callback: - yt_dl.add_progress_hook(callback) - yt_dl.extract_info('www.youtube.com/watch?v=%s' % youtube_id, download=True) + +class DownloadCancelled(Exception): + + def __str__(self): + return "Download has been cancelled" class Command(UpdatesDynamicCommand, CronCommand): @@ -91,7 +85,7 @@ def download_progress_callback(self, videofile, percent): if percent == 100: self.video = {} - except DownloadCancelled as de: + except DownloadCancelled: if self.video: self.stdout.write(_("Download cancelled!") + "\n") @@ -108,7 +102,7 @@ def handle(self, *args, **options): handled_youtube_ids = [] # stored to deal with caching failed_youtube_ids = [] # stored to avoid requerying failures. - set_process_priority.lowest(logging=settings.LOG) + set_process_priority.lowest(logging=logger) try: while True: @@ -144,57 +138,81 @@ def handle(self, *args, **options): # and call it a day! if not os.path.exists(os.path.join(settings.CONTENT_ROOT, "{id}.mp4".format(id=video.get("youtube_id")))): - try: - # Download via urllib - download_video(video.get("youtube_id"), callback=progress_callback) - - except URLNotFound: - # Video was not found on amazon cloud service, - # either due to a KA mistake, or due to the fact - # that it's a dubbed video. - # - # We can use youtube-dl to get that video!! - logging.debug(_("Retrieving youtube video %(youtube_id)s via youtube-dl") % {"youtube_id": video.get("youtube_id")}) - - def youtube_dl_cb(stats, progress_callback, *args, **kwargs): - if stats['status'] == "finished": - percent = 100. - elif stats['status'] == "downloading": - percent = 100. * stats['downloaded_bytes'] / stats['total_bytes'] - else: - percent = 0. - progress_callback(percent=percent) - scrape_video(video.get("youtube_id"), quiet=not settings.DEBUG, callback=partial(youtube_dl_cb, progress_callback=progress_callback)) - - except IOError as e: - logging.exception(e) - failed_youtube_ids.append(video.get("youtube_id")) - video_queue.remove_file(video.get("youtube_id")) - time.sleep(10) - continue + retries = 0 + while True: + try: + download_video(video.get("youtube_id"), callback=progress_callback) + break + except (socket.timeout, ConnectionError): + retries += 1 + msg = _( + "Pausing download for '{title}', failed {failcnt} times, sleeping for 30s, retry number {retries}" + ).format( + title=video.get("title"), + failcnt=DOWNLOAD_MAX_RETRIES, + retries=retries, + ) + try: + self.update_stage( + stage_name=video.get("youtube_id"), + stage_percent=0., + notes=msg + ) + except AssertionError: + # Raised by update_stage when the video + # download job has ended + raise DownloadCancelled() + logger.info(msg) + time.sleep(30) + continue # If we got here, we downloaded ... somehow :) handled_youtube_ids.append(video.get("youtube_id")) + + # Remove from item from the queue video_queue.remove_file(video.get("youtube_id")) self.stdout.write(_("Download is complete!") + "\n") annotate_content_models_by_youtube_id(youtube_ids=[video.get("youtube_id")], language=video.get("language")) except DownloadCancelled: - # Cancellation event video_queue.clear() failed_youtube_ids.append(video.get("youtube_id")) + break - except Exception as e: - # On error, report the error, mark the video as not downloaded, - # and allow the loop to try other videos. - msg = _("Error in downloading %(youtube_id)s: %(error_msg)s") % {"youtube_id": video.get("youtube_id"), "error_msg": unicode(e)} - self.stderr.write("%s\n" % msg) - - # Rather than getting stuck on one video, continue to the next video. - self.update_stage(stage_status="error", notes=_("%(error_msg)s; continuing to next video.") % {"error_msg": msg}) + except (HTTPError, Exception) as e: + # Rather than getting stuck on one video, + # completely remove this item from the queue failed_youtube_ids.append(video.get("youtube_id")) video_queue.remove_file(video.get("youtube_id")) + logger.exception(e) + + if getattr(e, "response", None): + reason = _( + "Got non-OK HTTP status: {status}" + ).format( + status=e.response.status_code + ) + else: + reason = _( + "Unhandled request exception: " + "{exception}" + ).format( + exception=str(e), + ) + msg = _( + "Skipping '{title}', reason: {reason}" + ).format( + title=video.get('title'), + reason=reason, + ) + # Inform the user of this problem + self.update_stage( + stage_name=video.get("youtube_id"), + stage_percent=0., + notes=msg + ) + logger.info(msg) continue # Update @@ -204,5 +222,6 @@ def youtube_dl_cb(stats, progress_callback, *args, **kwargs): }) except Exception as e: + logger.exception(e) self.cancel(stage_status="error", notes=_("Error: %(error_msg)s") % {"error_msg": e}) raise diff --git a/kalite/updates/management/utils.py b/kalite/updates/management/utils.py index e6e3efab5b..dc82d0bc3c 100644 --- a/kalite/updates/management/utils.py +++ b/kalite/updates/management/utils.py @@ -1,9 +1,10 @@ """ """ +import logging + from datetime import datetime from optparse import make_option -from django.conf import settings; logging = settings.LOG from django.utils.translation import ugettext as _ from ..models import UpdateProgressLog @@ -11,6 +12,9 @@ from functools import wraps +logger = logging.getLogger(__name__) + + def skip_if_no_progress_log(func): @wraps(func) def check(self, *args, **kwargs): @@ -62,7 +66,7 @@ def setup(self, options): @skip_if_no_progress_log def display_notes(self, notes, ignore_same=True): if notes and (not ignore_same or notes != self.progress_log.notes): - logging.info(notes) + logger.info(notes) @skip_if_no_progress_log def ended(self): @@ -131,6 +135,8 @@ def complete(self, notes=None): @skip_if_no_progress_log def check_if_cancel_requested(self): + if self.progress_log.id: + self.progress_log = UpdateProgressLog.objects.get(id=self.progress_log.id) if self.progress_log.cancel_requested: self.progress_log.end_time = datetime.now() self.progress_log.save() @@ -172,6 +178,9 @@ def next_stage(self, notes=None): @skip_if_no_progress_log def update_stage(self, stage_percent, stage_status=None, notes=None): + """ + :param: stage_percent: 0.0 - 1.0 (so not actually percent!?) + """ self.display_notes(notes) self.progress_log.update_stage(stage_name=self.stages[self.progress_log.current_stage - 1], stage_percent=stage_percent, stage_status=stage_status, notes=notes) diff --git a/kalite/updates/models.py b/kalite/updates/models.py index 43cb19d9b4..5267b77ff6 100644 --- a/kalite/updates/models.py +++ b/kalite/updates/models.py @@ -1,8 +1,8 @@ """ """ +import logging import datetime -from django.conf import settings; logging = settings.LOG from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils.translation import ugettext as _ @@ -13,6 +13,9 @@ from fle_utils.chronograph.models import Job +logger = logging.getLogger(__name__) + + class UpdateProgressLog(ExtendedModel): """ Gets progress @@ -91,7 +94,7 @@ def cancel_current_stage(self, stage_status=None, notes=None): """ Delete the current stage--it's reported progress, and contribution to the total # of stages """ - logging.info("Cancelling stage %s of process %s" % (self.stage_name, self.process_name)) + logger.info("Cancelling stage %s of process %s" % (self.stage_name, self.process_name)) self.stage_percent = 0. self.stage_name = None @@ -112,9 +115,9 @@ def update_total_stages(self, total_stages, current_stage=None): return if self.total_stages: - logging.debug("Updating %s from %d to %d stages." % (self.process_name, self.total_stages, total_stages)) + logger.debug("Updating %s from %d to %d stages." % (self.process_name, self.total_stages, total_stages)) else: - logging.debug("Setting %s to %d total stages." % (self.process_name, total_stages)) + logger.debug("Setting %s to %d total stages." % (self.process_name, total_stages)) self.total_stages = total_stages @@ -131,7 +134,7 @@ def cancel_progress(self, stage_status=None, notes=None): """ Stamps end time. """ - logging.info("Cancelling process %s" % (self.process_name)) + logger.info("Cancelling process %s" % (self.process_name)) self.stage_status = stage_status or "cancelled" self.end_time = datetime.datetime.now() @@ -144,7 +147,7 @@ def mark_as_completed(self, stage_status=None, notes=None): """ Completes stage and process percents, stamps end time. """ - logging.debug("Completing process %s" % (self.process_name)) + logger.debug("Completing process %s" % (self.process_name)) self.stage_percent = 1. self.process_percent = 1. diff --git a/kalite/updates/settings.py b/kalite/updates/settings.py index bc2e618d12..85eea50fe9 100644 --- a/kalite/updates/settings.py +++ b/kalite/updates/settings.py @@ -1,15 +1,21 @@ """ - New settings pattern See: https://github.com/learningequality/ka-lite/issues/4054 https://github.com/learningequality/ka-lite/issues/3757 -All settings for the updates app should be defined here, they can -only on django.conf.settings +All settings for the updates app should be defined here """ import os from django.conf import settings VIDEO_DOWNLOAD_QUEUE_FILE = os.path.join(settings.USER_DATA_ROOT, "videos_to_download.json") + + +#: Maximum number of retries for individual video downloads. There is a +#: backoff rate defined as: +#: {backoff factor} * (2 ^ ({number of total retries} - 1)) +DOWNLOAD_MAX_RETRIES = getattr(settings, "KALITE_DOWNLOAD_RETRIES", 5) + +DOWNLOAD_SOCKET_TIMEOUT = getattr(settings, "KALITE_DOWNLOAD_TIMEOUT", 20) \ No newline at end of file diff --git a/kalite/updates/static/css/updates/base.css b/kalite/updates/static/css/updates/base.css index e4879b6412..597c60cdb2 100644 --- a/kalite/updates/static/css/updates/base.css +++ b/kalite/updates/static/css/updates/base.css @@ -20,8 +20,6 @@ } .progress-section { - float: left; - padding: 5px 0px 0px 0px; display: none; } diff --git a/kalite/updates/static/css/updates/update_videos.less b/kalite/updates/static/css/updates/update_videos.less index ac614d0a33..a6ab38764b 100644 --- a/kalite/updates/static/css/updates/update_videos.less +++ b/kalite/updates/static/css/updates/update_videos.less @@ -126,6 +126,3 @@ h4.videos { .download-actions { display: block; float: none; width: 100%; } } -.progress-section { - width: 100%; -} diff --git a/kalite/updates/static/js/updates/bundle_modules/update_videos.js b/kalite/updates/static/js/updates/bundle_modules/update_videos.js index 5dcd637f17..666cb66c65 100644 --- a/kalite/updates/static/js/updates/bundle_modules/update_videos.js +++ b/kalite/updates/static/js/updates/bundle_modules/update_videos.js @@ -204,6 +204,7 @@ $(function() { if(server_is_online){ base.updatesStart("videodownload", 5000, video_callbacks); } else { + base.updatesStart("videodownload", 10000, video_callbacks); messages.show_message("error", gettext("Could not connect to the central server; videos cannot be downloaded at this time.")); } }); diff --git a/kalite/updates/templates/updates/progress-bar.html b/kalite/updates/templates/updates/progress-bar.html index a363e2dddb..8a89068889 100644 --- a/kalite/updates/templates/updates/progress-bar.html +++ b/kalite/updates/templates/updates/progress-bar.html @@ -1,13 +1,5 @@ {% load i18n %} -{% block headcss %} - -{% endblock headcss %} -

    @@ -18,7 +10,7 @@

    -

    +

    diff --git a/kalite/updates/templates/updates/update_languages.html b/kalite/updates/templates/updates/update_languages.html index 54a276c76e..0730051a6e 100644 --- a/kalite/updates/templates/updates/update_languages.html +++ b/kalite/updates/templates/updates/update_languages.html @@ -28,60 +28,66 @@
    -
    -
    -

    {% trans "Download or Update Language Packs" %}

    -
    -
    -

    - {% trans "Language packs contain all available translations for the interface and video subtitles." %} -

    -
    - -
    -
    - - - +
    +
    +
    +

    {% trans "Download or Update Language Packs" %}

    +
    +
    +

    + {% trans "Language packs contain all available translations for the interface and video subtitles." %} +

    +
    + +
    +
    + + + + + + - - + +
    +
    + + + +
    + + +
    +
    +
    - -
    -
    - - - -
    - - -
    +
    +
    + {% include "updates/progress-bar.html" %}
    -
    +
    -
    - {% include "updates/progress-bar.html" %}
    -
    -
    -
    -

    {% trans "Installed Languages" %}

    -
    +
    +
    +
    +

    {% trans "Installed Languages" %}

    +
    -
    -
    -
    +
    +
    +
    +
    diff --git a/kalite/updates/templates/updates/update_videos.html b/kalite/updates/templates/updates/update_videos.html index 90dd634cd5..b7871acc76 100644 --- a/kalite/updates/templates/updates/update_videos.html +++ b/kalite/updates/templates/updates/update_videos.html @@ -73,7 +73,7 @@

    -
    +
    {% include "updates/progress-bar.html" %}
    diff --git a/kalite/updates/tests/__init__.py b/kalite/updates/tests/__init__.py index cc9116afc8..f9a387c0a3 100644 --- a/kalite/updates/tests/__init__.py +++ b/kalite/updates/tests/__init__.py @@ -3,3 +3,5 @@ from base import * from class_tests import * from regression_tests import * +from download_tests import * +from contentpack_tests import * \ No newline at end of file diff --git a/kalite/updates/tests/base.py b/kalite/updates/tests/base.py index 4cd89a5cfb..7e5a741a46 100644 --- a/kalite/updates/tests/base.py +++ b/kalite/updates/tests/base.py @@ -1,4 +1,32 @@ +import random + from kalite.testing.base import KALiteTestCase +from kalite.topic_tools.content_models import set_database, Item + + +# Some small videos from the content database 2017-11-13 +TEST_YOUTUBE_IDS = ["9P80OLC6wKY", "zaGUlwslGGg"] + + +@set_database +def add_test_content_videos(instance, db): + + youtube_id = random.choice(TEST_YOUTUBE_IDS) + + real_video = Item.create( + title="A real video from KA/Youtube", + description="This video exists", + available=False, + kind="Video", + format="mp4", + id=youtube_id, + youtube_id=youtube_id, + slug="youtube-vid", + path="khan/topic0/topic0-3/real-video", + parent=random.choice(instance.content_subsubtopics).pk, + ) + instance.real_video = real_video + instance.content_videos.append(real_video) class UpdatesTestCase(KALiteTestCase): @@ -7,4 +35,4 @@ class UpdatesTestCase(KALiteTestCase): """ def setUp(self): super(UpdatesTestCase, self).setUp() - + add_test_content_videos(self) diff --git a/kalite/updates/tests/contentpack_tests.py b/kalite/updates/tests/contentpack_tests.py new file mode 100644 index 0000000000..0d5fea2dab --- /dev/null +++ b/kalite/updates/tests/contentpack_tests.py @@ -0,0 +1,52 @@ +import logging +import os + +from django.core.management import call_command + +from .base import UpdatesTestCase +from kalite.topic_tools.settings import CONTENT_DATABASE_PATH +from tempfile import NamedTemporaryFile +from kalite.i18n.base import get_content_pack_url, delete_language +from fle_utils.internet.download import download_file + + +logger = logging.getLogger(__name__) + + +TEST_LANGUAGE = "zu" +TEST_CONTENT_DB = CONTENT_DATABASE_PATH.format(channel="khan", language=TEST_LANGUAGE) + + +class TestContentPack(UpdatesTestCase): + """ + Test that topics with exercises are available, others are not. + """ + + def tearDown(self): + UpdatesTestCase.tearDown(self) + delete_language(TEST_LANGUAGE) + + + def test_retrievecontentpack(self): + """ + Tests that downloading and installing on of the smallest content packs + (Zulu) works. + """ + call_command("retrievecontentpack", "download", TEST_LANGUAGE) + self.assertTrue(os.path.exists( + TEST_CONTENT_DB + )) + + + def test_install_local_with_delete(self): + fp = NamedTemporaryFile(suffix=".zip", delete=False) + url = get_content_pack_url(TEST_LANGUAGE) + download_file(url, fp=fp, callback=None) + call_command("retrievecontentpack", "local", TEST_LANGUAGE, fp.name) + self.assertTrue(os.path.exists( + TEST_CONTENT_DB + )) + delete_language(TEST_LANGUAGE) + self.assertFalse(os.path.exists( + TEST_CONTENT_DB + )) diff --git a/kalite/updates/tests/download_tests.py b/kalite/updates/tests/download_tests.py new file mode 100644 index 0000000000..37cdd49a88 --- /dev/null +++ b/kalite/updates/tests/download_tests.py @@ -0,0 +1,150 @@ +import logging +import mock +import os +import socket + +from django.core.management import call_command + +from kalite.topic_tools.content_models import get_content_item,\ + annotate_content_models_by_youtube_id +from kalite.updates.download_track import VideoQueue +from kalite.updates.videos import download_video, delete_downloaded_files,\ + get_video_local_path, get_local_video_size + +from .base import UpdatesTestCase +from requests.exceptions import HTTPError, ConnectionError +from kalite.updates.models import UpdateProgressLog + + +logger = logging.getLogger(__name__) + + +class TestDownload(UpdatesTestCase): + """ + Test that topics with exercises are available, others are not. + """ + + def setUp(self): + UpdatesTestCase.setUp(self) + delete_downloaded_files(self.real_video.youtube_id) + annotate_content_models_by_youtube_id(youtube_ids=[self.real_video.youtube_id]) + updated = get_content_item(content_id=self.real_video.id) + self.assertFalse(updated['available']) + + def test_simple_download(self): + """ + Tests that a real, existing video can be downloaded + """ + # Download a video that exists for real! + download_video(self.real_video.youtube_id) + # Check that file exists + self.assertTrue(os.path.exists( + get_video_local_path(self.real_video.youtube_id) + )) + # After downloading the video, annotate the database + annotate_content_models_by_youtube_id(youtube_ids=[self.real_video.youtube_id]) + # Check that it's been marked available + updated = get_content_item(content_id=self.real_video.id) + logger.error(updated) + self.assertTrue(updated['available']) + + # Adding in an unrelated test (becase we don't need database etc. for + # this to be tested. + self.assertEqual( + get_local_video_size("/bogus/path", default=123), + 123 + ) + + def test_download_unavailable(self): + """ + Tests that a non-existent video doesn't result in any new files + """ + with self.assertRaises(HTTPError): + download_video(self.content_unavailable_item.youtube_id) + self.assertFalse(os.path.exists( + get_video_local_path(self.content_unavailable_item.youtube_id) + )) + + def test_download_fail_and_skip(self): + """ + Tests that trying to download a video file that doesn't work won't + make the `videodownload` command break. + """ + queue = VideoQueue() + # Yes this is weird, but the VideoQueue instance will return an + # instance of a queue that already exists + queue.clear() + queue.add_files({self.content_unavailable_item.youtube_id: self.content_unavailable_item.title}, language="en") + call_command("videodownload") + log = UpdateProgressLog.objects.get(process_name__icontains="videodownload") + self.assertIn("Downloaded 0 of 1 videos successfully", log.notes) + + + @mock.patch("kalite.updates.videos.download_video") + def test_download_exception_and_skip(self, download_video): + """ + Tests that some unknown exception doesn't break, but skips to next + video + """ + download_video.side_effect = Exception + queue = VideoQueue() + # Yes this is weird, but the VideoQueue instance will return an + # instance of a queue that already exists + queue.clear() + queue.add_files({self.content_unavailable_item.youtube_id: self.content_unavailable_item.title}, language="en") + call_command("videodownload") + log = UpdateProgressLog.objects.get(process_name__icontains="videodownload") + self.assertIn("Downloaded 0 of 1 videos successfully", log.notes) + + + @mock.patch("requests.adapters.HTTPAdapter.send") + def test_socket_error(self, requests_get): + """ + Tests that a mocked socket error makes the download fail + """ + requests_get.side_effect = socket.timeout + with self.assertRaises(socket.timeout): + download_video(self.content_unavailable_item.youtube_id) + self.assertFalse(os.path.exists( + get_video_local_path(self.content_unavailable_item.youtube_id) + )) + + @mock.patch("requests.adapters.HTTPAdapter.send") + def test_connection_error(self, requests_get): + """ + Tests that a mocked connection error makes the download fail + """ + requests_get.side_effect = ConnectionError + with self.assertRaises(ConnectionError): + download_video(self.content_unavailable_item.youtube_id) + self.assertFalse(os.path.exists( + get_video_local_path(self.content_unavailable_item.youtube_id) + )) + + def test_download_command(self): + """ + Basic test of the ``videodownload`` command. + """ + # Check that it's been marked unavailable + queue = VideoQueue() + # Yes this is weird, but the VideoQueue instance will return an + # instance of a queue that already exists + queue.clear() + queue.add_files({self.real_video.youtube_id: self.real_video.title}, language="en") + call_command("videodownload") + # Check that it's been marked available + updated = get_content_item(content_id=self.real_video.id) + self.assertTrue(updated['available']) + + @mock.patch("kalite.updates.videos.get_thumbnail_url") + @mock.patch("kalite.updates.videos.get_video_url") + def test_500_download(self, get_thumbnail_url, get_video_url): + get_thumbnail_url.return_value = "https://httpstat.us/500" + get_video_url.return_value = "https://httpstat.us/500" + + with self.assertRaises(HTTPError): + download_video(self.real_video.youtube_id) + + self.assertFalse(os.path.exists( + get_video_local_path(self.real_video.youtube_id) + )) diff --git a/kalite/updates/videos.py b/kalite/updates/videos.py index 0532f96a04..81c88615b7 100644 --- a/kalite/updates/videos.py +++ b/kalite/updates/videos.py @@ -1,27 +1,152 @@ """ """ +import glob +import logging import os +import socket -from django.conf import settings -logging = settings.LOG +from . import settings -from fle_utils import videos # keep access to all functions +from django.conf import settings as django_settings +from fle_utils.general import ensure_dir +from fle_utils.internet.download import callback_percent_proxy, download_file +from requests.exceptions import HTTPError + + +logger = logging.getLogger(__name__) def get_local_video_size(youtube_id, default=None): try: - return os.path.getsize(os.path.join(settings.CONTENT_ROOT, "%s.mp4" % youtube_id)) - except Exception as e: - logging.debug(str(e)) + return os.path.getsize(os.path.join(django_settings.CONTENT_ROOT, "%s.mp4" % youtube_id)) + except (IOError, OSError) as e: + logger.exception(e) return default -def download_video(youtube_id, format="mp4", callback=None): - """Downloads the video file to disk (note: this does NOT invalidate any of the cached html files in KA Lite)""" +def get_url_pattern(): + """ + Returns a pattern for generating URLs of videos and thumbnails + """ + base = "http://{}/download/videos/".format(django_settings.CENTRAL_SERVER_HOST) + return base + "{dir}/{filename}" + + +def get_video_filename(youtube_id, extension="mp4"): + return "%(id)s.%(format)s" % {"id": youtube_id, "format": extension} + + +def get_video_url(youtube_id, extension="mp4"): + filename = get_video_filename(youtube_id, extension) + return get_url_pattern().format( + dir=filename, + filename=filename + ) + + +def get_thumbnail_filename(youtube_id): + return "%(id)s.png" % {"id": youtube_id} + + +def get_thumbnail_url(youtube_id, video_extension="mp4"): + return get_url_pattern().format( + dir=get_video_filename(youtube_id, video_extension), + filename=get_thumbnail_filename(youtube_id) + ) + + +def get_video_local_path(youtube_id, extension="mp4"): + download_path=django_settings.CONTENT_ROOT + filename = get_video_filename(youtube_id, extension) + return os.path.join(download_path, filename) + - download_url = ("http://%s/download/videos/" % (settings.CENTRAL_SERVER_HOST)) + "%s/%s" - return videos.download_video(youtube_id, settings.CONTENT_ROOT, download_url, format, callback) +def get_thumbnail_local_path(youtube_id): + download_path=django_settings.CONTENT_ROOT + filename = get_thumbnail_filename(youtube_id) + return os.path.join(download_path, filename) + + +def download_video(youtube_id, extension="mp4", callback=None): + """ + Downloads video file to disk + """ + + ensure_dir(django_settings.CONTENT_ROOT) + + url = get_video_url(youtube_id, extension) + thumb_url = get_thumbnail_url(youtube_id, extension) + + filepath = get_video_local_path(youtube_id, extension) + thumb_filepath = get_thumbnail_local_path(youtube_id) + + logger.info( + "Downloading {id} (video: {video}, thumbnail: {thumbnail})".format( + id=youtube_id, + video=url, + thumbnail=thumb_url, + ) + ) + + # Download video + try: + download_file( + url, + dst=filepath, + callback=callback_percent_proxy(callback, end_percent=95), + max_retries=settings.DOWNLOAD_MAX_RETRIES + ) + except HTTPError as e: + logger.error( + "HTTP status {status} for URL: {url}".format( + status=e.response.status_code, + url=e.response.url, + ) + ) + raise + + except (socket.timeout, IOError) as e: + logger.error("Network error for URL: {url}, exception: {exc}".format( + url=url, + exc=str(e) + )) + delete_downloaded_files(youtube_id) + raise + + except Exception as e: + logger.exception(e) + delete_downloaded_files(youtube_id) + raise + + # Download thumbnail - don't fail if it doesn't succeed, because at this + # stage, we know that the video has been downloaded. + try: + download_file( + thumb_url, + dst=thumb_filepath, + callback=callback_percent_proxy(callback, start_percent=95, end_percent=100), + max_retries=settings.DOWNLOAD_MAX_RETRIES + ) + except HTTPError as e: + logger.error( + "HTTP status {status} for URL: {url}".format( + status=e.response.status_code, + url=e.response.url, + ) + ) + except (socket.timeout, IOError) as e: + logger.error("Network error for URL: {url}, exception: {exc}".format( + url=url, + exc=str(e) + )) def delete_downloaded_files(youtube_id): - return videos.delete_downloaded_files(youtube_id, settings.CONTENT_ROOT) + download_path = django_settings.CONTENT_ROOT + files_deleted = 0 + for filepath in glob.glob(os.path.join(download_path, youtube_id + ".*")): + if os.path.isfile(filepath): + os.remove(filepath) + files_deleted += 1 + if files_deleted: + return True diff --git a/kalite/version.py b/kalite/version.py index d624ffec2d..8717af891d 100644 --- a/kalite/version.py +++ b/kalite/version.py @@ -3,7 +3,7 @@ # Must also be of the form N.N.N for internal use, where N is a non-negative integer MAJOR_VERSION = "0" MINOR_VERSION = "17" -PATCH_VERSION = "3" +PATCH_VERSION = "4" VERSION = "%s.%s.%s" % (MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION) SHORTVERSION = "%s.%s" % (MAJOR_VERSION, MINOR_VERSION) diff --git a/requirements.txt b/requirements.txt index cd839787ea..9ed70be2cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,6 @@ ifcfg==0.10.1 importlib==1.0.3 pbkdf2==1.3 rsa==3.4.2 -youtube-dl==2015.11.24 django-tastypie-legacy requests==2.11.1 diff --git a/requirements_pipeline.txt b/requirements_pipeline.txt new file mode 100644 index 0000000000..4b21902217 --- /dev/null +++ b/requirements_pipeline.txt @@ -0,0 +1,3 @@ +requests==2.10.0 +github3.py==0.9.6 +gcloud diff --git a/windows_installer_docker_build/Dockerfile b/windows_installer_docker_build/Dockerfile new file mode 100644 index 0000000000..521bbee42b --- /dev/null +++ b/windows_installer_docker_build/Dockerfile @@ -0,0 +1,19 @@ +FROM ubuntu:xenial + +RUN apt-get -y update + +RUN dpkg --add-architecture i386 +RUN apt-get update && apt-get install -y --no-install-recommends git wget ca-certificates sudo software-properties-common + +RUN add-apt-repository -y ppa:ubuntu-wine/ppa && apt-get -y update && apt-get install --no-install-recommends --assume-yes wine + +COPY . /kalite + +RUN mkdir /installer/ + +# Build KA-Lite windows installer. +CMD cd /kalite/ka-lite-installers/windows \ + && WHL_FILE=$(basename $(find . -name ka_lite-*.whl)) \ + && export KALITE_BUILD_VERSION=$(echo $WHL_FILE | cut -d'-' -f 2) \ + && wine inno-compiler/ISCC.exe installer-source/KaliteSetupScript.iss \ + && cp /kalite/ka-lite-installers/windows/KALiteSetup-$KALITE_BUILD_VERSION.exe /installer/