Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/views/dashboard.py: 17%

157 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 07:15 +0000

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4from __future__ import annotations 

5 

6from typing import TYPE_CHECKING 

7 

8from django.conf import settings 

9from django.core.cache import cache 

10from django.core.exceptions import ImproperlyConfigured 

11from django.db.models import Count 

12from django.http import Http404 

13from django.shortcuts import redirect 

14from django.urls import reverse 

15from django.utils import translation 

16from django.utils.html import format_html 

17from django.utils.translation import gettext 

18from django.views.decorators.cache import never_cache 

19 

20from weblate.accounts.models import Profile 

21from weblate.lang.models import Language 

22from weblate.metrics.models import Metric 

23from weblate.trans.forms import ReportsForm, SearchForm 

24from weblate.trans.models import Component, ComponentList, Project, Translation 

25from weblate.trans.models.component import translation_prefetch_tasks 

26from weblate.trans.models.project import prefetch_project_flags 

27from weblate.trans.models.translation import GhostTranslation 

28from weblate.trans.util import render 

29from weblate.utils import messages 

30from weblate.utils.stats import prefetch_stats 

31from weblate.utils.views import get_paginator 

32 

33if TYPE_CHECKING: 33 ↛ 34line 33 didn't jump to line 34 because the condition on line 33 was never true

34 from collections.abc import Iterable 

35 

36 from django.http import HttpResponse 

37 

38 from weblate.auth.models import AuthenticatedHttpRequest, User 

39 from weblate.trans.models.translation import TranslationQuerySet 

40 

41 

42def get_untranslated( 

43 base: Iterable[Translation], limit: int | None = None 

44) -> list[Translation]: 

45 """Filter untranslated.""" 

46 result: list[Translation] = [] 

47 for item in base: 

48 if item.stats.translated != item.stats.all: 

49 result.append(item) 

50 if limit and len(result) >= limit: 

51 return result 

52 return result 

53 

54 

55def get_suggestions( 

56 user: User, 

57 user_has_languages: bool, 

58 base: TranslationQuerySet, 

59 filtered: bool = False, 

60) -> list[Translation]: 

61 """Return suggested translations for user.""" 

62 if not filtered: 

63 non_alerts = base.annotate(alert_count=Count("component__alert__pk")).filter( 

64 alert_count=0 

65 ) 

66 result = get_suggestions(user, user_has_languages, non_alerts, True) 

67 if result: 

68 return result 

69 if user_has_languages: 

70 # Remove user subscriptions 

71 result = get_untranslated( 

72 prefetch_stats( 

73 base.exclude(component__project__in=user.profile.watched.all()) 

74 ), 

75 10, 

76 ) 

77 if result: 

78 return result 

79 return get_untranslated(prefetch_stats(base), 10) 

80 

81 

82def guess_user_language( 

83 request: AuthenticatedHttpRequest, translations: TranslationQuerySet 

84) -> Language | None: 

85 """ 

86 Guess user language for translations. 

87 

88 It tries following: 

89 

90 - Use session language. 

91 - Parse Accept-Language header. 

92 - Fallback to random language. 

93 """ 

94 # Session language 

95 session_lang = translation.get_language() 

96 if session_lang and session_lang != "en": 

97 try: 

98 return Language.objects.get(code=session_lang) 

99 except Language.DoesNotExist: 

100 pass 

101 

102 # Try getting from Accept-Language 

103 if request.accepted_language: 

104 return request.accepted_language 

105 

106 # Random language from existing translations, we do not want to list all 

107 # languages by default 

108 try: 

109 return translations.order_by("?")[0].language 

110 except IndexError: 

111 # There are no existing translations 

112 return None 

113 

114 

115def get_user_translations( 

116 request: AuthenticatedHttpRequest, user: User, user_has_languages: bool 

117) -> TranslationQuerySet: 

118 """ 

119 Get list of translations in user languages. 

120 

121 Works also for anonymous users based on current UI language. 

122 """ 

123 result = Translation.objects.prefetch().filter_access(user).order() 

124 

125 if user_has_languages: 

126 result = result.filter(language__in=user.profile.all_languages) 

127 else: 

128 # Filter based on session language 

129 tmp = result.filter(language=guess_user_language(request, result)) 

130 if tmp: 

131 return tmp 

132 

133 return result 

134 

135 

136def redirect_single_project(user: User) -> HttpResponse: 

137 target: Component | Project 

138 if isinstance(settings.SINGLE_PROJECT, str): 

139 target = project = Project.objects.get(slug=settings.SINGLE_PROJECT) 

140 elif Component.objects.filter(is_glossary=False).count() == 1: 

141 target = component = Component.objects.filter(is_glossary=False).get() 

142 project = component.project 

143 elif Project.objects.count() == 1: 

144 target = project = Project.objects.get() 

145 else: 

146 msg = "SINGLE_PROJECT enabled, but no project found" 

147 raise ImproperlyConfigured(msg) 

148 

149 if not user.is_authenticated and not user.can_access_project(project): 

150 return redirect(f"{settings.LOGIN_URL}?next={target.get_absolute_url()}") 

151 return redirect(target) 

152 

153 

154@never_cache 

155def home(request: AuthenticatedHttpRequest) -> HttpResponse: 

156 """Home page handler serving different views based on user.""" 

157 user = request.user 

158 

159 # This is used on Hosted Weblate to handle removed translation projects. 

160 # The redirect itself is done in the http server. 

161 if "removed" in request.GET: 

162 messages.warning( 

163 request, 

164 gettext( 

165 "The project you were looking for has been removed, " 

166 "however you are welcome to contribute to other ones." 

167 ), 

168 ) 

169 

170 if "show_set_password" in request.session: 

171 messages.warning( 

172 request, 

173 gettext( 

174 "You have activated your account, now you should set " 

175 "the password to be able to sign in next time." 

176 ), 

177 ) 

178 return redirect("password") 

179 

180 # Warn about not filled in username, this is usually caused by migration of 

181 # users from older system 

182 if user.is_authenticated and (not user.full_name or not user.email): 

183 messages.warning( 

184 request, 

185 format_html( 

186 '<a href="{}">{}</a>', 

187 reverse("profile") + "#account", 

188 gettext("Please set your full name and e-mail in your profile."), 

189 ), 

190 ) 

191 

192 # Redirect to single project or component 

193 if settings.SINGLE_PROJECT: 

194 return redirect_single_project(user) 

195 

196 if not user.is_authenticated: 

197 return dashboard_anonymous(request) 

198 

199 return dashboard_user(request) 

200 

201 

202def fetch_componentlists( 

203 user: User, user_translations: TranslationQuerySet 

204) -> list[ComponentList]: 

205 componentlists = list( 

206 ComponentList.objects.filter( 

207 show_dashboard=True, 

208 components__project__in=user.allowed_projects, 

209 ) 

210 .distinct() 

211 .order() 

212 ) 

213 for componentlist in componentlists: 

214 components = componentlist.components.filter_access(user) 

215 # Force fetching the query now 

216 list(components) 

217 

218 translations = translation_prefetch_tasks( 

219 prefetch_stats(list(user_translations.filter(component__in=components))) 

220 ) 

221 

222 # Show ghost translations for user languages 

223 existing = { 

224 (translation.component.slug, translation.language.code) 

225 for translation in translations 

226 } 

227 languages = user.profile.all_languages 

228 for component in components: 

229 for language in languages: 

230 if ( 

231 component.slug, 

232 language.code, 

233 ) in existing or not component.can_add_new_language(user, fast=True): 

234 continue 

235 translations.append( 

236 GhostTranslation(component.project, language, component) 

237 ) 

238 

239 componentlist.translations = translations 

240 

241 # Filter out component lists with translations 

242 # This will remove the ones where user doesn't have access to anything 

243 return [c for c in componentlists if c.translations] 

244 

245 

246def component_list_user(request: AuthenticatedHttpRequest, name: str) -> HttpResponse: 

247 user = request.user 

248 user_has_languages = user.is_authenticated and user.profile.all_languages 

249 

250 user_translations = get_user_translations(request, user, user_has_languages) 

251 

252 componentlist = None 

253 for current in fetch_componentlists(request.user, user_translations): 

254 if current.slug == name: 

255 componentlist = current 

256 

257 if componentlist is None: 

258 raise Http404 

259 

260 if user.is_authenticated and user.profile.hide_completed: 

261 componentlist.translations = get_untranslated( 

262 prefetch_stats(componentlist.translations) 

263 ) 

264 return render( 

265 request, "dashboard/componentlist.html", {"componentlist": componentlist} 

266 ) 

267 

268 

269def dashboard_user(request: AuthenticatedHttpRequest) -> HttpResponse: 

270 """Home page of Weblate for authenticated user.""" 

271 user = request.user 

272 

273 user_has_languages = user.is_authenticated and user.profile.all_languages 

274 

275 user_translations = get_user_translations(request, user, user_has_languages) 

276 

277 suggestions = get_suggestions(user, user_has_languages, user_translations) 

278 

279 usersubscriptions = None 

280 

281 componentlists = fetch_componentlists(request.user, user_translations) 

282 

283 active_tab_id = user.profile.dashboard_view 

284 active_tab_slug = Profile.DASHBOARD_SLUGS.get(active_tab_id) 

285 if ( 

286 active_tab_id == Profile.DASHBOARD_COMPONENT_LIST 

287 and user.profile.dashboard_component_list 

288 ): 

289 active_tab_slug = user.profile.dashboard_component_list.tab_slug() 

290 

291 if user.is_authenticated: 

292 usersubscriptions = user_translations.filter_access(user).filter( 

293 component__project__in=user.watched_projects 

294 ) 

295 

296 if user.profile.hide_completed: 

297 usersubscriptions = get_untranslated(prefetch_stats(usersubscriptions)) 

298 for componentlist in componentlists: 

299 componentlist.translations = get_untranslated( 

300 prefetch_stats(componentlist.translations) 

301 ) 

302 

303 usersubscriptions = get_paginator(request, usersubscriptions, stats=True) 

304 usersubscriptions = translation_prefetch_tasks(usersubscriptions) 

305 owned = user.owned_projects.order() 

306 else: 

307 owned = Project.objects.none() 

308 

309 return render( 

310 request, 

311 "dashboard/user.html", 

312 { 

313 "allow_index": True, 

314 "suggestions": suggestions, 

315 "search_form": SearchForm(request=request, bootstrap_5=True), 

316 "usersubscriptions": usersubscriptions, 

317 "componentlists": componentlists, 

318 "all_componentlists": prefetch_stats( 

319 ComponentList.objects.filter( 

320 components__project__in=request.user.allowed_projects 

321 ) 

322 .distinct() 

323 .order() 

324 ), 

325 "active_tab_slug": active_tab_slug, 

326 "reports_form": ReportsForm({}), 

327 "all_owned_projects": owned, 

328 "owned_projects": prefetch_project_flags(prefetch_stats(owned[:10])), 

329 "bootstrap_5": True, 

330 }, 

331 ) 

332 

333 

334def dashboard_anonymous(request: AuthenticatedHttpRequest) -> HttpResponse: 

335 """Home page of Weblate showing list of projects for anonymous user.""" 

336 top_project_ids_cache = cache.get("dashboard-anonymous-projects") 

337 if top_project_ids_cache is not None: 

338 # hiredis-py 3 makes list from set 

339 top_project_ids = set(top_project_ids_cache) 

340 else: 

341 top_projects = sorted( 

342 prefetch_stats(request.user.allowed_projects), 

343 key=lambda prj: -prj.stats.monthly_changes, 

344 )[:20] 

345 top_project_ids = {p.id for p in top_projects} 

346 cache.set("dashboard-anonymous-projects", top_project_ids, 3600) 

347 top_projects = request.user.allowed_projects.filter(id__in=top_project_ids) 

348 

349 return render( 

350 request, 

351 "dashboard/anonymous.html", 

352 { 

353 "top_projects": prefetch_stats(prefetch_project_flags(top_projects)), 

354 "all_projects": Metric.objects.get_current_metric( 

355 None, Metric.SCOPE_GLOBAL, 0 

356 )["public_projects"], 

357 "bootstrap_5": True, 

358 }, 

359 )