"""Invitation-only registration and hierarchical space management.""" import secrets import logging import shutil from pathlib import Path from django.conf import settings from django.db.models import Q from datetime import timedelta from django.contrib.auth import get_user_model from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError from django.db import transaction, IntegrityError from django.utils import timezone from rest_framework.decorators import api_view, permission_classes, throttle_classes from rest_framework.permissions import AllowAny from rest_framework.throttling import AnonRateThrottle from rest_framework.response import Response from .models import (DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession, ItemGrant, PublicItemLink, ArchivedDriveAccount) from .services import error, valid_name from .spaces import ensure_space, ensure_private_space, my_space, create_space, _make_numbered_space, is_admin, hash_token, space_info, can_read_space, can_write_space, can_manage_space from .quotas import (parse_quota, locked_quota_tree, assert_new_child_quota, assert_updated_quota) def permission_flags(data): return {k: data.get(k) is True for k in ('can_view_parent','can_write_parent','can_invite')} def manager(user, parent): return can_manage_space(user, parent) def enforce_delegation(user, space, *, view=True, write=False, invite=False): # These checks must be repeated on the server for each creation/change. if view and not can_read_space(user, space): raise ValueError('Tu ne peux pas attribuer la lecture de cet espace.') if write and not can_write_space(user, space): raise ValueError('Tu ne peux pas attribuer l’écriture de cet espace.') if invite and not can_manage_space(user, space): raise ValueError('Tu ne peux pas déléguer la gestion des utilisateurs.') def invitation_info(inv): return {'id':str(inv.pk), 'created_at':inv.created_at.isoformat(), 'expires_at':inv.expires_at.isoformat(), 'used':bool(inv.used_at), 'revoked':bool(inv.revoked_at), 'can_view_parent':inv.can_view_parent,'can_write_parent':inv.can_write_parent, 'can_invite':inv.can_invite} @api_view(['GET']) def spaces(request): own = ensure_space(request.user) qs=DriveSpace.objects.select_related('user','private_owner','managed_by','parent').order_by('relative_path') private=ensure_private_space(request.user) if is_admin(request.user): permitted=[private, *qs.filter(private_owner__isnull=True)] else: permitted=[private] + ([own] if own else []) if own and own.user_id == request.user.pk and own.parent_id and own.can_view_parent and not own.parent.private_owner_id: permitted.append(own.parent) permitted.extend(space for space in qs.filter(access_grants__user=request.user) if not space.private_owner_id) permitted.extend(qs.filter(managed_by=request.user,private_owner__isnull=True)) permitted=list({space.pk:space for space in permitted}.values()) return Response({'mine':private.pk, 'assigned':own.pk if own else None, 'private':private.pk, 'spaces':[space_info(space,request.user) for space in permitted]}) @api_view(['PATCH']) def rename_my_space(request): own=my_space(request.user) if own is None:return error('Aucun espace attribué.',404) if own.private_owner_id or (own.user_id != request.user.pk and not is_admin(request.user)): return error('Impossible de renommer un espace attribué à un autre compte.',403) try: title=valid_name(request.data.get('display_name')) except ValueError as ex:return error(str(ex)) if len(title)>120:return error('Nom limité à 120 caractères.') own.display_name=title own.save(update_fields=['display_name']) return Response(space_info(own,request.user)) @api_view(['GET']) def members(request): own=my_space(request.user) parent=own if request.query_params.get('parent_space'): try:parent=DriveSpace.objects.get(pk=int(request.query_params['parent_space'])) except (ValueError,TypeError,DriveSpace.DoesNotExist):return error('Espace introuvable.',404) if not manager(request.user,parent):return error('Gestion des invitations non autorisée.',403) children=DriveSpace.objects.filter(parent=parent,private_owner__isnull=True).select_related('user','parent').order_by('id') invitations=SpaceInvitation.objects.filter(parent_space=parent).order_by('-created_at')[:100] return Response({'parent':space_info(parent,request.user), 'children':[space_info(c,request.user) for c in children if not c.private_owner_id], 'grants':[grant_info(g) for g in SpaceGrant.objects.filter(space=parent).select_related('user').order_by('user__username')], 'invitations':[invitation_info(i) for i in invitations]}) def _update_quota(space, requested): """Must run under transaction.atomic and after locking all ancestors.""" if space.private_owner_id: raise ValueError('Le quota des espaces privés est fixé à 50 Go.') with locked_quota_tree(space): quota = parse_quota(requested) assert_updated_quota(space, quota) space.quota_bytes = quota space.save(update_fields=['quota_bytes']) @api_view(['PATCH']) def update_member(request,space_id): with transaction.atomic(): try:child=DriveSpace.objects.select_related('parent','user').get(pk=space_id) except DriveSpace.DoesNotExist:return error('Espace introuvable.',404) if child.private_owner_id or not child.parent_id:return error('Impossible de modifier cet espace ici.',403) if not manager(request.user,child.parent):return error('Modification interdite.',403) changes=[] for key in ('can_view_parent','can_write_parent','can_invite'): if key in request.data: if not isinstance(request.data[key],bool):return error('Les permissions doivent être des booléens.') setattr(child,key,request.data[key]);changes.append(key) if child.can_write_parent and not child.can_view_parent: return error('L’écriture nécessite la lecture de l’espace parent.') try:enforce_delegation(request.user,child.parent,view=child.can_view_parent, write=child.can_write_parent,invite=child.can_invite) except ValueError as exc:return error(str(exc),403) if 'quota_bytes' in request.data: try:_update_quota(child, request.data['quota_bytes']) except ValueError as ex:return error(str(ex)) if changes:child.save(update_fields=changes) if 'is_active' in request.data and child.user_id is None: return error('Ce dossier partagé ne possède pas de compte utilisateur.') if 'is_active' in request.data: if not isinstance(request.data['is_active'],bool):return error('Statut invalide.') child.user.is_active=request.data['is_active'] child.user.save(update_fields=['is_active']) return Response(space_info(child,request.user)) @api_view(['PATCH']) def update_space_quota(request, space_id): """Administrators may cap any space; delegated inviters manage their direct children.""" with transaction.atomic(): try:space=DriveSpace.objects.select_related('parent','user').get(pk=space_id) except DriveSpace.DoesNotExist:return error('Espace introuvable.',404) if space.private_owner_id: return error('Le quota des espaces privés est fixé à 50 Go.',403) if not (is_admin(request.user) or (space.parent_id and not space.private_owner_id and manager(request.user,space.parent))): return error('Modification du quota interdite.',403) if 'quota_bytes' not in request.data:return error('quota_bytes est obligatoire.') try:_update_quota(space,request.data['quota_bytes']) except ValueError as ex:return error(str(ex)) return Response(space_info(space,request.user)) @api_view(['POST']) def create_invitation(request): own=my_space(request.user) parent=own if request.data.get('parent_space'): try:parent=DriveSpace.objects.get(pk=int(request.data['parent_space'])) except (ValueError,TypeError,DriveSpace.DoesNotExist):return error('Espace introuvable.',404) if not manager(request.user,parent):return error('Vous ne pouvez pas inviter depuis cet espace.',403) fields=permission_flags(request.data) if 'can_view_parent' not in request.data:fields['can_view_parent']=True if not fields['can_view_parent']: return error('L’invitation donne nécessairement accès en lecture à cet espace.') try:enforce_delegation(request.user,parent,view=fields['can_view_parent'],write=fields['can_write_parent'],invite=fields['can_invite']) except ValueError as exc:return error(str(exc),403) try:days=int(request.data.get('expires_days',7)) except (ValueError,TypeError):return error('Durée invalide.') if not 1<=days<=30:return error('La durée doit être comprise entre 1 et 30 jours.') # An invitation grants access. Storage limits are configured on spaces, # not on invitation links (the private space always has its 50-Go cap). token=secrets.token_urlsafe(32) inv=SpaceInvitation.objects.create(parent_space=parent,created_by=request.user, token_hash=hash_token(token),expires_at=timezone.now()+timedelta(days=days),**fields) # Returned exactly once; DB stores only SHA-256. return Response({**invitation_info(inv),'token':token},status=201) @api_view(['POST']) def revoke_invitation(request,invitation_id): try:inv=SpaceInvitation.objects.select_related('parent_space').get(pk=invitation_id) except SpaceInvitation.DoesNotExist:return error('Invitation introuvable.',404) if not manager(request.user,inv.parent_space):return error('Accès refusé.',403) if inv.used_at:return error('Invitation déjà utilisée.') inv.revoked_at=timezone.now();inv.save(update_fields=['revoked_at']) return Response({'ok':True}) def get_valid_invitation(token,lock=False): qs=SpaceInvitation.objects.select_related('parent_space', 'created_by') if lock:qs=qs.select_for_update() try:inv=qs.get(token_hash=hash_token(token)) except SpaceInvitation.DoesNotExist:return None if inv.used_at or inv.revoked_at or inv.expires_at<=timezone.now():return None # Revoking the inviter's delegation also invalidates their pending invitations. if not manager(inv.created_by, inv.parent_space):return None return inv @api_view(['GET']) @permission_classes([AllowAny]) @throttle_classes([AnonRateThrottle]) def invitation_preview(request,token): inv=get_valid_invitation(token) if inv is None:return error('Invitation invalide ou expirée.',404) inviter = inv.created_by inviter_name = inviter.get_full_name().strip() or inviter.get_username() return Response({'parent_name':inv.parent_space.display_name, 'inviter_name':inviter_name, 'can_view_parent':inv.can_view_parent, 'can_write_parent':inv.can_write_parent, 'can_invite':inv.can_invite, 'expires_at':inv.expires_at.isoformat()}) @api_view(['POST']) @permission_classes([AllowAny]) @throttle_classes([AnonRateThrottle]) def accept_invitation(request,token): if get_valid_invitation(token) is None:return error('Invitation invalide ou expirée.',404) username=str(request.data.get('username','')).strip() password=request.data.get('password','') email=str(request.data.get('email','')).strip() first_name=str(request.data.get('first_name','')).strip() last_name=str(request.data.get('last_name','')).strip() if not first_name or not last_name or len(first_name)>150 or len(last_name)>150: return error('Prénom et nom obligatoires (150 caractères maximum).') if not username or len(username)>150: return error('Nom d’utilisateur obligatoire (150 caractères maximum).') User=get_user_model() try: User._meta.get_field(User.USERNAME_FIELD).run_validators(username) validate_password(password) except ValidationError as ex:return error(' '.join(ex.messages)) if User.objects.filter(username__iexact=username).exists():return error('Ce nom d’utilisateur est déjà pris.') if email and len(email)>254:return error('Adresse e-mail trop longue.') try: with transaction.atomic(): inv=get_valid_invitation(token,lock=True) if inv is None:return error('Invitation invalide ou déjà utilisée.',404) enforce_delegation(inv.created_by,inv.parent_space,view=True, write=inv.can_write_parent,invite=inv.can_invite) user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name) SpaceGrant.objects.create(user=user,space=inv.parent_space, can_write=inv.can_write_parent,can_invite=inv.can_invite) space=inv.parent_space ensure_private_space(user) inv.used_by=user;inv.used_at=timezone.now() inv.save(update_fields=['used_by','used_at']) except IntegrityError:return error('Le nom d’utilisateur est déjà utilisé.') except ValueError as exc:return error(str(exc),403) return Response({'ok':True,'username':user.username,'space_id':space.pk},status=201) # ----------------------------------------------------------------------------- # Direct access for existing accounts; never relocate their private files. # ----------------------------------------------------------------------------- def grant_info(grant): user = grant.user return {'id': grant.pk, 'user_id': user.pk, 'username': user.username, 'first_name': user.first_name, 'last_name': user.last_name, 'can_write': grant.can_write, 'can_invite': grant.can_invite} def managed_parent(request): if request.query_params.get('parent_space'): try: return DriveSpace.objects.get(pk=int(request.query_params['parent_space'])) except (ValueError, TypeError, DriveSpace.DoesNotExist): return None return ensure_space(request.user) @api_view(['GET']) def search_users(request): parent = managed_parent(request) if parent is None or not manager(request.user, parent): return error('Recherche non autorisée.', 403) query = str(request.query_params.get('q', '')).strip() if len(query) < 2: return Response({'users': []}) User = get_user_model() qs = User.objects.filter(username__icontains=query, is_active=True, is_staff=False, is_superuser=False) qs = qs.exclude(pk=request.user.pk).order_by('username')[:10] return Response({'users': [{'id':u.pk, 'username':u.username, 'first_name':u.first_name,'last_name':u.last_name} for u in qs]}) @api_view(['POST']) def add_existing_user(request): parent = managed_parent(request) if parent is None or not manager(request.user, parent): return error('Ajout non autorisé.',403) try: user_id = int(request.data.get('user_id')) except (TypeError, ValueError): return error('Utilisateur invalide.') User = get_user_model() try: account = User.objects.get(pk=user_id, is_active=True, is_staff=False, is_superuser=False) except User.DoesNotExist: return error('Utilisateur introuvable.',404) if account.pk == parent.user_id or account.pk == request.user.pk: return error('Impossible de vous ajouter vous-même à cet espace.') own = DriveSpace.objects.filter(user=account, private_owner__isnull=True).first() if own and own.parent_id == parent.pk: return error('Cet utilisateur est déjà un sous-utilisateur direct de cet espace.') can_write=request.data.get('can_write', False) can_invite=request.data.get('can_invite', False) if not isinstance(can_write, bool) or not isinstance(can_invite, bool): return error('Permissions invalides.') try:enforce_delegation(request.user,parent,write=can_write,invite=can_invite) except ValueError as exc:return error(str(exc),403) with transaction.atomic(): grant,created = SpaceGrant.objects.select_for_update().get_or_create( user=account, space=parent, defaults={'can_write': can_write,'can_invite':can_invite}) if not created: return error('Cet utilisateur a déjà accès à cet espace.') return Response(grant_info(grant),status=201) @api_view(['PATCH','DELETE']) def edit_existing_user(request, grant_id): try: grant = SpaceGrant.objects.select_related('space','user').get(pk=grant_id) except SpaceGrant.DoesNotExist: return error('Accès introuvable.',404) if not manager(request.user,grant.space): return error('Gestion non autorisée.',403) if request.method == 'DELETE': grant.delete() return Response(status=204) for key in ('can_write','can_invite'): if key in request.data: if not isinstance(request.data[key],bool): return error('Permission invalide.') setattr(grant,key,request.data[key]) try:enforce_delegation(request.user,grant.space,write=grant.can_write,invite=grant.can_invite) except ValueError as exc:return error(str(exc),403) grant.save(update_fields=['can_write','can_invite']) return Response(grant_info(grant)) @api_view(['POST']) def create_shared_space(request): """Create a standalone numbered shared space; no additional user is needed.""" try: name=valid_name(request.data.get('display_name')) if len(name)>120:return error('Nom limité à 120 caractères.') quota=parse_quota(request.data.get('quota_bytes')) parent_id=request.data.get('parent_space') parent=DriveSpace.objects.get(pk=int(parent_id)) if parent_id else ensure_space(request.user) except (ValueError, TypeError) as exc:return error(str(exc)) except DriveSpace.DoesNotExist:return error('Espace parent introuvable.',404) if not manager(request.user,parent):return error('Création interdite dans cet espace.',403) with transaction.atomic(): with locked_quota_tree(parent): try:assert_new_child_quota(parent,quota) except ValueError as exc:return error(str(exc)) space=_make_numbered_space(parent,managed_by=request.user,display_name=name,quota_bytes=quota) return Response(space_info(space,request.user),status=201) @api_view(['PATCH','DELETE']) def edit_shared_space(request,space_id): try:space=DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id) except DriveSpace.DoesNotExist:return error('Espace introuvable.',404) if space.private_owner_id or not (is_admin(request.user) or space.managed_by_id == request.user.pk or (space.user_id == request.user.pk and manager(request.user,space))): return error('Modification interdite.',403) if request.method == 'DELETE': return delete_shared_space(request, space_id) try: with transaction.atomic(): if 'display_name' in request.data: title=valid_name(request.data['display_name']) if len(title)>120:return error('Nom limité à 120 caractères.') space.display_name=title space.save(update_fields=['display_name']) if 'quota_bytes' in request.data: _update_quota(space,request.data['quota_bytes']) except ValueError as exc:return error(str(exc)) return Response(space_info(space,request.user)) _logger = logging.getLogger(__name__) def _remove_deleted_space_files(relative_path, legacy_locations, temp_ids): """Remove only the shared directory after the DB transaction commits.""" root = Path(settings.DRIVE_STORAGE_ROOT).resolve() folder = (root / relative_path).resolve() private_root = root / 'privates' if folder == root or not folder.is_relative_to(root) or folder.is_relative_to(private_root): _logger.error('Invalid space path for disk cleanup: %s', relative_path) return for location in legacy_locations: path = (root / location).resolve() if path.is_relative_to(root) and not path.is_relative_to(private_root) and not path.is_relative_to(folder): try: path.unlink(missing_ok=True) except OSError: _logger.exception('Unable to remove legacy file: %s', path) try: if folder.exists(): shutil.rmtree(folder) except OSError: _logger.exception('Unable to remove deleted space directory: %s', folder) _remove_upload_parts(temp_ids) def _remove_upload_parts(upload_ids): temporary_root = Path(settings.FILE_UPLOAD_TEMP_DIR).resolve() for upload_id in upload_ids: try: (temporary_root / f'drive-{upload_id}.part').unlink(missing_ok=True) except OSError: _logger.exception('Unable to remove canceled upload %s', upload_id) def _clean_root_shared_files(file_locations, folder_locations, upload_ids): """The root DB row and physical storage directory must survive a reset.""" root = Path(settings.DRIVE_STORAGE_ROOT).resolve() private_root = root / 'privates' # Remove only tracked data, not the private directories or unrelated host files. for location in file_locations: path = (root / location).resolve() if path.is_relative_to(root) and not path.is_relative_to(private_root) and path.is_file(): try: path.unlink() except OSError: _logger.exception('Cannot delete root file: %s', path) for location in sorted(folder_locations, key=lambda x: len(x), reverse=True): path = (root / location).resolve() if path != root and path.is_relative_to(root) and not path.is_relative_to(private_root): try: if path.is_dir(): # Only remove empty dirs: unknown files are never destroyed. path.rmdir() except OSError: pass _remove_upload_parts(upload_ids) def delete_shared_space(request, space_id): """Admins can remove all *shared* spaces; private spaces are immutable. Deleting the root clears its shared contents but preserves its database anchor and physical storage directory for all private spaces. """ confirm_name = request.data.get('confirm_name') if not isinstance(confirm_name, str): return error('Confirme la suppression en saisissant le nom exact de l’espace.') with transaction.atomic(): try: space = DriveSpace.objects.select_for_update().select_related('parent').get(pk=space_id) except DriveSpace.DoesNotExist: return error('Espace introuvable.', 404) if space.private_owner_id: return error('Un espace privé ne peut jamais être supprimé.', 403) root = DriveSpace.objects.select_for_update().filter( parent__isnull=True, private_owner__isnull=True).first() if root is None: return error('Espace racine introuvable.', 409) admin = is_admin(request.user) if not admin and (not space.parent_id or space.user_id or space.managed_by_id != request.user.pk): return error('Suppression interdite.', 403) if confirm_name != space.display_name: return error('Le nom de confirmation ne correspond pas au nom de l’espace.') # Lock entire hierarchy to make re-parenting deterministic. all_spaces = list(DriveSpace.objects.select_for_update().order_by('pk')) # Do not remove any shared directory while a legacy private directory # still lives outside storage/privates. Migrate its physical layout first. if any(node.private_owner_id and not node.relative_path.startswith('privates/') for node in all_spaces): return error('Déplace d’abord les espaces privés vers storage/privates avant toute suppression.', 409) is_root_reset = space.pk == root.pk if is_root_reset and not admin: return error('Suppression interdite.', 403) prefix = space.relative_path + '/' if space.relative_path else '' targets = [node for node in all_spaces if not node.private_owner_id and ( is_root_reset or node.pk == space.pk or node.relative_path.startswith(prefix))] to_delete = [node for node in targets if not is_root_reset or node.pk != root.pk] ids = [node.pk for node in targets] # Private spaces retain their location under storage/privates and are # only relinked logically to root, so no private data is ever deleted. for private in all_spaces: if private.private_owner_id and private.parent_id in ids and private.parent_id != root.pk: private.parent = root private.save(update_fields=['parent']) root_items = list(DriveItem.objects.filter(space=root)) if is_root_reset else [] root_files = [(i.storage_location or f'{i.storage_key[:2]}/{i.storage_key}') for i in root_items if not i.is_folder and i.storage_key] # Directories can be represented in SQL without an on-disk file path. root_folders = [] if is_root_reset: from .storage_layout import file_location for item in root_items: if item.is_folder: root_folders.append(file_location(root, item.parent, item.name)) file_records = list(DriveItem.objects.filter(space_id__in=ids, is_folder=False).values_list( 'space_id', 'storage_location', 'storage_key')) legacy = [location or f'{key[:2]}/{key}' for sid, location, key in file_records if key and not location and (not is_root_reset or sid != root.pk)] upload_ids = list(UploadSession.objects.select_for_update().filter( space_id__in=ids).values_list('pk', flat=True)) DriveItem.objects.filter(space_id__in=ids).delete() # Foreign keys to these spaces cascade (grants, invitations, uploads). for node in sorted(to_delete, key=lambda x: len(x.relative_path), reverse=True): node.delete() if is_root_reset: # Clean each shared child directory, but NEVER storage/privates. top_dirs = [node.relative_path for node in to_delete if node.parent_id == root.pk] transaction.on_commit(lambda: ( [_remove_deleted_space_files(location, [], []) for location in top_dirs], _clean_root_shared_files(root_files + legacy, root_folders, upload_ids))) else: transaction.on_commit(lambda: _remove_deleted_space_files( space.relative_path, legacy, upload_ids)) return Response({'deleted_space_ids': ids, 'root_reset': is_root_reset}) @api_view(['GET']) def admin_users(request): if not is_admin(request.user):return error('Accès réservé aux administrateurs.',403) query = str(request.query_params.get('q','')).strip()[:100] User = get_user_model() qs = User.objects.filter(drive_archive__isnull=True).order_by('username') if query: qs = qs.filter(Q(username__icontains=query)|Q(first_name__icontains=query)|Q(last_name__icontains=query)|Q(email__icontains=query)) result=[] for user in qs[:201]: assigned=DriveSpace.objects.filter(user=user,private_owner__isnull=True).first() if not assigned: grant=SpaceGrant.objects.filter(user=user).order_by('created_at','pk').first() assigned=grant.space if grant else None result.append({'id':user.pk,'username':user.username,'first_name':user.first_name, 'last_name':user.last_name,'email':user.email,'is_active':user.is_active, 'is_admin':is_admin(user), 'space_name':assigned.display_name if assigned else None, 'date_joined':user.date_joined.isoformat()}) return Response({'users':result[:200], 'limited':len(result)>200}) @api_view(['GET']) def admin_user_access(request, user_id): """Show only shared-space access, never a user's private storage.""" if not is_admin(request.user): return error('Accès réservé aux administrateurs.', 403) User = get_user_model() try: target = User.objects.get(pk=user_id, drive_archive__isnull=True) except User.DoesNotExist: return error('Utilisateur introuvable.', 404) if is_admin(target): return error('Impossible de modifier les accès d’un administrateur.', 403) accesses = [] for grant in SpaceGrant.objects.filter(user=target, space__private_owner__isnull=True).select_related('space'): accesses.append({'kind':'grant', 'space_id':grant.space_id, 'space_name':grant.space.display_name}) for space in DriveSpace.objects.filter(user=target, private_owner__isnull=True): accesses.append({'kind':'owner', 'space_id':space.pk, 'space_name':space.display_name}) for space in DriveSpace.objects.filter(managed_by=target, private_owner__isnull=True): accesses.append({'kind':'manager', 'space_id':space.pk, 'space_name':space.display_name}) return Response({'accesses': accesses}) @api_view(['DELETE']) def admin_revoke_user_access(request, user_id, kind, space_id): """Revoke a space membership, legacy assignment, or managed-space grant. Never delete the shared space itself, its contents, or the user's private space. Other independent grants remain intact. """ if not is_admin(request.user): return error('Accès réservé aux administrateurs.', 403) if kind not in ('grant', 'owner', 'manager'): return error('Type d’accès inconnu.', 404) User = get_user_model() with transaction.atomic(): try: target = User.objects.select_for_update().get(pk=user_id, drive_archive__isnull=True) except User.DoesNotExist: return error('Utilisateur introuvable.', 404) if is_admin(target): return error('Impossible de modifier les accès d’un administrateur.', 403) qs = DriveSpace.objects.select_for_update().filter(pk=space_id, private_owner__isnull=True) if kind == 'grant': deleted, _ = SpaceGrant.objects.filter(space__in=qs, user=target).delete() if not deleted:return error('Accès introuvable.', 404) elif kind == 'owner': if not qs.filter(user=target).update(user=None): return error('Accès introuvable.', 404) else: if not qs.filter(managed_by=target).update(managed_by=None): return error('Accès introuvable.', 404) return Response(status=204) def _delete_staged_private(directory): """Physically remove a private directory after the SQL transaction commits.""" try: if directory is not None and directory.exists(): shutil.rmtree(directory) except OSError: _logger.exception('Échec de suppression du dossier privé %s', directory) def _delete_legacy_private_files(locations): """Remove only legacy UUID-key files stored directly at storage/ab/uuid.""" import uuid root = Path(settings.DRIVE_STORAGE_ROOT).resolve() for location in locations: try: parts = Path(location).parts if len(parts) != 2 or len(parts[0]) != 2 or not all(c in '0123456789abcdef' for c in parts[0]): continue if str(uuid.UUID(parts[1])) != parts[1] or parts[0] != parts[1][:2]: continue # Never remove a legacy file still referenced by a shared item. if DriveItem.objects.filter(storage_key=parts[1],storage_location='').exists(): continue path = root.joinpath(*parts) if path.is_symlink() or not path.resolve().is_relative_to(root): continue path.unlink(missing_ok=True) except (ValueError, OSError): _logger.exception('Impossible de supprimer le fichier privé historique : %s', location) @api_view(['DELETE']) def admin_delete_user(request, user_id): """Deactivate an account and PERMANENTLY remove its private files and space. Historical shared files keep the archived owner row because their owner FK is PROTECT; they are not removed by account deletion. """ if not is_admin(request.user): return error('Accès réservé aux administrateurs.', 403) User = get_user_model() staged = None original = None try: with transaction.atomic(): try: target = User.objects.select_for_update().get(pk=user_id, drive_archive__isnull=True) except User.DoesNotExist: return error('Utilisateur introuvable.', 404) if target.pk == request.user.pk or is_admin(target): return error('Impossible de supprimer un administrateur.', 403) if request.data.get('confirm_username') != target.username: return error('Saisis le nom d’utilisateur pour confirmer.') private = DriveSpace.objects.select_for_update().filter(private_owner=target).first() legacy = [] if private: # Never risk deleting an old shared directory when a private # space has not yet been migrated to storage/privates/. root = Path(settings.DRIVE_STORAGE_ROOT).resolve() private_root = root / 'privates' original = root / private.relative_path if (not private.relative_path.startswith('privates/') or original.parent != private_root or original.is_symlink() or private_root.is_symlink() or not original.resolve().is_relative_to(private_root.resolve())): return error('Déplace d’abord le stockage privé vers storage/privates.', 409) if DriveSpace.objects.filter(parent=private).exists(): return error('Cet espace privé possède encore des sous-espaces.', 409) # Legacy /ab/UUID paths are outside the numbered private folder. for loc, key in DriveItem.objects.filter(space=private, is_folder=False).values_list('storage_location','storage_key'): if key and not loc: legacy.append(f'{key[:2]}/{key}') if original.exists(): # Atomic rename to a location that is never browsable by # another account. Roll back the rename on DB failure. staged = private_root / f'.deleting-{private.pk}-{secrets.token_hex(12)}' original.rename(staged) # Deleting DriveItems removes their grants and public links. DriveItem.objects.filter(space=private).delete() UploadSession.objects.filter(space=private).delete() private.delete() now = timezone.now() SpaceInvitation.objects.filter(created_by=target, used_at__isnull=True, revoked_at__isnull=True).update(revoked_at=now) PublicItemLink.objects.filter(created_by=target, revoked_at__isnull=True).update(revoked_at=now) ItemGrant.objects.filter(Q(user=target) | Q(created_by=target)).delete() SpaceGrant.objects.filter(user=target).delete() # Shared spaces survive, but their former owner loses access. DriveSpace.objects.filter(user=target, private_owner__isnull=True).update(user=None) DriveSpace.objects.filter(managed_by=target, private_owner__isnull=True).update(managed_by=None) unfinished = list(UploadSession.objects.filter(owner=target).values_list('pk',flat=True)) UploadSession.objects.filter(owner=target).delete() target.is_active = False target.username = f'supprime-{target.pk}-{secrets.token_hex(5)}' target.first_name = '' target.last_name = '' target.email = '' target.set_unusable_password() target.save(update_fields=['is_active','username','first_name','last_name','email','password']) ArchivedDriveAccount.objects.create(user=target) # Work only after a successful DB commit; TestCase can explicitly # execute callbacks to validate the physical removal. def complete_cleanup(): _delete_staged_private(staged) _delete_legacy_private_files(legacy) _remove_upload_parts(unfinished) transaction.on_commit(complete_cleanup) except Exception: if staged is not None and staged.exists() and original is not None and not original.exists(): staged.rename(original) raise return Response(status=204)