Coverage for apis_core/utils/autocomplete.py: 0%

107 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-08-18 09:10 +0000

1import base64 

2import json 

3import logging 

4from urllib.parse import urlparse 

5 

6import httpx 

7from django.template.loader import render_to_string 

8 

9logger = logging.getLogger(__name__) 

10 

11 

12class ExternalAutocomplete: 

13 """ 

14 This is a helper base class for implementing external 

15 autocomplete classes. <Modelname>ExernalAutocomplete classes 

16 are expected to have a `get_results(self, q)` method that 

17 returns a list of results usable by the autocomplete view. 

18 This base class implements this `get_results` method in a 

19 way that you can inherit from it and just define a list of 

20 `adapters`. Those adapters are then used one by one to 

21 add external autocomplete search results. 

22 """ 

23 

24 client = httpx.Client() 

25 adapters = [] 

26 

27 def get_results(self, q): 

28 results = [] 

29 for adapter in self.adapters: 

30 results.extend(adapter.get_results(q, self.client)) 

31 return results 

32 

33 

34class ExternalAutocompleteAdapter: 

35 """ 

36 Base class for ExternalAutocompleteAdapters. It provides 

37 the methods used for templating the autocomplete results. 

38 You can pass a `template` name to initialization, which 

39 is then used to style the results. 

40 """ 

41 

42 template = None 

43 

44 def __init__(self, *args, **kwargs): 

45 self.template = kwargs.get("template", None) 

46 self.data_mapping = kwargs.get("data_mapping", {}) 

47 

48 def _nested_get(self, dict_, keys): 

49 """ 

50 Get a nested value from a dict by poviding the 

51 path as a list of keys 

52 """ 

53 for index, key in enumerate(keys): 

54 if index == len(keys) - 1: 

55 return [dict_.get(key, "")] 

56 dict_ = dict_.get(key, {}) 

57 

58 def map_data(self, result) -> dict: 

59 """ 

60 Map data from the result to a a new dict. The new dict 

61 is built using the settings in `.data_mapping`, which 

62 should be a mapping of keys to be created in the result 

63 dict to paths in the `result` item. 

64 """ 

65 data = {} 

66 for key, val in self.data_mapping.items(): 

67 if isinstance(val, str): 

68 val = [val] 

69 data[key] = self._nested_get(result, val) 

70 return data 

71 

72 def add_data_to_uri(self, uri, data): 

73 b64_data = base64.b64encode(json.dumps(data).encode()).decode("ascii") 

74 if urlparse(uri).query: 

75 uri += f"&anero_ac_data={b64_data}" 

76 else: 

77 uri += f"?anero_ac_data={b64_data}" 

78 return uri 

79 

80 def default_template(self, result): 

81 return f'{result["label"]} <a href="{result["id"]}">{result["id"]}</a>' 

82 

83 def get_result_label(self, result): 

84 if self.template: 

85 return render_to_string(self.template, {"result": result}) 

86 return self.default_template(result) 

87 

88 

89class TypeSenseAutocompleteAdapter(ExternalAutocompleteAdapter): 

90 """ 

91 This autocomplete adapters queries typesense collections on a 

92 typesense server. The `collections` variable can either be a 

93 string or a list - if its a string, that collection is queried 

94 directly, if its a list, the adapter uses typesense `multi_search` 

95 endpoint. 

96 """ 

97 

98 collections = None 

99 token = None 

100 server = None 

101 

102 def __init__(self, *args, **kwargs): 

103 super().__init__(*args, **kwargs) 

104 self.collections = kwargs.get("collections", None) 

105 self.token = kwargs.get("token", None) 

106 self.server = kwargs.get("server", None) 

107 

108 def default_template(self, result): 

109 return super().default_template(result["document"]) 

110 

111 def extract(self, res): 

112 if res.get("document"): 

113 data = self.map_data(res) 

114 uri = self.add_data_to_uri(res["document"]["id"], data) 

115 return { 

116 "id": uri, 

117 "text": self.get_result_label(res), 

118 "selected_text": self.get_result_label(res), 

119 } 

120 logger.error( 

121 "Could not parse result from typesense collection %s: %s", 

122 self.collections, 

123 res, 

124 ) 

125 return False 

126 

127 def get_results(self, q, client=httpx.Client()): 

128 headers = {"X-TYPESENSE-API-KEY": self.token} 

129 res = None 

130 if self.token and self.server: 

131 try: 

132 match self.collections: 

133 # if there is only on collection configured, we hit that collection directly 

134 case str() as collection: 

135 url = f"{self.server}/collections/{collection}/documents/search?q={q}&query_by=description&query_by=label" 

136 res = client.get(url, headers=headers) 

137 # if there are multiple collections configured, we use the `multi_search` endpoint 

138 case list() as collectionlist: 

139 url = f"{self.server}/multi_search?q={q}&query_by=description&query_by=label" 

140 data = {"searches": []} 

141 for collection in collectionlist: 

142 data["searches"].append({"collection": collection}) 

143 res = client.post(url, data=json.dumps(data), headers=headers) 

144 case unknown: 

145 logger.error( 

146 "Don't know what to do with collection %s", unknown 

147 ) 

148 except Exception: 

149 pass 

150 if res: 

151 data = res.json() 

152 hits = data.get("hits", []) 

153 for result in data.get("results", []): 

154 hits.extend(result["hits"]) 

155 return list(filter(bool, map(self.extract, hits))) 

156 return [] 

157 

158 

159class LobidAutocompleteAdapter(ExternalAutocompleteAdapter): 

160 """ 

161 This autocomplete adapters queries the lobid autocomplete apis. 

162 See https://lobid.org/gnd/api for details 

163 You can pass a `lobid_params` dict which will then be use as GET 

164 request parameters. 

165 """ 

166 

167 params = {} 

168 

169 def __init__(self, *args, **kwargs): 

170 super().__init__(*args, **kwargs) 

171 self.params = kwargs.get("params", {}) 

172 

173 def extract(self, res): 

174 data = self.map_data(res) 

175 uri = self.add_data_to_uri(res["id"], data) 

176 return { 

177 "id": uri, 

178 "text": self.get_result_label(res), 

179 "selected_text": self.get_result_label(res), 

180 } 

181 

182 def get_results(self, q, client=httpx.Client()): 

183 endpoint = "https://lobid.org/gnd/search?" 

184 self.params["q"] = q 

185 try: 

186 res = client.get(endpoint, params=self.params) 

187 if res: 

188 return list(filter(bool, map(self.extract, res.json()))) 

189 except Exception: 

190 pass 

191 return []