Coverage for apps / core / api.py: 91%

87 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 00:40 +0000

1"""System API for administrative operations like database reset.""" 

2 

3import os 

4import shutil 

5 

6from django.conf import settings 

7from django.core.cache import cache 

8from django.core.management import call_command 

9from django.contrib.sessions.models import Session 

10from ninja import Router, Schema 

11 

12from apps.profiles.models import Profile 

13from apps.recipes.models import ( 

14 Recipe, 

15 RecipeFavorite, 

16 RecipeCollection, 

17 RecipeCollectionItem, 

18 RecipeViewHistory, 

19 SearchSource, 

20 ServingAdjustment, 

21 CachedSearchImage, 

22) 

23from apps.ai.models import AIDiscoverySuggestion, AIPrompt 

24 

25router = Router(tags=['system']) 

26 

27 

28class HealthSchema(Schema): 

29 status: str 

30 database: str 

31 

32 

33@router.get('/health/', response=HealthSchema) 

34def health_check(request): 

35 """Simple health check for container orchestration.""" 

36 from django.db import connection 

37 try: 

38 with connection.cursor() as cursor: 

39 cursor.execute('SELECT 1') 

40 return {'status': 'healthy', 'database': 'ok'} 

41 except Exception: 

42 return {'status': 'unhealthy', 'database': 'error'} 

43 

44 

45class DataCountsSchema(Schema): 

46 profiles: int 

47 recipes: int 

48 recipe_images: int 

49 favorites: int 

50 collections: int 

51 collection_items: int 

52 view_history: int 

53 ai_suggestions: int 

54 serving_adjustments: int 

55 cached_search_images: int 

56 

57 

58class ResetPreviewSchema(Schema): 

59 data_counts: DataCountsSchema 

60 preserved: list[str] 

61 warnings: list[str] 

62 

63 

64class ResetConfirmSchema(Schema): 

65 confirmation_text: str # Must be "RESET" 

66 

67 

68class ErrorSchema(Schema): 

69 error: str 

70 message: str 

71 

72 

73class ResetSuccessSchema(Schema): 

74 success: bool 

75 message: str 

76 actions_performed: list[str] 

77 

78 

79@router.get('/reset-preview/', response=ResetPreviewSchema) 

80def get_reset_preview(request): 

81 """Get summary of data that will be deleted on reset.""" 

82 return { 

83 'data_counts': { 

84 'profiles': Profile.objects.count(), 

85 'recipes': Recipe.objects.count(), 

86 'recipe_images': Recipe.objects.exclude(image='').exclude( 

87 image__isnull=True 

88 ).count(), 

89 'favorites': RecipeFavorite.objects.count(), 

90 'collections': RecipeCollection.objects.count(), 

91 'collection_items': RecipeCollectionItem.objects.count(), 

92 'view_history': RecipeViewHistory.objects.count(), 

93 'ai_suggestions': AIDiscoverySuggestion.objects.count(), 

94 'serving_adjustments': ServingAdjustment.objects.count(), 

95 'cached_search_images': CachedSearchImage.objects.count(), 

96 }, 

97 'preserved': [ 

98 'Search source configurations', 

99 'AI prompt templates', 

100 'Application settings', 

101 ], 

102 'warnings': [ 

103 'All user data will be permanently deleted', 

104 'All recipe images will be removed from storage', 

105 'This action cannot be undone', 

106 ], 

107 } 

108 

109 

110@router.post('/reset/', response={200: ResetSuccessSchema, 400: ErrorSchema}) 

111def reset_database(request, data: ResetConfirmSchema): 

112 """ 

113 Completely reset the database to factory state. 

114 

115 Requires confirmation_text="RESET" to proceed. 

116 """ 

117 if data.confirmation_text != 'RESET': 

118 return 400, { 

119 'error': 'invalid_confirmation', 

120 'message': 'Type RESET to confirm', 

121 } 

122 

123 try: 

124 # 1. Clear database tables (order matters for FK constraints) 

125 # Start with leaf tables that depend on others 

126 AIDiscoverySuggestion.objects.all().delete() 

127 ServingAdjustment.objects.all().delete() 

128 RecipeViewHistory.objects.all().delete() 

129 RecipeCollectionItem.objects.all().delete() 

130 RecipeCollection.objects.all().delete() 

131 RecipeFavorite.objects.all().delete() 

132 CachedSearchImage.objects.all().delete() 

133 

134 # Delete all recipes (this will cascade to related items) 

135 Recipe.objects.all().delete() 

136 

137 # Delete all profiles 

138 Profile.objects.all().delete() 

139 

140 # Reset SearchSource failure counters (keep selectors) 

141 SearchSource.objects.all().update( 

142 consecutive_failures=0, 

143 needs_attention=False, 

144 last_validated_at=None, 

145 ) 

146 

147 # 2. Clear recipe images 

148 images_dir = os.path.join(settings.MEDIA_ROOT, 'recipe_images') 

149 if os.path.exists(images_dir): 

150 shutil.rmtree(images_dir) 

151 os.makedirs(images_dir) # Recreate empty directory 

152 

153 # Clear cached search images 

154 search_images_dir = os.path.join(settings.MEDIA_ROOT, 'search_images') 

155 if os.path.exists(search_images_dir): 

156 shutil.rmtree(search_images_dir) 

157 os.makedirs(search_images_dir) # Recreate empty directory 

158 

159 # 3. Clear Django cache 

160 cache.clear() 

161 

162 # 4. Clear all sessions 

163 Session.objects.all().delete() 

164 

165 # 5. Re-run migrations (ensures clean state) 

166 call_command('migrate', verbosity=0) 

167 

168 # 6. Re-seed default data 

169 try: 

170 call_command('seed_search_sources', verbosity=0) 

171 except Exception: 

172 pass # Command may not exist yet 

173 

174 try: 

175 call_command('seed_ai_prompts', verbosity=0) 

176 except Exception: 

177 pass # Command may not exist yet 

178 

179 return { 

180 'success': True, 

181 'message': 'Database reset complete', 

182 'actions_performed': [ 

183 'Deleted all user profiles', 

184 'Deleted all recipes and images', 

185 'Cleared all favorites and collections', 

186 'Cleared all view history', 

187 'Cleared all AI cache data', 

188 'Cleared all cached search images', 

189 'Reset search source counters', 

190 'Cleared application cache', 

191 'Cleared all sessions', 

192 'Re-ran database migrations', 

193 'Restored default seed data', 

194 ], 

195 } 

196 

197 except Exception as e: 

198 return 400, {'error': 'reset_failed', 'message': str(e)} 

← Back to Dashboard