"""Space-bound navigation, folder management and trash endpoints.""" from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import Sum, Count from django.utils import timezone from rest_framework.decorators import api_view from rest_framework.response import Response from .models import DriveItem, DriveSpace, ItemGrant from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, can_write_space, can_read_space from .permissions import has_space_right, item_rights from .quotas import quota_state from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination from .storage_layout import assert_available, prepare_parent, relocate_item, move_to_trash, NameConflict from .trash_cleanup import delete_trashed_item @api_view(['GET']) def me(request): space=ensure_space(request.user) private=ensure_private_space(request.user) return Response({'id':request.user.pk,'username':request.user.username, 'is_admin':is_admin(request.user),'first_name':request.user.first_name, 'last_name':request.user.last_name,'private_space_id':private.pk, 'space_id':space.pk if space else None,'can_invite':bool(space and space.can_invite) or is_admin(request.user)}) @api_view(['GET']) def listing(request): user=request.user view=request.query_params.get('view','files') if view not in ('files','trash','shared'): return error('Vue inconnue.',400) q=request.query_params.get('q','').strip() parent_id=request.query_params.get('parent') parent=None # Trash is global across *readable* spaces, regardless of the selected space. # In particular another person's private space is never included, even for admin. if view=='trash': accessible=[space.pk for space in DriveSpace.objects.all() if can_read_space(user,space)] nodes=DriveItem.objects.filter(space_id__in=accessible,trashed_at__isnull=False).select_related('owner','parent','space').prefetch_related('user_shares','public_links') if q:nodes=nodes.filter(name__icontains=q) space=None elif view=='shared': space=None if parent_id: parent=get_item(parent_id) if not parent.is_folder or not readable(parent,user): return error('Dossier partagé inaccessible.',403) # Without inherited item permission or normal space access, browsing is forbidden. nodes=DriveItem.objects.filter(parent=parent,trashed_at__isnull=True).select_related('owner','parent','space').prefetch_related('user_shares','public_links') else: nodes=DriveItem.objects.filter(user_shares__user=user,trashed_at__isnull=True).select_related('owner','parent','space').prefetch_related('user_shares','public_links').distinct() if q:nodes=nodes.filter(name__icontains=q) else: try:space=resolve_space(user,request.query_params.get('space')) except PermissionError as ex:return error(str(ex),403) if parent_id: parent=get_item(parent_id) if not parent.is_folder or parent.space_id!=space.pk or not readable(parent,user): return error('Dossier inaccessible.',403) nodes=DriveItem.objects.filter(space=space,trashed_at__isnull=True).select_related('owner','parent','space').prefetch_related('user_shares','public_links') if q: nodes=nodes.filter(name__icontains=q) elif parent: nodes=nodes.filter(parent=parent) else: nodes=nodes.filter(parent__isnull=True) items=[] for n in nodes[:501]: if readable(n,user,allow_trashed=view=='trash'): info=item_info(n,user) if view in ('trash','shared'):info['space_name']=n.space.display_name if not n.space.private_owner_id else 'Mes fichiers' if view=='shared': for ancestor in chain(n): if ItemGrant.objects.filter(item=ancestor,user=user).exists(): info['share_root_id']=str(ancestor.pk) break items.append(info) if len(items)>=500:break trail=[] if parent: ancestors=list(reversed(chain(parent))) if view=='shared' and not can_read_space(user,parent.space): shared_ancestors=set(ItemGrant.objects.filter(user=user,item_id__in=[a.pk for a in ancestors]).values_list('item_id',flat=True)) for index,a in enumerate(ancestors): if a.pk in shared_ancestors: ancestors=ancestors[index:];break for n in ancestors: trail.append({'id':str(n.id),'name':n.name}) return Response({'items':items,'breadcrumb':trail,'limited':len(items)>=500, 'can_write':('add' in item_rights(user,parent)) if view=='shared' and parent else (has_space_right(user,space,'add') if space else False), 'space_id':parent.space_id if view=='shared' and parent else (space.pk if space else None)}) @api_view(['GET']) def stats(request): try:space=resolve_space(request.user,request.query_params.get('space')) except PermissionError as ex:return error(str(ex),403) result=DriveItem.objects.filter(space=space,trashed_at__isnull=True,is_folder=False).aggregate(count=Count('id'),total=Sum('size')) return Response({'files':result['count'] or 0,'bytes':result['total'] or 0, **quota_state(space), 'folders':DriveItem.objects.filter(space=space,is_folder=True,trashed_at__isnull=True).count(), 'shared':0}) @api_view(['POST']) def create_folder(request): try: name=valid_name(request.data.get('name')) space,parent=writable_destination(request.user,request.data.get('space'),request.data.get('parent')) except ValueError as ex:return error(str(ex)) except PermissionError as ex:return error(str(ex),403) with transaction.atomic(): try: destination=assert_available(space,parent,name) prepare_parent(space,parent) destination.mkdir(mode=0o700) except (NameConflict,FileExistsError) as exc: return error(str(exc),409) except OSError as exc: return error(f'Création impossible : {exc}') folder=DriveItem(owner=request.user,space=space,parent=parent,name=name,is_folder=True) try: folder.full_clean();folder.save() except Exception: destination.rmdir() raise return Response(item_info(folder,request.user),status=201) @api_view(['PATCH']) def update_item(request,item_id): item=get_item(item_id) if not readable(item,request.user) or not writable(item,request.user,'modify'): return error('Modification interdite.',403) data=request.data try: new_name=valid_name(data['name']) if 'name' in data else item.name new_parent=(target_parent(data['parent'],request.user,item.space) if 'parent' in data else item.parent) except ValueError as exc:return error(str(exc)) except PermissionError as exc:return error(str(exc),403) if 'is_shared' in data: return error('Le partage global est désactivé : utilise les espaces et invitations.') if 'parent' in data and (new_parent.pk if new_parent else None)!=item.parent_id: # Moving to the space root requires space membership with Add. # A recipient can reorganize only inside a folder that grants Add. if new_parent is None and not has_space_right(request.user,item.space,'add'): return error('Déplacement hors du dossier partagé interdit.',403) if not has_space_right(request.user,item.space,'modify'): # Do not allow moving the shared root itself: it belongs to its creator. if ItemGrant.objects.filter(item=item,user=request.user).exists(): return error('Impossible de déplacer le dossier ou fichier partagé lui-même.',403) if new_parent and (new_parent.pk==item.pk or item.pk in [n.pk for n in chain(new_parent)]): return error('Déplacement impossible dans un sous-dossier de cet élément.') if new_name==item.name and (new_parent.pk if new_parent else None)==item.parent_id: return Response(item_info(item,request.user)) try: relocate_item(item,new_parent,new_name) except (NameConflict,FileExistsError) as exc: return error(str(exc),409) except (OSError,ValueError,ValidationError) as exc: return error(f'Déplacement impossible : {exc}',409) return Response(item_info(item,request.user)) @api_view(['POST']) def trash_item(request,item_id): item=get_item(item_id) if not readable(item,request.user) or not writable(item,request.user,'delete'):return error('Accès refusé.',403) if item.trashed_at:return error('Élément déjà dans la corbeille.') try: move_to_trash(item) except (OSError, ValueError) as exc: return error(f'Mise à la corbeille impossible : {exc}',409) return Response({'ok':True}) @api_view(['POST']) def restore_item(request,item_id): item=get_item(item_id) if not writable(item,request.user,'delete'):return error('Accès refusé.',403) if not item.trashed_at:return error('Cet élément n’est pas dans la corbeille.') parent = None if item.parent and any(n.trashed_at for n in chain(item.parent)) else item.parent # If the name has already been recreated, automatically restore with a suffix. name = item.name try: from .storage_layout import assert_available try: assert_available(item.space, parent, name, exclude=item.pk) except NameConflict: stem, dot, extension = name.rpartition('.') if not stem or not dot: stem, extension = name, '' suffix = f'.{extension}' if extension else '' for index in range(2, 10002): tag = f' (restauré {index})' candidate = stem[:255-len(tag)-len(suffix)] + tag + suffix try: assert_available(item.space, parent, candidate, exclude=item.pk) name = candidate break except NameConflict: continue else: return error('Impossible de trouver un nom libre pour restaurer cet élément.',409) # Do not expose the element in active listings before its physical move. with transaction.atomic(): relocate_item(item, parent, name) item.trashed_at = None item.save(update_fields=['trashed_at','updated_at']) except (NameConflict,FileExistsError,OSError,ValueError,ValidationError) as exc: return error(f'Restauration impossible : {exc}',409) return Response(item_info(item,request.user)) @api_view(['DELETE']) def delete_item(request,item_id): item=get_item(item_id) if not writable(item,request.user,'delete'):return error('Accès refusé.',403) if not item.trashed_at:return error('Déplace d’abord cet élément dans la corbeille.') try: delete_trashed_item(item) except (OSError,ValueError) as exc: return error(f'Suppression impossible : {exc}',409) return Response(status=204)