Aller au contenu
  1. Posts/

QFieldCloud auto-hébergé [A08] : composer un portail sans toucher au sous-module

Sommaire
QFieldCloud auto-hébergé - Cet article fait partie d'une série.
Partie 13: Cet article

À qui s’adresse cette annexe
#

L’annexe précédente s’arrêtait sur une question : un compte ordinaire n’a nulle part où aller sur le web, le laisse-t-on ainsi ? Cette annexe répond en écrivant les pages qui manquent. Le principe est celui du thème — tout vit un cran au-dessus du sous-module — appliqué cette fois à des vues, des gabarits et des règles d’accès.
QFieldCloud auto-hébergé [A07] : l'upstream n'a pas d'espace utilisateur

QFieldCloud auto-hébergé [A07] : l'upstream n'a pas d'espace utilisateur

Annexe technique de l’épisode 5. Le dépôt open source livre l’admin Django et l’API, pas le portail de app.qfield.cloud. Ce que ça change pour vos utilisateurs, la boucle de redirection qui les accueille, et ce que vaut vraiment le plan community. L’annexe suivante en tire les conséquences.

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.

Une seconde route est reprise, plus tard : 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.
Page d'accueil du portail auto-hébergé, servie à la place du RedirectView de l'upstream
La page d’accueil du portail : 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:ro

et 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.

Fiche d'un projet dans le portail : les onglets varient selon les permissions du compte
La fiche d’un projet : les onglets et actions affichés sont exactement ceux que les fonctions de 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.

Attention : 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.

Création d'une organisation dans le portail : la liste déroulante du rôle par défaut des membres, ouverte sur les cinq rôles
Le rôle par défaut des membres, à la création d’une organisation. Choisir 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.

Page exploitant : quotas accordés, stockage occupé et surengagement, par compte
La page exploitant. En tête, les quatre chiffres de l’instance : l’occupé réel (paquets QField compris), la part que les quotas facturent, le total promis aux comptes et la capacité déclarée. Dessous, chaque compte face à son quota, puis le catalogue des plans. Sur l’instance : /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_URLCONF se compose comme DJANGO_SETTINGS_MODULE. On reprend les routes de l’upstream, on en écarte une, on met les siennes devant. git -C src status reste 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_secrets n’admet que le rôle administrateur, là où can_update_project admet 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_email porte les règles et rend un message traduit, et n’est appelée nulle part.
  • default_project_role_for_members donne 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.
Documentation self-hosted de QFieldCloud

Image de couverture : Scott Blake sur Unsplash.

QFieldCloud auto-hébergé - Cet article fait partie d'une série.
Partie 13: Cet article

Articles connexes