stapel-translate 0.4.0__tar.gz

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.
Files changed (117) hide show
  1. stapel_translate-0.4.0/LICENSE +21 -0
  2. stapel_translate-0.4.0/PKG-INFO +57 -0
  3. stapel_translate-0.4.0/README.md +37 -0
  4. stapel_translate-0.4.0/__init__.py +41 -0
  5. stapel_translate-0.4.0/actions.py +23 -0
  6. stapel_translate-0.4.0/admin.py +488 -0
  7. stapel_translate-0.4.0/apps.py +23 -0
  8. stapel_translate-0.4.0/autofill.py +109 -0
  9. stapel_translate-0.4.0/collectors.py +33 -0
  10. stapel_translate-0.4.0/conf.py +202 -0
  11. stapel_translate-0.4.0/conftest.py +40 -0
  12. stapel_translate-0.4.0/dashboard_serializers.py +126 -0
  13. stapel_translate-0.4.0/dashboard_views.py +1646 -0
  14. stapel_translate-0.4.0/dto.py +232 -0
  15. stapel_translate-0.4.0/error_collector.py +144 -0
  16. stapel_translate-0.4.0/events.py +40 -0
  17. stapel_translate-0.4.0/figma_serializers.py +51 -0
  18. stapel_translate-0.4.0/figma_views.py +1031 -0
  19. stapel_translate-0.4.0/fixtures/builtin/ar.json +257 -0
  20. stapel_translate-0.4.0/fixtures/builtin/de.json +257 -0
  21. stapel_translate-0.4.0/fixtures/builtin/en.json +257 -0
  22. stapel_translate-0.4.0/fixtures/builtin/es.json +257 -0
  23. stapel_translate-0.4.0/fixtures/builtin/fr.json +257 -0
  24. stapel_translate-0.4.0/fixtures/builtin/he.json +257 -0
  25. stapel_translate-0.4.0/fixtures/builtin/hi.json +257 -0
  26. stapel_translate-0.4.0/fixtures/builtin/hr.json +257 -0
  27. stapel_translate-0.4.0/fixtures/builtin/hu.json +257 -0
  28. stapel_translate-0.4.0/fixtures/builtin/it.json +257 -0
  29. stapel_translate-0.4.0/fixtures/builtin/ja.json +257 -0
  30. stapel_translate-0.4.0/fixtures/builtin/ko.json +257 -0
  31. stapel_translate-0.4.0/fixtures/builtin/lb.json +257 -0
  32. stapel_translate-0.4.0/fixtures/builtin/pl.json +257 -0
  33. stapel_translate-0.4.0/fixtures/builtin/pt.json +257 -0
  34. stapel_translate-0.4.0/fixtures/builtin/ru.json +257 -0
  35. stapel_translate-0.4.0/fixtures/builtin/sr.json +257 -0
  36. stapel_translate-0.4.0/fixtures/builtin/tr.json +257 -0
  37. stapel_translate-0.4.0/fixtures/builtin/uk.json +257 -0
  38. stapel_translate-0.4.0/fixtures/builtin/zh.json +257 -0
  39. stapel_translate-0.4.0/functions.py +64 -0
  40. stapel_translate-0.4.0/gdpr.py +85 -0
  41. stapel_translate-0.4.0/management/__init__.py +0 -0
  42. stapel_translate-0.4.0/management/commands/__init__.py +0 -0
  43. stapel_translate-0.4.0/management/commands/autofill_translations.py +76 -0
  44. stapel_translate-0.4.0/management/commands/collect_translations.py +66 -0
  45. stapel_translate-0.4.0/management/commands/dump_translations.py +121 -0
  46. stapel_translate-0.4.0/management/commands/load_builtin_translations.py +118 -0
  47. stapel_translate-0.4.0/management/commands/translation_backlog.py +83 -0
  48. stapel_translate-0.4.0/migrations/0001_initial.py +34 -0
  49. stapel_translate-0.4.0/migrations/0002_add_revision_to_translation.py +23 -0
  50. stapel_translate-0.4.0/migrations/0003_replace_verified_with_per_language.py +70 -0
  51. stapel_translate-0.4.0/migrations/0004_add_languages.py +133 -0
  52. stapel_translate-0.4.0/migrations/0005_add_llm_translated.py +16 -0
  53. stapel_translate-0.4.0/migrations/0006_authorizedtranslator.py +25 -0
  54. stapel_translate-0.4.0/migrations/0007_translationentry_comment.py +16 -0
  55. stapel_translate-0.4.0/migrations/0008_alter_translationentry_source.py +16 -0
  56. stapel_translate-0.4.0/migrations/0009_translationhistory.py +34 -0
  57. stapel_translate-0.4.0/migrations/0010_figma_api_key.py +28 -0
  58. stapel_translate-0.4.0/migrations/0011_remove_serbo_croatian.py +21 -0
  59. stapel_translate-0.4.0/migrations/0012_add_serbian_croatian.py +33 -0
  60. stapel_translate-0.4.0/migrations/0013_add_translator_comment_and_refs.py +28 -0
  61. stapel_translate-0.4.0/migrations/0014_translationentry_order.py +16 -0
  62. stapel_translate-0.4.0/migrations/0015_authorizedtranslator_allowed_languages_and_more.py +23 -0
  63. stapel_translate-0.4.0/migrations/0016_translationentry_screenshot.py +16 -0
  64. stapel_translate-0.4.0/migrations/0017_translationvalue.py +30 -0
  65. stapel_translate-0.4.0/migrations/0018_copy_language_columns_to_values.py +71 -0
  66. stapel_translate-0.4.0/migrations/0019_remove_language_columns.py +53 -0
  67. stapel_translate-0.4.0/migrations/0020_figmaapikey_hashed_keys.py +33 -0
  68. stapel_translate-0.4.0/migrations/__init__.py +0 -0
  69. stapel_translate-0.4.0/mixins.py +25 -0
  70. stapel_translate-0.4.0/models.py +307 -0
  71. stapel_translate-0.4.0/notification_collector.py +109 -0
  72. stapel_translate-0.4.0/permissions.py +70 -0
  73. stapel_translate-0.4.0/providers.py +252 -0
  74. stapel_translate-0.4.0/py.typed +0 -0
  75. stapel_translate-0.4.0/pyproject.toml +42 -0
  76. stapel_translate-0.4.0/schemas/consumes/user.deleted.json +13 -0
  77. stapel_translate-0.4.0/schemas/emits/translations.changed.json +12 -0
  78. stapel_translate-0.4.0/schemas/functions/translate.resolve.json +19 -0
  79. stapel_translate-0.4.0/serializers.py +31 -0
  80. stapel_translate-0.4.0/setup.cfg +4 -0
  81. stapel_translate-0.4.0/stapel_translate.egg-info/PKG-INFO +57 -0
  82. stapel_translate-0.4.0/stapel_translate.egg-info/SOURCES.txt +222 -0
  83. stapel_translate-0.4.0/stapel_translate.egg-info/dependency_links.txt +1 -0
  84. stapel_translate-0.4.0/stapel_translate.egg-info/requires.txt +7 -0
  85. stapel_translate-0.4.0/stapel_translate.egg-info/top_level.txt +1 -0
  86. stapel_translate-0.4.0/tasks.py +41 -0
  87. stapel_translate-0.4.0/templates/admin/translations/change_list_with_lang.html +58 -0
  88. stapel_translate-0.4.0/templates/dashboard/base.html +742 -0
  89. stapel_translate-0.4.0/templates/dashboard/index.html +390 -0
  90. stapel_translate-0.4.0/templates/dashboard/language.html +451 -0
  91. stapel_translate-0.4.0/templates/dashboard/login.html +355 -0
  92. stapel_translate-0.4.0/templates/dashboard/translation.html +952 -0
  93. stapel_translate-0.4.0/tests/__init__.py +0 -0
  94. stapel_translate-0.4.0/tests/conftest.py +11 -0
  95. stapel_translate-0.4.0/tests/test_autofill.py +181 -0
  96. stapel_translate-0.4.0/tests/test_backlog_command.py +86 -0
  97. stapel_translate-0.4.0/tests/test_builtin_fixtures.py +83 -0
  98. stapel_translate-0.4.0/tests/test_builtin_loader.py +138 -0
  99. stapel_translate-0.4.0/tests/test_collect_command.py +145 -0
  100. stapel_translate-0.4.0/tests/test_collectors.py +171 -0
  101. stapel_translate-0.4.0/tests/test_conf.py +93 -0
  102. stapel_translate-0.4.0/tests/test_dashboard_api.py +221 -0
  103. stapel_translate-0.4.0/tests/test_dump_translations.py +141 -0
  104. stapel_translate-0.4.0/tests/test_events.py +85 -0
  105. stapel_translate-0.4.0/tests/test_export_import.py +159 -0
  106. stapel_translate-0.4.0/tests/test_figma_auth.py +145 -0
  107. stapel_translate-0.4.0/tests/test_llm_help.py +274 -0
  108. stapel_translate-0.4.0/tests/test_migrations_sanity.py +85 -0
  109. stapel_translate-0.4.0/tests/test_models.py +162 -0
  110. stapel_translate-0.4.0/tests/test_providers.py +201 -0
  111. stapel_translate-0.4.0/tests/test_public_api.py +54 -0
  112. stapel_translate-0.4.0/tests/test_resolve_function.py +149 -0
  113. stapel_translate-0.4.0/tests/test_serializer_seams.py +44 -0
  114. stapel_translate-0.4.0/tests/test_views.py +176 -0
  115. stapel_translate-0.4.0/urls.py +85 -0
  116. stapel_translate-0.4.0/utils.py +2 -0
  117. stapel_translate-0.4.0/views.py +193 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 usestapel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: stapel-translate
3
+ Version: 0.4.0
4
+ Summary: AI-powered content translation Django app for the Stapel framework
5
+ License: MIT
6
+ Keywords: django,stapel,translation,i18n,ai
7
+ Classifier: Framework :: Django
8
+ Classifier: Framework :: Django :: 5.2
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: stapel-core<0.4,>=0.3.0
15
+ Provides-Extra: kafka
16
+ Requires-Dist: confluent-kafka>=2.3; extra == "kafka"
17
+ Provides-Extra: all
18
+ Requires-Dist: stapel-translate[kafka]; extra == "all"
19
+ Dynamic: license-file
20
+
21
+ # stapel-translate
22
+
23
+ [![CI](https://github.com/usestapel/stapel-translate/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-translate/actions/workflows/ci.yml)
24
+ [![codecov](https://codecov.io/gh/usestapel/stapel-translate/graph/badge.svg)](https://codecov.io/gh/usestapel/stapel-translate)
25
+
26
+ > AI-powered content translation — multilingual support, LLM routing (Anthropic/OpenAI)
27
+
28
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install stapel-translate
34
+ ```
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ # settings.py
40
+ INSTALLED_APPS = [
41
+ ...
42
+ 'stapel_translate',
43
+ ]
44
+ ```
45
+
46
+ ## Bus events
47
+
48
+ ### Emits
49
+ | `translations.changed` | [schema](schemas/emits/translations.changed.json) | One or more translation keys were updated for a language. |
50
+
51
+ ### Consumes
52
+ | `user.deleted` | [schema](schemas/consumes/user.deleted.json) |
53
+ | `user.deletion_initiated` | [schema](schemas/consumes/user.deletion_initiated.json) |
54
+
55
+ ## License
56
+
57
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,37 @@
1
+ # stapel-translate
2
+
3
+ [![CI](https://github.com/usestapel/stapel-translate/actions/workflows/ci.yml/badge.svg)](https://github.com/usestapel/stapel-translate/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/usestapel/stapel-translate/graph/badge.svg)](https://codecov.io/gh/usestapel/stapel-translate)
5
+
6
+ > AI-powered content translation — multilingual support, LLM routing (Anthropic/OpenAI)
7
+
8
+ Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install stapel-translate
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```python
19
+ # settings.py
20
+ INSTALLED_APPS = [
21
+ ...
22
+ 'stapel_translate',
23
+ ]
24
+ ```
25
+
26
+ ## Bus events
27
+
28
+ ### Emits
29
+ | `translations.changed` | [schema](schemas/emits/translations.changed.json) | One or more translation keys were updated for a language. |
30
+
31
+ ### Consumes
32
+ | `user.deleted` | [schema](schemas/consumes/user.deleted.json) |
33
+ | `user.deletion_initiated` | [schema](schemas/consumes/user.deletion_initiated.json) |
34
+
35
+ ## License
36
+
37
+ MIT — see [LICENSE](LICENSE)
@@ -0,0 +1,41 @@
1
+ """Stapel Translate — translation management Django app for the Stapel framework.
2
+
3
+ The public API is exported lazily (PEP 562), so importing the package never
4
+ pulls in Django-dependent modules until an attribute is actually accessed.
5
+ """
6
+
7
+ from importlib import import_module
8
+
9
+ # name -> (relative module, attribute)
10
+ _LAZY_EXPORTS = {
11
+ "translate_settings": (".conf", "translate_settings"),
12
+ "SUPPORTED_LANGUAGES": (".conf", "SUPPORTED_LANGUAGES"),
13
+ "LANGUAGE_NAMES": (".conf", "LANGUAGE_NAMES"),
14
+ "get_supported_languages": (".conf", "get_supported_languages"),
15
+ "get_language_names": (".conf", "get_language_names"),
16
+ "get_default_language": (".conf", "get_default_language"),
17
+ "emit_translations_changed": (".events", "emit_translations_changed"),
18
+ "TRANSLATIONS_CHANGED": (".events", "TRANSLATIONS_CHANGED"),
19
+ "get_cache_key": (".utils", "get_cache_key"),
20
+ "register_collector": (".collectors", "register_collector"),
21
+ "autofill_missing": (".autofill", "autofill_missing"),
22
+ "get_llm_provider": (".providers", "get_llm_provider"),
23
+ }
24
+
25
+ __all__ = sorted(_LAZY_EXPORTS)
26
+
27
+
28
+ def __getattr__(name):
29
+ try:
30
+ module_path, attr = _LAZY_EXPORTS[name]
31
+ except KeyError:
32
+ raise AttributeError(
33
+ f"module {__name__!r} has no attribute {name!r}"
34
+ ) from None
35
+ value = getattr(import_module(module_path, __name__), attr)
36
+ globals()[name] = value # cache so __getattr__ runs once per name
37
+ return value
38
+
39
+
40
+ def __dir__():
41
+ return sorted(set(globals()) | set(_LAZY_EXPORTS))
@@ -0,0 +1,23 @@
1
+ """Action subscriptions of the translate module.
2
+
3
+ Handlers must be idempotent: delivery is at-least-once (outbox retries,
4
+ broker redelivery).
5
+ """
6
+ import logging
7
+
8
+ from stapel_core.comm import on_action
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @on_action("user.deleted")
14
+ def handle_user_deleted(event):
15
+ """Erase this module's PII when an account deletion is executed."""
16
+ from .gdpr import TranslateGDPRProvider
17
+
18
+ user_id = event.payload.get("user_id")
19
+ if not user_id:
20
+ logger.error("user.deleted event without user_id: %s", event.event_id)
21
+ return
22
+ TranslateGDPRProvider().delete(user_id)
23
+ logger.info("translate data erased for deleted user %s", user_id)
@@ -0,0 +1,488 @@
1
+ import logging
2
+ import threading
3
+
4
+ import requests as http_requests
5
+ from django import forms
6
+ from django.conf import settings
7
+ from django.contrib import admin, messages
8
+ from django.core.cache import cache
9
+ from django.http import JsonResponse
10
+ from django.shortcuts import redirect
11
+ from django.urls import path
12
+
13
+ from .conf import LANGUAGE_NAMES
14
+ from .models import (
15
+ SUPPORTED_LANGUAGES,
16
+ AuthorizedTranslator,
17
+ FigmaApiKey,
18
+ TranslationEntry,
19
+ TranslationHistory,
20
+ TranslationValue,
21
+ )
22
+ from .providers import agent_payload, get_agent_url
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ LLM_TASK_CACHE_KEY = "llm_translation_task_status"
27
+
28
+
29
+ def _translate_entries_background(entry_ids: list):
30
+ """Background task to translate entries with LLM."""
31
+ api_key = getattr(settings, "SERVICE_API_KEY", None)
32
+ headers = (
33
+ {
34
+ "Content-Type": "application/json",
35
+ "X-API-KEY": api_key,
36
+ }
37
+ if api_key
38
+ else {"Content-Type": "application/json"}
39
+ )
40
+
41
+ total = len(entry_ids)
42
+ translated_count = 0
43
+ error_count = 0
44
+ errors = []
45
+
46
+ # Update status: started
47
+ cache.set(
48
+ LLM_TASK_CACHE_KEY,
49
+ {
50
+ "status": "running",
51
+ "total": total,
52
+ "completed": 0,
53
+ "errors": 0,
54
+ "error_messages": [],
55
+ },
56
+ timeout=3600,
57
+ )
58
+
59
+ for entry_id in entry_ids:
60
+ try:
61
+ entry = TranslationEntry.objects.get(id=entry_id)
62
+ except TranslationEntry.DoesNotExist:
63
+ error_count += 1
64
+ continue
65
+
66
+ # Build context from existing translations
67
+ existing_translations = []
68
+ missing_langs = []
69
+
70
+ for lang in SUPPORTED_LANGUAGES:
71
+ value = entry.get_value(lang)
72
+ is_verified = entry.get_verified(lang)
73
+ if value:
74
+ status_label = "VERIFIED" if is_verified else "unverified"
75
+ existing_translations.append(f'- {lang} [{status_label}]: "{value}"')
76
+ else:
77
+ missing_langs.append(lang)
78
+
79
+ # Only translate languages that have no value at all
80
+ if not missing_langs:
81
+ translated_count += 1
82
+ continue
83
+
84
+ lang_list = "\n".join([f"- {code}" for code in missing_langs])
85
+ json_example = ", ".join([f'"{code}": "..."' for code in missing_langs])
86
+
87
+ existing_section = ""
88
+ if existing_translations:
89
+ existing_section = f"""
90
+ Existing translations (use as reference for style and tone):
91
+ {chr(10).join(existing_translations)}
92
+ """
93
+
94
+ prompt = f"""You are a professional translator for a marketplace application.
95
+ Translate this UI/feature label into the specified languages.
96
+
97
+ Key to translate: "{entry.key}"
98
+ {f"Comment/context: {entry.comment}" if entry.comment else ""}
99
+ {existing_section}
100
+ Languages to translate (provide translations ONLY for these missing languages):
101
+ {lang_list}
102
+
103
+ Rules:
104
+ - Keep translations concise and natural for UI labels
105
+ - Preserve any technical terms or brand names
106
+ - If the key looks like a sentence, translate as a sentence
107
+ - If it's a single word or short phrase, keep it as such
108
+ - For ambiguous terms, prefer marketplace/e-commerce context
109
+ - Match the style and tone of existing translations
110
+
111
+ Return ONLY valid JSON like: {{{json_example}}}"""
112
+
113
+ try:
114
+ response = http_requests.post(
115
+ f"{get_agent_url()}/api/llm/complete",
116
+ json=agent_payload(prompt),
117
+ headers=headers,
118
+ timeout=60,
119
+ )
120
+ response.raise_for_status()
121
+ data = response.json()
122
+
123
+ if data.get("status") == "ok":
124
+ result = data.get("result", {})
125
+ # Handle string result (try to parse as JSON)
126
+ if isinstance(result, str):
127
+ import json
128
+
129
+ try:
130
+ result = json.loads(result)
131
+ except json.JSONDecodeError:
132
+ error_count += 1
133
+ errors.append(f"{entry.key}: LLM returned invalid JSON")
134
+ continue
135
+
136
+ # Update translations: only fill empty, non-verified fields
137
+ for lang in SUPPORTED_LANGUAGES:
138
+ if lang in result and result[lang]:
139
+ # Skip if already has a value
140
+ if entry.get_value(lang):
141
+ continue
142
+ # Skip if verified
143
+ if entry.get_verified(lang):
144
+ continue
145
+ entry.set_value(lang, result[lang])
146
+ entry.llm_translated = True
147
+ entry.save()
148
+ translated_count += 1
149
+ else:
150
+ error_count += 1
151
+ errors.append(f"{entry.key}: LLM returned non-ok status")
152
+
153
+ except Exception as e:
154
+ error_count += 1
155
+ errors.append(f"{entry.key}: {str(e)}")
156
+ logger.warning(f"Error translating '{entry.key}': {e}")
157
+
158
+ # Update progress in cache
159
+ cache.set(
160
+ LLM_TASK_CACHE_KEY,
161
+ {
162
+ "status": "running",
163
+ "total": total,
164
+ "completed": translated_count + error_count,
165
+ "translated": translated_count,
166
+ "errors": error_count,
167
+ "error_messages": errors[-10:], # Keep last 10 errors
168
+ },
169
+ timeout=3600,
170
+ )
171
+
172
+ # Final status
173
+ cache.set(
174
+ LLM_TASK_CACHE_KEY,
175
+ {
176
+ "status": "completed",
177
+ "total": total,
178
+ "completed": total,
179
+ "translated": translated_count,
180
+ "errors": error_count,
181
+ "error_messages": errors[-10:],
182
+ },
183
+ timeout=3600,
184
+ )
185
+
186
+ logger.info(
187
+ f"LLM translation completed: {translated_count} translated, {error_count} errors"
188
+ )
189
+
190
+
191
+ @admin.action(description="Translate selected with LLM (background)")
192
+ def translate_with_llm(modeladmin, request, queryset):
193
+ """Start background LLM translation for selected entries."""
194
+ # Check if task is already running
195
+ current_status = cache.get(LLM_TASK_CACHE_KEY)
196
+ if current_status and current_status.get("status") == "running":
197
+ modeladmin.message_user(
198
+ request,
199
+ f"Translation already in progress: {current_status.get('completed', 0)}/{current_status.get('total', 0)}. Wait for it to complete.",
200
+ messages.WARNING,
201
+ )
202
+ return
203
+
204
+ entry_ids = list(queryset.values_list("id", flat=True))
205
+ if not entry_ids:
206
+ modeladmin.message_user(request, "No entries selected", messages.WARNING)
207
+ return
208
+
209
+ # Start background thread
210
+ thread = threading.Thread(
211
+ target=_translate_entries_background, args=(entry_ids,), daemon=True
212
+ )
213
+ thread.start()
214
+
215
+ modeladmin.message_user(
216
+ request,
217
+ f"Started LLM translation for {len(entry_ids)} entries in background. Check status via 'LLM Status' button.",
218
+ messages.SUCCESS,
219
+ )
220
+
221
+
222
+ def _language_choices():
223
+ return [
224
+ (code, f"{code} — {LANGUAGE_NAMES.get(code, code)}")
225
+ for code in SUPPORTED_LANGUAGES
226
+ ]
227
+
228
+
229
+ class AuthorizedTranslatorForm(forms.ModelForm):
230
+ allowed_languages = forms.MultipleChoiceField(
231
+ choices=_language_choices,
232
+ widget=forms.CheckboxSelectMultiple,
233
+ required=False,
234
+ help_text="Languages this translator can edit. Leave all unchecked = access to all languages.",
235
+ )
236
+
237
+ class Meta:
238
+ model = AuthorizedTranslator
239
+ fields = "__all__"
240
+
241
+ def __init__(self, *args, **kwargs):
242
+ super().__init__(*args, **kwargs)
243
+ if self.instance and self.instance.pk and self.instance.allowed_languages:
244
+ self.initial["allowed_languages"] = self.instance.allowed_languages
245
+
246
+ def clean_allowed_languages(self):
247
+ return self.cleaned_data.get("allowed_languages", [])
248
+
249
+
250
+ @admin.register(AuthorizedTranslator)
251
+ class AuthorizedTranslatorAdmin(admin.ModelAdmin):
252
+ form = AuthorizedTranslatorForm
253
+ list_display = [
254
+ "email",
255
+ "name",
256
+ "is_active",
257
+ "allowed_languages_display",
258
+ "created_at",
259
+ ]
260
+ list_filter = ["is_active"]
261
+ search_fields = ["email", "name", "notes"]
262
+ readonly_fields = ["created_at"]
263
+
264
+ @admin.display(description="Languages")
265
+ def allowed_languages_display(self, obj):
266
+ if obj.allowed_languages:
267
+ return ", ".join(obj.allowed_languages)
268
+ return "All"
269
+
270
+
271
+ @admin.register(FigmaApiKey)
272
+ class FigmaApiKeyAdmin(admin.ModelAdmin):
273
+ list_display = ["name", "prefix", "is_active", "created_at", "last_used_at"]
274
+ list_filter = ["is_active"]
275
+ search_fields = ["name", "prefix"]
276
+ readonly_fields = ["prefix", "created_at", "last_used_at"]
277
+ fields = ["name", "is_active", "prefix", "created_at", "last_used_at"]
278
+
279
+ def save_model(self, request, obj, form, change):
280
+ super().save_model(request, obj, form, change)
281
+ plaintext = getattr(obj, "plaintext_key", None)
282
+ if plaintext:
283
+ messages.warning(
284
+ request,
285
+ f"Figma API key for '{obj.name}': {plaintext} — "
286
+ "copy it now, it will NOT be shown again.",
287
+ )
288
+
289
+
290
+ @admin.register(TranslationHistory)
291
+ class TranslationHistoryAdmin(admin.ModelAdmin):
292
+ list_display = [
293
+ "created_at",
294
+ "entry_key",
295
+ "language",
296
+ "change_type",
297
+ "author_email",
298
+ "author_name",
299
+ "source",
300
+ "old_value_short",
301
+ "new_value_short",
302
+ ]
303
+ list_filter = ["source", "change_type", "language", "author_email"]
304
+ search_fields = [
305
+ "entry__key",
306
+ "author_email",
307
+ "author_name",
308
+ "old_value",
309
+ "new_value",
310
+ ]
311
+ readonly_fields = [
312
+ "entry",
313
+ "language",
314
+ "change_type",
315
+ "old_value",
316
+ "new_value",
317
+ "author_email",
318
+ "author_name",
319
+ "source",
320
+ "created_at",
321
+ ]
322
+ date_hierarchy = "created_at"
323
+ ordering = ["-created_at"]
324
+
325
+ @admin.display(
326
+ description="Key",
327
+ ordering="entry__key",
328
+ )
329
+ def entry_key(self, obj):
330
+ return obj.entry.key
331
+
332
+ @admin.display(description="Old Value")
333
+ def old_value_short(self, obj):
334
+ if obj.old_value:
335
+ return (
336
+ obj.old_value[:50] + "..." if len(obj.old_value) > 50 else obj.old_value
337
+ )
338
+ return "-"
339
+
340
+ @admin.display(description="New Value")
341
+ def new_value_short(self, obj):
342
+ if obj.new_value:
343
+ return (
344
+ obj.new_value[:50] + "..." if len(obj.new_value) > 50 else obj.new_value
345
+ )
346
+ return "-"
347
+
348
+ def has_add_permission(self, request):
349
+ return False
350
+
351
+ def has_change_permission(self, request, obj=None):
352
+ return False
353
+
354
+ def has_delete_permission(self, request, obj=None):
355
+ return request.user.is_superuser
356
+
357
+
358
+ class TranslationValueInline(admin.TabularInline):
359
+ model = TranslationValue
360
+ extra = 0
361
+ fields = ["language", "value", "verified"]
362
+
363
+
364
+ @admin.register(TranslationEntry)
365
+ class TranslationEntryAdmin(admin.ModelAdmin):
366
+ list_display = [
367
+ "key",
368
+ "comment",
369
+ "en_value",
370
+ "lb_value",
371
+ "fr_value",
372
+ "de_value",
373
+ "en_verified",
374
+ "llm_translated",
375
+ "source",
376
+ "revision",
377
+ "deleted",
378
+ ]
379
+ list_filter = ["source", "deleted", "llm_translated"]
380
+ search_fields = ["key", "comment", "values__value"]
381
+ change_list_template = "admin/translations/change_list_with_lang.html"
382
+ actions = [translate_with_llm]
383
+ inlines = [TranslationValueInline]
384
+
385
+ def get_queryset(self, request):
386
+ return super().get_queryset(request).prefetch_related("values")
387
+
388
+ @admin.display(description="English")
389
+ def en_value(self, obj):
390
+ return obj.get_value("en")
391
+
392
+ @admin.display(description="Luxembourgish")
393
+ def lb_value(self, obj):
394
+ return obj.get_value("lb")
395
+
396
+ @admin.display(description="French")
397
+ def fr_value(self, obj):
398
+ return obj.get_value("fr")
399
+
400
+ @admin.display(description="German")
401
+ def de_value(self, obj):
402
+ return obj.get_value("de")
403
+
404
+ @admin.display(description="English verified", boolean=True)
405
+ def en_verified(self, obj):
406
+ return obj.get_verified("en")
407
+
408
+ def changelist_view(self, request, extra_context=None):
409
+ lang = cache.get("admin_translation_lang", "en")
410
+ extra_context = extra_context or {}
411
+ extra_context["current_language"] = lang
412
+ extra_context["languages"] = [
413
+ (code, LANGUAGE_NAMES.get(code, code)) for code in SUPPORTED_LANGUAGES
414
+ ]
415
+ return super().changelist_view(request, extra_context=extra_context)
416
+
417
+ def get_urls(self):
418
+ urls = super().get_urls()
419
+ custom_urls = [
420
+ path(
421
+ "set_translation_lang/",
422
+ self.admin_site.admin_view(self.set_translation_lang),
423
+ name="set_translation_lang",
424
+ ),
425
+ path(
426
+ "llm_status/",
427
+ self.admin_site.admin_view(self.llm_status_view),
428
+ name="llm_translation_status",
429
+ ),
430
+ path(
431
+ "collect_keys/",
432
+ self.admin_site.admin_view(self.collect_keys_view),
433
+ name="collect_translation_keys",
434
+ ),
435
+ ]
436
+ return custom_urls + urls
437
+
438
+ def collect_keys_view(self, request):
439
+ """Collect error and notification keys from other services."""
440
+ if request.method != "POST":
441
+ return redirect("..")
442
+ if not request.user.is_superuser and not request.user.is_staff:
443
+ messages.error(request, "Permission denied.")
444
+ return redirect("..")
445
+
446
+ from .error_collector import collect_error_keys_from_services
447
+ from .notification_collector import collect_notification_keys
448
+
449
+ try:
450
+ err = collect_error_keys_from_services()
451
+ failed = ", ".join(s["name"] for s in err["services_failed"]) or "none"
452
+ messages.success(
453
+ request,
454
+ f"Errors: {err['total_keys']} keys "
455
+ f"({err['created']} created, {err['updated']} updated). "
456
+ f"Failed: {failed}.",
457
+ )
458
+ except Exception as e:
459
+ logger.exception("Error keys collection failed")
460
+ messages.error(request, f"Error keys collection failed: {e}")
461
+
462
+ try:
463
+ notif = collect_notification_keys()
464
+ messages.success(
465
+ request,
466
+ f"Notifications: {notif['total_keys']} keys "
467
+ f"({notif['created']} created, {notif['updated']} updated).",
468
+ )
469
+ except Exception as e:
470
+ logger.exception("Notification keys collection failed")
471
+ messages.error(request, f"Notification keys collection failed: {e}")
472
+
473
+ return redirect("..")
474
+
475
+ def llm_status_view(self, request):
476
+ """Return current LLM translation task status."""
477
+ status = cache.get(LLM_TASK_CACHE_KEY) or {
478
+ "status": "idle",
479
+ "message": "No translation task running",
480
+ }
481
+ return JsonResponse(status)
482
+
483
+ def set_translation_lang(self, request):
484
+ if request.method == "POST":
485
+ lang = request.POST.get("language")
486
+ if lang:
487
+ cache.set("admin_translation_lang", lang, timeout=None)
488
+ return redirect(request.headers.get("referer", "/admin/"))
@@ -0,0 +1,23 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class TranslateConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "stapel_translate"
7
+ label = 'translate'
8
+ verbose_name = "Stapel Translate"
9
+
10
+ def ready(self):
11
+ from stapel_core.gdpr import gdpr_registry
12
+ from .gdpr import TranslateGDPRProvider
13
+ gdpr_registry.register(TranslateGDPRProvider())
14
+
15
+ # Action subscriptions (in-process in a monolith, bus consumer in
16
+ # microservices — same code, transport chosen by STAPEL_COMM).
17
+ from . import actions # noqa: F401
18
+
19
+ # Comm task handlers (translate.autofill).
20
+ from . import tasks # noqa: F401
21
+
22
+ # Comm Function providers (translate.resolve).
23
+ from . import functions # noqa: F401