GooDrive/backend/drive/views_items.py
Louis Mazin 26e0e05754 test
2026-10-14 18:57:05 +02:00

263 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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, cleanup_empty_trash_container, NameConflict
from .trash_cleanup import delete_trashed_item, has_active_uploads_in_tree
@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'])
# The restored item is no longer in its .gooddrive-trash/<ID>/ folder.
# Leaving that empty directory prevents the next trash operation.
cleanup_empty_trash_container(item)
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)
@api_view(['DELETE'])
def empty_trash(request):
"""Permanently delete only trashed items for which the caller has Delete.
Traverse all spaces rather than only the first 500 results in the UI.
An admin never acquires access to another user's private space.
"""
from .spaces import can_read_space
allowed_spaces = [s.pk for s in DriveSpace.objects.all() if can_read_space(request.user, s)]
candidates = list(DriveItem.objects.filter(
space_id__in=allowed_spaces, trashed_at__isnull=False
).order_by('trashed_at').values_list('pk', flat=True))
deleted = skipped = 0
for pk in candidates:
# A parent already purged may have removed this item by cascade.
item = DriveItem.objects.select_related('owner', 'parent', 'space').filter(pk=pk).first()
if item is None:
continue
if not readable(item, request.user, allow_trashed=True) or not writable(item, request.user, 'delete'):
skipped += 1
continue
if has_active_uploads_in_tree(item):
skipped += 1
continue
try:
delete_trashed_item(item)
except (ValueError, OSError):
skipped += 1
else:
deleted += 1
return Response({'deleted': deleted, 'skipped': skipped})