diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e0a513e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env +pip-log.txt +pip-delete-this-directory.txt +.tox +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.mypy_cache +.pytest_cache +.hypothesis% \ No newline at end of file diff --git a/.env-template b/.env-template new file mode 100644 index 0000000..d75d812 --- /dev/null +++ b/.env-template @@ -0,0 +1,5 @@ +SECRET_FLASK=CHANGEME +LTI_KEY=CHANGEME +LTI_SECRET=CHANGEME +CONFIG=config.DevelopmentConfig +REQUIREMENTS=requirements.txt \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..05004c1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: Run Python Tests and Build Image + +on: + push: + branches: + - issue/15-Dockerize + - develop + - master + +env: + 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 requirements.txt + pip install coveralls + - name: Setup Repo + run: | + 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: Run unittests + run: coverage run -m unittest discover + + + + diff --git a/.gitignore b/.gitignore index eb287e4..8f6c042 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,136 @@ venv/ error.log *.DS_STORE test.db + +# config/settings +logs + +.env + + + +# system +*.DS_Store + +# database files +*.db + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +*.log.* +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +env2/ +env3/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/.mdlrc b/.mdlrc new file mode 100644 index 0000000..bdaf13d --- /dev/null +++ b/.mdlrc @@ -0,0 +1 @@ +style "markdown-style.rb" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4539f5e..6192170 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,18 +3,21 @@ Thanks for being interested! Here's our guidelines to contributing to the templates. ## Get Set Up + - Check the issues to see if your problem has already been brought up. - Submit a ticket if it hasn't. Be sure to be clear and give instructions on how to reproduce the issue you're having. ## Getting Your Hands Dirty + - Fork the repository. - Please try not to work on master. Instead, create a new branch named after your issue and its number: - `git checkout -b issue/number-brief-issue-description` -## Submit! +## Submit + - Push your changes. - Submit a pull request. - We will review your changes and try to respond in a timely manner. -That's it! \ No newline at end of file +That's it! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..93984e7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.7 +ARG REQUIREMENTS + +COPY ./requirements.txt /code/requirements.txt +RUN pip install --upgrade pip +RUN pip install -r /code/$REQUIREMENTS + +WORKDIR /code +COPY ./ /code/ +EXPOSE 3104 +CMD ["gunicorn", "--conf", "gunicorn_conf.py", "--bind", "0.0.0.0:9001", "views:app"] \ No newline at end of file diff --git a/README.md b/README.md index aac3b4f..e464a1e 100644 --- a/README.md +++ b/README.md @@ -4,48 +4,47 @@ ## Setup -### Virtual Environment -Create a virtual environment that uses Python 2: +### Docker -``` -virtualenv venv -p /usr/bin/python2.7 -source venv/bin/activate -``` - -Install the dependencies from the requirements file. - -``` -pip install -r requirements.txt -``` +We are going to be creating a Docker container that uses Python 3: -### Create your local settings file -Create settings.py from settings.py.template +#### Setup environment variables +```bash +cp .env-template .env ``` -cp settings.py.template settings.py -``` - -Note: settings.py is alreay referenced in the .gitignore and multiple python files, if you want a different settings file name be sure to update the references. -#### Add your values to the settings file. -At a minimum, CONSUMER_KEY, SHARED_SECRET, and secret_key need to be input by the developer. The secret_key is used by Flask, but the CONSUMER_KEY and SHARED_SECRET will be used in setting up the LTI. For security purposes, it's best to have randomized keys. You can generate random keys in the command line by using os.urandom(24) and inputing the resulting values into the settings.py file: +Now you can begin editing the `.env` file. At a minimum, CONSUMER_KEY, SHARED_SECRET, and SECRET_FLASK need to be input by the developer. The SECRET_FLASK is used by Flask, but the CONSUMER_KEY and SHARED_SECRET will be used in setting up the LTI. For security purposes, it's best to have randomized keys. You can generate random keys in the command line by using os.urandom(24) and inputing the resulting values into the .env file: -``` +```bash import os os.urandom(24) ``` -### Run a Development Server -Here's how you run the flask app from the terminal: +#### Build and run Docker Container + +```bash +docker-compose build +docker-compose up ``` -export FLASK_APP=views.py -flask run + +You should see something like this in your console: + +```bash +[INFO] Starting gunicorn 20.1.0 +[INFO] Listening at: http://0.0.0.0:9001 (1) +[INFO] Using worker: gthread +[INFO] Booting worker with pid: 11 ``` +Your server should now be and running. + ### Open in a Browser -Your running server will be visible at [http://127.0.0.1:5000](http://127.0.0.1:5000) + +Your running server will be visible at [http://127.0.0.1:9001](http://127.0.0.1:9001) ## Install LTI in Canvas + - Have the XML, consumer key, and secret ready. - You can use the [XML Config Builder](https://www.edu-apps.org/build_xml.html) to build XML. - Navigate to the course that you would like the LTI to be added to. Click Settings in the course navigation bar. Then, select the Apps tab. Near the tabs on the right side, click 'View App Configurations'. It should lead to a page that lists what LTIs are inside the course. Click the button near the tabs that reads '+ App'. @@ -57,4 +56,21 @@ Your running server will be visible at [http://127.0.0.1:5000](http://127.0.0.1: - Account Navigation (account-level navigation) - User Navigation (user profile) -**Note**: If you're using Canvas, your version might be finicky about SSL certificates. Keep HTTP/HTTPS in mind when creating your XML and while developing your project. Some browsers will disable non-SSL LTI content until you enable it through clicking a shield in the browser bar or something similar. \ No newline at end of file +**Note**: If you're using Canvas, your version might be finicky about SSL certificates. Keep HTTP/HTTPS in mind when creating your XML and while developing your project. Some browsers will disable non-SSL LTI content until you enable it through clicking a shield in the browser bar or something similar. + +## Testing + +The LTI Template comes with a pre written test suite. In order to run it make sure your .env key and secret match the tests.py key and secret. + +### Run the test suite + +```bash +docker-compose run lti --rm coverage run -m unittest discover +``` + +### Generate reports + +```bash +docker-compose run lti --rm coverage report +docker-compose run lti --rm coverage html +``` diff --git a/config.py b/config.py index 3b73470..f3a1146 100644 --- a/config.py +++ b/config.py @@ -1,25 +1,59 @@ -import settings - - -class Config(object): - PYLTI_CONFIG = settings.PYLTI_CONFIG +import os class BaseConfig(object): DEBUG = False TESTING = False - PYLTI_CONFIG = settings.PYLTI_CONFIG + + # Using a placeholder for environment variables. + # If not found in the environment, it will default to the local value, which is set in + # the second parameter. Example: + # os.environ.get("ENVIRONMENT_KEY", "LOCAL_VALUE") + # This makes it a bit easier to use one file for local and environment deployment. + + # Declare your consumer key and shared secret. If you end + # up having multiple consumers, you may want to add separate + # key/secret sets for them. + LTI_KEY = os.environ.get("LTI_KEY", "CHANGEME") + LTI_SECRET = os.environ.get("LTI_SECRET", "CHANGEME") + + # Configuration for LTI + PYLTI_CONFIG = { + "consumers": { + LTI_KEY: {"secret": LTI_SECRET} + # Feel free to add more key/secret pairs for other consumers. + }, + "roles": { + # Maps values sent in the lti launch value of "roles" to a group + # Allows you to check LTI.is_role('admin') for your user + "admin": ["Administrator", "urn:lti:instrole:ims/lis/Administrator"], + "student": ["Student", "urn:lti:instrole:ims/lis/Student"], + }, + } + + # Secret key used for Flask sessions, etc. Must stay named 'secret_key'. + # Can be any randomized string, recommend generating one with os.urandom(24) + secret_key = os.environ.get("SECRET_FLASK", "CHANGEME") + SECRET_FLASK = os.environ.get("SECRET_FLASK", "CHANGEME") + # Store application wide settings here + # For example: we could store our app's api keys for canvas + # + # CANVAS_API_URL = '' + # CANVAS_API_KEY = '' + # + # Chrome 80 SameSite=None; Secure fix + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_SAMESITE = "None" class DevelopmentConfig(BaseConfig): DEBUG = True TESTING = True - PYLTI_CONFIG = settings.PYLTI_CONFIG class TestingConfig(BaseConfig): DEBUG = False TESTING = True - PYLTI_CONFIG = settings.PYLTI_CONFIG + # DEFINE ADDITIONAL CONFIGS AS NEEDED diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..52ff158 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.1' + +services: + lti: + build: + context: . + args: + - "REQUIREMENTS=${REQUIREMENTS}" + volumes: + - .:/code + ports: + - "9001:9001" + env_file: + - .env 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/markdown-style.rb b/markdown-style.rb new file mode 100644 index 0000000..defe4ef --- /dev/null +++ b/markdown-style.rb @@ -0,0 +1,9 @@ +all + +exclude_rule 'first-line-h1' +exclude_rule 'line-length' +exclude_rule 'no-duplicate-header' + +rule 'no-trailing-punctuation', :punctuation => '.,;:!' +rule 'ol-prefix', :style => 'ordered' +rule 'ul-indent', :indent => 4 diff --git a/requirements.txt b/requirements.txt index 0d38200..70316e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,19 @@ -Flask==1.0.2 -Flask-SQLAlchemy==2.3.2 -oauth==1.0.1 +Flask==2.0.1 -e git+https://github.com/ucfcdl/pylti.git@roles#egg=PyLTI +gunicorn==20.1.0 +Werkzeug==2.0.2 +itsdangerous==2.0.1 +Jinja2==3.0.2 +MarkupSafe==2.0.1 + +# Testing +black +blinker +coverage +flake8 +Flask-Testing>=0.8.0 +mock +oauthlib +requests-mock + + diff --git a/settings.py.template b/settings.py.template deleted file mode 100644 index 535d46c..0000000 --- a/settings.py.template +++ /dev/null @@ -1,52 +0,0 @@ -# COPY THIS FILE TO settings.py -import os - -# Using a placeholder for environment variables. -# If not found in the environment, it will default to the local value, which is set in -# the second parameter. Example: -# os.environ.get("ENVIRONMENT_KEY", "LOCAL_VALUE") -# This makes it a bit easier to use one file for local and environment deployment. - -# Declare your consumer key and shared secret. If you end -# up having multiple consumers, you may want to add separate -# key/secret sets for them. -CONSUMER_KEY = os.environ.get("CONSUMER_KEY", "CHANGEME") -SHARED_SECRET = os.environ.get("SHARED_SECRET", "CHANGEME") - -# Configuration for LTI -PYLTI_CONFIG = { - 'consumers': { - CONSUMER_KEY: { - "secret": SHARED_SECRET - } - # Feel free to add more key/secret pairs for other consumers. - }, - 'roles': { - # Maps values sent in the lti launch value of "roles" to a group - # Allows you to check LTI.is_role('admin') for your user - 'admin': ['Administrator', 'urn:lti:instrole:ims/lis/Administrator'], - 'student': ['Student', 'urn:lti:instrole:ims/lis/Student'] - } -} - -# Secret key used for Flask sessions, etc. Must stay named 'secret_key'. -# Can be any randomized string, recommend generating one with os.urandom(24) -secret_key = os.environ.get("SECRET_FLASK", "CHANGEME") - -# Application Logging -LOG_FILE = 'error.log' -LOG_FORMAT = '%(asctime)s [%(levelname)s] {%(filename)s:%(lineno)d} %(message)s' -LOG_LEVEL = 'INFO' -LOG_MAX_BYTES = 1024 * 1024 * 5 # 5 MB -LOG_BACKUP_COUNT = 1 - -# Config object settings -# See config.py other environments and options -configClass = 'config.DevelopmentConfig' - -# Store application wide settings here -# For example: we could store our app's api keys for canvas -# -# CANVAS_API_URL = '' -# CANVAS_API_KEY = '' -# diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..3a166fe --- /dev/null +++ b/setup.cfg @@ -0,0 +1,19 @@ +[flake8] +exclude= + venv*/* + env/* + env2/* + env3/* + src/* +max_line_length=99 +ignore = W503, E203 + +[coverage:run] +source = . +omit= + venv*/* + env/* + env2/* + env3/* + src/* + gunicorn_conf.py diff --git a/templates/index.html b/templates/index.html index 5f970a5..3dcdca2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,7 +7,7 @@

Configuration XML

View the LTI configuration xml here

-

This configuration is used to install your LTI tool into an LSM. This xml can be referenced by url or copied and pasted durring the install process.

+

This configuration is used to install your LTI tool into an LMS. This xml can be referenced by url or copied and pasted durring the install process.

LTI Launch

@@ -20,8 +20,8 @@

Testing LTI Launch

  1. Visit the IMS LTI Tool Consumer Emulator
  2. Set Launch URL to "{{ url_for('launch', _external=True) }}"
  3. -
  4. Set Consmer key to the value defined in "settings.py"
  5. -
  6. Set Shared secret to the value defined in "settings.py"
  7. +
  8. Set Consmer key to the value defined in your ".env"
  9. +
  10. Set Shared secret to the value defined in your ".env"
{% endblock content %} diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..de37c9d --- /dev/null +++ b/tests.py @@ -0,0 +1,129 @@ +import logging +from urllib.parse import urlencode + +import oauthlib.oauth1 + +from flask import url_for +import flask_testing +import requests_mock +from pylti.common import LTI_SESSION_KEY + +import views + + +@requests_mock.Mocker() +class LTITests(flask_testing.TestCase): + def create_app(self): + app = views.app + app.config["PRESERVE_CONTEXT_ON_EXCEPTION"] = False + app.config["SECRET_KEY"] = "S3cr3tK3y" + app.config["SESSION_COOKIE_DOMAIN"] = None + + return app + + @classmethod + def setUpClass(cls): + logging.disable(logging.CRITICAL) + app = views.app + app.config["BASE_URL"] = "https://example.edu/" + app.config["GOOGLE_ANALYTICS"] = "123abc" + + def setUp(self): + with self.app.test_request_context(): + pass + + @classmethod + def tearDownClass(cls): + logging.disable(logging.NOTSET) + + def tearDown(self): + pass + + @staticmethod + def generate_launch_request( + url, + body=None, + http_method="GET", + base_url="http://localhost", + roles="Instructor", + headers=None, + ): + params = {} + + if roles is not None: + params["roles"] = roles + + urlparams = urlencode(params) + + client = oauthlib.oauth1.Client( + "CHANGEME", + client_secret="CHANGEME", + signature_method=oauthlib.oauth1.SIGNATURE_HMAC, + signature_type=oauthlib.oauth1.SIGNATURE_TYPE_QUERY, + ) + signature = client.sign( + "{}{}?{}".format(base_url, url, urlparams), + body=body, + http_method=http_method, + headers=headers, + ) + signed_url = signature[0] + new_url = signed_url[len(base_url) :] + return new_url + + # index + def test_index(self, m): + response = self.client.get(url_for("index")) + + self.assert_200(response) + self.assert_template_used("index.html") + + self.assertIn(b"LTI Python/Flask Template", response.data) + + # xml + def test_xml(self, m): + response = self.client.get(url_for("xml")) + + self.assert_200(response) + self.assert_template_used("lti.xml") + self.assertEqual(response.mimetype, "application/xml") + + # launch + def test_launch(self, m): + with self.client.session_transaction() as sess: + sess[LTI_SESSION_KEY] = True + sess["oauth_consumer_key"] = "key" + sess["roles"] = "Instructor" + sess["canvas_user_id"] = 1 + sess["user_id"] = 1 + + payload = { + "launch_presentation_return_url": "http://localhost/", + "custom_canvas_user_id": "1", + "custom_canvas_course_id": "1", + "user_id": "1", + "lis_person_name_full": "Tester", + } + + signed_url = self.generate_launch_request( + "/launch", + http_method="POST", + body=payload, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + response = self.client.post( + signed_url, + data=payload, + ) + + self.assertIn(b"Tester", response.data) + self.assert_200(response) + self.assert_template_used("launch.html") + + # error launch + def test_error(self, m): + response = self.client.get(url_for("launch")) + + self.assert_200(response) + self.assert_template_used("error.html") diff --git a/views.py b/views.py index 639950f..f6d1896 100644 --- a/views.py +++ b/views.py @@ -1,43 +1,46 @@ +import os +import sys from flask import Flask, render_template, session, request, Response from pylti.flask import lti -import settings import logging import json -from logging.handlers import RotatingFileHandler +from logging import Formatter, INFO app = Flask(__name__) -app.secret_key = settings.secret_key -app.config.from_object(settings.configClass) +app.config.from_object(os.environ.get("CONFIG", "config.DevelopmentConfig")) +app.secret_key = app.config["SECRET_FLASK"] # ============================================ # Logging # ============================================ -formatter = logging.Formatter(settings.LOG_FORMAT) -handler = RotatingFileHandler( - settings.LOG_FILE, - maxBytes=settings.LOG_MAX_BYTES, - backupCount=settings.LOG_BACKUP_COUNT +handler = logging.StreamHandler(sys.stdout) +handler.setLevel(INFO) +handler.setFormatter( + Formatter( + "%(asctime)s %(levelname)s: %(message)s " + "[in %(pathname)s: %(lineno)d of %(funcName)s]" + ) ) -handler.setLevel(logging.getLevelName(settings.LOG_LEVEL)) -handler.setFormatter(formatter) app.logger.addHandler(handler) - # ============================================ # Utility Functions # ============================================ + def return_error(msg): - return render_template('error.html', msg=msg) + return render_template("error.html", msg=msg) def error(exception=None): app.logger.error("PyLTI error: {}".format(exception)) - return return_error('''Authentication error, + return return_error( + """Authentication error, please refresh and try again. If this error persists, - please contact support.''') + please contact support.""" + ) # ============================================ @@ -45,8 +48,8 @@ def error(exception=None): # ============================================ # LTI Launch -@app.route('/launch', methods=['POST', 'GET']) -@lti(error=error, request='initial', role='any', app=app) +@app.route("/launch", methods=["POST", "GET"]) +@lti(error=error, request="initial", role="any", app=app) def launch(lti=lti): """ Returns the launch page @@ -55,32 +58,27 @@ def launch(lti=lti): # example of getting lti data from the request # let's just store it in our session - session['lis_person_name_full'] = request.form.get('lis_person_name_full') + session["lis_person_name_full"] = request.form.get("lis_person_name_full") # Write the lti params to the console app.logger.info(json.dumps(request.form, indent=2)) - return render_template('launch.html', lis_person_name_full=session['lis_person_name_full']) + return render_template( + "launch.html", lis_person_name_full=session["lis_person_name_full"] + ) # Home page -@app.route('/', methods=['GET']) +@app.route("/", methods=["GET"]) def index(lti=lti): - return render_template('index.html') + return render_template("index.html") # LTI XML Configuration -@app.route("/xml/", methods=['GET']) +@app.route("/xml/", methods=["GET"]) def xml(): """ Returns the lti.xml file for the app. XML can be built at https://www.eduappcenter.com/ """ - try: - return Response(render_template( - 'lti.xml'), mimetype='application/xml' - ) - except: - app.logger.error("Error with XML.") - return return_error('''Error with XML. Please refresh and try again. If this error persists, - please contact support.''') + return Response(render_template("lti.xml"), mimetype="application/xml")