Coverage for app/venv/lib/python3.14/site-packages/weblate/addons/autotranslate.py: 26%

75 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 

4 

5from __future__ import annotations 

6 

7from collections import defaultdict 

8from typing import TYPE_CHECKING, ClassVar 

9 

10from django.conf import settings 

11from django.utils import timezone 

12from django.utils.translation import gettext_lazy 

13 

14from weblate.addons.base import BaseAddon 

15from weblate.addons.events import AddonEvent 

16from weblate.addons.forms import AutoAddonForm 

17from weblate.trans.actions import ACTIONS_CONTENT, ActionEvents 

18from weblate.trans.models import Component 

19from weblate.trans.tasks import auto_translate, auto_translate_component 

20 

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

22 from django.forms.boundfield import BoundField 

23 from django_stubs_ext import StrOrPromise 

24 

25 from weblate.trans.models import Change 

26 

27SKIP_ACTIONS = {ActionEvents.AUTO, ActionEvents.ENFORCED_CHECK} 

28 

29 

30class AutoTranslateAddon(BaseAddon): 

31 events: ClassVar[set[AddonEvent]] = { 

32 AddonEvent.EVENT_COMPONENT_UPDATE, 

33 AddonEvent.EVENT_DAILY, 

34 AddonEvent.EVENT_CHANGE, 

35 } 

36 name = "weblate.autotranslate.autotranslate" 

37 verbose = gettext_lazy("Automatic translation") 

38 description = gettext_lazy( 

39 "Automatically translates strings using machine translation or " 

40 "other components." 

41 ) 

42 settings_form = AutoAddonForm 

43 multiple = True 

44 icon = "language.svg" 

45 

46 def component_update( 

47 self, component: Component, activity_log_id: int | None = None 

48 ) -> None: 

49 self.trigger_autotranslate(component=component) 

50 

51 def trigger_autotranslate( 

52 self, 

53 *, 

54 component: Component | None = None, 

55 user_id: int | None = None, 

56 translation_id: int | None = None, 

57 unit_ids: list[int] | None = None, 

58 ) -> None: 

59 conf = self.instance.configuration 

60 if component is None: 

61 auto_translate.delay_on_commit( 

62 mode=conf["mode"], 

63 q=conf["q"], 

64 auto_source=conf["auto_source"], 

65 engines=conf["engines"], 

66 threshold=conf["threshold"], 

67 component=conf["component"], 

68 user_id=user_id, 

69 translation_id=translation_id, 

70 unit_ids=unit_ids, 

71 ) 

72 else: 

73 auto_translate_component.delay_on_commit( 

74 component.pk, 

75 mode=conf["mode"], 

76 q=conf["q"], 

77 auto_source=conf["auto_source"], 

78 engines=conf["engines"], 

79 threshold=conf["threshold"], 

80 component=conf["component"], 

81 ) 

82 

83 def daily(self, component: Component, activity_log_id: int | None = None) -> None: 

84 # Translate every component less frequenctly to reduce load. 

85 # The translation is anyway triggered on update, so it should 

86 # not matter that much that we run this less often. 

87 if settings.BACKGROUND_TASKS == "never": 

88 return 

89 today = timezone.now() 

90 if settings.BACKGROUND_TASKS == "monthly" and component.id % 30 != today.day: 

91 return 

92 if ( 

93 settings.BACKGROUND_TASKS == "weekly" 

94 and component.id % 7 != today.weekday() 

95 ): 

96 return 

97 

98 self.trigger_autotranslate(component=component) 

99 

100 def change_event(self, change: Change, activity_log_id: int | None = None) -> None: 

101 units = [] 

102 if change.action in ACTIONS_CONTENT and change.action not in SKIP_ACTIONS: 

103 if change.unit is not None: 

104 units.append(change.unit) 

105 elif change.action in { 

106 ActionEvents.SCREENSHOT_UPLOADED, 

107 ActionEvents.SCREENSHOT_ADDED, 

108 }: 

109 if change.screenshot is not None: 

110 units.extend(change.screenshot.units.all()) 

111 elif change.action in { 

112 ActionEvents.SUGGESTION, 

113 ActionEvents.SUGGESTION_CLEANUP, 

114 ActionEvents.SUGGESTION_DELETE, 

115 }: 

116 if change.suggestion is not None: 

117 units.append(change.suggestion.unit) 

118 elif ( 

119 change.action 

120 in { 

121 ActionEvents.COMMENT, 

122 ActionEvents.COMMENT_RESOLVE, 

123 ActionEvents.COMMENT_DELETE, 

124 } 

125 and change.comment is not None 

126 ): 

127 units.append(change.comment.unit) 

128 

129 all_units = set() 

130 for unit in units: 

131 all_units.add(unit) 

132 # for source units, trigger auto-translation on all target units 

133 if unit.is_source: 

134 all_units.update(unit.unit_set.exclude(pk=unit.pk)) 

135 

136 translation_with_unit_ids = defaultdict(list) 

137 for unit in all_units: 

138 if unit.labels.filter(name="Automatically translated").exists(): 

139 # Skip already automatically translated strings here to avoid repeated 

140 # translating, for example with enforced checks. 

141 continue 

142 translation_with_unit_ids[unit.translation.id].append(unit.pk) 

143 

144 for translation_id, unit_ids in translation_with_unit_ids.items(): 

145 self.trigger_autotranslate( 

146 user_id=change.user_id, 

147 translation_id=translation_id, 

148 unit_ids=unit_ids, 

149 ) 

150 

151 def show_setting_field(self, field: BoundField) -> bool: 

152 auto_source = self.instance.configuration["auto_source"] 

153 # Do not show UI hidden fields 

154 if (auto_source == "mt" and field.name == "component") or ( 

155 auto_source == "others" and field.name in {"engines", "threshold"} 

156 ): 

157 return False 

158 return not field.is_hidden and field.value() 

159 

160 def get_setting_value(self, field: BoundField) -> StrOrPromise: 

161 if field.name == "component" and not hasattr(field.field, "choices"): 

162 # Manually handle char field 

163 return str(Component.objects.get(pk=field.value())) 

164 return super().get_setting_value(field)