diff --git a/CHANGELOG.md b/CHANGELOG.md index d6b581b..fce81e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,70 @@ +## [v0.1.5] - 2024-11-11 + +This release introduces two major enhancements to `Lisan`'s translation model functionality, improving flexibility in primary key configurations and structuring translation data for API responses. + +### Added +- **Flexible Primary Key Configuration for Translation Tables**: + - Added support to configure primary key type for translation (Lisan) tables, allowing the use of either `BigInt` or `UUID` as primary keys. + - Configurable globally via `settings.py` (`LISAN_PRIMARY_KEY_TYPE`) or per model by setting the `lisan_primary_key_type` attribute. + - Ensures flexibility for projects that require UUIDs or BigInts based on specific requirements or database configurations. + +- **Nested Translation Serializer for Structured API Responses**: + - Introduced a `TranslationSerializer` to handle multilingual data in API responses, providing a structured, nested format. + - Integrated `TranslationSerializer` within `LisanSerializerMixin`, enabling organized representation of translations in API responses. + - Allows each translation entry to include fields such as `language_code` and all specified translatable fields, making it easier to work with multilingual data in client applications. + +### Improved +- **Dynamic Primary Key Assignment for Lisan Models**: + - Enhanced the `LisanModelMeta` metaclass to detect and apply the specified primary key type dynamically, either at the model level or globally. + - Ensured that the primary key type for Many-to-Many join tables remains `AutoField` even when the `Lisan` model uses `UUIDField` as its primary key, simplifying compatibility with Django’s default join tables. + +### Configuration Changes +- **New Settings**: + - `LISAN_PRIMARY_KEY_TYPE`: Allows configuration of the primary key type for translation tables globally. Options include `BigAutoField` (default) and `UUIDField`. + +### Migration Notes +- A new migration is required if you change the primary key type for existing translation tables. After updating, use the following commands: + + ```bash + python manage.py makemigrations + python manage.py migrate + ``` + +### Example Usage + +- **Primary Key Configuration**: Define primary key type globally in `settings.py` or per model: + ```python + # In settings.py + LISAN_PRIMARY_KEY_TYPE = models.UUIDField + + # Per model configuration + class MyModel(LisanModelMixin, models.Model): + lisan_primary_key_type = models.UUIDField + ``` + +- **Translation Serializer**: Access structured translation data in API responses with `TranslationSerializer`: + ```json + { + "id": 1, + "title": "Sample Title", + "description": "Sample Description", + "translations": [ + { + "language_code": "am", + "title": "ምሳሌ ርእስ", + "description": "ምሳሌ መግለጫ" + }, + { + "language_code": "en", + "title": "Sample Title", + "description": "Sample Description" + } + ] + } + ``` + +--- + ## [v0.1.4] - 2024-10-07 This release introduces improvements in the translation validation process for partial updates, ensuring that translations are properly validated unless explicitly omitted in partial updates. diff --git a/README.md b/README.md index 858ce70..0ba57b8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ ## Features - **Automatic Translation Models:** Automatically generate translation models for your fields. +- **Flexible Primary Key Configuration:** Use either BigInt or UUID as primary keys for translation tables, configurable globally or per model. - **Admin Integration:** Seamlessly manage translations through the Django admin interface. - **Fallback Mechanism:** Fallback to the default language if a translation is not available. - **Dynamic Getter Methods:** Automatically generate methods to access translated fields. @@ -44,7 +45,7 @@ pip install lisan ### 1. Configuring Lisan -To start using `Lisan` in your project, you need to configure the language settings and middleware in your Django settings file. +To start using `Lisan` in your project, configure the language settings, middleware, and primary key type (if needed) in your Django settings file. #### Step 1.0: Add Lisan Language Settings @@ -55,7 +56,20 @@ LISAN_FALLBACK_LANGUAGES = ['fr', 'es', 'en'] # Customize fallback languages LISAN_DEFAULT_TRANSLATION_SERVICE = 'yourapp.google_translate_service.GoogleTranslateService' # Pluggable translation service ``` -#### Step 1.1: Add Lisan Middleware +#### Step 1.1: Configure Primary Key Type (Optional) + +You can configure `Lisan` to use either `BigInt` or `UUID` as the primary key for translation tables. + +To set this globally, use the `LISAN_PRIMARY_KEY_TYPE` setting in `settings.py`: + +```python +from django.db import models +LISAN_PRIMARY_KEY_TYPE = models.UUIDField # Options: models.BigAutoField (default) or models.UUIDField +``` + +Alternatively, define `lisan_primary_key_type` on specific models to override the global setting. + +#### Step 1.2: Add Lisan Middleware Make sure to include `Lisan`'s middleware in your `MIDDLEWARE` settings for automatic language detection and management: @@ -86,6 +100,9 @@ class Snippet(LisanModelMixin, models.Model): description = models.TextField(blank=True, default='') created = models.DateTimeField(auto_now_add=True) + # Optionally specify UUIDField as primary key for translation tables + lisan_primary_key_type = models.UUIDField + class Meta: ordering = ['created'] ``` @@ -162,9 +179,9 @@ To create a snippet with translations, send a `POST` request to the appropriate } ``` -### 2. Retrieving a Snippet with a Specific Translation +### 2. Retrieving a Snippet with Translations Using Nested Translation Serializer -To retrieve a snippet in a specific language, send a `GET` request with the appropriate `Accept-Language` header to specify the desired language (e.g., `am` for Amharic). +To retrieve translations for a snippet, use the `TranslationSerializer` to structure the translations in a nested format. **Request Example**: @@ -173,15 +190,27 @@ GET /api/snippets/1/ Accept-Language: am ``` -The response will return the snippet information in the requested language if available, or it will fallback to the default language: +The response will include all translations for the snippet in a structured format: **Response Example**: ```json { "id": 1, - "title": "ኮድ ቅርጸት ምሳሌ", - "description": "እንቁ ምሳሌ" + "title": "Code Snippet Example", + "description": "Example Description", + "translations": [ + { + "language_code": "am", + "title": "ኮድ ቅርጸት ምሳሌ", + "description": "እንቁ ምሳሌ" + }, + { + "language_code": "en", + "title": "Code Snippet Example", + "description": "Example Description" + } + ] } ``` @@ -219,7 +248,9 @@ from googletrans import Translator from lisan.translation_services import BaseTranslationService class GoogleTranslateService(BaseTranslationService): - def __init__(self): + def __init + +__(self): self.translator = Translator() def translate(self, text, target_language): @@ -263,4 +294,4 @@ If you find any issues or have suggestions for improvements, feel free to open a ## License -Lisan is licensed under the MIT License. See the [LICENSE](LICENSE) file for more information. +Lisan is licensed under the MIT License. See the [LICENSE](LICENSE) file for more information. \ No newline at end of file diff --git a/lisan/metaclasses.py b/lisan/metaclasses.py index 7eb7c6d..e6136fb 100644 --- a/lisan/metaclasses.py +++ b/lisan/metaclasses.py @@ -1,8 +1,10 @@ from django.db import models +from django.conf import settings from django.utils.translation import gettext_lazy as _ -def create_lisan_model(model_cls, fields): +def create_lisan_model( + model_cls, fields, primary_key_type=models.BigAutoField): """ Dynamically create a Lisan model for the given model class. @@ -37,6 +39,19 @@ class Meta: ), } + if primary_key_type == models.UUIDField: + import uuid + # Configure UUIDField with auto-generation + attrs['id'] = models.UUIDField( + primary_key=True, + default=uuid.uuid4, + editable=False, + verbose_name=_("id") + ) + else: + # Default primary key field setup + attrs['id'] = primary_key_type(primary_key=True) + # Add the specified fields to the Lisan model for field_name, field in fields.items(): attrs[field_name] = field @@ -86,24 +101,37 @@ def __new__(cls, name, bases, attrs): """ if 'LisanModelMixin' in [base.__name__ for base in bases]: lisan_fields = attrs.get('lisan_fields') - + # If `lisan_fields` is not defined, raise an exception if lisan_fields is None: raise AttributeError( - f"{name} must define 'lisan_fields' when using LisanModelMixin." + f"{name} must define 'lisan_fields' when using LisanModelMixin." # noqa ) - # Filter translatable fields by checking if they are defined in lisan_fields + # Filter translatable fields by checking if they are + # defined in lisan_fields translatable_fields = { key: value for key, value in attrs.items() if isinstance(value, models.Field) and key in lisan_fields } + # Determine primary key type, checking model-specific + # setting first, then global + primary_key_type = attrs.get( + 'lisan_primary_key_type', # Model-specific setting + getattr( + settings, + 'LISAN_PRIMARY_KEY_TYPE', + models.BigAutoField + ) + ) + # Create the new model class new_class = super().__new__(cls, name, bases, attrs) # Generate the Lisan model - lisan_model = create_lisan_model(new_class, translatable_fields) + lisan_model = create_lisan_model( + new_class, translatable_fields, primary_key_type) setattr(new_class, 'Lisan', lisan_model) # Add a ForeignKey linking the original model to the Lisan model diff --git a/lisan/mixins.py b/lisan/mixins.py index 3622930..b07854e 100644 --- a/lisan/mixins.py +++ b/lisan/mixins.py @@ -75,13 +75,29 @@ def set_lisan(self, language_code, **lisan_fields): f"Field '{field_name}' does not exist in the " "translation model." ) + + primary_key_field = getattr(self.Lisan._meta, 'pk', None) + if primary_key_field and primary_key_field.name != 'id': + primary_key_value = lisan_fields.pop( + primary_key_field.name, None) + else: + primary_key_value = None # Explicitly set the foreign key to self (the instance) + # TODO: this is not capturing the UUID for the id lisan = self.Lisan( **lisan_fields, language_code=language_code, **{self._meta.model_name: self} ) + + if primary_key_value is not None: + setattr( + lisan, + primary_key_field.name, + primary_key_value + ) + lisan.save() self.lisans.add(lisan) else: diff --git a/lisan/serializers.py b/lisan/serializers.py index 21629b7..3e9df83 100644 --- a/lisan/serializers.py +++ b/lisan/serializers.py @@ -3,6 +3,24 @@ from rest_framework.exceptions import ValidationError +class TranslationSerializer(serializers.Serializer): + """ + Serializer for each translation entry, containing a + language code and the translatable fields. + """ + language_code = serializers.CharField() + + def __init__(self, *args, **kwargs): + # Dynamically add fields based on `lisan_fields` + # provided by the main model + lisan_fields = kwargs.pop('lisan_fields', []) + super().__init__(*args, **kwargs) + + for field in lisan_fields: + self.fields[field] = serializers.CharField( + allow_blank=True, required=False) + + class LisanSerializerMixin(serializers.ModelSerializer): """ A serializer mixin that handles dynamic language translations @@ -26,6 +44,16 @@ def __init__(self, *args, **kwargs): self.default_language = getattr( settings, 'LISAN_DEFAULT_LANGUAGE', 'en' ) + + # Initialize `translations` with the nested + # serializer for each language entry + self.fields['translations'] = serializers.ListSerializer( + child=TranslationSerializer( + lisan_fields=getattr(self.Meta.model, 'lisan_fields', [])), + required=False, + write_only=True + ) + self._handle_dynamic_fields() def _handle_dynamic_fields(self): @@ -36,10 +64,12 @@ def _handle_dynamic_fields(self): """ if self.request and self.request.method in ['POST', 'PUT', 'PATCH']: if 'translations' not in self.fields: - self.fields['translations'] = serializers.ListField( - child=serializers.DictField(), - write_only=True, - required=False + self.fields['translations'] = serializers.ListSerializer( + child=TranslationSerializer( + lisan_fields=getattr( + self.Meta.model, 'lisan_fields', [])), + required=False, + write_only=True ) else: self.fields.pop('translations', None) @@ -47,8 +77,7 @@ def _handle_dynamic_fields(self): def to_representation(self, instance): """ Override the default representation of the instance to include - language-specific fields based on the requested language. This - ensures that the correct language data is included in the response. + language-specific fields based on the requested language. """ representation = super().to_representation(instance) language_code = getattr( @@ -67,6 +96,16 @@ def to_representation(self, instance): field, language_code ) + # Add structured `translations` with data for each language + translations_representation = [] + for lang_code in self.allowed_languages: + translation_data = {'language_code': lang_code} + for field in instance.lisan_fields: + translation_data[field] = instance.get_lisan_field( + field, lang_code) + translations_representation.append(translation_data) + + representation['translations'] = translations_representation return representation def _process_translations( @@ -75,13 +114,6 @@ def _process_translations( Process and save the translations for each language provided in the translations list. This method updates the instance with language-specific data. - - Args: - instance: The model instance being created or updated. - translations: A list of translation dictionaries containing - language_code and field data. - default_language_code: The default language code to use if - none is provided in a translation. """ for translation in translations: lang_code = translation.pop('language_code', None) @@ -99,13 +131,6 @@ def create(self, validated_data): Create a new instance of the model, handling any translations provided in the validated data. The translations are processed and saved separately after the main instance is created. - - Args: - validated_data: The data that has been validated and is ready - for creating a new model instance. - - Returns: - The newly created model instance. """ translations = validated_data.pop('translations', []) self._validate_translations(translations) @@ -135,14 +160,6 @@ def update(self, instance, validated_data): Update an existing instance of the model, handling any translations provided in the validated data. The translations are processed and saved separately after the main instance is updated. - - Args: - instance: The existing model instance being updated. - validated_data: The data that has been validated and is ready - for updating the model instance. - - Returns: - The updated model instance. """ translations = validated_data.pop('translations', []) self._validate_translations(translations, partial=True) @@ -179,15 +196,6 @@ def _validate_translations(self, translations, partial=False): fields. This method ensures that each translation includes a language_code and that all necessary fields are present for each translation. - - Args: - translations: A list of dictionaries containing translation data. - partial: A boolean indicating whether the validation is for a - partial update (PATCH) or a full update/creation. - - Raises: - ValidationError: If any required language or field is missing, or - if an unsupported language code is provided. """ if not translations: # Skip translation validation for partial updates diff --git a/setup.py b/setup.py index f18216a..5e6708f 100644 --- a/setup.py +++ b/setup.py @@ -7,11 +7,11 @@ def read_file(filename): with open(filename, encoding='utf-8') as f: return f.read() -long_description = read_file('README.md') if os.path.exists('README.md') else '' +long_description = read_file('README.md') if os.path.exists('README.md') else '' # noqa setup( name='lisan', - version='0.1.4', + version='0.1.5', packages=find_packages(), include_package_data=True, license='MIT',