Coverage for ckanext/udc/i18n.py: 58%

183 statements  

« prev     ^ index     » next       coverage.py v7.7.1, created at 2026-01-19 23:48 +0000

1from __future__ import annotations 

2import json 

3import logging 

4from typing import Dict, List, Any 

5import ckan.plugins.toolkit as tk 

6from ckan.lib.navl.dictization_functions import Missing, missing 

7 

8log = logging.getLogger(__name__) 

9 

10 

11def _is_missing(v): 

12 return v is missing or isinstance(v, Missing) or v is None or v == "" 

13 

14 

15def udc_lang_object(value, context): 

16 """ 

17 Validate a localized string object: {lang: "text", ...} 

18 Accepts missing/None/'' (returns None). 

19 Accepts a plain string by coercing to {default_lang: string}. 

20 """ 

21 if _is_missing(value): 

22 return None 

23 if isinstance(value, str): 

24 # Coerce a bare string to the default locale 

25 default_lang = tk.config.get("ckan.locale_default", "en") or "en" 

26 return {default_lang: value} 

27 if isinstance(value, (int, float, bool)): 

28 default_lang = tk.config.get("ckan.locale_default", "en") or "en" 

29 return {default_lang: str(value)} 

30 if not isinstance(value, dict): 

31 raise tk.Invalid("Expected an object of {lang: string} for localized text.") 

32 

33 default_lang = tk.config.get("ckan.locale_default", "en") or "en" 

34 

35 # Track if default language was explicitly set to empty (should be preserved) 

36 default_was_empty = default_lang in value and value[default_lang] == "" 

37 

38 # Validate entries - remove only None/missing, but keep empty strings 

39 for k, v in list(value.items()): 

40 if _is_missing(v): 

41 value.pop(k, None) 

42 elif not isinstance(v, str): 

43 raise tk.Invalid("Localized text values must be strings.") 

44 

45 # Restore empty default language if it was explicitly set 

46 if default_was_empty and default_lang not in value: 

47 value[default_lang] = "" 

48 

49 if not value: 

50 return None 

51 

52 # Don't auto-seed default language from other languages 

53 # This allows setting only non-default language values without clearing default 

54 

55 return value 

56 

57 

58def udc_json_dump(value, context): 

59 """ 

60 If value is dict/list, dump to JSON string. 

61 If already a string or missing/empty, pass through. 

62 """ 

63 if _is_missing(value): 

64 return None 

65 if isinstance(value, (dict, list)): 

66 try: 

67 return json.dumps(value, ensure_ascii=False) 

68 except Exception: 

69 log.debug("udc_json_dump: could not dump (leaving as-is): %r", value) 

70 return value 

71 return value 

72 

73 

74def udc_json_load(value, context): 

75 """ 

76 If value is a JSON string, parse to Python object. 

77 If already a dict/list, return as-is. 

78 If missing/empty, return None (so ignore_missing upstream/downstream can skip). 

79 Never raise on CKAN Missing; no spurious warnings. 

80 """ 

81 if _is_missing(value): 

82 return None 

83 if isinstance(value, (dict, list)): 

84 return value 

85 if isinstance(value, str): 

86 s = value.strip() 

87 if not s: 

88 return None 

89 try: 

90 parsed = json.loads(s) 

91 if isinstance(parsed, (dict, list)): 

92 return parsed 

93 return value 

94 except Exception: 

95 # Soft-fail: leave it as-is so subsequent validators can decide. 

96 log.debug("udc_json_load: not JSON (leaving as-is): %r", value) 

97 return value 

98 # Unknown type; pass through 

99 return value 

100 

101 

102def udc_core_translated_to_extras(core_field: str): 

103 """ 

104 INPUT: when receiving <core_field>_translated, copy default-locale value into core_field. 

105 """ 

106 

107 def _copy(value, context): 

108 if _is_missing(value): 

109 return None 

110 # value may be dict or JSON string 

111 if isinstance(value, str): 

112 try: 

113 value = json.loads(value) 

114 except Exception: 

115 return value 

116 if isinstance(value, dict): 

117 default_lang = tk.config.get("ckan.locale_default", "en") or "en" 

118 s = value.get(default_lang) 

119 if isinstance(s, str) and s.strip(): 

120 data = context.get("data") or context.get("data_dict") or {} 

121 data[core_field] = s 

122 return value 

123 

124 return _copy 

125 

126 

127def udc_set_core_from_translated(core_field: str): 

128 """ 

129 Output (show) side: if `<core_field>` is empty, fill it from 

130 `<core_field>_translated[default_lang]`. 

131 """ 

132 

133 def _output(value, context): 

134 data = context.get("data") or context.get("data_dict") or {} 

135 default_lang = tk.config.get("ckan.locale_default", "en") 

136 translated = data.get(f"{core_field}_translated") 

137 if (not data.get(core_field)) and isinstance(translated, dict): 

138 v = translated.get(default_lang) 

139 if v: 

140 data[core_field] = v 

141 return value 

142 

143 return _output 

144 

145 

146def udc_lang_string_list(value, context): 

147 """ 

148 Validate {lang: [string, ...]} (used by tags_translated). 

149 Missing/empty -> None. 

150 A single string is coerced to a one-item list. 

151 """ 

152 if _is_missing(value): 

153 return None 

154 if not isinstance(value, dict): 

155 raise tk.Invalid("Expected an object of {lang: [strings]}") 

156 

157 out = {} 

158 for lang, vals in value.items(): 

159 if _is_missing(vals): 

160 continue 

161 if isinstance(vals, str): 

162 vals = [vals] 

163 if not isinstance(vals, list) or not all(isinstance(x, str) for x in vals): 

164 raise tk.Invalid( 

165 'Expected a list of strings for language "{}"'.format(lang) 

166 ) 

167 # dedupe + strip empties 

168 seen = set() 

169 cleaned = [] 

170 for x in vals: 

171 x = x.strip() 

172 if x and x not in seen: 

173 seen.add(x) 

174 cleaned.append(x) 

175 if cleaned: 

176 out[lang] = cleaned 

177 return out or None 

178 

179 

180def udc_set_core_tags_from_translated(value, context): 

181 """ 

182 If data['tags_translated'][default_lang] exists, set core 'tags' accordingly. 

183 Runs in the tags_translated pipeline and (optionally) in the tags pipeline. 

184 """ 

185 data = context.get("data") or context.get("data_dict") or {} 

186 t = data.get("tags_translated") 

187 # handle Missing / JSON string 

188 if _is_missing(t): 

189 return value 

190 if isinstance(t, str): 

191 try: 

192 t = json.loads(t) 

193 except Exception: 

194 return value 

195 if not isinstance(t, dict): 

196 return value 

197 default_lang = tk.config.get("ckan.locale_default", "en") or "en" 

198 names = t.get(default_lang) or [] 

199 if isinstance(names, str): 

200 names = [names] 

201 if isinstance(names, list): 

202 cleaned = [] 

203 seen = set() 

204 for n in names: 

205 if isinstance(n, str): 

206 n = n.strip() 

207 if n and n not in seen: 

208 seen.add(n) 

209 cleaned.append({"name": n}) 

210 if cleaned: 

211 data["tags"] = cleaned 

212 return value 

213 

214 

215def udc_fill_tags_translated_from_core(value, context): 

216 """ 

217 On show: if tags_translated is absent or missing default_lang, seed from core tags. 

218 """ 

219 data = context.get("data") or context.get("data_dict") or {} 

220 default_lang = tk.config.get("ckan.locale_default", "en") or "en" 

221 

222 tags_trans = data.get("tags_translated") 

223 

224 # If tags_translated exists and has data for default language, keep it 

225 if isinstance(tags_trans, dict) and tags_trans and tags_trans.get(default_lang): 

226 return value 

227 

228 # Otherwise, seed from core tags 

229 tags = data.get("tags") or [] 

230 names = [] 

231 for t in tags: 

232 if isinstance(t, dict) and isinstance(t.get("name"), str): 

233 names.append(t["name"]) 

234 elif isinstance(t, str): 

235 names.append(t) 

236 

237 if names: 

238 # If tags_translated exists but is missing default_lang, add it 

239 if isinstance(tags_trans, dict): 

240 tags_trans[default_lang] = names 

241 data["tags_translated"] = tags_trans 

242 else: 

243 # Create new tags_translated 

244 data["tags_translated"] = {default_lang: names} 

245 

246 return value 

247 

248 

249def udc_seed_translated_from_core(core_field: str): 

250 """ 

251 INPUT (create/update): if <translated> is empty, seed {user_lang: core} so it gets stored. 

252 Seeds to the user's current locale, not the system default. 

253 """ 

254 

255 def _seed(value, context): 

256 if not _is_missing(value) and value: 

257 return value 

258 data = context.get("data") or context.get("data_dict") or {} 

259 core_val = data.get(core_field) 

260 if isinstance(core_val, str) and core_val.strip(): 

261 # Use the current user's language, not the system default 

262 import ckan.lib.helpers as h 

263 user_lang = h.lang() or tk.config.get("ckan.locale_default", "en") or "en" 

264 return {user_lang: core_val} 

265 return None 

266 

267 return _seed 

268 

269 

270def udc_fill_translated_from_core_on_show(core_field: str): 

271 """ 

272 SHOW (read): ensure translated_field appears by seeding from core if absent. 

273 """ 

274 translated_field = core_field + "_translated" 

275 

276 def _fill(value, context): 

277 data = context.get("data") or context.get("data_dict") or {} 

278 tval = data.get(translated_field) 

279 if isinstance(tval, dict) and tval: 

280 return value 

281 core_val = data.get(core_field) 

282 if isinstance(core_val, str) and core_val.strip(): 

283 default_lang = tk.config.get("ckan.locale_default", "en") or "en" 

284 data[translated_field] = {default_lang: core_val} 

285 return value 

286 

287 return _fill