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
+
+
+
+
+
+
+
+
+
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"]