"""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 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_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): # Administrators can manage any descendant; delegates their own direct children only. return can_manage_space(user, parent) 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.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, '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) 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.') 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 fields['can_write_parent'] and not fields['can_view_parent']: return error('L’écriture nécessite la lecture de l’espace parent.') 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','') display_name=str(request.data.get('display_name','')).strip() 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 or not display_name or len(display_name)>120: return error('Nom d’utilisateur et nom d’espace obligatoires (150/120 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) with locked_quota_tree(inv.parent_space): user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name) space=create_space(user,inv.parent_space,display_name, can_view_parent=inv.can_view_parent,can_write_parent=inv.can_write_parent, can_invite=inv.can_invite) 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é.') 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 = ensure_space(account) if 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.') 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]) 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 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): """Executed only AFTER commit: keep SQL changes atomic before touching disk.""" root = Path(settings.DRIVE_STORAGE_ROOT).resolve() folder = (root / relative_path).resolve() if folder == root or not folder.is_relative_to(root): _logger.error('Invalid space path for disk cleanup: %s', relative_path) return # Old files may predate the numbered storage directories. for location in legacy_locations: path = (root / location).resolve() if path.is_relative_to(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) temporary_root = Path(settings.FILE_UPLOAD_TEMP_DIR).resolve() for upload_id in temp_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 delete_shared_space(request, space_id): """Permanently delete a manually managed space and its independent descendants. Never delete root, assigned account spaces, or ANY private space, including a private descendant. All cleanup is committed before disk deletion starts. """ 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 not space.parent_id or space.user_id or space.private_owner_id or not space.managed_by_id: return error('Les espaces personnels, attribués et racine ne peuvent pas être supprimés.', 403) if not manager(request.user, space): 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.') # Also lock the parent: creation and deletion of siblings are serialized. list(DriveSpace.objects.select_for_update().filter(pk=space.parent_id)) descendants = list(DriveSpace.objects.select_for_update().filter( Q(relative_path=space.relative_path) | Q(relative_path__startswith=space.relative_path + '/') ).order_by('-relative_path')) if any(child.user_id or child.private_owner_id or not child.managed_by_id for child in descendants): return error('Cet espace contient un espace attribué ou privé. Réorganise les sous-utilisateurs avant de le supprimer.', 409) ids = [child.pk for child in descendants] files = list(DriveItem.objects.filter(space_id__in=ids, is_folder=False).values_list( 'storage_location', 'storage_key')) legacy_locations = [location or f'{key[:2]}/{key}' for location, key in files if key and not location] upload_ids = list(UploadSession.objects.select_for_update().filter( space_id__in=ids).values_list('pk', flat=True)) # Children of DriveItem use CASCADE; item ownership and other accounts # are left intact, only metadata from these owned spaces is deleted. DriveItem.objects.filter(space_id__in=ids).delete() for child in descendants: child.delete() # bottom-up, as DriveSpace.parent uses PROTECT transaction.on_commit(lambda: _remove_deleted_space_files( space.relative_path, legacy_locations, upload_ids)) return Response({'deleted_space_ids': ids})