Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/change_display.py: 0%
271 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
5from __future__ import annotations
7from typing import TYPE_CHECKING, Any, ClassVar, cast
9from django.template.loader import render_to_string
10from django.utils.html import escape, format_html
11from django.utils.translation import gettext, gettext_lazy, npgettext, pgettext
13from weblate.addons.models import ADDONS
14from weblate.lang.models import Language
15from weblate.trans.actions import ActionEvents
16from weblate.trans.models.alert import ALERTS
17from weblate.trans.models.change import COMPONENT_ORIGINS
18from weblate.trans.models.project import Project
19from weblate.trans.templatetags.translations import (
20 format_language_string,
21 format_unit_source,
22 format_unit_target,
23)
24from weblate.utils.files import FileUploadMethod, get_upload_message
25from weblate.utils.markdown import render_markdown
26from weblate.utils.pii import mask_email
28if TYPE_CHECKING:
29 from django_stubs_ext import StrOrPromise
31 from weblate.trans.models.change import Change, Translation
33AUTO_ACTIONS = {
34 # Translators: Name of event in the history
35 ActionEvents.LOCK: gettext_lazy(
36 "The component was automatically locked because of an alert."
37 ),
38 # Translators: Name of event in the history
39 ActionEvents.UNLOCK: gettext_lazy(
40 "Fixing an alert automatically unlocked the component."
41 ),
42}
45class ChangeDetailsRenderFactory:
46 @staticmethod
47 def get_strategy(change: Change) -> BaseDetailsRenderStrategy:
48 """Get strategy for displaying change details."""
49 for strategy in [
50 s for s in DETAILS_DISPLAY_STRATEGIES if not s.details_required
51 ]:
52 if change.action in strategy.actions:
53 return strategy()
55 if not change.details:
56 return RenderEmptyDetails()
58 # Following rendering relies on details present
59 for strategy in [s for s in DETAILS_DISPLAY_STRATEGIES if s.details_required]:
60 if change.action in strategy.actions:
61 return strategy()
63 # No specific strategy is found, return an empty display strategy
64 return RenderEmptyDetails()
67DETAILS_DISPLAY_STRATEGIES: list[type[BaseDetailsRenderStrategy]] = []
70def register_details_display_strategy(
71 strategy: type[BaseDetailsRenderStrategy],
72) -> type[BaseDetailsRenderStrategy]:
73 """Register a new strategy for displaying change details."""
74 DETAILS_DISPLAY_STRATEGIES.append(strategy)
75 return strategy
78class BaseDetailsRenderStrategy:
79 actions: ClassVar[set[ActionEvents]] = set()
80 details_required: bool = False
82 def render_details(self, change: Change) -> StrOrPromise:
83 raise NotImplementedError
86@register_details_display_strategy
87class RenderEmptyDetails(BaseDetailsRenderStrategy):
88 def render_details(self, change: Change) -> StrOrPromise:
89 return ""
92@register_details_display_strategy
93class RenderFileUploadDetails(BaseDetailsRenderStrategy):
94 actions: ClassVar[set[ActionEvents]] = {ActionEvents.FILE_UPLOAD}
96 def render_details(self, change: Change) -> StrOrPromise:
97 details = change.details
98 try:
99 method = FileUploadMethod[details["method"].upper()].label
100 except KeyError:
101 method = details["method"]
102 return format_html(
103 "{}<br>{} {}",
104 get_upload_message(
105 details["not_found"],
106 details["skipped"],
107 details["accepted"],
108 details["total"],
109 ),
110 gettext("File upload mode:"),
111 method,
112 )
115@register_details_display_strategy
116class RenderTarget(BaseDetailsRenderStrategy):
117 actions: ClassVar[set[ActionEvents]] = {
118 ActionEvents.ANNOUNCEMENT,
119 ActionEvents.AGREEMENT_CHANGE,
120 }
122 def render_details(self, change: Change) -> StrOrPromise:
123 return render_markdown(change.target)
126@register_details_display_strategy
127class RenderComment(BaseDetailsRenderStrategy):
128 actions: ClassVar[set[ActionEvents]] = {
129 ActionEvents.COMMENT_DELETE,
130 ActionEvents.COMMENT,
131 }
133 def render_details(self, change: Change) -> StrOrPromise:
134 if "comment" in change.details:
135 return render_markdown(change.details["comment"])
136 return ""
139@register_details_display_strategy
140class RenderAddon(BaseDetailsRenderStrategy):
141 actions: ClassVar[set[ActionEvents]] = {
142 ActionEvents.ADDON_CREATE,
143 ActionEvents.ADDON_CHANGE,
144 ActionEvents.ADDON_REMOVE,
145 }
147 def render_details(self, change: Change) -> StrOrPromise:
148 try:
149 return ADDONS[change.target].name
150 except KeyError:
151 return change.target
154@register_details_display_strategy
155class RenderUpdateEventDetails(BaseDetailsRenderStrategy):
156 """Strategy for displaying details of an update event."""
158 actions: ClassVar[set[ActionEvents]] = {ActionEvents.UPDATE}
160 def render_details(self, change: Change) -> StrOrPromise:
161 details = change.details
162 reason = details.get("reason", "content changed")
163 filename = format_html(
164 "<code>{}</code>",
165 details.get(
166 "filename",
167 change.translation.filename if change.translation else "",
168 ),
169 )
170 if reason == "content changed":
171 message = gettext("The “{}” file was changed.")
172 elif reason == "check forced":
173 message = gettext("Parsing of the “{}” file was enforced.")
174 elif reason == "new file":
175 message = gettext("File “{}” was added.")
176 else:
177 msg = f"Unknown reason: {reason}"
178 raise ValueError(msg)
179 return format_html(escape(message), filename)
182@register_details_display_strategy
183class RenderLicenceChangeDetails(BaseDetailsRenderStrategy):
184 """Strategy for displaying details of a license change event."""
186 actions: ClassVar[set[ActionEvents]] = {ActionEvents.LICENSE_CHANGE}
188 def render_details(self, change: Change) -> StrOrPromise:
189 not_available = pgettext("License information not available", "N/A")
190 return gettext(
191 'The license of the "%(component)s" component was changed '
192 "from %(old)s to %(target)s."
193 ) % {
194 "component": change.component,
195 "old": change.old or not_available,
196 "target": change.target or not_available,
197 }
200@register_details_display_strategy
201class RenderAutoActions(BaseDetailsRenderStrategy):
202 actions: ClassVar[set[ActionEvents]] = set(AUTO_ACTIONS.keys())
204 def render_details(self, change: Change) -> StrOrPromise:
205 if change.auto_status:
206 return AUTO_ACTIONS[cast("ActionEvents", change.action)]
207 return ""
210@register_details_display_strategy
211class RenderAccessEdit(BaseDetailsRenderStrategy):
212 """Strategy for displaying details of an access edit event."""
214 details_required = True
215 actions: ClassVar[set[ActionEvents]] = {ActionEvents.ACCESS_EDIT}
217 def render_details(self, change: Change) -> StrOrPromise:
218 details = change.details
219 for number, name in Project.ACCESS_CHOICES:
220 if number == details["access_control"]:
221 return name
222 return "Unknown {}".format(details["access_control"])
225@register_details_display_strategy
226class RenderUserActions(BaseDetailsRenderStrategy):
227 """Strategy for displaying details of user actions."""
229 actions: ClassVar[set[ActionEvents]] = {
230 ActionEvents.ADD_USER,
231 ActionEvents.INVITE_USER,
232 ActionEvents.REMOVE_USER,
233 }
234 details_required = True
236 def render_details(self, change: Change) -> StrOrPromise:
237 details = change.details
238 if "username" in details:
239 result = details["username"]
240 else:
241 result = mask_email(details["email"])
242 if "group" in details:
243 result = f"{result} ({details['group']})"
244 return result
247@register_details_display_strategy
248class RenderLanguage(BaseDetailsRenderStrategy):
249 """Strategy for displaying details of language actions."""
251 actions: ClassVar[set[ActionEvents]] = {
252 ActionEvents.ADDED_LANGUAGE,
253 ActionEvents.REQUESTED_LANGUAGE,
254 }
255 details_required = True
257 def render_details(self, change: Change) -> StrOrPromise:
258 try:
259 return Language.objects.get(code=change.details["language"])
260 except Language.DoesNotExist:
261 return change.details["language"]
264@register_details_display_strategy
265class RenderAlert(BaseDetailsRenderStrategy):
266 """Strategy for displaying details of an alert event."""
268 actions: ClassVar[set[ActionEvents]] = {ActionEvents.ALERT}
269 details_required = True
271 def render_details(self, change: Change) -> StrOrPromise:
272 try:
273 return ALERTS[change.details["alert"]].verbose
274 except KeyError:
275 return change.details["alert"]
278@register_details_display_strategy
279class RenderRepositoryDetails(BaseDetailsRenderStrategy):
280 """Strategy for displaying details of repository events."""
282 actions: ClassVar[set[ActionEvents]] = {
283 ActionEvents.RESET,
284 ActionEvents.MERGE,
285 ActionEvents.REBASE,
286 }
288 def render_details(self, change: Change) -> StrOrPromise:
289 return format_html(
290 "{}<br/><br/>{}<br/>{}",
291 change.get_action_display(),
292 format_html(
293 escape(gettext("Original revision: {}")),
294 change.details.get("previous_head", "N/A"),
295 ),
296 format_html(
297 escape(gettext("New revision: {}")),
298 change.details.get("new_head", "N/A"),
299 ),
300 )
303@register_details_display_strategy
304class RenderParseError(BaseDetailsRenderStrategy):
305 """Strategy for displaying details of a parse error event."""
307 actions: ClassVar[set[ActionEvents]] = {ActionEvents.PARSE_ERROR}
308 details_required = True
310 def render_details(self, change: Change) -> StrOrPromise:
311 return "{filename}: {error_message}".format(**change.details)
314@register_details_display_strategy
315class RenderHook(BaseDetailsRenderStrategy):
316 """Strategy for displaying details of a hook event."""
318 actions: ClassVar[set[ActionEvents]] = {ActionEvents.HOOK}
319 details_required = True
321 def render_details(self, change: Change) -> StrOrPromise:
322 return "{service_long_name}: {repo_url}, {branch}".format(**change.details)
325@register_details_display_strategy
326class RenderCreateComponent(BaseDetailsRenderStrategy):
327 """Strategy for displaying details of a component creation event."""
329 actions: ClassVar[set[ActionEvents]] = {ActionEvents.CREATE_COMPONENT}
331 def render_details(self, change: Change) -> StrOrPromise:
332 try:
333 origin = change.details["origin"]
334 except KeyError:
335 return ActionEvents.CREATE_COMPONENT.label
336 try:
337 return COMPONENT_ORIGINS[origin]
338 except KeyError:
339 return f"{ActionEvents.CREATE_COMPONENT.label} ({origin})"
342def get_change_history_context(change: Change) -> dict[str, Any]:
343 """
344 Get context for rendering change history.
346 This function returns a dictionary containing the main content and fields
347 for displaying the change history.
348 """
349 if details := change.get_details_display():
350 return {"description": details, "change_details_fields": []}
351 if change.show_content() and change.unit:
352 return ShowChangeContent(change).get_context()
353 if change.show_source():
354 return ShowChangeSource(change).get_context()
355 if change.show_diff():
356 return ShowChangeDiff(change).get_context()
357 if change.show_removed_string():
358 return ShowRemovedString(change).get_context()
359 if change.target:
360 return ShowChangeTarget(change).get_context()
361 return ShowChangeAction(change).get_context()
364class BaseChangeHistoryContext:
365 """Base class for change history context rendering."""
367 def __init__(self, change: Change) -> None:
368 self.change = change
369 self.fields: list[dict[str, Any]] = []
371 def get_context(self) -> dict[str, Any]:
372 """Get context for rendering change history."""
373 return {
374 "description": self.get_description(),
375 "change_details_fields": self.get_change_details_fields(),
376 }
378 def get_description(self) -> str:
379 return ""
381 def get_change_details_fields(self) -> list[dict]:
382 """
383 Generate a list of dictionaries representing fields to be displayed as label and input-like content.
385 Returns:
386 list[dict]: A list of dictionaries, each representing a field as returned by `make_field`.
388 """
389 return []
391 def make_field(
392 self,
393 label: str,
394 field_content: str,
395 label_badges: list[str] | None = None,
396 extra_label_classes: list[str] | None = None,
397 ) -> dict:
398 return {
399 "label": label,
400 "content": field_content,
401 "tags": label_badges or [],
402 "label_class": " ".join(extra_label_classes or []),
403 }
405 def format_translation(self, context: dict) -> str:
406 """Format translation context for rendering."""
407 return render_to_string("snippets/format-translation.html", context)
409 def make_distance_badge(self, count: int) -> str:
410 """Create a badge for the Damerau–Levenshtein distance.""" # noqa: RUF002
411 return npgettext(
412 "Number of edits on a change in Damerau–Levenshtein distance",
413 "%(count)d character edited",
414 "%(count)d characters edited",
415 count,
416 ) % {"count": count}
418 def add_context_field(self, translation: Translation) -> None:
419 if context := self.change.details.get("context"):
420 self.fields.append(
421 self.make_field(
422 gettext("Context"),
423 self.format_translation(
424 format_language_string(context, translation)
425 ),
426 )
427 )
430class ShowChangeTarget(BaseChangeHistoryContext):
431 """Display the target of a change in the history context."""
433 def get_description(self) -> str:
434 return format_html("<pre>{}</pre>", self.change.target)
437class ShowChangeAction(BaseChangeHistoryContext):
438 """Display the action of a change in the history context."""
440 def get_description(self) -> str:
441 return self.change.get_action_display()
444class ShowChangeContent(BaseChangeHistoryContext):
445 """Display the content of a change in the history context."""
447 LIST_GROUP_ITEM_TEMPLATE = """
448 <div class="list-group">
449 <div class="list-group-item sidebar-button">{0}</div>
450 </div>
451 """
453 def get_change_details_fields(self) -> list[dict]:
454 """Return the fields to be displayed in the change history."""
455 self.fields = []
456 change = self.change
457 if change.unit is None:
458 msg = "Change does not contain unit, can not render content!"
459 raise ValueError(msg)
460 unit = change.unit
462 # rejection reason field
463 if "rejection_reason" in change.details:
464 self.fields.append(
465 self.make_field(
466 gettext("Rejection reason"),
467 format_html(
468 self.LIST_GROUP_ITEM_TEMPLATE,
469 change.details["rejection_reason"],
470 ),
471 )
472 )
474 # source language field
475 self.fields.append(
476 self.make_field(
477 str(unit.translation.component.source_language),
478 self.format_translation(
479 format_unit_source(unit, value=self.change.get_source())
480 ),
481 )
482 )
484 # translation language field
485 translation_language_badges = [
486 self.make_distance_badge(change.get_distance()),
487 ]
489 if unit.target == change.target:
490 translation_language_badges.append(gettext("Current translation"))
491 else:
492 translation_language_badges.append(gettext("Previous translation"))
493 if change.show_unit_state():
494 state = change.get_state_display()
495 translation_language_badges.append(
496 gettext("State: %(state)s") % {"state": state}
497 )
499 self.fields.append(
500 self.make_field(
501 str(unit.translation.language),
502 self.format_translation(
503 format_unit_target(unit, value=change.target, diff=change.old)
504 ),
505 label_badges=translation_language_badges,
506 extra_label_classes=["tags-list"],
507 )
508 )
509 self.add_context_field(unit.translation)
510 return self.fields
513class ShowChangeSource(BaseChangeHistoryContext):
514 """Display the source of a change in the history context."""
516 def get_change_details_fields(self) -> list[dict]:
517 self.fields = []
518 change = self.change
519 if change.unit is None:
520 msg = "Change does not contain unit, can not render content!"
521 raise ValueError(msg)
522 unit = change.unit
524 # source language field
525 source_lang_badges = [
526 self.make_distance_badge(change.get_distance()),
527 ]
528 if change.show_unit_state():
529 source_lang_badges.append(change.get_state_display())
531 if change.target:
532 source_lang_content = format_unit_source(
533 unit, value=change.target, diff=change.old
534 )
535 else:
536 source_lang_content = format_unit_source(unit)
538 self.fields.append(
539 self.make_field(
540 str(unit.translation.component.source_language),
541 self.format_translation(source_lang_content),
542 label_badges=source_lang_badges,
543 )
544 )
545 self.add_context_field(unit.translation)
546 return self.fields
549class ShowChangeDiff(BaseChangeHistoryContext):
550 """Display the diff of a change in the history context."""
552 def get_change_details_fields(self) -> list[dict]:
553 change = self.change
554 if change.unit is None:
555 msg = "Change does not contain unit, can not render content!"
556 raise ValueError(msg)
557 unit = change.unit
558 return [
559 self.make_field(
560 "",
561 self.format_translation(
562 format_unit_source(unit, value=change.target, diff=change.old)
563 ),
564 label_badges=[self.make_distance_badge(change.get_distance())],
565 )
566 ]
569class ShowRemovedString(BaseChangeHistoryContext):
570 """Display the removed string of a change in the history context."""
572 def get_change_details_fields(self) -> list[dict]:
573 change = self.change
574 component = change.component
575 translation = change.translation
576 if component is None or translation is None:
577 msg = "Change does not contain translation, can not render content!"
578 raise ValueError(msg)
580 self.fields = [
581 self.make_field(
582 str(component.source_translation.language),
583 self.format_translation(
584 format_language_string(
585 change.details["source"], component.source_translation
586 )
587 ),
588 )
589 ]
590 if "target" in change.details and not translation.is_source:
591 self.fields.append(
592 self.make_field(
593 str(translation.language),
594 self.format_translation(
595 format_language_string(change.details["target"], translation)
596 ),
597 )
598 )
600 self.add_context_field(translation)
601 return self.fields