"""Space quotas, including logical descendants, trash, and pending uploads. A child can be constrained by both its own allocation and its ancestors'. Call assert_space_capacity() inside transaction.atomic() before reserving/writing. """ from contextlib import contextmanager from django.db import transaction from django.db.models import Sum from .models import DriveItem, DriveSpace, UploadSession MAX_QUOTA_BYTES = 9_007_199_254_740_991 # exact integer in JS too PRIVATE_SPACE_QUOTA_BYTES = 50 * 1024 ** 3 # Maximum privé, non réservé dans le quota attribué class QuotaExceeded(Exception): pass def parse_quota(value): """None = unlimited. Accept only an integer number of bytes (0 is valid).""" if value is None: return None if isinstance(value, bool) or isinstance(value, float): raise ValueError('Le quota doit être un entier en octets, ou null pour illimité.') if isinstance(value, str) and (not value.strip().isdigit()): raise ValueError('Le quota doit être un entier en octets, ou null pour illimité.') try: quota = int(value) except (TypeError, ValueError, OverflowError): raise ValueError('Quota invalide.') from None if quota < 0 or quota > MAX_QUOTA_BYTES: raise ValueError('Quota hors limites (0 à 9 Po environ).') return quota def ancestry(space): result = [] seen = set() current = space while current is not None: if current.pk in seen: raise ValueError('Hiérarchie des espaces invalide.') seen.add(current.pk) result.append(current) current = current.parent return result def subtree_space_ids(space): """Resolve the logical quota subtree, not the filesystem path. Private accounts store files below storage/privates/ but are children of their assigned spaces *for quota accounting*. Physical path-prefix queries would silently omit their real disk usage. """ by_parent = {} for pk, parent_id in DriveSpace.objects.values_list('pk', 'parent_id'): by_parent.setdefault(parent_id, []).append(pk) result = set() to_visit = [space.pk] while to_visit: pk = to_visit.pop() if pk in result: raise ValueError('Hiérarchie des espaces invalide.') result.add(pk) to_visit.extend(by_parent.get(pk, [])) return result def occupancy(space): """Count ALL files, even in the trash. Pending sessions reserve their full size.""" ids = subtree_space_ids(space) files = DriveItem.objects.filter(space_id__in=ids, is_folder=False).aggregate(n=Sum('size'))['n'] or 0 pending = UploadSession.objects.filter(space_id__in=ids).aggregate(n=Sum('total_size'))['n'] or 0 return int(files), int(pending) def trash_occupancy(space): """Bytes still on disk in the trash, including descendants of trashed folders. Ordinary (not trashed) folders require no traversal. Only traverse the descendants of explicitly trashed folders; avoid counting trashed files twice. """ ids = subtree_space_ids(space) bytes_in_trash = DriveItem.objects.filter( space_id__in=ids, is_folder=False, trashed_at__isnull=False ).aggregate(n=Sum('size'))['n'] or 0 folder_ids = list(DriveItem.objects.filter( space_id__in=ids, is_folder=True, trashed_at__isnull=False ).values_list('pk', flat=True)) visited = set(folder_ids) while folder_ids: next_folders = [] for start in range(0, len(folder_ids), 300): for pk, is_folder, size, trashed in DriveItem.objects.filter( space_id__in=ids, parent_id__in=folder_ids[start:start + 300] ).values_list('pk', 'is_folder', 'size', 'trashed_at'): if is_folder: if pk not in visited: visited.add(pk) next_folders.append(pk) elif trashed is None: bytes_in_trash += size folder_ids = next_folders return int(bytes_in_trash) def available_for_growth(space): """Unreserved quotas: every upload consumes capacity in its space and ALL ancestors.""" limits = [] for node in ancestry(space): if node.quota_bytes is not None: used, pending = occupancy(node) limits.append(node.quota_bytes - used - pending) return max(0, min(limits)) if limits else None def assert_new_child_quota(parent, quota): """A child limit may not exceed its parent's maximum, but reserves nothing.""" if quota is None and parent.quota_bytes is not None: raise ValueError('Le quota du sous-espace doit respecter la limite de son parent.') if quota is not None: limits = [node.quota_bytes for node in ancestry(parent) if node.quota_bytes is not None] if limits and quota > min(limits): raise ValueError('Le quota du sous-espace dépasse la limite de son parent.') def assert_updated_quota(space, quota): """Validate actual occupancy, nested maximums, never preallocate capacity.""" used, pending = occupancy(space) if quota is not None and quota < used + pending: raise ValueError('Le quota ne peut pas être inférieur au stockage utilisé.') if space.parent_id: assert_new_child_quota(space.parent, quota) if quota is not None: # Keep the hierarchy valid if the parent limit shrinks. descendants = subtree_space_ids(space) - {space.pk} existing = DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True, quota_bytes__isnull=False) if existing.filter(quota_bytes__gt=quota).exists(): raise ValueError('Le quota est inférieur à la limite d’un sous-espace existant.') if quota == 0 and DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True, quota_bytes__isnull=True).exists(): raise ValueError('Un sous-espace sans limite empêche ce changement.') def quota_state(space): used, reserved = occupancy(space) trashed = trash_occupancy(space) available = available_for_growth(space) # This number indicates effective capacity already unavailable within a # child cap, including bytes used elsewhere in a shared parent. effective_used = used if not space.private_owner_id and space.quota_bytes is not None and available is not None: effective_used = max(used + reserved, space.quota_bytes - available) return { 'quota_bytes': space.quota_bytes, 'used_bytes': max(0, effective_used - reserved), 'actual_used_bytes': used, 'active_bytes': max(0, used - trashed), 'trashed_bytes': trashed, 'reserved_bytes': reserved, 'available_bytes': available, 'effective_limit_bytes': None if available is None else effective_used + available, } @contextmanager def locked_quota_tree(space): """Lock ancestors in deterministic order so uploads and limit changes serialize.""" if not transaction.get_connection().in_atomic_block: raise RuntimeError('locked_quota_tree must run inside transaction.atomic()') ids = sorted(a.pk for a in ancestry(space)) list(DriveSpace.objects.select_for_update().filter(pk__in=ids).order_by('pk')) yield def assert_space_capacity(space, additional_bytes): """Called with ancestor locks; private uploads count toward both caps.""" available=available_for_growth(space) if available is not None and additional_bytes>available: raise QuotaExceeded( f'Espace insuffisant : {available} octets encore disponibles après les quotas des sous-espaces.' )