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
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-02 13:22 +0000
1"""
2Shared profile-deletion logic.
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.
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"""
16import os
17from typing import Any
19from django.conf import settings
21from apps.profiles.models import Profile
24def get_deletion_preview(profile: Profile) -> dict[str, Any]:
25 """Build the deletion-preview payload for a single Profile.
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 )
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()
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 }
76def collect_remix_image_paths(profile: Profile) -> list[str]:
77 """Snapshot remix image paths BEFORE the cascade delete.
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
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 )
91def remove_remix_image_files(image_paths: list[str]) -> None:
92 """Best-effort filesystem cleanup of remix image files.
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