diff --git a/.circleci/config.yml b/.circleci/config.yml index 0020a495..f85c788d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -137,7 +137,7 @@ jobs: - checkout - restore_cache: keys: - - v1-dependencies-<< parameters.python-image >>-{{ .Revision }} + - v1-backend-dependencies-<< parameters.python-image >>-{{ .Revision }} - run: name: Install development dependencies command: | @@ -149,7 +149,7 @@ jobs: - save_cache: paths: - ~/.local - key: v1-dependencies-<< parameters.python-image >>-{{ .Revision }} + key: v1-backend-dependencies-<< parameters.python-image >>-{{ .Revision }} lint-backend: docker: @@ -162,7 +162,7 @@ jobs: - checkout - restore_cache: keys: - - v1-dependencies-python:3.11-{{ .Revision }} + - v1-backend-dependencies-python:3.11-{{ .Revision }} - run: name: Lint code with black command: ~/.local/bin/black --check --config core/pyproject.toml core plugins @@ -189,12 +189,104 @@ jobs: - checkout - restore_cache: keys: - - v1-dependencies-<< parameters.python-image >>-{{ .Revision }} + - v1-backend-dependencies-<< parameters.python-image >>-{{ .Revision }} - run: name: Run tests command: ~/.local/bin/pytest -c core/pyproject.toml working_directory: src/backend + # ---- App jobs ---- + # Build app development environment + + build-app: + parameters: + python-image: + type: string + docker: + - image: cimg/<< parameters.python-image >> + auth: + username: $DOCKER_HUB_USER + password: $DOCKER_HUB_PASSWORD + working_directory: ~/fun + steps: + - checkout + - restore_cache: + keys: + - v1-app-dependencies-<< parameters.python-image >>-{{ .Revision }} + - run: + name: Install development dependencies + command: pip install --user .[dev]; + working_directory: src/app + - save_cache: + paths: + - ~/.local + key: v1-app-dependencies-<< parameters.python-image >>-{{ .Revision }} + + lint-app: + docker: + - image: cimg/python:3.11 + auth: + username: $DOCKER_HUB_USER + password: $DOCKER_HUB_PASSWORD + working_directory: ~/fun + steps: + - checkout + - restore_cache: + keys: + - v1-app-dependencies-python:3.11-{{ .Revision }} + - run: + name: Lint code with black + command: ~/.local/bin/black --check --config ./pyproject.toml apps warren manage.py + working_directory: src/app + - run: + name: Lint code with ruff + command: ~/.local/bin/ruff --config ./pyproject.toml apps warren manage.py + working_directory: src/app + + test-app: + parameters: + python-image: + type: string + docker: + - image: cimg/<< parameters.python-image >> + auth: + username: $DOCKER_HUB_USER + password: $DOCKER_HUB_PASSWORD + environment: + DJANGO_SETTINGS_MODULE: warren.settings + DJANGO_CONFIGURATION: Test + DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly + DB_NAME: lti + DB_USER: fun + DB_PASSWORD: pass + DB_PORT: 5432 + POSTGRES_HOST: localhost + POSTGRES_DB: lti + POSTGRES_USER: fun + POSTGRES_PASSWORD: pass + - image: cimg/postgres:12.14 + auth: + username: $DOCKER_HUB_USER + password: $DOCKER_HUB_PASS + environment: + POSTGRES_DB: lti + POSTGRES_USER: fun + POSTGRES_PASSWORD: pass + working_directory: ~/fun + steps: + - checkout + - restore_cache: + keys: + - v1-app-dependencies-<< parameters.python-image >>-{{ .Revision }} + - run: + name: Run tests + command: | + dockerize \ + -wait tcp://localhost:5432 \ + -timeout 60s \ + ~/.local/bin/pytest -c ./pyproject.toml + working_directory: src/app + # ---- Frontend jobs ---- lint-frontend: docker: @@ -248,7 +340,7 @@ jobs: - checkout - restore_cache: keys: - - v1-dependencies-python:3.11-{{ .Revision }} + - v1-backend-dependencies-python:3.11-{{ .Revision }} - attach_workspace: at: ~/fun - run: @@ -367,6 +459,33 @@ workflows: tags: only: /.*/ + # App jobs + # + # Build, lint and test production and development Docker images + # (debian-based) + - build-app: + matrix: + parameters: + python-image: [python:3.8, python:3.9, python:3.10, python:3.11] + filters: + tags: + only: /.*/ + - lint-app: + requires: + - build-app + filters: + tags: + only: /.*/ + - test-app: + matrix: + parameters: + python-image: [python:3.8, python:3.9, python:3.10, python:3.11] + requires: + - build-app + filters: + tags: + only: /.*/ + # Frontend jobs # # Build, lint and test frontend sources diff --git a/.env.dist b/.env.dist index 4a4a0bfd..7049eade 100644 --- a/.env.dist +++ b/.env.dist @@ -1,10 +1,29 @@ -# Define development environment variables here. +# Warren API WARREN_BACKEND_SERVER_PORT=8100 WARREN_LRS_HOSTS=http://ralph:8200 WARREN_LRS_AUTH_BASIC_USERNAME=ralph WARREN_LRS_AUTH_BASIC_PASSWORD=secret +# Warren App +DJANGO_SETTINGS_MODULE=warren.settings +DJANGO_CONFIGURATION=Development +DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly +DB_HOST=postgresql +DB_NAME=lti +DB_USER=fun +DB_PASSWORD=pass +DB_PORT=5432 + +# Python +PYTHONPATH=/app + +# Postgresql configuration +POSTGRES_DB=lti +POSTGRES_USER=fun +POSTGRES_PASSWORD=pass + +# Ralph LRS +RALPH_AUTH_FILE=/app/.ralph/auth.json RALPH_BACKENDS__DATABASE__ES__HOSTS=http://elasticsearch:9200 RALPH_BACKENDS__DATABASE__ES__INDEX=statements RALPH_RUNSERVER_PORT=8200 -RALPH_AUTH_FILE=/app/.ralph/auth.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 037f36c8..e430d824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,5 +19,6 @@ and this project adheres to - Add the LRS backend - Switch the video view plugin from an elasticsearch to a LRS backend - Remove the elasticsearch backend +- Add the LTI django application [unreleased]: https://github.com/openfun/warren \ No newline at end of file diff --git a/Makefile b/Makefile index 2661cbab..07e0bae7 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,8 @@ COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose COMPOSE_RUN = $(COMPOSE) run --rm --no-deps COMPOSE_RUN_BACKEND = $(COMPOSE_RUN) backend COMPOSE_RUN_FRONTEND = $(COMPOSE_RUN) frontend +COMPOSE_RUN_APP = $(COMPOSE_RUN) app +MANAGE = $(COMPOSE_RUN_APP) python manage.py # -- Potsie POTSIE_RELEASE = 0.6.0 @@ -30,7 +32,15 @@ RALPH_LRS_AUTH_USER_NAME = ralph RALPH_LRS_AUTH_USER_PWD = secret RALPH_LRS_AUTH_USER_SCOPE = ralph_scope +# -- Postgresql +DB_HOST = postgresql +DB_PORT = 5432 + # -- WARREN +WARREN_APP_IMAGE_NAME ?= warren-app +WARREN_APP_IMAGE_TAG ?= development +WARREN_APP_IMAGE_BUILD_TARGET ?= development +WARREN_APP_SERVER_PORT ?= 8090 WARREN_BACKEND_IMAGE_NAME ?= warren-backend WARREN_BACKEND_IMAGE_TAG ?= development WARREN_BACKEND_IMAGE_BUILD_TARGET ?= development @@ -77,15 +87,25 @@ bootstrap: \ bin/patch_statements_date.py \ data/statements.jsonl.gz \ build \ + migrate-app \ fixtures .PHONY: bootstrap build: ## build the app containers build: \ + build-docker-app \ build-docker-backend \ build-docker-frontend .PHONY: build +build-docker-app: ## build the app container +build-docker-app: .env + WARREN_APP_IMAGE_BUILD_TARGET=$(WARREN_APP_IMAGE_BUILD_TARGET) \ + WARREN_APP_IMAGE_NAME=$(WARREN_APP_IMAGE_NAME) \ + WARREN_APP_IMAGE_TAG=$(WARREN_APP_IMAGE_TAG) \ + $(COMPOSE) build app +.PHONY: build-docker-app + build-docker-backend: ## build the backend container build-docker-backend: .env WARREN_BACKEND_IMAGE_BUILD_TARGET=$(WARREN_BACKEND_IMAGE_BUILD_TARGET) \ @@ -120,13 +140,20 @@ logs-frontend: ## display frontend logs (follow mode) .PHONY: logs-frontend logs: ## display frontend/backend logs (follow mode) - @$(COMPOSE) logs -f backend frontend + @$(COMPOSE) logs -f app backend frontend .PHONY: logs run: ## run the whole stack run: run-frontend .PHONY: run +run-app: ## run the app server (development mode) + @$(COMPOSE) up -d app + @echo "Waiting for the app to be up and running..." + @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s + @$(COMPOSE_RUN) dockerize -wait tcp://app:$(WARREN_APP_SERVER_PORT) -timeout 60s +.PHONY: run-app + run-backend: ## run the backend server (development mode) @$(COMPOSE) up -d backend @echo "Waiting for backend to be up and running..." @@ -174,53 +201,111 @@ fixtures: \ .PHONY: fixtures +migrate-app: ## run django migration for the sandbox project. + @echo "Running migrations…" + @$(COMPOSE) up -d postgresql + @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s + @$(MANAGE) migrate +.PHONY: migrate-app + +check-django: ## Run the Django "check" command + @echo "Checking django…" + @$(MANAGE) check +.PHONY: check-django + +make-migrations: ## Generate potential migrations + @echo "Generating potential migrations…" + @$(MANAGE) makemigrations +.PHONY: make-migrations + +check-migrations: ## Check that all needed migrations exist + @echo "Checking migrations…" + @$(MANAGE) makemigrations --check --dry-run +.PHONY: check-migrations + # -- Linters -lint: ## lint backend python sources +lint: ## lint backend, app and frontend sources lint: \ - lint-black \ - lint-ruff \ - lint-frontend + lint-backend \ + lint-app \ + lint-frontend .PHONY: lint ### Backend ### -lint-black: ## lint backend python sources with black - @echo 'lint:black started…' +lint-backend: ## lint backend python sources +lint-backend: \ + lint-backend-black \ + lint-backend-ruff +.PHONY: lint-backend + +lint-backend-black: ## lint backend python sources with black + @echo 'lint-backend:black started…' @$(COMPOSE_RUN_BACKEND) black --config core/pyproject.toml core plugins -.PHONY: lint-black +.PHONY: lint-backend-black -lint-ruff: ## lint backend python sources with ruff - @echo 'lint:ruff started…' +lint-backend-ruff: ## lint backend python sources with ruff + @echo 'lint-backend:ruff started…' @$(COMPOSE_RUN_BACKEND) ruff --config core/pyproject.toml core plugins -.PHONY: lint-ruff +.PHONY: lint-backend-ruff -lint-ruff-fix: ## lint and fix backend python sources with ruff - @echo 'lint:ruff-fix started…' +lint-backend-ruff-fix: ## lint and fix backend python sources with ruff + @echo 'lint-backend:ruff-fix started…' @$(COMPOSE_RUN_BACKEND) ruff --config core/pyproject.toml core plugins --fix -.PHONY: lint-ruff-fix +.PHONY: lint-backend-ruff-fix + +### App ### + +lint-app: ## lint app python sources +lint-app: \ + lint-app-black \ + lint-app-ruff +.PHONY: lint-app + +lint-app-black: ## lint app python sources with black + @echo 'lint-app:black started…' + @$(COMPOSE_RUN_APP) black --config ./pyproject.toml apps warren manage.py +.PHONY: lint-app-black + +lint-app-ruff: ## lint app python sources with ruff + @echo 'lint-app:ruff started…' + @$(COMPOSE_RUN_APP) ruff --config ./pyproject.toml apps warren manage.py +.PHONY: lint-app-ruff + +lint-app-ruff-fix: ## lint and fix app python sources with ruff + @echo 'lint-app:ruff-fix started…' + @$(COMPOSE_RUN_APP) ruff --config ./pyproject.toml apps warren manage.py --fix +.PHONY: lint-app-ruff-fix ### Frontend ### lint-frontend: ## lint frontend sources - @echo 'lint:frontend started…' + @echo 'lint-frontend:linter started…' @$(COMPOSE_RUN_FRONTEND) yarn lint .PHONY: lint-frontend format-frontend: ## use prettier to format frontend sources - @echo 'format:frontend started…' + @echo 'format-frontend: started…' @$(COMPOSE_RUN_FRONTEND) yarn format .PHONY: format-frontend ## -- Tests test: ## run tests -test: test-backend +test: \ + test-backend \ + test-app .PHONY: test test-backend: ## run backend tests test-backend: run-backend - bin/pytest -.PHONY: test + @$(COMPOSE_RUN_BACKEND) pytest +.PHONY: test-backend + +test-app: ## run app tests +test-app: run-app + @$(COMPOSE_RUN_APP) pytest +.PHONY: test-app # -- Misc help: diff --git a/docker-compose.yml b/docker-compose.yml index 393b90f9..a396fcf6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,31 @@ -version: '3.4' +version: '3.8' services: + postgresql: + image: postgres:12 + env_file: + - .env + + app: + build: + context: src/app + target: "${WARREN_APP_IMAGE_BUILD_TARGET:-development}" + args: + DOCKER_USER: ${DOCKER_USER:-1000} + user: ${DOCKER_USER:-1000} + image: "${WARREN_APP_IMAGE_NAME:-warren-app}:${WARREN_APP_IMAGE_TAG:-development}" + environment: + PYLINTHOME: /app/.pylint.d + env_file: + - .env + ports: + - "${WARREN_APP_SERVER_PORT:-8090}:${WARREN_APP_SERVER_PORT:-8090}" + volumes: + - ./src/app:/app + depends_on: + - "postgresql" + - "backend" + backend: build: context: src/backend diff --git a/src/app/.bandit b/src/app/.bandit new file mode 100644 index 00000000..7438283b --- /dev/null +++ b/src/app/.bandit @@ -0,0 +1,2 @@ +[bandit] +exclude = tests diff --git a/src/app/Dockerfile b/src/app/Dockerfile new file mode 100644 index 00000000..189318c1 --- /dev/null +++ b/src/app/Dockerfile @@ -0,0 +1,65 @@ +# -- Base image -- +FROM python:3.11-slim as base + +ENV PYTHONUNBUFFERED=1 + +# Upgrade pip to its latest release to speed up dependencies installation +RUN pip install --upgrade pip + +# ---- Back-end builder image ---- +FROM base as builder + +WORKDIR /builder + +# Copy required python dependencies +COPY . /builder/ + +RUN mkdir /install && \ + pip install --prefix=/install . + +# ---- Core application image ---- +FROM base as core + +# Install gettext +RUN apt-get update && \ + apt-get install -y \ + gettext && \ + rm -rf /var/lib/apt/lists/* + +# Copy installed python dependencies +COPY --from=builder /install /usr/local + +# Copy runtime-required files +COPY . /app/ + +# Prepare production run using gunicorn +RUN mkdir -p /usr/local/etc/gunicorn +COPY ./docker/files/usr/local/etc/gunicorn/warren.py /usr/local/etc/gunicorn/warren.py + +WORKDIR /app + +# Un-privileged user running the application +USER ${DOCKER_USER:-1000} + +# ---- Development image ---- +FROM core as development + +# Switch to privileged user to uninstall app +USER root:root + +# Uninstall warren and re-install it in editable mode along with development +# dependencies +RUN pip uninstall -y warren +RUN pip install -e .[dev] + +# Switch back to Un-privileged user to run the application +USER ${DOCKER_USER:-1000} + +# Run django development server +CMD python manage.py runserver 0.0.0.0:${WARREN_APP_SERVER_PORT:-8090} + +# ---- Production image ---- +from core as production + +# The default command runs gunicorn WSGI server +CMD gunicorn -c /usr/local/etc/gunicorn/warren.py warren.wsgi:application diff --git a/src/app/__init__.py b/src/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/app/apps/__init__.py b/src/app/apps/__init__.py new file mode 100644 index 00000000..b309325c --- /dev/null +++ b/src/app/apps/__init__.py @@ -0,0 +1 @@ +"""Warren Apps.""" diff --git a/src/app/apps/dashboards/__init__.py b/src/app/apps/dashboards/__init__.py new file mode 100644 index 00000000..b09bc0bb --- /dev/null +++ b/src/app/apps/dashboards/__init__.py @@ -0,0 +1 @@ +"""Warren dashboard app.""" diff --git a/src/app/apps/dashboards/admin.py b/src/app/apps/dashboards/admin.py new file mode 100644 index 00000000..95e3d465 --- /dev/null +++ b/src/app/apps/dashboards/admin.py @@ -0,0 +1,4 @@ +"""Admin for the dashboard app.""" +from django.contrib import admin # noqa: F401 + +# Register your models here. diff --git a/src/app/apps/dashboards/apps.py b/src/app/apps/dashboards/apps.py new file mode 100644 index 00000000..673ba5e1 --- /dev/null +++ b/src/app/apps/dashboards/apps.py @@ -0,0 +1,9 @@ +"""The dashboard app.""" +from django.apps import AppConfig + + +class DashboardsConfig(AppConfig): + """Configuration for the dashboard app.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "apps.dashboards" diff --git a/src/app/apps/dashboards/migrations/__init__.py b/src/app/apps/dashboards/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/app/apps/dashboards/models.py b/src/app/apps/dashboards/models.py new file mode 100644 index 00000000..34bbca7a --- /dev/null +++ b/src/app/apps/dashboards/models.py @@ -0,0 +1,4 @@ +"""Models for the dashboard app.""" +from django.db import models # noqa: F401 + +# Create your models here. diff --git a/src/app/apps/dashboards/templates/dashboards/base.html b/src/app/apps/dashboards/templates/dashboards/base.html new file mode 100644 index 00000000..f5f609e3 --- /dev/null +++ b/src/app/apps/dashboards/templates/dashboards/base.html @@ -0,0 +1 @@ +Woot. diff --git a/src/app/apps/dashboards/tests.py b/src/app/apps/dashboards/tests.py new file mode 100644 index 00000000..6cf1521d --- /dev/null +++ b/src/app/apps/dashboards/tests.py @@ -0,0 +1,4 @@ +"""Tests for the dashboard app.""" +from django.test import TestCase # noqa: F401 + +# Create your tests here. diff --git a/src/app/apps/dashboards/urls.py b/src/app/apps/dashboards/urls.py new file mode 100644 index 00000000..95cabc20 --- /dev/null +++ b/src/app/apps/dashboards/urls.py @@ -0,0 +1,11 @@ +"""Warren dashboards app URLs configuration.""" + +from django.urls import path + +from .views import DashboardView + +app_name = "dashboards" + +urlpatterns = [ + path("", DashboardView.as_view(), name="dashboard-view"), +] diff --git a/src/app/apps/dashboards/views.py b/src/app/apps/dashboards/views.py new file mode 100644 index 00000000..7bbcccd6 --- /dev/null +++ b/src/app/apps/dashboards/views.py @@ -0,0 +1,8 @@ +"""Views for the dashboard app.""" +from django.views.generic import TemplateView + + +class DashboardView(TemplateView): + """Dummy dashboard view.""" + + template_name = "dashboards/base.html" diff --git a/src/app/apps/development/__init__.py b/src/app/apps/development/__init__.py new file mode 100644 index 00000000..57b83fba --- /dev/null +++ b/src/app/apps/development/__init__.py @@ -0,0 +1 @@ +"""Warren development app.""" diff --git a/src/app/apps/development/admin.py b/src/app/apps/development/admin.py new file mode 100644 index 00000000..7d113997 --- /dev/null +++ b/src/app/apps/development/admin.py @@ -0,0 +1 @@ +"""Admin for the development app.""" diff --git a/src/app/apps/development/apps.py b/src/app/apps/development/apps.py new file mode 100644 index 00000000..06d439ac --- /dev/null +++ b/src/app/apps/development/apps.py @@ -0,0 +1,10 @@ +"""The development app.""" + +from django.apps import AppConfig + + +class DevelopmentConfig(AppConfig): + """Configuration for the development app.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "apps.development" diff --git a/src/app/apps/development/migrations/__init__.py b/src/app/apps/development/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/app/apps/development/models.py b/src/app/apps/development/models.py new file mode 100644 index 00000000..af09495a --- /dev/null +++ b/src/app/apps/development/models.py @@ -0,0 +1 @@ +"""Models for the development app.""" diff --git a/src/app/apps/development/templates/development/lti_development.html b/src/app/apps/development/templates/development/lti_development.html new file mode 100644 index 00000000..9f9edf26 --- /dev/null +++ b/src/app/apps/development/templates/development/lti_development.html @@ -0,0 +1,167 @@ +{% load i18n %} + + + + + + {% trans "Warren LTI Test page" %} + + + +
+
+

LTI Consumer test

+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {% for key, value in oauth_dict.items %} + {% if forloop.first or forloop.counter|divisibleby:"4" %} +
+ {% endif %} +
+ + +
+ {% if forloop.last or forloop.counter|divisibleby:"3" %} +
+ {% endif %} + {% endfor %} + + +
+ +
+ +
+
+ + diff --git a/src/app/apps/development/tests.py b/src/app/apps/development/tests.py new file mode 100644 index 00000000..2435b063 --- /dev/null +++ b/src/app/apps/development/tests.py @@ -0,0 +1,13 @@ +"""Tests for the development app.""" + +from django.test import TestCase, override_settings + + +@override_settings(DEBUG=True) +class DevelopmentLTIViewTest(TestCase): + """Test the development view.""" + + def test_development_route(self): + """The development app has a route that answers.""" + response = self.client.get("/development/") + self.assertEqual(response.status_code, 200) diff --git a/src/app/apps/development/urls.py b/src/app/apps/development/urls.py new file mode 100644 index 00000000..dfb59bfe --- /dev/null +++ b/src/app/apps/development/urls.py @@ -0,0 +1,11 @@ +"""Warren Development app URLs configuration.""" + +from django.urls import path + +from .views import DevelopmentLTIView + +app_name = "development" + +urlpatterns = [ + path("development/", DevelopmentLTIView.as_view(), name="lti-development-view"), +] diff --git a/src/app/apps/development/views.py b/src/app/apps/development/views.py new file mode 100644 index 00000000..5fcc30a5 --- /dev/null +++ b/src/app/apps/development/views.py @@ -0,0 +1,98 @@ +"""Views for the development app.""" + +import uuid +from logging import getLogger +from urllib.parse import unquote, urlparse + +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.views.decorators.clickjacking import xframe_options_exempt +from django.views.decorators.csrf import csrf_exempt +from django.views.generic.base import TemplateView +from lti_toolbox.models import LTIConsumer, LTIPassport +from oauthlib import oauth1 + +logger = getLogger(__name__) + + +@method_decorator(csrf_exempt, name="dispatch") +@method_decorator(xframe_options_exempt, name="dispatch") +class DevelopmentLTIView(TemplateView): + """A development view with iframe POST / plain POST helpers. + + Not available outside of DEBUG = true environments. + """ + + template_name = "development/lti_development.html" + + def get_context_data(self, **kwargs): + """Generate a UUID to pre-populate the `uuid` fields in the LTI request form. + + Parameters + ---------- + kwargs : dictionary + keyword extra arguments + Returns + ------- + dictionary + context for template rendering. + """ + lti_parameters = { + "lti_message_type": "basic-lti-launch-request", + "lti_version": "LTI-1p0", + "resource_link_id": str(uuid.uuid4()), + "lis_person_contact_email_primary": "johndoe@example.com", + "lis_person_sourcedid": "johndoe", + "context_id": "course-v1:openfun+mathematics101+session01", + "context_title": "Mathematics 101", + "roles": "student", + "launch_presentation_locale": "fr", + } + + # use the HTTP_REFERER like to be consistent with the LTI passport + request_url = ( + urlparse(self.request.build_absolute_uri()) + ._replace(path=reverse("lti:lti-request-view")) + .geturl() + ) + try: + lti_consumer = LTIConsumer.objects.get(url=request_url) + except LTIConsumer.DoesNotExist: + lti_consumer, _ = LTIConsumer.objects.get_or_create( + slug="localhost", + title="localhost test", + url=request_url, + ) + + passport, _ = LTIPassport.objects.get_or_create( + consumer=lti_consumer, title="Development passport" + ) + + oauth_client = oauth1.Client( + client_key=passport.oauth_consumer_key, + client_secret=passport.shared_secret, + ) + # Compute Authorization header which looks like: + # Authorization: OAuth oauth_nonce="80966668944732164491378916897", + # oauth_timestamp="1378916897", oauth_version="1.0", + # oauth_signature_method="HMAC-SHA1", oauth_consumer_key="", + # oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D" + _uri, headers, _body = oauth_client.sign( + request_url, + http_method="POST", + body=lti_parameters, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + # Parse headers to pass to template as part of context: + oauth_dict = dict( + param.strip().replace('"', "").split("=") + for param in headers["Authorization"].split(",") + ) + + signature = oauth_dict["oauth_signature"] + oauth_dict["oauth_signature"] = unquote(signature) + oauth_dict["oauth_nonce"] = oauth_dict.pop("OAuth oauth_nonce") + lti_parameters.update({"oauth_dict": oauth_dict}) + + return lti_parameters diff --git a/src/app/apps/lti/__init__.py b/src/app/apps/lti/__init__.py new file mode 100644 index 00000000..5919fd21 --- /dev/null +++ b/src/app/apps/lti/__init__.py @@ -0,0 +1 @@ +"""Warren LTI app.""" diff --git a/src/app/apps/lti/admin.py b/src/app/apps/lti/admin.py new file mode 100644 index 00000000..18968325 --- /dev/null +++ b/src/app/apps/lti/admin.py @@ -0,0 +1 @@ +"""Admin for the LTI app.""" diff --git a/src/app/apps/lti/apps.py b/src/app/apps/lti/apps.py new file mode 100644 index 00000000..24f4d9df --- /dev/null +++ b/src/app/apps/lti/apps.py @@ -0,0 +1,10 @@ +"""The LTI app.""" + +from django.apps import AppConfig + + +class LTIConfig(AppConfig): + """Configuration for the LTI app.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "apps.lti" diff --git a/src/app/apps/lti/forms.py b/src/app/apps/lti/forms.py new file mode 100644 index 00000000..4613857e --- /dev/null +++ b/src/app/apps/lti/forms.py @@ -0,0 +1,37 @@ +"""Warren LTI app forms.""" + +from django import forms +from django.core import signing +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + + +class BaseLTIUserForm(forms.Form): + """Warren LTI user form. + + This form is used to validate user initially authenticated via LTI. + """ + + platform = forms.URLField(required=True) + course = forms.CharField(required=True, max_length=100) + user = forms.CharField(required=True, max_length=100) + email = forms.EmailField(required=True) + + +class SignedLTIUserForm(forms.Form): + """A LTI user form with a valid signature.""" + + signature = forms.CharField(required=True) + + def get_lti_user(self, signature, max_age=3600): + """Get LTI user corresponding to input signature.""" + return signing.loads(signature, max_age=max_age) + + def clean_signature(self): + """Validate input signature.""" + signature = self.cleaned_data.get("signature") + try: + self.get_lti_user(signature) + except signing.BadSignature as exc: + raise ValidationError(_("Request signature is not valid")) from exc + return signature diff --git a/src/app/apps/lti/migrations/__init__.py b/src/app/apps/lti/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/app/apps/lti/models.py b/src/app/apps/lti/models.py new file mode 100644 index 00000000..92034579 --- /dev/null +++ b/src/app/apps/lti/models.py @@ -0,0 +1 @@ +"""Models for the LTI app.""" diff --git a/src/app/apps/lti/tests.py b/src/app/apps/lti/tests.py new file mode 100644 index 00000000..7da176ef --- /dev/null +++ b/src/app/apps/lti/tests.py @@ -0,0 +1 @@ +"""Tests for the LTI app.""" diff --git a/src/app/apps/lti/urls.py b/src/app/apps/lti/urls.py new file mode 100644 index 00000000..4ba51e3e --- /dev/null +++ b/src/app/apps/lti/urls.py @@ -0,0 +1,11 @@ +"""Warren LTI app URLs configuration.""" + +from django.urls import path + +from .views import LTIRequestView + +app_name = "lti" + +urlpatterns = [ + path("", LTIRequestView.as_view(), name="lti-request-view"), +] diff --git a/src/app/apps/lti/views.py b/src/app/apps/lti/views.py new file mode 100644 index 00000000..a6426456 --- /dev/null +++ b/src/app/apps/lti/views.py @@ -0,0 +1,39 @@ +"""Views for the LTI app.""" + +import logging + +from django.core.exceptions import PermissionDenied +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect +from lti_toolbox.exceptions import LTIException +from lti_toolbox.lti import LTI +from lti_toolbox.views import BaseLTIView + +from .forms import BaseLTIUserForm + +logger = logging.getLogger(__name__) + + +class LTIRequestView(BaseLTIView): + """Base view to handle LTI launch request verification.""" + + def _do_on_success(self, lti_request: LTI) -> HttpResponse: + """Redirect to the target view.""" + lti_user = { + "platform": lti_request.get_consumer().url, + "course": lti_request.get_param("context_id"), + "user": lti_request.get_param("lis_person_sourcedid"), + "email": lti_request.get_param("lis_person_contact_email_primary"), + } + + lti_user_form = BaseLTIUserForm(lti_user) + if not lti_user_form.is_valid(): + logger.debug("LTI user is not valid: %s", lti_user_form.errors) + raise PermissionDenied + + return redirect("dashboards:dashboard-view") + + def _do_on_failure(self, request: HttpRequest, error: LTIException) -> HttpResponse: + """Raise an error when the LTI request fails.""" + logger.debug("LTI request failed with error: %s", error) + raise PermissionDenied diff --git a/src/app/docker/files/usr/local/etc/gunicorn/warren.py b/src/app/docker/files/usr/local/etc/gunicorn/warren.py new file mode 100644 index 00000000..01712a25 --- /dev/null +++ b/src/app/docker/files/usr/local/etc/gunicorn/warren.py @@ -0,0 +1,18 @@ +"""Gunicorn configuration file for warren.""" + +# Gunicorn-django settings +bind = ["0.0.0.0:8000"] +name = "warren" +python_path = "/app" + +# Run +graceful_timeout = 90 +timeout = 90 +workers = 3 + +# Logging +# Using '-' for the access log file makes gunicorn log accesses to stdout +accesslog = "-" +# Using '-' for the error log file makes gunicorn log errors to stderr +errorlog = "-" +loglevel = "info" diff --git a/src/app/locale/.keep b/src/app/locale/.keep new file mode 100644 index 00000000..e69de29b diff --git a/src/app/manage.py b/src/app/manage.py new file mode 100755 index 00000000..4bc84f71 --- /dev/null +++ b/src/app/manage.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "warren.settings") + os.environ.setdefault("DJANGO_CONFIGURATION", "Development") + + from configurations.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/src/app/pyproject.toml b/src/app/pyproject.toml new file mode 100644 index 00000000..0e2d7542 --- /dev/null +++ b/src/app/pyproject.toml @@ -0,0 +1,106 @@ +# +# Warren app +# +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "warren" +description = "The visualization platform for your learning analytics" +readme = "README.md" +authors = [ + { name="Open FUN (France Universite Numerique)", email="fun.dev@fun-mooc.fr" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] +requires-python = ">=3.7" +license = {file = "LICENSE.md"} +keywords = ["Analytics", "xAPI", "LRS", "LTI"] +dependencies = [ + "Django==4.2.2", + "django-configurations==2.4.1", + "django-lti-toolbox==1.0.1", + "dockerflow==2022.8.0", + "gunicorn==20.1.0", + "psycopg2-binary==2.9.5", + "sentry-sdk==1.13.0", +] +dynamic = ["version"] + +[project.urls] +"Homepage" = "https://github.com/openfun/warren" +"Bug Tracker" = "https://github.com/openfun/warren/issues" + +[project.optional-dependencies] +dev = [ + "black==23.1.0", + "build==0.10.0", + "factory-boy==3.2.1", + "Faker==17.0.0", + "ipdb==0.13.11", + "ipython==8.10.0", + "pytest==7.2.1", + "pytest-cov==4.0.0", + "pytest-django==4.5.2", + "ruff==0.0.272", +] +ci = [ + "twine==4.0.2", +] + +[tool.setuptools] +packages = ["warren", "apps"] + +[tool.setuptools.dynamic] +version = { attr = "warren.__version__" } + +# Third party packages configuration +[tool.pytest.ini_options] +addopts = "-v --cov-report term-missing --cov=apps" +python_files = [ + "test_*.py", + "tests.py", +] + +[tool.ruff] +select = [ + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "D", # pydocstyle + "E", # pycodestyle error + "F", # Pyflakes + "I", # Isort + "ISC", # flake8-implicit-str-concat + "PLC", # Pylint Convention + "PLE", # Pylint Error + "PLR", # Pylint Refactor + "PLW", # Pylint Warning + "RUF100", # Ruff unused-noqa + "S", # flake8-bandit + "W", # pycodestyle warning +] +exclude = ["apps/*/migrations/*"] + +# Assume Python 3.8. +target-version = "py38" + +[tool.ruff.per-file-ignores] +"*/tests/*" = [ + "S101", + "PLR2004", # Pylint magic-value-comparison +] + +[tool.ruff.pydocstyle] +# Use Google-style docstrings. +convention = "google" diff --git a/src/app/staticfiles/.keep b/src/app/staticfiles/.keep new file mode 100644 index 00000000..e69de29b diff --git a/src/app/version.json b/src/app/version.json new file mode 100644 index 00000000..b228029e --- /dev/null +++ b/src/app/version.json @@ -0,0 +1,6 @@ +{ + "source" : "https://github.com/openfun/warren", + "version": "0.1.0", + "commit" : "fixme", + "build" : "fixme" +} diff --git a/src/app/warren/__init__.py b/src/app/warren/__init__.py new file mode 100644 index 00000000..c3bfa45a --- /dev/null +++ b/src/app/warren/__init__.py @@ -0,0 +1,3 @@ +"""Warren app.""" + +__version__ = "0.1.0" diff --git a/src/app/warren/asgi.py b/src/app/warren/asgi.py new file mode 100644 index 00000000..9153c1fa --- /dev/null +++ b/src/app/warren/asgi.py @@ -0,0 +1,15 @@ +"""ASGI config for Warren project. + +It exposes the ASGI callable as a module-level variable named `application`. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "warren.settings") + +application = get_asgi_application() diff --git a/src/app/warren/settings.py b/src/app/warren/settings.py new file mode 100644 index 00000000..44dc27a0 --- /dev/null +++ b/src/app/warren/settings.py @@ -0,0 +1,328 @@ +"""Django settings for warren project.""" + +import json +from pathlib import Path +from typing import List + +import sentry_sdk +from configurations import Configuration, values +from django.utils.translation import gettext_lazy as _ +from sentry_sdk.integrations.django import DjangoIntegration + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +def get_release(): + """Get the current release of the application. + + By release, we mean the release from the version.json file à la Mozilla [1] + (if any). If this file has not been found, it defaults to "NA". + [1] + https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md. + """ + # Try to get the current release from the version.json file generated by the + # CI during the Docker image build + try: + with (BASE_DIR / Path("version.json")).open(encoding="utf8") as version: + return json.load(version)["version"] + except FileNotFoundError: + return "NA" # Default: not available + + +class Base(Configuration): + """Base configuration every configuration should inherit from. + + This is the base configuration every configuration (aka environnement) + should inherit from. It is recommended to configure third-party + applications by creating a configuration mixins in ./configurations and + compose the Base configuration with those mixins. + + It depends on an environment variable that SHOULD be defined: + + * DJANGO_SECRET_KEY + + You may also want to override default configuration by setting the + following environment variables: + + * DB_NAME + * DB_HOST + * DB_PASSWORD + * DB_USER + """ + + AUTHENTICATION_BACKENDS = [ + "lti_toolbox.backend.LTIBackend", + "django.contrib.auth.backends.ModelBackend", + ] + + DEBUG = False + + # Security + ALLOWED_HOSTS: List[str] = [] + SECRET_KEY = values.Value(None) + + # SECURE_PROXY_SSL_HEADER allows to fix the scheme in Django's HttpRequest + # object when you application is behind a reverse proxy. + # + # Keep this SECURE_PROXY_SSL_HEADER configuration only if : + # - your Django app is behind a proxy. + # - your proxy strips the X-Forwarded-Proto header from all incoming requests + # - Your proxy sets the X-Forwarded-Proto header and sends it to Django + # + # In other cases, you should comment the following line to avoid security issues. + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + + # Disable Samesite flag in session and csrf cookies, because a LTI tool provider app + # is meant to run in an iframe on external websites. + # Note : The better solution is to send a flag Samesite=none, because + # modern browsers are considering Samesite=Lax by default when the flag is + # not specified. + # It will be possible to specify CSRF_COOKIE_SAMESITE="none" in Django 3.1 + CSRF_COOKIE_SAMESITE = None + SESSION_COOKIE_SAMESITE = None + + # Password validation + # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators + + AUTH_PASSWORD_VALIDATORS = [ + {"NAME": name} + for name in [ + "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + "django.contrib.auth.password_validation.MinimumLengthValidator", + "django.contrib.auth.password_validation.CommonPasswordValidator", + "django.contrib.auth.password_validation.NumericPasswordValidator", + ] + ] + + # Privacy + SECURE_REFERRER_POLICY = "same-origin" + + # Application definition + ROOT_URLCONF = "warren.urls" + WSGI_APPLICATION = "warren.wsgi.application" + + # Internationalization + # https://docs.djangoproject.com/en/4.1/topics/i18n/ + TIME_ZONE = "Europe/Paris" + USE_I18N = True + USE_L10N = True + USE_TZ = True + LOCALE_PATHS = [BASE_DIR / Path("locale")] + LANGUAGE_CODE = "fr" + LANGUAGES = [ + ("fr", _("French")), + ("en", _("English")), + ] + + # Static files (CSS, JavaScript, Images) + # https://docs.djangoproject.com/en/4.1/howto/static-files/ + STATIC_URL = "static/" + STATIC_ROOT = values.Value(BASE_DIR / "staticfiles") + + # Default primary key field type + # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + # Database + DATABASES = { + "default": { + "ENGINE": values.Value( + "django.db.backends.postgresql_psycopg2", + environ_name="DB_ENGINE", + environ_prefix=None, + ), + "NAME": values.Value("lti", environ_name="DB_NAME", environ_prefix=None), + "USER": values.Value("fun", environ_name="DB_USER", environ_prefix=None), + "PASSWORD": values.Value( + "pass", environ_name="DB_PASSWORD", environ_prefix=None + ), + "HOST": values.Value( + "localhost", environ_name="DB_HOST", environ_prefix=None + ), + "PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None), + } + } + + # Templates + TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.csrf", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.request", + "django.template.context_processors.tz", + ], + }, + }, + ] + + MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "dockerflow.django.middleware.DockerflowMiddleware", + ] + + # Django applications from the highest priority to the lowest + INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # Runtime + "dockerflow.django", + # LTI Toolbox + "lti_toolbox", + # Utilities + "apps.development", + "apps.lti", + "apps.dashboards", + ] + + # Cache + CACHES = { + "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, + } + + # Sentry + SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN") + + # Warren + # Should be a dict with platform urls as keys and corresponding edx API + # keys as values. + EDX_PLATFORM_API_TOKENS = values.DictValue( + {}, environ_name="EDX_PLATFORM_API_TOKENS", environ_prefix=None + ) + + @classmethod + def post_setup(cls): + """Post setup configuration. + + This is the place where you can configure settings that require other + settings to be loaded. + """ + super().post_setup() + + # The SENTRY_DSN setting should be available to activate sentry for an env. + if cls.SENTRY_DSN is not None: + sentry_sdk.init( + dsn=cls.SENTRY_DSN, + environment=cls.__name__.lower(), + release=get_release(), + integrations=[DjangoIntegration()], + ) + + +class Development(Base): + """Development environment settings. + + We set DEBUG to True and configure the server to respond from all hosts. + """ + + DEBUG = True + ALLOWED_HOSTS = ["*"] + + LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "[%(levelname)s] [%(asctime)s] [%(module)s] " + "%(process)d %(thread)d %(message)s" + } + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", + } + }, + "loggers": { + "oauthlib": {"handlers": ["console"], "level": "DEBUG", "propagate": True}, + "lti_toolbox": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": True, + }, + "django": {"handlers": ["console"], "level": "INFO", "propagate": True}, + "apps.lti": {"handlers": ["console"], "level": "DEBUG", "propagate": True}, + }, + } + + +class Test(Base): + """Test environment settings.""" + + +class ContinuousIntegration(Test): + """Continous Integration environment settings. + + nota bene: it should inherit from the Test environment. + """ + + +class Production(Base): + """Production environment settings. + + You must define the DJANGO_ALLOWED_HOSTS environment variable in Production + configuration (and derived configurations): + DJANGO_ALLOWED_HOSTS="foo.com,foo.fr". + """ + + # Security + ALLOWED_HOSTS = values.ListValue(None) + CSRF_COOKIE_SECURE = True + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + SESSION_COOKIE_SECURE = True + # System check reference: + # https://docs.djangoproject.com/en/2.2/ref/checks/#security + SILENCED_SYSTEM_CHECKS = values.ListValue( + [ + # Allow to disable django.middleware.clickjacking.XFrameOptionsMiddleware + # It is necessary since the LTI tool provider application will be displayed + # in an iframe on external LMS sites. + "security.W002", + # SECURE_SSL_REDIRECT is not defined in the base configuration + "security.W008", + # No value is defined for SECURE_HSTS_SECONDS + "security.W004", + ] + ) + + +class Feature(Production): + """Feature environment settings. + + nota bene: it should inherit from the Production environment. + """ + + +class Staging(Production): + """Staging environment settings. + + nota bene: it should inherit from the Production environment. + """ + + +class PreProduction(Production): + """Pre-production environment settings. + + nota bene: it should inherit from the Production environment. + """ diff --git a/src/app/warren/urls.py b/src/app/warren/urls.py new file mode 100644 index 00000000..2c6f2ece --- /dev/null +++ b/src/app/warren/urls.py @@ -0,0 +1,28 @@ +"""Warren URL Configuration. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.1/topics/http/urls/ + +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.conf import settings +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("lti/", include("apps.lti.urls")), + path("dashboards/", include("apps.dashboards.urls")), +] +if settings.DEBUG: + urlpatterns += [path("", include("apps.development.urls"))] diff --git a/src/app/warren/wsgi.py b/src/app/warren/wsgi.py new file mode 100644 index 00000000..d9167b3b --- /dev/null +++ b/src/app/warren/wsgi.py @@ -0,0 +1,16 @@ +"""WSGI config for Warren project. + +It exposes the WSGI callable as a module-level variable named `application`. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ +""" + +import os + +from configurations.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "warren.settings") +os.environ.setdefault("DJANGO_CONFIGURATION", "Development") + +application = get_wsgi_application() diff --git a/src/backend/core/pyproject.toml b/src/backend/core/pyproject.toml index cca8d638..7800be83 100644 --- a/src/backend/core/pyproject.toml +++ b/src/backend/core/pyproject.toml @@ -6,8 +6,8 @@ requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] -name = "warren" -description = "A visualization platform for your learning analytics" +name = "warren-api" +description = "The visualization platform for your learning analytics (API backend)" readme = "README.md" authors = [ { name="Open FUN (France Universite Numerique)", email="fun.dev@fun-mooc.fr" }, @@ -18,7 +18,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/src/backend/plugins/video/pyproject.toml b/src/backend/plugins/video/pyproject.toml index c12c3835..e898f513 100644 --- a/src/backend/plugins/video/pyproject.toml +++ b/src/backend/plugins/video/pyproject.toml @@ -18,7 +18,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -28,7 +27,7 @@ requires-python = ">=3.7" license = {file = "LICENSE.md"} keywords = ["Analytics", "xAPI", "LRS", "LTI", "Video"] dependencies = [ - "warren", + "warren-api", ] dynamic = ["version"]