Ce guide explique comment fonctionnent les modèles de données dans Mayan EDMS et le rôle des migrations — à la fois les migrations de base de données (Django standard) et le système propre à Mayan pour la migration des paramètres.
Un modèle Django est une classe Python qui décrit la structure d'une table en base de données. Chaque attribut de la classe correspond à une colonne, et chaque instance de la classe correspond à une ligne.
# Exemple simplifié
from django.db import models
class Tag(models.Model):
label = models.CharField(max_length=128, unique=True)
color = models.CharField(max_length=7)
documents = models.ManyToManyField('documents.Document')
Django traduit cette définition Python en instructions SQL adaptées à la base de données utilisée (PostgreSQL, SQLite, etc.). Le développeur ne manipule jamais le SQL directement — il manipule des objets Python.
Mayan n'utilise pas des modèles simples. Chaque modèle est composé de plusieurs mixins assemblés par héritage multiple. Cette approche sépare clairement les responsabilités.
class Document(
DocumentBusinessLogicMixin, # Méthodes métier (upload, trash, etc.)
ExtraDataModelMixin, # Données supplémentaires flexibles
HooksModelMixin, # Système de hooks (pre/post save)
models.Model # Classe Django de base
):
# Managers personnalisés
objects = DocumentManager() # Tous les documents
valid = ValidDocumentManager() # Documents hors corbeille
trash = TrashCanManager() # Documents dans la corbeille
# Champs de la table
label = models.CharField(...)
description = models.TextField(...)
in_trash = models.BooleanField(default=False, db_index=True)
datetime_created = models.DateTimeField(auto_now_add=True)
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
document_type = models.ForeignKey(DocumentType, ...)
language = models.CharField(...)
ExtraDataModelMixinPermet d'attacher des données temporaires à une instance sans modifier le schéma. Utilisé pour passer des informations entre couches sans polluer les champs du modèle.
# Attacher une donnée temporaire
document._instance_extra_data = {'source': 'api_upload'}
# L'attribut est accessible comme un attribut normal de l'objet
HooksModelMixinFournit un système de callbacks enregistrables autour des opérations de sauvegarde.
# Enregistrer un hook dans une autre app
Document.register_pre_create_hook(func=ma_fonction, order=1)
Document.register_post_save_hook(func=ma_fonction_post)
# Hooks disponibles :
# _hooks_pre_create → avant la création
# _pre_open_hooks → avant l'ouverture
# _pre_save_hooks → avant la sauvegarde
# _post_save_hooks → après la sauvegarde
Cela permet à des apps tierces d'intervenir dans le cycle de vie d'un modèle sans modifier son code.
ValueChangeModelMixinMémorise les valeurs précédentes de champs pour détecter les changements.
# Vérifier si un champ a changé depuis la dernière lecture en base
if document._has_field_changed('document_type_id'):
# Le type de document a changé → déclencher des actions
DocumentBusinessLogicMixin)Contiennent toutes les méthodes de haut niveau :
# Exemples de méthodes sur Document
document.delete(to_trash=True) # Corbeille ou suppression définitive
document.document_type_change(...) # Changer le type
document.add_as_recent_document_for_user(user)
document.get_label()
Ces mixins vivent dans des fichiers séparés (document_model_mixins.py) pour garder les fichiers de modèles lisibles.
Un manager est l'interface entre le modèle et la base de données. Model.objects est le manager par défaut de Django. Mayan en définit plusieurs par modèle selon le contexte d'accès.
# Tous les documents (inclut ceux dans la corbeille)
Document.objects.all()
# Seulement les documents actifs (in_trash=False)
Document.valid.all()
# Seulement les documents dans la corbeille (in_trash=True)
Document.trash.all()
# Fichiers des documents actifs uniquement (avec select_related optimisé)
DocumentFile.valid.all()
# DocumentTypeManager — gestion des politiques de rétention
DocumentType.objects.check_delete_periods() # Suppression définitive automatique
DocumentType.objects.check_trash_periods() # Mise à la corbeille automatique
DocumentType.objects.document_stubs_delete() # Nettoyage des stubs
# FavoriteDocumentManager — gestion des favoris par utilisateur
FavoriteDocument.valid.add_for_user(user, document) # Auto-purge des anciens favoris
AccessControlListManager)Le manager le plus puissant de Mayan. Il filtre n'importe quel queryset selon les permissions de l'utilisateur :
# Retourne uniquement les documents que l'utilisateur peut voir
queryset = AccessControlList.objects.restrict_queryset(
queryset=Document.valid.all(),
permission=permission_document_view,
user=request.user
)
Ce manager est utilisé dans toutes les vues et l'API REST pour garantir que chaque utilisateur ne voit que ce à quoi il a accès.
ManagerMinixCreateBulk)# Insertion efficace par batch de 100
SavedResultsetEntry.objects.create_bulk(entries_generator)
Le système de documents repose sur une hiérarchie de 6 modèles :
DocumentType
│ (type de document : Facture, Contrat, etc.)
│
└── Document
│ label, description, uuid, datetime_created, in_trash
│ ├── .valid → documents actifs
│ ├── .trash → documents dans la corbeille
│
├── DocumentFile (1:n)
│ │ Fichier physique uploadé (PDF, image, etc.)
│ │ filename, checksum, size, mimetype, encoding
│ │ ├── .valid → fichiers de documents actifs
│ │
│ └── DocumentFilePage (1:n)
│ Page individuelle du fichier (image rendue)
│ page_number
│
└── DocumentVersion (1:n)
│ Version logique assemblée à partir de pages
│ comment, active, timestamp
│ ├── .valid → versions de documents actifs
│
└── DocumentVersionPage (1:n)
Page de version — pointe vers une DocumentFilePage
page_number
content_object → (GenericForeignKey → DocumentFilePage)
| Champ | Type | Description |
|---|---|---|
uuid |
UUIDField, unique | Identifiant permanent et immuable |
label |
CharField | Titre du document |
description |
TextField | Description libre |
in_trash |
BooleanField, db_index | Soft-delete (corbeille) |
trashed_date_time |
DateTimeField | Date de mise à la corbeille |
datetime_created |
DateTimeField, auto | Date de création |
language |
CharField | Langue du document (pour l'OCR) |
document_type |
ForeignKey → DocumentType | Type associé |
version_active |
OneToOneField → DocumentVersion | Version active courante |
file_latest |
OneToOneField → DocumentFile | Dernier fichier uploadé |
is_stub |
BooleanField | True si aucun fichier n'est encore attaché |
Mayan décore les méthodes save() et delete() des modèles pour déclencher automatiquement des événements dans le journal d'activité :
@method_event(
event_manager_class=EventManagerSave,
created={'event': event_document_created, 'target': 'self'},
edited={'event': event_document_edited, 'target': 'self'}
)
def save(self, *args, **kwargs):
return super().save(*args, **kwargs)
À chaque création ou modification d'un Document, un enregistrement est créé dans le journal d'activité, traçable depuis l'interface.
Quand un modèle est modifié (ajout d'un champ, changement de type, nouvelle table), la base de données doit être mise à jour pour refléter ces changements. Les migrations sont des fichiers Python qui décrivent ces changements de manière ordonnée et reproductible.
Sans migrations, il faudrait modifier le schéma manuellement sur chaque environnement (développement, staging, production) — une opération risquée et non traçable.
# mayan/apps/documents/migrations/0080_populate_file_size.py
from django.db import migrations, models
class Migration(migrations.Migration):
# Dépendances : cette migration ne peut s'exécuter qu'après celle-ci
dependencies = [
('documents', '0079_documentfile_size'), # Le champ 'size' doit exister
]
operations = [
# Opération 1 : modification de schéma
migrations.AlterField(
model_name='documentfile',
name='size',
field=models.PositiveIntegerField(null=True),
),
# Opération 2 : migration de données (Python arbitraire)
migrations.RunPython(
code=populate_file_sizes,
reverse_code=migrations.RunPython.noop
),
]
def populate_file_sizes(apps, schema_editor):
# apps.get_model() donne le modèle tel qu'il était à ce moment de l'historique
DocumentFile = apps.get_model('documents', 'DocumentFile')
for doc_file in DocumentFile.objects.iterator(chunk_size=1000):
if doc_file.file.storage.exists(name=doc_file.file.name):
doc_file.size = doc_file.file.storage.size(name=doc_file.file.name)
doc_file.save(update_fields=('size',))
| Opération | Effet |
|---|---|
CreateModel |
Crée une nouvelle table |
DeleteModel |
Supprime une table |
AddField |
Ajoute une colonne |
RemoveField |
Supprime une colonne |
AlterField |
Modifie une colonne (type, null, index…) |
RenameField |
Renomme une colonne |
RenameModel |
Renomme une table |
AddIndex |
Ajoute un index |
RunPython |
Exécute du code Python arbitraire (migration de données) |
RunSQL |
Exécute du SQL arbitraire |
1. Django lit la table `django_migrations` en base
→ Liste des migrations déjà appliquées (app_label + nom)
2. Django lit tous les fichiers de migrations dans migrations/
→ Construit un graphe de dépendances
3. Tri topologique du graphe
→ Ordre d'exécution garanti (les dépendances en premier)
4. Exécution des migrations non encore appliquées
→ Chaque opération modifie le schéma ou les données
5. Enregistrement dans django_migrations
→ La migration est marquée comme appliquée avec la date
La commande migrate est idempotente : on peut l'exécuter plusieurs fois, elle saute les migrations déjà appliquées.
# Activer le virtualenv
source venv/bin/activate
# Vérifier les migrations manquantes (dry-run)
make check-missing-migrations
# Générer les migrations après modification d'un modèle
./manage.py makemigrations --settings=mayan.settings.development
# Générer pour une app spécifique
./manage.py makemigrations documents --settings=mayan.settings.development
# Appliquer toutes les migrations
./manage.py migrate --settings=mayan.settings.development
# Appliquer jusqu'à une version spécifique
./manage.py migrate documents 0050 --settings=mayan.settings.development
# Voir l'état des migrations
./manage.py showmigrations --settings=mayan.settings.development
# Voir l'état d'une app spécifique
./manage.py showmigrations documents --settings=mayan.settings.development
Les migrations peuvent dépendre de migrations d'autres apps :
dependencies = [
('documents', '0050_document_uuid'),
('acls', '0002_auto_acl_permission'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
swappable_dependency est utilisé pour les modèles configurables (comme le modèle User) dont le nom d'app peut varier selon la configuration.
Migration de schéma : modifie la structure (colonnes, tables, index). Générée automatiquement par makemigrations.
Migration de données : transforme les données existantes. Écrite manuellement avec RunPython.
# Bonne pratique : séparer les deux en deux migrations distinctes
# 0079_documentfile_size.py → AddField (schéma)
# 0080_populate_file_size.py → RunPython (données)
Cela permet de revenir en arrière sur la migration de données indépendamment de la migration de schéma.
# Lancer les tests sans exécuter les migrations (plus rapide)
make test MODULE=mayan.apps.documents
# → Utilise --skip-migrations par défaut
# Lancer les tests AVEC les migrations (teste aussi les migrations)
make test-migrations MODULE=mayan.apps.documents
Avec --skip-migrations, Django crée directement le schéma depuis les modèles sans passer par l'historique des migrations. C'est plus rapide mais ne teste pas que les migrations sont correctes.
# mayan/apps/documents/tests/test_migrations.py
class DocumentFileSizeMigrationTestCase(MayanMigratorTestCase):
migrate_from = ('documents', '0079_documentfile_size')
migrate_to = ('documents', '0080_populate_file_size')
def prepare(self):
# Créer des données AVANT la migration
DocumentFile = self.old_state.apps.get_model('documents', 'DocumentFile')
# ...
def test_size_populated(self):
# Vérifier l'état APRÈS la migration
DocumentFile = self.new_state.apps.get_model('documents', 'DocumentFile')
self.assertIsNotNone(DocumentFile.objects.first().size)
En plus des migrations de base de données, Mayan a un second système de migration pour les paramètres de configuration. Il résout un problème différent : quand un paramètre est renommé ou son format change entre deux versions, les anciennes valeurs stockées dans les variables d'environnement doivent être converties.
Version 1.0 : DOCUMENTS_STORAGE_BACKEND = 'mayan.apps.storage.backends.FileSystemStorage'
Version 2.0 : DOCUMENTS_FILE_STORAGE_BACKEND = 'django.core.files.storage.FileSystemStorage'
↑ renommé + chemin Python différent
L'utilisateur qui met à jour de 1.0 à 2.0 a encore l'ancienne variable d'environnement. Le setting migration s'en occupe automatiquement.
setting_migrations.py# mayan/apps/documents/setting_migrations.py
class DocumentsSettingMigration(SettingNamespaceMigration):
def documents_file_storage_backend_0001(self, value):
"""
Migration de la version 0001 → 0002 du paramètre
documents_file_storage_backend.
Si la nouvelle valeur est absente, lire l'ancienne variable.
"""
if value is not None:
return value
try:
value, _ = setting_cluster.get_domains_value(
key='DOCUMENTS_STORAGE_BACKEND' # Ancien nom
)
except KeyError:
return DEFAULT_DOCUMENTS_FILE_STORAGE_BACKEND
return value
def documents_cache_storage_backend_arguments_0001(self, value):
"""
Les arguments du backend n'étaient plus entre guillemets dans la v0002.
Convertir la chaîne YAML en objet Python.
"""
return smart_yaml_load(value=value)
{nom_du_setting_en_minuscules}_{numéro_de_version}
documents_file_storage_backend_0001 → migration du paramètre DOCUMENTS_FILE_STORAGE_BACKEND de la version 0001 à 0002documents_cache_storage_backend_arguments_0001 → migration du paramètre DOCUMENTS_CACHE_STORAGE_BACKEND_ARGUMENTS de la version 0001 à 0002| Aspect | Migrations DB | Setting Migrations |
|---|---|---|
| Cible | Schéma et données en base | Valeurs de paramètres de configuration |
| Déclenchement | Commande migrate |
Automatique à l'accès au paramètre |
| Traçabilité | Table django_migrations |
Version dans la configuration namespace |
| Réversibilité | Oui (reverse_code) | Non (sens unique) |
| Format | Fichier de migration numéroté | Méthodes dans setting_migrations.py |
| Portée | Persistant en base | En mémoire, appliqué à chaque démarrage |
Développeur modifie models.py
↓
makemigrations → génère 0NNN_description.py
↓
Code review + tests de migration
↓
migrate → applique le schéma en base
↓
django_migrations enregistre l'application
Au runtime :
↓
Model.save() → triggers hooks → déclenche événements
↓
Signal post_save → handlers (indexation, search, etc.)
↓
Tâche Celery asynchrone si nécessaire
apps.get_model() dans les RunPython# CORRECT — utilise le modèle historique (état à ce moment de la migration)
def ma_migration(apps, schema_editor):
Document = apps.get_model('documents', 'Document')
# INCORRECT — utilise le modèle actuel (peut ne pas correspondre)
def ma_migration(apps, schema_editor):
from mayan.apps.documents.models import Document # ← NE PAS FAIRE
iterator() pour les grandes tables# Évite de charger tous les objets en mémoire
for obj in Model.objects.iterator(chunk_size=1000):
obj.field = nouvelle_valeur
obj.save(update_fields=('field',))
update_fields pour les mises à jour partielles# Plus efficace : ne met à jour que les colonnes spécifiées
obj.save(update_fields=('size', 'checksum'))
# Moins efficace : met à jour toutes les colonnes
obj.save()
mayan/apps/documents/migrations/Mayan a environ 80+ migrations pour l'app documents seule. La numérotation séquentielle garantit l'ordre d'application. Ne jamais renommer ou renuméroter une migration déjà appliquée en production.
| Besoin | Commande / Action |
|---|---|
| Voir les modèles d'une app | Lire mayan/apps/{app}/models/ |
| Créer une migration après modif | ./manage.py makemigrations {app} |
| Appliquer les migrations | ./manage.py migrate |
| Voir l'état des migrations | ./manage.py showmigrations |
| Vérifier si des migrations manquent | make check-missing-migrations |
| Tester sans migrations (rapide) | make test MODULE=... |
| Tester avec migrations | make test-migrations MODULE=... |
| Tous les docs actifs | Document.valid.all() |
| Filtrer par permission ACL | AccessControlList.objects.restrict_queryset(...) |
| Ajouter un hook sur save | Document.register_post_save_hook(func) |