loko/streetup/projects/management/commands/create_project_folders.py
2026-07-22 14:48:40 +02:00

219 lines
8.9 KiB
Python

"""
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