diff --git a/backend/drive/test_private_layout.py b/backend/drive/test_private_layout.py deleted file mode 100644 index fe8920a..0000000 --- a/backend/drive/test_private_layout.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Private disk layout, 50-Go private cap, and real-byte pooled accounting. - -Run with: USE_SQLITE=true python manage.py test drive.test_private_layout -""" -import tempfile -import uuid -from pathlib import Path - -from django.contrib.auth import get_user_model -from django.core.files.uploadedfile import SimpleUploadedFile -from django.core.management import call_command -from django.core.management.base import CommandError -from django.db import transaction -from django.test import TestCase, override_settings -from rest_framework.test import APIClient - -from .models import DriveItem, DriveSpace -from .quotas import (PRIVATE_SPACE_QUOTA_BYTES, QuotaExceeded, assert_space_capacity, - assert_updated_quota, locked_quota_tree, occupancy, quota_state) -from .spaces import ensure_private_space, ensure_space, create_space, storage_path - - -class PrivateStorageTests(TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.addCleanup(self.temp.cleanup) - self.rootdir = Path(self.temp.name) - overrides = override_settings( - DRIVE_STORAGE_ROOT=self.rootdir, - FILE_UPLOAD_TEMP_DIR=str(self.rootdir / '.uploads'), - ) - overrides.enable() - self.addCleanup(overrides.disable) - User = get_user_model() - self.admin = User.objects.create_superuser('root-admin', 'admin@example.org', 'TestPassword123!') - self.shared = ensure_space(self.admin) - self.private = ensure_private_space(self.admin) - self.client = APIClient() - self.client.force_authenticate(user=self.admin) - - def test_private_directories_are_separate_from_shared_spaces(self): - self.assertEqual(self.private.parent_id, self.shared.pk) - self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}') - self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES) - self.assertTrue((self.rootdir / self.private.relative_path).is_dir()) - person = get_user_model().objects.create_user('member', password='TestPassword123!') - shared = create_space(person, self.shared, 'Compte membre') - private = ensure_private_space(person) - self.assertTrue(shared.relative_path.startswith(f'{shared.directory_name}')) - self.assertEqual(private.relative_path, f'privates/{private.directory_name}') - self.assertEqual(private.parent_id, shared.pk) - self.assertNotEqual(shared.directory_name, private.directory_name) - - def test_private_usage_counts_toward_assigned_space_without_reserving(self): - self.shared.quota_bytes = 100 - self.shared.save(update_fields=['quota_bytes']) - # Private can be 50 Go even when the assigned budget is just 100 bytes. - self.assertEqual(quota_state(self.shared)['available_bytes'], 100) - self.assertEqual(quota_state(self.private)['available_bytes'], 100) - created = self.client.post('/api/drive/uploads/', { - 'files': [SimpleUploadedFile('photo.jpg', b'abcdef')] - }, format='multipart') - self.assertEqual(created.status_code, 201, created.data) - node = DriveItem.objects.get(pk=created.data['items'][0]['id']) - self.assertEqual(node.storage_location, f'{self.private.relative_path}/photo.jpg') - self.assertEqual(storage_path(node).read_bytes(), b'abcdef') - self.assertEqual(occupancy(self.shared)[0], 6) - self.assertEqual(quota_state(self.shared)['available_bytes'], 94) - self.assertEqual(quota_state(self.private)['available_bytes'], 94) - self.assertEqual(quota_state(self.private)['quota_bytes'], PRIVATE_SPACE_QUOTA_BYTES) - self.assertEqual(quota_state(self.private)['used_bytes'], 6) - rejected = self.client.patch(f'/api/drive/spaces/{self.private.pk}/quota/', { - 'quota_bytes': None - }, format='json') - self.assertEqual(rejected.status_code, 403) - - def test_actual_private_use_is_pooled_in_100_go_assigned_space(self): - person = get_user_model().objects.create_user('quota-user', password='TestPassword123!') - assigned = create_space(person, self.shared, '100 Go', quota_bytes=100 * 1024 ** 3) - private = ensure_private_space(person) - gib = 1024 ** 3 - self.assertEqual(private.parent_id, assigned.pk) - self.assertEqual(private.relative_path, f'privates/{private.directory_name}') - self.assertEqual(quota_state(assigned)['available_bytes'], 100 * gib) - self.assertEqual(quota_state(private)['available_bytes'], 50 * gib) - # Existing 20 Go in shared space. This leaves both limits at their max. - DriveItem.objects.create( - owner=person, space=assigned, name='shared.bin', - storage_key=str(uuid.uuid4()), storage_location=f'{assigned.relative_path}/shared.bin', - size=20 * gib, - ) - self.assertEqual(quota_state(assigned)['used_bytes'], 20 * gib) - self.assertEqual(quota_state(private)['used_bytes'], 0) - DriveItem.objects.create( - owner=person, space=private, name='private.bin', - storage_key=str(uuid.uuid4()), storage_location=f'{private.relative_path}/private.bin', - size=20 * gib, - ) - self.assertEqual(occupancy(assigned)[0], 40 * gib) - self.assertEqual(quota_state(assigned)['used_bytes'], 40 * gib) - self.assertEqual(quota_state(assigned)['available_bytes'], 60 * gib) - self.assertEqual(quota_state(private)['used_bytes'], 20 * gib) - self.assertEqual(quota_state(private)['available_bytes'], 30 * gib) - with transaction.atomic(): - with locked_quota_tree(assigned): - assert_space_capacity(assigned, 60 * gib) - with self.assertRaises(QuotaExceeded): - assert_space_capacity(assigned, 60 * gib + 1) - assert_updated_quota(assigned, 40 * gib) - with self.assertRaisesRegex(ValueError, 'fichiers'): - assert_updated_quota(assigned, 39 * gib) - - def test_parent_limit_blocks_private_even_before_50_go(self): - self.shared.quota_bytes = 20 - self.shared.save(update_fields=['quota_bytes']) - DriveItem.objects.create( - owner=self.admin, space=self.private, name='existing.bin', - storage_key=str(uuid.uuid4()), storage_location=f'{self.private.relative_path}/existing.bin', size=19, - ) - self.assertEqual(quota_state(self.private)['available_bytes'], 1) - with transaction.atomic(): - with locked_quota_tree(self.private): - assert_space_capacity(self.private, 1) - with self.assertRaises(QuotaExceeded): - assert_space_capacity(self.private, 2) - - def test_invitations_allow_less_than_50_go_no_reservation(self): - self.shared.quota_bytes = 15 - self.shared.save(update_fields=['quota_bytes']) - res = self.client.post('/api/drive/invitations/', {'quota_bytes': 10}, format='json') - self.assertEqual(res.status_code, 201, res.data) - self.assertEqual(quota_state(self.shared)['available_bytes'], 15) - member = get_user_model().objects.create_user('tiny-private', password='TestPassword123!') - assigned = create_space(member, self.shared, '10 octets', quota_bytes=10) - private = ensure_private_space(member) - self.assertEqual(private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES) - self.assertEqual(quota_state(assigned)['available_bytes'], 10) - self.assertEqual(quota_state(private)['available_bytes'], 10) - - def test_private_pending_uploads_count_in_parent(self): - from .models import UploadSession - UploadSession.objects.create( - owner=self.admin, space=self.private, name='movie.bin', total_size=1234 - ) - self.assertEqual(quota_state(self.shared)['reserved_bytes'], 1234) - self.assertEqual(quota_state(self.shared)['used_bytes'], 0) - self.assertEqual(quota_state(self.private)['available_bytes'], PRIVATE_SPACE_QUOTA_BYTES - 1234) - - def test_private_50_go_upload_limit_is_enforced(self): - DriveItem.objects.create( - owner=self.admin, space=self.private, name='large.bin', - storage_key=str(uuid.uuid4()), - storage_location=f'{self.private.relative_path}/large.bin', - size=PRIVATE_SPACE_QUOTA_BYTES - 1, - ) - with transaction.atomic(): - with locked_quota_tree(self.private): - with self.assertRaises(QuotaExceeded): - assert_space_capacity(self.private, 2) - assert_space_capacity(self.private, 1) - - def simulate_legacy_private(self): - """Relocate the empty modern directory to the historical nested location.""" - new = self.rootdir / self.private.relative_path - new.rmdir() - old_name = self.private.directory_name - old = f'{old_name}' - src = self.rootdir / old - (src / 'Documents').mkdir(parents=True) - (src / 'Documents' / 'facture.pdf').write_bytes(b'PDF CONTENT') - self.private.parent = self.shared - self.private.relative_path = old - self.private.quota_bytes = None - self.private.save(update_fields=['parent', 'relative_path', 'quota_bytes']) - folder = DriveItem.objects.create( - owner=self.admin, space=self.private, name='Documents', is_folder=True, - ) - item = DriveItem.objects.create( - owner=self.admin, space=self.private, parent=folder, name='facture.pdf', - storage_key=str(uuid.uuid4()), storage_location=f'{old}/Documents/facture.pdf', - size=11, - ) - return old, item - - def test_migrate_private_storage_dry_run_and_apply(self): - old, item = self.simulate_legacy_private() - call_command('move_private_storage') - self.private.refresh_from_db() - self.assertEqual(self.private.relative_path, old) - self.assertTrue((self.rootdir / old / 'Documents' / 'facture.pdf').exists()) - call_command('move_private_storage', '--apply') - self.private.refresh_from_db() - item.refresh_from_db() - self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}') - self.assertEqual(self.private.parent_id, self.shared.pk) - self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES) - self.assertEqual(item.storage_location, f'{self.private.relative_path}/Documents/facture.pdf') - self.assertEqual(storage_path(item).read_bytes(), b'PDF CONTENT') - self.assertFalse((self.rootdir / old).exists()) - self.assertFalse((self.rootdir / '.gooddrive-move-privates.json').exists()) - call_command('move_private_storage', '--apply') # Idempotent. - - def test_interrupted_migration_can_resume_after_directory_move(self): - from .management.commands.move_private_storage import Command, write_journal - old, item = self.simulate_legacy_private() - plan = Command().plan() - journal = self.rootdir / '.gooddrive-move-privates.json' - write_journal(journal, plan) - new = self.rootdir / 'privates' / self.private.directory_name - (self.rootdir / old).rename(new) # Simulate a crash before SQL update. - call_command('move_private_storage', '--apply') - item.refresh_from_db() - self.assertEqual(storage_path(item).read_bytes(), b'PDF CONTENT') - self.assertFalse(journal.exists()) - - def test_migration_never_overwrites_existing_destination(self): - old, item = self.simulate_legacy_private() - target = self.rootdir / 'privates' / self.private.directory_name - target.mkdir(parents=True) - (target / 'unrelated.txt').write_text('KEEP ME') - with self.assertRaises(CommandError): - call_command('move_private_storage', '--apply') - self.assertEqual((target / 'unrelated.txt').read_text(), 'KEEP ME') - self.assertTrue((self.rootdir / old / 'Documents' / 'facture.pdf').exists()) - item.refresh_from_db() - self.assertEqual(item.storage_location, f'{old}/Documents/facture.pdf') diff --git a/backend/drive/views_spaces.py b/backend/drive/views_spaces.py index 671062b..ca69f52 100644 --- a/backend/drive/views_spaces.py +++ b/backend/drive/views_spaces.py @@ -36,7 +36,7 @@ def invitation_info(inv): '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,'quota_bytes':inv.quota_bytes} + 'can_invite':inv.can_invite} @api_view(['GET']) @@ -154,15 +154,11 @@ def create_invitation(request): 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.') - try:quota=parse_quota(request.data.get('quota_bytes')) - except ValueError as ex:return error(str(ex)) - with transaction.atomic(): - with locked_quota_tree(parent): - try:assert_new_child_quota(parent,quota) - except ValueError as exc:return error(str(exc)) + # 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),quota_bytes=quota,**fields) + 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) @@ -198,7 +194,6 @@ def invitation_preview(request,token): 'can_view_parent':inv.can_view_parent, 'can_write_parent':inv.can_write_parent, 'can_invite':inv.can_invite, - 'quota_bytes':inv.quota_bytes, 'expires_at':inv.expires_at.isoformat()}) @@ -229,12 +224,10 @@ def accept_invitation(request,token): 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): - try:assert_new_child_quota(inv.parent_space,inv.quota_bytes) - except ValueError as exc:return error(str(exc)) 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,quota_bytes=inv.quota_bytes) + 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']) diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx index d6e4654..b80fc0d 100644 --- a/frontend/src/components/drive/DriveSidebar.jsx +++ b/frontend/src/components/drive/DriveSidebar.jsx @@ -140,7 +140,6 @@ export default function DriveSidebar({ controller, user, onLogout }) {
Fichiers actifs : {fmtSize(activeBytes)}
{trashBytes > 0 &&Corbeille : {fmtSize(trashBytes)} (vider pour libérer)
} {reservedBytes > 0 &&Transferts réservés : {fmtSize(reservedBytes)} (annuler pour libérer)
} -Sous-espaces inclus. Le compteur correspond à l’espace réellement occupé.
diff --git a/frontend/src/components/drive/QuotaField.jsx b/frontend/src/components/drive/QuotaField.jsx index 9901bcb..ed51b28 100644 --- a/frontend/src/components/drive/QuotaField.jsx +++ b/frontend/src/components/drive/QuotaField.jsx @@ -13,6 +13,5 @@ export default function QuotaField({ value, onChange, disabled = false, id = 'qu Sans limite dédiée -1 Go = 1 073 741 824 octets. Les limites des espaces parents restent applicables.
} diff --git a/frontend/src/components/drive/dialogs/ItemFormDialog.jsx b/frontend/src/components/drive/dialogs/ItemFormDialog.jsx index 40c9ff3..85e240c 100644 --- a/frontend/src/components/drive/dialogs/ItemFormDialog.jsx +++ b/frontend/src/components/drive/dialogs/ItemFormDialog.jsx @@ -6,6 +6,6 @@ import { HardDrive, ChevronRight, Folder } from 'lucide-react' export default function ItemFormDialog({controller: c}) { const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c return ( - + ) } diff --git a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx index a3b9ba3..e63450c 100644 --- a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx +++ b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx @@ -6,7 +6,6 @@ import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import QuotaField from '@/components/drive/QuotaField' import {fromQuota,toQuota} from '@/lib/quota-utils' -import {fmtSize} from '@/lib/drive-format' export default function SpaceSettingsDialog({controller: c}) { const {spaceDialog, setSpaceDialog, spaces, activeSpaceId, loadSpaces, switchSpace, setError, setNote, setManagedSpaceId} = c @@ -18,10 +17,6 @@ export default function SpaceSettingsDialog({controller: c}) { const [parent,setParent] = useState('') const [busy,setBusy] = useState(false) const [localError,setLocalError] = useState('') - // En mode gestion, prendre le parent de l'espace édité ; en création, - // prendre le parent sélectionné. Le state doit être déclaré avant usage. - const parentId = manage ? current?.parent_id : parent - const parentSpace = spaces.find(space => space.id === Number(parentId)) useEffect(() => { setName(manage ? current?.display_name || '' : '') setQuota(manage ? fromQuota(current?.quota_bytes) : '') @@ -53,9 +48,7 @@ export default function SpaceSettingsDialog({controller: c}) {- Accès réservé aux comptes autorisés par l'administrateur. -
diff --git a/frontend/src/pages/MembersPage.jsx b/frontend/src/pages/MembersPage.jsx index 1d5f89b..91268bb 100644 --- a/frontend/src/pages/MembersPage.jsx +++ b/frontend/src/pages/MembersPage.jsx @@ -31,7 +31,6 @@ export default function MembersPage({ controller, user, embedded = false }) { const [error, setError] = useState('') const [info, setInfo] = useState('') const [expires, setExpires] = useState('7') - const [invitationQuota, setInvitationQuota] = useState('') const [rootQuota, setRootQuota] = useState('') const [userQuery, setUserQuery] = useState('') const [userResults, setUserResults] = useState([]) @@ -70,7 +69,7 @@ export default function MembersPage({ controller, user, embedded = false }) { const create = async () => { setBusy(true);setError('');setUrl('') try { - const inv = await drive.invite({ ...rights, expires_days:Number(expires), parent_space:parentId, quota_bytes:toQuota(invitationQuota) }) + const inv = await drive.invite({ ...rights, expires_days:Number(expires), parent_space:parentId }) setUrl(`${window.location.origin}/invite/${inv.token}`) setInfo('Invitation créée ! Le lien n’est affiché qu’une seule fois : copie-le maintenant.') await reload() @@ -145,16 +144,13 @@ export default function MembersPage({ controller, user, embedded = false }) { {info &&Gestion de « {selectedManagedSpace.display_name} »
-Les accès et invitations ci-dessous concernent uniquement cet espace.
Le nom visible peut être modifié sans changer le dossier enregistré sur le serveur.
Utilisé avec tous les sous-espaces : {fmtSize(mine?.used_bytes || 0)}. La corbeille compte aussi.
Lien privé, à transmettre à la personne invitée
Aucun sous-utilisateur dans cet espace.
} {selectedRecord?.children?.map(child=>La création de sous-utilisateurs n’est pas autorisée pour ton compte.
}