Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/views/edit.py: 13%
499 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
6import json
7import time
8from math import ceil
9from typing import TYPE_CHECKING, Any
11import sentry_sdk
12from django.conf import settings
13from django.contrib.auth.decorators import login_required
14from django.contrib.messages import get_messages
15from django.core.exceptions import PermissionDenied
16from django.db import transaction
17from django.db.models import Count, Q, Value
18from django.db.models.functions import MD5, Lower
19from django.http import (
20 HttpResponse,
21 HttpResponseBadRequest,
22 HttpResponseRedirect,
23 JsonResponse,
24)
25from django.shortcuts import get_object_or_404, redirect
26from django.urls import reverse
27from django.utils.translation import gettext
28from django.views.decorators.http import require_POST
30from weblate.auth.models import AuthenticatedHttpRequest
31from weblate.checks.models import CHECKS, get_display_checks
32from weblate.glossary.forms import TermForm
33from weblate.glossary.models import fetch_glossary_terms, get_glossary_terms
34from weblate.screenshots.forms import ScreenshotForm
35from weblate.trans.actions import ActionEvents
36from weblate.trans.autotranslate import AutoTranslate
37from weblate.trans.exceptions import FileParseError, SuggestionSimilarToTranslationError
38from weblate.trans.forms import (
39 AutoForm,
40 ChecksumForm,
41 CommentForm,
42 ContextForm,
43 MergeForm,
44 PositionSearchForm,
45 RevertForm,
46 TranslationForm,
47 ZenTranslationForm,
48 get_new_unit_form,
49)
50from weblate.trans.models import Comment, Suggestion, Translation, Unit, Vote
51from weblate.trans.models.unit import fill_in_source_translation
52from weblate.trans.tasks import auto_translate
53from weblate.trans.templatetags.translations import (
54 try_linkify_filename,
55 unit_state_class,
56 unit_state_title,
57)
58from weblate.trans.util import redirect_next, render, split_plural
59from weblate.utils import messages
60from weblate.utils.antispam import is_spam
61from weblate.utils.hash import hash_to_checksum
62from weblate.utils.html import format_html_join_comma, list_to_tuples
63from weblate.utils.messages import get_message_kind
64from weblate.utils.ratelimit import revert_rate_limit, session_ratelimit_post
65from weblate.utils.state import STATE_APPROVED, STATE_FUZZY, STATE_TRANSLATED
66from weblate.utils.stats import CategoryLanguage, ProjectLanguage
67from weblate.utils.views import (
68 parse_path,
69 parse_path_units,
70 show_form_errors,
71)
73if TYPE_CHECKING: 73 ↛ 74line 73 didn't jump to line 74 because the condition on line 73 was never true
74 from weblate.auth.models import AuthenticatedHttpRequest
76SESSION_SEARCH_CACHE_TTL = 1800
79def display_fixups(request: AuthenticatedHttpRequest, fixups) -> None:
80 messages.info(
81 request,
82 gettext("Following fixups were applied to translation: %s")
83 % format_html_join_comma("{}", list_to_tuples(fixups)),
84 )
87def get_other_units(unit):
88 """Return other units to show while translating."""
89 with sentry_sdk.start_span(op="unit.others", name=f"{unit.pk}"):
90 result: dict[str, Any] = {
91 "total": 0,
92 "skipped": False,
93 "same": [],
94 "matching": [],
95 "context": [],
96 "source": [],
97 "other": [],
98 }
100 allow_merge = False
101 untranslated = False
102 translation = unit.translation
103 component = translation.component
104 propagation = component.allow_translation_propagation
105 same = None
106 any_propagated = False
108 query_match_source = Q(source__lower__md5=MD5(Lower(Value(unit.source))))
109 query_match_context = Q(context__lower__md5=MD5(Lower(Value(unit.context))))
111 if unit.source and unit.context:
112 match = Q(source=unit.source) & Q(context=unit.context)
113 if component.has_template():
114 query = query_match_source | query_match_context
115 else:
116 query = query_match_source
117 elif unit.source:
118 match = Q(source=unit.source) & Q(context="")
119 query = query_match_source
120 elif unit.context:
121 match = Q(context=unit.context)
122 query = query_match_context
123 else:
124 return result
126 base = (
127 Unit.objects.filter(
128 translation__component__project=component.project,
129 translation__language=translation.language,
130 )
131 .annotate(matches_current=Count("id", filter=match))
132 .prefetch()
133 .prefetch_source()
134 .order_by("-matches_current")
135 )
137 units = base.filter(query)
138 if unit.target:
139 units |= base.filter(
140 Q(target__lower__md5=MD5(Lower(Value(unit.target))))
141 & Q(state__gte=STATE_TRANSLATED)
142 )
144 # Use memory_db for the query in case it exists. This is supposed
145 # to be a read-only replica for offloading expensive translation
146 # queries.
147 if "memory_db" in settings.DATABASES:
148 units = units.using("memory_db")
150 max_units = 20
151 units_limited = units[:max_units].fill_in_source_translation()
152 units_count = len(units_limited)
154 # Is it only this unit?
155 if units_count == 1:
156 return result
158 if units_count == max_units:
159 # Get the real units count from the database
160 units_count = units.count()
162 result["skipped"] = units_count > max_units
164 for item in units_limited:
165 item.allow_merge = item.differently_translated = (
166 item.translated and item.target != unit.target
167 )
168 item.is_propagated = (
169 propagation
170 and item.translation.component.allow_translation_propagation
171 and item.translation.plural_id == translation.plural_id
172 and item.source == unit.source
173 and item.context == unit.context
174 )
175 if item.pk != unit.pk:
176 any_propagated |= item.is_propagated
177 untranslated |= not item.translated
178 allow_merge |= item.allow_merge
179 if item.pk == unit.pk:
180 same = item
181 result["same"].append(item)
182 elif item.source == unit.source and item.context == unit.context:
183 result["matching"].append(item)
184 elif item.source == unit.source:
185 result["source"].append(item)
186 elif item.context == unit.context:
187 result["context"].append(item)
188 else:
189 result["other"].append(item)
191 # Slightly different logic to allow applying current translation to
192 # the propagated strings
193 if same is not None and any_propagated:
194 same.allow_merge = (
195 (untranslated or allow_merge) and same.translated and propagation
196 )
197 allow_merge |= same.allow_merge
199 result["total"] = sum(
200 len(result[x]) for x in ("matching", "source", "context", "other")
201 )
202 result["allow_merge"] = allow_merge
204 return result
207def cleanup_session(session, delete_all: bool = False) -> None:
208 """Delete old search results from session storage."""
209 now = int(time.time())
210 keys = list(session.keys())
211 for key in keys:
212 if not key.startswith("search_"):
213 continue
214 value = session[key]
215 if delete_all or not isinstance(value, dict) or value["ttl"] < now:
216 del session[key]
219def search(
220 base, project, unit_set, request, blank: bool = False, use_cache: bool = True
221):
222 """Perform search or returns cached search results."""
223 now = int(time.time())
224 # Possible new search
225 form = PositionSearchForm(
226 request=request, data=request.GET, show_builder=False, obj=base
227 )
229 # Process form
230 form_valid = form.is_valid()
231 if form_valid:
232 cleaned_data = form.cleaned_data
233 search_url = form.urlencode()
234 search_query = form.cleaned_data["q"]
235 name = form.get_name()
236 search_items = form.items()
237 else:
238 cleaned_data = {}
239 show_form_errors(request, form)
240 search_url = ""
241 search_query = ""
242 name = ""
243 search_items = ()
245 with sentry_sdk.start_span(op="unit.search", name=search_url):
246 search_result = {
247 "form": form,
248 "offset": cleaned_data.get("offset", 1),
249 }
250 session_key = f"search_{base.cache_key}_{search_url}"
252 # Remove old search results
253 cleanup_session(request.session)
255 session_data = request.session.get(session_key)
256 if use_cache and session_data and "offset" in request.GET:
257 search_result.update(request.session[session_key])
258 request.session[session_key]["ttl"] = now + SESSION_SEARCH_CACHE_TTL
259 return search_result
261 query_string = cleaned_data.get("q", "")
262 allunits = unit_set.search(query_string, project=project)
264 # Grab unit IDs
265 unit_ids = list(
266 allunits.order_by_request(cleaned_data, base).values_list("id", flat=True)
267 )
269 # Check empty search results
270 if not unit_ids and not blank:
271 messages.warning(request, gettext("No strings found!"))
272 return redirect(f"{base.get_absolute_url()}?q={query_string}#search")
274 store_result = {
275 "query": search_query,
276 "url": search_url,
277 "items": search_items,
278 "key": session_key,
279 "name": str(name),
280 "ids": unit_ids,
281 "ttl": now + SESSION_SEARCH_CACHE_TTL,
282 "last_viewed_unit_id": None,
283 }
284 if use_cache:
285 request.session[session_key] = store_result
287 search_result.update(store_result)
288 return search_result
291def perform_suggestion(unit, form, request: AuthenticatedHttpRequest):
292 """Handle suggesion saving."""
293 if not form.cleaned_data["target"][0]:
294 messages.error(request, gettext("Your suggestion is empty!"))
295 # Stay on same entry
296 return False
297 if not request.user.has_perm("suggestion.add", unit):
298 # Need privilege to add
299 messages.error(
300 request, gettext("You don't have privileges to add suggestions!")
301 )
302 # Stay on same entry
303 return False
304 # Spam check for unauthenticated users
305 if not request.user.is_authenticated and is_spam(
306 request, form.cleaned_data["target"]
307 ):
308 messages.error(request, gettext("Your suggestion has been identified as spam!"))
309 return False
311 # Create the suggestion
312 try:
313 result = Suggestion.objects.add(
314 unit,
315 form.cleaned_data["target"],
316 request,
317 request.user.has_perm("suggestion.vote", unit),
318 raise_exception=True,
319 )
320 except SuggestionSimilarToTranslationError:
321 messages.error(
322 request, gettext("Your suggestion is similar to the current translation!")
323 )
324 result = False
325 else:
326 if not result:
327 messages.error(request, gettext("Your suggestion already exists!"))
328 elif result.fixups:
329 display_fixups(request, result.fixups)
330 return result
333def perform_translation(unit, form, request: AuthenticatedHttpRequest) -> bool:
334 """Handle translation and stores it to a backend."""
335 user = request.user
336 profile = user.profile
337 component = unit.translation.component
338 project = component.project
339 # Remember old checks
340 oldchecks = unit.all_checks_names
341 # Alternative translations handling
342 add_alternative = "add_alternative" in request.POST
344 # Update explanation for glossary
345 change_explanation = (
346 component.is_glossary and unit.explanation != form.cleaned_data["explanation"]
347 )
348 # Save
349 saved = unit.translate(
350 user,
351 form.cleaned_data["target"],
352 form.cleaned_data["state"],
353 request=request,
354 add_alternative=add_alternative,
355 )
356 # Make sure explanation is saved
357 if change_explanation:
358 saved |= unit.update_explanation(form.cleaned_data["explanation"], user)
360 # Warn about applied fixups
361 if unit.fixups:
362 display_fixups(request, unit.fixups)
364 # No change edit - should we skip to next entry
365 if not saved:
366 revert_rate_limit("translate", request)
367 return True
369 # Auto subscribe user
370 if not profile.all_languages:
371 language = unit.translation.language
372 profile.languages.add(language)
373 messages.info(
374 request,
375 gettext(
376 "Added %(language)s to your translated languages. "
377 "You can adjust them in the settings."
378 )
379 % {"language": language},
380 )
381 if profile.auto_watch and not profile.watched.filter(pk=project.pk).exists():
382 profile.watched.add(project)
383 messages.info(
384 request,
385 gettext(
386 "Added %(project)s to your watched projects. "
387 "You can adjust this behavior in the settings."
388 )
389 % {"project": project},
390 )
392 # Get new set of checks
393 newchecks = unit.all_checks_names
395 # Did we introduce any new failures?
396 if (
397 saved
398 and form.cleaned_data["state"] >= STATE_TRANSLATED
399 and (
400 # Any new checks?
401 newchecks > oldchecks
402 or
403 # Any enforced check?
404 (component.enforced_checks and newchecks & set(component.enforced_checks))
405 )
406 ):
407 # Show message to user
408 messages.error(
409 request,
410 gettext(
411 "The translation has been saved, however there "
412 "are some newly failing checks: {0}"
413 ).format(
414 format_html_join_comma(
415 "{}", list_to_tuples(CHECKS[check].name for check in newchecks)
416 )
417 ),
418 )
419 # Stay on same entry
420 return False
422 return True and not add_alternative
425@session_ratelimit_post("translate", logout_user=False)
426def handle_translate(
427 request: AuthenticatedHttpRequest, unit, this_unit_url, next_unit_url
428):
429 """Save translation or suggestion to database and backend."""
430 form = TranslationForm(request.user, unit, request.POST)
431 if not form.is_valid():
432 show_form_errors(request, form)
433 return None
435 go_next = True
437 if "suggest" in request.POST:
438 go_next = perform_suggestion(unit, form, request)
439 elif not request.user.has_perm("unit.edit", unit):
440 if request.user.has_perm("meta:unit.flag", unit):
441 unit.update_explanation(form.cleaned_data["explanation"], request.user)
442 else:
443 messages.error(
444 request, gettext("Insufficient privileges for saving translations.")
445 )
446 else:
447 go_next = perform_translation(unit, form, request)
449 # Redirect to next entry
450 if "save-stay" not in request.POST and go_next:
451 return HttpResponseRedirect(next_unit_url)
452 return HttpResponseRedirect(this_unit_url)
455def handle_merge(unit, request: AuthenticatedHttpRequest, next_unit_url):
456 """Handle unit merging."""
457 mergeform = MergeForm(unit, request.POST)
458 if not mergeform.is_valid():
459 show_form_errors(request, mergeform)
460 return None
462 merged = mergeform.cleaned_data["merge_unit"]
464 if not request.user.has_perm("unit.edit", unit):
465 messages.error(
466 request, gettext("Insufficient privileges for saving translations.")
467 )
468 return None
470 # Store unit
471 unit.translate(request.user, merged.get_target_plurals(), merged.state)
472 # Redirect to next entry
473 return HttpResponseRedirect(next_unit_url)
476def handle_revert(unit, request: AuthenticatedHttpRequest, next_unit_url):
477 revertform = RevertForm(unit, request.GET)
478 if not revertform.is_valid():
479 show_form_errors(request, revertform)
480 return None
482 change = revertform.cleaned_data["revert_change"]
484 if not request.user.has_perm("unit.edit", unit):
485 messages.error(
486 request, gettext("Insufficient privileges for saving translations.")
487 )
488 return None
490 if not change.can_revert():
491 messages.error(request, gettext("Can not revert to empty translation!"))
492 return None
493 # Store unit
494 unit.translate(
495 request.user,
496 split_plural(change.old),
497 STATE_FUZZY if change.action == ActionEvents.MARKED_EDIT else unit.state,
498 change_action=ActionEvents.REVERT,
499 )
500 # Redirect to next entry
501 return HttpResponseRedirect(next_unit_url)
504def check_suggest_permissions(
505 request: AuthenticatedHttpRequest, mode, unit, suggestion
506) -> bool:
507 """Check permission for suggestion handling."""
508 user = request.user
509 if mode in {"accept", "accept_edit", "accept_approve"}:
510 if not user.has_perm("suggestion.accept", unit) or (
511 mode == "accept_approve" and not user.has_perm("unit.review", unit)
512 ):
513 messages.error(
514 request, gettext("You do not have privilege to accept suggestions!")
515 )
516 return False
517 elif mode in {"delete", "spam"}:
518 if not user.has_perm("suggestion.delete", suggestion):
519 messages.error(
520 request, gettext("You do not have privilege to delete suggestions!")
521 )
522 return False
523 elif mode in {"upvote", "downvote"} and not user.has_perm("suggestion.vote", unit):
524 messages.error(
525 request, gettext("You do not have privilege to vote for suggestions!")
526 )
527 return False
528 return True
531def handle_suggestions(
532 request: AuthenticatedHttpRequest, unit, this_unit_url, next_unit_url
533):
534 """Handle suggestion deleting/accepting."""
535 sugid = ""
536 params = (
537 "accept",
538 "accept_edit",
539 "accept_approve",
540 "delete",
541 "spam",
542 "upvote",
543 "downvote",
544 )
545 redirect_url = this_unit_url
546 mode = None
548 # Parse suggestion ID
549 for param in params:
550 if param in request.POST:
551 sugid = request.POST[param]
552 mode = param
553 break
555 # Fetch suggestion
556 try:
557 suggestion = Suggestion.objects.get(pk=int(sugid), unit=unit)
558 except (Suggestion.DoesNotExist, ValueError):
559 messages.error(request, gettext("Invalid suggestion!"))
560 return HttpResponseRedirect(this_unit_url)
562 # Permissions check
563 if not check_suggest_permissions(request, mode, unit, suggestion):
564 return HttpResponseRedirect(this_unit_url)
566 # Perform operation
567 if (
568 "accept" in request.POST
569 or "accept_edit" in request.POST
570 or "accept_approve" in request.POST
571 ):
572 suggestion.accept(
573 request,
574 state=STATE_APPROVED
575 if "accept_approve" in request.POST
576 else STATE_TRANSLATED,
577 )
578 if "accept_edit" not in request.POST:
579 redirect_url = next_unit_url
580 elif "delete" in request.POST or "spam" in request.POST:
581 suggestion.delete_log(
582 request.user,
583 is_spam="spam" in request.POST,
584 rejection_reason=request.POST.get("rejection", ""),
585 old=unit.target,
586 )
587 elif "upvote" in request.POST:
588 suggestion.add_vote(request, Vote.POSITIVE)
589 redirect_url = next_unit_url
590 elif "downvote" in request.POST:
591 suggestion.add_vote(request, Vote.NEGATIVE)
593 return HttpResponseRedirect(redirect_url)
596@transaction.atomic
597def translate(request: AuthenticatedHttpRequest, path):
598 """Translate, suggest and search view."""
599 obj, unit_set, context = parse_path_units(
600 request, path, (Translation, ProjectLanguage, CategoryLanguage)
601 )
602 project = context["project"]
603 user = request.user
605 # Search results
606 search_result = search(obj, project, unit_set, request)
608 # Handle redirects
609 if isinstance(search_result, HttpResponse):
610 return search_result
612 # Get number of results
613 num_results = len(search_result["ids"])
615 # Search offset
616 offset = search_result["offset"]
618 # Checksum unit access
619 payload = request.GET or request.POST
620 if payload.get("checksum"):
621 checksum_form = ChecksumForm(unit_set, payload)
622 if checksum_form.is_valid():
623 unit = checksum_form.cleaned_data["unit"]
624 try:
625 offset = search_result["ids"].index(unit.id) + 1
626 except ValueError:
627 offset = None
628 else:
629 offset = None
630 if offset is None:
631 messages.warning(request, gettext("No strings found!"))
632 return redirect(obj)
633 else:
634 # Check boundaries
635 if not 0 < offset <= num_results:
636 messages.info(request, gettext("The translation has come to an end."))
637 # Delete search
638 del request.session[search_result["key"]]
639 return redirect(obj)
641 # Grab actual unit
642 try:
643 unit = unit_set.get(pk=search_result["ids"][offset - 1])
644 except Unit.DoesNotExist:
645 # Can happen when using SID for other translation
646 messages.error(request, gettext("Invalid search string!"))
647 return redirect(obj)
649 last_viewed_unit_id = search_result.get("last_viewed_unit_id")
650 if last_viewed_unit_id:
651 previous_unit = unit_set.get(pk=last_viewed_unit_id)
652 if unit.translation.component != previous_unit.translation.component:
653 messages.warning(
654 request,
655 gettext("You have shifted from %(previous)s to %(current)s.")
656 % {
657 "previous": previous_unit.translation.full_slug,
658 "current": unit.translation.full_slug,
659 },
660 )
661 search_result["last_viewed_unit_id"] = unit.id
663 # Some URLs we will most likely use
664 base_unit_url = "{}?{}&offset=".format(
665 obj.get_translate_url(), search_result["url"]
666 )
667 this_unit_url = base_unit_url + str(offset)
668 next_unit_url = base_unit_url + str(offset + 1)
670 response = None
672 if request.method == "POST" and "merge" not in request.POST:
673 if (
674 "accept" in request.POST
675 or "accept_edit" in request.POST
676 or "accept_approve" in request.POST
677 or "delete" in request.POST
678 or "spam" in request.POST
679 or "upvote" in request.POST
680 or "downvote" in request.POST
681 ):
682 # Handle accepting/deleting suggestions
683 response = handle_suggestions(request, unit, this_unit_url, next_unit_url)
684 else:
685 # Handle translation
686 response = handle_translate(request, unit, this_unit_url, next_unit_url)
688 # Handle translation merging
689 elif "merge" in request.POST:
690 response = handle_merge(unit, request, next_unit_url)
692 # Handle reverting
693 elif "revert" in request.GET:
694 response = handle_revert(unit, request, this_unit_url)
696 # Pass possible redirect further
697 if response is not None:
698 return response
700 # Show secondary languages for signed in users
701 secondary = unit.get_secondary_units(user) if user.is_authenticated else None
703 # Prepare form
704 form = TranslationForm(user, unit)
706 screenshot_form = None
707 if user.has_perm("screenshot.add", unit.translation):
708 screenshot_form = ScreenshotForm(
709 unit.translation.component, initial={"translation": unit.translation}
710 )
712 return render(
713 request,
714 "translate.html",
715 {
716 "path_object": obj,
717 "this_unit_url": this_unit_url,
718 "first_unit_url": base_unit_url + "1",
719 "last_unit_url": base_unit_url + str(num_results),
720 "next_section_url": base_unit_url
721 + str(offset + user.profile.nearby_strings),
722 "prev_section_url": base_unit_url
723 + str(max(1, offset - user.profile.nearby_strings)),
724 "next_unit_url": next_unit_url,
725 "prev_unit_url": base_unit_url + str(offset - 1),
726 "object": obj,
727 "project": project,
728 "component": obj.component if isinstance(obj, Translation) else None,
729 "unit": unit,
730 "nearby": unit.nearby(user.profile.nearby_strings),
731 "nearby_keys": unit.nearby_keys(user.profile.nearby_strings),
732 "can_go_next_section": offset + user.profile.nearby_strings <= num_results,
733 "others": get_other_units(unit) if user.is_authenticated else {"total": 0},
734 "search_url": search_result["url"],
735 "search_items": search_result["items"],
736 "search_query": search_result["query"],
737 "offset": offset,
738 "filter_count": num_results,
739 "filter_pos": offset,
740 "form": form,
741 "comment_form": CommentForm(
742 unit.translation,
743 initial={"scope": "global" if unit.is_source else "translation"},
744 ),
745 "context_form": ContextForm(instance=unit.source_unit, user=user),
746 "search_form": search_result["form"].reset_offset(),
747 "secondary": secondary,
748 "locked": unit.translation.component.locked,
749 "glossary": get_glossary_terms(unit, full=True),
750 "addterm_form": TermForm(unit, user),
751 "last_changes": unit.change_set.prefetch().recent(skip_preload="unit"),
752 "screenshots": (
753 unit.source_unit.screenshots.all() | unit.screenshots.all()
754 ).order(),
755 "display_checks": list(get_display_checks(unit)),
756 "comments_to_check": unit.unresolved_comments,
757 "machinery_services": json.dumps(
758 list(project.get_machinery_settings().keys())
759 ),
760 "new_unit_form": get_new_unit_form(
761 unit.translation, user, initial={"variant": unit.pk}
762 ),
763 "screenshot_form": screenshot_form,
764 "translation_file_link": lambda: try_linkify_filename(
765 unit.translation.filename,
766 unit.translation.filename,
767 # '1' as a placeholder, because `get_repoweb_link` can't currently
768 # generate links without line specified. Although it's ok to use
769 # '' or '0' on GitHub or GitLab, let's play it safe for now.
770 "1",
771 unit,
772 user.profile,
773 ),
774 },
775 )
778@require_POST
779@login_required
780def auto_translation(request: AuthenticatedHttpRequest, path):
781 translation = parse_path(request, path, (Translation,))
782 project = translation.component.project
783 if not request.user.has_perm("translation.auto", project):
784 raise PermissionDenied
786 autoform = AutoForm(translation.component, request.user, request.POST)
788 if translation.component.locked or not autoform.is_valid():
789 messages.error(request, gettext("Could not process form!"))
790 show_form_errors(request, autoform)
791 return redirect(translation)
793 if settings.CELERY_TASK_ALWAYS_EAGER:
794 auto = AutoTranslate(
795 user=request.user,
796 translation=translation,
797 mode=autoform.cleaned_data["mode"],
798 q=autoform.cleaned_data.get("q"),
799 )
800 message = auto.perform(
801 auto_source=autoform.cleaned_data["auto_source"],
802 source=autoform.cleaned_data["component"],
803 engines=autoform.cleaned_data["engines"],
804 threshold=autoform.cleaned_data["threshold"],
805 )
806 messages.success(request, message)
807 else:
808 task = auto_translate.delay(
809 user_id=request.user.id,
810 translation_id=translation.id,
811 mode=autoform.cleaned_data["mode"],
812 q=autoform.cleaned_data.get("q"),
813 auto_source=autoform.cleaned_data["auto_source"],
814 component=autoform.cleaned_data["component"],
815 engines=autoform.cleaned_data["engines"],
816 threshold=autoform.cleaned_data["threshold"],
817 )
818 messages.success(
819 request, gettext("Automatic translation in progress"), f"task:{task.id}"
820 )
822 return redirect(translation)
825@login_required
826@transaction.atomic
827@session_ratelimit_post("comment", logout_user=False)
828def comment(request: AuthenticatedHttpRequest, pk):
829 """Add new comment."""
830 unit = get_object_or_404(Unit, pk=pk)
832 if not request.user.has_perm("comment.add", unit.translation):
833 raise PermissionDenied
835 form = CommentForm(unit.translation, request.POST)
837 if form.is_valid():
838 text = form.cleaned_data["comment"]
839 scope = form.cleaned_data["scope"]
840 Comment.objects.add(request, unit, text, scope)
841 messages.success(request, gettext("Posted new comment"))
842 else:
843 show_form_errors(request, form)
844 messages.error(request, gettext("Could not add comment!"))
846 return redirect_next(request.POST.get("next"), unit)
849@login_required
850@require_POST
851@transaction.atomic
852def delete_comment(request: AuthenticatedHttpRequest, pk):
853 """Delete comment."""
854 comment_obj = get_object_or_404(Comment, pk=pk)
856 if not request.user.has_perm("comment.delete", comment_obj):
857 raise PermissionDenied
859 fallback_url = comment_obj.unit.get_absolute_url()
861 if "spam" in request.POST:
862 comment_obj.report_spam()
863 comment_obj.delete(user=request.user)
864 messages.info(request, gettext("Comment has been deleted."))
866 return redirect_next(request.POST.get("next"), fallback_url)
869@login_required
870@require_POST
871@transaction.atomic
872def resolve_comment(request: AuthenticatedHttpRequest, pk):
873 """Resolve comment."""
874 comment_obj = get_object_or_404(Comment, pk=pk)
876 if not request.user.has_perm("comment.resolve", comment_obj):
877 raise PermissionDenied
879 fallback_url = comment_obj.unit.get_absolute_url()
881 comment_obj.resolve(user=request.user)
882 messages.info(request, gettext("Comment has been resolved."))
884 return redirect_next(request.POST.get("next"), fallback_url)
887def get_zen_unitdata(obj, project, unit_set, request: AuthenticatedHttpRequest):
888 """Load unit data for zen mode."""
889 # Search results
890 search_result = search(obj, project, unit_set, request)
892 # Handle redirects
893 if isinstance(search_result, HttpResponse):
894 return search_result, None
896 offset = search_result["offset"] - 1
897 search_result["last_section"] = offset + 20 >= len(search_result["ids"])
899 units = unit_set.prefetch_full().get_ordered(
900 search_result["ids"][offset : offset + 20]
901 )
902 fill_in_source_translation(units)
903 fetch_glossary_terms(units)
905 unitdata = [
906 {
907 "unit": unit,
908 "secondary": (
909 unit.get_secondary_units(request.user)
910 if request.user.is_authenticated
911 and request.user.profile.secondary_in_zen
912 else None
913 ),
914 "form": ZenTranslationForm(request.user, unit),
915 "offset": offset + pos + 1,
916 "glossary": get_glossary_terms(unit),
917 }
918 for pos, unit in enumerate(units)
919 ]
921 return search_result, unitdata
924def zen(request: AuthenticatedHttpRequest, path):
925 """Zen mode view."""
926 obj, unit_set, context = parse_path_units(
927 request, path, (Translation, ProjectLanguage, CategoryLanguage)
928 )
929 project = context["project"]
931 search_result, unitdata = get_zen_unitdata(obj, project, unit_set, request)
933 # Handle redirects
934 if isinstance(search_result, HttpResponse):
935 return search_result
937 return render(
938 request,
939 "zen.html",
940 {
941 "object": obj,
942 "path_object": obj,
943 "project": project,
944 "component": obj.component if isinstance(obj, Translation) else None,
945 "unitdata": unitdata,
946 "search_query": search_result["query"],
947 "filter_count": len(search_result["ids"]),
948 "filter_pos": search_result["offset"],
949 "last_section": search_result["last_section"],
950 "search_url": search_result["url"],
951 "offset": search_result["offset"],
952 "search_form": search_result["form"].reset_offset(),
953 "is_zen": True,
954 },
955 )
958def load_zen(request: AuthenticatedHttpRequest, path):
959 """Load additional units for zen editor."""
960 obj, unit_set, context = parse_path_units(
961 request, path, (Translation, ProjectLanguage, CategoryLanguage)
962 )
963 project = context["project"]
965 search_result, unitdata = get_zen_unitdata(obj, project, unit_set, request)
967 # Handle redirects
968 if isinstance(search_result, HttpResponse):
969 return search_result
971 return render(
972 request,
973 "zen-units.html",
974 {
975 "object": obj,
976 "path_object": obj,
977 "project": project,
978 "component": obj.component if isinstance(obj, Translation) else None,
979 "unitdata": unitdata,
980 "search_query": search_result["query"],
981 "search_url": search_result["url"],
982 "last_section": search_result["last_section"],
983 },
984 )
987@login_required
988@require_POST
989@transaction.atomic
990def save_zen(request: AuthenticatedHttpRequest, path):
991 """Save handler for zen mode."""
992 _obj, unit_set, _context = parse_path_units(
993 request, path, (Translation, ProjectLanguage, CategoryLanguage)
994 )
996 checksum_form = ChecksumForm(unit_set, request.POST)
997 if not checksum_form.is_valid():
998 show_form_errors(request, checksum_form)
999 return HttpResponseBadRequest("Invalid checksum")
1001 unit = checksum_form.cleaned_data["unit"]
1002 translationsum = ""
1004 form = TranslationForm(request.user, unit, request.POST)
1005 if not form.is_valid():
1006 show_form_errors(request, form)
1007 elif not request.user.has_perm("unit.edit", unit):
1008 if request.user.has_perm("meta:unit.flag", unit):
1009 unit.update_explanation(form.cleaned_data["explanation"], request.user)
1010 else:
1011 messages.error(
1012 request, gettext("Insufficient privileges for saving translations.")
1013 )
1014 else:
1015 perform_translation(unit, form, request)
1017 translationsum = hash_to_checksum(unit.get_target_hash())
1019 response: dict[str, Any] = {
1020 "messages": [],
1021 "state": "success",
1022 "translationsum": translationsum,
1023 "unit_state_class": unit_state_class(unit) if unit else "",
1024 "unit_state_title": unit_state_title(unit) if unit else "",
1025 }
1027 storage = get_messages(request)
1028 if storage:
1029 response["messages"] = [
1030 {"tags": m.tags, "kind": get_message_kind(m.tags), "text": m.message}
1031 for m in storage
1032 ]
1033 tags = {m.tags for m in storage}
1034 if "error" in tags:
1035 response["state"] = "danger"
1036 elif "warning" in tags:
1037 response["state"] = "warning"
1038 elif "info" in tags:
1039 response["state"] = "info"
1041 return JsonResponse(data=response)
1044@require_POST
1045@login_required
1046@transaction.atomic
1047def new_unit(request: AuthenticatedHttpRequest, path):
1048 translation = parse_path(request, path, (Translation,))
1049 if not request.user.has_perm("unit.add", translation):
1050 raise PermissionDenied
1052 form = get_new_unit_form(translation, request.user, request.POST)
1053 if not form.is_valid():
1054 show_form_errors(request, form)
1055 else:
1056 created_unit = translation.add_unit(request, **form.as_kwargs())
1057 messages.success(request, gettext("New string has been added."))
1058 return redirect(created_unit)
1060 return redirect(translation)
1063@login_required
1064@require_POST
1065@transaction.atomic
1066def delete_unit(request: AuthenticatedHttpRequest, unit_id):
1067 """Delete unit."""
1068 unit = get_object_or_404(Unit, pk=unit_id)
1070 if not request.user.has_perm("unit.delete", unit):
1071 raise PermissionDenied
1073 try:
1074 unit.translation.delete_unit(request, unit)
1075 except FileParseError as error:
1076 unit.translation.component.update_import_alerts(delete=False)
1077 messages.error(request, gettext("Could not remove the string: %s") % error)
1078 return redirect(unit)
1079 messages.success(request, gettext("String was removed."))
1080 # Remove cached search results as we've just removed one of the unit there
1081 cleanup_session(request.session, delete_all=True)
1082 return redirect_next(request.POST.get("next"), unit.translation)
1085def browse(request: AuthenticatedHttpRequest, path):
1086 """Strings browsing."""
1087 obj, unit_set, context = parse_path_units(
1088 request, path, (Translation, ProjectLanguage)
1089 )
1090 project = context["project"]
1091 search_result = search(obj, project, unit_set, request, blank=True, use_cache=False)
1092 offset = search_result["offset"]
1093 page = 20
1094 units = unit_set.prefetch_full().get_ordered(
1095 search_result["ids"][(offset - 1) * page : (offset - 1) * page + page]
1096 )
1098 base_unit_url = "{}?{}&offset=".format(
1099 reverse("browse", kwargs={"path": obj.get_url_path()}),
1100 search_result["url"],
1101 )
1102 num_results = ceil(len(search_result["ids"]) / page)
1104 return render(
1105 request,
1106 "browse.html",
1107 {
1108 "object": obj,
1109 "path_object": obj,
1110 "project": project,
1111 "component": obj.component if isinstance(obj, Translation) else None,
1112 "units": units,
1113 "search_query": search_result["query"],
1114 "search_url": search_result["url"],
1115 "search_form": search_result["form"].reset_offset(),
1116 "filter_count": num_results,
1117 "filter_pos": offset,
1118 "first_unit_url": base_unit_url + "1",
1119 "last_unit_url": base_unit_url + str(num_results),
1120 "next_unit_url": base_unit_url + str(offset + 1)
1121 if offset < num_results
1122 else None,
1123 "prev_unit_url": base_unit_url + str(offset - 1) if offset > 1 else None,
1124 "is_in_browse": True,
1125 },
1126 )