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
« 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
6from typing import TYPE_CHECKING
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
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
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
36 from django.http import HttpResponse
38 from weblate.auth.models import AuthenticatedHttpRequest, User
39 from weblate.trans.models.translation import TranslationQuerySet
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
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)
82def guess_user_language(
83 request: AuthenticatedHttpRequest, translations: TranslationQuerySet
84) -> Language | None:
85 """
86 Guess user language for translations.
88 It tries following:
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
102 # Try getting from Accept-Language
103 if request.accepted_language:
104 return request.accepted_language
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
115def get_user_translations(
116 request: AuthenticatedHttpRequest, user: User, user_has_languages: bool
117) -> TranslationQuerySet:
118 """
119 Get list of translations in user languages.
121 Works also for anonymous users based on current UI language.
122 """
123 result = Translation.objects.prefetch().filter_access(user).order()
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
133 return result
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)
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)
154@never_cache
155def home(request: AuthenticatedHttpRequest) -> HttpResponse:
156 """Home page handler serving different views based on user."""
157 user = request.user
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 )
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")
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 )
192 # Redirect to single project or component
193 if settings.SINGLE_PROJECT:
194 return redirect_single_project(user)
196 if not user.is_authenticated:
197 return dashboard_anonymous(request)
199 return dashboard_user(request)
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)
218 translations = translation_prefetch_tasks(
219 prefetch_stats(list(user_translations.filter(component__in=components)))
220 )
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 )
239 componentlist.translations = translations
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]
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
250 user_translations = get_user_translations(request, user, user_has_languages)
252 componentlist = None
253 for current in fetch_componentlists(request.user, user_translations):
254 if current.slug == name:
255 componentlist = current
257 if componentlist is None:
258 raise Http404
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 )
269def dashboard_user(request: AuthenticatedHttpRequest) -> HttpResponse:
270 """Home page of Weblate for authenticated user."""
271 user = request.user
273 user_has_languages = user.is_authenticated and user.profile.all_languages
275 user_translations = get_user_translations(request, user, user_has_languages)
277 suggestions = get_suggestions(user, user_has_languages, user_translations)
279 usersubscriptions = None
281 componentlists = fetch_componentlists(request.user, user_translations)
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()
291 if user.is_authenticated:
292 usersubscriptions = user_translations.filter_access(user).filter(
293 component__project__in=user.watched_projects
294 )
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 )
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()
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 )
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)
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 )