diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..20d7ebb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.dockerignore +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env +venv* +pip-log.txt +pip-delete-this-directory.txt +.tox +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +*.log +.git +*.template +.env* +fake-s3 diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..e452872 --- /dev/null +++ b/.env.template @@ -0,0 +1,16 @@ +TOOL_TITLE=Faculty Tools +THEME_DIR= +BASE_CANVAS_SERVER_URL=https://example.com/ +SECRET_KEY=CHANGEME +LTI_KEY=key +LTI_SECRET=secret +OAUTH2_URI=http://127.0.0.1:9001/oauthlogin +OAUTH2_ID=CHANGEME +OAUTH2_KEY=CHANGEME +GOOGLE_ANALYTICS=GA-000000 +CONFIG=config.DevelopmentConfig +DATABASE_URI=mysql://root:secret@db/faculty_tools + +REQUIREMENTS=test_requirements.txt + +WHITELIST_JSON=whitelist.json \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..796eb2d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,96 @@ +name: Run Python Tests and Build Image + +on: + push: + branches: + - issue/21-dockerize + - develop + - master + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + REQUIREMENTS: requirements.txt + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install Python 3 + uses: actions/setup-python@v1 + with: + python-version: 3.7 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r test_requirements.txt + pip install coveralls + - name: Setup Repo + run: | + cp whitelist.json.template whitelist.json + cp .env.template .env + - name: Run flake8 + run: flake8 + - name: Run black + run: black --check . + - name: Lint markdown files + uses: bewuethr/mdl-action@v1 + # - name: Load dotenv + # uses: falti/dotenv-action@v0.2.5 + - name: Environment Variables from Dotenv + uses: c-py/action-dotenv-to-setenv@v3 + # - name: Print Repo + # run: | + # env + - name: Run unittests + run: coverage run -m unittest discover + + + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Print Buildx + run: | + docker buildx ls + + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: REQUIREMENTS=${{ env.REQUIREMENTS }} + platforms: linux/amd64 + + - name: Build and push ARM64 Docker image + uses: docker/build-push-action@v3 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: REQUIREMENTS=${{ env.REQUIREMENTS }} + platforms: linux/arm64 + + - name: Build and push ARMv7 Docker image + uses: docker/build-push-action@v3 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: REQUIREMENTS=${{ env.REQUIREMENTS }} + platforms: linux/arm/v7 + diff --git a/.gitignore b/.gitignore index 0e980ad..86319a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # config/settings logs -settings.py whitelist.json +.env # local theming themes/* diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 802f960..0000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -language: python -matrix: - include: - - python: 3.7 - - python: 3.8 -install: -- pip install -r test_requirements.txt -- pip install coveralls -- gem install mdl -before_script: -- cp settings.py.template settings.py; cp whitelist.json.template whitelist.json -- mkdir logs; touch logs/faculty-tools.log -script: -- flake8 -- black --check . -- mdl . -- coverage run -m unittest discover -after_success: -- coveralls -notifications: - slack: - rooms: - secure: F3YANiuNHZjtbgiJC8M1JOKBKhct2EyDEkCitW4FMwrauV0G5XcWKqtqLyRzSchI1LUALn+Dnj032ElGEx7D7cJrXlqC700UjQ4wXv938GQObVnRfTVCVrsktpr5gf077dIXvwcYJYt9ZSu4uIyk+HqyNnHLX4qIL0zhFR8HytoOeXVdik35SQoLJLvorgf4EGfqU8Yo25LUJArp2AB7RceAiMg3QXmi+nDHumFFczURexaYXIDBrRYTyZgfpYP245HOUmEf/LD6G53e6FU+8hiISYr7nE0hTkNkj3U4WNga25//9VIdpjWW8VWd+G7vf8CuhzHYuWEtredoVqNnJDwKE/MLhixleA+1lAEUypAEp+0k3+zMfTk748gdl1buJ/kINjoNqjhLv6MtDH/YTw/eQKEXA+V+odoudDiHUCztHQbCaIXYmIDFnebzO9u/Gz7QJ7PfpBlmUASQru5qTFPL0tmHP/w6/zrog0n07+uwl4qo2d6qxslgtmw4+K0VxGXl1Z8STArEgD6a8KoTZ1N8XDFF0E7KXE3kGYEtLHNoV7Z9OaohB3AFwJaKPTytYyIQ+OPvzYmpyUwRGIWBfRIP7t9qemyOExbYoinw0rF7jCMm7T3gLxkwxHNOCt+Gc1kwfLvuDy8QhQR2iHspMM8NeuDQKVzK4L3FeLx7bTo= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbfdd11 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.7 as base +ARG REQUIREMENTS +COPY requirements.txt /app/ +COPY test_requirements.txt /app/ +RUN pip install -r /app/$REQUIREMENTS +WORKDIR /app +COPY ./ /app/ +EXPOSE 9001 +CMD ["gunicorn", "--conf", "gunicorn_conf.py", "--bind", "0.0.0.0:9001", "lti:app"] \ No newline at end of file diff --git a/README.md b/README.md index 529383f..06fefc9 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,54 @@ # Documentation for Faculty Tools -## Settings +## Setting up Faculty Tools with Docker & Docker-Compose -Create a new `settings.py` file from the template +First clone and setup the repo. ```sh -cp settings.py.template settings.py +git clone git@github.com:ucfopen/faculty-tools.git +cd faculty-tools +cp whitelist.json.template whitelist.json +cp .env.template .env ``` -Edit `settings.py` to configure the application. All fields are required, +### Environment Variables + +Edit `.env` to configure the application. All fields are required, unless specifically noted. -## Developer Key +To create a good secure secret key, run this command: + +```sh +docker-compose run --rm lti python -c "import os, binascii; print(binascii.b2a_base64(os.urandom(24)).decode('ascii'))" +``` + +```sh +TOOL_TITLE=Faculty Tools # Window Title of Page +THEME_DIR= # Keep blank unless building out your own theme directory +BASE_CANVAS_SERVER_URL=https://example.com/ # the URL for your canvas server +SECRET_KEY=CHANGEME # Random key used to secure portions of Flask - Follow instructions above +LTI_KEY=key # Random key - This is public - Used to install LTI. +LTI_SECRET=secret # Random secret key - Used to install LTI. Do not share! +OAUTH2_URI=http://127.0.0.1:9001/oauthlogin # URL of faculty tools oauthlogin page +OAUTH2_ID=CHANGEME # ID given by LMS Admins / Developer Key (API Key) page in Canvas +OAUTH2_KEY=CHANGEME # ID given by LMS Admins / Developer Key (API Key) page in Canvas +GOOGLE_ANALYTICS=GA-000000 # Your Google Analytics id. +DATABASE_URI=mysql://root:secret@db/faculty_tools # Your mysql connection string. + +# config.py configuration settings +# config.Development for Dev, config.BaseConfig for production +CONFIG=config.DevelopmentConfig + + +# test_requirements for development / requirements.txt for production. +REQUIREMENTS=test_requirements.txt + +WHITELIST_JSON=whitelist.json # See below + +``` + +## Developer Key -> API Key You will need a developer key for the OAuth2 flow. Check out the [Canvas documentation for creating a new developer key](https://community.canvaslms.com/docs/DOC-12657-4214441833) @@ -57,31 +93,14 @@ Add the tools you want instructors and faculty to see to `whitelist.json`. ] ``` -## Virtual Environment - -Create a new virtual environment. - -```sh -virtualenv env -``` - -Activate the environment. - -```sh -source env/bin/activate -``` - -Install everything: - -```sh -pip install -r requirements.txt -``` - ## Create DB -Change directory into the project folder. Create the database in python shell: +We need to generate the database and tables for faculty tools to run properly. +The MySQL docker image automatically creates the user, password, and database +name set in the `docker-compose.yml` file. ```sh +docker-compose run --rm lti python from lti import db db.create_all() ``` @@ -90,34 +109,35 @@ If you want to look at your users table in the future, you can do so in the python shell: ```python +docker-compose run lti python from lti import Users Users.query.all() ``` -## Environment Variables +## Run the App -Set the flask app to `lti.py` and debug to true. +It's time to use docker-compose to bring up the application. ```sh -export FLASK_APP=lti.py -export FLASK_DEBUG=1 +docker-compose up -d ``` -Alternatively, you can run the setup script to simultaneously setup environment -variables and the virtual environment. +Go to the /xml page, by default. -```sh -source setup.sh -``` +Copy the xml, and install it into a course (Course->Settings->Apps). -## Run the App +## View the Logs -Run the lti script while your virtual environment is active. +To view the logs while the application is running use this docker command: ```sh -flask run +docker-compose logs -f ``` -Go to the /xml page, [http://0.0.0.0:5000/xml](http://0.0.0.0:5000/xml) by default +## Stopping the App + +To shutdown Faculty Tools -Copy the xml, install it into a course. +```sh +docker-compose down +``` diff --git a/config.py b/config.py index c360a34..473e348 100644 --- a/config.py +++ b/config.py @@ -1,12 +1,12 @@ -import settings +import os -class Config(object): +class BaseConfig(object): + DEBUG = False + TESTING = False + # make the warning shut up until Flask-SQLAlchemy v3 comes out SQLALCHEMY_TRACK_MODIFICATIONS = True - SQLALCHEMY_DATABASE_URI = settings.select_db("Config") - - PYLTI_CONFIG = settings.PYLTI_CONFIG SESSION_COOKIE_NAME = "ft_session" @@ -14,33 +14,59 @@ class Config(object): SESSION_COOKIE_SECURE = True SESSION_COOKIE_SAMESITE = "None" + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URI") -class BaseConfig(object): - DEBUG = False - TESTING = False + # Title of the tool. Appears in the element, headers, and configuration XML + TOOL_TITLE = os.environ.get("TOOL_TITLE", "Faculty Tools") - # make the warning shut up until Flask-SQLAlchemy v3 comes out - SQLALCHEMY_TRACK_MODIFICATIONS = True - SQLALCHEMY_DATABASE_URI = settings.select_db("BaseConfig") + # Which theme directory to use. Leave blank for default. + THEME_DIR = os.environ.get("THEME_DIR", "") - PYLTI_CONFIG = settings.PYLTI_CONFIG + # Canvas instance URL. ex: https://example.instructure.com/ + BASE_URL = os.environ.get("BASE_CANVAS_SERVER_URL", "https://example.com/") + API_URL = BASE_URL + "api/v1/" - SESSION_COOKIE_NAME = "ft_session" + # Secret key to sign Flask sessions with. KEEP THIS SECRET! + # Set this in .env file. + SECRET_KEY = os.environ.get("SECRET_KEY") - # Chrome 80 SameSite=None; Secure fix - SESSION_COOKIE_SECURE = True - SESSION_COOKIE_SAMESITE = "None" + # LTI consumer key and shared secret + CONSUMER_KEY = os.environ.get("LTI_KEY") + SHARED_SECRET = os.environ.get("LTI_SECRET") + + # Configuration for pylti library. Uses the above key and secret + PYLTI_CONFIG = { + "consumers": {CONSUMER_KEY: {"secret": SHARED_SECRET}}, + # Custom configurable roles + "roles": { + "staff": [ + "urn:lti:instrole:ims/lis/Administrator", + "Instructor", + "ContentDeveloper", + "urn:lti:role:ims/lis/TeachingAssistant", + ] + }, + } + + # The "Oauth2 Redirect URI" that you provided to Instructure. + # Set in .env file + OAUTH2_URI = os.environ.get("OAUTH2_URI") # ex. 'http://localhost:9001/oauthlogin' + # The Client_ID Instructure gave you + OAUTH2_ID = os.environ.get("OAUTH2_ID") + # The Secret Instructure gave you + OAUTH2_KEY = os.environ.get("OAUTH2_KEY") + + WHITELIST = os.environ.get("WHITELIST_JSON") + + # Google Analytics Tracking ID (optional) + GOOGLE_ANALYTICS = os.environ.get("GOOGLE_ANALYTICS", "GA-") class DevelopmentConfig(BaseConfig): DEBUG = True TESTING = True - SQLALCHEMY_DATABASE_URI = settings.select_db("DevelopmentConfig") - class TestingConfig(BaseConfig): DEBUG = False TESTING = True - - SQLALCHEMY_DATABASE_URI = settings.select_db("TestingConfig") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..921263f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.1' + +services: + lti: + build: + context: . + args: + - "REQUIREMENTS=${REQUIREMENTS}" + ports: + - "9001:9001" + env_file: + - .env + depends_on: + - db + + db: + image: mysql:5.7 + platform: linux/amd64 + volumes: + - ft_dbdata:/var/lib/mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: faculty_tools + MYSQL_PASSWORD: secret + ports: + - "33061:3306" +volumes: + ft_dbdata: {} + + + + diff --git a/gunicorn_conf.py b/gunicorn_conf.py new file mode 100644 index 0000000..cad3455 --- /dev/null +++ b/gunicorn_conf.py @@ -0,0 +1,9 @@ +# Gunicorn config variables +loglevel = "info" +errorlog = "-" # stderr +accesslog = "-" # stdout +worker_tmp_dir = "/dev/shm" +graceful_timeout = 120 +timeout = 120 +keepalive = 5 +threads = 3 diff --git a/lti.py b/lti.py index 3b98987..073ba0c 100644 --- a/lti.py +++ b/lti.py @@ -1,7 +1,8 @@ +import logging from logging import Formatter, INFO -from logging.handlers import RotatingFileHandler import json import os +import sys import time from canvasapi.exceptions import CanvasException @@ -16,17 +17,18 @@ send_from_directory, ) from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import text import jinja2 from pylti.flask import lti import requests from requests.exceptions import HTTPError from utils import filter_tool_list, slugify -import settings app = Flask(__name__) -app.config.from_object(settings.configClass) -app.secret_key = settings.secret_key +app.config.from_object(os.environ.get("CONFIG", "config.DevelopmentConfig")) +app.secret_key = app.config["SECRET_KEY"] + db = SQLAlchemy(app) @@ -34,8 +36,8 @@ def select_theme_dirs(): """ Load theme templates, if applicable """ - if settings.THEME_DIR: - return ["themes/" + settings.THEME_DIR + "/templates", "templates"] + if app.config["THEME_DIR"]: + return ["themes/" + app.config["THEME_DIR"] + "/templates", "templates"] else: return ["templates"] @@ -44,11 +46,7 @@ def select_theme_dirs(): app.jinja_loader = jinja2.ChoiceLoader([jinja2.FileSystemLoader(theme_dirs)]) # Logging -handler = RotatingFileHandler( - settings.ERROR_LOG, - maxBytes=settings.LOG_MAX_BYTES, - backupCount=settings.LOG_BACKUP_COUNT, -) +handler = logging.StreamHandler(sys.stdout) handler.setLevel(INFO) handler.setFormatter( Formatter( @@ -79,7 +77,7 @@ def __repr__(self): @app.context_processor def ga_utility_processor(): def google_analytics(): - return settings.GOOGLE_ANALYTICS + return app.config["GOOGLE_ANALYTICS"] return dict(google_analytics=google_analytics()) @@ -87,7 +85,7 @@ def google_analytics(): @app.context_processor def title_utility_processor(): def title(): - return settings.TOOL_TITLE + return app.config["TOOL_TITLE"] return dict(title=title()) @@ -95,13 +93,13 @@ def title(): @app.context_processor def theme_static_files_processor(): def theme_static_files(folder): - if not settings.THEME_DIR: + if not app.config["THEME_DIR"]: return list() try: all_files = os.listdir( "themes/{theme_dir}/static/{folder}".format( - theme_dir=settings.THEME_DIR, folder=folder + theme_dir=app.config["THEME_DIR"], folder=folder ) ) @@ -144,7 +142,7 @@ def error(exception=None): @app.route("/themes/static/<path:filename>") def theme_static(filename): # pragma: nocover - static_dir = "themes/{theme_dir}/static".format(theme_dir=settings.THEME_DIR) + static_dir = "themes/{theme_dir}/static".format(theme_dir=app.config["THEME_DIR"]) return send_from_directory(static_dir, filename) @@ -167,7 +165,7 @@ def index(lti=lti): # Test API key to see if they need to reauthenticate auth_header = {"Authorization": "Bearer " + session["api_key"]} - r = requests.get(settings.API_URL + "users/self", headers=auth_header) + r = requests.get(app.config["API_URL"] + "users/self", headers=auth_header) if "WWW-Authenticate" in r.headers: # reroll oauth app.logger.info( @@ -178,11 +176,11 @@ def index(lti=lti): ) return redirect( - settings.BASE_URL + app.config["BASE_URL"] + "login/oauth2/auth?client_id=" - + settings.oauth2_id + + app.config["OAUTH2_ID"] + "&response_type=code&redirect_uri=" - + settings.oauth2_uri + + app.config["OAUTH2_URI"] ) if "WWW-Authenticate" not in r.headers and r.status_code == 401: @@ -210,15 +208,15 @@ def index(lti=lti): ) ) return redirect( - settings.BASE_URL + app.config["BASE_URL"] + "login/oauth2/auth?client_id=" - + settings.oauth2_id + + app.config["OAUTH2_ID"] + "&response_type=code&redirect_uri=" - + settings.oauth2_uri + + app.config["OAUTH2_URI"] ) r = requests.get( - settings.API_URL + app.config["API_URL"] + "courses/{0}/external_tools?include_parents=true&per_page=100".format( session["course_id"] ), @@ -261,7 +259,7 @@ def status(): "checks": {"index": False, "xml": False, "db": False, "dev_key": False}, "url": url_for("index", _external=True), "xml_url": url_for("xml", _external=True), - "base_url": settings.BASE_URL, + "base_url": app.config["BASE_URL"], "debug": app.debug, } @@ -269,7 +267,7 @@ def status(): try: response = requests.get(url_for("index", _external=True), verify=False) index_check = ( - response.status_code == 200 and settings.TOOL_TITLE in response.text + response.status_code == 200 and app.config["TOOL_TITLE"] in response.text ) status["checks"]["index"] = index_check except Exception: @@ -286,7 +284,7 @@ def status(): # Check DB connection try: - db.session.query("1").all() + db.session.query(text("1")).all() status["checks"]["db"] = True except Exception: app.logger.exception("DB connection failed.") @@ -295,7 +293,9 @@ def status(): try: response = requests.get( "{}login/oauth2/auth?client_id={}&response_type=code&redirect_uri={}".format( - settings.BASE_URL, settings.oauth2_id, settings.oauth2_uri + app.config["BASE_URL"], + app.config["OAUTH2_ID"], + app.config["OAUTH2_URI"], ) ) status["checks"]["dev_key"] = response.status_code == 200 @@ -337,12 +337,12 @@ def oauth_login(lti=lti): payload = { "grant_type": "authorization_code", - "client_id": settings.oauth2_id, - "redirect_uri": settings.oauth2_uri, - "client_secret": settings.oauth2_key, + "client_id": app.config["OAUTH2_ID"], + "redirect_uri": app.config["OAUTH2_URI"], + "client_secret": app.config["OAUTH2_KEY"], "code": code, } - r = requests.post(settings.BASE_URL + "login/oauth2/token", data=payload) + r = requests.post(app.config["BASE_URL"] + "login/oauth2/token", data=payload) try: r.raise_for_status() @@ -442,12 +442,14 @@ def refresh_access_token(user): payload = { "grant_type": "refresh_token", - "client_id": settings.oauth2_id, - "redirect_uri": settings.oauth2_uri, - "client_secret": settings.oauth2_key, + "client_id": app.config["OAUTH2_ID"], + "redirect_uri": app.config["OAUTH2_URI"], + "client_secret": app.config["OAUTH2_KEY"], "refresh_token": refresh_token, } - response = requests.post(settings.BASE_URL + "login/oauth2/token", data=payload) + response = requests.post( + app.config["BASE_URL"] + "login/oauth2/token", data=payload + ) try: response.raise_for_status() @@ -535,11 +537,11 @@ def auth(lti=lti): ) ) return redirect( - settings.BASE_URL + app.config["BASE_URL"] + "login/oauth2/auth?client_id=" - + settings.oauth2_id + + app.config["OAUTH2_ID"] + "&response_type=code&redirect_uri=" - + settings.oauth2_uri + + app.config["OAUTH2_URI"] ) # Get the expiration date @@ -567,17 +569,17 @@ def auth(lti=lti): # Refresh didn't work. Reauthenticate. app.logger.info("Reauthenticating:\nSession: {}".format(session)) return redirect( - settings.BASE_URL + app.config["BASE_URL"] + "login/oauth2/auth?client_id=" - + settings.oauth2_id + + app.config["OAUTH2_ID"] + "&response_type=code&redirect_uri=" - + settings.oauth2_uri + + app.config["OAUTH2_URI"] ) else: # API key that shouldn't be expired. Test it. auth_header = {"Authorization": "Bearer " + session["api_key"]} r = requests.get( - settings.API_URL + "users/%s/profile" % (session["canvas_user_id"]), + app.config["API_URL"] + "users/%s/profile" % (session["canvas_user_id"]), headers=auth_header, ) # check for WWW-Authenticate @@ -595,11 +597,11 @@ def auth(lti=lti): # Refresh didn't work. Reauthenticate. app.logger.info("Reauthenticating\nSession: {}".format(session)) return redirect( - settings.BASE_URL + app.config["BASE_URL"] + "login/oauth2/auth?client_id=" - + settings.oauth2_id + + app.config["OAUTH2_ID"] + "&response_type=code&redirect_uri=" - + settings.oauth2_uri + + app.config["OAUTH2_URI"] ) @@ -616,7 +618,7 @@ def get_sessionless_url(lti_id, is_course_nav, lti=lti): "&launch_type=course_navigation" ) r = requests.get( - url.format(settings.API_URL, session["course_id"], lti_id), + url.format(app.config["API_URL"], session["course_id"], lti_id), headers=auth_header, ) if r.status_code >= 400: @@ -640,7 +642,7 @@ def get_sessionless_url(lti_id, is_course_nav, lti=lti): auth_header = {"Authorization": "Bearer " + session["api_key"]} # get sessionless launch url r = requests.get( - settings.API_URL + app.config["API_URL"] + "courses/{0}/external_tools/sessionless_launch?id={1}".format( session["course_id"], lti_id ), diff --git a/requirements.txt b/requirements.txt index b4f768f..246acc5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,11 @@ -canvasapi==0.15.0 -Flask==1.1.1 -Flask-SQLAlchemy==2.4.1 -mysqlclient +Flask==2.0.1 -e git+https://github.com/ucfcdl/pylti.git@roles#egg=PyLTI -requests==2.22.0 -Werkzeug>=1.0.1 # Chrome 80 SameSite fix +requests==2.27.1 +gunicorn==20.1.0 +mysqlclient==2.1.0 +Flask-SQLAlchemy==2.5.1 +canvasapi==2.2.0 +Werkzeug==2.0.2 +itsdangerous==2.0.1 +Jinja2==3.0.2 +MarkupSafe==2.0.1 \ No newline at end of file diff --git a/settings.py.template b/settings.py.template deleted file mode 100644 index ce34aac..0000000 --- a/settings.py.template +++ /dev/null @@ -1,63 +0,0 @@ -# Title of the tool. Appears in the <title> element, headers, and configuration XML -TOOL_TITLE = "Faculty Tools" - -# Which theme directory to use. Leave blank for default. -THEME_DIR = "" - -# Canvas instance URL. ex: https://example.instructure.com/ -BASE_URL = "https://example.instructure.com/" -API_URL = BASE_URL + "api/v1/" - -# Secret key to sign Flask sessions with. KEEP THIS SECRET! -secret_key = "" - -# LTI consumer key and shared secret -CONSUMER_KEY = "key" -SHARED_SECRET = "secret" - -# Configuration for pylti library. Uses the above key and secret -PYLTI_CONFIG = { - "consumers": { - CONSUMER_KEY: { - "secret": SHARED_SECRET - } - }, - # Custom configurable roles - "roles": { - "staff": [ - "urn:lti:instrole:ims/lis/Administrator", - "Instructor", - "ContentDeveloper", - "urn:lti:role:ims/lis/TeachingAssistant", - ] - }, -} - -# The "Oauth2 Redirect URI" that you provided to Instructure. -oauth2_uri = "" # ex. 'https://localhost:5000/oauthlogin' -# The Client_ID Instructure gave you -oauth2_id = "" -# The Secret Instructure gave you -oauth2_key = "" - -# Logging configuration -LOG_MAX_BYTES = 1024 * 1024 * 5 # 5 MB -LOG_BACKUP_COUNT = 2 -ERROR_LOG = "logs/faculty-tools.log" - -whitelist = "whitelist.json" - -# Google Analytics Tracking ID (optional) -GOOGLE_ANALYTICS = "" - - -def select_db(x): - return { - "DevelopmentConfig": "sqlite:///test.db", - "Config": "sqlite:///test.db", - "BaseConfig": "sqlite:///test.db", - "TestingConfig": "sqlite:///test.db", - }.get(x, "sqlite:///test2.db") - - -configClass = "config.DevelopmentConfig" diff --git a/tests.py b/tests.py index c7c09e4..b6d989d 100644 --- a/tests.py +++ b/tests.py @@ -6,7 +6,7 @@ import canvasapi import oauthlib.oauth1 import flask -from flask import url_for +from flask import Flask, url_for import flask_testing import requests_mock from pylti.common import LTI_SESSION_KEY @@ -14,7 +14,6 @@ from mock import patch, mock_open import lti -import settings import utils @@ -34,11 +33,12 @@ def create_app(self): @classmethod def setUpClass(cls): logging.disable(logging.CRITICAL) - settings.BASE_URL = "https://example.edu/" - settings.oauth2_id = "10000000000001" - settings.oauth2_uri = "oauthlogin" - settings.GOOGLE_ANALYTICS = "123abc" - settings.THEME_DIR = "test_theme" + app = lti.app + app.config["BASE_URL"] = "https://example.edu/" + app.config["OAUTH2_ID"] = "10000000000001" + app.config["OAUTH2_URI"] = "oauthlogin" + app.config["GOOGLE_ANALYTICS"] = "123abc" + app.config["THEME_DIR"] = "test_theme" def setUp(self): with self.app.test_request_context(): @@ -94,8 +94,10 @@ def test_select_theme_dirs(self, m): self.assertEqual(theme_dirs[0], "themes/test_theme/templates") self.assertEqual(theme_dirs[1], "templates") - @patch("settings.THEME_DIR", "") + # @patch('self.app.config["BASE_URL"]', "") def test_select_theme_dirs_no_theme(self, m): + self.app.config["BASE_URL"] = "" + self.app.config["THEME_DIR"] = "" theme_dirs = lti.select_theme_dirs() self.assertIsInstance(theme_dirs, list) @@ -115,6 +117,7 @@ def test__slugify_empty(self, m): @patch("os.listdir") def test_theme_static_files_processor(self, m, mocked_listdir): + self.app.config["THEME_DIR"] = "test_theme" mocked_listdir.return_value = ["file1.css", "file2.js"] files = lti.theme_static_files_processor() @@ -144,8 +147,9 @@ def test_theme_static_files_processor_oserror(self, m, mocked_listdir): self.assertIsInstance(files["theme_static_js"], list) self.assertEqual(len(files["theme_static_js"]), 0) - @patch("settings.THEME_DIR", "") - def test_heme_static_files_processor_no_theme(self, m): + # @patch('app.config["THEME_DIR"]', "") + def test_theme_static_files_processor_no_theme(self, m): + self.app.config["THEME_DIR"] = "" files = lti.theme_static_files_processor() self.assertIsInstance(files, dict) @@ -181,7 +185,7 @@ def test_ga_utility_processor(self, m): self.assertIsInstance(ga, dict) self.assertIn("google_analytics", ga) - self.assertEqual(ga["google_analytics"], settings.GOOGLE_ANALYTICS) + self.assertEqual(ga["google_analytics"], self.app.config["GOOGLE_ANALYTICS"]) # title_utility_processor def test_title_utility_processor(self, m): @@ -189,7 +193,7 @@ def test_title_utility_processor(self, m): self.assertIsInstance(title, dict) self.assertIn("title", title) - self.assertEqual(title["title"], settings.TOOL_TITLE) + self.assertEqual(title["title"], self.app.config["TOOL_TITLE"]) # return_error def test_return_error(self, m): @@ -260,7 +264,9 @@ def test_index_api_key_expired(self, m): self.assert_redirects( response, redirect_url.format( - settings.BASE_URL, settings.oauth2_id, settings.oauth2_uri + self.app.config["BASE_URL"], + self.app.config["OAUTH2_ID"], + self.app.config["OAUTH2_URI"], ), ) @@ -300,7 +306,9 @@ def test_index_api_key_404(self, m): self.assert_redirects( response, redirect_url.format( - settings.BASE_URL, settings.oauth2_id, settings.oauth2_uri + self.app.config["BASE_URL"], + self.app.config["OAUTH2_ID"], + self.app.config["OAUTH2_URI"], ), ) @@ -349,7 +357,7 @@ def test_index_whitelist_error(self, m, filter_tool_list): ], headers={ "Link": '<{}api/v1/courses/1/external_tools?page=2>; rel="next"'.format( - settings.BASE_URL + self.app.config["BASE_URL"] ) }, status_code=200, @@ -394,7 +402,7 @@ def test_index_canvas_error(self, m, filter_tool_list): ], headers={ "Link": '<{}api/v1/courses/1/external_tools?page=2>; rel="next"'.format( - settings.BASE_URL + self.app.config["BASE_URL"] ) }, status_code=200, @@ -432,7 +440,7 @@ def test_index(self, m): ], headers={ "Link": '<{}api/v1/courses/1/external_tools?page=2>; rel="next"'.format( - settings.BASE_URL + self.app.config["BASE_URL"] ) }, status_code=200, @@ -452,8 +460,13 @@ def test_index(self, m): # status def test_status_healthy(self, m): + self.app.config["BASE_URL"] = "https://example.edu/" + m.register_uri( - "GET", "http://localhost/", status_code=200, text=settings.TOOL_TITLE + "GET", + "http://localhost/", + status_code=200, + text=self.app.config["TOOL_TITLE"], ) m.register_uri( "GET", @@ -908,7 +921,9 @@ def test_auth_no_user(self, m): self.assert_redirects( response, redirect_url.format( - settings.BASE_URL, settings.oauth2_id, settings.oauth2_uri + self.app.config["BASE_URL"], + self.app.config["OAUTH2_ID"], + self.app.config["OAUTH2_URI"], ), ) @@ -990,7 +1005,9 @@ def test_auth_no_api_key_refresh_fail(self, m, mock_refresh_access_token): self.assert_redirects( response, redirect_url.format( - settings.BASE_URL, settings.oauth2_id, settings.oauth2_uri + self.app.config["BASE_URL"], + self.app.config["OAUTH2_ID"], + self.app.config["OAUTH2_URI"], ), ) @@ -1088,7 +1105,9 @@ def test_auth_invalid_api_key_refresh_fail(self, m, mock_refresh_access_token): self.assert_redirects( response, redirect_url.format( - settings.BASE_URL, settings.oauth2_id, settings.oauth2_uri + self.app.config["BASE_URL"], + self.app.config["OAUTH2_ID"], + self.app.config["OAUTH2_URI"], ), ) @@ -1238,20 +1257,28 @@ def test_get_sessionless_url_not_course_nav_succeed(self, m): class UtilsTests(unittest.TestCase): + app = Flask("test") + app.config["WHITELIST"] = "whitelist.json" + app.config["BASE_URL"] = "https://example.edu/" + @classmethod def setUpClass(cls): - settings.BASE_URL = "https://example.edu/" - settings.whitelist = "whitelist.json" + app = lti.app + app.config["BASE_URL"] = "https://example.edu/" + app.config["WHITELIST"] = "whitelist.json" + return app def test_filter_tool_list_empty_file(self): - with self.assertRaises(JSONDecodeError): - with patch("builtins.open", mock_open(read_data="")): - utils.filter_tool_list(1, "password") + with self.app.app_context(): + with self.assertRaises(JSONDecodeError): + with patch("builtins.open", mock_open(read_data="")): + utils.filter_tool_list(1, "password") def test_filter_tool_list_empty_data(self): - with self.assertRaisesRegex(ValueError, r"whitelist\.json is empty"): - with patch("builtins.open", mock_open(read_data="{}")): - utils.filter_tool_list(1, "password") + with self.app.app_context(): + with self.assertRaisesRegex(ValueError, r"whitelist\.json is empty"): + with patch("builtins.open", mock_open(read_data="{}")): + utils.filter_tool_list(1, "password") @patch("canvasapi.canvas.Canvas.get_course") @patch("canvasapi.course.Course.get_external_tools") diff --git a/utils.py b/utils.py index aca6e28..f6adfac 100644 --- a/utils.py +++ b/utils.py @@ -4,7 +4,7 @@ from canvasapi import Canvas -import settings +from flask import current_app def get_tool_info(whitelist, tool_name): @@ -37,13 +37,13 @@ def filter_tool_list(course_id, access_token): The values are a list of all installed external tools that are in that category and on the whitelist. """ - with open(settings.whitelist, "r") as wl_file: + with open(current_app.config["WHITELIST"], "r") as wl_file: whitelist = json.loads(wl_file.read()) if not whitelist: raise ValueError("whitelist.json is empty") - canvas = Canvas(settings.BASE_URL, access_token) + canvas = Canvas(current_app.config["BASE_URL"], access_token) course = canvas.get_course(course_id) installed_tools = course.get_external_tools(include_parents=True)