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

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

2# 

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

4from __future__ import annotations 

5 

6import json 

7import time 

8from math import ceil 

9from typing import TYPE_CHECKING, Any 

10 

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 

29 

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) 

72 

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 

75 

76SESSION_SEARCH_CACHE_TTL = 1800 

77 

78 

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 ) 

85 

86 

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 } 

99 

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 

107 

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)))) 

110 

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 

125 

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 ) 

136 

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 ) 

143 

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") 

149 

150 max_units = 20 

151 units_limited = units[:max_units].fill_in_source_translation() 

152 units_count = len(units_limited) 

153 

154 # Is it only this unit? 

155 if units_count == 1: 

156 return result 

157 

158 if units_count == max_units: 

159 # Get the real units count from the database 

160 units_count = units.count() 

161 

162 result["skipped"] = units_count > max_units 

163 

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) 

190 

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 

198 

199 result["total"] = sum( 

200 len(result[x]) for x in ("matching", "source", "context", "other") 

201 ) 

202 result["allow_merge"] = allow_merge 

203 

204 return result 

205 

206 

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] 

217 

218 

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 ) 

228 

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 = () 

244 

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}" 

251 

252 # Remove old search results 

253 cleanup_session(request.session) 

254 

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 

260 

261 query_string = cleaned_data.get("q", "") 

262 allunits = unit_set.search(query_string, project=project) 

263 

264 # Grab unit IDs 

265 unit_ids = list( 

266 allunits.order_by_request(cleaned_data, base).values_list("id", flat=True) 

267 ) 

268 

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") 

273 

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 

286 

287 search_result.update(store_result) 

288 return search_result 

289 

290 

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 

310 

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 

331 

332 

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 

343 

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) 

359 

360 # Warn about applied fixups 

361 if unit.fixups: 

362 display_fixups(request, unit.fixups) 

363 

364 # No change edit - should we skip to next entry 

365 if not saved: 

366 revert_rate_limit("translate", request) 

367 return True 

368 

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 ) 

391 

392 # Get new set of checks 

393 newchecks = unit.all_checks_names 

394 

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 

421 

422 return True and not add_alternative 

423 

424 

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 

434 

435 go_next = True 

436 

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) 

448 

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) 

453 

454 

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 

461 

462 merged = mergeform.cleaned_data["merge_unit"] 

463 

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 

469 

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) 

474 

475 

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 

481 

482 change = revertform.cleaned_data["revert_change"] 

483 

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 

489 

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) 

502 

503 

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 

529 

530 

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 

547 

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 

554 

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) 

561 

562 # Permissions check 

563 if not check_suggest_permissions(request, mode, unit, suggestion): 

564 return HttpResponseRedirect(this_unit_url) 

565 

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) 

592 

593 return HttpResponseRedirect(redirect_url) 

594 

595 

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 

604 

605 # Search results 

606 search_result = search(obj, project, unit_set, request) 

607 

608 # Handle redirects 

609 if isinstance(search_result, HttpResponse): 

610 return search_result 

611 

612 # Get number of results 

613 num_results = len(search_result["ids"]) 

614 

615 # Search offset 

616 offset = search_result["offset"] 

617 

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) 

640 

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) 

648 

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 

662 

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) 

669 

670 response = None 

671 

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) 

687 

688 # Handle translation merging 

689 elif "merge" in request.POST: 

690 response = handle_merge(unit, request, next_unit_url) 

691 

692 # Handle reverting 

693 elif "revert" in request.GET: 

694 response = handle_revert(unit, request, this_unit_url) 

695 

696 # Pass possible redirect further 

697 if response is not None: 

698 return response 

699 

700 # Show secondary languages for signed in users 

701 secondary = unit.get_secondary_units(user) if user.is_authenticated else None 

702 

703 # Prepare form 

704 form = TranslationForm(user, unit) 

705 

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 ) 

711 

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 ) 

776 

777 

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 

785 

786 autoform = AutoForm(translation.component, request.user, request.POST) 

787 

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) 

792 

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 ) 

821 

822 return redirect(translation) 

823 

824 

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) 

831 

832 if not request.user.has_perm("comment.add", unit.translation): 

833 raise PermissionDenied 

834 

835 form = CommentForm(unit.translation, request.POST) 

836 

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!")) 

845 

846 return redirect_next(request.POST.get("next"), unit) 

847 

848 

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) 

855 

856 if not request.user.has_perm("comment.delete", comment_obj): 

857 raise PermissionDenied 

858 

859 fallback_url = comment_obj.unit.get_absolute_url() 

860 

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.")) 

865 

866 return redirect_next(request.POST.get("next"), fallback_url) 

867 

868 

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) 

875 

876 if not request.user.has_perm("comment.resolve", comment_obj): 

877 raise PermissionDenied 

878 

879 fallback_url = comment_obj.unit.get_absolute_url() 

880 

881 comment_obj.resolve(user=request.user) 

882 messages.info(request, gettext("Comment has been resolved.")) 

883 

884 return redirect_next(request.POST.get("next"), fallback_url) 

885 

886 

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) 

891 

892 # Handle redirects 

893 if isinstance(search_result, HttpResponse): 

894 return search_result, None 

895 

896 offset = search_result["offset"] - 1 

897 search_result["last_section"] = offset + 20 >= len(search_result["ids"]) 

898 

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) 

904 

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 ] 

920 

921 return search_result, unitdata 

922 

923 

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"] 

930 

931 search_result, unitdata = get_zen_unitdata(obj, project, unit_set, request) 

932 

933 # Handle redirects 

934 if isinstance(search_result, HttpResponse): 

935 return search_result 

936 

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 ) 

956 

957 

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"] 

964 

965 search_result, unitdata = get_zen_unitdata(obj, project, unit_set, request) 

966 

967 # Handle redirects 

968 if isinstance(search_result, HttpResponse): 

969 return search_result 

970 

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 ) 

985 

986 

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 ) 

995 

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") 

1000 

1001 unit = checksum_form.cleaned_data["unit"] 

1002 translationsum = "" 

1003 

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) 

1016 

1017 translationsum = hash_to_checksum(unit.get_target_hash()) 

1018 

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 } 

1026 

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" 

1040 

1041 return JsonResponse(data=response) 

1042 

1043 

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 

1051 

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) 

1059 

1060 return redirect(translation) 

1061 

1062 

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) 

1069 

1070 if not request.user.has_perm("unit.delete", unit): 

1071 raise PermissionDenied 

1072 

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) 

1083 

1084 

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 ) 

1097 

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) 

1103 

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 )