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

1"""Global Search Views."""

2

3 from __future__ import annotations

4

5 from collections import defaultdict

6 from dataclasses import asdict, dataclass

7

8 from django.apps import apps

9 from django.contrib.admin.sites import AdminSite

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

11 from django.contrib.contenttypes.models import ContentType

12 from django.http import HttpRequest

13 from django.shortcuts import render

14 from django.utils.decorators import method_decorator

15 from django.views import View

16

17 from django_global_search.searcher import GlobalSearch, GlobalSearchResult, ModelSearchResult

18

19

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

21 class 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()))