django-altified 0.1.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.
- django_altified-0.1.0/PKG-INFO +69 -0
- django_altified-0.1.0/README.md +56 -0
- django_altified-0.1.0/django_altified/__init__.py +3 -0
- django_altified-0.1.0/django_altified/apps.py +18 -0
- django_altified-0.1.0/django_altified/cache.py +17 -0
- django_altified-0.1.0/django_altified/client.py +55 -0
- django_altified-0.1.0/django_altified/descriptors.py +52 -0
- django_altified-0.1.0/django_altified/keys.py +7 -0
- django_altified-0.1.0/django_altified/management/__init__.py +1 -0
- django_altified-0.1.0/django_altified/management/commands/__init__.py +1 -0
- django_altified-0.1.0/django_altified/management/commands/init_altified.py +34 -0
- django_altified-0.1.0/django_altified/registry.py +55 -0
- django_altified-0.1.0/django_altified/signals.py +50 -0
- django_altified-0.1.0/django_altified/translate.py +87 -0
- django_altified-0.1.0/django_altified/urls.py +7 -0
- django_altified-0.1.0/django_altified/views.py +45 -0
- django_altified-0.1.0/django_altified.egg-info/PKG-INFO +69 -0
- django_altified-0.1.0/django_altified.egg-info/SOURCES.txt +21 -0
- django_altified-0.1.0/django_altified.egg-info/dependency_links.txt +1 -0
- django_altified-0.1.0/django_altified.egg-info/requires.txt +2 -0
- django_altified-0.1.0/django_altified.egg-info/top_level.txt +1 -0
- django_altified-0.1.0/pyproject.toml +25 -0
- django_altified-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-altified
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django SDK for syncing model content with Altified translations.
|
|
5
|
+
Author: Altified
|
|
6
|
+
Classifier: Framework :: Django
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: Django>=4.2
|
|
12
|
+
Requires-Dist: requests>=2.31
|
|
13
|
+
|
|
14
|
+
# django-altified
|
|
15
|
+
|
|
16
|
+
Django SDK for syncing configured model fields to Altified and reading translated values from Django cache.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install django-altified
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Configure
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
INSTALLED_APPS = [
|
|
28
|
+
...,
|
|
29
|
+
"django_altified",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
ALTIFIED_API_KEY = "altified_..."
|
|
33
|
+
ALTIFIED_API_URL = "https://your-altified-domain.com/api/v1"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The API key points to a Altified project, so the SDK does not need the project default or target languages in Django settings. Configure those languages in the Altified dashboard.
|
|
37
|
+
|
|
38
|
+
At runtime the SDK uses Django active language, or an explicit language passed to `translate_field`, as the requested locale. Altified validates that locale against the project connected to the API key and returns the source text when the requested locale is the project default language.
|
|
39
|
+
|
|
40
|
+
Create `altified.py` beside `manage.py`:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from shop.models import Product
|
|
44
|
+
|
|
45
|
+
TRANSLATIONS = {
|
|
46
|
+
Product: {
|
|
47
|
+
"fields": ["name", "description"],
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use the safe helper first:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from django_altified import translate_field, translate_fields
|
|
56
|
+
|
|
57
|
+
name = translate_field(product, "name")
|
|
58
|
+
copy = translate_fields(product, ["name", "description"])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Optionally expose the webhook endpoint:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from django.urls import include, path
|
|
65
|
+
|
|
66
|
+
urlpatterns = [
|
|
67
|
+
path("altified/", include("django_altified.urls")),
|
|
68
|
+
]
|
|
69
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# django-altified
|
|
2
|
+
|
|
3
|
+
Django SDK for syncing configured model fields to Altified and reading translated values from Django cache.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install django-altified
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configure
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
INSTALLED_APPS = [
|
|
15
|
+
...,
|
|
16
|
+
"django_altified",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
ALTIFIED_API_KEY = "altified_..."
|
|
20
|
+
ALTIFIED_API_URL = "https://your-altified-domain.com/api/v1"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The API key points to a Altified project, so the SDK does not need the project default or target languages in Django settings. Configure those languages in the Altified dashboard.
|
|
24
|
+
|
|
25
|
+
At runtime the SDK uses Django active language, or an explicit language passed to `translate_field`, as the requested locale. Altified validates that locale against the project connected to the API key and returns the source text when the requested locale is the project default language.
|
|
26
|
+
|
|
27
|
+
Create `altified.py` beside `manage.py`:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from shop.models import Product
|
|
31
|
+
|
|
32
|
+
TRANSLATIONS = {
|
|
33
|
+
Product: {
|
|
34
|
+
"fields": ["name", "description"],
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Use the safe helper first:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from django_altified import translate_field, translate_fields
|
|
43
|
+
|
|
44
|
+
name = translate_field(product, "name")
|
|
45
|
+
copy = translate_fields(product, ["name", "description"])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Optionally expose the webhook endpoint:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from django.urls import include, path
|
|
52
|
+
|
|
53
|
+
urlpatterns = [
|
|
54
|
+
path("altified/", include("django_altified.urls")),
|
|
55
|
+
]
|
|
56
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AltifiedConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "django_altified"
|
|
7
|
+
|
|
8
|
+
def ready(self):
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
|
|
11
|
+
from .descriptors import install_descriptors
|
|
12
|
+
from .registry import registry
|
|
13
|
+
from .signals import connect_signals
|
|
14
|
+
|
|
15
|
+
registry.load()
|
|
16
|
+
if getattr(settings, "ALTIFIED_AUTO_TRANSLATE", True):
|
|
17
|
+
install_descriptors(registry)
|
|
18
|
+
connect_signals(registry)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django.conf import settings
|
|
2
|
+
from django.core.cache import cache
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def make_key(source_key, language):
|
|
6
|
+
prefix = getattr(settings, "ALTIFIED_CACHE_PREFIX", "altified")
|
|
7
|
+
return f"{prefix}:{source_key}:{language}"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_translation(source_key, language):
|
|
11
|
+
return cache.get(make_key(source_key, language))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def set_translation(source_key, language, text, timeout=None):
|
|
15
|
+
if timeout is None:
|
|
16
|
+
timeout = getattr(settings, "ALTIFIED_CACHE_TIMEOUT", None)
|
|
17
|
+
cache.set(make_key(source_key, language), text, timeout=timeout)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AltifiedClient:
|
|
10
|
+
def __init__(self, api_key=None, api_url=None, timeout=None):
|
|
11
|
+
self.api_key = api_key or getattr(settings, "ALTIFIED_API_KEY", "")
|
|
12
|
+
self.api_url = (api_url or getattr(settings, "ALTIFIED_API_URL", "http://localhost:8000/api/v1")).rstrip("/")
|
|
13
|
+
self.timeout = timeout or getattr(settings, "ALTIFIED_TIMEOUT", 5)
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def headers(self):
|
|
17
|
+
return {
|
|
18
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
19
|
+
"Content-Type": "application/json",
|
|
20
|
+
"User-Agent": "django-altified/0.1.0",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def enabled(self):
|
|
24
|
+
return bool(self.api_key)
|
|
25
|
+
|
|
26
|
+
def post_sources(self, sources):
|
|
27
|
+
if not self.enabled() or not sources:
|
|
28
|
+
return None
|
|
29
|
+
return self._post("/sources/batch/", {"sources": sources})
|
|
30
|
+
|
|
31
|
+
def resolve(self, locale, sources):
|
|
32
|
+
if not self.enabled() or not sources:
|
|
33
|
+
return None
|
|
34
|
+
return self._post("/translations/resolve/", {"locale": locale, "sources": sources})
|
|
35
|
+
|
|
36
|
+
def heartbeat(self):
|
|
37
|
+
if not self.enabled():
|
|
38
|
+
return None
|
|
39
|
+
return self._post("/heartbeat/", {"kind": "secret"})
|
|
40
|
+
|
|
41
|
+
def _post(self, path, payload):
|
|
42
|
+
url = f"{self.api_url}{path}"
|
|
43
|
+
response = requests.post(url, json=payload, headers=self.headers, timeout=self.timeout)
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
return response.json()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def safe_call(method, *args, **kwargs):
|
|
49
|
+
try:
|
|
50
|
+
return method(*args, **kwargs)
|
|
51
|
+
except requests.RequestException as exc:
|
|
52
|
+
if getattr(settings, "ALTIFIED_RAISE_ERRORS", False):
|
|
53
|
+
raise
|
|
54
|
+
logger.warning("Altified request failed: %s", exc)
|
|
55
|
+
return None
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from .translate import translate_fields
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TranslatedFieldDescriptor:
|
|
5
|
+
cache_attr = "_altified_translated_fields"
|
|
6
|
+
|
|
7
|
+
def __init__(self, field_name, original_descriptor, fields_for_model):
|
|
8
|
+
self.field_name = field_name
|
|
9
|
+
self.original_descriptor = original_descriptor
|
|
10
|
+
self.fields_for_model = tuple(fields_for_model)
|
|
11
|
+
|
|
12
|
+
def __get__(self, instance, owner=None):
|
|
13
|
+
if instance is None:
|
|
14
|
+
return self
|
|
15
|
+
cached = getattr(instance, self.cache_attr, None)
|
|
16
|
+
if cached is None:
|
|
17
|
+
cached = self._translate_instance(instance)
|
|
18
|
+
setattr(instance, self.cache_attr, cached)
|
|
19
|
+
if self.field_name in cached:
|
|
20
|
+
return cached[self.field_name]
|
|
21
|
+
return self._get_original(instance)
|
|
22
|
+
|
|
23
|
+
def __set__(self, instance, value):
|
|
24
|
+
if hasattr(instance, self.cache_attr):
|
|
25
|
+
delattr(instance, self.cache_attr)
|
|
26
|
+
if hasattr(self.original_descriptor, "__set__"):
|
|
27
|
+
self.original_descriptor.__set__(instance, value)
|
|
28
|
+
return
|
|
29
|
+
instance.__dict__[self.field_name] = value
|
|
30
|
+
|
|
31
|
+
def _translate_instance(self, instance):
|
|
32
|
+
raw_getters = {}
|
|
33
|
+
for field_name in self.fields_for_model:
|
|
34
|
+
descriptor = getattr(instance.__class__, field_name, None)
|
|
35
|
+
if isinstance(descriptor, TranslatedFieldDescriptor):
|
|
36
|
+
raw_getters[field_name] = descriptor._get_original
|
|
37
|
+
return translate_fields(instance, self.fields_for_model, raw_getters=raw_getters)
|
|
38
|
+
|
|
39
|
+
def _get_original(self, instance):
|
|
40
|
+
if hasattr(self.original_descriptor, "__get__"):
|
|
41
|
+
return self.original_descriptor.__get__(instance, instance.__class__)
|
|
42
|
+
return instance.__dict__.get(self.field_name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def install_descriptors(registry):
|
|
46
|
+
for model in registry.models():
|
|
47
|
+
fields = registry.fields_for(model)
|
|
48
|
+
for field_name in fields:
|
|
49
|
+
current = getattr(model, field_name, None)
|
|
50
|
+
if isinstance(current, TranslatedFieldDescriptor):
|
|
51
|
+
continue
|
|
52
|
+
setattr(model, field_name, TranslatedFieldDescriptor(field_name, current, fields))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
TEMPLATE = '''"""
|
|
8
|
+
Altified translation configuration.
|
|
9
|
+
|
|
10
|
+
Add models and fields that should sync with Altified.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# from shop.models import Product
|
|
14
|
+
|
|
15
|
+
TRANSLATIONS = {
|
|
16
|
+
# Product: {
|
|
17
|
+
# "fields": ["name", "description"],
|
|
18
|
+
# },
|
|
19
|
+
}
|
|
20
|
+
'''
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Command(BaseCommand):
|
|
24
|
+
help = "Create a starter altified.py configuration file."
|
|
25
|
+
|
|
26
|
+
def add_arguments(self, parser):
|
|
27
|
+
parser.add_argument("--force", action="store_true", help="Overwrite an existing altified.py file.")
|
|
28
|
+
|
|
29
|
+
def handle(self, *args, **options):
|
|
30
|
+
path = Path(settings.BASE_DIR) / "altified.py"
|
|
31
|
+
if path.exists() and not options["force"]:
|
|
32
|
+
raise CommandError(f"{path} already exists. Use --force to overwrite it.")
|
|
33
|
+
path.write_text(TEMPLATE, encoding="utf-8")
|
|
34
|
+
self.stdout.write(self.style.SUCCESS(f"Created {path}"))
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TranslationRegistry:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self._models = {}
|
|
14
|
+
self.loaded = False
|
|
15
|
+
|
|
16
|
+
def load(self):
|
|
17
|
+
if self.loaded:
|
|
18
|
+
return
|
|
19
|
+
self.loaded = True
|
|
20
|
+
module_name = getattr(settings, "ALTIFIED_CONFIG_MODULE", "altified")
|
|
21
|
+
self._ensure_base_dir_importable()
|
|
22
|
+
try:
|
|
23
|
+
module = importlib.import_module(module_name)
|
|
24
|
+
except ModuleNotFoundError as exc:
|
|
25
|
+
if exc.name == module_name:
|
|
26
|
+
logger.info("Altified config module %s was not found.", module_name)
|
|
27
|
+
return
|
|
28
|
+
raise
|
|
29
|
+
|
|
30
|
+
translations = getattr(module, "TRANSLATIONS", {})
|
|
31
|
+
for model, config in translations.items():
|
|
32
|
+
fields = config.get("fields", []) if isinstance(config, dict) else config
|
|
33
|
+
self.register(model, fields)
|
|
34
|
+
|
|
35
|
+
def register(self, model, fields):
|
|
36
|
+
clean_fields = tuple(dict.fromkeys(fields or []))
|
|
37
|
+
if clean_fields:
|
|
38
|
+
self._models[model] = clean_fields
|
|
39
|
+
|
|
40
|
+
def fields_for(self, model):
|
|
41
|
+
return self._models.get(model, ())
|
|
42
|
+
|
|
43
|
+
def models(self):
|
|
44
|
+
return tuple(self._models.keys())
|
|
45
|
+
|
|
46
|
+
def _ensure_base_dir_importable(self):
|
|
47
|
+
base_dir = getattr(settings, "BASE_DIR", None)
|
|
48
|
+
if not base_dir:
|
|
49
|
+
return
|
|
50
|
+
base_dir = os.fspath(base_dir)
|
|
51
|
+
if base_dir not in sys.path:
|
|
52
|
+
sys.path.insert(0, base_dir)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
registry = TranslationRegistry()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.db.models.signals import post_save
|
|
5
|
+
|
|
6
|
+
from .client import AltifiedClient, safe_call
|
|
7
|
+
from .keys import source_key
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def connect_signals(registry):
|
|
11
|
+
if getattr(settings, "ALTIFIED_DISABLE_SIGNALS", False):
|
|
12
|
+
return
|
|
13
|
+
for model in registry.models():
|
|
14
|
+
post_save.connect(sync_instance, sender=model, weak=False, dispatch_uid=f"altified.sync.{model._meta.label_lower}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sync_instance(sender, instance, **kwargs):
|
|
18
|
+
if instance.pk is None:
|
|
19
|
+
return
|
|
20
|
+
from .registry import registry
|
|
21
|
+
|
|
22
|
+
fields = registry.fields_for(sender)
|
|
23
|
+
sources = []
|
|
24
|
+
for field_name in fields:
|
|
25
|
+
value = getattr(instance, field_name, None)
|
|
26
|
+
if value in (None, ""):
|
|
27
|
+
continue
|
|
28
|
+
sources.append(
|
|
29
|
+
{
|
|
30
|
+
"key": source_key(instance, field_name),
|
|
31
|
+
"text": str(value),
|
|
32
|
+
"context": {
|
|
33
|
+
"model": sender._meta.label,
|
|
34
|
+
"object_id": str(instance.pk),
|
|
35
|
+
"field": field_name,
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
if not sources:
|
|
40
|
+
return
|
|
41
|
+
if getattr(settings, "ALTIFIED_SYNC_ASYNC", True):
|
|
42
|
+
thread = threading.Thread(target=_send_sources, args=(sources,), daemon=True)
|
|
43
|
+
thread.start()
|
|
44
|
+
else:
|
|
45
|
+
_send_sources(sources)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _send_sources(sources):
|
|
49
|
+
client = AltifiedClient()
|
|
50
|
+
safe_call(client.post_sources, sources)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from django.utils.translation import get_language
|
|
2
|
+
|
|
3
|
+
from .cache import get_translation, set_translation
|
|
4
|
+
from .client import AltifiedClient, safe_call
|
|
5
|
+
from .keys import source_key
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _field_source(instance, field_name, original):
|
|
9
|
+
key = source_key(instance, field_name)
|
|
10
|
+
return key, {
|
|
11
|
+
"key": key,
|
|
12
|
+
"text": str(original),
|
|
13
|
+
"context": {
|
|
14
|
+
"model": instance._meta.label,
|
|
15
|
+
"object_id": str(instance.pk),
|
|
16
|
+
"field": field_name,
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _translation_text(response, key):
|
|
22
|
+
if not response:
|
|
23
|
+
return None
|
|
24
|
+
item = response.get("translations", {}).get(key)
|
|
25
|
+
if isinstance(item, dict):
|
|
26
|
+
return item.get("text")
|
|
27
|
+
if isinstance(item, str):
|
|
28
|
+
return item
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _original_value(instance, field_name, raw_getter=None):
|
|
33
|
+
if raw_getter:
|
|
34
|
+
return raw_getter(instance)
|
|
35
|
+
descriptor = getattr(instance.__class__, field_name, None)
|
|
36
|
+
getter = getattr(descriptor, "_get_original", None)
|
|
37
|
+
if getter:
|
|
38
|
+
return getter(instance)
|
|
39
|
+
return getattr(instance, field_name)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def translate_fields(instance, field_names, language=None, defaults=None, raw_getters=None):
|
|
43
|
+
locale = language or get_language()
|
|
44
|
+
defaults = defaults or {}
|
|
45
|
+
raw_getters = raw_getters or {}
|
|
46
|
+
results = {}
|
|
47
|
+
pending = []
|
|
48
|
+
pending_by_key = {}
|
|
49
|
+
|
|
50
|
+
for field_name in field_names:
|
|
51
|
+
original = _original_value(instance, field_name, raw_getters.get(field_name))
|
|
52
|
+
if original in (None, ""):
|
|
53
|
+
results[field_name] = original
|
|
54
|
+
continue
|
|
55
|
+
if not locale:
|
|
56
|
+
results[field_name] = original
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
key, source = _field_source(instance, field_name, original)
|
|
60
|
+
cached = get_translation(key, locale)
|
|
61
|
+
if cached is not None:
|
|
62
|
+
results[field_name] = cached
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
pending.append(source)
|
|
66
|
+
pending_by_key[key] = (field_name, original)
|
|
67
|
+
results[field_name] = original if field_name not in defaults else defaults[field_name]
|
|
68
|
+
|
|
69
|
+
if not pending:
|
|
70
|
+
return results
|
|
71
|
+
|
|
72
|
+
client = AltifiedClient()
|
|
73
|
+
response = safe_call(client.resolve, locale, pending)
|
|
74
|
+
for key, (field_name, original) in pending_by_key.items():
|
|
75
|
+
translated = _translation_text(response, key)
|
|
76
|
+
if translated:
|
|
77
|
+
set_translation(key, locale, translated)
|
|
78
|
+
results[field_name] = translated
|
|
79
|
+
elif field_name not in defaults:
|
|
80
|
+
results[field_name] = original
|
|
81
|
+
return results
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def translate_field(instance, field_name, language=None, default=None, raw_getter=None):
|
|
85
|
+
defaults = {field_name: default} if default is not None else None
|
|
86
|
+
raw_getters = {field_name: raw_getter} if raw_getter else None
|
|
87
|
+
return translate_fields(instance, [field_name], language=language, defaults=defaults, raw_getters=raw_getters)[field_name]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from django.conf import settings
|
|
6
|
+
from django.http import JsonResponse
|
|
7
|
+
from django.utils.decorators import method_decorator
|
|
8
|
+
from django.views import View
|
|
9
|
+
from django.views.decorators.csrf import csrf_exempt
|
|
10
|
+
|
|
11
|
+
from .cache import set_translation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def verify_signature(body, signature):
|
|
15
|
+
api_key = getattr(settings, "ALTIFIED_API_KEY", "")
|
|
16
|
+
if not api_key or not signature:
|
|
17
|
+
return False
|
|
18
|
+
expected = hmac.new(api_key.encode(), body, hashlib.sha256).hexdigest()
|
|
19
|
+
provided = signature.removeprefix("sha256=")
|
|
20
|
+
return hmac.compare_digest(expected, provided)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@method_decorator(csrf_exempt, name="dispatch")
|
|
24
|
+
class WebhookView(View):
|
|
25
|
+
def post(self, request):
|
|
26
|
+
if not verify_signature(request.body, request.headers.get("X-Altified-Signature", "")):
|
|
27
|
+
return JsonResponse({"detail": "Invalid Altified signature."}, status=403)
|
|
28
|
+
try:
|
|
29
|
+
payload = json.loads(request.body.decode("utf-8"))
|
|
30
|
+
except json.JSONDecodeError:
|
|
31
|
+
return JsonResponse({"detail": "Invalid JSON payload."}, status=400)
|
|
32
|
+
|
|
33
|
+
items = payload.get("translations")
|
|
34
|
+
if items is None:
|
|
35
|
+
items = [payload]
|
|
36
|
+
stored = 0
|
|
37
|
+
for item in items:
|
|
38
|
+
key = item.get("key")
|
|
39
|
+
language = item.get("language") or item.get("locale")
|
|
40
|
+
text = item.get("text")
|
|
41
|
+
if not key or not language or text is None:
|
|
42
|
+
continue
|
|
43
|
+
set_translation(key, language, text)
|
|
44
|
+
stored += 1
|
|
45
|
+
return JsonResponse({"stored": stored})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-altified
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django SDK for syncing model content with Altified translations.
|
|
5
|
+
Author: Altified
|
|
6
|
+
Classifier: Framework :: Django
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: Django>=4.2
|
|
12
|
+
Requires-Dist: requests>=2.31
|
|
13
|
+
|
|
14
|
+
# django-altified
|
|
15
|
+
|
|
16
|
+
Django SDK for syncing configured model fields to Altified and reading translated values from Django cache.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install django-altified
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Configure
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
INSTALLED_APPS = [
|
|
28
|
+
...,
|
|
29
|
+
"django_altified",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
ALTIFIED_API_KEY = "altified_..."
|
|
33
|
+
ALTIFIED_API_URL = "https://your-altified-domain.com/api/v1"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The API key points to a Altified project, so the SDK does not need the project default or target languages in Django settings. Configure those languages in the Altified dashboard.
|
|
37
|
+
|
|
38
|
+
At runtime the SDK uses Django active language, or an explicit language passed to `translate_field`, as the requested locale. Altified validates that locale against the project connected to the API key and returns the source text when the requested locale is the project default language.
|
|
39
|
+
|
|
40
|
+
Create `altified.py` beside `manage.py`:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from shop.models import Product
|
|
44
|
+
|
|
45
|
+
TRANSLATIONS = {
|
|
46
|
+
Product: {
|
|
47
|
+
"fields": ["name", "description"],
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use the safe helper first:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from django_altified import translate_field, translate_fields
|
|
56
|
+
|
|
57
|
+
name = translate_field(product, "name")
|
|
58
|
+
copy = translate_fields(product, ["name", "description"])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Optionally expose the webhook endpoint:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from django.urls import include, path
|
|
65
|
+
|
|
66
|
+
urlpatterns = [
|
|
67
|
+
path("altified/", include("django_altified.urls")),
|
|
68
|
+
]
|
|
69
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
django_altified/__init__.py
|
|
4
|
+
django_altified/apps.py
|
|
5
|
+
django_altified/cache.py
|
|
6
|
+
django_altified/client.py
|
|
7
|
+
django_altified/descriptors.py
|
|
8
|
+
django_altified/keys.py
|
|
9
|
+
django_altified/registry.py
|
|
10
|
+
django_altified/signals.py
|
|
11
|
+
django_altified/translate.py
|
|
12
|
+
django_altified/urls.py
|
|
13
|
+
django_altified/views.py
|
|
14
|
+
django_altified.egg-info/PKG-INFO
|
|
15
|
+
django_altified.egg-info/SOURCES.txt
|
|
16
|
+
django_altified.egg-info/dependency_links.txt
|
|
17
|
+
django_altified.egg-info/requires.txt
|
|
18
|
+
django_altified.egg-info/top_level.txt
|
|
19
|
+
django_altified/management/__init__.py
|
|
20
|
+
django_altified/management/commands/__init__.py
|
|
21
|
+
django_altified/management/commands/init_altified.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django_altified
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-altified"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Django SDK for syncing model content with Altified translations."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"Django>=4.2",
|
|
13
|
+
"requests>=2.31",
|
|
14
|
+
]
|
|
15
|
+
authors = [
|
|
16
|
+
{ name = "Altified" }
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Framework :: Django",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
include = ["django_altified*"]
|