Ici, je remplis 4 liens depuis un model Task dans Django Admin qui sont :
Lorsque je clique sur "Enregistrer" dans Django Admin, voici ce qui se passe concrètement :
Cet article détaille ces concepts basés sur Python :
Je vous explique également comment les utiliser avec Docker.
Si vous êtes plus pratique que théorique, vous pouvez aller directement tester la webapp et explorer le code sur le lien du répertoire Github django-selenium-docker.
Django Admin est une webapp qui permet de créer rapidement des sites d'administration. Cet outil utilise les modèles Django et les modèles Admin Django.
Pour faire tourner Django avec Docker, j'utilise :
Cette combinaison est nécessaire pour la suite car j'utilise Django-channels.
Voici le code de mon Dockerfile :
FROM python:3.9.2-slim-buster
RUN useradd app
EXPOSE 8000
ENV PYTHONUNBUFFERED=1 \
PORT=8000
WORKDIR /app
COPY --chown=app:app . .
RUN pip install "daphne==3.0.2"
RUN pip install -r requirements.txt
USER app
CMD set -xe; daphne -b 0.0.0.0 -p 8000 app.app.asgi:application
Voici le point d'entré de notre app Django avec ASGI :
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings.production')
django_asgi_app = get_asgi_application()
application = ProtocolTypeRouter({
"http": django_asgi_app,
})
Je connecte ensuite une base de données Postgresql dans les réglages de Django comme ceci :
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'app',
'USER': 'app_user',
'PASSWORD': 'changeme',
'HOST': 'localhost',
'PORT': '5432',
}
}
Je peux maintenant lancer mes services avec docker compose comme ceci :
version: "3.3"
services:
app_db:
container_name: app_db
image: postgres:13.1
environment:
- POSTGRES_USER=app_user
- POSTGRES_PASSWORD=changeme
- POSTGRES_DB=app_db
volumes:
- app_db:/var/lib/postgresql/13.1/main
ports:
- "5432:5432"
networks:
- app_network
restart: on-failure
app:
container_name: app
build: ./
depends_on:
- app_db
ports:
- "8001:8000"
image: app
networks:
- app_network
restart: on-failure
networks:
app_network:
volumes:
app_db:
Pour le gestion des fichiers statiques de Django Admin, j'utilise un CloufFront + S3 ou j'ai déjà collecté les fichiers.
Ceci me permet d'éviter de compliquer la configuration dans mon docker compose pour la suite grâce à cette simple ligne de code dans les réglages de Django :
STATIC_URL = 'https://static.snoweb.fr/'
Je n'utilise pas de fichiers media sur ce projet.
Si vous souhaitez en utilisez, une bonne solution consiste à utiliser le paquet django-storages avec comme réglage :
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
Cette technique permet également de simplifier le docker compose.
Selenium, c'est des outils et des bibliothèques permettant l'automatisation des navigateurs Web.
Il existe de nombreuses façons pour faire tourner Selenium avec Docker. Pour utiliser les images officielles, rendez-vous sur docker-selenium.
Ici, je réutilise mon Dockerfile précédent en y ajoutant les dépendances et l'installation de Chrome :
FROM python:3.9.2-slim-buster
RUN useradd app
EXPOSE 8000
ENV PYTHONUNBUFFERED=1 \
PORT=8000
WORKDIR /app
COPY --chown=app:app . .
RUN apt-get update --yes --quiet
# Installe les dépendances utilisés par Chrome et Selenium
RUN apt-get install --yes --quiet --no-install-recommends \
gettext \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libatspi2.0-0 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libgdk-pixbuf2.0-0 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libx11-6 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxshmfence1 \
wget \
xdg-utils \
netcat \
xvfb \
&& rm -rf /var/lib/apt/lists/*
# Installe Chrome
RUN dpkg -i ./bin/google-chrome.deb
RUN pip install "daphne==3.0.2"
RUN pip install -r requirements.txt
USER app
CMD set -xe; daphne -b 0.0.0.0 -p 8000 app.app.asgi:application
Pour que le Dockerfile fonctionne, vous avez besoin de télécharger manuellement le paquet google-chrome.deb et de le placer correctement dans votre projet.
Pour lancer Chrome avec Python, j'utilise une classe qui hérite du driver Chrome, voici un aperçu :
import os
from django.conf import settings
from selenium import webdriver
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36"
WINDOW_SIZE = "1200x1000"
class Browser(webdriver.Chrome):
timeout = 15
def __init__(self):
# Ajoute le dossier des exécutable dans PATH pour que chromedriver soit disponible
path_bin = str(settings.BASE_DIR / 'bin')
if path_bin not in os.environ["PATH"]:
os.environ["PATH"] += os.pathsep + path_bin
# Personnalise les options de Chrome
chrome_options = webdriver.ChromeOptions()
# Permet de lancer Chrome sans fenêtre
chrome_options.add_argument('--headless')
# Google recommande cette option
chrome_options.add_argument("--no-sandbox")
# Fix des problèmes avec Docker sur la mémoire
chrome_options.add_argument("--disable-dev-shm-usage")
# Permet de personnaliser le USER_AGENT
chrome_options.add_argument(f"user-agent={USER_AGENT}")
# Permet de modifier la taille de la fenêtre
chrome_options.add_argument(f"window-size={WINDOW_SIZE}")
super().__init__(chrome_options=chrome_options)
Voici comment utiliser cette classe pour dire par exemple à Chrome d'aller sur le site de Linkedin :
browser = Browser()
browser.get("https://www.linkedin.com/")
Retrouvez toutes les commandes possibles sur la documentation de Selenium Python.
Celery est une librairie Python qui permet de lancer des longues tâches grâce à un "travailleur". Voici des exemples d'utilisations :
Le travailleur a pour rôle de prendre la charge de travail de la webapp et permet d'éviter de faire attendre l'utilisateur.
Celery a besoin d'un broker pour transporter les messages entre les différents services.
Ici, nous utilisons RabbitMQ pour le broker.
Il est également possible d'utiliser Redis. Cependant, vous allez être limité avec certaines fonctionnalités de Celery.
Voici la configuration que j'utilise dans les réglages de Django :
# RabbitMQ
APP_BROKER_URL = 'pyamqp://'
# Permet de choisir ou stoker le résultat d'une tâche. Ici, j'utilise notre base de donnée Postgresql
APP_RESULT_BACKEND = 'django-db'
# Permet de tracker le début d'une tâche
APP_TASK_TRACK_STARTED = True
# Permet d'ajouter une durée limite d'éxecution
APP_TASK_TIME_LIMIT = 12 * 60 * 60
# Nombre de tâche en simultané
APP_WORKER_CONCURRENCY = 1
Pour lancer Celery, je définis une app Celery dans une app Django comme ceci :
import os
from django.conf import settings
from celery import Celery
# Permet d'ajouter une variable d'environnement dans le travailleur, ici ce sont les settings de Django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.production")
app = Celery('app.core')
# Ajoute préfix APP qu'on a définit précedemment dans les settings de Django
app.config_from_object(settings, namespace='APP')
# Vide la cache de Celery
app.control.purge()
# Recherche les tâches des apps
app.autodiscover_tasks()
J'utilise ensuite cette app pour pouvoir définir une tâche.
Voici un exemple de tâche simplifiée avec un résultat "OK" qui se relance 3 fois si une Exception est levé avec un intervalle qui augmente entre les Exceptions :
from app.core.tasks_app import app
@app.task(bind=True, max_retries=3)
def task_example(self):
try:
# Celery termine sa tâche ici avec un résultat "OK"
return "OK"
except Exception as exc:
# Une exception est levé, Celery relance la tâche avec un intervalle définit
self.retry(exc=exc, countdown=5 * self.request.retries)
Voici le code pour lancer cette tâche depuis n'importe ou :
from app.core.tasks import task_example
task_example.delay()
Celery fournis une fonction "start_worker" pour pouvoir tester facilement une tâche.
Voici comment lancer la tâche précédemment définis dans un test avec Django :
import json
from celery.contrib.testing.worker import start_worker
from django.test import SimpleTestCase
from app.core.models import Task
from app.core.tasks import app, task_example
class WorkerTest(SimpleTestCase):
celery_worker = None
databases = '__all__'
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.celery_worker = start_worker(app, perform_ping_check=False)
cls.celery_worker.__enter__()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
cls.celery_worker.__exit__(None, None, None)
@classmethod
def test_run_script(cls):
task_example.delay()
Ici, je réutilise le docker compose précédent et j'ajoute :
version: "3.3"
services:
app_rabbitmq:
container_name: app_rabbitmq
hostname: rabbitmq
image: rabbitmq:latest
ports:
- "5672:5672"
networks:
- app_network
restart: on-failure
environment:
- RABBITMQ_DEFAULT_USER=app_user
- RABBITMQ_DEFAULT_PASS=changeme
app_db:
container_name: app_db
image: postgres:13.1
environment:
- POSTGRES_USER=app_user
- POSTGRES_PASSWORD=changeme
- POSTGRES_DB=app_db
volumes:
- app_db:/var/lib/postgresql/13.1/main
ports:
- "5432:5432"
networks:
- app_network
restart: on-failure
app_worker_core:
command: sh -c "celery -A app.core worker -l info"
container_name: app_worker_core
depends_on:
- app
- app_db
- app_rabbitmq
hostname: app_worker_core
image: app
networks:
- app_network
restart: on-failure
app:
container_name: app
build: ./
depends_on:
- app_db
- app_rabbitmq
ports:
- "8001:8000"
image: app
networks:
- app_network
restart: on-failure
networks:
app_network:
volumes:
app_db:
Django Channels permet d'utiliser simplement les WebSockets avec Django. Il utilise l'ASGI que j'ai configuré précédemment.
Voici des exemples d'utilisations :
Django Channels a besoin d'un channel layers pour faire communiquer les services.
Ici, j'utilise Redis. Voici comment je le définis dans les réglages de Django avec le paquet channels_redis :
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('127.0.0.1', 6379)],
},
},
}
Django Channels utilise des classes consumers. Il s'agit de l'interface de la WebSocket. Par exemple, les actions :
Voici un exemple de consumer qui :
from channels.generic.websocket import WebsocketConsumer
import json
from asgiref.sync import async_to_sync
class TaskConsumer(WebsocketConsumer):
task_key_composer = None
def connect(self):
self.task_key_composer = self.scope['url_route']['kwargs']['key_composer']
async_to_sync(self.channel_layer.group_add)(
self.task_key_composer,
self.channel_name
)
self.accept()
def disconnect(self, close_code):
async_to_sync(self.channel_layer.group_discard)(
self.task_key_composer,
self.channel_name
)
def sync_function(self, event):
self.send(text_data=json.dumps({
'screenshot_b64': event['screenshot_b64']
}))
Je définis ensuite ce consumer sur une route pour qu'il soit accessible par le client comme ceci :
from django.urls import path
from app.core.consumers import TaskConsumer
websocket_urlpatterns = [
path('ws/task/<key_composer>/', TaskConsumer.as_asgi()),
]
J'ajoute ensuite cette route dans l'app ASGI précédemment définis :
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings.production')
django_asgi_app = get_asgi_application()
from channels.auth import AuthMiddlewareStack
from app.core.urls import websocket_urlpatterns
application = ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": AuthMiddlewareStack(
URLRouter(
websocket_urlpatterns
)
),
})
Pour écrire sur la WebSocket depuis Python, voici le code à utiliser :
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
'my_key_composer',
{
'type': 'sync_function',
'screenshot_b64': browser.get_screenshot_as_base64()
}
)
Voici l'exemple de la webapp django-selenium-docker côté client (front-end) :
<script>
const screenshot = document.querySelector('#screenshoot-my_key_composer');
const wsProtocol = location.protocol !== 'https:' ? 'ws' : 'wss'
const socket = new WebSocket(
wsProtocol + '://'
+ window.location.host
+ '/ws/task/my_key_composer/'
);
socket.onmessage = function (e) {
const data = JSON.parse(e.data);
screenshot.src = 'data:image/png;base64, ' + data.screenshot_b64;
};
socket.onclose = function (e) {
console.error('Chat socket closed unexpectedly');
};
</script>
Je met ensuite mon docker compose à jour avec le channel layer Redis comme ceci :
version: "3.3"
services:
app_rabbitmq:
container_name: app_rabbitmq
hostname: rabbitmq
image: rabbitmq:latest
ports:
- "5672:5672"
networks:
- app_network
restart: on-failure
environment:
- RABBITMQ_DEFAULT_USER=app_user
- RABBITMQ_DEFAULT_PASS=changeme
app_redis:
container_name: app_redis
networks:
- app_network
image: redis:latest
command: redis-server --requirepass changeme
ports:
- "6379:6379"
restart: on-failure
app_db:
container_name: app_db
image: postgres:13.1
environment:
- POSTGRES_USER=app_user
- POSTGRES_PASSWORD=changeme
- POSTGRES_DB=app_db
volumes:
- app_db:/var/lib/postgresql/13.1/main
ports:
- "5432:5432"
networks:
- app_network
restart: on-failure
app_worker_core:
command: sh -c "celery -A app.core worker -l info"
container_name: app_worker_core
depends_on:
- app
- app_db
- app_rabbitmq
- app_redis
hostname: app_worker_core
image: app
networks:
- app_network
restart: on-failure
app:
container_name: app
build: ./
depends_on:
- app_db
- app_rabbitmq
- app_redis
ports:
- "8001:8000"
image: app
networks:
- app_network
restart: on-failure
networks:
app_network:
volumes:
app_db:
Si vous utilisez les WebSockets avec l'app en HTTPS, vous devez forcément utiliser le protocle WSS (web socket secure).
Voici un exemple de configuration avec :
upstream app {
server 127.0.0.1:8001;
}
server {
server_name example.com;
listen 443 ssl http2;
client_max_body_size 50m;
location / {
include proxy_params;
proxy_pass http://app;
}
location /ws/ {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
if ($host = example.com) {
return 301 https://$host$request_uri;
}
server_name example.com;
listen 80;
return 404;
}
Nous avons vu dans cet article de nombreux concepts à utiliser dans une application web avec Python :
Retrouvez le code de cette webapp exemple sur le lien django-selenium-docker.