115 lines
4.5 KiB
Python
115 lines
4.5 KiB
Python
"""Store DriveItems as normal filenames beneath their numbered DriveSpace.
|
|
|
|
All relative paths are inside DRIVE_STORAGE_ROOT. The DB hierarchy is authoritative,
|
|
so a physical folder can be rebuilt from its current DriveItem ancestors.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
|
|
from .models import DriveItem
|
|
|
|
|
|
class NameConflict(ValueError):
|
|
pass
|
|
|
|
|
|
def folder_location(space, parent):
|
|
parts = []
|
|
seen = set()
|
|
while parent is not None:
|
|
if parent.pk in seen or not parent.is_folder or parent.space_id != space.pk:
|
|
raise ValueError('Hiérarchie de dossiers invalide.')
|
|
if parent.name in ('.', '..') or '/' in parent.name or '\\' in parent.name:
|
|
raise ValueError('Nom de dossier invalide.')
|
|
seen.add(parent.pk)
|
|
parts.append(parent.name)
|
|
parent = parent.parent
|
|
return '/'.join(part for part in (space.relative_path, *reversed(parts)) if part)
|
|
|
|
|
|
def file_location(space, parent, name):
|
|
return '/'.join(part for part in (folder_location(space, parent), name) if part)
|
|
|
|
|
|
def disk_path(location):
|
|
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
|
|
path = root
|
|
for part in Path(location).parts:
|
|
if part in ('', '.', '..') or part.startswith('/') or '\\' in part:
|
|
raise ValueError('Chemin de stockage invalide.')
|
|
path = path / part
|
|
if path.is_symlink():
|
|
raise ValueError('Lien symbolique interdit dans le stockage.')
|
|
if not path.resolve().is_relative_to(root):
|
|
raise ValueError('Chemin de stockage hors du répertoire autorisé.')
|
|
return path
|
|
|
|
|
|
def assert_available(space, parent, name, exclude=None):
|
|
siblings = DriveItem.objects.filter(space=space, parent=parent, name=name)
|
|
if exclude is not None:
|
|
siblings = siblings.exclude(pk=exclude)
|
|
if siblings.exists():
|
|
raise NameConflict('Un élément portant ce nom existe déjà à cet emplacement (y compris dans la corbeille).')
|
|
target = disk_path(file_location(space, parent, name))
|
|
if target.exists() or target.is_symlink():
|
|
raise NameConflict('Un fichier ou un dossier portant ce nom existe déjà sur le disque.')
|
|
return target
|
|
|
|
|
|
def prepare_parent(space, parent):
|
|
location = folder_location(space, parent)
|
|
destination = disk_path(location)
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
return destination
|
|
|
|
|
|
def relocate_item(item, new_parent, new_name):
|
|
"""Rename/move a file or folder and its descendants on disk and in SQL.
|
|
|
|
The on-disk move is reversed if the database transaction fails. The caller
|
|
must ensure authorization and that new_parent is not a descendant of item.
|
|
"""
|
|
old = (file_location(item.space, item.parent, item.name) if item.is_folder
|
|
else item.storage_location or f'{item.storage_key[:2]}/{item.storage_key}')
|
|
new = file_location(item.space, new_parent, new_name)
|
|
source = disk_path(old)
|
|
target = assert_available(item.space, new_parent, new_name, exclude=item.pk)
|
|
prepare_parent(item.space, new_parent)
|
|
if not source.exists():
|
|
if not item.is_folder:
|
|
raise FileNotFoundError(f'Fichier source introuvable : {old}')
|
|
# Historical logical folders had no corresponding physical directory.
|
|
source.mkdir(parents=True, exist_ok=True)
|
|
if (not source.is_dir()) if item.is_folder else (not source.is_file()):
|
|
raise ValueError('Type de fichier incorrect sur le disque.')
|
|
moved = False
|
|
try:
|
|
with transaction.atomic():
|
|
# Two distinct paths are guaranteed by caller's early unchanged check.
|
|
source.rename(target)
|
|
moved = True
|
|
if item.is_folder:
|
|
prefix = old.rstrip('/') + '/'
|
|
affected = list(DriveItem.objects.filter(
|
|
space=item.space, storage_location__startswith=prefix
|
|
).only('pk', 'storage_location'))
|
|
for child in affected:
|
|
child.storage_location = new + '/' + child.storage_location[len(prefix):]
|
|
if affected:
|
|
DriveItem.objects.bulk_update(affected, ['storage_location'], batch_size=500)
|
|
else:
|
|
item.storage_location = new
|
|
item.name = new_name
|
|
item.parent = new_parent
|
|
item.full_clean()
|
|
item.save()
|
|
except Exception:
|
|
if moved:
|
|
# Do not replace a path created by another operation in the meantime.
|
|
if target.exists() and not source.exists():
|
|
target.rename(source)
|
|
raise
|