wagtail-localize-intentional-blanks 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ """
2
+ Template tags and filters for intentional blanks.
3
+
4
+ These template tags can be used to display information about
5
+ translation status and "do not translate" markers in templates.
6
+ """
7
+
8
+ from django import template
9
+
10
+ from ..utils import get_source_fallback_stats, is_do_not_translate
11
+
12
+ register = template.Library()
13
+
14
+
15
+ @register.filter
16
+ def is_marked_do_not_translate(string_translation):
17
+ """
18
+ Template filter to check if a StringTranslation is marked as "do not translate".
19
+
20
+ Usage:
21
+ {% load intentional_blanks %}
22
+ {% if translation|is_marked_do_not_translate %}
23
+ <span class="badge">Do not translate</span>
24
+ {% endif %}
25
+
26
+ Args:
27
+ string_translation: StringTranslation instance
28
+
29
+ Returns:
30
+ bool: True if marked as "do not translate"
31
+ """
32
+ return is_do_not_translate(string_translation)
33
+
34
+
35
+ @register.simple_tag
36
+ def translation_stats(translation):
37
+ """
38
+ Template tag to get translation statistics.
39
+
40
+ Usage:
41
+ {% load intentional_blanks %}
42
+ {% translation_stats translation as stats %}
43
+ <p>{{ stats.do_not_translate }} segments marked as do not translate</p>
44
+ <p>{{ stats.manually_translated }} segments manually translated</p>
45
+
46
+ Args:
47
+ translation: Translation instance
48
+
49
+ Returns:
50
+ dict: Statistics about translation
51
+ """
52
+ return get_source_fallback_stats(translation)
@@ -0,0 +1,27 @@
1
+ """
2
+ URL patterns for the intentional blanks API.
3
+ """
4
+
5
+ from django.urls import path
6
+
7
+ from . import views
8
+
9
+ app_name = "wagtail_localize_intentional_blanks"
10
+
11
+ urlpatterns = [
12
+ path(
13
+ "translations/<int:translation_id>/segment/<int:segment_id>/do-not-translate/",
14
+ views.mark_segment_do_not_translate_view,
15
+ name="mark_segment_do_not_translate",
16
+ ),
17
+ path(
18
+ "translations/<int:translation_id>/segment/<int:segment_id>/status/",
19
+ views.get_segment_status,
20
+ name="get_segment_status",
21
+ ),
22
+ path(
23
+ "translations/<int:translation_id>/status/",
24
+ views.get_translation_status,
25
+ name="get_translation_status",
26
+ ),
27
+ ]
@@ -0,0 +1,415 @@
1
+ """
2
+ Utility functions for marking segments as "do not translate".
3
+ """
4
+
5
+ import logging
6
+
7
+ from django.db.models import Q
8
+ from wagtail_localize.models import StringSegment, StringTranslation
9
+
10
+ from .constants import get_setting
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def get_marker():
16
+ """Get the configured marker value."""
17
+ return get_setting("MARKER")
18
+
19
+
20
+ def get_backup_separator():
21
+ """
22
+ Get the configured backup separator value.
23
+
24
+ The backup separator is used when encoding backup values in the marker.
25
+ Format: MARKER + BACKUP_SEPARATOR + original_value
26
+ Example: "__DO_NOT_TRANSLATE__|backup|original_value"
27
+ """
28
+ return get_setting("BACKUP_SEPARATOR")
29
+
30
+
31
+ def validate_configuration():
32
+ """
33
+ Validate that required configuration values are set.
34
+
35
+ Raises:
36
+ ValueError: If marker or backup_separator is None or empty
37
+ """
38
+ marker = get_marker()
39
+ backup_separator = get_backup_separator()
40
+
41
+ if marker is None or marker == "":
42
+ raise ValueError(
43
+ "WAGTAIL_LOCALIZE_INTENTIONAL_BLANKS_MARKER must be set to a non-empty string. Check your Django settings."
44
+ )
45
+
46
+ if backup_separator is None or backup_separator == "":
47
+ raise ValueError(
48
+ "WAGTAIL_LOCALIZE_INTENTIONAL_BLANKS_BACKUP_SEPARATOR must be set to a non-empty string. Check your Django settings."
49
+ )
50
+
51
+
52
+ def mark_segment_do_not_translate(translation, segment, user=None):
53
+ """
54
+ Mark a translation segment as "do not translate".
55
+
56
+ Args:
57
+ translation: Translation instance
58
+ segment: StringSegment instance
59
+ user: Optional user who made the change (for audit log)
60
+
61
+ Returns:
62
+ The created/updated StringTranslation
63
+
64
+ Example:
65
+ >>> from wagtail_localize.models import Translation, StringSegment
66
+ >>> translation = Translation.objects.get(id=123)
67
+ >>> segment = StringSegment.objects.get(id=456)
68
+ >>> mark_segment_do_not_translate(translation, segment)
69
+ """
70
+ validate_configuration()
71
+ marker = get_marker()
72
+
73
+ logger.info(
74
+ f"Marking segment: string_id={segment.string.id}, locale={translation.target_locale}, context='{segment.context}', marker='{marker}'"
75
+ )
76
+
77
+ # Check if there's an existing translation with real data (not the marker)
78
+ backup_separator = get_backup_separator()
79
+ try:
80
+ existing = StringTranslation.objects.get(
81
+ translation_of=segment.string,
82
+ locale=translation.target_locale,
83
+ context=segment.context,
84
+ )
85
+ # Encode backup in the marker itself
86
+ if existing.data != marker and not existing.data.startswith(
87
+ marker + backup_separator
88
+ ):
89
+ backup_data = existing.data
90
+ logger.info(f"Backing up existing translation: '{backup_data}'")
91
+ # Encode backup in the data field: __DO_NOT_TRANSLATE__|backup|original_value
92
+ marker_with_backup = f"{marker}{backup_separator}{backup_data}"
93
+ else:
94
+ marker_with_backup = marker
95
+ except StringTranslation.DoesNotExist:
96
+ marker_with_backup = marker
97
+
98
+ string_translation, created = StringTranslation.objects.update_or_create(
99
+ translation_of=segment.string,
100
+ locale=translation.target_locale,
101
+ context=segment.context,
102
+ defaults={
103
+ "data": marker_with_backup,
104
+ "translation_type": StringTranslation.TRANSLATION_TYPE_MANUAL,
105
+ "last_translated_by": user,
106
+ },
107
+ )
108
+
109
+ logger.info(
110
+ f"StringTranslation {'created' if created else 'updated'}: id={string_translation.id}"
111
+ )
112
+
113
+ return string_translation
114
+
115
+
116
+ def unmark_segment_do_not_translate(translation, segment):
117
+ """
118
+ Remove "do not translate" marking, allowing manual translation.
119
+
120
+ Args:
121
+ translation: Translation instance
122
+ segment: StringSegment instance
123
+
124
+ Returns:
125
+ int: Number of records deleted
126
+
127
+ Example:
128
+ >>> unmark_segment_do_not_translate(translation, segment)
129
+ """
130
+ validate_configuration()
131
+ marker = get_marker()
132
+ backup_separator = get_backup_separator()
133
+
134
+ # Log what we're trying to delete
135
+ logger.info(
136
+ f"Attempting to unmark segment: string_id={segment.string.id}, "
137
+ f"locale={translation.target_locale}, context='{segment.context}', marker='{marker}'"
138
+ )
139
+
140
+ # First, let's see ALL StringTranslation records for this string in this locale
141
+ all_for_string = StringTranslation.objects.filter(
142
+ translation_of=segment.string, locale=translation.target_locale
143
+ )
144
+ logger.info(
145
+ f"Total StringTranslation records for this string in locale: {all_for_string.count()}"
146
+ )
147
+ for st in all_for_string:
148
+ logger.info(f" - id={st.id}, context='{st.context}', data='{st.data}'")
149
+
150
+ # Find the marked translation (could be just marker or marker with encoded backup)
151
+ try:
152
+ # Try to find with exact marker first
153
+ try:
154
+ marked_translation = StringTranslation.objects.get(
155
+ translation_of=segment.string,
156
+ locale=translation.target_locale,
157
+ context=segment.context,
158
+ data=marker,
159
+ )
160
+ backup_data = None
161
+ except StringTranslation.DoesNotExist:
162
+ # Try to find with encoded backup
163
+ marked_translation = StringTranslation.objects.get(
164
+ translation_of=segment.string,
165
+ locale=translation.target_locale,
166
+ context=segment.context,
167
+ data__startswith=marker + backup_separator,
168
+ )
169
+ # Extract backup from encoded data: __DO_NOT_TRANSLATE__|backup|original_value
170
+ backup_data = (
171
+ marked_translation.data.split(backup_separator, 1)[1]
172
+ if backup_separator in marked_translation.data
173
+ else None
174
+ )
175
+ logger.info(f"Found backup in data field: '{backup_data}'")
176
+
177
+ if backup_data:
178
+ # Restore the backup
179
+ logger.info(f"Restoring backup translation: '{backup_data}'")
180
+ marked_translation.data = backup_data
181
+ marked_translation.save()
182
+ return 1 # Updated
183
+ else:
184
+ # No backup, just delete
185
+ marked_translation.delete()
186
+ logger.info("Deleted marked translation (no backup)")
187
+ return 1 # Deleted
188
+
189
+ except StringTranslation.DoesNotExist:
190
+ logger.info("No matching StringTranslation found to delete")
191
+ return 0
192
+
193
+
194
+ def is_do_not_translate(string_translation):
195
+ """
196
+ Check if a StringTranslation is marked as "do not translate".
197
+
198
+ Args:
199
+ string_translation: StringTranslation instance
200
+
201
+ Returns:
202
+ bool: True if marked as "do not translate"
203
+
204
+ Example:
205
+ >>> from wagtail_localize.models import StringTranslation
206
+ >>> st = StringTranslation.objects.get(id=789)
207
+ >>> if is_do_not_translate(st):
208
+ ... print("Marked as do not translate")
209
+ """
210
+ validate_configuration()
211
+ marker = get_marker()
212
+ backup_separator = get_backup_separator()
213
+ return string_translation.data == marker or string_translation.data.startswith(
214
+ marker + backup_separator
215
+ )
216
+
217
+
218
+ def get_source_fallback_stats(translation):
219
+ """
220
+ Get statistics on how many segments are marked as "do not translate".
221
+
222
+ Args:
223
+ translation: Translation instance
224
+
225
+ Returns:
226
+ dict with counts:
227
+ - total: Total translated segments
228
+ - do_not_translate: Segments marked as "do not translate"
229
+ - manually_translated: Segments with manual translations
230
+
231
+ Example:
232
+ >>> stats = get_source_fallback_stats(translation)
233
+ >>> print(f"{stats['do_not_translate']} segments marked as do not translate")
234
+ """
235
+ validate_configuration()
236
+ marker = get_marker()
237
+ backup_separator = get_backup_separator()
238
+
239
+ # Get all string IDs from segments belonging to this translation source
240
+ string_ids = StringSegment.objects.filter(source=translation.source).values_list(
241
+ "string_id", flat=True
242
+ )
243
+
244
+ # Query translations for these strings in the target locale
245
+ all_translations = StringTranslation.objects.filter(
246
+ locale=translation.target_locale, translation_of_id__in=string_ids
247
+ )
248
+
249
+ # Match both exact marker and encoded backup format
250
+ do_not_translate = all_translations.filter(
251
+ Q(data=marker) | Q(data__startswith=marker + backup_separator)
252
+ )
253
+ manually_translated = all_translations.exclude(
254
+ Q(data=marker) | Q(data__startswith=marker + backup_separator)
255
+ )
256
+
257
+ return {
258
+ "total": all_translations.count(),
259
+ "do_not_translate": do_not_translate.count(),
260
+ "manually_translated": manually_translated.count(),
261
+ }
262
+
263
+
264
+ def bulk_mark_segments(translation, segments, user=None):
265
+ """
266
+ Mark multiple segments as "do not translate" in a single operation.
267
+
268
+ Args:
269
+ translation: Translation instance
270
+ segments: Iterable of StringSegment instances
271
+ user: Optional user who made the change
272
+
273
+ Returns:
274
+ int: Number of segments marked
275
+
276
+ Example:
277
+ >>> segments = StringSegment.objects.filter(source=source)[:10]
278
+ >>> count = bulk_mark_segments(translation, segments)
279
+ >>> print(f"Marked {count} segments")
280
+ """
281
+ count = 0
282
+
283
+ for segment in segments:
284
+ mark_segment_do_not_translate(translation, segment, user=user)
285
+ count += 1
286
+
287
+ return count
288
+
289
+
290
+ def get_segments_do_not_translate(translation):
291
+ """
292
+ Get all segments that are marked as "do not translate" for a translation.
293
+
294
+ Args:
295
+ translation: Translation instance
296
+
297
+ Returns:
298
+ QuerySet of StringSegment instances
299
+
300
+ Example:
301
+ >>> segments = get_segments_do_not_translate(translation)
302
+ >>> for segment in segments:
303
+ ... print(f"Segment {segment.id}: {segment.string.data}")
304
+ """
305
+ validate_configuration()
306
+ marker = get_marker()
307
+ backup_separator = get_backup_separator()
308
+
309
+ # Get all string IDs from segments belonging to this translation source
310
+ string_ids = StringSegment.objects.filter(source=translation.source).values_list(
311
+ "string_id", flat=True
312
+ )
313
+
314
+ # Find translations marked as "do not translate" (match both exact marker and encoded backup format)
315
+ marked_string_translations = StringTranslation.objects.filter(
316
+ locale=translation.target_locale, translation_of_id__in=string_ids
317
+ ).filter(Q(data=marker) | Q(data__startswith=marker + backup_separator))
318
+
319
+ # Get the string IDs of marked translations
320
+ marked_string_ids = marked_string_translations.values_list(
321
+ "translation_of_id", flat=True
322
+ )
323
+
324
+ # Return the segments that have these marked strings
325
+ return StringSegment.objects.filter(
326
+ source=translation.source, string_id__in=marked_string_ids
327
+ )
328
+
329
+
330
+ def migrate_do_not_translate_markers(translation_source, target_locale):
331
+ """
332
+ Migrate "Do Not Translate" markers when source Strings change.
333
+
334
+ When the source page content changes, wagtail-localize creates new String
335
+ objects. This function finds StringTranslation records with the marker that
336
+ point to old Strings and updates them to point to the current Strings based
337
+ on matching context paths.
338
+
339
+ This ensures that "Do Not Translate" markings persist across sync operations.
340
+
341
+ Args:
342
+ translation_source: TranslationSource instance
343
+ target_locale: Target Locale instance
344
+
345
+ Returns:
346
+ int: Number of StringTranslation records migrated
347
+
348
+ Example:
349
+ >>> from wagtail_localize.models import TranslationSource, Locale
350
+ >>> source = TranslationSource.objects.get(id=123)
351
+ >>> locale = Locale.objects.get(language_code='fr')
352
+ >>> count = migrate_do_not_translate_markers(source, locale)
353
+ >>> print(f"Migrated {count} markers")
354
+ """
355
+ validate_configuration()
356
+ marker = get_marker()
357
+ backup_separator = get_backup_separator()
358
+
359
+ # Get all current StringSegments for this source
360
+ current_segments = StringSegment.objects.filter(
361
+ source=translation_source
362
+ ).select_related("string", "context")
363
+
364
+ migrated_count = 0
365
+
366
+ # For each current segment, check if there's an old marker to migrate
367
+ for segment in current_segments:
368
+ # Find orphanzed markers - these are StringTranslations with the marker
369
+ # for this context+locale that DON'T point to the current String.
370
+ orphaned_markers = (
371
+ StringTranslation.objects.filter(
372
+ locale=target_locale,
373
+ context=segment.context,
374
+ )
375
+ .filter(Q(data=marker) | Q(data__startswith=marker + backup_separator))
376
+ .exclude(translation_of=segment.string)
377
+ )
378
+
379
+ # If we found orphaned markers, migrate them to the current String
380
+ for orphaned_marker in orphaned_markers:
381
+ old_string_id = orphaned_marker.translation_of_id
382
+ logger.info(
383
+ f"Migrating marker: context='{segment.context}', "
384
+ f"old_string_id={old_string_id} -> new_string_id={segment.string.id}, "
385
+ f"locale={target_locale}"
386
+ )
387
+
388
+ # Check if there's already a StringTranslation for the new String
389
+ # (this can happen if wagtail-localize created one during sync)
390
+ existing_for_new_string = StringTranslation.objects.filter(
391
+ translation_of=segment.string,
392
+ locale=target_locale,
393
+ context=segment.context,
394
+ ).exclude(id=orphaned_marker.id)
395
+
396
+ if existing_for_new_string.exists():
397
+ # Delete the existing one to avoid unique constraint violation
398
+ logger.info(
399
+ f"Deleting existing StringTranslation for new String "
400
+ f"to avoid conflict: {existing_for_new_string.first().id}"
401
+ )
402
+ existing_for_new_string.delete()
403
+
404
+ # Update the StringTranslation to point to the new String
405
+ orphaned_marker.translation_of = segment.string
406
+ orphaned_marker.save()
407
+
408
+ migrated_count += 1
409
+
410
+ if migrated_count > 0:
411
+ logger.info(
412
+ f"Migrated {migrated_count} 'Do Not Translate' markers for source {translation_source.id} to locale {target_locale}"
413
+ )
414
+
415
+ return migrated_count