mirror of
				https://github.com/SectorLabs/django-localized-fields.git
				synced 2025-10-28 17:58:58 +03:00 
			
		
		
		
	Compare commits
	
		
			32 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 3951266747 | ||
|  | b5f4c43d6b | ||
|  | 3d08475468 | ||
|  | b3d7092b91 | ||
|  | 97a785e9b0 | ||
|  | 97c14fd2ba | ||
|  | 6cb4cdf52e | ||
|  | bcb2ff0092 | ||
|  | 43a48403e9 | ||
|  | f453c44a73 | ||
|  | 3cb8b04195 | ||
|  | 3f8fc77c4d | ||
|  | 9245c85e5d | ||
|  | 818a0a2fe3 | ||
|  | c206005cae | ||
|  | 3850c34374 | ||
|  | 99c8830f10 | ||
|  | d7bd217a90 | ||
|  | b9a4d3be2c | ||
|  | 6d7a937eac | ||
|  | 2e9b83e49b | ||
|  | 679dcafef6 | ||
|  | ad2ef34546 | ||
|  | 1317023160 | ||
|  | ca6b1c88fa | ||
|  | 64c3c06612 | ||
|  | b121dfc2d7 | ||
|  | d529da8886 | ||
|  | ca879087ea | ||
|  | 302a64a02c | ||
|  | bb11253207 | ||
|  | 5db87763fb | 
| @@ -1,6 +1,3 @@ | |||||||
| [MASTER] |  | ||||||
| load-plugins=pylint_common, pylint_django |  | ||||||
|  |  | ||||||
| [FORMAT] | [FORMAT] | ||||||
| max-line-length=120 | max-line-length=120 | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										31
									
								
								.scrutinizer.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								.scrutinizer.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | checks: | ||||||
|  |     python: | ||||||
|  |         code_rating: true | ||||||
|  |         duplicate_code: true | ||||||
|  | tools: | ||||||
|  |     pylint: | ||||||
|  |         python_version: '3' | ||||||
|  |         config_file: .pylintrc | ||||||
|  | filter: | ||||||
|  |     excluded_paths: | ||||||
|  |         - '*/tests/*' | ||||||
|  |         - '*/migrations/*' | ||||||
|  | build: | ||||||
|  |     environment: | ||||||
|  |         python: '3.5.0' | ||||||
|  |         node: 'v6.2.0' | ||||||
|  |         variables: | ||||||
|  |             DJANGO_SETTINGS_MODULES: settings | ||||||
|  |             DATABASE_URL: postgres://scrutinizer:scrutinizer@localhost:5434/localized_fields | ||||||
|  |         postgresql: true | ||||||
|  |         redis: true | ||||||
|  |     dependencies: | ||||||
|  |         override: | ||||||
|  |             - 'pip install -r requirements/test.txt' | ||||||
|  |     tests: | ||||||
|  |         override: | ||||||
|  |             - | ||||||
|  |                 command: coverage run manage.py test | ||||||
|  |                 coverage: | ||||||
|  |                     file: '.coverage' | ||||||
|  |                     format: 'py-cc' | ||||||
							
								
								
									
										88
									
								
								README.rst
									
									
									
									
									
								
							
							
						
						
									
										88
									
								
								README.rst
									
									
									
									
									
								
							| @@ -7,24 +7,14 @@ django-localized-fields | |||||||
| .. image:: https://scrutinizer-ci.com/g/SectorLabs/django-localized-fields/badges/coverage.png | .. image:: https://scrutinizer-ci.com/g/SectorLabs/django-localized-fields/badges/coverage.png | ||||||
|     :target: https://scrutinizer-ci.com/g/SectorLabs/django-localized-fields/ |     :target: https://scrutinizer-ci.com/g/SectorLabs/django-localized-fields/ | ||||||
|  |  | ||||||
| .. image:: https://travis-ci.com/SectorLabs/django-localized-fields.svg?token=sFgxhDFpypxkMcvhRoSz&branch=master | .. image:: https://img.shields.io/github/license/SectorLabs/django-localized-fields.svg | ||||||
|     :target: https://travis-ci.com/SectorLabs/django-localized-fields |  | ||||||
|  |  | ||||||
| .. image:: https://badge.fury.io/py/django-localized-fields.svg | .. image:: https://badge.fury.io/py/django-localized-fields.svg | ||||||
|     :target: https://pypi.python.org/pypi/django-localized-fields |     :target: https://pypi.python.org/pypi/django-localized-fields | ||||||
|  |  | ||||||
| .. image:: https://img.shields.io/github/license/SectorLabs/django-localized-fields.svg |  | ||||||
|  |  | ||||||
| ``django-localized-fields`` is an implementation of a field class for Django models that allows the field's value to be set in multiple languages. It does this by utilizing the ``hstore`` type (PostgreSQL specific), which is available as ``models.HStoreField`` in Django 1.10. | ``django-localized-fields`` is an implementation of a field class for Django models that allows the field's value to be set in multiple languages. It does this by utilizing the ``hstore`` type (PostgreSQL specific), which is available as ``models.HStoreField`` in Django 1.10. | ||||||
|  |  | ||||||
| This package requires Python 3.5 or newer and Django 1.10 or newer. | This package requires Python 3.5 or newer, Django 1.10 or newer and PostgreSQL 9.6 or newer. | ||||||
|  |  | ||||||
| In the pipeline |  | ||||||
| --------------- |  | ||||||
| We're working on making this easier to setup and use. Any feedback is apreciated. Here's a short list of things we're working to improve: |  | ||||||
|  |  | ||||||
| * Make it unnecesarry to add anything to your `INSTALLED_APPS`. |  | ||||||
| * Move generic PostgreSQL code to a separate package. |  | ||||||
|  |  | ||||||
| Installation | Installation | ||||||
| ------------ | ------------ | ||||||
| @@ -45,14 +35,14 @@ Installation | |||||||
|             'localized_fields' |             'localized_fields' | ||||||
|         ] |         ] | ||||||
|  |  | ||||||
| 3. Set the database engine to ``localized_fields.db_backend``: | 3. Set the database engine to ``psqlextra.backend``: | ||||||
|  |  | ||||||
|     .. code-block:: python |     .. code-block:: python | ||||||
|  |  | ||||||
|         DATABASES = { |         DATABASES = { | ||||||
|             'default': { |             'default': { | ||||||
|                 ... |                 ... | ||||||
|                 'ENGINE': 'localized_fields.db_backend' |                 'ENGINE': 'psqlextra.backend' | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -146,14 +136,14 @@ At the moment, it is not possible to select two languages to be marked as requir | |||||||
|  |  | ||||||
|     .. code-block:: python |     .. code-block:: python | ||||||
|  |  | ||||||
|         class MyModel(models.Model): |         class MyModel(LocalizedModel): | ||||||
|             title = LocalizedField(required=True) |             title = LocalizedField(required=True) | ||||||
|  |  | ||||||
| * Make all languages optional: | * Make all languages optional: | ||||||
|  |  | ||||||
|     .. code-block:: python |     .. code-block:: python | ||||||
|  |  | ||||||
|         class MyModel(models.Model): |         class MyModel(LocalizedModel): | ||||||
|             title = LocalizedField(null=True) |             title = LocalizedField(null=True) | ||||||
|  |  | ||||||
| **Uniqueness** | **Uniqueness** | ||||||
| @@ -164,7 +154,7 @@ By default the values stored in a ``LocalizedField`` are *not unique*. You can e | |||||||
|  |  | ||||||
|     .. code-block:: python |     .. code-block:: python | ||||||
|  |  | ||||||
|         class MyModel(models.Model): |         class MyModel(LocalizedModel): | ||||||
|             title = LocalizedField(uniqueness=['en', 'ro']) |             title = LocalizedField(uniqueness=['en', 'ro']) | ||||||
|  |  | ||||||
| * Enforce uniqueness for **all** languages: | * Enforce uniqueness for **all** languages: | ||||||
| @@ -173,14 +163,14 @@ By default the values stored in a ``LocalizedField`` are *not unique*. You can e | |||||||
|  |  | ||||||
|         from localized_fields import get_language_codes |         from localized_fields import get_language_codes | ||||||
|  |  | ||||||
|         class MyModel(models.Model): |         class MyModel(LocalizedModel): | ||||||
|             title = LocalizedField(uniqueness=get_language_codes()) |             title = LocalizedField(uniqueness=get_language_codes()) | ||||||
|  |  | ||||||
| * Enforce uniqueness for one ore more languages **together** (similar to Django's ``unique_together``): | * Enforce uniqueness for one ore more languages **together** (similar to Django's ``unique_together``): | ||||||
|  |  | ||||||
|     .. code-block:: python |     .. code-block:: python | ||||||
|  |  | ||||||
|         class MyModel(models.Model): |         class MyModel(LocalizedModel): | ||||||
|             title = LocalizedField(uniqueness=[('en', 'ro')]) |             title = LocalizedField(uniqueness=[('en', 'ro')]) | ||||||
|  |  | ||||||
| * Enforce uniqueness for **all** languages **together**: | * Enforce uniqueness for **all** languages **together**: | ||||||
| @@ -189,7 +179,7 @@ By default the values stored in a ``LocalizedField`` are *not unique*. You can e | |||||||
|  |  | ||||||
|         from localized_fields import get_language_codes |         from localized_fields import get_language_codes | ||||||
|  |  | ||||||
|         class MyModel(models.Model): |         class MyModel(LocalizedModel): | ||||||
|             title = LocalizedField(uniqueness=[(*get_language_codes())]) |             title = LocalizedField(uniqueness=[(*get_language_codes())]) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -197,19 +187,29 @@ Other fields | |||||||
| ^^^^^^^^^^^^ | ^^^^^^^^^^^^ | ||||||
| Besides ``LocalizedField``, there's also: | Besides ``LocalizedField``, there's also: | ||||||
|  |  | ||||||
| * ``LocalizedMagicSlugField`` | * ``LocalizedUniqueSlugField`` | ||||||
|     Successor of ``LocalizedAutoSlugField`` that fixes concurrency issues and enforces |     Successor of ``LocalizedAutoSlugField`` that fixes concurrency issues and enforces | ||||||
|     uniqueness of slugs on a database level. Usage is the exact same: |     uniqueness of slugs on a database level. Usage is the exact same: | ||||||
|  |  | ||||||
|           .. code-block:: python |           .. code-block:: python | ||||||
|  |  | ||||||
|               from localized_fields.models import LocalizedModel |               from localized_fields import (LocalizedModel, | ||||||
|               from localized_fields.fields import (LocalizedField, |                                             AtomicSlugRetryMixin, | ||||||
|                                                    LocalizedMagicSlugField) |                                             LocalizedField, | ||||||
|  |                                             LocalizedUniqueSlugField) | ||||||
|  |  | ||||||
|               class MyModel(LocalizedModel): |               class MyModel(AtomicSlugRetryMixin, LocalizedModel): | ||||||
|                    title = LocalizedField() |                    title = LocalizedField() | ||||||
|                    slug = LocalizedMagicSlugField(populate_from='title') |                    slug = LocalizedUniqueSlugField(populate_from='title') | ||||||
|  |  | ||||||
|  |     By setting the option ``include_time=True`` | ||||||
|  |  | ||||||
|  |           .. code-block:: python | ||||||
|  |  | ||||||
|  |                slug = LocalizedUniqueSlugField(populate_from='title', include_time=True) | ||||||
|  |  | ||||||
|  |     You can instruct the field to include a part of the current time into | ||||||
|  |     the resulting slug. This is useful if you're running into a lot of collisions. | ||||||
|  |  | ||||||
| * ``LocalizedAutoSlugField`` | * ``LocalizedAutoSlugField`` | ||||||
|      Automatically creates a slug for every language from the specified field. |      Automatically creates a slug for every language from the specified field. | ||||||
| @@ -218,15 +218,15 @@ Besides ``LocalizedField``, there's also: | |||||||
|  |  | ||||||
|           .. code-block:: python |           .. code-block:: python | ||||||
|  |  | ||||||
|               from localized_fields.models import LocalizedModel |               from localized_fields import (LocalizedModel, | ||||||
|               from localized_fields.fields import (LocalizedField, |                                             LocalizedField, | ||||||
|                                                    LocalizedAutoSlugField) |                                             LocalizedUniqueSlugField) | ||||||
|  |  | ||||||
|               class MyModel(LocalizedModel): |               class MyModel(LocalizedModel): | ||||||
|                    title = LocalizedField() |                    title = LocalizedField() | ||||||
|                    slug = LocalizedAutoSlugField(populate_from='title') |                    slug = LocalizedAutoSlugField(populate_from='title') | ||||||
|  |  | ||||||
|      This implementation is **NOT** concurrency safe, prefer ``LocalizedMagicSlugField``. |      This implementation is **NOT** concurrency safe, prefer ``LocalizedUniqueSlugField``. | ||||||
|  |  | ||||||
| * ``LocalizedBleachField`` | * ``LocalizedBleachField`` | ||||||
|      Automatically bleaches the content of the field. |      Automatically bleaches the content of the field. | ||||||
| @@ -236,9 +236,9 @@ Besides ``LocalizedField``, there's also: | |||||||
|  |  | ||||||
|            .. code-block:: python |            .. code-block:: python | ||||||
|  |  | ||||||
|               from localized_fields.models import LocalizedModel |               from localized_fields import (LocalizedModel, | ||||||
|               from localized_fields.fields import (LocalizedField, |                                             LocalizedField, | ||||||
|                                                    LocalizedBleachField) |                                             LocalizedBleachField) | ||||||
|  |  | ||||||
|               class MyModel(LocalizedModel): |               class MyModel(LocalizedModel): | ||||||
|                    title = LocalizedField() |                    title = LocalizedField() | ||||||
| @@ -247,34 +247,26 @@ Besides ``LocalizedField``, there's also: | |||||||
| Frequently asked questions (FAQ) | Frequently asked questions (FAQ) | ||||||
| -------------------------------- | -------------------------------- | ||||||
|  |  | ||||||
| 1. Why do I need to change the database back-end/engine? | 1. Does this package work with Python 2? | ||||||
|  |  | ||||||
|     We utilize PostgreSQL's `hstore` data type, which allows you to store key-value pairs in a column.  In order to create `UNIQUE` constraints on specific key, we need to create a special type of index. We could do this without a custom database back-end, but it would require everyone to manually write their migrations. By using a custom database back-end, we added support for this. When changing the `uniqueness` constraint on a `LocalizedField`, our custom database back-end takes care of creating, updating and deleting these constraints/indexes in the database. |  | ||||||
|  |  | ||||||
| 2. I am already using a custom database back-end, can I still use yours? |  | ||||||
|  |  | ||||||
|     Yes. You can set the ``LOCALIZED_FIELDS_DB_BACKEND_BASE`` setting to your current back-end. This will instruct our custom database back-end to inherit from the database back-end you specified. **Warning**: this will only work if the base you specified indirectly inherits from the standard PostgreSQL database back-end. |  | ||||||
|  |  | ||||||
| 3. Does this package work with Python 2? |  | ||||||
|  |  | ||||||
|     No. Only Python 3.5 or newer is supported. We're using type hints. These do not work well under older versions of Python. |     No. Only Python 3.5 or newer is supported. We're using type hints. These do not work well under older versions of Python. | ||||||
|  |  | ||||||
| 4. Does this package work with Django 1.X? | 2. Does this package work with Django 1.X? | ||||||
|  |  | ||||||
|     No. Only Django 1.10 or newer is supported. This is because we rely on Django's ``HStoreField``. |     No. Only Django 1.10 or newer is supported. This is because we rely on Django's ``HStoreField``. | ||||||
|  |  | ||||||
| 5. Does this package come with support for Django Admin? | 3. Does this package come with support for Django Admin? | ||||||
|  |  | ||||||
|     Yes. Our custom fields come with a special form that will automatically be used in Django Admin if the field is of ``LocalizedField``. |     Yes. Our custom fields come with a special form that will automatically be used in Django Admin if the field is of ``LocalizedField``. | ||||||
|  |  | ||||||
| 7. Why should I pick this over any of the other translation packages out there? | 4. Why should I pick this over any of the other translation packages out there? | ||||||
|  |  | ||||||
|     You should pick whatever you feel comfortable with. This package stores translations in your database without having to have translation tables. It however only works on PostgreSQL. |     You should pick whatever you feel comfortable with. This package stores translations in your database without having to have translation tables. It however only works on PostgreSQL. | ||||||
|  |  | ||||||
| 8. I am using PostgreSQL <8.4, can I use this? | 5. I am using PostgreSQL <9.6, can I use this? | ||||||
|  |  | ||||||
|     No. The ``hstore`` data type was introduced in PostgreSQL 8.4. |     No. The ``hstore`` data type was introduced in PostgreSQL 9.6. | ||||||
|  |  | ||||||
| 9. I am using this package. Can I give you some beer? | 6. I am using this package. Can I give you some beer? | ||||||
|  |  | ||||||
|     Yes! If you're ever in the area of Cluj-Napoca, Romania, swing by :) |     Yes! If you're ever in the area of Cluj-Napoca, Romania, swing by :) | ||||||
|   | |||||||
| @@ -1,18 +1,20 @@ | |||||||
| from .util import get_language_codes | from .util import get_language_codes | ||||||
| from .forms import LocalizedFieldForm, LocalizedFieldWidget | from .forms import LocalizedFieldForm, LocalizedFieldWidget | ||||||
| from .fields import (LocalizedField, LocalizedBleachField, | from .fields import (LocalizedField, LocalizedBleachField, | ||||||
|                      LocalizedAutoSlugField, LocalizedMagicSlugField) |                      LocalizedAutoSlugField, LocalizedUniqueSlugField) | ||||||
| from .localized_value import LocalizedValue | from .mixins import AtomicSlugRetryMixin | ||||||
| from .models import LocalizedModel | from .models import LocalizedModel | ||||||
|  | from .localized_value import LocalizedValue | ||||||
|  |  | ||||||
| __all__ = [ | __all__ = [ | ||||||
|     'get_language_codes', |     'get_language_codes', | ||||||
|     'LocalizedField', |     'LocalizedField', | ||||||
|     'LocalizedValue', |     'LocalizedValue', | ||||||
|     'LocalizedAutoSlugField', |     'LocalizedAutoSlugField', | ||||||
|     'LocalizedMagicSlugField', |     'LocalizedUniqueSlugField', | ||||||
|     'LocalizedBleachField', |     'LocalizedBleachField', | ||||||
|     'LocalizedFieldWidget', |     'LocalizedFieldWidget', | ||||||
|     'LocalizedFieldForm', |     'LocalizedFieldForm', | ||||||
|     'LocalizedModel' |     'LocalizedModel', | ||||||
|  |     'AtomicSlugRetryMixin' | ||||||
| ] | ] | ||||||
|   | |||||||
| @@ -1,224 +0,0 @@ | |||||||
| import importlib |  | ||||||
|  |  | ||||||
| from django.conf import settings |  | ||||||
| from django.core.exceptions import ImproperlyConfigured |  | ||||||
| from django.db.backends.postgresql.base import \ |  | ||||||
|     DatabaseWrapper as Psycopg2DatabaseWrapper |  | ||||||
|  |  | ||||||
| from ..fields import LocalizedField |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def _get_backend_base(): |  | ||||||
|     """Gets the base class for the custom database back-end. |  | ||||||
|  |  | ||||||
|     This should be the Django PostgreSQL back-end. However, |  | ||||||
|     some people are already using a custom back-end from |  | ||||||
|     another package. We are nice people and expose an option |  | ||||||
|     that allows them to configure the back-end we base upon. |  | ||||||
|  |  | ||||||
|     As long as the specified base eventually also has |  | ||||||
|     the PostgreSQL back-end as a base, then everything should |  | ||||||
|     work as intended. |  | ||||||
|     """ |  | ||||||
|     base_class_name = getattr( |  | ||||||
|         settings, |  | ||||||
|         'LOCALIZED_FIELDS_DB_BACKEND_BASE', |  | ||||||
|         'django.db.backends.postgresql' |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     base_class_module = importlib.import_module(base_class_name + '.base') |  | ||||||
|     base_class = getattr(base_class_module, 'DatabaseWrapper', None) |  | ||||||
|  |  | ||||||
|     if not base_class: |  | ||||||
|         raise ImproperlyConfigured(( |  | ||||||
|             '\'%s\' is not a valid database back-end.' |  | ||||||
|             ' The module does not define a DatabaseWrapper class.' |  | ||||||
|             ' Check the value of LOCALIZED_FIELDS_DB_BACKEND_BASE.' |  | ||||||
|         )) |  | ||||||
|  |  | ||||||
|     if isinstance(base_class, Psycopg2DatabaseWrapper): |  | ||||||
|         raise ImproperlyConfigured(( |  | ||||||
|             '\'%s\' is not a valid database back-end.' |  | ||||||
|             ' It does inherit from the PostgreSQL back-end.' |  | ||||||
|             ' Check the value of LOCALIZED_FIELDS_DB_BACKEND_BASE.' |  | ||||||
|         )) |  | ||||||
|  |  | ||||||
|     return base_class |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def _get_schema_editor_base(): |  | ||||||
|     """Gets the base class for the schema editor. |  | ||||||
|  |  | ||||||
|     We have to use the configured base back-end's |  | ||||||
|     schema editor for this.""" |  | ||||||
|     return _get_backend_base().SchemaEditorClass |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class SchemaEditor(_get_schema_editor_base()): |  | ||||||
|     """Custom schema editor for hstore indexes. |  | ||||||
|  |  | ||||||
|     This allows us to put UNIQUE constraints for |  | ||||||
|     localized fields.""" |  | ||||||
|  |  | ||||||
|     sql_hstore_unique_create = "CREATE UNIQUE INDEX {name} ON {table}{using} ({columns}){extra}" |  | ||||||
|     sql_hstore_unique_drop = "DROP INDEX IF EXISTS {name}" |  | ||||||
|  |  | ||||||
|     @staticmethod |  | ||||||
|     def _hstore_unique_name(model, field, keys): |  | ||||||
|         """Gets the name for a UNIQUE INDEX that applies |  | ||||||
|         to one or more keys in a hstore field. |  | ||||||
|  |  | ||||||
|         Arguments: |  | ||||||
|             model: |  | ||||||
|                 The model the field is a part of. |  | ||||||
|  |  | ||||||
|             field: |  | ||||||
|                 The hstore field to create a |  | ||||||
|                 UNIQUE INDEX for. |  | ||||||
|  |  | ||||||
|             key: |  | ||||||
|                 The name of the hstore key |  | ||||||
|                 to create the name for. |  | ||||||
|  |  | ||||||
|                 This can also be a tuple |  | ||||||
|                 of multiple names. |  | ||||||
|  |  | ||||||
|         Returns: |  | ||||||
|             The name for the UNIQUE index. |  | ||||||
|         """ |  | ||||||
|         postfix = '_'.join(keys) |  | ||||||
|         return '{table_name}_{field_name}_unique_{postfix}'.format( |  | ||||||
|             table_name=model._meta.db_table, |  | ||||||
|             field_name=field.column, |  | ||||||
|             postfix=postfix |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def _drop_hstore_unique(self, model, field, keys): |  | ||||||
|         """Drops a UNIQUE constraint for the specified hstore keys.""" |  | ||||||
|  |  | ||||||
|         name = self._hstore_unique_name(model, field, keys) |  | ||||||
|         sql = self.sql_hstore_unique_drop.format(name=name) |  | ||||||
|         self.execute(sql) |  | ||||||
|  |  | ||||||
|     def _create_hstore_unique(self, model, field, keys): |  | ||||||
|         """Creates a UNIQUE constraint for the specified hstore keys.""" |  | ||||||
|  |  | ||||||
|         name = self._hstore_unique_name(model, field, keys) |  | ||||||
|         columns = [ |  | ||||||
|             '(%s->\'%s\')' % (field.column, key) |  | ||||||
|             for key in keys |  | ||||||
|         ] |  | ||||||
|         sql = self.sql_hstore_unique_create.format( |  | ||||||
|             name=name, |  | ||||||
|             table=model._meta.db_table, |  | ||||||
|             using='', |  | ||||||
|             columns=','.join(columns), |  | ||||||
|             extra='' |  | ||||||
|         ) |  | ||||||
|         self.execute(sql) |  | ||||||
|  |  | ||||||
|     def _apply_hstore_constraints(self, method, model, field): |  | ||||||
|         """Creates/drops UNIQUE constraints for a field.""" |  | ||||||
|  |  | ||||||
|         def _compose_keys(constraint): |  | ||||||
|             if isinstance(constraint, str): |  | ||||||
|                 return [constraint] |  | ||||||
|  |  | ||||||
|             return constraint |  | ||||||
|  |  | ||||||
|         uniqueness = getattr(field, 'uniqueness', None) |  | ||||||
|         if not uniqueness: |  | ||||||
|             return |  | ||||||
|  |  | ||||||
|         for keys in uniqueness: |  | ||||||
|             method( |  | ||||||
|                 model, |  | ||||||
|                 field, |  | ||||||
|                 _compose_keys(keys) |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def _update_hstore_constraints(self, model, old_field, new_field): |  | ||||||
|         """Updates the UNIQUE constraints for the specified field.""" |  | ||||||
|  |  | ||||||
|         old_uniqueness = getattr(old_field, 'uniqueness', None) |  | ||||||
|         new_uniqueness = getattr(new_field, 'uniqueness', None) |  | ||||||
|  |  | ||||||
|         # drop any old uniqueness constraints |  | ||||||
|         if old_uniqueness: |  | ||||||
|             self._apply_hstore_constraints( |  | ||||||
|                 self._drop_hstore_unique, |  | ||||||
|                 model, |  | ||||||
|                 old_field |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         # (re-)create uniqueness constraints |  | ||||||
|         if new_uniqueness: |  | ||||||
|             self._apply_hstore_constraints( |  | ||||||
|                 self._create_hstore_unique, |  | ||||||
|                 model, |  | ||||||
|                 new_field |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def _alter_field(self, model, old_field, new_field, *args, **kwargs): |  | ||||||
|         """Ran when the configuration on a field changed.""" |  | ||||||
|  |  | ||||||
|         super()._alter_field( |  | ||||||
|             model, old_field, new_field, |  | ||||||
|             *args, **kwargs |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         is_old_field_localized = isinstance(old_field, LocalizedField) |  | ||||||
|         is_new_field_localized = isinstance(new_field, LocalizedField) |  | ||||||
|  |  | ||||||
|         if is_old_field_localized or is_new_field_localized: |  | ||||||
|             self._update_hstore_constraints(model, old_field, new_field) |  | ||||||
|  |  | ||||||
|     def create_model(self, model): |  | ||||||
|         """Ran when a new model is created.""" |  | ||||||
|  |  | ||||||
|         super().create_model(model) |  | ||||||
|  |  | ||||||
|         for field in model._meta.local_fields: |  | ||||||
|             if not isinstance(field, LocalizedField): |  | ||||||
|                 continue |  | ||||||
|  |  | ||||||
|             self._apply_hstore_constraints( |  | ||||||
|                 self._create_hstore_unique, |  | ||||||
|                 model, |  | ||||||
|                 field |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|     def delete_model(self, model): |  | ||||||
|         """Ran when a model is being deleted.""" |  | ||||||
|  |  | ||||||
|         super().delete_model(model) |  | ||||||
|  |  | ||||||
|         for field in model._meta.local_fields: |  | ||||||
|             if not isinstance(field, LocalizedField): |  | ||||||
|                 continue |  | ||||||
|  |  | ||||||
|             self._apply_hstore_constraints( |  | ||||||
|                 self._drop_hstore_unique, |  | ||||||
|                 model, |  | ||||||
|                 field |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class DatabaseWrapper(_get_backend_base()): |  | ||||||
|     """Wraps the standard PostgreSQL database back-end. |  | ||||||
|  |  | ||||||
|     Overrides the schema editor with our custom |  | ||||||
|     schema editor and makes sure the `hstore` |  | ||||||
|     extension is enabled.""" |  | ||||||
|  |  | ||||||
|     SchemaEditorClass = SchemaEditor |  | ||||||
|  |  | ||||||
|     def prepare_database(self): |  | ||||||
|         """Ran to prepare the configured database. |  | ||||||
|  |  | ||||||
|         This is where we enable the `hstore` extension |  | ||||||
|         if it wasn't enabled yet.""" |  | ||||||
|  |  | ||||||
|         super().prepare_database() |  | ||||||
|         with self.cursor() as cursor: |  | ||||||
|             cursor.execute('CREATE EXTENSION IF NOT EXISTS hstore') |  | ||||||
| @@ -1,12 +1,12 @@ | |||||||
| from .localized_field import LocalizedField | from .localized_field import LocalizedField | ||||||
| from .localized_autoslug_field import LocalizedAutoSlugField | from .localized_autoslug_field import LocalizedAutoSlugField | ||||||
| from .localized_magicslug_field import LocalizedMagicSlugField | from .localized_uniqueslug_field import LocalizedUniqueSlugField | ||||||
| from .localized_bleach_field import LocalizedBleachField | from .localized_bleach_field import LocalizedBleachField | ||||||
|  |  | ||||||
|  |  | ||||||
| __all__ = [ | __all__ = [ | ||||||
|     'LocalizedField', |     'LocalizedField', | ||||||
|     'LocalizedAutoSlugField', |     'LocalizedAutoSlugField', | ||||||
|     'LocalizedMagicSlugField', |     'LocalizedUniqueSlugField', | ||||||
|     'LocalizedBleachField', |     'LocalizedBleachField', | ||||||
| ] | ] | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| from typing import Callable | from typing import Callable | ||||||
|  | from datetime import datetime | ||||||
|  |  | ||||||
| from django import forms | from django import forms | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| @@ -16,6 +17,7 @@ class LocalizedAutoSlugField(LocalizedField): | |||||||
|         """Initializes a new instance of :see:LocalizedAutoSlugField.""" |         """Initializes a new instance of :see:LocalizedAutoSlugField.""" | ||||||
|  |  | ||||||
|         self.populate_from = kwargs.pop('populate_from', None) |         self.populate_from = kwargs.pop('populate_from', None) | ||||||
|  |         self.include_time = kwargs.pop('include_time', False) | ||||||
|  |  | ||||||
|         super(LocalizedAutoSlugField, self).__init__( |         super(LocalizedAutoSlugField, self).__init__( | ||||||
|             *args, |             *args, | ||||||
| @@ -30,6 +32,7 @@ class LocalizedAutoSlugField(LocalizedField): | |||||||
|             LocalizedAutoSlugField, self).deconstruct() |             LocalizedAutoSlugField, self).deconstruct() | ||||||
|  |  | ||||||
|         kwargs['populate_from'] = self.populate_from |         kwargs['populate_from'] = self.populate_from | ||||||
|  |         kwargs['include_time'] = self.include_time | ||||||
|         return name, path, args, kwargs |         return name, path, args, kwargs | ||||||
|  |  | ||||||
|     def formfield(self, **kwargs): |     def formfield(self, **kwargs): | ||||||
| @@ -76,6 +79,9 @@ class LocalizedAutoSlugField(LocalizedField): | |||||||
|             if not value: |             if not value: | ||||||
|                 continue |                 continue | ||||||
|  |  | ||||||
|  |             if self.include_time: | ||||||
|  |                 value += '-%s' % datetime.now().microsecond | ||||||
|  |  | ||||||
|             def is_unique(slug: str, language: str) -> bool: |             def is_unique(slug: str, language: str) -> bool: | ||||||
|                 """Gets whether the specified slug is unique.""" |                 """Gets whether the specified slug is unique.""" | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,8 +1,9 @@ | |||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.postgres.fields import HStoreField |  | ||||||
| from django.db.utils import IntegrityError | from django.db.utils import IntegrityError | ||||||
|  |  | ||||||
| from localized_fields import LocalizedFieldForm | from localized_fields import LocalizedFieldForm | ||||||
|  | from psqlextra.fields import HStoreField | ||||||
|  |  | ||||||
| from ..localized_value import LocalizedValue | from ..localized_value import LocalizedValue | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -14,13 +15,11 @@ class LocalizedField(HStoreField): | |||||||
|  |  | ||||||
|     Meta = None |     Meta = None | ||||||
|  |  | ||||||
|     def __init__(self, *args, uniqueness=None, **kwargs): |     def __init__(self, *args, **kwargs): | ||||||
|         """Initializes a new instance of :see:LocalizedValue.""" |         """Initializes a new instance of :see:LocalizedField.""" | ||||||
|  |  | ||||||
|         super(LocalizedField, self).__init__(*args, **kwargs) |         super(LocalizedField, self).__init__(*args, **kwargs) | ||||||
|  |  | ||||||
|         self.uniqueness = uniqueness |  | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def from_db_value(value, *_): |     def from_db_value(value, *_): | ||||||
|         """Turns the specified database value into its Python |         """Turns the specified database value into its Python | ||||||
|   | |||||||
| @@ -1,68 +0,0 @@ | |||||||
| from django.conf import settings |  | ||||||
| from django.utils.text import slugify |  | ||||||
|  |  | ||||||
| from ..localized_value import LocalizedValue |  | ||||||
| from .localized_autoslug_field import LocalizedAutoSlugField |  | ||||||
| from ..util import get_language_codes |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class LocalizedMagicSlugField(LocalizedAutoSlugField): |  | ||||||
|     """Automatically provides slugs for a localized |  | ||||||
|     field upon saving." |  | ||||||
|  |  | ||||||
|     An improved version of :see:LocalizedAutoSlugField, |  | ||||||
|     which adds: |  | ||||||
|  |  | ||||||
|         - Concurrency safety |  | ||||||
|         - Improved performance |  | ||||||
|  |  | ||||||
|     When in doubt, use this over :see:LocalizedAutoSlugField. |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     def __init__(self, *args, **kwargs): |  | ||||||
|         """Initializes a new instance of :see:LocalizedMagicSlugField.""" |  | ||||||
|  |  | ||||||
|         self.populate_from = kwargs.pop('populate_from') |  | ||||||
|         kwargs['uniqueness'] = kwargs.pop('uniqueness', get_language_codes()) |  | ||||||
|  |  | ||||||
|         super(LocalizedAutoSlugField, self).__init__( |  | ||||||
|             *args, |  | ||||||
|             **kwargs |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def pre_save(self, instance, add: bool): |  | ||||||
|         """Ran just before the model is saved, allows us to built |  | ||||||
|         the slug. |  | ||||||
|  |  | ||||||
|         Arguments: |  | ||||||
|             instance: |  | ||||||
|                 The model that is being saved. |  | ||||||
|  |  | ||||||
|             add: |  | ||||||
|                 Indicates whether this is a new entry |  | ||||||
|                 to the database or an update. |  | ||||||
|  |  | ||||||
|         Returns: |  | ||||||
|             The localized slug that was generated. |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|         slugs = LocalizedValue() |  | ||||||
|  |  | ||||||
|         for lang_code, _ in settings.LANGUAGES: |  | ||||||
|             value = self._get_populate_from_value( |  | ||||||
|                 instance, |  | ||||||
|                 self.populate_from, |  | ||||||
|                 lang_code |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|             if not value: |  | ||||||
|                 continue |  | ||||||
|  |  | ||||||
|             slug = slugify(value, allow_unicode=True) |  | ||||||
|             if instance.retries > 0: |  | ||||||
|                 slug += '-%d' % instance.retries |  | ||||||
|  |  | ||||||
|             slugs.set(lang_code, slug) |  | ||||||
|  |  | ||||||
|         setattr(instance, self.name, slugs) |  | ||||||
|         return slugs |  | ||||||
							
								
								
									
										110
									
								
								localized_fields/fields/localized_uniqueslug_field.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								localized_fields/fields/localized_uniqueslug_field.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | |||||||
|  | from datetime import datetime | ||||||
|  |  | ||||||
|  | from django.conf import settings | ||||||
|  | from django.utils.text import slugify | ||||||
|  | from django.core.exceptions import ImproperlyConfigured | ||||||
|  |  | ||||||
|  | from ..util import get_language_codes | ||||||
|  | from ..mixins import AtomicSlugRetryMixin | ||||||
|  | from ..localized_value import LocalizedValue | ||||||
|  | from .localized_autoslug_field import LocalizedAutoSlugField | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LocalizedUniqueSlugField(LocalizedAutoSlugField): | ||||||
|  |     """Automatically provides slugs for a localized | ||||||
|  |     field upon saving." | ||||||
|  |  | ||||||
|  |     An improved version of :see:LocalizedAutoSlugField, | ||||||
|  |     which adds: | ||||||
|  |  | ||||||
|  |         - Concurrency safety | ||||||
|  |         - Improved performance | ||||||
|  |  | ||||||
|  |     When in doubt, use this over :see:LocalizedAutoSlugField. | ||||||
|  |     Inherit from :see:AtomicSlugRetryMixin in your model to | ||||||
|  |     make this field work properly. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         """Initializes a new instance of :see:LocalizedUniqueSlugField.""" | ||||||
|  |  | ||||||
|  |         kwargs['uniqueness'] = kwargs.pop('uniqueness', get_language_codes()) | ||||||
|  |  | ||||||
|  |         super(LocalizedUniqueSlugField, self).__init__( | ||||||
|  |             *args, | ||||||
|  |             **kwargs | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         self.populate_from = kwargs.pop('populate_from') | ||||||
|  |         self.include_time = kwargs.pop('include_time', False) | ||||||
|  |  | ||||||
|  |     def deconstruct(self): | ||||||
|  |         """Deconstructs the field into something the database | ||||||
|  |         can store.""" | ||||||
|  |  | ||||||
|  |         name, path, args, kwargs = super( | ||||||
|  |             LocalizedUniqueSlugField, self).deconstruct() | ||||||
|  |  | ||||||
|  |         kwargs['populate_from'] = self.populate_from | ||||||
|  |         kwargs['include_time'] = self.include_time | ||||||
|  |         return name, path, args, kwargs | ||||||
|  |  | ||||||
|  |     def pre_save(self, instance, add: bool): | ||||||
|  |         """Ran just before the model is saved, allows us to built | ||||||
|  |         the slug. | ||||||
|  |  | ||||||
|  |         Arguments: | ||||||
|  |             instance: | ||||||
|  |                 The model that is being saved. | ||||||
|  |  | ||||||
|  |             add: | ||||||
|  |                 Indicates whether this is a new entry | ||||||
|  |                 to the database or an update. | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             The localized slug that was generated. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         if not isinstance(instance, AtomicSlugRetryMixin): | ||||||
|  |             raise ImproperlyConfigured(( | ||||||
|  |                 'Model \'%s\' does not inherit from AtomicSlugRetryMixin. ' | ||||||
|  |                 'Without this, the LocalizedUniqueSlugField will not work.' | ||||||
|  |             ) % type(instance).__name__) | ||||||
|  |  | ||||||
|  |         slugs = LocalizedValue() | ||||||
|  |  | ||||||
|  |         for lang_code, _ in settings.LANGUAGES: | ||||||
|  |             value = self._get_populate_from_value( | ||||||
|  |                 instance, | ||||||
|  |                 self.populate_from, | ||||||
|  |                 lang_code | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             if not value: | ||||||
|  |                 continue | ||||||
|  |  | ||||||
|  |             slug = slugify(value, allow_unicode=True) | ||||||
|  |  | ||||||
|  |             # verify whether it's needed to re-generate a slug, | ||||||
|  |             # if not, re-use the same slug | ||||||
|  |             if instance.pk is not None: | ||||||
|  |                 current_slug = getattr(instance, self.name).get(lang_code) | ||||||
|  |                 if current_slug is not None: | ||||||
|  |                     stripped_slug = current_slug[0:current_slug.rfind('-')] | ||||||
|  |                     if slug == stripped_slug: | ||||||
|  |                         slugs.set(lang_code, current_slug) | ||||||
|  |                         continue | ||||||
|  |  | ||||||
|  |             if self.include_time: | ||||||
|  |                 slug += '-%d' % datetime.now().microsecond | ||||||
|  |  | ||||||
|  |             if instance.retries > 0: | ||||||
|  |                 # do not add another - if we already added time | ||||||
|  |                 if not self.include_time: | ||||||
|  |                     slug += '-' | ||||||
|  |                 slug += '%d' % instance.retries | ||||||
|  |  | ||||||
|  |             slugs.set(lang_code, slug) | ||||||
|  |  | ||||||
|  |         setattr(instance, self.name, slugs) | ||||||
|  |         return slugs | ||||||
| @@ -2,7 +2,7 @@ from django.conf import settings | |||||||
| from django.utils import translation | from django.utils import translation | ||||||
|  |  | ||||||
|  |  | ||||||
| class LocalizedValue: | class LocalizedValue(dict): | ||||||
|     """Represents the value of a :see:LocalizedField.""" |     """Represents the value of a :see:LocalizedField.""" | ||||||
|  |  | ||||||
|     def __init__(self, keys: dict=None): |     def __init__(self, keys: dict=None): | ||||||
| @@ -20,7 +20,7 @@ class LocalizedValue: | |||||||
|         else: |         else: | ||||||
|             for lang_code, _ in settings.LANGUAGES: |             for lang_code, _ in settings.LANGUAGES: | ||||||
|                 value = keys.get(lang_code) if keys else None |                 value = keys.get(lang_code) if keys else None | ||||||
|                 setattr(self, lang_code, value) |                 self.set(lang_code, value) | ||||||
|  |  | ||||||
|     def get(self, language: str=None) -> str: |     def get(self, language: str=None) -> str: | ||||||
|         """Gets the underlying value in the specified or |         """Gets the underlying value in the specified or | ||||||
| @@ -37,7 +37,7 @@ class LocalizedValue: | |||||||
|         """ |         """ | ||||||
|  |  | ||||||
|         language = language or settings.LANGUAGE_CODE |         language = language or settings.LANGUAGE_CODE | ||||||
|         return getattr(self, language, None) |         return super().get(language, None) | ||||||
|  |  | ||||||
|     def set(self, language: str, value: str): |     def set(self, language: str, value: str): | ||||||
|         """Sets the value in the specified language. |         """Sets the value in the specified language. | ||||||
| @@ -50,7 +50,8 @@ class LocalizedValue: | |||||||
|                 The value to set. |                 The value to set. | ||||||
|         """ |         """ | ||||||
|  |  | ||||||
|         setattr(self, language, value) |         self[language] = value | ||||||
|  |         self.__dict__.update(self) | ||||||
|         return self |         return self | ||||||
|  |  | ||||||
|     def deconstruct(self) -> dict: |     def deconstruct(self) -> dict: | ||||||
| @@ -76,7 +77,51 @@ class LocalizedValue: | |||||||
|  |  | ||||||
|         return value or '' |         return value or '' | ||||||
|  |  | ||||||
|  |     def __eq__(self, other): | ||||||
|  |         """Compares :paramref:self to :paramref:other for | ||||||
|  |         equality. | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             True when :paramref:self is equal to :paramref:other. | ||||||
|  |             And False when they are not. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         if not isinstance(other, type(self)): | ||||||
|  |             if isinstance(other, str): | ||||||
|  |                 return self.__str__() == other | ||||||
|  |             return False | ||||||
|  |  | ||||||
|  |         for lang_code, _ in settings.LANGUAGES: | ||||||
|  |             if self.get(lang_code) != other.get(lang_code): | ||||||
|  |                 return False | ||||||
|  |  | ||||||
|  |         return True | ||||||
|  |  | ||||||
|  |     def __ne__(self, other): | ||||||
|  |         """Compares :paramref:self to :paramerf:other for | ||||||
|  |         in-equality. | ||||||
|  |  | ||||||
|  |         Returns: | ||||||
|  |             True when :paramref:self is not equal to :paramref:other. | ||||||
|  |             And False when they are. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         return not self.__eq__(other) | ||||||
|  |  | ||||||
|  |     def __setattr__(self, language: str, value: str): | ||||||
|  |         """Sets the value for a language with the specified name. | ||||||
|  |  | ||||||
|  |         Arguments: | ||||||
|  |             language: | ||||||
|  |                 The language to set the value in. | ||||||
|  |  | ||||||
|  |             value: | ||||||
|  |                 The value to set. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         self.set(language, value) | ||||||
|  |  | ||||||
|     def __repr__(self):  # pragma: no cover |     def __repr__(self):  # pragma: no cover | ||||||
|         """Gets a textual representation of this object.""" |         """Gets a textual representation of this object.""" | ||||||
|  |  | ||||||
|         return 'LocalizedValue<%s> 0x%s' % (self.__dict__, id(self)) |         return 'LocalizedValue<%s> 0x%s' % (dict(self), id(self)) | ||||||
|   | |||||||
							
								
								
									
										38
									
								
								localized_fields/mixins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								localized_fields/mixins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | |||||||
|  | from django.db import transaction | ||||||
|  | from django.conf import settings | ||||||
|  | from django.db.utils import IntegrityError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AtomicSlugRetryMixin: | ||||||
|  |     """Makes :see:LocalizedUniqueSlugField work by retrying upon | ||||||
|  |     violation of the UNIQUE constraint.""" | ||||||
|  |  | ||||||
|  |     def save(self, *args, **kwargs): | ||||||
|  |         """Saves this model instance to the database.""" | ||||||
|  |  | ||||||
|  |         max_retries = getattr( | ||||||
|  |             settings, | ||||||
|  |             'LOCALIZED_FIELDS_MAX_RETRIES', | ||||||
|  |             100 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         if not hasattr(self, 'retries'): | ||||||
|  |             self.retries = 0 | ||||||
|  |  | ||||||
|  |         with transaction.atomic(): | ||||||
|  |             try: | ||||||
|  |                 return super().save(*args, **kwargs) | ||||||
|  |             except IntegrityError as ex: | ||||||
|  |                 # this is as retarded as it looks, there's no | ||||||
|  |                 # way we can put the retry logic inside the slug | ||||||
|  |                 # field class... we can also not only catch exceptions | ||||||
|  |                 # that apply to slug fields... so yea.. this is as | ||||||
|  |                 # retarded as it gets... i am sorry :( | ||||||
|  |                 if 'slug' not in str(ex): | ||||||
|  |                     raise ex | ||||||
|  |  | ||||||
|  |                 if self.retries >= max_retries: | ||||||
|  |                     raise ex | ||||||
|  |  | ||||||
|  |         self.retries += 1 | ||||||
|  |         return self.save() | ||||||
| @@ -1,12 +1,10 @@ | |||||||
| from django.db import models, transaction | from psqlextra.models import PostgresModel | ||||||
| from django.db.utils import IntegrityError |  | ||||||
| from django.conf import settings |  | ||||||
|  |  | ||||||
| from .fields import LocalizedField | from .fields import LocalizedField | ||||||
| from .localized_value import LocalizedValue | from .localized_value import LocalizedValue | ||||||
|  |  | ||||||
|  |  | ||||||
| class LocalizedModel(models.Model): | class LocalizedModel(PostgresModel): | ||||||
|     """A model that contains localized fields.""" |     """A model that contains localized fields.""" | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
| @@ -34,33 +32,3 @@ class LocalizedModel(models.Model): | |||||||
|                     value = LocalizedValue() |                     value = LocalizedValue() | ||||||
|  |  | ||||||
|             setattr(self, field.name, value) |             setattr(self, field.name, value) | ||||||
|  |  | ||||||
|     def save(self, *args, **kwargs): |  | ||||||
|         """Saves this model instance to the database.""" |  | ||||||
|  |  | ||||||
|         max_retries = getattr( |  | ||||||
|             settings, |  | ||||||
|             'LOCALIZED_FIELDS_MAX_RETRIES', |  | ||||||
|             100 |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         if not hasattr(self, 'retries'): |  | ||||||
|             self.retries = 0 |  | ||||||
|  |  | ||||||
|         with transaction.atomic(): |  | ||||||
|             try: |  | ||||||
|                 return super(LocalizedModel, self).save(*args, **kwargs) |  | ||||||
|             except IntegrityError as ex: |  | ||||||
|                 # this is as retarded as it looks, there's no |  | ||||||
|                 # way we can put the retry logic inside the slug |  | ||||||
|                 # field class... we can also not only catch exceptions |  | ||||||
|                 # that apply to slug fields... so yea.. this is as |  | ||||||
|                 # retarded as it gets... i am sorry :( |  | ||||||
|                 if 'slug' not in str(ex): |  | ||||||
|                     raise ex |  | ||||||
|  |  | ||||||
|                 if self.retries >= max_retries: |  | ||||||
|                     raise ex |  | ||||||
|  |  | ||||||
|         self.retries += 1 |  | ||||||
|         return self.save() |  | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								requirements/base.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								requirements/base.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | django-postgres-extra==1.4 | ||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | -r base.txt | ||||||
|  |  | ||||||
| coverage==4.2 | coverage==4.2 | ||||||
| Django==1.10.2 | Django==1.10.2 | ||||||
| django-autoslug==1.9.3 | django-autoslug==1.9.3 | ||||||
|   | |||||||
| @@ -11,7 +11,7 @@ DATABASES = { | |||||||
|     'default': dj_database_url.config(default='postgres:///localized_fields') |     'default': dj_database_url.config(default='postgres:///localized_fields') | ||||||
| } | } | ||||||
|  |  | ||||||
| DATABASES['default']['ENGINE'] = 'localized_fields.db_backend' | DATABASES['default']['ENGINE'] = 'psqlextra.backend' | ||||||
|  |  | ||||||
| LANGUAGE_CODE = 'en' | LANGUAGE_CODE = 'en' | ||||||
| LANGUAGES = ( | LANGUAGES = ( | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								setup.py
									
									
									
									
									
								
							| @@ -7,7 +7,7 @@ with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: | |||||||
|  |  | ||||||
| setup( | setup( | ||||||
|     name='django-localized-fields', |     name='django-localized-fields', | ||||||
|     version='2.5', |     version='3.5', | ||||||
|     packages=find_packages(), |     packages=find_packages(), | ||||||
|     include_package_data=True, |     include_package_data=True, | ||||||
|     license='MIT License', |     license='MIT License', | ||||||
| @@ -17,6 +17,9 @@ setup( | |||||||
|     author='Sector Labs', |     author='Sector Labs', | ||||||
|     author_email='open-source@sectorlabs.ro', |     author_email='open-source@sectorlabs.ro', | ||||||
|     keywords=['django', 'localized', 'language', 'models', 'fields'], |     keywords=['django', 'localized', 'language', 'models', 'fields'], | ||||||
|  |     install_requires=[ | ||||||
|  |         'django-postgres-extra>=1.4' | ||||||
|  |     ], | ||||||
|     classifiers=[ |     classifiers=[ | ||||||
|         'Environment :: Web Environment', |         'Environment :: Web Environment', | ||||||
|         'Framework :: Django', |         'Framework :: Django', | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ from django.db import connection, migrations | |||||||
| from django.db.migrations.executor import MigrationExecutor | from django.db.migrations.executor import MigrationExecutor | ||||||
| from django.contrib.postgres.operations import HStoreExtension | from django.contrib.postgres.operations import HStoreExtension | ||||||
|  |  | ||||||
| from localized_fields import LocalizedModel | from localized_fields import LocalizedModel, AtomicSlugRetryMixin | ||||||
|  |  | ||||||
|  |  | ||||||
| def define_fake_model(name='TestModel', fields=None): | def define_fake_model(name='TestModel', fields=None): | ||||||
| @@ -14,7 +14,7 @@ def define_fake_model(name='TestModel', fields=None): | |||||||
|  |  | ||||||
|     if fields: |     if fields: | ||||||
|         attributes.update(fields) |         attributes.update(fields) | ||||||
|     model = type(name, (LocalizedModel,), attributes) |     model = type(name, (AtomicSlugRetryMixin,LocalizedModel,), attributes) | ||||||
|  |  | ||||||
|     return model |     return model | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,87 +0,0 @@ | |||||||
| from unittest import mock |  | ||||||
|  |  | ||||||
| from django.db import connection |  | ||||||
| from django.apps import apps |  | ||||||
| from django.conf import settings |  | ||||||
| from django.test import TestCase |  | ||||||
| from django.db.backends.base.schema import BaseDatabaseSchemaEditor |  | ||||||
|  |  | ||||||
| from localized_fields import LocalizedField, get_language_codes |  | ||||||
|  |  | ||||||
| from .fake_model import define_fake_model |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class DBBackendTestCase(TestCase): |  | ||||||
|     """Tests the custom database back-end.""" |  | ||||||
|  |  | ||||||
|     @staticmethod |  | ||||||
|     def test_hstore_extension_enabled(): |  | ||||||
|         """Tests whether the `hstore` extension was |  | ||||||
|         enabled automatically.""" |  | ||||||
|  |  | ||||||
|         with connection.cursor() as cursor: |  | ||||||
|             cursor.execute(( |  | ||||||
|                 'SELECT count(*) FROM pg_extension ' |  | ||||||
|                 'WHERE extname = \'hstore\'' |  | ||||||
|             )) |  | ||||||
|  |  | ||||||
|             assert cursor.fetchone()[0] == 1 |  | ||||||
|  |  | ||||||
|     @classmethod |  | ||||||
|     def test_migration_create_drop_model(cls): |  | ||||||
|         """Tests whether models containing a :see:LocalizedField |  | ||||||
|         with a `uniqueness` constraint get created properly, |  | ||||||
|         with the contraints in the database.""" |  | ||||||
|  |  | ||||||
|         model = define_fake_model('NewModel', { |  | ||||||
|             'title': LocalizedField(uniqueness=get_language_codes()) |  | ||||||
|         }) |  | ||||||
|  |  | ||||||
|         # create the model in db and verify the indexes are being created |  | ||||||
|         with mock.patch.object(BaseDatabaseSchemaEditor, 'execute') as execute: |  | ||||||
|             with connection.schema_editor() as schema_editor: |  | ||||||
|                 schema_editor.create_model(model) |  | ||||||
|  |  | ||||||
|             create_index_calls = [ |  | ||||||
|                 call for call in execute.mock_calls if 'CREATE UNIQUE INDEX' in str(call) |  | ||||||
|             ] |  | ||||||
|  |  | ||||||
|             assert len(create_index_calls) == len(settings.LANGUAGES) |  | ||||||
|  |  | ||||||
|         # delete the model in the db and verify the indexes are being deleted |  | ||||||
|         with mock.patch.object(BaseDatabaseSchemaEditor, 'execute') as execute: |  | ||||||
|             with connection.schema_editor() as schema_editor: |  | ||||||
|                 schema_editor.delete_model(model) |  | ||||||
|  |  | ||||||
|             drop_index_calls = [ |  | ||||||
|                 call for call in execute.mock_calls if 'DROP INDEX' in str(call) |  | ||||||
|             ] |  | ||||||
|  |  | ||||||
|             assert len(drop_index_calls) == len(settings.LANGUAGES) |  | ||||||
|  |  | ||||||
|     @classmethod |  | ||||||
|     def test_migration_alter_field(cls): |  | ||||||
|         """Tests whether the back-end correctly removes and |  | ||||||
|         adds `uniqueness` constraints when altering a :see:LocalizedField.""" |  | ||||||
|  |  | ||||||
|         define_fake_model('ExistingModel', { |  | ||||||
|             'title': LocalizedField(uniqueness=get_language_codes()) |  | ||||||
|         }) |  | ||||||
|  |  | ||||||
|         app_config = apps.get_app_config('tests') |  | ||||||
|  |  | ||||||
|         with mock.patch.object(BaseDatabaseSchemaEditor, 'execute') as execute: |  | ||||||
|             with connection.schema_editor() as schema_editor: |  | ||||||
|                 dynmodel = app_config.get_model('ExistingModel') |  | ||||||
|                 schema_editor.alter_field( |  | ||||||
|                     dynmodel, |  | ||||||
|                     dynmodel._meta.fields[1], |  | ||||||
|                     dynmodel._meta.fields[1] |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|             index_calls = [ |  | ||||||
|                 call for call in execute.mock_calls |  | ||||||
|                 if 'INDEX' in str(call) and 'title' in str(call) |  | ||||||
|             ] |  | ||||||
|  |  | ||||||
|             assert len(index_calls) == len(settings.LANGUAGES) * 2 |  | ||||||
| @@ -100,6 +100,19 @@ class LocalizedValueTestCase(TestCase): | |||||||
|             translation.activate(language) |             translation.activate(language) | ||||||
|             assert str(localized_value) == value |             assert str(localized_value) == value | ||||||
|  |  | ||||||
|  |     @staticmethod | ||||||
|  |     def test_eq(): | ||||||
|  |         """Tests whether the __eq__ operator | ||||||
|  |         of :see:LocalizedValue works properly.""" | ||||||
|  |  | ||||||
|  |         a = LocalizedValue({'en': 'a', 'ar': 'b'}) | ||||||
|  |         b = LocalizedValue({'en': 'a', 'ar': 'b'}) | ||||||
|  |  | ||||||
|  |         assert a == b | ||||||
|  |  | ||||||
|  |         b.en = 'b' | ||||||
|  |         assert a != b | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def test_str_fallback(): |     def test_str_fallback(): | ||||||
|         """Tests whether the :see:LocalizedValue |         """Tests whether the :see:LocalizedValue | ||||||
|   | |||||||
| @@ -1,11 +1,12 @@ | |||||||
|  | import copy | ||||||
|  |  | ||||||
| from django import forms | from django import forms | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.test import TestCase | from django.test import TestCase | ||||||
| from django.db.utils import IntegrityError | from django.db.utils import IntegrityError | ||||||
| from django.utils.text import slugify |  | ||||||
|  |  | ||||||
| from localized_fields import (LocalizedField, LocalizedAutoSlugField, | from localized_fields import (LocalizedField, LocalizedAutoSlugField, | ||||||
|                               LocalizedMagicSlugField) |                               LocalizedUniqueSlugField) | ||||||
|  | from django.utils.text import slugify | ||||||
|  |  | ||||||
| from .fake_model import get_fake_model | from .fake_model import get_fake_model | ||||||
|  |  | ||||||
| @@ -31,10 +32,10 @@ class LocalizedSlugFieldTestCase(TestCase): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         cls.MagicSlugModel = get_fake_model( |         cls.MagicSlugModel = get_fake_model( | ||||||
|             'LocalizedMagicSlugFieldTestModel', |             'LocalizedUniqueSlugFieldTestModel', | ||||||
|             { |             { | ||||||
|                 'title': LocalizedField(), |                 'title': LocalizedField(), | ||||||
|                 'slug': LocalizedMagicSlugField(populate_from='title') |                 'slug': LocalizedUniqueSlugField(populate_from='title') | ||||||
|             } |             } | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
| @@ -43,7 +44,7 @@ class LocalizedSlugFieldTestCase(TestCase): | |||||||
|         cls._test_populate(cls.AutoSlugModel) |         cls._test_populate(cls.AutoSlugModel) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def test_populate_magic(cls): |     def test_populate_unique(cls): | ||||||
|         cls._test_populate(cls.MagicSlugModel) |         cls._test_populate(cls.MagicSlugModel) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
| @@ -51,7 +52,7 @@ class LocalizedSlugFieldTestCase(TestCase): | |||||||
|         cls._test_populate_multiple_languages(cls.AutoSlugModel) |         cls._test_populate_multiple_languages(cls.AutoSlugModel) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def test_populate_multiple_languages_magic(cls): |     def test_populate_multiple_languages_unique(cls): | ||||||
|         cls._test_populate_multiple_languages(cls.MagicSlugModel) |         cls._test_populate_multiple_languages(cls.MagicSlugModel) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
| @@ -59,14 +60,62 @@ class LocalizedSlugFieldTestCase(TestCase): | |||||||
|         cls._test_unique_slug(cls.AutoSlugModel) |         cls._test_unique_slug(cls.AutoSlugModel) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def test_unique_slug_magic(cls): |     def test_unique_slug_unique(cls): | ||||||
|         cls._test_unique_slug(cls.MagicSlugModel) |         cls._test_unique_slug(cls.MagicSlugModel) | ||||||
|  |  | ||||||
|     def test_unique_slug_magic_max_retries(self): |     @staticmethod | ||||||
|         """Tests whether the magic slug implementation doesn't |     def test_unique_slug_with_time(): | ||||||
|  |         """Tests whether the primary key is included in | ||||||
|  |         the slug when the 'use_pk' option is enabled.""" | ||||||
|  |  | ||||||
|  |         title = 'myuniquetitle' | ||||||
|  |  | ||||||
|  |         PkModel = get_fake_model( | ||||||
|  |             'PkModel', | ||||||
|  |             { | ||||||
|  |                 'title': LocalizedField(), | ||||||
|  |                 'slug': LocalizedUniqueSlugField(populate_from='title', include_time=True) | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         obj = PkModel() | ||||||
|  |         obj.title.en = title | ||||||
|  |         obj.save() | ||||||
|  |  | ||||||
|  |         assert obj.slug.en.startswith('%s-' % title) | ||||||
|  |  | ||||||
|  |     @classmethod | ||||||
|  |     def test_uniue_slug_no_change(cls): | ||||||
|  |         """Tests whether slugs are not re-generated if not needed.""" | ||||||
|  |  | ||||||
|  |         NoChangeSlugModel = get_fake_model( | ||||||
|  |             'NoChangeSlugModel', | ||||||
|  |             { | ||||||
|  |                 'title': LocalizedField(), | ||||||
|  |                 'slug': LocalizedUniqueSlugField(populate_from='title', include_time=True) | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         title = 'myuniquetitle' | ||||||
|  |  | ||||||
|  |         obj = NoChangeSlugModel() | ||||||
|  |         obj.title.en = title | ||||||
|  |         obj.title.nl = title | ||||||
|  |         obj.save() | ||||||
|  |  | ||||||
|  |         old_slug_en = copy.deepcopy(obj.slug.en) | ||||||
|  |         old_slug_nl = copy.deepcopy(obj.slug.nl) | ||||||
|  |         obj.title.nl += 'beer' | ||||||
|  |         obj.save() | ||||||
|  |  | ||||||
|  |         assert old_slug_en == obj.slug.en | ||||||
|  |         assert old_slug_nl != obj.slug.nl | ||||||
|  |  | ||||||
|  |     def test_unique_slug_unique_max_retries(self): | ||||||
|  |         """Tests whether the unique slug implementation doesn't | ||||||
|         try to find a slug forever and gives up after a while.""" |         try to find a slug forever and gives up after a while.""" | ||||||
|  |  | ||||||
|         title = 'mymagictitle' |         title = 'myuniquetitle' | ||||||
|  |  | ||||||
|         obj = self.MagicSlugModel() |         obj = self.MagicSlugModel() | ||||||
|         obj.title.en = title |         obj.title.en = title | ||||||
| @@ -83,7 +132,7 @@ class LocalizedSlugFieldTestCase(TestCase): | |||||||
|         cls._test_unique_slug_utf(cls.AutoSlugModel) |         cls._test_unique_slug_utf(cls.AutoSlugModel) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def test_unique_slug_utf_magic(cls): |     def test_unique_slug_utf_unique(cls): | ||||||
|         cls._test_unique_slug_utf(cls.MagicSlugModel) |         cls._test_unique_slug_utf(cls.MagicSlugModel) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
| @@ -91,16 +140,16 @@ class LocalizedSlugFieldTestCase(TestCase): | |||||||
|         cls._test_deconstruct(LocalizedAutoSlugField) |         cls._test_deconstruct(LocalizedAutoSlugField) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def test_deconstruct_magic(cls): |     def test_deconstruct_unique(cls): | ||||||
|         cls._test_deconstruct(LocalizedMagicSlugField) |         cls._test_deconstruct(LocalizedUniqueSlugField) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def test_formfield_auto(cls): |     def test_formfield_auto(cls): | ||||||
|         cls._test_formfield(LocalizedAutoSlugField) |         cls._test_formfield(LocalizedAutoSlugField) | ||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def test_formfield_magic(cls): |     def test_formfield_unique(cls): | ||||||
|         cls._test_formfield(LocalizedMagicSlugField) |         cls._test_formfield(LocalizedUniqueSlugField) | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def _test_populate(model): |     def _test_populate(model): | ||||||
| @@ -130,7 +179,7 @@ class LocalizedSlugFieldTestCase(TestCase): | |||||||
|     def _test_unique_slug(model): |     def _test_unique_slug(model): | ||||||
|         """Tests whether unique slugs are properly generated.""" |         """Tests whether unique slugs are properly generated.""" | ||||||
|  |  | ||||||
|         title = 'mymagictitle' |         title = 'myuniquetitle' | ||||||
|  |  | ||||||
|         obj = model() |         obj = model() | ||||||
|         obj.title.en = title |         obj.title.en = title | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user