# Generated by Django 4.2.17 on 2025-01-22 21:53 import collections import itertools import logging from typing import TYPE_CHECKING import django.db.models.deletion from django.conf import settings from django.db import migrations, models from django.db.migrations.state import StateApps import sas.models if TYPE_CHECKING: import core.models # NB : tous les commentaires sont écrits en français, # parce qu'on est sur des opérations qui sont complexes, # et qui sont surtout DANGEREUSES. # Ici, la clarté des explications prime sur toute autre considération. def copy_albums_and_pictures(apps: StateApps, schema_editor): SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile") Album: type[sas.models.Album] = apps.get_model("sas", "Album") Picture: type[sas.models.Picture] = apps.get_model("sas", "Picture") logger = logging.getLogger("django") # Il y a environ 1800 albums, 257k photos et 488k identifications # d'utilisateurs dans la db de prod. # En supposant qu'une insertion prenne 10ms (ce qui est très optimiste), # migrer tous les enregistrements de la db prendrait plus de 2h. # C'est trop long. # Mais d'un autre côté, j'ai pas assez confiance dans les capacités de nos # machines pour charger presque un million d'objets en mémoire. # Pour faire un compromis, les albums sont migrés individuellement un à un, # mais tous les objets liés à ces albums # (photos, groupes de vue, groupe d'édition, identification d'utilisateurs) # sont migrés en tas. # # Ordre des opérations : # 1. On migre les albums 1 à 1 (il y en a 1800, donc c'est relativement court) # 2. On migre les photos par paquet de 2500 (soit ~une centaine d'opérations) # 3. On migre tous les groupes de vue et tous les groupes d'édition des albums # # Au total, la migration devrait demander aux alentours de 2000 insertions, # ce qui est un compromis acceptable entre une migration # pas trop longue et une RAM pas trop surchargée. # # Pour ce qui est de la répartition des tables, quatre nouvelles tables # sont créées : sas_album, sas_picture, # sas_pictureviewgroups et sas_picture_editgroups. # Tous les albums et toutes les photos qui sont dans core_sithfile # vont être copiés dans ces tables. # Comme les albums sont migrés un à un, ils recevront une nouvelle # clef primaire. # Pour les photos, en revanche, c'est beaucoup plus sûr de leur donner # le même id que celui qu'il y avait dans core_sithfile. # # Les identifications des photos ne sont pas migrées pour l'instant. # Ce qu'on va faire, c'est qu'on va changer la contrainte de clef étrangère # sur la colonne des photos pour pointer vers sas_picture # au lieu de core_sithfile. # Cependant, pour que ça marche, # il faut qu'au moment où ce changement est effectué, # toutes les clefs primaires référencées existent à la fois dans # les deux tables, sinon les contraintes d'intégrité ne sont pas respectées. # La migration de ce fichier va donc s'occuper de créer les nouvelles tables # et d'y copier les données nécessaires. # Puis une deuxième migration s'occupera de changer les contraintes. # Et enfin une troisième migration supprimera les anciennes données. # # Pavé César albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).prefetch_related( "view_groups", "edit_groups" ) old_albums = collections.deque( albums.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID) ) # Changement de représentation en DB. # Dans l'ancien système, un fichier était dans le SAS si # un fichier spécial (le SAS_ROOT) était parmi ses ancêtres. # Comme maintenant les fichiers du SAS sont dans des tables à part, # il ne peut plus y avoir de confusion. # Les photos ont donc obligatoirement un parent (qui est un album) # et les albums peuvent avoir un parent null. # Un album sans parent est considéré comme se trouvant à la racine # de l'arborescence. # En quelque sorte, None est le nouveau SITH_SAS_ROOT_DIR_ID album_id_old_to_new = {settings.SITH_SAS_ROOT_DIR_ID: None} logger.info(f"migrating {albums.count()} albums") while len(old_albums) > 0: # Comme les albums référencent leur parent, les albums doivent être migrés # par ordre croissant de profondeur dans l'arborescence. # Chaque album est donc pris par la gauche de la file # et ses enfants ajoutés sur la droite. old_album = old_albums.popleft() old_albums.extend(list(albums.filter(parent=old_album))) new_album = Album.objects.create( parent_id=album_id_old_to_new[old_album.parent_id], event_date=old_album.date.date(), name=old_album.name, thumbnail=(old_album.file or None), is_moderated=old_album.is_moderated, ) # on garde un dictionnaire qui associe les id des albums dans l'ancienne table # à leur id dans la nouvelle table, pour pouvoir recréer # les liens de parenté entre albums album_id_old_to_new[old_album.id] = new_album.id pictures = SithFile.objects.filter(is_in_sas=True, is_folder=False) nb_pictures = pictures.count() logger.info(f"migrating {nb_pictures} pictures") for i, pictures_batch in enumerate(itertools.batched(pictures, 2500), start=1): Picture.objects.bulk_create( [ Picture( id=p.id, name=p.name, parent_id=album_id_old_to_new[p.parent_id], thumbnail=p.thumbnail, compressed=p.compressed, original=p.file, owner_id=p.owner_id, created_at=p.date, is_moderated=p.is_moderated, asked_for_removal=p.asked_for_removal, moderator_id=p.moderator_id, ) for p in pictures_batch ] ) logger.info(f"Migrated {min(i * 2500, nb_pictures)} / {nb_pictures} pictures") logger.info("Migrating album groups") albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).exclude( id=settings.SITH_SAS_ROOT_DIR_ID ) Album.edit_groups.through.objects.bulk_create( [ Album.view_groups.through( album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id ) for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums) ] ) Album.edit_groups.through.objects.bulk_create( [ Album.view_groups.through( album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id ) for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums) ] ) class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("core", "0043_bangroup_alter_group_description_alter_user_groups_and_more"), ("sas", "0004_picturemoderationrequest_and_more"), ] operations = [ # les relations et les demandes de modération étaient liées à SithFile, # via le model proxy Picture. # Pour que la migration marche malgré la disparition du modèle Proxy, # on change la relation pour qu'elle pointe directement vers SithFile migrations.AlterField( model_name="peoplepicturerelation", name="picture", field=models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="people", to="core.sithfile", verbose_name="picture", ), ), migrations.AlterField( model_name="picturemoderationrequest", name="picture", field=models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="moderation_requests", to="core.sithfile", verbose_name="Picture", ), ), migrations.DeleteModel(name="Album"), migrations.DeleteModel(name="Picture"), migrations.DeleteModel(name="SasFile"), migrations.CreateModel( name="Album", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "thumbnail", models.FileField( max_length=256, upload_to=sas.models.get_thumbnail_directory, verbose_name="thumbnail", ), ), ("name", models.CharField(max_length=100, verbose_name="name")), ( "event_date", models.DateField( default=django.utils.timezone.localdate, help_text="The date on which the photos in this album were taken", verbose_name="event date", ), ), ( "is_moderated", models.BooleanField(default=False, verbose_name="is moderated"), ), ( "edit_groups", models.ManyToManyField( related_name="editable_albums", to="core.group", verbose_name="edit groups", ), ), ( "parent", models.ForeignKey( blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="children", to="sas.album", verbose_name="parent", ), ), ( "view_groups", models.ManyToManyField( related_name="viewable_albums", to="core.group", verbose_name="view groups", ), ), ], options={"verbose_name": "album"}, ), migrations.CreateModel( name="Picture", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "thumbnail", models.FileField( unique=True, upload_to=sas.models.get_thumbnail_directory, verbose_name="thumbnail", max_length=256, ), ), ("name", models.CharField(max_length=256, verbose_name="file name")), ( "original", models.FileField( unique=True, upload_to=sas.models.get_directory, verbose_name="original image", max_length=256, ), ), ( "compressed", models.FileField( unique=True, upload_to=sas.models.get_compressed_directory, verbose_name="compressed image", max_length=256, ), ), ("created_at", models.DateTimeField(default=django.utils.timezone.now)), ( "is_moderated", models.BooleanField(default=False, verbose_name="is moderated"), ), ( "asked_for_removal", models.BooleanField( default=False, verbose_name="asked for removal" ), ), ( "moderator", models.ForeignKey( blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="moderated_pictures", to=settings.AUTH_USER_MODEL, ), ), ( "owner", models.ForeignKey( on_delete=django.db.models.deletion.PROTECT, related_name="owned_pictures", to=settings.AUTH_USER_MODEL, verbose_name="owner", ), ), ( "parent", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="pictures", to="sas.album", verbose_name="album", ), ), ], options={"abstract": False}, ), migrations.AddConstraint( model_name="album", constraint=models.UniqueConstraint( fields=("name", "parent"), name="unique_album_name_if_same_parent" ), ), migrations.RunPython( copy_albums_and_pictures, reverse_code=migrations.RunPython.noop, elidable=True, ), ]