""" Management command: create_project_folders ========================================== Backfill that, for every Project: 1. Creates a DocumentFolder + DocumentFolderAttachment (if missing). 2. Migrates each legacy ProjectDocument into the new ManagedDocument / DocumentVersion / DocumentAttachment system (if not already migrated). Idempotent: safe to run multiple times. - Folder step : skipped when a DocumentFolderAttachment already exists. - Document step: skipped when ProjectDocument.managed_document is already set. Usage: python manage.py create_project_folders python manage.py create_project_folders --dry-run python manage.py create_project_folders --folders-only python manage.py create_project_folders --docs-only """ import os from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand from django.db import transaction from documents.models import ( DocumentAttachment, DocumentFolderAttachment, DocumentVersion, ManagedDocument, ) from projects.models import Project, ProjectDocument from projects.signals import ensure_project_document_folder class Command(BaseCommand): help = ( "Create a default DocumentFolder for every project that lacks one, " "and migrate legacy ProjectDocument records into the managed-document system." ) def add_arguments(self, parser): parser.add_argument( "--dry-run", action="store_true", help="Show what would be done without making any changes.", ) parser.add_argument( "--folders-only", action="store_true", help="Only create folders; skip document migration.", ) parser.add_argument( "--docs-only", action="store_true", help="Only migrate documents; skip folder creation (folder must already exist).", ) # ------------------------------------------------------------------ # # Entry point # # ------------------------------------------------------------------ # def handle(self, *args, **options): dry_run = options["dry_run"] folders_only = options["folders_only"] docs_only = options["docs_only"] if dry_run: self.stdout.write(self.style.WARNING("DRY RUN — no changes will be saved.")) project_ct = ContentType.objects.get_for_model(Project) # IDs of projects that already have a folder attachment already_foldered = set( DocumentFolderAttachment.objects.filter(content_type=project_ct) .values_list("object_id", flat=True) .distinct() ) projects = Project.objects.prefetch_related("documents").order_by("id") total_projects = projects.count() self.stdout.write(f"Found {total_projects} project(s).\n") folders_created = 0 folders_skipped = 0 docs_migrated = 0 docs_skipped = 0 for project in projects: self.stdout.write(f"[{project.code}] {project.name}") # ── 1. Folder ────────────────────────────────────────────── folder = None if not docs_only: if project.pk in already_foldered: self.stdout.write(" folder: already exists — skipped") folders_skipped += 1 # Retrieve the existing folder for the document step attachment = ( DocumentFolderAttachment.objects .filter(content_type=project_ct, object_id=project.pk) .select_related("folder") .first() ) folder = attachment.folder if attachment else None else: if dry_run: self.stdout.write(" folder: WOULD CREATE") folders_created += 1 else: folder = ensure_project_document_folder(project) self.stdout.write( self.style.SUCCESS(f" folder: created [{folder.slug}]") ) folders_created += 1 else: # docs-only: retrieve existing folder attachment = ( DocumentFolderAttachment.objects .filter(content_type=project_ct, object_id=project.pk) .select_related("folder") .first() ) folder = attachment.folder if attachment else None # ── 2. Documents ─────────────────────────────────────────── if folders_only: continue legacy_docs = list(project.documents.select_related("uploaded_by").all()) if not legacy_docs: self.stdout.write(" docs : none") continue for proj_doc in legacy_docs: if not proj_doc.file or not proj_doc.file.name: continue if proj_doc.managed_document_id: self.stdout.write( f" docs : {proj_doc.filename!r} — already migrated, skipped" ) docs_skipped += 1 continue if dry_run: self.stdout.write( f" docs : WOULD MIGRATE {proj_doc.filename!r}" ) docs_migrated += 1 continue managed = self._migrate_one(proj_doc, project, project_ct, folder) self.stdout.write( self.style.SUCCESS( f" docs : migrated {proj_doc.filename!r} -> ManagedDocument #{managed.pk}" ) ) docs_migrated += 1 # ── Summary ──────────────────────────────────────────────────── verb = "would be " if dry_run else "" self.stdout.write("") self.stdout.write( self.style.SUCCESS( f"Done. " f"Folders: {folders_created} {verb}created, {folders_skipped} skipped. " f"Documents: {docs_migrated} {verb}migrated, {docs_skipped} already done." ) ) # ------------------------------------------------------------------ # # Migration of a single ProjectDocument # # ------------------------------------------------------------------ # @transaction.atomic def _migrate_one(self, proj_doc, project, project_ct, folder): """ Create ManagedDocument + DocumentVersion + DocumentAttachment for a single ProjectDocument and mark it as migrated. The file is NOT moved or copied — DocumentVersion.file simply references the existing storage path. The folder is added automatically by DocumentAttachment.apply_default_folder() (triggered by its save()). """ title = os.path.splitext(proj_doc.filename)[0] or proj_doc.filename managed = ManagedDocument.objects.create( title=title, created_by=proj_doc.uploaded_by, ) # Build a DocumentVersion referencing the existing file path directly. # Assigning a string to a FileField stores the path without re-uploading. version = DocumentVersion( document=managed, uploaded_by=proj_doc.uploaded_by, comment="Migré depuis ProjectDocument (héritage)", status=DocumentVersion.STATUS_APPROVED, ) version.file = proj_doc.file.name # existing storage path, no upload if proj_doc.thumbnail and proj_doc.thumbnail.name: version.thumbnail = proj_doc.thumbnail.name # version.save() auto-sets version_number, calls document.touch(), # attempts thumbnail generation (fails gracefully for non-images). version.save() # DocumentAttachment.save() → apply_default_folder() adds project folder # to managed.folders automatically (because DocumentFolderAttachment # with is_default=True already exists for this project). DocumentAttachment.objects.create( document=managed, content_type=project_ct, object_id=project.pk, attached_by=proj_doc.uploaded_by, ) # Mark legacy record as migrated (idempotence flag) ProjectDocument.objects.filter(pk=proj_doc.pk).update(managed_document=managed) return managed