Dépôt template pour monter une instance QFieldCloud auto-hébergée sans jamais modifier une ligne du dépôt upstream.
Le principe tient en une phrase : opengisch/QFieldCloud est un sous-module
épinglé sur un tag, en lecture seule ; tout ce qui vous appartient vit ici, un cran
au-dessus. git -C src status doit rester vide en permanence — c'est le contrôle
qui dit si vous avez contracté une dette.
Cela vaut jusqu'à l'apparence : mettre son logo et ses couleurs sur la page de connexion se fait depuis le dehors, par un module de réglages Django composé — voir « Thème ».
Ce template est livré avec un thème actif, pas avec l'apparence d'origine de
QFieldCloud : le logo officiel, le bleu de la marque, le vert de QField et un
fond de courbes de niveau. Il sert deux fins — habiller une instance dès le
premier démarrage, et montrer jusqu'où va le principe du dehors, puisqu'il ne
coûte pas une ligne de src/. Il se retire en trois étapes, décrites à
« Revenir au thème de l'upstream » ; le nom affiché, lui, est un placeholder à
changer, INSTANCE_NAME dans theme/settings_custom.py.
Il est aussi livré avec un portail utilisateur, que l'upstream n'a pas : sans
lui, / renvoie à l'admin et un compte non-staff y boucle en redirections. Même
principe, même prix — pas une ligne de src/. Voir « Portail utilisateur ».
| Fichier | Rôle |
|---|---|
src/ |
Sous-module opengisch/QFieldCloud, épinglé sur un tag |
.env.template |
Modèle de configuration. À copier en .env, qui n'est jamais versionné |
docker-compose.override.yml |
Le seul fichier Compose qui vous appartient. Chargé en dernier |
Makefile |
Raccourcis, pour ne plus se demander d'où lancer Compose ni où vivent les scripts |
scripts/ |
Sauvegarde du bucket et test de restauration. À appeler par le Makefile (make backup, make restore-test), pas directement |
theme/ |
Apparence de l'instance : logos, couleurs, textes. Livré actif. Chargé par DJANGO_SETTINGS_MODULE, sans rien modifier dans src/. Voir « Thème » |
theme/portal/ |
Portail utilisateur : les pages que l'upstream ne livre pas. Application Django montée dans l'image, mise devant l'URLconf upstream par theme/urls_custom.py. Voir « Portail utilisateur » |
theme/portal/management/ |
La commande apply_instance_plans, qui aligne les quotas des plans sur INSTANCE_PLANS. Appelée par make plans |
.gitignore |
Exclut .env — il contient vos secrets |
Si vous êtes passé par le bouton « Use this template », votre dépôt est déjà vierge : clonez-le et sautez l'étape 0.
# 0. Repartir d'un historique à vous
# Les commits de ce dépôt racontent MON instance (mon frontal, mes IP). Le vôtre
# doit raconter la vôtre : c'est tout l'intérêt du montage.
git clone https://github.com/allfab/qfieldcloud-stack-template.git qfieldcloud-stack
cd qfieldcloud-stack
rm -rf .git src # src/.git pointe dans .git/modules/ : les deux partent ensemble
git init
# 1. Le socle
git submodule add -b release https://github.com/opengisch/QFieldCloud.git src
git -C src checkout v26.26 # choisissez le tag, ne restez pas sur une branche
# 2. Les dossiers que Docker créerait en root si on ne le prenait pas de vitesse
mkdir -p src/conf/certbot src/conf/nginx/config.d
# 3. Votre configuration
cp .env.template .env
$EDITOR .env # voir « Les variables à changer » ci-dessous
$EDITOR theme/settings_custom.py # INSTANCE_NAME : le nom affiché de l'instance
make check # valide le .env contre les fichiers Compose
make config # valide la configuration Compose fusionnée
# 4. Les images QGIS, une par une : elles sont énormes
cd src
alias dc='docker compose --env-file ../.env'
dc build qgis3 && docker builder prune -f && df -h /
dc build qgis4 && docker builder prune -f && df -h /
cd ..
# 5. La mise en service
make up
cd src
dc exec app python manage.py migrate
cd .. && make plans && cd src # quotas des plans : voir INSTANCE_PLANS
dc run --rm app python manage.py collectstatic --noinput
dc exec --user root app python manage.py compilemessages
dc exec app python manage.py createsuperuserAu premier up, quatre services sortent en Exited (0) — c'est normal, ils ont fait
leur travail — et worker_wrapper boucle sur relation "project_project" does not exist jusqu'au migrate. Voir la section « pièges » plus bas.
Le code est en anglais — cibles du Makefile, variables, noms de fichiers, noms
de jobs Ofelia, bases de données — et la prose en français : commentaires,
messages affichés, documentation. C'est la convention la plus courante des dépôts
publics, et elle évite le mélange des deux dans une même ligne de commande.
Toutes sont marquées change_me ou pointent vers example.org dans le template.
| Variable | Remarque |
|---|---|
QFIELDCLOUD_HOST |
Sans schéma, sans port, sans slash |
DJANGO_ALLOWED_HOSTS |
Doit contenir QFIELDCLOUD_HOST |
SECRET_KEY, SALT_KEY |
64 caractères tirés au sort. Sans eux, les champs chiffrés de la base sont perdus : ils font partie de votre sauvegarde |
POSTGRES_PASSWORD, OBJECT_STORAGE_ROOT_*, WEBDAV_PASSWORD |
idem |
STORAGES |
access_key/secret_key doivent être alignés sur OBJECT_STORAGE_ROOT_*, sinon createbuckets échoue |
WEB_BIND_IP |
Non upstream : où publier le port HTTPS. 127.0.0.1 si le frontal est sur cette machine, l'IP de l'hôte s'il est ailleurs |
LETSENCRYPT_EMAIL |
LETSENCRYPT_STAGING reste à 1 tant que le DNS public ne pointe pas ici |
QFIELDCLOUD_ACCOUNT_ADAPTER |
À ne pas oublier. Défaut upstream ...AccountAdapterSignUpOpen : n'importe qui trouvant votre URL peut se créer un compte. ...AccountAdapterSignUpClosed bascule en mode sur invitation (les invitations continuent de marcher, l'admin Django aussi) |
QFIELDCLOUD_DEFAULT_TIME_ZONE |
Défaut upstream : Europe/Zurich |
S3_BACKUP_* |
Non upstream : lues uniquement par scripts/backup-storage.sh. Voir « Sauvegarde » |
SMTP4DEV_WEB_BIND_IP |
Non upstream : où publier l'interface web du piège à courriels. 127.0.0.1 par défaut ; voir le piège 3 avant d'y mettre une IP de LAN |
DJANGO_SETTINGS_MODULE |
Le point d'extension de tous les réglages Django. Le passer à qfieldcloud.settings_custom active theme/ ; le laisser au défaut donne l'apparence upstream. Voir « Thème » |
1. COMPOSE_FILE livré par l'upstream est un profil de développement. Le template
charge standalone + prod + votre override :
COMPOSE_FILE=docker-compose.yml:docker-compose.override.standalone.yml:docker-compose.override.prod.yml:../docker-compose.override.yml
Les trois premiers appartiennent au sous-module et seront remplacés à l'identique au
prochain git checkout. Le quatrième est à vous, d'où le ../.
2. En DEBUG=0, les workers doivent passer par nginx. Le défaut upstream
QFIELDCLOUD_WORKER_QFIELDCLOUD_URL=http://app:8000/api/v1/ court-circuite nginx,
donc pas de X-Forwarded-For, donc 500 sur tous les téléchargements de fichiers
de projet. Le template corrige cela avec trois choses qui vont ensemble :
NGINX_ALLOW_INTERNAL_HTTP=1, un alias réseau ${QFIELDCLOUD_HOST} sur nginx dans
l'override, et l'URL du worker qui passe par ce nom.
3. SMTP4DEV_SMTP_PORT vaut 25 par défaut et smtp4dev publie ce port sur toutes
les interfaces. Sur une Debian avec un agent de transport local, le démarrage échoue
sur un address already in use qui ne nomme pas le coupable. Vérifiez avec
ss -tlnp | grep ':25 '.
Tant qu'on y est : l'interface web de smtp4dev n'a aucune authentification, et elle donne accès aux liens de réinitialisation de mot de passe — donc à la prise de contrôle des comptes. L'override publie ses trois ports séparément pour cette raison :
ports: !override
- "${SMTP4DEV_WEB_BIND_IP}:${SMTP4DEV_WEB_PORT}:80"
- "127.0.0.1:${SMTP4DEV_SMTP_PORT}:25"
- "127.0.0.1:${SMTP4DEV_IMAP_PORT}:143"Seule l'interface web peut sortir sur le LAN, en renseignant SMTP4DEV_WEB_BIND_IP
— c'est la seule qu'on ait une raison d'ouvrir dans un navigateur. L'IMAP donne accès
aux mêmes messages et le SMTP accepterait n'importe quel envoi : ils restent sur le
loopback en dur. À 127.0.0.1, l'accès se fait par un tunnel SSH :
ssh -N -L 8012:127.0.0.1:8012 <hôte>. La sortie définitive de ce compromis, c'est
un vrai relais SMTP — après quoi smtp4dev se retire par un profil.
Le dépôt open source livre l'admin Django et l'API REST, pas l'espace utilisateur
de app.qfield.cloud. Le profil, le choix d'abonnement et la liste de projets qu'on y
voit ne sont pas dans le sous-module : core/templates/ ne contient que account/,
admin/, allauth/, axes/, captcha/ et socialaccount/, tout core/views/ est du
DRF, et urls.py bloque explicitement le peu de libre-service qu'allauth apporterait :
path("accounts/3rdparty/", blocked_view),
path("accounts/email/", blocked_view),
path("accounts/password/change/", blocked_view),Ce n'est pas un oubli, c'est une décision de l'upstream. Trois conséquences pratiques.
Un compte non-is_staff qui se connecte par le web boucle — chez l'upstream.
LOGIN_REDIRECT_URL vaut index, index redirige vers QFIELDCLOUD_ADMIN_URI,
l'admin refuse le non-staff, allauth le voit connecté et le renvoie à index :
/ -> admin/ -> /admin/login/?next=/admin/ -> /accounts/login/?next=/admin/ -> /admin/ -> …
Le navigateur affiche ERR_TOO_MANY_REDIRECTS. Ce template ne boucle pas : le
portail livré prend la place de cette redirection — voir « Portail utilisateur ». Le
paragraphe reste ici parce que c'est ce que vous trouverez sur une instance montée
sans lui, et parce que c'est ce qui explique la forme du correctif.
L'utilisateur travaille depuis QField et QFieldSync. Le portail lui donne ses
projets, son profil et son mot de passe ; c'est l'API qui porte le reste
(/api/v1/auth/user/, /api/v1/projects/), et c'est de là qu'il pousse ses projets
et synchronise. Rien de ce que le portail affiche n'est une règle nouvelle : il lit
les mêmes objets que l'API.
Les quotas sont des lignes en base, pas du code. Le plan community, attribué
d'office à l'inscription, vaut sur une instance neuve :
| Réglage | Valeur |
|---|---|
storage_mb |
10 000, soit 10 Go |
storage_keep_versions |
10 versions par fichier |
job_minutes |
10 000 |
is_external_db_supported |
False |
initial_subscription_status |
active_paid — actif d'emblée, rien n'est facturé |
Le seul réglage qui mord vraiment est le quatrième : un projet QGIS branché sur
PostGIS ou un WFS est refusé au packaging pour un compte community
(PlanInsufficientError, dans core/permissions_utils.py). Un projet en GeoPackage
passe sans rien demander. Tout cela s'édite dans l'admin, Subscription → Plans — sur
une instance auto-hébergée sans facturation, relever une limite est une décision
d'exploitant, pas un contournement.
Deux détails à ne pas croire sur parole : synchronizations_per_months n'est lu nulle
part dans le code, il ne limite rien ; et can_always_upload_files() exempte les
clients QFIELD et WORKER du contrôle de quota fichier par fichier — c'est au
packaging que le quota global s'applique.
git -C src fetch --tags
git -C src checkout v26.27
make check && make config
make up
cd src && docker compose --env-file ../.env exec app python manage.py migrate
git add src && git commit -m "Montée en v26.27"Le commit ne contient qu'un changement de pointeur de sous-module. C'est tout l'intérêt du montage : rien à reporter à la main.
On ne supprime pas un service du sous-module : on lui donne un profil que
personne n'active, depuis docker-compose.override.yml.
certbot:
profiles: ["never"]Le service disparaît de docker compose config --services. Mais up -d --remove-orphans ne supprime pas le conteneur déjà en marche : Compose ne
considère pas comme orpheline une instance simplement exclue par un profil. Il
faut la nommer, en réactivant le profil le temps de la commande :
docker compose --env-file ../.env --profile never rm -sf certbotCe dépôt retire ainsi certbot (le TLS est terminé par un frontal), puis
rustfs et createbuckets (le stockage objet est externalisé). Si vous restez
en profil standalone, enlevez les deux dernières lignes profiles.
Le dossier theme/ porte l'apparence de l'instance. Rien n'est modifié dans
src/ : DJANGO_SETTINGS_MODULE désigne un module à nous, qui importe les
réglages upstream et n'écrase que l'apparence.
theme/
settings_custom.py # WHITELABEL (pages publiques) + JAZZMIN_* (admin)
contours.py # régénère static/contours.svg (outil de conception)
static/ # logos, favicon, theme.css -> servis sous custom/
account-base.html # gabarit recopié, + une ligne <link>
account-base.html.upstream # sa version d'origine, pour `make theme-diff`
Deux thèmes, parce que la racine du site redirige vers /admin/ : WHITELABEL
habille /accounts/…, Jazzmin habille l'admin. Les deux se règlent dans
settings_custom.py.
L'habillage reprend les codes de qfield.cloud : le logo
officiel, le bleu de la marque #4a6fae, le vert de QField #80cc28 en accent,
et des courbes de niveau en filigrane derrière la page de connexion.
Le fond n'est pas une image trouvée quelque part : theme/contours.py
échantillonne un champ scalaire et en suit les lignes de niveau par marching
squares, comme une carte topographique. Le SVG produit est versionné — le script
n'est là que pour le rejouer autrement. Changer SEED donne un autre relief,
LEVELS l'équidistance des isolignes :
python3 theme/contours.py # réécrit theme/static/contours.svg
make static # sinon le nouveau fichier n'est pas serviLes courbes sont posées en masque CSS, pas en image de fond : une seule
source SVG, teintée par background-color, d'où le même fichier en bleu sur la
page claire et en blanc sous le bandeau.
Deux limites à connaître avant de s'approprier le thème :
- le logo est celui d'OPENGIS.ch, repris tel quel. Il habille une instance
QFieldCloud, il ne dit pas qui l'exploite. Le nom, lui, est à vous : une seule
ligne dans
settings_custom.py,INSTANCE_NAME, le porte partout — onglet, admin, page de connexion. La laisser surMon instance QFieldCloudlaisse l'instance anonyme, ce qui est un choix, pas un oubli ; - l'admin ne reçoit que les couleurs Jazzmin (
JAZZMIN_UI_TWEAKS), pastheme.css:custom_cssreste celui de l'upstream, qui porte déjà les correctifs de l'admin QFieldCloud.
Quatre choses à ne pas oublier :
- le module de réglages se monte sur
appetworker_wrapper— ils partagent le même bloc d'environnement, et le worker ne démarre pas sans lui ; make staticaprès toute modification detheme/static/: le stockage statique est à manifeste, et une référence non collectée donne une erreur 500 sur la page entière, pas une image manquante ;- redémarrer
appaprèsmake staticquand le contenu d'un fichier a changé. Le manifeste est lu au démarrage :collectstaticécrit bien le nouveau nom haché sur le disque, mais le processus continue de servir la page avec l'ancien. Rien ne casse, rien ne prévient — la modification semble simplement sans effet.cd src && docker compose --env-file ../.env restart app; make theme-diffà chaque montée de version. Sortie vide = le gabarit upstream n'a pas bougé. Sortie non vide = reporter la ligne<link>dans le nouveau gabarit, puis rafraîchir le fichier.upstream.
Le thème est livré actif ; ceci le retire et rend à l'instance l'apparence
d'origine de QFieldCloud — le logo upstream, sa palette, aucune courbe. Rien
n'est perdu au passage : theme/ reste en place, et le chemin se refait dans
l'autre sens.
Le thème tient à deux leviers indépendants, et .env n'en commande qu'un.
Le module de réglages porte les logos, les titres et les couleurs de l'admin ;
le gabarit account/base.html, lui, charge custom/theme.css de lui-même, sans
rien demander à Django. Ne défaire que .env laisse donc la palette du thème
sur les pages /accounts/… — l'instance a l'air inchangée, et c'est normal.
Dans l'ordre :
# 1. Les réglages : reprendre ceux de l'upstream.
sed -i 's/^DJANGO_SETTINGS_MODULE=.*/DJANGO_SETTINGS_MODULE=qfieldcloud.settings/' .env
# 2. Les montages : commenter le bloc `volumes` du thème sous `app` ET celui
# sous `worker_wrapper`, dans docker-compose.override.yml.
$EDITOR docker-compose.override.yml
# 3. Recréer les conteneurs, puis recollecter : collectstatic réécrit le
# manifeste, d'où `custom/` disparaît. C'est l'étape qui purge le thème.
# Dans cet ordre : `make up` recrée les conteneurs, donc le manifeste que
# `make static` vient d'écrire est bien celui que le processus a en mémoire.
make up
make staticVérifier plutôt que croire — la sortie attendue est celle-ci, à la lettre :
cd src && docker compose --env-file ../.env exec app sh -c '
echo $DJANGO_SETTINGS_MODULE
grep -c custom/theme.css qfieldcloud/core/templates/account/base.html
ls qfieldcloud/core/staticfiles/custom 2>&1'qfieldcloud.settings
0
ls: cannot access 'qfieldcloud/core/staticfiles/custom': No such file or directory
Le retour au thème se fait en défaisant les trois étapes. Décommenter les
montages de app et de worker_wrapper ensemble : sous settings_custom,
un worker privé du fichier ne démarre pas.
Un mot sur ce qui est versionné, parce que la moitié de l'opération ne l'est
pas. docker-compose.override.yml et .env.template sont livrés thème actif ;
votre .env, lui, n'est pas dans le dépôt. Une instance qui reprend le template
sans toucher à rien démarre donc thémée, et une instance revenue à l'upstream le
reste tant que son .env le dit — mais le prochain git pull ne le lui
rappellera pas. C'est le fichier qui décide, pas le dépôt.
L'upstream ne livre aucune page pour un utilisateur ordinaire — voir « Ce que
l'instance n'a pas ». Ce template en livre une poignée, montées depuis le
dehors comme le thème : une application Django dans theme/portal/, une
URLconf dans theme/urls_custom.py, trois lignes dans theme/settings_custom.py
et deux montages par service. git -C src status reste vide.
Deux routes de l'upstream sont reprises, pas seulement complétées : index
(la redirection vers l'admin) et a/<user>/<projet>/ (qui redirigeait elle
aussi vers l'admin, donc vers une page interdite pour un non-staff). C'est
tout ; le reste des urlpatterns est repris tel quel.
Ce qui est en place :
| Page | Chemin | Ce qu'elle fait |
|---|---|---|
| Accueil | / |
Tout ce que le compte peut voir, y compris les projets d'autrui où il collabore ; recherche, filtre de visibilité et tri |
| Mes projets / profil | /a/<user>/ |
Le même tableau, restreint à ce qu'un compte POSSÈDE ; avatar, biographie, organisations |
| Projets publics | /projects/public/ |
Les projets ouverts à tous les comptes de l'instance |
| Compte utilisateur | /settings/<user>/ |
Prénom, nom, adresse e-mail, comptes externes liés |
| Profil | /settings/<user>/profile/ |
Avatar, biographie, organisme, localisation, fuseau horaire |
| Notifications | /settings/<user>/notifications/ |
Fréquence des courriels |
| Sécurité | /settings/<user>/security/ |
Mot de passe, jetons des clients, déconnexion globale |
| Mon plan | /settings/<user>/plan/ |
Quotas du plan, stockage consommé, et ce que le plan refuse |
| Projet — aperçu | /a/<user>/<projet>/ |
Fichier QGIS, couches et leurs erreurs, chiffres, derniers traitements |
| Projet — fichiers | /a/<user>/<projet>/files/ |
Fichiers, tailles, versions, téléchargement |
| Projet — traitements | /a/<user>/<projet>/jobs/ |
Historique des jobs, avec leur sortie repliée |
| Projet — modifications | /a/<user>/<projet>/deltas/ |
Ce qui est remonté du terrain, filtrable par état |
| Projet — collaborateurs | /a/<user>/<projet>/collaborators/ |
Ajout, rôle, retrait |
| Projet — secrets | /a/<user>/<projet>/secrets/ |
Variables d'environnement et services PostgreSQL, chiffrés. Administrateur du projet seulement |
| Projet — réglages | /a/<user>/<projet>/settings/ |
Nom, visibilité, conflits, versions gardées, moteur de packaging, suppression |
| Mes organisations | /settings/<user>/organizations/ |
Celles qu'on possède, celles où l'on est membre |
| Nouvelle organisation | /organizations/new/ |
Création |
| Organisation — projets | /o/<orga>/ |
Les projets de l'organisation |
| Organisation — membres | /o/<orga>/members/ |
Ajout, rôle, retrait, plafond du plan |
| Organisation — équipes | /o/<orga>/teams/ |
Création, suppression |
| Organisation — réglages | /o/<orga>/settings/ |
Rôle par défaut des membres, profil public |
| Équipe | /o/<orga>/teams/<équipe>/ |
Membres de l'équipe |
| Plans et quotas | /plans/ |
Exploitant. Le stockage de l'instance (consommé / promis / capacité), tous les comptes avec leur remplissage, puis le catalogue des plans |
Ce qui n'y est pas, et pourquoi :
- La carte d'un projet — l'emprise et les couches sont en base
(
QgisProject.extent,QgisLayer), mais afficher une carte demande un fond et une bibliothèque, donc un choix qui engage. Les couches sont listées, avec le message d'erreur de celles qui sont invalides — c'est ce qui explique un packaging en échec. - Créer un projet — cela se fait depuis QGIS, avec QFieldSync. Le portail ne double pas ce chemin.
- Cloner un projet et transférer sa propriété — deux actions que
l'application de référence propose sur la page de réglages. Le clonage passe
par le champ
clone_from_projectà la création, le transfert engage tout le contenu du projet : les deux méritent mieux qu'un bouton ajouté en passant. - Les invitations — l'écran existe côté upstream (
remaining_invitations,invitations_utils), mais il n'a de sens qu'avec des inscriptions ouvertes. Tant queQFIELDCLOUD_ACCOUNT_ADAPTERvautAccountAdapterSignUpClosed, il enverrait des gens vers une porte fermée. Ajouter un collaborateur par adresse e-mail déclenche quand même l'invitation upstream, si vous ouvrez. - La facturation — sans objet ici.
stripeest bien dansrequirements.in, mais aucun fichier deqfieldcloud/ne l'importe : l'intégration de paiement n'est pas dans l'open source. Ce qui reste — les plans, les quotas, le stockage consommé — est présenté par « Mon plan », qui prend la place de la page de facturation sans en prendre le titre. - Changer de nom d'utilisateur — il est dans l'adresse de chacun de ses projets, et l'upstream ne prévoit aucune redirection après un renommage.
- Supprimer son compte — sur une instance auto-hébergée, c'est une décision d'exploitant. L'admin le fait.
Six points de conception valent d'être connus.
L'adresse e-mail ne s'écrit pas directement. Le formulaire de compte confie
le changement à allauth (EmailAddress.objects.add_new_email) : un lien part à
la nouvelle adresse, et l'ancienne reste celle du compte tant que le lien n'est
pas suivi. Écrire User.email à la main ferait perdre l'accès au compte sur une
faute de frappe, puisque l'adresse est aussi un identifiant de connexion.
La déconnexion globale fait expirer les jetons, elle ne les supprime pas. Les jetons sont référencés ailleurs — journaux, statistiques d'usage ; les effacer creuserait des trous dans l'historique. Les connexions par navigateur passent par la session Django et non par un jeton : elles ne sont pas listées, et la déconnexion globale ne les touche pas.
Aucune règle d'accès n'est réécrite. Chaque onglet d'un projet est gardé
par la fonction de core/permissions_utils.py que l'API applique de son côté —
can_read_files, can_list_jobs, can_read_deltas, can_read_collaborators.
Les mêmes fonctions décident si l'onglet s'affiche : un onglet visible est un
onglet accessible. Le projet lui-même passe par
Project.objects.for_user(skip_invalid=True) : un projet hors de portée rend
404, pas 403, pour ne pas confirmer son existence. Le téléchargement d'un
fichier ne passe par aucune vue à nous — le lien vise l'endpoint de l'API, qui
accepte la session Django et revérifie tout.
L'ajout d'un collaborateur passe par l'upstream, y compris pour ses refus.
project/utils/projects_utils.py porte create_collaborator_by_username_or_email,
écrit pour exactement cet usage et appelé nulle part dans le dépôt open
source — un reste du frontal fermé. Il applique le plafond du plan,
l'appartenance à l'organisation, le cas du doublon, et l'invitation par e-mail
d'un inconnu ; il rend un message déjà traduit, que le portail affiche tel
quel. Conséquence à connaître, et elle se lit de travers si on va vite :
check_can_become_collaborator refuse un collaborateur sur un projet privé
dont le propriétaire est une personne, quand le plan du collaborateur
n'est pas premium — aucun plan livré ne l'est. Le contrôle est dans la branche
else de la fonction : un projet appartenant à une organisation n'y passe
jamais, et y ajouter quelqu'un demande seulement qu'il soit déjà membre de
l'organisation. Le portail le dit avant l'échec, sur la page elle-même.
Un mot pour qui édite ces gabarits. DEBUG=0 active le loader de gabarits en
cache : un fichier modifié dans theme/portal/templates/ n'est pas relu, le
montage soit-il en place. docker compose restart app après chaque retouche —
sans quoi on corrige deux fois la même chose en croyant que le correctif ne
prend pas. Et le commentaire de gabarit {# … #} ne vaut que sur une ligne :
sur plusieurs, Django ne le reconnaît pas et le recopie dans la page. Le
commentaire multiligne, c'est {% comment %}.
Les organisations vivent sous /o/, pas sous /a/. Rien n'interdit
d'appeler un projet « members » ou « teams » — le validateur de
Project.name accepte toute lettre, chiffre, tiret, souligné ou point — donc
a/<orga>/members/ serait avalé par la route du détail projet. Les deux
espaces sont séparés, et /a/<orga>/ redirige vers /o/<orga>/ pour que tous
les liens qui affichent un propriétaire continuent de fonctionner.
« Plans et quotas » est gardé par une permission, pas par is_staff.
PermissionRequiredMixin avec subscription.view_subscription : la page lit
des lignes Subscription, et la permission qui gouverne cette lecture existe
déjà. Elle respecte les groupes — un groupe « support » sans accès aux
abonnements n'aura pas la page — et un superuser l'a d'office, donc sur une
instance simple le comportement est celui de is_staff.
Son coût mérite un mot, parce que c'est ce qui la distingue de l'admin. Le
stockage consommé, le nombre de projets et le stockage additionnel sont
calculés en trois requêtes groupées pour tous les comptes, et l'abonnement
courant vient de la vue SQL current_subscriptions_vw par select_related.
Résultat mesuré : 9 requêtes, que l'instance ait 10 ou 150 comptes. Un
piège s'y cache, et il est signalé dans le code de l'upstream lui-même :
User.objects.get_queryset() appelle select_subclasses(), qui reconstruit
chaque ligne en Person ou en Organization — le select_related est bien
émis, mais l'instance rendue n'est plus celle sur laquelle il a été résolu, et
tout repart en requêtes ligne par ligne. La page part donc de UserAccount,
qui n'a pas cette mécanique, et qui est de toute façon le vrai sujet : un plan
appartient au compte, pas à la personne.
Les quotas des plans sont une valeur versionnée, pas un réglage d'admin.
community et organization viennent d'une migration de l'upstream
(subscription/0002_populate_plans) qui ne les crée que s'ils n'existent pas :
une instance neuve hérite toujours des valeurs d'OPENGIS.ch, taillées pour une
offre hébergée — 10 Go par personne, dix versions gardées par fichier. Les
retoucher dans l'admin marche, mais ne se rejoue pas : remontez l'instance
ailleurs et vous repartez des valeurs upstream sans que rien ne vous le
rappelle.
D'où INSTANCE_PLANS dans theme/settings_custom.py, et make plans après
chaque migrate (make plans-dry montre ce qui changerait sans rien écrire).
La commande ne CRÉE aucun plan — ceux-là appartiennent à l'upstream — n'écrit
que les champs déclarés, et est idempotente.
Un mot sur la nature des deux plans, parce qu'elle se lit de travers.
community et organization ne sont pas deux échelons d'une même grille :
Plan.user_type les sépare, et l'upstream choisit à la création d'un compte le
plan par défaut de son type. Un compte personnel ne peut pas recevoir le
plan organisation, et on ne « passe » pas de l'un à l'autre. Surtout, le quota
d'un compte ne mesure QUE les projets qu'il possède
(storage_used_bytes filtre sur user.projects) : un membre qui pousse dans
un projet d'organisation consomme le quota de l'organisation, pas le sien. Si
votre montage fait porter les projets par une organisation, les quotas
personnels ne seront jamais consommés — d'où la valeur basse livrée pour
community, qui évite de gonfler les « promesses » de la page avec ce que
personne ne réclamera.
Ce qui mord vraiment, vérifié dans le code : storage_mb,
storage_keep_versions (le multiplicateur silencieux — dix versions d'un
paquet de 500 Mo, ce sont 5 Go), is_external_db_supported,
max_organization_members et is_premium. Ce qui ne mord pas, malgré son nom :
job_minutes et synchronizations_per_months ne sont lus nulle part dans
le code de l'upstream. Les régler ne limite rien.
Un piège de nommage, enfin : storage_threshold_warning_bytes et
..._critical_bytes sont des octets restants, pas des pourcentages, et
l'upstream refuse un seuil supérieur ou égal au quota. Réduire storage_mb
sans les réduire fait échouer la commande — Plan.save() appelle
full_clean(), et c'est tant mieux : l'incohérence est refusée avant d'entrer
en base.
Les secrets sont réservés aux administrateurs du projet, pas aux
gestionnaires. can_read_project_secrets n'admet que le rôle ADMIN, là où
can_update_project admet aussi MANAGER : un gestionnaire règle le projet
sans voir ses identifiants. L'onglet suit cette règle, donc il disparaît pour
lui. Un secret s'ajoute et se retire mais ne se modifie pas — value est un
EncryptedTextField et rien ne le relit en clair ; proposer une édition
supposerait de réafficher la valeur.
storage_keep_versions par projet n'est honoré que pour un plan premium.
owner_aware_storage_keep_versions retombe sinon sur la valeur du plan. Le
champ est donc verrouillé quand le plan du propriétaire ne l'est pas, avec la
valeur qui s'appliquera réellement — plutôt que de laisser saisir un réglage
sans effet.
Le stockage de l'instance se déclare, il ne se mesure pas. Les fichiers de
projet vivent dans un bucket objet ; Django n'a aucun moyen d'en connaître
l'espace libre, et cela relève de la supervision de l'hôte, pas d'une vue web.
INSTANCE_STORAGE_CAPACITY_BYTES, dans theme/settings_custom.py, porte donc
une capacité déclarée. Le template en livre une par défaut — 50 Go, un
placeholder au même titre qu'INSTANCE_NAME — pour que la page montre à quoi
elle ressemble dès le premier démarrage ; mettez la vôtre, ou None pour que
la page dise simplement qu'elle ne sait pas.
Ce que la page calcule vraiment, elle, est le surengagement : la somme des quotas ACCORDÉS à tous les comptes n'a aucune raison de tenir dans la capacité réelle. Promettre 10 Go à vingt comptes, c'est promettre 200 Go. Ce n'est pas une erreur en soi — on le pratique sciemment, comme une banque — mais c'est la différence entre le choisir et le découvrir quand le bucket est plein.
Elle affiche pour cela deux mesures de l'occupation, et la distinction n'est
pas comptable. UserAccount.storage_used_bytes, la propriété de l'upstream que
les quotas appliquent, ne compte que les fichiers de type PROJECT_FILE. Or le
bucket porte aussi les paquets préparés pour QField (PACKAGE_FILE), refaits
à chaque packaging : ils occupent la place sans entrer dans le quota de
personne. C'est donc le total du bucket, paquets compris, que la page compare à
la capacité — c'est lui qui remplit le disque. Les miniatures de projet et les
avatars restent hors décompte : ce ne sont pas des FileVersion, et ils pèsent
des kilo-octets.
Trois gestes, symétriques de ceux du thème :
# 1. Les réglages : commenter le bloc « Portail utilisateur » de
# theme/settings_custom.py (INSTALLED_APPS et ROOT_URLCONF).
$EDITOR theme/settings_custom.py
# 2. Les montages : retirer les deux lignes `portal` et `urls_custom.py` sous
# `app` ET sous `worker_wrapper`, dans docker-compose.override.yml.
$EDITOR docker-compose.override.yml
# 3. Recréer, puis recollecter.
make up
make static/ redirige alors de nouveau vers l'admin, et un compte non-staff reboucle :
c'est le comportement de l'upstream, retrouvé tel quel.
Le portail et le thème sont indépendants. Le portail charge
custom/theme.css puis custom/portal.css, et portal.css ne redéfinit
aucune couleur — il ne pose que des formes, sur les variables du thème.
Retirer le thème rend donc le portail neutre, pas cassé.
L'upstream fait frapper Ofelia à la porte de django-cron toutes les minutes :
ofelia.job-exec.runcrons.schedule: "@every 1m"Chaque passage relance un bootstrap Django complet — import de l'application,
connexion à la base, initialisation de django-axes — soit environ 2,7 s de CPU,
1440 fois par jour. Sur une instance à quelques utilisateurs et une
synchronisation par jour, c'est du chauffage. Cela s'entend littéralement : sur
l'hyperviseur qui héberge cette instance, ce pic faisait monter le ventilateur
CPU de 2000 à 2400 RPM une fois par minute. La corrélation se lit à la seconde
près entre les Finished in "2.7...s" des logs Ofelia et les relevés de
sensors.
L'override espace donc la cadence à l'heure :
app:
labels:
ofelia.job-exec.runcrons.schedule: "@every 1h"Les labels fusionnent par clé : enabled, command et no-overlap restent ceux
du sous-module, seul schedule est remplacé. À vérifier avec make config, ou
plutôt docker compose --env-file ../.env config | grep ofelia.
Ofelia ne dégrade aucune tâche, il ne fait que retarder. Chaque classe de
CRON_CLASSES porte sa propre fréquence et django-cron ne l'exécute que si son
délai est écoulé. Le seul effet est donc un retard, borné par la cadence Ofelia :
| Tâche | run_every_mins |
Conséquence à @every 1h |
|---|---|---|
qfieldcloud.send_notifications |
1 | notification retardée jusqu'à 1 h |
qfieldcloud.resend_failed_invitations |
1 | idem |
qfieldcloud.set_terminated_workers_to_final_status |
3 | un job dont le worker est mort reste STARTED jusqu'à 1 h |
qfieldcloud.delete_obsolete_project_packages |
60 | voir ci-dessous |
Ne pas aller au-delà d'une heure. delete_obsolete_project_packages ne balaye
que les projets modifiés dans les 70 dernières minutes. À @every 2h, la
fenêtre ne recouvre plus l'intervalle : les projets modifiés dans le trou ne sont
jamais nettoyés, et leurs packages obsolètes s'accumulent en silence. Le
worker-wrapper en supprime déjà une partie au moment du packaging, mais ce cron
est le filet de sécurité — inutile de le trouer.
Le label vit sur le conteneur app, et Ofelia relit les labels au démarrage. Il
faut donc les deux commandes :
make up
cd src && docker compose --env-file ../.env restart ofelia
docker compose --env-file ../.env logs ofelia | grep "job registered"La dernière ligne doit annoncer New job registered "runcrons" ... "@every 1h".
Un exit code 137 sur le runcrons juste avant le redémarrage est normal :
c'est l'exec en cours, tué par la recréation du conteneur app.
L'upstream ne pose de plafond que sur cinq services :
app 1000m × 10 = 9,8 Go
nginx 1000m × 10 = 9,8 Go
qgis3, qgis4, worker_wrapper 100m × 10 = 1,0 Go chacun
────────
22,5 Go
Ce total, souvent cité, est un plancher et non un plafond : db, ofelia,
memcached, smtp4dev, webdav, mkcert et mirror_transformation_grids ne
déclarent aucune limite et retombent sur le défaut du démon, json-file sans
limite. Le service le plus exposé est justement db, que le profil standalone
lance avec log_statement=all.
L'override borne tous les services que Compose gère, à 100m × 5, soit 5,9 Go
au total au lieu d'un plafond non borné :
x-logging-cap: &logging-cap
options:
max-size: "100m"
max-file: "5"Les options de journalisation sont figées à la création d'un conteneur : un
make up est nécessaire, et docker inspect <conteneur> --format '{{.HostConfig.LogConfig.Config}}' dit ce qui s'applique réellement.
Un filet reste à poser hors du dépôt. Les conteneurs QGIS éphémères sont
créés par worker_wrapper via l'API Docker, pas par Compose : aucun fichier de
ce dépôt ne les couvre, et ils ne connaissent que le défaut du démon. Sur la
machine :
sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
"log-driver": "json-file",
"log-opts": { "max-size": "100m", "max-file": "3" }
}
EOF
sudo systemctl restart dockerLe redémarrage du démon coupe brièvement tous les conteneurs. Ce défaut ne s'applique qu'aux conteneurs créés ensuite : il ne change rien à ceux qui tournent déjà, dont les options sont figées.
Trois choses, et trois seulement :
- la base —
pg_dumplogique ; - le bucket du stockage objet — miroir S3 ;
- le
.env, sans lequel les deux premiers sont inexploitables (SECRET_KEYetSALT_KEYdéchiffrent les champs chiffrés de la base).
Les grilles PROJ (~850 Mo) et les images sont intégralement reconstructibles.
Deux ordonnanceurs, pour une raison précise :
| Quoi | Par qui | Quand |
|---|---|---|
pg_dump -Fc + purge à 14 jours |
ofelia, job-exec sur db (labels de l'override) |
02:30 |
miroir du bucket + copie du .env |
crontab utilisateur, make backup |
02:45 |
La ligne de crontab, en absolu puisque cron ne se place nulle part :
45 2 * * * make -C /opt/docker/qfieldcloud-stack backup >> /opt/docker/qfieldcloud-stack/backups/backup-storage.log 2>&1Elle passe par le Makefile et jamais par scripts/backup-storage.sh :
l'emplacement du script reste ainsi un détail interne. Le déplacer ne casserait
pas une ligne de crontab qui, elle, ne préviendrait personne — elle échouerait à
2 h 45 dans un fichier de log que personne ne lit.
Pourquoi pas ofelia pour les deux : en 0.3.18, un job job-run déclaré par
label n'est jamais enregistré — aucune erreur, il n'apparaît simplement pas
dans les New job registered du journal. Et les labels sont lisibles par
docker inspect : la clé secrète du stockage objet n'a rien à y faire.
Les fichiers atterrissent dans backups/ (ignoré par git), d'où la sauvegarde
du conteneur les emporte hors machine.
scripts/backup-storage.sh lit quatre variables qui n'existent pas chez l'upstream
et ne servent qu'à lui — l'application, elle, lit STORAGES :
| Variable | Valeur |
|---|---|
S3_BACKUP_ENDPOINT |
URL de l'API S3 |
S3_BACKUP_ACCESS_KEY, S3_BACKUP_SECRET_KEY |
Une clé en lecture suffit |
S3_BACKUP_BUCKET |
Le bucket_name de STORAGES |
Rien là-dedans n'est propre à un fournisseur : c'est du mc mirror standard. En
profil standalone (rustfs embarqué), pointez l'endpoint sur
${STORAGE_API_BIND_IP}:${OBJECT_STORAGE_API_PORT} et réutilisez
OBJECT_STORAGE_ROOT_USER / OBJECT_STORAGE_ROOT_PASSWORD. Le script refuse de
démarrer, en nommant les variables fautives, si l'une manque ou est restée à
change_me.
Ces quatre variables figurent dans IGNORED_VARS du Makefile : check_envvars.py
n'analyse que les fichiers Compose et les signalerait comme orphelines.
Le miroir tourne avec --remove : un objet supprimé en amont disparaît de la
copie au passage suivant. C'est voulu, mais cela suppose que quelque chose garde
un historique de backups/ — ici la sauvegarde du conteneur. Sans cet historique
derrière, ce miroir ne protège pas d'une suppression accidentelle : il la
recopie fidèlement. Retirez --remove si vous n'avez rien de tel.
make restore-testRestaure le dernier dump dans une base jetable, compare les effectifs table par table avec la production, vérifie que PostGIS est bien là, puis supprime la base. Une sauvegarde dont on n'a jamais tenté la restauration n'est pas une sauvegarde.