"""Permanent deletion shared by the trash API and scheduled retention cleanup.""" from django.db import transaction from .models import DriveItem, UploadSession from .services import descendant_files from .storage_layout import disk_path, file_location def has_active_uploads_in_tree(item): """Do not purge directories while a resumable upload targets a descendant.""" if not item.is_folder: return False pending = [item.pk] while pending: batch, pending = pending[:250], pending[250:] if UploadSession.objects.filter(parent_id__in=batch).exists(): return True pending.extend(DriveItem.objects.filter(parent_id__in=batch, is_folder=True).values_list('pk', flat=True)) return False def delete_trashed_item(item): """Delete the SQL tree, then its tracked files after commit. Untracked files and directories are not erased. Failures resolving a stored path abort before the SQL delete; missing physical files are tolerated. """ if item.trashed_at is None: raise ValueError('L’élément doit être dans la corbeille.') if has_active_uploads_in_tree(item): raise ValueError('Un transfert est encore en cours dans ce dossier.') files = [disk_path(key) for key in descendant_files(item)] folder = disk_path(file_location(item.space, item.parent, item.name)) if item.is_folder else None with transaction.atomic(): item.delete() def remove_files(): for path in files: path.unlink(missing_ok=True) if folder and folder.is_dir() and not folder.is_symlink(): # Only remove empty directories, never user data untracked by SQL. for candidate in sorted(folder.rglob('*'), key=lambda p:len(p.parts), reverse=True): if candidate.is_dir() and not candidate.is_symlink(): try: candidate.rmdir() except OSError: pass try: folder.rmdir() except OSError: pass transaction.on_commit(remove_files)