Introduction

L'installeur Debian (d-i pour les intimes) est le programme qui sert à installer le système d'exploitation GNU/Linux Debian sur une machine (physique ou virtuelle) à partir d'un support d'installation qui peut être un CD, une clef USB ou une image accessible sur un réseau IP. Si vous avez déjà installé manuellement Debian sur une machine vous avez déjà forcément utilisé ce logiciel qui permet de préconfigurer le système d'exploitation sur un grand nombre de points avant que celui-ci ne soit pleinement opérationnel à l'issue du processus.

L'installeur Debian vous offre une interface utilisateur pour règler les paramètres tels que la langue du système, le partitionnement des périphériques de masse, la configuration du réseau ainsi que de choisir quels sont les groupes de paquets qui seront installés en plus du système de base. Il suffit de répondre aux questions des différentes boîtes de dialogue pour obtenir un système bien paramétré.

Mais si vous devez déployer Debian sur une centaine de machines avec pour chacune d'entre elles une configuration adaptée, allez-vous vous taper cent fois les réponses manuelles dans l'installeur Debian ? Certains créeront des images pré-installées qui seront recopiées bits à bits sur le support de stockage (processus dit de "Ghost" de machine) mais cette tendance tend à diminuer.

Saviez-vous qu'il est possible d'installer un système d'exploitation GNU/Linux Debian avec une configuration personnalisée et ce de manière totalement automatisée ? Ce mécanisme porte le nom de "preseed" et il est disponible dans l'installeur Debian depuis de très nombreuses années.

Preseed, comment ça marche ?

Le principe du fichier preseed est de contenir les réponses posées en temps normal par l'installeur Debian. Celui-ci ouvre normalement tout un tas de boîtes de dialogues selon un scénario bien précis. L'intérêt du fichier preseed est de répondre à ces questions en amont. Si une boîte de dialogue dispose déjà d'une réponse, elle ne sera pas présentée à l'utilisateur. Si on travaille bien, on peut donc complètement automatiser l'installation du système sur la machine.

Le fichier preseed est un simple fichier texte qui contient des chaînes de configuration qui prennent la forme suivante:

d-i module/paramètre type_de_données contenu_de_la_réponse
  • d-i indique qu'on s'adresse à l'installeur Debian (d-i) ou un autre programme si ce dernier utilise debconf.
  • module indique quel est le module de l'installeur Debian concerné. Car l'installeur Debian fonctionne avec des modules. Par exemple un module s'occupe de la configuration réseau (netcfg), un autre du partionnement (partman), etc.
  • paramètre indique quelle est la variable qu'on souhaite pré-renseigner.
  • type_de_données indique quel est le type de la variable concernée (ex: string pour une chaîne de caractère, toggle pour cocher une case, etc.).
  • contenu_de_la_réponse contient la valeur affectée à la variable.

Ces chaînes reprennent la syntaxe Debconf pour ceux qui l'auraient remarqué.

Pour utiliser un fichier preseed, il existe plusieurs méthodes. La plus simple consiste à mettre dans l'initrd de l'image d'installation un fichier nommé preseed.cfg à la racine. Une autre méthode qui est du même niveau consiste à faire indiquer ce fichier par un serveur DHCP (en cas d'installation par le réseau). Ces techniques sont décrites dans la documentation officielle Debian et je vous invite à lire cette partie pour mieux comprendre.

Plus directement, vous pouvez utiliser l'astuce présentée ici pour inclure le fichier preseed dans l'initrd.

Voyons maintenant quelques astuces qui nous permettront de voir quels modules et quelles paramètres sont utilisés dans l'installeur Debian.

Astuce n°1: Bien configurer le clavier

Cette astuce est assez simple mais j'ai parfois eu du mal à trouver les bonnes commandes pour garantir d'avoir un clavier français (azerty) configuré au redémarrage de la machine suite à l'installation. Voici donc les chaînes de configuration:

### Localization
# Configurer la locale permet aussi de configurer
# la langue et le pays de l'OS
d-i debian-installer/locale string fr_FR.UTF-8

# Choix du clavier
# keymap est un alias de keyboard-configuration/xkb-keymap
d-i keymap select fr(latin9)
# On désactive la sélection fine de la configuration du clavier
d-i keyboard-configuration/toggle select No toggling

Astuce n°2: Désactiver la détection de réseau

Parfois, on ne souhaite pas que l'installeur fasse de la détection de réseau. C'est par exemple le cas lors d'un désastre majeur qui affecte une infrastructure et notamment son réseau. Il faut bien parfois rebâtir un serveur de base de l'infrastructure et ce, assez rapidement, sans que ce dernier ne puisse se connecter au réseau local. Cela peut également être intéressant pour remonter une machine de zéro sans le réseau.

La configuration de ce dernier peut d'ailleurs être ajoutée plus tard lors de l'installation d'un paquet de configuration ou par simple copie de fichier à l'issue du processus d'installation.

Pour y parvenir, voici les chaînes que j'utilise:

### Configuration Réseau
# On désactive la configuration réseau
d-i netcfg/enable boolean false

# Mais on doit quand même configurer le nom de machine
d-i netcfg/get_hostname string goofy
# Ainsi que le domaine
d-i netcfg/get_domain string example.com

# Ici, on définit le nom local de la machine
d-i netcfg/hostname string goofy
...
# On désactive l'utilisation de NTP pour configurer l'heure (le réseau n'est pas disponible)
d-i clock-setup/ntp boolean false

Astuce n°3: Partionnement automatique

Un des points complexes de d-i est la gestion du partionnement. En effet, on peut paramétrer totalement le programme partman de l'installeur Debian (le programme qui gère la configuration des partitions) via le fichier preseed.

Ici, nous allons aborder un point simple: on veut utiliser une table de partition au format GPT (on est en 2015 !) et tout mettre dans une seule partition.

### Partitionnement
# Nous voulons une table de partition au format GPT
d-i partman-basicfilesystems/choose_label string gpt
d-i partman-basicfilesystems/default_label string gpt
d-i partman-partitioning/choose_label string gpt
d-i partman-partitioning/default_label string gpt
d-i partman/choose_label string gpt
d-i partman/default_label string gpt
partman-partitioning partman-partitioning/choose_label select gpt

# Seul le premier disque est partionné
d-i partman-auto/disk string /dev/sda
# On partionne en "normal": pas de RAID ni de LVM
d-i partman-auto/method string regular
# Pour être sûr, on supprime une éventuelle configuration LVM
d-i partman-lvm/device_remove_lvm boolean true
# Même chose pour le RAID
d-i partman-md/device_remove_md boolean true
# Chaînes pour ne pas toucher la configuration LVM (donc pas de configuration)
d-i partman-lvm/confirm boolean true
d-i partman-lvm/confirm_nooverwrite boolean true

# L'installation sera simple: on fout tout dans une seule partition
# C'est ce que fait la recette atomic
d-i partman-auto/choose_recipe select atomic

# On valide sans confirmation utilisateur la configuration de partman
d-i partman-md/confirm boolean true
d-i partman-partitioning/confirm_write_new_label boolean true
d-i partman/choose_partition select finish
d-i partman/confirm boolean true
d-i partman/confirm_nooverwrite boolean true

# fstab utilisera des UUID plutôt que des noms de périphériques
d-i partman/mount_style select uuid

Pour un autre example, nous allons configurer un partitionnement un peu plus complexe:

  • Nous allons utiliser un disque dur complet (le premier)
  • Sur ce volume, nous allons réaliser 4 partitions (principalement en ext4):
    • 20 Go pour l'espace système.
    • 20 Go pour les répertoires home.
    • 60 Go pour le répertoire /opt.
    • 200% de la taille de la RAM et au maximum 16Go pour le swap.
d-i partman-auto/expert_recipe string                         \
      boot-root ::                                            \
              20480 20480 20480 ext4                          \
                      method{ format } format{ }              \
                      use_filesystem{ } filesystem{ ext4 }    \
                      mountpoint{ / }                         \
              .                                               \
              20480 20480 20480 ext4                          \
                      method{ format } format{ }              \
                      use_filesystem{ } filesystem{ ext4 }    \
                      mountpoint{ /home }                     \
              .                                               \
              60000 60000 60000 ext4                          \
                      method{ format } format{ }              \
                      use_filesystem{ } filesystem{ ext4 }    \
                      mountpoint{ /opt }                      \
              .                                               \
              200% 16000 16000 linux-swap                     \
                      method{ swap } format{ }                \
              .

Pour appliquer cette recette, il faut bien entendu supprimer la recette atomic du point précédent.

Ce qu'il faut retenir de cette recette:

  • Tout doir tenir sur une seule ligne. C'est pourquoi on échappe tout.
  • Chaque description de partition se termine par un caractère ..
  • Les trois chiffres en entrée de partition correspondent à la taille minimale, la taille prioritaire et la taille maximum.
  • Les tailles peuvent s'exprimer également en pourcentage de la RAM.
  • method { format } indique qu'on souhaite formatter la partition.
  • format { } effectue le formattage.
  • use_filesystem { } indique qu'on souhaite utiliser cette partition dans notre futur système d'exploitation.
  • mountpoint { xxx } permet de préciser le point de montage de la partition.
  • la méthode method { swap } permet d'indiquer qu'on utilise un swap.

On peut bien sûr aller plus loin et créer des volumes RAID ou LVM et spécifier comment faire avec. Vous pouvez lire la documentation de l'installeur Debian sur ce sujet: partman-auto-recipe.txt et partman-auto-raid-recipe.txt.

Astuce n°4: Désinstaller des paquets non indispensables

Parfois, l'installation de base de Debian peut ne pas convenir. Par exemple, en ce qui concerne l'éditeur de texte par défaut. Ce dernier est vim.tiny ce qui ne correspond pas du tout à ce que j'installe de facto sur mes machines (je suis un sale emacs fanboy). On pourrait également avoir le même raisonnement avec le MTA (Exim4) qui pour certaines configuration peut paraître "overkill" (on peut le remplacer par un MTA plus léger comme msmtp par exemple).

Voici un exemple de suppression de paquets dans un fichier preseed:

## Suppression de certains paquets
d-i preseed/late_command string \
    in-target apt-get -y purge nano; \
    in-target apt-get -y purge vim-tiny; \
    in-target apt-get -y purge vim-common

Le principe est assez simple à comprendre: on lance une commande terminale qui va se charger de supprimer ces paquets via le classique apt-get. Cette commande est unique et c'est pour ça qu'on est obligé de mettre une seule ligne qu'on peut bien entendu "échapper". Dans le cas présent, le in-target indique qu'on joue la commande dans le système installé (et non dans l'environnement de l'installeur Debian).

Si vous avez bien compris le fonctionnement de l'installeur Debian, les paquets de la catégorie "essential" sont tous installés par défaut. Cela signifie que dans notre cas, l'installeur va commencer par installer tous ces paquets et qu'il les désinstallera lorsqu'il traitera la chaîne preseed/late_command. Ce n'est pas très élégant mais c'est comme ça que ça marche. Dans la pratique, on enlève quand même assez peu de paquets "essential".

Astuce n°5: Configurer mes dépots de paquets APT

Depuis l'avènement d'httpredir, ce point n'est pas forcément très intéressant mais si vous avez des dépôts Debian personnalisés vous serrez sans doute tenté de les incorporer directement via l'installeur Debian. Voici comment y parvenir...

Avant tout, sachez que c'est complexe et qu'on ne peut pas tout faire. Le premier point est de définir un mirroir Debian. Il semble que ce soit nécéssaire pour que l'installeur Debian ne pose pas de question sur ce sujet. Il existe un certain nombre de chaînes de configuration pour cela.

Ensuite, on indique qu'on ne souhaite pas utiliser de mirroir. La configuration pré-existante n'est donc pas injectée dans /etc/apt/sources.list. Dans un troisième temps, on indique qu'on souhaite activer les services security et updates. Les adresses de ces services sont hardcodées (security.debian.org). Enfin, on peut procéder à l'ajout de notre dépôt local avec une chaîne de configuration digne de ce nom. On peut en ajouter autant qu'on veut et même y glisser des commentaires.

### Paramètres de mirroir
d-i mirror/country string manual
d-i mirror/http/hostname string httpredir.debian.org
d-i mirror/http/directory string /debian
d-i mirror/http/proxy string

### Configuration d'apt
# On indique qu'on ne souhaite pas utiliser de mirroir
d-i apt-setup/use_mirror boolean false
d-i apt-setup/no_mirror boolean true
# On active les services security et updates
d-i apt-setup/services-select multiselect security, updates
d-i apt-setup/security_host string security.debian.org
# Notez qu'on ne peut pas modifier la chaîne pour updates.
# Ensuite, on indique quel est notre dépôt local (le premier qui démarre à 0)
d-i apt-setup/local0/repository string \
       http://debian.example.com/debian stable main contrib
# On peut même y mettre un commentaire qui sera injecté dans le fichier sources.list
d-i apt-setup/local0/comment string Serveur de paquets locaux de l'organisation

Vous pouvez tester plusieurs valeurs et ajouter d'autres dépôts (les backports par exemple).

Exemple de fichier de preseed qui automatise complètement le processus d'installation

Voici un des fichiers de preseed que j'utilise pour installer mon serveur de backup. L'installation est complètement automatique, sans aucune interaction de l'administrateur (hors manipulations pour booter sur l'image d'installation). Ce serveur est destiné à être remonté "from scratch" à partir de la simple clef USB qui contient également les paquets à installer et la configuration des principaux logiciels utilisés ainsi que la configuration système.

#### Fichier de preseed pour Debian jessie
# Configurer la locale permet aussi de configurer
# la langue et le pays de l'OS
d-i debian-installer/locale string fr_FR.UTF-8

# Choix du clavier
# keymap est un alias de keyboard-configuration/xkb-keymap
d-i keymap select fr(latin9)
# On désactive la sélection fine de la configuration du clavier
d-i keyboard-configuration/toggle select No toggling

### Configuration Réseau
# On désactive la configuration réseau
d-i netcfg/enable boolean false

# Mais on doit quand même configurer le nom de machine
d-i netcfg/get_hostname string testjessie
# Ainsi que le domaine
d-i netcfg/get_domain string example.com

# Ici, on définit le nom local de la machine
d-i netcfg/hostname string goofy

### Mirror settings
# Do not configure mirror repository
#d-i apt-setup/no_mirror boolean true
d-i mirror/country string manual
d-i mirror/http/hostname string debian.proxad.net
d-i mirror/http/directory string /debian
d-i mirror/http/proxy string

### Configuration des comptes
# On indique le mot de passe root, sous forme d'un hash MD5 (echo "mypassword" | mkpasswd -s -H MD5)
d-i passwd/root-password-crypted password xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# On ne souhaite pas créer d'autres utilisateurs
d-i passwd/make-user boolean false

### Gestion de l'horloge
# L'horloge matérielle fonctionne sur la zone UTC
d-i clock-setup/utc boolean true

# La zone utilisée par l'OS sera celle de Paris
d-i time/zone string Europe/Paris

# On désactive l'utilisation de NTP pour configurer l'heure
d-i clock-setup/ntp boolean false

### Partitioning
# Nous voulons une table de partition au format GPT
d-i partman-basicfilesystems/choose_label string gpt
d-i partman-basicfilesystems/default_label string gpt
d-i partman-partitioning/choose_label string gpt
d-i partman-partitioning/default_label string gpt
d-i partman/choose_label string gpt
d-i partman/default_label string gpt
partman-partitioning partman-partitioning/choose_label select gpt

# Seul le premier disque est partionné
d-i partman-auto/disk string /dev/sda
# On partionne en "normal": pas de RAID ni de LVM
d-i partman-auto/method string regular
# Pour être sûr, on supprime une éventuelle configuration LVM
d-i partman-lvm/device_remove_lvm boolean true
# Même chose pour le RAID
d-i partman-md/device_remove_md boolean true
# Chaînes pour ne pas toucher la configuration LVM (donc pas de configuration)
d-i partman-lvm/confirm boolean true
d-i partman-lvm/confirm_nooverwrite boolean true

# L'installation sera simple: on fout tout dans une seule partition
d-i partman-auto/choose_recipe select atomic

# On valide sans confirmation la configuration de partman
d-i partman-md/confirm boolean true
d-i partman-partitioning/confirm_write_new_label boolean true
d-i partman/choose_partition select finish
d-i partman/confirm boolean true
d-i partman/confirm_nooverwrite boolean true

# fstab utilisera des UUID plutôt que des noms de périphériques
d-i partman/mount_style select uuid

### Installation du système
# On ne souhaite pas installer les paquets recommandés
# L'installation sera limitée aux paquets "essentials"
d-i base-installer/install-recommends boolean false

### Configuration d'apt
# On indique qu'on ne souhaite pas utiliser de mirroir
d-i apt-setup/use_mirror boolean false
d-i apt-setup/no_mirror boolean true
# On active les services security et updates
d-i apt-setup/services-select multiselect security, updates
d-i apt-setup/security_host string security.debian.org
# Notez qu'on ne peut pas modifier la chaîne pour updates.
# Ensuite, on indique quel est notre dépôt local (le premier qui démarre à 0)
d-i apt-setup/local0/repository string \
       http://debian.proxad.net/debian stable main contrib
# On peut même y mettre un commentaire qui sera injecté dans le fichier sources.list
d-i apt-setup/local0/comment string Serveur principal

# Pas d'envoi de rapport vers popcon
popularity-contest popularity-contest/participate boolean false

### Configuration de GRUB
# Seul Debian sera géré par GRUB
d-i grub-installer/only_debian boolean true

# Si on détecte un autre OS, on installera GRUB sur le MBR
d-i grub-installer/with_other_os boolean true

# On installe GRUB sur /dev/sda
d-i grub-installer/bootdev  string /dev/sda

## Suppression de paquets non désirés
d-i preseed/late_command string \
    in-target apt-get -y purge nano; \
    in-target apt-get -y purge vim-tiny; \
    in-target apt-get -y purge vim-common

### Fin de l'installation
# Désactivation du message indiquant que l'installation est terminée.
d-i finish-install/reboot_in_progress note

# Pas d'éjection du média d'installation (bien pour faire des tests sur une VM)
d-i cdrom-detect/eject boolean true

# Une fois l'installation terminée, on éteint la machine
d-i debian-installer/exit/poweroff boolean true

Aller plus loin: trouver les chaînes de preseed dans le code de d-i

Le guide d'installation de Debian donne un exemple assez complet de fichier preseed avec des explications dans les commentaires pour avoir une bonne idée de la majorité des chaînes de configuration. Néanmoins, il est parfois obligatoire d'utiliser une option peu courante ou non documentée dans l'exemple. C'est ce qui m'est arrivé avec les dépôts APT. J'ai dû fouiller le code de l'installeur pour avoir au moins le nom des options.

La recherche peut parfois être complexe. Le plus simple consiste à rechercher les fichiers .templates de debconf pour le module d-i que vous souhaitez explorer. Dans ces fichiers, vous trouverez le nom des variables et des paramètres ainsi que le texte des questions posées.

Pour cela vous pouvez utiliser Codesearch. Par exemple, vous souhaitez avoir plus d'informations sur les possibles questions sur le module apt-setup. Faîtes une recherche avec les bons paramètres sur Codesearch: path:/debian/* apt-setup. Vous trouverez tous les paquets qui contiennent la chaîne apt-setup dans le répertoire debian. Dans la liste des paquets, on trouve le paquet apt-setup. Ce dernier semble être le bon candidat car il est écrit dans sa description qu'il s'agit d'un module de d-i.

Si vous prenez le fichier apt-mirror-templates du répertoire debian, vous trouverez l'ensemble des chaînes de configuration disponibles pour cette partie de module. A vous de trouvez et de tester celle qui semble correspondre à vos besoins.

Veuillez noter que parfois, les chaîne de configuration ne sont pas forcément dans des fichiers avec une extension en .templates et il vous faudra fouiller dans l'arborescence debian du paquet pour trouver ce que vous cherchez.

Conclusions

Avec la méthode preseed, on peut donc installer Debian de manière complètement automatisée tout en bénéficiant de toutes les options présentes dans les boîtes de dialogues de l'installeur Debian. La plus grande difficulté sera de trouver les chaînes qui permettront de bien configurer la machine comme on le souhaite.

Attention, certains éléments ne sont pas gérés par l'installeur Debian et il faudra trouver une autre solution. Par exemple, si vous utilisez la configuration réseau via le mécanisme /etc/network, vous ne pourrez pas indiquer des scripts à lancer lors du lancement de l'interface (pre-up.d) avec l'installeur Debian. Il vous faudra un paquet de configuration ou utiliser un programme d'orchestration de configuration (Chef, Puppet, Ansible, Propellor, etc.)

Pour aller plus loin, vous pouvez utiliser deux programmes qui vous permettront d'industrialiser vos déploiements avec les fichiers preseed et même plus. Le premier se nomme simple-cdd. Il permet de construire des images ISO hybrides (bootables à partir de clef USB) à partir de simples fichiers de configuration. Vous pouvez ainsi gérer autant d'images ISO que vous voulez selon la configuration du système et les paquets que vous souhaitez utiliser. L'intérêt de simple-cdd est que l'image construite contient tous les paquets indispensables à l'installation de la machine. Vous pouvez même y ajouter des paquets que vous avez fabriqués. En une simple image ISO (ou une clef USB), vous pouvez donc reconstruire une machine "from scratch" et même sans accès au réseau local ou à Internet. Pour votre information, simple-cdd est un simple script shell qui est une surcouche à debian-cd. Ce dernier est le programme qui est utilisé pour fabriquer les images officielles de Debian et il est assez complexe à utiliser. Avec simple-cdd, quelques fichiers de configuration suffisent.

Le deuxième programme que j'utilise est l'ensemble des outils de config-package-dev qui permet de fabriquer un ou plusieurs paquets Debian dits "de configuration". Le principe est de mettre l'ensemble des fichiers de configuration spécifiques dans un répertoire de source et de créer un paquet Debian avec les outils config-package-dev. Par exemple, vous pouvez faire un paquet qui installe le fichier /etc/network/interface spécifique à une machine. L'installation de ce dernier sera gérée avec le sérieux de la gestion des paquets sous Debian. De plus, lorsque vous désinstallerez ce paquet de configuration, cette dernière reviendra à l'état initial (c'est-à-dire selon la configuration par défaut du mainteneur Debian ou la votre si elle a été customisée avant). En règle générale, je fais un paquet qui contient toute la configuration spécifique de la machine (réseau/configuration de grub/configuration du MTA système/netfilter/anonymisation IPv6/ajout de cacert dans les autorités de confiance/etc.). Ensuite, j'embarque ce paquet dans la configuration simple-cdd de la machine et à l'issue de l'installation, j'obtiens une machine prête à fonctionner et complètement configurée. Bien entendu, rien ne vous empêche de mettre ce paquet sur un dépôt local et d'orchestrer la configuration de vos machines avec. Vous pouvez même gérer ce paquet avec un gestionnaire de version pour pouvoir facilement revenir en arrière.

Cette méthode d'installation automatisée présente l'avantage d'utiliser uniquement des outils Debian et reste sans doute moins complexe que des logiciels d'orchestration de configuration classiques qui imposent de mettre en place toute une infrastructure pour ça et qui ne règlent pas le problème de l'installation initiale. C'est assez intéressant lorsqu'on gère des serveurs physiques hétérogènes notamment car ces derniers ne sont pas clonables facilement.

Posted lun. 05 oct. 2015 18:28:00

Introduction

This article is the next of my serie of articles about QGIS forms. You can read the previous one here. Today we will focus on photo form controls. In QGis, they are dedicated to image viewing. Change your Edit Widget to Photo. You can then edit the size of the photo display (seems to be required if you want to effectively display the photo on the form).

Image of Photo widget configuration

Nice widget, isn't it ? Be we can go further...

Custom UI

With this configuration, QGis is able to render photos on your Widget by its own means. You do not need to code. But you perhaps want to go further and show some url links for documents that are not photos or images but other things (think about a photo inside a PDF file or a video) ? Or perhaps, you would like to use a Photo widget into a custom UI ? Let's see how it can be achieved.

The first thing to do is to build a custom form with qtcreator (designer-qt4 i Debian Jessie). You'll have to create a QWidget. It will be named with the field you have used to store the path to the photo in the data layer. This QWidget will hold a QGridLayout. Inside this layout, put a QLabel and name it PhotoLabel (required name). It will be used to display the photo. Then, add a QLineEdit named lineEdit (required name). It will be used to show the path of the photo. You also need to add a QPushButton named FileChooserButton in the layout.

If you want to show an URL, add a QLabel and name it with the name of your choice. You'll have to be careful to add two properties for this QLabel:

  • openExternalLinks: checked (otherwise, you will not be able to open the file with a click).
  • textInteractionFlags: LinksAccessibleByMouse.

Image of Qt4 Designer for custom Photo form

With this form, QGIS will be able to display the photo in the fields and the widget will work as if it was auto-generated by QGIS.

Code

Time to dive into Python... We want to show a URL to open an external document. This document will be our image (that is presently shown) or another type of document (PDF/video/sound/etc.).

def manageIll(dialog, layerid, featureid):
    '''Handle link for files in Illustration'''
    # Find file value:
    child = dialog.findChild(QLineEdit, u"lineEdit")
    if child:
        child.textChanged.connect(partial(modifyPhoto, dialog))
        modifyPhoto(dialog)

def modifyPhoto(dialog):
    '''Function to buidl link for files in Illustration form'''
    # Find the fie path value
    lineEdit = dialog.findChild(QLineEdit, u"lineEdit")
    if lineEdit:
        fileValue = lineEdit.text()
        nullValue = QSettings().value("qgis/nullValue" , u"NULL")
        if fileValue == nullValue or fileValue is None:
            return False

        basename = os.path.basename(fileValue)
        filename = u"<a href=\"file:///{0}\">{1}</a>".format(fileValue,basename)

        # Affect the value to the URL QLabel
        urlLabel = dialog.findChild(QLabel, u"ILLURL_L")
        if urlLabel and fileValue is not None:
            urlLabel.setText(filename)

        # Determine if we can produce a QPixmap from the file
        if not QPixmap().load(fileValue):
            photoLabel = dialog.findChild(QLabel, u"PhotoLabel")
            if photoLabel:
                photoLabel.setText(u"This file is not a photo !")

The manageIll function will connect the signal textChanged of the QLineEdit named "lineEdit" (remember the UI part) to a dedicated function named modifyPhoto. modifyPhoto will take the value of "lineEdit" and will transform it into an "URL" that will be shown into a QLabel named ILLURL_L (see UI part). At the end, we try to find if the filepath from "lineEdit" is a valid image and if it is not the case, we display a text inside the dedicated photo QLabel (named PhotoLabel).

You'll need to change the objectName property to reflect the widgets names found in your form (and in your layer of course).

Conclusion

This article just show an easy way to add a QGis photo form control inside a custom form. You can also add code to change the default behaviour of your photo control with Python. Whitout doubt, I am sure that one day, the QGIS developpers will implement a better external documents behaviour in QGIS...

Posted sam. 17 oct. 2015 17:52:00

Introduction

This article is the last of my series of articles about QGIS forms. You can read the previous one here.

Today, we will focus on n,n relations. n,n is a database syntax to tell that one object of a table can have multiples values linked to another reference table. On a database schema, it looks like the following:

Image of n,n table schema

You can see that n,n relations are materialized by two 1,n relations, involving three tables:

  • The first data table (ANALYSIS).
  • The second data table (PESTICIDE).
  • A relation table (ANALYSIS_PESTICIDE) that make the connexion between the two tables mentionned above.

With such a mechanism, we are able to link multiple pesticides to multiple analysis without having to store a list into the ANALYSIS table. Instead, the central table (ANALYSIS_PESTICIDE) is used to make a link. Everytime you would like to implement n,n relations, just think about this intermediate table.

For the moment, QGIS doesn't support n,n tables on forms (but it supports 1,n with sub-forms). There is no control dedicated to that. But we can code it !

User Interface

What to do ?

Before diving into code, we need to solve the UI problem. What control are we going to use ? As we are not constrained by QGIS existing controls, we can imagine the following:

  • The main source of data is ANALYSIS: we want to add multiple pesticides from one analysis.
  • We only need the relevant information: which pesticides have been measured during the analysis.
  • If you have a catalog of ten thousand of pesticides, there is no need to show the whole list on the analysis form.
  • So we need a dedicated dialog for selecting pesticides. In this sub-form, the list of all the pesticides will be presented to the user in order to make a choice.
  • The pesticides table will store all informations about pesticides. We need to have a list of the pesticide's names and be able to choose multiple pesticides entries.
  • The simplest way to achieve this is to use a QListWidget with a checkbox for each pesticide entry. If the checkbox is checked, this analysis has this pesticide.
  • We need a "search engine" to quickly find pesticides from their names if the PESTICIDE table is huge.
  • Once selected, we need to only show the selected pesticides into analysis form.

Main form: analysis form

Here is a mockup of the analysis form:

Image of analysis form mockup

You can see that the last form control is a bit special: it is our QListWidget which lists all the pesticides that have been found in the displayed analysis. The list only shows the relevant information and when the control is not long enough, you have a vertical scrollbar. Elements of the list are selectable (multi or mono depending on QListWidget attributes) and you can copy/paste them in the clipboard. Whenever you need to have some information about the analysis, everything is displayed on only one form.

This form can't be autogenerated by QGIS because the QListWidget is mandatory and there is no field to hold pesticides values in ANALYSIS table. For n,n relations you have to use a custom ui form with qt4-designer like the following:

Image of Qtdesigner of the custom form for displaying pesticides results

Furthermore, printing information in the QListWidget of this form needs some code for a dedicated function in Python just to retrieve the good results from the ANALYSIS_PESTICIDE table. We will study this in the code part below.

What about editing pesticides into this analysis ?

Pesticide form

When you click on the "Modify" button, the following dialog will be printed:

Image pesticide dialog box

The dialog box is very simple: on the top, you have a QLineEdit which will be used to type the name of the pesticide you want. The main control of the dialog is a QListWidget with the name of all the pesticides. There is a checkbox to add or remove pesticides to the analysis. Checking a box will add the pesticide into the ANALYSIS_PESTICIDE table, unchecking will delete it from the table. With this dialog, you can add or delete as many pesticides you want for one analysis without bothering with the other controls.

Whenever your modifications are done, results need to affect the ANALYSIS_PESTICIDE table and it also needs some dedicated code.

Using QGIS relations and value relations

Now that the concepts of the UI part have been elaborated, we need to go further. Our approach seems to be good with one n,n relation. But imagine that you try to build a true complex GIS application that involves about 50 n,n relations. you can't put everything into code, it will take too much time to develop and to maintain. You will also make a lot of mistakes to try to keep the names of all the controls into Python code. So we need to be a little bit more generic.

QGIS has already a mechanism to handle 1,n relations: it is called "Relations". Relations are a way for QGIS to know that a table is linked to another. It is used to show sub-forms inside a parent form. So, could we try to use two 1,n relations and deal with it into code ? I have tried this but there is something more efficient. Creating two relations (first from ANALYSIS to ANALYSIS_PESTICIDE and second from PESTICIDE TO ANALYSIS_PESTICIDE) seems to be the good way but you have to remember what we want. We would like to display a list of pesticides names in the control and store the ID_PESTICIDE into ANALYSIS_PESTICIDE and there is nothing in a QGIS relation to tell that you want to display a field.

But there is something inside QGIS to deal with and it's called "Value Relation". When you define a Value Relation for a field, you are linking values from another layer and you can choose what field to show and what field is the ID. So instead of creating two relations, we could do the following:

  • Create only one relation: from ANALYSIS to ANALYSIS_PESTICIDE.
  • Create a Value Relation control for ID_PESTICIDE of ANALYSIS_PESTICIDE towards ID_PESTICIDE of table PESTICIDE and show PESTICIDE.NAME (this is the field we want to display in the n,n sub dialog).

Here is the definition of the relation:

Image pesticide dialog box

The name/id of the relation should be the same than the QListWidget of the custom .ui file.

Here is the definition of the Value Relation:

Image pesticide dialog box

This is a classic Value Relation configuration. It is made in the ANALYSIS_PESTICIDE table on the ID_PESTICIDE attribute.

The relation is used as the following:

  • the code launched when the ANALYSIS form is opened knows that the layer of the form is ANALYSIS.
  • with this name, it is easy to search inside the project relations (there is an API for that) where ANALYSIS is the parent layer of another.
  • code will take the name of the child layer (ANALYSIS_PESTICIDE) and extract the shared attributes (ID_ANALYSIS).
  • Now, we know from what table we must read the results to update the form (this will be ANALYSIS_PESTICIDE) and we also know what is the attribute to filter (ID_ANALYSIS) to have the corresponding value of the analysis that is displayed in the form.

The Value Relation is used as the following:

  • We already know that the intermediate table is ANALYSIS_PESTICIDE.
  • We search for its Value Relation form controls.
  • In its configuration, we find what is the last table (PESTICIDE), what field is displayed (NAME) and what field is used as ID.

The relation will be used inside ANALYSIS form to make the link between ANALYSIS and ANALYSIS_PESTICIDE. The QListWidget needs to have the name of the relation for the code to have a way to find which tables are involved. Value Relation is for the other part of the n,n relation: ANALYSIS_PESTICIDE to PESTICIDE.

We are done with the concepts !

Show me the code !

n,n dedicated dialog

Time to dive into Python...

First thing to do: the pesticide UI ! With PyQt you can create a .ui file and build it with qt4-designer. But loading a .ui file from Python can be unsafe: you have to deal with the file location. As the pesticide UI is very trivial, I prefer to build it with code. So, here is the Python code of the dialog:

   def setupUi(self):
        '''Builds the QDialog'''
        # Form building
        self.setObjectName(u"nnDialog")
        self.resize(550, 535)
        self.setMinimumSize(QtCore.QSize(0, 0))
        self.buttonBox = QtGui.QDialogButtonBox(self)
        self.buttonBox.setGeometry(QtCore.QRect(190, 500, 341, 32))
        self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
        self.buttonBox.setStandardButtons(QtGui.QDialogButtonBox.Cancel|QtGui.QDialogButtonBox.Ok)
        self.buttonBox.setObjectName(u"buttonBox")
        self.verticalLayoutWidget = QtGui.QWidget(self)
        self.verticalLayoutWidget.setGeometry(QtCore.QRect(9, 9, 521, 491))
        self.verticalLayoutWidget.setObjectName(u"verticalLayoutWidget")
        self.verticalLayout = QtGui.QVBoxLayout(self.verticalLayoutWidget)
        self.verticalLayout.setMargin(0)
        self.verticalLayout.setObjectName(u"verticalLayout")
        self.horizontalLayout = QtGui.QHBoxLayout()
        self.horizontalLayout.setObjectName(u"horizontalLayout")
        self.label = QtGui.QLabel(self.verticalLayoutWidget)
        self.label.setObjectName(u"label")
        self.horizontalLayout.addWidget(self.label)
        self.SEARCH = QtGui.QLineEdit(self.verticalLayoutWidget)
        self.SEARCH.setObjectName(u"SEARCH")
        self.horizontalLayout.addWidget(self.SEARCH)
        self.verticalLayout.addLayout(self.horizontalLayout)
        self.horizontalLayout_2 = QtGui.QHBoxLayout()
        self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
        self.LIST = QtGui.QListWidget(self.verticalLayoutWidget)
        self.LIST.setObjectName(u"LIST")
        self.horizontalLayout_2.addWidget(self.LIST)
        self.verticalLayout.addLayout(self.horizontalLayout_2)

        self.buttonBox.accepted.connect(self.accept)
        self.buttonBox.rejected.connect(self.reject)
        QtCore.QMetaObject.connectSlotsByName(self)

Well, this is what you can have from pyuic4 from a qt4-designer .ui file. But this time you don't need the file anymore.

n,n list behaviour functions

Next thing to do is to populate the n,n dialog. We also need to add the "search engine" functions and a way to pre-check values that are in the ANALYSIS_PESTICIDE table for the current analysis. And at the end, we need to send the checked values to the main form (ANALYSIS one) in order to make the database update and to update the form control.

I've created a class for this:

class nnDialog(QtGui.QDialog):
    '''Dedicated n,n relations Form Class'''
    def __init__(self, parent, layer, shownField, IdField, initValues, search=False):
        '''Constructor'''
        QtGui.QDialog.__init__(self,parent)

        self.initValues = initValues
        self.shownField = shownField
        self.layer =  layer
        self.IdField = IdField
        self.search = search
        if self.layer is None and DEBUGMODE:
            QgsMessageLog.logMessage(u"nnDialog constructor: The layer {0} doesn't exists !".format(layer.name()),"nnForms", QgsMessageLog.INFO)

        # Build the GUI and populate the list with the good values
        self.setupUi()
        self.populateList()

        # Add dynamic control when list is changing
        self.SEARCH.textChanged.connect(self.populateList)
        self.LIST.itemChanged.connect(self.changeValues)

    def setupUi(self):
        '''Builds the QDialog'''
        # Form building
        self.setObjectName(u"nnDialog")
        self.resize(550, 535)
        self.setMinimumSize(QtCore.QSize(0, 0))
        self.buttonBox = QtGui.QDialogButtonBox(self)
        self.buttonBox.setGeometry(QtCore.QRect(190, 500, 341, 32))
        self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
        self.buttonBox.setStandardButtons(QtGui.QDialogButtonBox.Cancel|QtGui.QDialogButtonBox.Ok)
        self.buttonBox.setObjectName(u"buttonBox")
        self.verticalLayoutWidget = QtGui.QWidget(self)
        self.verticalLayoutWidget.setGeometry(QtCore.QRect(9, 9, 521, 491))
        self.verticalLayoutWidget.setObjectName(u"verticalLayoutWidget")
        self.verticalLayout = QtGui.QVBoxLayout(self.verticalLayoutWidget)
        self.verticalLayout.setMargin(0)
        self.verticalLayout.setObjectName(u"verticalLayout")
        self.horizontalLayout = QtGui.QHBoxLayout()
        self.horizontalLayout.setObjectName(u"horizontalLayout")
        self.label = QtGui.QLabel(self.verticalLayoutWidget)
        self.label.setObjectName(u"label")
        self.horizontalLayout.addWidget(self.label)
        self.SEARCH = QtGui.QLineEdit(self.verticalLayoutWidget)
        self.SEARCH.setObjectName(u"SEARCH")
        self.horizontalLayout.addWidget(self.SEARCH)
        self.verticalLayout.addLayout(self.horizontalLayout)
        self.horizontalLayout_2 = QtGui.QHBoxLayout()
        self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
        self.LIST = QtGui.QListWidget(self.verticalLayoutWidget)
        self.LIST.setObjectName(u"LIST")
        self.horizontalLayout_2.addWidget(self.LIST)
        self.verticalLayout.addLayout(self.horizontalLayout_2)

        self.buttonBox.accepted.connect(self.accept)
        self.buttonBox.rejected.connect(self.reject)
        QtCore.QMetaObject.connectSlotsByName(self)

    def changeValues(self, element):
        '''Whenever a checkbox is checked, modify the values'''
        # Check if we check or uncheck the value:
        if element.checkState() == Qt.Checked:
            self.initValues.append(element.data(Qt.UserRole))
        else:
            self.initValues.remove(element.data(Qt.UserRole))

    def populateList(self, txtFilter=None):
        '''Fill the QListWidget with values'''
        # Delete everything
        self.LIST.clear()

        # We need a request
        request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
        if txtFilter is not None:
            fields = self.layer.dataProvider().fields()
            fieldname = fields[self.shownField].name()
            request.setFilterExpression(u"\"{0}\" LIKE '%{1}%'".format(fieldname, txtFilter))

        # Grab the results from the layer
        features = self.layer.getFeatures(request)

        for feature in sorted(features, key = lambda f: f[0]):
            attr = feature.attributes()
            value = attr[self.shownField]
            element = QListWidgetItem(value)
            element.setData(Qt.UserRole, attr[self.IdField])

            # initValues will be checked
            if attr[self.IdField] in self.initValues:
                element.setCheckState(Qt.Checked)
            else:
                element.setCheckState(Qt.Unchecked)
            self.LIST.addItem(element)

    def getValues(self):
        '''Return the selected values of the QListWidget'''
        return self.initValues

The class is named nnDialog and it deals with the n,n dialog used to add/remove pesticides of the current analysis. The constructor is very simple:

  • We need to know which layer will be displayed,
  • what is the name of the field that will be displayed in the list,
  • what is the name of the field used as ID,
  • what are the values already checked (stored into ANALYSIS_PESTICIDE)
  • once everything is transmitted by arguments to the constructor, we have to create the UI (see above),
  • populate the list
  • and add dynamic controls for search QLineEdit and QListWidget.

The changeValues method is called when you check a checkBox in the list. Whenever there is action, the ID_PESTICIDE value is added/removed from initValues.

The populateList method is used when the n,n dialog is opened (called by the constructor) and whenever there is some changes in the search text bar. This method is used to populate the list:

  • List is first cleared.
  • If there is some text in the search QLineEdit, we make a request on the displayed field (NAME is our case) of the current layer (PESTICIDE) to retrieve only the correct values.
  • Otherwise, we grab all the values of the layer.
  • We sort them alphabetically.
  • And for each value, we create a QListWidgetItem (element of a list) with a checkBox.
  • If the value is in the initValues, it is checked, otherwise, it is unchecked.

nnDialog class implements all the logic of the n,n Dialog and it's code is quite generic: every parameters are transmitted by the constructor. This class is used when you click on the "Modify" button at the right of the list of pesticides in the ANALYSIS form.

But to stay generic we have also to be generic with the code which triggers nnDialog...

main form code

Last thing to do: add logic to the ANALYSIS form. Here is the code:

class nnForm:
    '''Class to handle forms to type data'''
    def __init__(self, dialog, layerid, featureid):    
        self.dialog = dialog
        self.layerid = layerid
        self.featureid = featureid
        self.nullValue = QSettings().value("qgis/nullValue" , u"NULL")
        self.search = False

    def id2listWidget(self, table, values, listWidget):
        '''Show all the selected values of a link table on a QListWidget'''
        # Find the Widget
        if listWidget is None or table is None:
            QgsMessageLog.logMessage(u"id2listWidget: We need to have a relation and a true widget !", "DBPAT", QgsMessageLog.INFO)
            return False

        # Empty the list
        listWidget.clear()

        # Get the params (for the first child table)
        if self.valueRelationParams(table):
            params = self.valueRelationParams(table)[0]
        if params is None or not params:
            QgsMessageLog.logMessage(u"id2listWidget: You need to add Value Relation to layer: {0} !".format(table.name()), "nnForms", QgsMessageLog.INFO)
            return False

        # Get target layer:
        tgtLayer = params['tgtLayer']

        # Handle values: need to escape \' characters
        values = [v.replace(u"'", u"''") if isinstance(v, basestring) else v for v in values]

        ## Then, get the real values from other-side table
        if values:
            request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
            if params[u'tgtIdType'] in (QVariant.String, QVariant.Char):
                query = u"{0} IN ('{1}')".format(params[u'tgtId'], u"','".join(values))
            else:
                query = u"{0} IN ({1})".format(params[u'tgtId'], u",".join([unicode(x) for x in values]))
            request.setFilterExpression(query)

            # and display them in the QListWidget
            for feature in tgtLayer.getFeatures(request):
                value = feature.attributes()[params[u'tgtValueIdx']]
                if value != u"NULL":
                    element = QListWidgetItem(value)
                    element.setData(Qt.UserRole, feature.attributes()[params[u'tgtIdIdx']])
                    listWidget.addItem(element)

        return True

    def valueRelationParams(self,layer):
        '''Function that returns the configuration parameters of a valueRelation as a list of dict'''
        params = []
        if layer is not None:
            for idx, field in enumerate(layer.dataProvider().fields()):
                if layer.editorWidgetV2(idx) == u"ValueRelation":
                    param = {}
                    param[u'srcId'] = field.name()
                    param[u'srcIdIdx'] = idx
                    if u"Layer" in layer.editorWidgetV2Config(idx):
                        tgtLayerName = layer.editorWidgetV2Config(idx)[u"Layer"]
                        tgtLayer = QgsMapLayerRegistry.instance().mapLayer(tgtLayerName)
                        if tgtLayer is None:
                            QgsMessageLog.logMessage(u"valueRelationParams: Can't find the layer {0} !".format(tgtLayerName), "nnForms", QgsMessageLog.INFO)
                            return False

                        param[u'tgtLayer'] = tgtLayer
                        param[u'tgtId'] = layer.editorWidgetV2Config(idx)[u"Key"]
                        param[u'tgtValue'] = layer.editorWidgetV2Config(idx)[u"Value"]

                        # Find index of all fields:
                        for indx, f in enumerate(tgtLayer.dataProvider().fields()):
                            if f.name() == param[u'tgtId']:
                                param[u'tgtIdIdx'] = indx
                                param[u'tgtIdType'] = f.type()
                            if f.name() == param[u'tgtValue']:
                                param[u'tgtValueIdx'] = indx
                        params.append(param)

        # notification
        if not params:
            QgsMessageLog.logMessage(u"valueRelationParams: There is not Value Relation for the layer {0} !".format(layer.name()), "nnForms", QgsMessageLog.INFO)

        return params

    def manageMultiple(self):
        '''Handle specifics thesaurus form'''
        # Scan all of the QgsRelations of the project
        relations = QgsProject.instance().relationManager().relations()

        for listWidget in [f for f in self.dialog.findChildren(QListWidget) if u"REL_" in f.objectName()]:
            listName = listWidget.objectName()
            if listName not in relations.keys():
                QgsMessageLog.logMessage(u"manageMultiple: There is no Relation for control {0} !".format(listWidget.objectName()), "nnforms", QgsMessageLog.INFO)
                continue

            # Find what is the table to show
            relation = relations[listName]
            shownLayer = relation.referencingLayer()

            # Find other side of n,n relation
            if self.valueRelationParams(shownLayer):
                params = self.valueRelationParams(shownLayer)[0]
            if params is None:
                continue

            # When found, we are ready to populate the QListWidget with the good values
            values = []
            if self.featureid:
                # Get the features to display
                request = relation.getRelatedFeaturesRequest(self.featureid)
                request.setFlags(QgsFeatureRequest.NoGeometry)
                for feature in shownLayer.getFeatures(request):
                    values.append(feature.attributes()[params[u'srcIdIdx']])
                self.id2listWidget(shownLayer, values, listWidget)

            buttonWidget = self.dialog.findChild(QPushButton, listName+u"_B")
            if buttonWidget:
                if self.search or self.layerid.isEditable():
                    buttonWidget.clicked.connect(partial(self.openSubform, listWidget, relation, values))
                    buttonWidget.setEnabled(True)
                else:
                    buttonWidget.setEnabled(False)
            elif DEBUGMODE:
                QgsMessageLog.logMessage(u"manageMultiple: There is no button for control {0} !".format(listName), "nnForms", QgsMessageLog.INFO)

    def openSubform(self, widget, relation, values):
        '''Open a dedicated dialog form with values taken from a child table.'''
        table = relation.referencingLayer()
        if self.valueRelationParams(table):
            params = self.valueRelationParams(table)[0]

        if params is None or not params:
            QgsMessageLog.logMessage(u"openSubform: There is no Value Relation for layer: {0} !".format(table.name()), "nnForms", QgsMessageLog.INFO)
            return False

        if widget is None:
            QgsMessageLog.logMessage(u"openSubForm: no widgets found for field {0} !".format(field), "nnForms", QgsMessageLog.INFO)

        # Open the form with the good values
        dialog = nnDialog(self.dialog, params[u'tgtLayer'], params[u'tgtValueIdx'], params[u'tgtIdIdx'], values, self.search)

        # handle results
        if dialog.exec_():
            # Get the results:
            thevalues = dialog.getValues()

            # Modify target table if we have a featureid
            if self.featureid:
                table.startEditing()
                caps = table.dataProvider().capabilities()
                ## Delete all the previous values
                if caps & QgsVectorDataProvider.DeleteFeatures:
                    request = relation.getRelatedFeaturesRequest(self.featureid)
                    request.setFlags(QgsFeatureRequest.NoGeometry)
                    fids = [f.id() for f in table.getFeatures(request)]
                    table.dataProvider().deleteFeatures(fids)

                ## Add the new values
                if caps & QgsVectorDataProvider.AddFeatures:
                    for value in thevalues:
                        feat = QgsFeature()
                        feat.setAttributes([None, self.featureid.attributes()[0], value])
                        table.dataProvider().addFeatures([feat])
                ## Commit changes
                table.commitChanges()

            # refresh listWidget aspect
            self.id2listWidget(table, thevalues, widget)

The nnForm class will manage the form of ANALYSIS (or every form that has the same class Python function).

The manageMultiple method, will "scan" the layer form to find all QListWidgets with the same name than a relation. For each of those QListWidgets, we try to find what is the intermediate table (ANALYSIS_PESTICIDE) and what is the last table (from Value Relation). Then the QListWidget is populated with the values from ANALYSIS_PESTICIDE (and by retreiving the pesticides names). At last, the QPushButton that is named like the relation (+_B) is connected to a method which will open a nnDialog (see previous chapter).

OpenSubForm method is used to create the nnDialog (from the same named class), to give it the already checked values and to grab the result once the nnDialog dialog is closed. Most of the code of this method is for updating values with quite a brutal approach: we erase every data stored into ANALYSIS_PESTICIDE that have the same ID_ANALYSIS value than the current analysis ! Then, we re-add everything... But it seems to be faster than filtering the already checked values ! At last, th QListWidget involved is refreshed.

id2listWidget is the method used to populate and refresh a QListWidget with relations on the form. Everything is first cleared. A request to the last table is done (PESTICIDE) to grab the field that msut be shown (NAME). The values (IDs) are requested before and put into the constructor of this method.

valueRelationParams is used to find what are: the target layer, the shown field, the identifying field of a value relation control configuration of a table. It is used in manageMultiple and id2listWidget methods to find what to display.

Putting everything into one file

# -*- coding: utf-8 -*-

from PyQt4.QtCore import *
from PyQt4.QtGui import *
from qgis.core import QgsMapLayerRegistry, QgsMessageLog, QgsFeatureRequest, QgsFeature
from qgis.core import QgsRelationManager, QgsRelation, QgsProject, QgsVectorDataProvider
from qgis.utils import iface
from functools import partial
from PyQt4 import QtCore, QtGui

# Global variables
DEBUGMODE = True

class nnDialog(QtGui.QDialog):
    '''Dedicated n,n relations Form Class'''
    def __init__(self, parent, layer, shownField, IdField, initValues, search=False):
        '''Constructor'''
        QtGui.QDialog.__init__(self,parent)

        self.initValues = initValues
        self.shownField = shownField
        self.layer =  layer
        self.IdField = IdField
        self.search = search
        if self.layer is None and DEBUGMODE:
            QgsMessageLog.logMessage(u"nnDialog constructor: The layer {0} doesn't exists !".format(layer.name()),"Your App", QgsMessageLog.INFO)

        # Build the GUI and populate the list with the good values
        self.setupUi()
        self.populateList()

        # Add dynamic control when list is changing
        self.SEARCH.textChanged.connect(self.populateList)
        self.LIST.itemChanged.connect(self.changeValues)

    def setupUi(self):
        '''Builds the QDialog'''
        # Form building
        self.setObjectName(u"nnDialog")
        self.resize(550, 535)
        self.setMinimumSize(QtCore.QSize(0, 0))
        self.buttonBox = QtGui.QDialogButtonBox(self)
        self.buttonBox.setGeometry(QtCore.QRect(190, 500, 341, 32))
        self.buttonBox.setOrientation(QtCore.Qt.Horizontal)
        self.buttonBox.setStandardButtons(QtGui.QDialogButtonBox.Cancel|QtGui.QDialogButtonBox.Ok)
        self.buttonBox.setObjectName(u"buttonBox")
        self.verticalLayoutWidget = QtGui.QWidget(self)
        self.verticalLayoutWidget.setGeometry(QtCore.QRect(9, 9, 521, 491))
        self.verticalLayoutWidget.setObjectName(u"verticalLayoutWidget")
        self.verticalLayout = QtGui.QVBoxLayout(self.verticalLayoutWidget)
        self.verticalLayout.setMargin(0)
        self.verticalLayout.setObjectName(u"verticalLayout")
        self.horizontalLayout = QtGui.QHBoxLayout()
        self.horizontalLayout.setObjectName(u"horizontalLayout")
        self.label = QtGui.QLabel(self.verticalLayoutWidget)
        self.label.setObjectName(u"label")
        self.horizontalLayout.addWidget(self.label)
        self.SEARCH = QtGui.QLineEdit(self.verticalLayoutWidget)
        self.SEARCH.setObjectName(u"SEARCH")
        self.horizontalLayout.addWidget(self.SEARCH)
        self.verticalLayout.addLayout(self.horizontalLayout)
        self.horizontalLayout_2 = QtGui.QHBoxLayout()
        self.horizontalLayout_2.setObjectName(u"horizontalLayout_2")
        self.LIST = QtGui.QListWidget(self.verticalLayoutWidget)
        self.LIST.setObjectName(u"LIST")
        self.horizontalLayout_2.addWidget(self.LIST)
        self.verticalLayout.addLayout(self.horizontalLayout_2)

        self.buttonBox.accepted.connect(self.accept)
        self.buttonBox.rejected.connect(self.reject)
        QtCore.QMetaObject.connectSlotsByName(self)

    def changeValues(self, element):
        '''Whenever a checkbox is checked, modify the values'''
        # Check if we check or uncheck the value:
        if element.checkState() == Qt.Checked:
            self.initValues.append(element.data(Qt.UserRole))
        else:
            self.initValues.remove(element.data(Qt.UserRole))

    def populateList(self, txtFilter=None):
        '''Fill the QListWidget with values'''
        # Delete everything
        self.LIST.clear()

        # We need a request
        request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
        if txtFilter is not None:
            fields = self.layer.dataProvider().fields()
            fieldname = fields[self.shownField].name()
            request.setFilterExpression(u"\"{0}\" LIKE '%{1}%'".format(fieldname, txtFilter))

        # Grab the results from the layer
        features = self.layer.getFeatures(request)

        for feature in sorted(features, key = lambda f: f[0]):
            attr = feature.attributes()
            value = attr[self.shownField]
            element = QListWidgetItem(value)
            element.setData(Qt.UserRole, attr[self.IdField])

            # initValues will be checked
            if attr[self.IdField] in self.initValues:
                element.setCheckState(Qt.Checked)
            else:
                element.setCheckState(Qt.Unchecked)
            self.LIST.addItem(element)

    def getValues(self):
        '''Return the selected values of the QListWidget'''
        return self.initValues

class nnForm:
    '''Class to handle forms to type data'''
    def __init__(self, dialog, layerid, featureid):    
        self.dialog = dialog
        self.layerid = layerid
        self.featureid = featureid
        self.nullValue = QSettings().value("qgis/nullValue" , u"NULL")
        self.search = False

    def id2listWidget(self, table, values, listWidget):
        '''Show all the selected values of a link table on a QListWidget'''
        # Find the Widget
        if listWidget is None or table is None:
            QgsMessageLog.logMessage(u"id2listWidget: We need to have a relation and a true widget !", "nnForms", QgsMessageLog.INFO)
            return False

        # Empty the list
        listWidget.clear()

        # Get the params (for the first child table)
        if self.valueRelationParams(table):
            params = self.valueRelationParams(table)[0]
        if params is None or not params:
            QgsMessageLog.logMessage(u"id2listWidget: You need to add Value Relation to layer: {0} !".format(table.name()), "nnForms", QgsMessageLog.INFO)
            return False

        # Get target layer:
        tgtLayer = params['tgtLayer']

        # Handle values: need to escape \' characters
        values = [v.replace(u"'", u"''") if isinstance(v, basestring) else v for v in values]

        ## Then, get the real values from other-side table
        if values:
            request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
            if params[u'tgtIdType'] in (QVariant.String, QVariant.Char):
                query = u"{0} IN ('{1}')".format(params[u'tgtId'], u"','".join(values))
            else:
                query = u"{0} IN ({1})".format(params[u'tgtId'], u",".join([unicode(x) for x in values]))
            request.setFilterExpression(query)

            # and display them in the QListWidget
            for feature in tgtLayer.getFeatures(request):
                value = feature.attributes()[params[u'tgtValueIdx']]
                if value != u"NULL":
                    element = QListWidgetItem(value)
                    element.setData(Qt.UserRole, feature.attributes()[params[u'tgtIdIdx']])
                    listWidget.addItem(element)

        return True

    def valueRelationParams(self,layer):
        '''Function that returns the configuration parameters of a valueRelation as a list of dict'''
        params = []
        if layer is not None:
            for idx, field in enumerate(layer.dataProvider().fields()):
                if layer.editorWidgetV2(idx) == u"ValueRelation":
                    param = {}
                    param[u'srcId'] = field.name()
                    param[u'srcIdIdx'] = idx
                    if u"Layer" in layer.editorWidgetV2Config(idx):
                        tgtLayerName = layer.editorWidgetV2Config(idx)[u"Layer"]
                        tgtLayer = QgsMapLayerRegistry.instance().mapLayer(tgtLayerName)
                        if tgtLayer is None:
                            QgsMessageLog.logMessage(u"valueRelationParams: Can't find the layer {0} !".format(tgtLayerName), "nnForms", QgsMessageLog.INFO)
                            return False

                        param[u'tgtLayer'] = tgtLayer
                        param[u'tgtId'] = layer.editorWidgetV2Config(idx)[u"Key"]
                        param[u'tgtValue'] = layer.editorWidgetV2Config(idx)[u"Value"]

                        # Find index of all fields:
                        for indx, f in enumerate(tgtLayer.dataProvider().fields()):
                            if f.name() == param[u'tgtId']:
                                param[u'tgtIdIdx'] = indx
                                param[u'tgtIdType'] = f.type()
                            if f.name() == param[u'tgtValue']:
                                param[u'tgtValueIdx'] = indx
                        params.append(param)

        # notification
        if not params:
            QgsMessageLog.logMessage(u"valueRelationParams: There is not Value Relation for the layer {0} !".format(layer.name()), "nnForms", QgsMessageLog.INFO)

        return params

    def manageMultiple(self):
        '''Handle specifics thesaurus form'''
        # Scan all of the QgsRelations of the project
        relations = QgsProject.instance().relationManager().relations()

        for listWidget in [f for f in self.dialog.findChildren(QListWidget) if u"REL_" in f.objectName()]:
            listName = listWidget.objectName()
            if listName not in relations.keys():
                QgsMessageLog.logMessage(u"manageMultiple: There is no Relation for control {0} !".format(listWidget.objectName()), "nnforms", QgsMessageLog.INFO)
                continue

            # Find what is the table to show
            relation = relations[listName]
            shownLayer = relation.referencingLayer()

            # Find other side of n,n relation
            if self.valueRelationParams(shownLayer):
                params = self.valueRelationParams(shownLayer)[0]
            if params is None:
                continue

            # When found, we are ready to populate the QListWidget with the good values
            values = []
            if self.featureid:
                # Get the features to display
                request = relation.getRelatedFeaturesRequest(self.featureid)
                request.setFlags(QgsFeatureRequest.NoGeometry)
                for feature in shownLayer.getFeatures(request):
                    values.append(feature.attributes()[params[u'srcIdIdx']])
                self.id2listWidget(shownLayer, values, listWidget)

            buttonWidget = self.dialog.findChild(QPushButton, listName+u"_B")
            if buttonWidget:
                if self.search or self.layerid.isEditable():
                    buttonWidget.clicked.connect(partial(self.openSubform, listWidget, relation, values))
                    buttonWidget.setEnabled(True)
                else:
                    buttonWidget.setEnabled(False)
            elif DEBUGMODE:
                QgsMessageLog.logMessage(u"manageMultiple: There is no button for control {0} !".format(listName), "nnForms", QgsMessageLog.INFO)

    def openSubform(self, widget, relation, values):
        '''Open a dedicated dialog form with values taken from a child table.'''
        table = relation.referencingLayer()
        if self.valueRelationParams(table):
            params = self.valueRelationParams(table)[0]

        if params is None or not params:
            QgsMessageLog.logMessage(u"openSubform: There is no Value Relation for layer: {0} !".format(table.name()), "nnForms", QgsMessageLog.INFO)
            return False

        if widget is None:
            QgsMessageLog.logMessage(u"openSubForm: no widgets found for field {0} !".format(field), "nnForms", QgsMessageLog.INFO)

        # Open the form with the good values
        dialog = nnDialog(self.dialog, params[u'tgtLayer'], params[u'tgtValueIdx'], params[u'tgtIdIdx'], values, self.search)

        # handle results
        if dialog.exec_():
            # Get the results:
            thevalues = dialog.getValues()

            # Modify target table if we have a featureid
            if self.featureid:
                table.startEditing()
                caps = table.dataProvider().capabilities()
                ## Delete all the previous values
                if caps & QgsVectorDataProvider.DeleteFeatures:
                    request = relation.getRelatedFeaturesRequest(self.featureid)
                    request.setFlags(QgsFeatureRequest.NoGeometry)
                    fids = [f.id() for f in table.getFeatures(request)]
                    table.dataProvider().deleteFeatures(fids)

                ## Add the new values
                if caps & QgsVectorDataProvider.AddFeatures:
                    for value in thevalues:
                        feat = QgsFeature()
                        feat.setAttributes([None, self.featureid.attributes()[0], value])
                        table.dataProvider().addFeatures([feat])
                ## Commit changes
                table.commitChanges()

            # refresh listWidget aspect
            self.id2listWidget(table, thevalues, widget)

def opennnForm(dialog, layerid, featureid):
    '''Generic function to open a nnForm'''
    form = nnForm(dialog, layerid, featureid)
    QgsMessageLog.logMessage(u"opennnForm !", "nnforms", QgsMessageLog.INFO)
    form.manageMultiple()

Conclusion

Okay, this one is quite complex ! If you want to implement n,n forms, you have to code because QGIS is not able to handle them for the moment. For a true implementation into QGIS code, I would take a different path. I can imagine to have a new relation type dedicated to n,n relations. In those relations, you would have to:

  • Declare the "parent" layer (ANALYSIS in our case).
  • Declare the "intermediate" table (ANALYSIS_PESTICIDE) and the shared attributes.
  • Declare the "displayed" layer (PESTICIDE) and the shared attributes (with the intermediate table).

Once in the form, you would use a new form control to configure:

  • the displayed field(s) or expression(s).
  • if you want a search bar or not.
  • if you want to filter values of the "displayed" layer.
Posted lun. 19 oct. 2015 19:52:00