Coverage for src/django_global_search/views.py: 93%

103 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-06 22:25 +0000

1"""Global Search Views.""" 

2 

3from __future__ import annotations 

4 

5from collections import defaultdict 

6from dataclasses import asdict, dataclass 

7 

8from django.apps import apps 

9from django.contrib.admin.sites import AdminSite 

10from django.contrib.admin.views.decorators import staff_member_required 

11from django.contrib.contenttypes.models import ContentType 

12from django.http import HttpRequest 

13from django.shortcuts import render 

14from django.utils.decorators import method_decorator 

15from django.views import View 

16 

17from django_global_search.searcher import GlobalSearch, GlobalSearchResult, ModelSearchResult 

18 

19 

20@method_decorator(staff_member_required, name="dispatch") 

21class GlobalSearchView(View): 

22 """Global Search View.""" 

23 

24 template_name = "global_search/search.html" 

25 admin_site = None 

26 

27 @dataclass 

28 class SearchItemContext: 

29 """Search result item for template context.""" 

30 

31 url: str 

32 display_text: str 

33 

34 @dataclass 

35 class ModelResultContext: 

36 """Model search result for template context.""" 

37 

38 content_type_id: int 

39 model_name: str 

40 verbose_name: str 

41 verbose_name_plural: str 

42 has_more: bool 

43 changelist_url: str | None 

44 items: list[GlobalSearchView.SearchItemContext] 

45 

46 @dataclass 

47 class AppResultContext: 

48 """App search result for template context.""" 

49 

50 app_label: str 

51 app_verbose_name: str 

52 models: list[GlobalSearchView.ModelResultContext] 

53 

54 @dataclass 

55 class SearchContext: 

56 """Template context for search view.""" 

57 

58 query: str 

59 apps_data: dict 

60 selected_content_type_ids: list[int] 

61 search_results: list[GlobalSearchView.AppResultContext] 

62 elapsed_time: float | None 

63 error_message: str | None 

64 

65 def get(self, request, *args, **kwargs): 

66 """Handle GET request.""" 

67 query = request.GET.get("q", "").strip() 

68 selected_ct_ids = self._get_selected_content_type_ids(request) 

69 admin_site = self.admin_site 

70 

71 # Build context 

72 context = self.SearchContext( 

73 query=query, 

74 apps_data=self._get_apps_data(request, admin_site), 

75 selected_content_type_ids=selected_ct_ids, 

76 search_results=[], 

77 elapsed_time=None, 

78 error_message=None, 

79 ) 

80 

81 # Execute search if query is provided 

82 if query: 

83 try: 

84 searcher = GlobalSearch(admin_site) 

85 result = searcher.search( 

86 request=request, 

87 query=query, 

88 content_type_ids=selected_ct_ids or None, 

89 ) 

90 

91 context.search_results = self._convert_search_results(result) 

92 context.elapsed_time = result.elapsed_time_ms / 1000.0 

93 

94 if result.is_timeout: 

95 context.error_message = "Search timeout exceeded. Please refine your query." 

96 

97 except ValueError: 

98 context.error_message = "Invalid query" 

99 except Exception: 

100 context.error_message = "Search error" 

101 

102 # Merge with admin site context for proper URL resolution 

103 template_context = { 

104 **self.admin_site.each_context(request), 

105 **asdict(context), 

106 } 

107 return render(request, self.template_name, template_context) 

108 

109 def _convert_search_results(self, result: GlobalSearchResult) -> list[AppResultContext]: 

110 return [ 

111 self.AppResultContext( 

112 app_label=app_result.app_label, 

113 app_verbose_name=app_result.app_verbose_name, 

114 models=[ 

115 self._convert_model_result(model_result) for model_result in app_result.models 

116 ], 

117 ) 

118 for app_result in result.apps 

119 ] 

120 

121 def _convert_model_result(self, model_result: ModelSearchResult) -> ModelResultContext: 

122 return self.ModelResultContext( 

123 content_type_id=model_result.content_type_id, 

124 model_name=model_result.model_name, 

125 verbose_name=model_result.verbose_name, 

126 verbose_name_plural=model_result.verbose_name_plural, 

127 has_more=model_result.has_more, 

128 changelist_url=model_result.changelist_url, 

129 items=[ 

130 self.SearchItemContext(url=item.url, display_text=item.display_text) 

131 for item in model_result.items 

132 ], 

133 ) 

134 

135 def _get_selected_content_type_ids(self, request: HttpRequest): 

136 content_type_ids = set() 

137 

138 # Process 'apps' parameter (select all models in the app) 

139 apps_param = request.GET.get("apps", "") 

140 if apps_param: 

141 app_labels = [a.strip() for a in apps_param.split(",") if a.strip()] 

142 for app_label in app_labels: 

143 content_type_ids.update(self._get_content_type_ids_for_app(request, app_label)) 

144 

145 # Process 'content_type' parameter (individual model selection) 

146 content_types_param = request.GET.get("content_type", "") 

147 if content_types_param: 

148 try: 

149 ids = [int(cid) for cid in content_types_param.split(",") if cid] 

150 content_type_ids.update(ids) 

151 except ValueError: 

152 pass 

153 

154 return list(content_type_ids) if content_type_ids else [] 

155 

156 def _get_content_type_ids_for_app(self, request: HttpRequest, app_label: str) -> list[int]: 

157 """Get all searchable content type IDs for a given app.""" 

158 searcher = GlobalSearch(self.admin_site) 

159 searchable_admins = searcher.get_searchable_model_admins(request) 

160 

161 content_type_ids = [] 

162 for model_admin in searchable_admins: 

163 model = model_admin.model 

164 if model._meta.app_label == app_label: 

165 content_type = ContentType.objects.get_for_model(model) 

166 content_type_ids.append(content_type.id) 

167 

168 return content_type_ids 

169 

170 def _get_apps_data(self, request: HttpRequest, admin_site: AdminSite): 

171 """Get apps and models data for sidebar.""" 

172 searcher = GlobalSearch(admin_site) 

173 searchable_admins = searcher.get_searchable_model_admins(request) 

174 

175 # Build apps data from searchable models 

176 # {app_label: {"verbose_name": "", "models": []}} 

177 apps_data = defaultdict(lambda: {"verbose_name": "", "models": []}) 

178 

179 for model_admin in searchable_admins: 

180 model = model_admin.model 

181 app_label = model._meta.app_label 

182 app_config = apps.get_app_config(app_label) 

183 

184 content_type = ContentType.objects.get_for_model(model) 

185 

186 if not apps_data[app_label]["verbose_name"]: 

187 apps_data[app_label]["verbose_name"] = app_config.verbose_name 

188 

189 apps_data[app_label]["models"].append( 

190 { 

191 "content_type_id": content_type.id, 

192 "verbose_name_plural": model._meta.verbose_name_plural, 

193 "model_name": model._meta.model_name, 

194 } 

195 ) 

196 

197 # Return in app_label alphabetical order 

198 return dict(sorted(apps_data.items()))