Lespass est un moteur de billetterie, d'adhesion et de gestion de budget contributif. C'est une brique de l'ecosysteme TiBillet (avec LaBoutik pour la caisse/cashless et Fedow pour le portefeuille federe). Fabrique par la Cooperative Code Commun, licence AGPLv3.
Stack : Django 4.2, Python 3.11, Django REST Framework, PostgreSQL 13, Redis, Memcached, Celery, Poetry, Docker.
FALC = Facile A Lire et Comprendre. C'est LA regle numero un du projet.
Ce projet est un commun numerique cooperatif. Le code doit etre lisible par des developpeurs non-experts. Concretement :
- Noms de variables explicites et verbeux. La longueur n'est pas un probleme.
- Commentaires bilingues FR/EN qui expliquent le pourquoi, pas le quoi.
- Preferer les boucles
forsimples aux comprehensions complexes. Le verbeux > le malin. - Phrases courtes, mots simples dans les commentaires et les docstrings.
- Pas de sucre syntaxique qui masque la logique. On veut voir ce qui se passe. Eviter les abstractions magiques, les decorateurs complexes, les metaclasses. Le code doit se lire de haut en bas sans devoir aller fouiller dans 5 fichiers.
On utilise viewsets.ViewSet de DRF comme controleur, y compris pour les vues qui rendent du HTML.
Pas de ModelViewSet : c'est trop de magie cachee. On ecrit explicitement list(), retrieve(), create(), etc.
Si besoin de route supplémentaire, on utilise les actions @action().
Les ViewSets retournent soit :
- Des templates Django (pages completes ou partiels HTMX) pour l'UI
- Du JSON uniquement pour l'API v2 (
api_v2/)
Exemple type (module crowds) :
class InitiativeViewSet(viewsets.ViewSet):
authentication_classes = [SessionAuthentication]
permission_classes = [permissions.AllowAny]
def list(self, request):
# ... queryset explicite, pas de get_queryset() magique
return render(request, "crowds/views/list.html", context)
def retrieve(self, request, pk=None):
initiative = get_object_or_404(Initiative, uuid=pk)
return render(request, "crowds/views/detail.html", context)
@action(detail=True, methods=["POST"])
def vote(self, request, pk=None):
# ... retourne un partiel HTMX
return render(request, "crowds/partial/votes_badge.html", context)On n'utilise pas les forms Django. Chaque input est valide par un serializers.Serializer de DRF.
Pas de ModelSerializer sauf dans api_v2/ pour les endpoints JSON semantiques.
- Rendu cote serveur uniquement. Les vues retournent du HTML (pages completes ou partiels). Pas de JSON pour l'UI.
- HTMX pour les interactions dynamiques :
hx-get,hx-post,hx-target,hx-swap,hx-push-url. - Bootstrap 5 pour le style et la grille.
- Minimal JavaScript. JS seulement pour les toasts SweetAlert2 et les petites interactions.
- Anti-blink : navigation liste/detail via
hx-target="body"+hx-swap="innerHTML"pour ne pas recharger le<head>. - Toujours conserver
href/actionpour le repli sans JS. - CSRF :
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'sur le<body>. - i18n :
{% translate %}/gettextpour tout texte visible. Texte source en français par defaut : le libelle ecrit dans{% translate "..." %}/_()est en FR ; la traduction EN est generee depuis le FR (jamais l'inverse). Ne plus creer de nouveaux msgid en anglais. (Du code ancien a encore des msgid EN : ne pas les convertir hors session.)
L'extension HTMX loading-states gere l'overlay de chargement pendant les navigations.
Active sur <body> via hx-ext="loading-states".
Principe : un overlay frosted glass (flou gaussien + voile sombre) apparait uniquement si la requete dure plus de loading_delay ms (defaut : 400ms). Les requetes rapides ne declenchent rien.
- Overlay :
reunion/loading.html— inclus dansbase.htmletheadless.html - Delai configurable : variable
loading_delaydansget_context()(views.py), injectee via{{ loading_delay|default:'400' }} - Declenchement : attribut
data-loading-target="#tibillet-spinner"sur le conteneur parent des liens de navigation - Scope : ne jamais mettre
data-loading-targetsur un conteneur qui contient des formulaires (hx-post). Scoper au plus pres des liens de navigation.
Attributs utilises :
data-loading-class="active"— sur le spinner, ajoute une classe CSS (permet les transitions)data-loading-delay="{{ loading_delay|default:'400' }}"— debounce, pas de blink si requete rapidedata-loading-target="#tibillet-spinner"— sur les conteneurs de liens, pointe vers l'overlaydata-loading-disable— desactive un bouton pendant la requete (utile pour les formulaires)
<!-- Conteneur de liens de navigation — scope du spinner -->
<nav data-loading-target="#tibillet-spinner" data-loading-delay="{{ loading_delay|default:'400' }}">
<a href="/event/" hx-get="/event/" hx-target="body" hx-push-url="true">Agenda</a>
</nav>
<!-- NE PAS faire : data-loading-target sur un conteneur avec des formulaires -->
<div data-loading-target="#tibillet-spinner"> <!-- MAUVAIS -->
<form hx-post="/save/">...</form> <!-- Le spinner va se declencher ici aussi -->
</div>Regle CSS requise (deja dans loading.html) :
[data-loading] { display: none; }Pattern HTMX pour les toasts (reponses partielles) :
# Cote controleur : utiliser HX-Trigger + django.messages
messages.add_message(request, messages.SUCCESS, _("Action reussie !"))
payload = [{"level": m.level_tag, "text": str(m)} for m in get_messages(request)]
response = render(request, "mon/partial.html", context)
response["HX-Trigger"] = json.dumps({"toast": {"items": payload}})
return response- Etendre la base du skin (
reunion/base.html,faire_festival/base.html, etc.) - Factoriser en partiels (
partial/*.html) pour les blocs HTMX reutilisables. - Multi-skin : chaque skin est un dossier dans
BaseBillet/templates/<skin_name>/.
Base PostgreSQL unique avec isolation par schema. Chaque lieu/organisation a son propre schema.
- Apps partagees (schema public) :
Customers,AuthBillet,Administration - Apps tenant (schema par tenant) :
BaseBillet,ApiBillet,api_v2,PaiementStripe,fedow_connect,crowds... - Routage URL :
TiBillet/urls_public.py(domaine racine) vsTiBillet/urls_tenants.py(sous-domaines) - Cache tenant-aware via
django_tenants.cache.make_key - Migrations :
python manage.py migrate_schemas --executor=multiprocessing
| Module | Role |
|---|---|
BaseBillet |
Coeur : modeles Event, Product, Offering, Reservation, Membership, Sale + vues templates |
Administration |
Admin Django (django-unfold). Fichier principal : admin_tenant.py |
AuthBillet |
Modele User custom (TibilletUser), auth, OAuth2/SSO |
seo |
SHARED_APPS. Cache cross-tenant pour landing publique, explorer, sitemap-index. Source unique pour le widget Leaflet (JS+CSS+widget+data builder) partage entre /explorer/ public et /federation/ tenant. |
api_v2 |
API semantique schema.org/JSON-LD (voir api_v2/GUIDELINES.md) |
ApiBillet |
API REST legacy (v1) |
PaiementStripe |
Webhooks Stripe, paiements, abonnements, remboursements |
fedow_connect |
Client API Fedow (tokens SSA, monnaie locale/temps) |
crowds |
Financement participatif avec contribution adaptive/cascade (voir crowds/GUIDELINES.md) |
Customers |
Modeles Client/Domain pour django-tenants |
wsocket |
Django Channels / WebSocket |
seo (SHARED_APPS) et BaseBillet (TENANT_APPS) s'importent mutuellement (imports
locaux pour eviter les cycles au load). Pattern bidirectionnel assume :
BaseBillet.views::FederationViewsetimporteseo.services,seo.models,seo.views_common(pour acceder au SEOCache cross-tenant).seo.services::build_tenant_config_dataimporteBaseBillet.models.Configuration(pour lire la config singleton de chaque tenant).seo.views_common::humans_txtimporteBaseBillet.views_humans(reuse du parseur de fichier VERSION).
Les imports sont LOCAUX (dans la methode, pas au load time du module) pour
eviter les ImportError au demarrage. Si seo/ doit etre extrait en
microservice un jour, ces 3 imports sont les points a casser.
- SEOCache est une table dans le schema
public. Lecture depuis n'importe quel tenant. Rafraichi par le Celery taskseo.tasks.refresh_seo_cachetoutes les 4h. - Cache 2 niveaux : Memcached L1 (TTL 4h) + DB L2 fallback via
seo.views_common::get_seo_cache(cache_type, tenant_uuid=None). - Widget explorer (carte Leaflet + liste filtree) : source unique dans
seo/templates/seo/partials/explorer_widget.html+seo/static/seo/explorer.{js,css}, utilisee par/explorer/public ET/federation/tenant via 2 wrappers triviaux. - JS IIFE encapsule :
seo/static/seo/explorer.jsn'expose rien surwindow. Event delegation, garde-foustry/catch, i18n viadata-i18n-*sur#explorer-root. - Vendor Leaflet :
seo/static/seo/vendor/leaflet/(plus de CDN externe en prod, pour la stabilite et la confidentialite).
Pattern obligatoire pour tout JSON injecte dans <script type="application/ld+json"> :
from seo.views_common import json_for_html
context = {"my_json_ld": json_for_html(my_json_ld_dict)}<script type="application/ld+json">{{ my_json_ld|safe }}</script>json_for_html() translate <, >, & en sequences unicode (<>&).
Equivalent semantique pour un parser JSON, mais ne casse pas le HTML parent. Cas
d'usage : si un admin tenant met </script> dans son nom d'organisation,
le JSON-LD passe par le SEOCache et se retrouve dans la page des voisins. Sans
echappement, le </script> interieur ferme prematurement la balise script
→ vecteur XSS.
Pour les donnees JSON cote JavaScript (consommees par JSON.parse() ou
fetch), utiliser {{ data|json_script:"my-id" }} qui fait le meme job
nativement et expose les donnees via document.getElementById('my-id').textContent.
Le projet tourne derriere Traefik qui termine TLS et forwarde en HTTP au
container Django. Sans SECURE_PROXY_SSL_HEADER, request.scheme = 'http'
et tous les request.build_absolute_uri() retournent http://.... Le
reglage SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
permet a Django de detecter HTTPS via le header forwarded par Traefik
(active par defaut en mode HTTPS auto).
Consequence : canonical URLs, JSON-LD, et tous les liens generes avec
request.build_absolute_uri() sont en https://. Important pour le SEO.
Vocabulaire schema.org avec champs JSON-LD. Voir api_v2/GUIDELINES.md pour le mapping complet.
- Auth : header
Authorization: Api-Key <key> - Endpoints :
/api/v2/events/,/api/v2/postal-addresses/,/api/v2/products/, etc. - OpenAPI maintenu manuellement dans
api_v2/openapi-schema.yaml - Ici on utilise
viewsets.ViewSetaussi, mais les reponses sont du JSON semantique.
Lancer le serveur en arriere-plan depuis un terminal :
# Lance dans le terminal actuel ET garde un trace dans un fichier de log :
docker exec lespass_django poetry run python /DjangoFiles/manage.py runserver 0.0.0.0:8002 2>&1 | tee /DjangoFiles/logs/runserver.logLes logs du serveur (tracebacks, requetes) sont ecrits dans un fichier temporaire :
Pour que le mainteneur suive les logs en temps reel dans un terminal PyCharm :
tail -f logs/runserver.log# Demarrer la stack
docker compose up -d
# Commandes Django dans le conteneur
docker exec lespass_django poetry run python manage.py <commande>
# Migrations multi-tenant
docker exec lespass_django poetry run python manage.py migrate_schemas --executor=multiprocessing
# Collectstatic
docker exec lespass_django poetry run python manage.py collectstatic --no-input
# i18n
docker exec lespass_django poetry run django-admin makemessages -l fr
docker exec lespass_django poetry run django-admin makemessages -l en
docker exec lespass_django poetry run django-admin compilemessages
# Celery (lance via docker-compose, commande pour reference)
poetry run celery -A TiBillet worker -l INFO -B --concurrency=6Source de verite : tests/README.md (suites, commandes, politique Stripe,
migration TS→Python). Pieges : tests/PIEGES.md — a lire avant d'ecrire un test.
# Suite essentielle (smoke ~10 s) : wallet, adhesion, billetterie
# Voir tests/README.md pour la liste des fichiers
docker exec lespass_django poetry run pytest tests/pytest/test_api_v2_wallet_refill.py \
tests/pytest/test_membership_create.py tests/pytest/test_membership_by_wallet.py \
tests/pytest/test_reservation_create.py tests/pytest/test_event_create.py \
tests/pytest/test_events_list.py -q
# Tous les tests backend (Stripe mocke inclus)
docker exec lespass_django poetry run pytest tests/pytest/ -q
# Tests integration API v2 uniquement
docker exec lespass_django poetry run pytest -m integration tests/pytest/
# Un seul fichier
docker exec lespass_django poetry run pytest tests/pytest/test_events_list.py -qs# Serveur Django actif requis (Traefik). Login instantane via force_login
# (E2E_TEST_TOKEN dans .env, voir tests/README.md).
docker exec lespass_django poetry run pytest tests/e2e/ -qL'ancienne suite Playwright TypeScript a été entièrement migrée en Python puis supprimée (2026-06-11). Plus aucune dépendance Node/yarn pour les tests.
Carte Stripe test : 4242 4242 4242 4242, nom : Douglas Adams, exp : 12/42, CVC : 424.
- Atomique : un test = un comportement precis.
- Verbeux : noms de fonctions longs et clairs.
- Bilingue : commentaires FR + EN.
- FALC : mots simples.
- Incremental : d'abord comprendre la structure (curl), ensuite ecrire le test.
Toujours dans le conteneur. Le projet est bind-monte et le .venv est PARTAGE entre l'hote
et le conteneur : lancer poetry / ruff / pytest depuis l'hote fait recreer le
virtualenv par Poetry et detruit l'environnement du conteneur (Django disparait, le serveur
tombe).
docker exec lespass_django poetry run ruff check --fix /DjangoFiles/<fichier>.py
docker exec lespass_django poetry run ruff format /DjangoFiles/<fichier>.pyIl reformate le fichier entier — indentation, guillemets, sauts de ligne — sur des milliers de lignes que personne n'a ecrites dans la session. Reserve aux fichiers neufs.
⚠️ Cette regle disait longtemps que--fixetait « sans danger ». C'etait FAUX, et ca a casse le projet le 2026-07-13.
Un import a effet de bord est un import dont le seul but est d'executer le module :
from Administration.admin import (products, prices) # lance les @admin.register(...)Le nom importe n'est jamais reference. Ruff le voit donc comme mort (F401) et --fix le
supprime. L'enregistrement disparait, en silence.
Ce qui s'est passe : --fix a supprime cet import de Administration/admin_tenant.py.
ProductAdmin n'etait plus enregistre → admin.E039 → Django refuse de demarrer, le serveur
tombe, et 319 tests partent en erreur — non pas parce qu'ils testaient l'admin, mais parce
que la fixture conftest.py appelle manage.py test_api_key, qui ne bootait plus. Le symptome
etait a des kilometres de la cause.
Ne jamais compter sur le # noqa: F401 : rien ne garantit que tous les imports a effet de
bord soient proteges. A l'audit, le projet avait 141 imports F401 « fixables » et aucune
configuration ruff.
Les deux parades, en place depuis :
pyproject.toml—[tool.ruff.lint.per-file-ignores]: F401 est ignore suradmin*.py,admin/*.py,apps.py,signals.py,triggers.py,settings.py,__init__.py. Ruff ne peut plus y toucher.tests/pytest/test_django_system_checks.py— la suite echoue immediatement si un enregistrement d'admin disparait. C'est le filet : il transforme une panne silencieuse en test rouge.
La regle qui reste, malgre ces parades : apres tout ruff --fix, lancer
manage.py check et la suite complete — pas seulement les tests du domaine touche. Et si un
--fix produit un diff qui deborde de la session : le signaler, ne jamais rollback avec git.
aria-labelpour les groupes d'info,visually-hiddenpour decrire les valeurs.- Ne pas encoder de texte dans les icones; elles sont decoratives (
aria-hidden="true"). - Utiliser les classes Bootstrap (
text-body,text-muted,bg-body-tertiary, etc.) pour respecter clair/sombre.
- Reponses JSON pour piloter l'UI — preferer HTML +
HX-Triggerpour les toasts. - Swapper
html/head— provoque des clignotements et recharge les assets. - JS volumineux pour des comportements que HTMX gere nativement.
- ModelViewSet / ModelSerializer pour les vues templates — trop de magie cachee.
- Django Forms — on utilise les serializers DRF.
- Comprehensions complexes et one-liners — preferer le code verbeux et lisible.
- Decorateurs/metaclasses qui cachent la logique — le code doit se lire lineairement.
json.dumps()dans un template<script type="application/ld+json">— caracteres< > &non echappes, vecteur XSS si l'input vient de la DB. Utiliserseo.views_common.json_for_html()ou{{ data|json_script:"id" }}(voir section "JSON-LD dans les templates" plus haut).- CDN externes pour assets prod (unpkg.com, cdnjs, jsDelivr, etc.) —
fragile (dependance externe), tracking tiers possible. Vendoré dans
*/static/*/vendor/*/(ex:seo/static/seo/vendor/leaflet/). - Globals JavaScript (
var foo = ...au top-level d'un fichier JS) — polluewindow, conflit possible avec d'autres scripts. Encapsuler dans une IIFE(function(){ 'use strict'; ... })();(pattern utilise dansseo/static/seo/explorer.js). - Inline
onclick="..."dans des strings construits cote JS — fragile, casse avec CSP stricte, dur a tester. Utiliser event delegation sur un conteneur parent. setTimeout(..., N)avec un delai magique pour attendre un event (animations, transitions) — flaky en CI lent. Preferer les events natifs (animationend,transitionend, etc.) avec fallback timer reduit.
| Conteneur | Role | Port |
|---|---|---|
lespass_django |
Django (Gunicorn) | 8002 |
lespass_celery |
Celery worker + Beat | — |
lespass_postgres |
PostgreSQL 13 | 5432 (interne) |
lespass_redis |
Redis 7.2 (broker) | — |
lespass_memcached |
Memcached 1.6 (cache) | — |
lespass_nginx |
Nginx reverse proxy | 80 |
Reseau externe frontend (pour Traefik). Acces : https://lespass.tibillet.localhost. Auto-login admin quand DEBUG=1 et TEST=1.
stripe listen --forward-to https://tibillet.localhost/api/webhook_stripe/ --skip-verifyNe jamais realiser d'operation git. Pas de git add, git commit, git push, git checkout, git branch, etc. Le mainteneur s'en occupe.
- Toutes les interactions renvoient du HTML (partials) — pas de JSON UI.
- Pas de « blink »: navigations
bodyet swaps ciblés. - Recherche/pagination/tag OK, URLs mises à jour (
hx-push-url). - FALC: libellés simples, pictos, contrastes, aides d’accessibilité.
- i18n: tous les labels dans
{% translate %}. - Sécurité: droits des actions, CSRF OK.
- Admin: URLs fonctionnelles dans Unfold.