À qui s’adresse cette annexe #
QFieldCloud auto-hébergé [A07] : l'upstream n'a pas d'espace utilisateur
Le levier : une URLconf composée #
Le thème avait montré qu’on pouvait remplacer le module de réglages sans toucher au sous-module, en désignant le sien par DJANGO_SETTINGS_MODULE. ROOT_URLCONF obéit à la même logique : c’est un chemin d’import, et rien n’oblige à ce que ce soit celui de l’upstream.
# theme/urls_custom.py — chez nous, monté en lecture seule
from django.urls import include, path
from qfieldcloud.urls import urlpatterns as upstream_urlpatterns
urlpatterns = [
path("", include("qfieldcloud.portal.urls")),
*(
route
for route in upstream_urlpatterns
if getattr(route, "name", None) != "index"
),
]Une douzaine de lignes de code. On reprend toutes les routes de l’upstream, on en écarte une seule, et on met les siennes devant.
Cette route unique est index : un RedirectView vers l’admin. C’est elle, et elle seule, qui causait la boucle de redirections de l’annexe précédente. Comme notre portail reprend le nom index pour sa page d’accueil, tous les {% url 'index' %} des gabarits de l’upstream continuent de résoudre, et LOGIN_REDIRECT_URL — qui vaut ce nom-là — envoie désormais vers une page qui existe. La boucle ne se corrige pas : elle disparaît, parce que son ingrédient a changé de destination.
a/<user>/<projet>/, qui redirigeait elle aussi vers l’admin — donc vers une page interdite pour un compte ordinaire. C’est tout : deux routes reprises sur les vingt-trois entrées de premier niveau que déclare l’upstream — plus de trois cents une fois les inclusions dépliées.
qfieldcloud.portal reprend le nom index et remplace le RedirectView vers l’admin. La boucle de l’annexe A07 disparaît d’elle-même. Sur l’instance : /.Une application Django sans modèle #
Le reste du portail est une application Django ordinaire, montée dans l’image à côté de celles de l’upstream :
# docker-compose.override.yml — le seul fichier Compose qui nous appartient
volumes:
- ../theme/portal:/usr/src/app/qfieldcloud/portal:ro
- ../theme/urls_custom.py:/usr/src/app/qfieldcloud/urls_custom.py:roet déclarée dans le module de réglages :
# theme/settings_custom.py
INSTALLED_APPS = [*INSTALLED_APPS[:-1], "qfieldcloud.portal", INSTALLED_APPS[-1]]
ROOT_URLCONF = "qfieldcloud.urls_custom"L’insertion en avant-dernière position n’est pas un détail : django_cleanup doit rester la dernière application installée, elle s’accroche aux signaux de suppression de fichiers et veut passer après tout le monde.
Le point important est ailleurs. Cette application n’a aucun modèle. Elle n’a donc aucune migration, et rien à réconcilier le jour où le sous-module change de tag. Tout ce qu’elle affiche appartient déjà à qfieldcloud.core, qfieldcloud.project ou qfieldcloud.subscription ; elle ne fait que des pages. APP_DIRS étant à True chez l’upstream, ses gabarits et son statique sont trouvés sans une ligne de configuration supplémentaire.
La règle qui tient tout : ne jamais réécrire une règle d’accès #
C’est la décision structurante de ce montage, et celle qui décide s’il vieillira bien.
L’upstream porte ses autorisations dans core/permissions_utils.py, sous forme de fonctions ordinaires : can_read_files, can_list_jobs, can_read_deltas, can_read_collaborators, can_update_user. L’API les applique. Le portail applique exactement les mêmes, et — c’est la seconde moitié de la règle — ce sont ces mêmes fonctions qui décident d’afficher l’onglet ou le lien.
class ProjectMixin(LoginRequiredMixin):
permission_check = staticmethod(perms.can_retrieve_project)
def dispatch(self, request, *args, **kwargs):
if not self.permission_check(request.user, self.get_project()):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)Un onglet visible est un onglet accessible. Écrire la garde avec une fonction et l’affichage avec une autre — même si les deux donnent aujourd’hui le même résultat — c’est planter la divergence pour le jour où l’upstream fera bouger l’une des deux.
Cette règle n’est pas une coquetterie : elle fait apparaître des distinctions qu’on n’aurait pas devinées. Sur la fiche d’un projet, deux onglets voisins ne demandent pas les mêmes droits —
| Onglet | Fonction de garde | Rôles admis |
|---|---|---|
| Réglages | can_update_project |
administrateur et gestionnaire |
| Secrets | can_read_project_secrets |
administrateur seulement |
Un gestionnaire règle donc le projet sans jamais voir ses identifiants de base de données, et l’onglet Secrets disparaît de sa vue. Personne n’a décidé cela en écrivant le portail : c’est la règle de l’upstream, rendue visible.
Trois conséquences pratiques, qui se voient à l’usage :
Un projet hors de portée rend 404, pas 403. Le projet est cherché dans Project.objects.for_user(user, skip_invalid=True), le queryset annoté de l’upstream. S’il n’y est pas, on ne confirme pas son existence.
Le téléchargement d’un fichier ne passe par aucune vue à nous. Le lien vise filestorage_crud_file, l’endpoint de l’API : SessionAuthentication est dans les classes d’authentification par défaut, le cookie suffit donc, can_read_files est revérifié côté API, et le fichier sort par X-Accel-Redirect. Écrire une vue de téléchargement aurait été écrire une seconde règle d’accès.
Un secret s’ajoute et se retire, il ne se modifie pas. Secret.value est un EncryptedTextField que rien ne relit en clair ; proposer une édition supposerait de réafficher la valeur. L’avertissement de l’upstream mérite au passage d’être lu plutôt que recopié : tout compte autorisé à envoyer des fichiers peut en pratique atteindre ces valeurs. Le chiffrement protège la base, pas les collaborateurs les uns des autres — donnez à ces identifiants les droits les plus étroits possible.
permissions_utils autorisent — un onglet visible est un onglet accessible. Sur l’instance : /a/<compte>/<projet>/.Du code écrit pour un frontal qui n’existe pas #
En cherchant comment ajouter un collaborateur proprement, on tombe sur ceci :
# src/docker-app/qfieldcloud/project/utils/projects_utils.py — upstream
def create_collaborator_by_username_or_email(project, username, created_by):
"""..."""Cette fonction gère le nom d’utilisateur ou l’adresse e-mail, les équipes, le cas de l’organisation, l’invitation d’un inconnu, et renvoie (succès, message) avec un message déjà traduit. Elle est appelée nulle part dans le dépôt open source : c’est un reste du frontal fermé. L’appeler plutôt que de réécrire ses règles est le choix le plus fidèle possible — et le message qu’elle rend s’affiche tel quel.
C’est ainsi qu’on découvre une règle qui surprend :
# core/permissions_utils.py — check_can_become_collaborator, branche « else »
else:
# Rules for private projects
if not project.is_public:
if not user.useraccount.current_subscription.plan.is_premium:
raise ExpectedPremiumUserError(...)Ce contrôle est dans la branche else — celle des projets dont le propriétaire est une personne. Un projet appartenant à une organisation n’y passe jamais. Et le plan testé est celui du collaborateur, pas celui du propriétaire. Relevé sur les quatre combinaisons :
| Projet | Ajout d’un collaborateur |
|---|---|
| privé, propriétaire personne | refusé si son plan n’est pas premium |
| public, propriétaire personne | accepté |
| privé, propriétaire organisation | accepté |
| public, propriétaire organisation | accepté |
Le portail affiche l’avertissement dans le seul cas qui mord. C’est le genre de nuance qu’on ne lit pas dans un nom de fonction.
create_collaborator_by_username_or_email lève une ValueError si project.owner est une instance de Person — elle filtre Team.objects sur team_organization=project.owner, et Django refuse le type. Le portail y échappe parce qu’il charge le projet avec select_related("owner"), qui rend un User nu. Un accès direct au modèle, lui, passe par le manager polymorphe et casse.
Donner accès à une flotte de téléphones sans toucher aux projets #
Le besoin est courant : une équipe, des projets en lecture seule sur les téléphones, personne à déclarer projet par projet. La réponse tient dans un champ de l’organisation, default_project_role_for_members, et elle n’est pas celle qu’on attend.
Ce champ est lu par une vue SQL, projects_with_roles_vw, qui accorde le rôle sans créer la moindre ligne de collaborateur :
organization_member AS (
SELECT 6 AS rank, P1."id", OM1."member_id",
O1."default_project_role_for_members" AS "name",
'organization_member' AS "origin"
FROM "core_organizationmember" OM1
INNER JOIN "core_organization" O1 ON (O1."user_ptr_id" = OM1."organization_id")
INNER JOIN "project_project" P1 ON (P1."owner_id" = O1."user_ptr_id")
WHERE OM1."role" != 'admin'
AND O1."default_project_role_for_members" IS NOT NULL
)Le rôle n’étant pas un ProjectCollaborator, il ne passe pas par check_can_become_collaborator — donc pas par le contrôle premium. Un projet privé d’organisation, un plan non premium, un membre : le projet est visible, en reader, origine organization_member, zéro ligne de collaborateur.
Ce que reader permet exactement :
| Action | reader |
reporter |
|---|---|---|
| Lire les fichiers, ouvrir le projet | oui | oui |
| Télécharger le paquet dans QField | oui | oui |
| Déclencher un packaging | oui | oui |
| Renvoyer des saisies (deltas) | non | oui |
| Pousser depuis QFieldSync | non | oui |
Le troisième point n’est pas une évidence : can_create_jobs accepte tous les rôles pour un job de type PACKAGE. Un lecteur peut donc réclamer un paquet frais, ce qui est exactement ce qu’il faut pour qu’un téléphone se mette à jour.
Reader accorde la lecture de tous ses projets à chacun de ses membres — par la vue SQL projects_with_roles_vw, sans une seule ligne de collaborateur ni passage par le contrôle premium. Sur l’instance : /organizations/new/, puis /o/<organisation>/settings/ pour le changer ensuite.Les quotas deviennent une valeur versionnée #
L’annexe précédente disait que les plans s’éditent dans l’admin. C’est vrai, et insuffisant : community et organization sont créés par une migration de l’upstream qui ne les crée que s’ils n’existent pas. Une instance neuve hérite donc toujours des valeurs d’OPENGIS.ch — 10 Go par personne, dix versions gardées par fichier — et un réglage fait dans l’admin ne se rejoue pas. Remontez l’instance ailleurs et vous repartez du défaut sans que rien ne vous le rappelle.
D’où un dictionnaire dans le module de réglages, et une commande de gestion à nous qui l’applique :
$ make plans-dry
community storage_mb 10000 -> 500
community storage_threshold_warning_bytes 2000000000 -> 100000000
community storage_keep_versions 10 -> 3
community is_premium False -> True
organization storage_mb 5000 -> 10000
organization max_organization_members -1 -> 25
…
9 changement(s), rien écrit (--dry-run)
La commande ne crée aucun plan — ceux-là appartiennent à l’upstream —, n’écrit que les champs déclarés, et est idempotente. Plan.save() appelant full_clean(), une incohérence est refusée avant d’entrer en base : c’est ainsi qu’on apprend que storage_threshold_warning_bytes est un nombre d’octets restants et non un pourcentage, et qu’on ne peut pas réduire un quota sans réduire ses seuils.
Trois choses valent d’être sues avant de choisir des chiffres.
community et organization ne sont pas deux échelons. 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 ; on ne « passe » pas de l’un à l’autre.
Le quota suit le propriétaire du projet. 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 vos projets appartiennent à une organisation, les quotas personnels ne seront jamais consommés — inutile de les dimensionner généreusement.
Deux réglages ne limitent rien. job_minutes et synchronizations_per_months sont déclarés par l’upstream et lus nulle part dans son code. Les régler ne change rien ; le catalogue de la page des quotas le dit, pour ne pas laisser croire à des garde-fous inexistants.
Le stockage : ce qui est promis contre ce qui est occupé #
Une page réservée à l’exploitant rassemble tous les comptes, leur plan et leur remplissage. Elle n’est pas un doublon de l’admin, qui fait très bien l’écriture — changer un plan, ajouter du stockage, filtrer, chercher. Elle donne ce que l’admin a renoncé à donner, et la preuve est dans son propre code :
# src/docker-app/qfieldcloud/core/admin.py — PersonAdmin
list_display = (
"username",
...
# "storage_usage__field",
)Commenté. La consommation s’affiche sur la fiche d’un compte, jamais dans la liste, parce qu’un agrégat par ligne coûte cher. La page la calcule donc en requêtes groupées pour tous les comptes — mesuré à neuf requêtes, que l’instance ait dix ou cent cinquante comptes.
Ce qu’elle affiche compte deux mesures, et la distinction n’est pas comptable :
- ce qui occupe le stockage objet — tous les fichiers, paquets compris ;
- ce que les quotas facturent — les seuls fichiers de projet.
UserAccount.storage_used_bytes ne compte que les PROJECT_FILE. Or le bucket porte aussi les paquets préparés pour QField, refaits à chaque packaging : ils occupent la place sans entrer dans le quota de personne, donc sans que personne ne soit averti. Sur un usage de flotte, ils pèsent autant que les projets. C’est donc le total, paquets compris, que la page compare à la capacité.
Cette capacité, enfin, est déclarée et non mesurée. Les fichiers vivent dans un bucket S3 dont Django n’a aucun moyen de connaître l’espace libre — cela relève de la supervision de l’hôte. Ce que la page calcule vraiment, c’est le surengagement : la somme des quotas accordés 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 — 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.
/plans/, réservée aux comptes portant la permission subscription.view_subscription.Les pièges qui coûtent chacun une soirée #
Aucun n’est propre à QFieldCloud ; tous se paient comptant.
Le commentaire de gabarit {# … #} ne vaut que sur une ligne. Sur plusieurs, Django ne le reconnaît pas et le recopie dans la page, à la vue des visiteurs. Le commentaire multiligne, c’est {% comment %}.
DEBUG=0 active le loader de gabarits en cache. Un fichier modifié dans un dossier monté n’est pas relu : 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.
User.objects.get_queryset() appelle select_subclasses(). Chaque ligne est reconstruite en Person ou Organization ; le select_related est bien émis, mais l’instance rendue n’est plus celle sur laquelle il a été résolu, et son cache est perdu. Coût mesuré sur la page des quotas : 2,7 requêtes par compte, jusqu’à ce qu’on parte de UserAccount — qui n’a pas cette mécanique, et qui est de toute façon le vrai sujet, un plan appartenant au compte et non à la personne.
Toutes les exceptions de l’upstream ne sont pas des ValidationError. ReachedMaxOrganizationMembersError est une QFieldCloudException, levée depuis clean() et donc pas convertie par full_clean(). Sans ce second type dans la capture, atteindre le plafond de membres d’une organisation sort en 500.
Et un piège de banc d’essai, celui-là coûteux à diagnostiquer. now() de PostgreSQL est l’heure de début de transaction, or la vue current_subscriptions_vw filtre active_since < now(). Un abonnement créé pendant la transaction d’un test n’y entre donc pas toujours : la chaîne de permissions juge le rôle invalide et la vue rend 404, de façon intermittente. Mesuré : quatre échecs sur vingt dans une transaction englobante, zéro sur quinze sans.
Ce n’est pas un défaut du produit — en service, l’organisation est créée par une requête et le projet consulté par une autre. Mais si vos tests enveloppent tout dans un atomic() qu’ils annulent, antidatez l’abonnement après avoir créé une organisation.
Ce qu’il faut retenir #
-
ROOT_URLCONFse compose commeDJANGO_SETTINGS_MODULE. On reprend les routes de l’upstream, on en écarte une, on met les siennes devant.git -C src statusreste vide. - Une application Django sans modèle n’a pas de migration, donc rien à réconcilier au prochain tag du sous-module.
- N’écrivez jamais une règle d’accès deux fois. La fonction qui garde la page est celle qui décide d’afficher son lien ; sinon la divergence arrive le jour où l’upstream bouge.
- Un gestionnaire de projet règle sans voir les secrets.
can_read_project_secretsn’admet que le rôle administrateur, là oùcan_update_projectadmet aussi le gestionnaire — une distinction qu’on ne devine pas et que le portail rend visible. - Cherchez le code écrit pour le frontal fermé.
create_collaborator_by_username_or_emailporte les règles et rend un message traduit, et n’est appelée nulle part. -
default_project_role_for_membersdonne accès à une flotte sans une ligne de collaborateur, et sans passer par le contrôle premium — c’est une vue SQL qui l’accorde. - Les quotas d’une instance sont une valeur versionnée, pas un réglage d’admin : un réglage d’admin ne se rejoue pas.
- Le stockage promis n’est pas le stockage occupé, et l’occupé n’est pas ce que les quotas facturent. Les paquets QField remplissent le disque sans entrer dans le quota de personne.
Image de couverture : Scott Blake sur Unsplash.