Coverage for apps / profiles / deletion.py: 78%

27 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-02 13:22 +0000

1""" 

2Shared profile-deletion logic. 

3 

4Extracted so the home-mode `DELETE /api/profiles/{id}/` endpoint 

5(apps/profiles/api.py) and the passkey-mode self-delete endpoint 

6(apps/core/auth_api.py — `DELETE /api/auth/me/`) can both produce the 

7same deletion preview and perform the same cascade + media cleanup. 

8 

9In passkey mode, deleting the Profile's User also deletes the Profile 

10via Django's CASCADE on the OneToOne, so the call sequence is: 

11 preview(profile) → collect image paths → user.delete() → rm images 

12In home mode there's no User; profile.delete() handles everything. 

13Both paths live-test the same Recipe.image cleanup to avoid orphan files. 

14""" 

15 

16import os 

17from typing import Any 

18 

19from django.conf import settings 

20 

21from apps.profiles.models import Profile 

22 

23 

24def get_deletion_preview(profile: Profile) -> dict[str, Any]: 

25 """Build the deletion-preview payload for a single Profile. 

26 

27 Counts data that will be removed by cascading profile deletion. The 

28 return shape matches DeletionPreviewSchema in apps/profiles/api.py. 

29 """ 

30 # Local imports: keeps this module light and avoids Django's circular 

31 # import dance when deletion.py is imported before apps are ready. 

32 from apps.ai.models import AIDiscoverySuggestion 

33 from apps.recipes.models import ( 

34 Recipe, 

35 RecipeCollection, 

36 RecipeCollectionItem, 

37 RecipeFavorite, 

38 RecipeViewHistory, 

39 ServingAdjustment, 

40 ) 

41 

42 remixes = Recipe.objects.filter(is_remix=True, remix_profile=profile) 

43 favorites = RecipeFavorite.objects.filter(profile=profile) 

44 collections = RecipeCollection.objects.filter(profile=profile) 

45 collection_items = RecipeCollectionItem.objects.filter(collection__profile=profile) 

46 view_history = RecipeViewHistory.objects.filter(profile=profile) 

47 scaling_cache = ServingAdjustment.objects.filter(profile=profile) 

48 discover_cache = AIDiscoverySuggestion.objects.filter(profile=profile) 

49 remix_images_count = remixes.exclude(image="").exclude(image__isnull=True).count() 

50 

51 return { 

52 "profile": { 

53 "id": profile.id, 

54 "name": profile.name, 

55 "avatar_color": profile.avatar_color, 

56 "created_at": profile.created_at, 

57 }, 

58 "data_to_delete": { 

59 "remixes": remixes.count(), 

60 "remix_images": remix_images_count, 

61 "favorites": favorites.count(), 

62 "collections": collections.count(), 

63 "collection_items": collection_items.count(), 

64 "view_history": view_history.count(), 

65 "scaling_cache": scaling_cache.count(), 

66 "discover_cache": discover_cache.count(), 

67 }, 

68 "warnings": [ 

69 "All remixed recipes will be permanently deleted", 

70 "Recipe images for remixes will be removed from storage", 

71 "This action cannot be undone", 

72 ], 

73 } 

74 

75 

76def collect_remix_image_paths(profile: Profile) -> list[str]: 

77 """Snapshot remix image paths BEFORE the cascade delete. 

78 

79 The Recipe rows vanish when the profile is deleted, so the file paths 

80 must be captured first to enable post-delete cleanup on disk. 

81 """ 

82 from apps.recipes.models import Recipe 

83 

84 return list( 

85 Recipe.objects.filter(is_remix=True, remix_profile=profile, image__isnull=False) 

86 .exclude(image="") 

87 .values_list("image", flat=True) 

88 ) 

89 

90 

91def remove_remix_image_files(image_paths: list[str]) -> None: 

92 """Best-effort filesystem cleanup of remix image files. 

93 

94 Called AFTER the DB cascade. We intentionally swallow OSError so that 

95 a missing or unwritable media file doesn't prevent account deletion 

96 from completing — the DB rows are already gone. 

97 """ 

98 for image_path in image_paths: 

99 full_path = os.path.join(settings.MEDIA_ROOT, str(image_path)) 

100 try: 

101 if os.path.exists(full_path): 

102 os.remove(full_path) 

103 except OSError: 

104 pass 

← Back to Dashboard