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.
@@ -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,3 @@
1
+ from .translate import translate_field, translate_fields
2
+
3
+ __all__ = ["translate_field", "translate_fields"]
@@ -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,7 @@
1
+ def model_label(model_or_instance):
2
+ meta = model_or_instance._meta
3
+ return f"{meta.app_label}.{meta.object_name}"
4
+
5
+
6
+ def source_key(instance, field_name):
7
+ return f"db:{model_label(instance)}:{instance.pk}:{field_name}"
@@ -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,7 @@
1
+ from django.urls import path
2
+
3
+ from .views import WebhookView
4
+
5
+ urlpatterns = [
6
+ path("webhook/", WebhookView.as_view(), name="altified-webhook"),
7
+ ]
@@ -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,2 @@
1
+ Django>=4.2
2
+ requests>=2.31
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+