Compare commits

..

349 Commits
2.5 ... master

Author SHA1 Message Date
Swen Kooij
d9913f1eec
Bump version to 6.8b5 2024-07-02 07:45:57 +03:00
Swen Kooij
3b973361d3 Display pointer cursor when hovering tabs 2024-07-02 07:38:11 +03:00
seroy
4c5d45f25a Fix tabs displaying in Django 5.x 2024-07-02 07:38:11 +03:00
Swen Kooij
e6527fff4c Don't double key transform in lookups
In Django 4.2, `process_lhs()` is called for IsNull lookups,
which is how this bug came to appear. It was already there
and could be reproduced on Django 3.2.

The issue is when `LocalizedRef` is combined with a localized
lookup. The lookup is unaware of the existing localized ref
and adds another transform on top. This results in the
expression becoming:

```
field->'en'->'en'
```
2024-07-02 07:10:41 +03:00
Swen Kooij
25259b8469 Silence our own deprecation warning when running the tests 2024-07-02 07:10:29 +03:00
Swen Kooij
84beade9e0 Make Django tests use USE_TZ = True to silence Django 5.x deprecation warning 2024-07-02 07:10:29 +03:00
Swen Kooij
150f671115 Pin click version for Black 2024-07-02 07:10:29 +03:00
Roman Selivanov
c35844b471 Fix type hint for required kwarg 2024-07-02 07:10:29 +03:00
Tudor Văran
5bb16af6a4
Support for Django 4 (#101)
* Support for Django 4

* Change pytest-django version

* Change psycopg2 version

* Change pytest version

* Change tox version

* Change pytest-cov version

* Update circle ci jobs

* Fix ci jobs

* Update ci to postgres 12

* Allow Django 5.0

* Bump Django min version to 3.2

* Fix CI to account for >= 3.2 requirement

* Fix quote

* Remove python 3.12 for now due to distutils removal

* Remove Django upper bound

* Add back python3.6,3.7 wfs

* Downgrade dj-database-url

* Fix isnull issue from Django 4
2024-06-04 12:49:13 +03:00
Bogdan Hopulele
a55986d28c Bump version number to 6.8b3 2024-03-01 19:53:18 +02:00
Bogdan Hopulele
bcd1f1cc1a Relax "deprecation" dependency 2024-03-01 19:46:45 +02:00
GRazvan12
463c415be2 Add deconstruct method for LocalizedBleachField for it to be recognized in django migrations 2024-01-23 16:45:57 +01:00
Swen Kooij
fbc1eec754 Bump version number to 6.8b1 2024-01-23 13:55:51 +01:00
Gherman Razvan
911251ebaa
Prevent LocalizedBleachField from escaping values (#97) 2024-01-23 13:52:37 +01:00
Swen Kooij
a9906dd159 Bump version number to v6.7 2023-08-10 14:44:20 +02:00
Gherman Razvan
a66b3492cd
Add LocalizedBooleanField (#93) 2023-08-10 14:33:47 +02:00
Swen Kooij
bc494694f5 Rename default test database to localized_fields 2023-08-09 11:26:23 +02:00
Swen Kooij
b0cfaea2b4 Fix instructions for creating Postgres super user
It makes no sense to create a user named `psqlextra`
but then connect with a user named `localized_fields`.
2023-08-09 11:25:48 +02:00
Swen Kooij
8c7d0773f7 Bump version number to v6.6 2021-11-08 15:08:48 +02:00
Swen Kooij
cc911d4909 LocalizedAutoSlugField should only warn about deprecation if used 2021-11-08 15:08:08 +02:00
Swen Kooij
0f30cc1493 Ignore build/ folder 2021-11-08 14:39:32 +02:00
Swen Kooij
0d1e9510cf Fix broken Django icon in README 2021-11-08 14:39:20 +02:00
Swen Kooij
25c1c24ccb Declare support for Django 3.2 and Python 3.9 2021-11-08 14:38:01 +02:00
Swen Kooij
bd3005a7e9 Bump version number to 6.5 2021-11-08 14:36:55 +02:00
Swen Kooij
7902d8225a Do not set default_app_config for Django 3.2 and newer
See: See: https://docs.djangoproject.com/en/3.2/releases/3.2/#what-s-new-in-django-3-2
2021-11-08 14:36:45 +02:00
Swen Kooij
f024e4feb5 Bump version to v6.4 2021-03-22 07:47:23 +02:00
Swen Kooij
92cb5e8b1f Add LocalizedValue.is_empty() 2021-03-22 07:47:00 +02:00
Swen Kooij
5c298ef13e Bump version number to 6.3 2021-03-13 14:01:51 +02:00
Swen Kooij
1b3e5989d3 LocalizedUniqueSlugField should properly deconstruct 'enabled' flag 2021-03-13 13:45:22 +02:00
Swen Kooij
d57f9a41bb Mark pytest as a third-party library for isort
Not sure why it doesn't get that.
2021-03-13 13:32:01 +02:00
Swen Kooij
bd8924224e Add flag to disable LocalizedUniqueSlugField 2021-03-13 13:24:36 +02:00
Swen Kooij
62e1e805c7 Bump version number to v6.2 2020-12-07 10:49:36 +02:00
Swen Kooij
afc39745bf Bump version number to 6.2rc1 2020-11-30 12:28:03 +02:00
Swen Kooij
1406954dec
Merge pull request #91 from SectorLabs/immutable-slugs
Add a flag to make LocalizedUniqueSlugField immutable
2020-11-30 12:26:47 +02:00
Swen Kooij
afb94ecf66 Add a flag to make LocalizedUniqueSlugField immutable 2020-11-27 16:36:23 +02:00
Swen Kooij
7ba0ff60ec Bump version number to 6.1 2020-11-25 13:27:11 +02:00
Swen Kooij
63fb79b02b
Merge pull request #90 from SectorLabs/prevent-memory-leak
Prevent accumulating redundant data
2020-11-25 13:24:41 +02:00
Cristi Ingineru
8ed09f712d Prevent accumulating redundant data 2020-11-25 12:00:11 +02:00
Swen Kooij
e10ed0e693 Bump version number to 6.0 2020-10-07 12:32:24 +03:00
seroy
da4b1701c7 add tests for ActiveRef and TranslatedRef lookups 2020-10-07 12:32:22 +03:00
seroy
c9ae71aec7 introduce ActiveRef and TranslatedRef lookups 2020-10-07 11:56:33 +03:00
Swen Kooij
ce6ed4a513 Upgrade django-postgres-extra for Django 3.1 compatibility 2020-10-07 11:54:11 +03:00
Swen Kooij
9f99e2cdb0 Declare support for Python 3.9 and Django 3.1 in README 2020-10-07 11:53:10 +03:00
Swen Kooij
da7e39c071 Set junit_family=legacy to keep the same coverage report 2020-10-07 11:52:24 +03:00
Swen Kooij
5a9ff191d4 Use force_str() over force_text()
force_text() is going to be removed in Django 4.0
It's just an alias for now.
2020-10-07 11:52:24 +03:00
Swen Kooij
c06d09f7d8 Run tests against Django 3.1 2020-10-07 11:52:22 +03:00
Swen Kooij
fd2d85064f Run tests against officially released Python 3.9 2020-10-07 11:47:59 +03:00
Swen Kooij
44aefb4e0b Bump version number to 6.0b5 2020-04-29 12:58:49 +03:00
Swen Kooij
f982eac7d8
Merge pull request #85 from SectorLabs/default-callables-admin-form
Fix callable default usage in admin forms
2020-04-29 12:53:13 +03:00
Swen Kooij
f775883790
Merge pull request #83 from belkka/patch-1
Fix dead link to django-postgres-extra docs
2020-04-29 12:52:15 +03:00
tudorvaran
7f48903137 Change from docstring comment to normal 2020-04-29 12:16:25 +03:00
tudorvaran
3bf4435622 Disable show_hidden_initial for localized forms 2020-04-28 21:23:03 +03:00
tudorvaran
77e8807876 Revert "Fix callable default usage in admin forms"
This reverts commit f807212c
2020-04-28 21:16:15 +03:00
tudorvaran
f807212cf3 Fix callable default usage in admin forms 2020-04-28 18:31:15 +03:00
belkka
dbd337520e
Fix dead link to django-postgres-extra docs 2020-03-30 15:43:13 +03:00
Swen Kooij
74c119d32a Bump version number to 6.0b4 2020-03-10 13:15:22 +02:00
Alexandru Arnăutu
36a2dda2b1
Add support for FloatField on v6 (#81)
* Add LocalizedFloatValue

* Add LocalizedFloatField

* Add tests for float field

* Create LocalizedNumericValue with __int__ and __float__ methods

* Format and lint code
2020-03-10 12:49:46 +02:00
Alexandru Arnăutu
98330ad38c
Update README.md (#82) 2020-03-10 12:49:35 +02:00
Alexandru Arnautu
8968b0c7a8
Format and lint code 2020-03-10 09:33:34 +02:00
Alexandru Arnautu
5e1d46669c Create LocalizedNumericValue with __int__ and __float__ methods 2020-03-10 09:28:52 +02:00
Alexandru Arnautu
0f1d6636f6 Add tests for float field 2020-03-10 09:27:42 +02:00
Alexandru Arnautu
5ed1a1219d Add LocalizedFloatField 2020-03-10 09:27:31 +02:00
Alexandru Arnautu
0d9ec6385c Add LocalizedFloatValue 2020-03-10 09:26:00 +02:00
Swen Kooij
21a42a383c Bump version number to 6.0b3 2020-02-17 12:58:42 +02:00
Swen Kooij
701114c20e
Merge pull request #78 from SectorLabs/default-values-callables
Accept callables as values in fields
2020-02-17 12:58:02 +02:00
tudorvaran
bc63c57598 LocalizedIntegerField not LocalizedIntegerValue 2020-02-12 17:11:14 +02:00
tudorvaran
905bfd4353 Format with black 2020-02-12 17:05:17 +02:00
tudorvaran
a9a5add303 Manual code format 2020-02-12 16:55:39 +02:00
tudorvaran
598b8ca65e Add test 2020-02-12 16:51:34 +02:00
tudorvaran
47367da401 Accept callables as values in fields 2020-02-12 16:37:44 +02:00
Swen Kooij
fbaef6e1ac Add missing long_description_content_type to setup.py 2020-01-06 17:15:37 +01:00
Swen Kooij
49d88af76a Python 3.6, 3.9 and Django 3.0 compatibility 2020-01-06 16:51:30 +01:00
Swen Kooij
53d7cd0c66
Merge pull request #74 from jar3b/django-30-support
Django 3.0 support
2020-01-06 16:44:59 +01:00
jar3b
311843f647 feat: add django 3.0 support in tests and readme 2019-12-15 01:17:56 +03:00
jar3b
769066a461 fix: remove django.utils.six import and usage due to Django 3.0 dropped support for it 2019-12-14 23:12:01 +03:00
Swen Kooij
e5ea632f24 Fix typo in the constraints docs 2019-10-21 17:39:02 +03:00
Swen Kooij
801fa477f1 Remove admin widget image (is in docs now) 2019-10-20 18:29:49 +03:00
Swen Kooij
7c6d3b026d Ensure release is not compatible with django-postgres-extra 3.0 2019-10-20 18:12:20 +03:00
Swen Kooij
65d0811995 Store tests results for python 3.8 job 2019-10-20 18:10:21 +03:00
Swen Kooij
92b1dce239 Set LOCALIZED_FIELDS_EXPERIMENTAL to True by default 2019-10-20 18:05:06 +03:00
Swen Kooij
2bcab4d83a Always run tests against python 3.8 2019-10-20 18:02:58 +03:00
Swen Kooij
82a6efcffe Update links to RTD pages in README 2019-10-20 17:55:51 +03:00
Swen Kooij
a8549d6ad3 Ignore pip-wheel-metadata dir 2019-10-20 17:55:00 +03:00
Swen Kooij
7d4e40647a Update example database URL in README 2019-10-20 17:55:00 +03:00
Swen Kooij
4a81363853 django_bleach is a known third party library 2019-10-20 17:54:57 +03:00
Swen Kooij
e6ce2da161 Replace README with Markdown version 2019-10-20 17:48:49 +03:00
Swen Kooij
3bf7926c57 Split analysis and test dependencies 2019-10-20 17:48:03 +03:00
Swen Kooij
8c78e5f978 Add readthedocs config 2019-10-20 17:23:26 +03:00
Swen Kooij
cdf1831d35 Add sphinx based docs 2019-10-20 17:11:33 +03:00
Swen Kooij
7bf0311306 Add a language argument to LocalizedValue.translate(..) 2019-10-20 16:49:33 +03:00
Swen Kooij
e5dcc1b492 Fix #72: LocalizedIntegerField should sort numerically, not lexicographically 2019-10-19 15:48:29 +03:00
Swen Kooij
696050cf6b Fix warning in tests because test class is prefixed with Test 2019-10-19 14:11:41 +03:00
Swen Kooij
0f3ab6af7a Add more keywords 2019-10-19 14:08:25 +03:00
Swen Kooij
71cdeef7a3 Run tests against python 3.8 2019-10-19 14:06:01 +03:00
Swen Kooij
90d2e1fc57 Rename lint job to analysis 2019-10-19 13:13:59 +03:00
Swen Kooij
e56a0697b3 Add back dj-database-url, need it for CI 2019-10-19 13:12:52 +03:00
Swen Kooij
19f0ddb336 Fix some flake8/pycodestyle issues 2019-10-19 13:04:53 +03:00
Swen Kooij
2f7314d105 Put requirements in setup.py 2019-10-19 12:51:51 +03:00
Swen Kooij
2cb80431cc Use pytest for running tests 2019-10-19 12:48:32 +03:00
Swen Kooij
7cdd1f4490 Re-format all files 2019-10-19 12:44:41 +03:00
Swen Kooij
4ee1a5f487 Set up setup.py commands for auto formatting and linting 2019-10-19 12:41:06 +03:00
Swen Kooij
eb2d166dbe Set up configs for flake8, pep8, black and doc strings formatter 2019-10-19 12:40:19 +03:00
Swen Kooij
76098141e9 Set up dependencies for auto formatting etc 2019-10-19 12:40:16 +03:00
Swen Kooij
3c9251b45a Remove dependency on dj_database_url 2019-10-19 12:33:09 +03:00
Swen Kooij
1fad9fd3b1 Remove duplicate django-postgres-extra dependency 2019-10-19 12:30:57 +03:00
Swen Kooij
fa8373cafe Deprecate Python 3.5, 3.6 and Django 1.11 2019-10-14 13:02:05 +03:00
Swen Kooij
a59706fd95
Merge pull request #70 from umazalakain/bump-deprecation
The deprecation package removes its unnecessary unittest2 dependency
2019-10-08 08:57:28 +03:00
Swen Kooij
39495da918 Add test for falling back to default value during an update 2019-10-06 21:16:40 +03:00
Uma Zalakain
54ad6eb434
The deprecation package removes its unnecessary unittest2 dependency 2019-09-13 21:03:31 +02:00
Swen Kooij
893fe0f5ab Bump version to 5.4 2019-08-14 08:58:08 +03:00
Swen Kooij
3de1492a58 Use collections.abc.Iterable instead of collections.Iterable
The latter is going to be removed after Python 3.8
2019-08-14 08:57:05 +03:00
Swen Kooij
946e9a67c4 Bump version to 5.3 2019-06-28 08:31:31 +03:00
Swen Kooij
36f6e946b0 Attempt at reducing deprecation warning spam 2019-06-27 15:15:44 +03:00
Swen Kooij
909ebfee69 Bump version number to 5.2 2019-06-27 14:53:58 +03:00
Swen Kooij
95284e6fd0 Add extra step to set up instructions about applying migrations 2019-06-27 13:42:42 +03:00
Swen Kooij
e84a5e4ff1 Bump version to 5.1 2019-06-27 13:37:28 +03:00
Swen Kooij
472c7bbc41 Remove dead travis-ci configuration 2019-06-27 13:36:23 +03:00
Swen Kooij
8cc50889ec Set version back to 5.0a11, it was never released 2019-06-27 13:30:19 +03:00
Swen Kooij
8494615d05 Upgrade django-postgres-extra to 1.22 2019-06-27 13:28:29 +03:00
Swen Kooij
f0541c047b Bump version number to 5.0a12 2019-06-27 13:27:59 +03:00
Swen Kooij
84658f6010 Bump version number to 5.0a10 2019-06-05 23:26:47 +03:00
Swen Kooij
0e29871458
Merge pull request #62 from GabLeRoux/patch-1
Remove extra dot in example integration code
2019-06-05 23:12:43 +03:00
Swen Kooij
ffe235d3ac
Merge pull request #66 from martinsvoboda/admin-widget-image
Added the image of admin widget
2019-06-05 23:12:25 +03:00
Swen Kooij
d7db2c58c0
Merge pull request #68 from MELScience/fix-django-22
update to support Django 2.2
2019-06-05 23:11:38 +03:00
Dmitry Groshev
6a7545a910 update to support Django 2.2 2019-05-30 13:11:33 +01:00
Martin Svoboda
11bf4ee88a Added the image of admin widget 2019-05-26 11:12:45 +02:00
Gabriel Le Breton
d3223eca53
Remove extra dot in example integration code 2019-04-23 11:12:29 -04:00
Swen Kooij
f0ac0f7f25 Merge branch 'bump-version' 2019-02-21 12:54:04 +02:00
Swen Kooij
93ffce557c Bump version number to 5.0a9 2019-02-21 12:50:58 +02:00
Swen Kooij
7c432baec7 Upgrade django-postgres-extra to 1.21a16 2019-02-21 12:50:46 +02:00
Swen Kooij
476a20ba88
Merge pull request #61 from SectorLabs/bump-version
Bump version number to 5.0a8
2019-02-21 12:47:15 +02:00
Swen Kooij
ad99b77bcd Bump version number to 5.0a8 2019-02-21 12:34:22 +02:00
Swen Kooij
151250505d
Merge pull request #60 from AdrianMuntean/error_on_empty_localized_integer_field
Return empty string in case the LocalizedIntegerField is null
2019-02-21 12:31:50 +02:00
Adrian Muntean
d8b872758c Return empty string in case of None 2019-02-20 12:26:04 +02:00
Adrian Muntean
a0ca977cab Set None in case the LocalizedIntegerField is null
In case the LocalizedIntegerField is null in the DB then it must explicitly be set to None,
otherwise it will yield TypeError: __str__ returned non-string
2019-02-14 14:48:38 +02:00
Swen Kooij
eb2cb6b244 Bump version number to 5.0a7 2019-01-14 11:00:20 +02:00
Swen Kooij
ed15fb0079 Upgrade django-postgres-extra>=1.21a15 2019-01-14 11:00:12 +02:00
Swen Kooij
f59904f8ea Bump version number to 5.0a6 2019-01-11 15:03:19 +02:00
Swen Kooij
f20966d6d2 Fix tests not passing for Django 2.X 2019-01-11 15:02:22 +02:00
Swen Kooij
25417b5815
Merge pull request #56 from sliverc/user_defined_pk_descriptor
Avoid DoesNotExist error when creating model with user defined pk
2019-01-11 14:47:33 +02:00
Swen Kooij
abd1587ca0
Merge pull request #54 from sliverc/query_by_active_lang
Add support for localized query lookups
2019-01-11 14:47:18 +02:00
Swen Kooij
60fc79e9ff
Merge pull request #57 from velrest/master
Fix typo in documentation for clean
2019-01-11 14:46:21 +02:00
Swen Kooij
ca470fc577
Merge pull request #49 from MELScience/admin-fix
Add tests for LocalizedFieldsAdminMixin
2019-01-11 14:46:10 +02:00
Swen Kooij
fac1a595aa
Merge pull request #55 from sliverc/hstore_extension
Enable HStore extension for localized fields to work
2019-01-11 14:44:05 +02:00
Swen Kooij
d66f5085a8
Merge branch 'master' into hstore_extension 2019-01-11 14:40:38 +02:00
Swen Kooij
c6e8321ae7 Add CircleCI badge to README 2019-01-11 14:40:27 +02:00
Swen Kooij
b2f50ec82b Convert to use CircleCI and run tests against Django 2.1/Python 3.7 2019-01-11 14:37:03 +02:00
Oliver Sauder
ff836836bf Add support for localized query look ups 2018-12-03 09:45:08 +01:00
Swen Kooij
acf8867974
Merge pull request #52 from SectorLabs/localized-integer-field-widget
Add LocalizedIntegerFieldWidget
2018-09-24 15:16:55 +03:00
Cristi Ingineru
4922a1b93f self.translate() 2018-09-24 12:57:46 +03:00
Jonas Cosandey
d308e773cf fix typo 2018-09-12 14:27:44 +02:00
Oliver Sauder
b3b88d6d28 Avoid does not exist error when creating model with user defined pk 2018-09-10 12:05:46 +02:00
Oliver Sauder
6c902229ce Enable HStore extension for localized fields to work 2018-08-27 15:07:41 +02:00
Cristi Ingineru
4f83cbf4ed Add LocalizedIntegerFieldWidget 2018-08-16 14:27:38 +03:00
Swen Kooij
88e2d29596 Bump version number to 5.0a3 2018-06-28 12:39:25 +03:00
Swen Kooij
588f32086b
Merge pull request #51 from SectorLabs/multiwidget
Copy the widget for each language
2018-06-28 12:39:02 +03:00
Cristi Ingineru
13e2666a51 Copy the widget for each language 2018-06-28 12:33:44 +03:00
Swen Kooij
2393539b44 Bump version number to 5.0a2 2018-06-19 12:30:04 +03:00
Swen Kooij
e322b3d63b Upgrade django-postgres-extra to 1.21a11 2018-06-19 12:29:55 +03:00
Swen Kooij
1b1d24a460 Make defaults work for LocalizedIntegerField 2018-06-15 17:07:12 +03:00
Swen Kooij
fb233e8f25 Make sure values are strings before saving LocalizedIntegerValue 2018-06-15 16:19:32 +03:00
Swen Kooij
bca94a3508 Add quick docs on LocalizedIntegerField 2018-06-15 13:04:00 +03:00
Swen Kooij
8c83fa6b49 Bump version number to 5.0a1 2018-06-15 12:58:27 +03:00
Swen Kooij
90597da8fd Add a LocalizedIntegerField 2018-06-15 12:58:01 +03:00
Swen Kooij
752e17064d Deprecate LocalizedFileValue.localized() 2018-06-14 08:01:10 +03:00
Swen Kooij
def7dae640 Add LocalizedValue.translate()
LocalizedValue.translate() behaves the exact same as the str(..) cast
works, with the exception that it returns None if there is no value
instead of an empty string. This makes it easier to implement custom
value classes on top of the LocalizedValue class.

Behavior for str(..) stays the same as it was.
2018-06-14 07:57:02 +03:00
seroy
8ba0540237 Fix using LocalizedFieldsAdminMixin with inlines 2018-04-20 02:33:15 +03:00
seroy
b3624916b2 Add tests for LocalizedFieldsAdminMixin 2018-04-20 02:27:57 +03:00
Swen Kooij
db4324fbf3 Bump version number to 4.6a3 2018-04-02 15:59:42 +03:00
Swen Kooij
9ff0b781ab Upgrade django-postgres-extra to 1.21a9 2018-04-02 15:59:27 +03:00
Swen Kooij
a76101c9ad Fix LocalizedFieldsAdminMixin not having a base class
This was a breaking change and broke a lot of projects.
2018-04-02 15:59:16 +03:00
Swen Kooij
04f15363bb Upgrade django-postgres-extra to 1.21a8 2018-04-01 18:28:43 +03:00
Swen Kooij
a849866bcb Make it more clear Django 2.X is supported 2018-03-31 17:05:39 +03:00
Swen Kooij
5e58bc6e24 Revert "Use standard postgres on Scrutinizer"
This reverts commit 04a750053f0aea3a9b947d67f9fdc1892cdfc90a.
2018-03-31 16:59:11 +03:00
Swen Kooij
04a750053f Use standard postgres on Scrutinizer 2018-03-31 16:56:01 +03:00
Swen Kooij
d25b1b92fe Merge branch 'master' of https://github.com/SectorLabs/django-localized-fields 2018-03-31 16:53:48 +03:00
Swen Kooij
ccc46e1899 Allow raw dicts to be used in update statements 2018-03-31 16:53:10 +03:00
Swen Kooij
9063509817
Merge pull request #47 from MELScience/fix-admin-widget-inline-tabs
fix admin widget tabs width in inline formset
2018-03-31 16:47:36 +03:00
Swen Kooij
8fbe3e8680 Merge branch 'master' of https://github.com/SectorLabs/django-localized-fields 2018-03-31 16:42:08 +03:00
Swen Kooij
b9f14322be Bump version to 4.6a2 2018-03-31 16:40:50 +03:00
Swen Kooij
24974b2f6b Upgrade django-postgres-extra to 1.21a7 2018-03-31 16:40:29 +03:00
Swen Kooij
af02593ebd Add test to make sure LocalizedValue doesn't cast values to string 2018-03-31 08:52:12 +03:00
seroy
f161b5d047 fix admin widget tabs width in inline formset 2018-03-01 13:22:54 +03:00
Swen Kooij
77f626fc69
Merge pull request #46 from MELScience/fix-admin-widget-inline
fix admin widget work with inline formsets
2018-02-20 09:14:40 +02:00
seroy
d7889b0601 use label tag + for attribute instead of a + href for properly work with inline formsets 2018-02-20 00:58:08 +03:00
Swen Kooij
09cd0a8177
Merge pull request #44 from si14/patch-4
fix LocalizedValue.deconstruct (wrong module name)
2018-02-04 18:00:48 +02:00
Dmitry Groshev
2a29efdf14
fix LocalizedValue.deconstruct (wrong module name) 2018-01-31 23:59:48 +00:00
Dmitry Groshev
d8eb3394d0 LocalizedFieldsAdminMixin can be used with inlines
LocalizedFieldsAdminMixin was inheriting from ModelAdmin. This means in a code like this

```python
class FooInline(LocalizedFieldsAdminMixin, admin.TabularMixin):
    pass
```

`__init__` was being resolved to `ModelAdmin.__init__` which is clearly wrong.
2018-01-29 13:39:47 +02:00
Swen Kooij
b27f1535ee
Merge pull request #43 from SectorLabs/fix-test-on-django20
Fix test on django20
2018-01-29 13:39:09 +02:00
Swen Kooij
cd844fccec Add support for tox to run tests against Django 1.X/2.0 2018-01-29 13:34:40 +02:00
seroy
6a4beca193 fix tests on Django 2.0 2018-01-29 13:30:43 +02:00
Swen Kooij
bf0383d742 Upgrade to alpha version of django-postgres-extra (1.21a4) 2018-01-29 12:37:44 +02:00
Swen Kooij
879b857aa5
Merge pull request #41 from MELScience/update-requirements
update requirements
2018-01-24 23:53:02 +02:00
seroy
59e8e18b2a update requirements 2018-01-22 01:19:23 +03:00
Swen Kooij
11cfe5b6e1 Add unaizalakain to the contributor list 2017-07-18 15:47:07 +03:00
Swen Kooij
084f5dd0b6 Merge pull request #36 from MELScience/issue35
Fix non-valid HTML tags attributes
2017-07-18 14:37:11 +03:00
Swen Kooij
83605cde13 Merge pull request #37 from MELScience/gitignore
Gitignore
2017-07-18 14:36:46 +03:00
seroy
7437b58f20 Ignore PyCharm 2017-07-18 14:17:45 +03:00
seroy
db6664c84f Remove wrong filename 2017-07-18 14:16:46 +03:00
seroy
928c4c624d Add test whether get_context contains 'lang_code' and 'lang_name' attribute 2017-07-18 13:54:53 +03:00
seroy
3d4f9c413e Fix non-valid HTML tags attributes 2017-07-18 13:52:17 +03:00
Swen Kooij
f434566338 Bump version number to 4.5 2017-07-18 09:40:55 +03:00
Swen Kooij
2807ed10c8 Add target link for the license Badge 2017-07-18 09:40:29 +03:00
Swen Kooij
d5f43c783a Merge pull request #34 from MELScience/widget_refactor
Use template-based widget rendering in AdminLocalizedFieldWidget
2017-07-18 09:39:00 +03:00
Swen Kooij
5347db6d8f Add contributors list to README 2017-07-18 09:38:51 +03:00
Swen Kooij
1accee0b59 Merge branch 'master' into widget_refactor 2017-07-18 09:33:59 +03:00
Swen Kooij
05bcd84a88 Merge pull request #29 from MELScience/required
Improved functionality of required parameter
2017-07-18 09:33:24 +03:00
Swen Kooij
1c2e013695 Add Django 1.11 as a dependency to setup.py
Fixes #32
2017-07-18 09:31:47 +03:00
Swen Kooij
8b76948328 Merge pull request #33 from MELScience/issue32
Django requirement updated
2017-07-18 09:29:09 +03:00
seroy
940587d748 Updated django requirement 2017-07-18 01:06:49 +03:00
seroy
6522e38f18 Added tests for LocalizedFieldWidget.get_context method 2017-07-18 01:03:50 +03:00
seroy
69cf0df166 Use template-based widget rendering in AdminLocalizedFieldWidget 2017-07-18 00:29:44 +03:00
seroy
d8c5544e91 Use template-based widget rendering in AdminLocalizedFieldWidget 2017-07-17 22:53:47 +03:00
seroy
ccc41f9143 Updated django requirement in documentation 2017-07-17 22:16:40 +03:00
seroy
073846d74b Updated django requirement 2017-07-17 22:10:19 +03:00
seroy
0f08eb8280 Add test on render method 2017-07-17 22:05:31 +03:00
seroy
60d14069d8 Updated 'required' attribute in documentation 2017-07-17 20:57:59 +03:00
seroy
33e9709373 Add tests for 'required' attribute 2017-07-17 20:55:13 +03:00
seroy
c4bf151938 Refactor required_langs into required 2017-07-17 20:53:50 +03:00
Swen Kooij
7d629c186d Add basic tests for 'required' attribute 2017-07-16 12:22:48 +03:00
Swen Kooij
cff388cae3 Add PEP8 and Flake8 as part of Scrutinizer test 2017-07-15 14:13:51 +03:00
Swen Kooij
08690ab361 Fix outstanding PEP8 and Flake8 issues 2017-07-15 14:06:50 +03:00
Swen Kooij
5ac05efbd0 Slight clean up, use dict instead of {} 2017-07-15 14:04:09 +03:00
Swen Kooij
aff41f671a Implement code review suggestions for #29 2017-07-15 13:59:39 +03:00
Swen Kooij
a38d53b609 Merge pull request #30 from SectorLabs/subwidgets-labels
Subwidgets labels
2017-07-15 13:56:29 +03:00
Swen Kooij
968840188d Formatting cleanup in widgets.py 2017-07-15 13:48:54 +03:00
Unai Zalakain
e8e044f6e2 Add labels to localized subwidgets 2017-07-15 13:45:10 +03:00
seroy
aaf49614f2 improve functionality of required parameter 2017-07-14 15:07:48 +03:00
Swen Kooij
d4c24dea97 Merge pull request #28 from MELScience/fix_imports_in_doc
fix imports in README
2017-07-11 11:21:57 +03:00
seroy
b38ded0cc3 fix imports for LocalizedCharField, LocalizedTextField and LocalizedFileField 2017-07-11 11:03:21 +03:00
Swen Kooij
226d47e766 Merge pull request #27 from MELScience/issue26
Fix incorrect indentation
2017-07-11 10:40:48 +03:00
seroy
cc4bfb48b9 Fix incorrect indentation 2017-07-11 10:27:07 +03:00
Swen Kooij
d14859a45b Bump version number to 4.4 2017-06-29 18:56:33 +03:00
Swen Kooij
cb7fda5abc Merge branch 'master' of https://github.com/SectorLabs/django-localized-fields 2017-06-29 18:52:23 +03:00
Swen Kooij
9000635f1f Open README as UTF-8 2017-06-29 18:51:53 +03:00
Swen Kooij
dabeb3b79f Merge pull request #24 from MELScience/ff_value_to_string
Added `value_to_string` method
2017-06-27 10:49:17 +03:00
seroy
0b4bb7295e Added value_to_string method 2017-06-26 18:27:03 +03:00
seroy
2b34b6751e Added test for value_to_string method 2017-06-26 18:08:15 +03:00
Swen Kooij
32696f4e1e Add test that confirms slug is re-computed when value changes 2017-06-26 14:01:25 +03:00
Swen Kooij
3ce57ed4cc Bump version number to 4.3 2017-06-26 13:37:02 +03:00
Swen Kooij
7316d312b4 Add simple test for LOCALIZED_FIELDS_FALLBACKS setting 2017-06-26 13:36:21 +03:00
Swen Kooij
16e23963cc Add support for LOCALIZED_FIELDS_FALLBACKS 2017-06-26 13:27:52 +03:00
Swen Kooij
b10472d3e9 Officially deprecate LocalizedAutoSlugField 2017-06-26 13:10:21 +03:00
Swen Kooij
833ceb849c Update docs on enhanced LocalizedUniqueSlugField 2017-06-26 13:07:18 +03:00
Swen Kooij
d7382fbf30 Add support for using a callable to populate slug with 2017-06-26 13:03:41 +03:00
Swen Kooij
8ad9268426 Remove tests for LocalizedAutoSlug 2017-06-26 12:52:42 +03:00
Swen Kooij
96ddc77cfc Fake models now have generated names 2017-06-26 12:44:09 +03:00
Swen Kooij
51fc6959d2 Support for slugging from multiple fields 2017-06-26 12:34:50 +03:00
Swen Kooij
3b28a5e707 Fix PEP8 violations 2017-06-26 11:33:25 +03:00
Swen Kooij
06873afbda Merge pull request #15 from MELScience/extra-fields
Extra fields
2017-06-21 11:33:30 +03:00
Swen Kooij
f7c14f897c Bump version number to 4.2 2017-06-20 18:04:44 +03:00
Swen Kooij
e5e3068835 Merge pull request #22 from MELScience/issue17
Added templates and static folders
2017-06-20 18:03:11 +03:00
seroy
de7d3b9a21 Tests excluded 2017-06-20 17:22:24 +03:00
seroy
fd47deaa2e Tests excluded 2017-06-20 16:37:49 +03:00
seroy
4b6d997ddd Upgrade django-postgres-extra to 1.11 2017-06-20 16:30:58 +03:00
seroy
68d6991608 Added templates and static folders 2017-06-20 14:52:54 +03:00
seroy
e5d7cd25e2 Shorten names for everything 2017-06-19 21:58:48 +03:00
seroy
236ce1648c Upgrade django-postgres-extra to 1.11 2017-06-19 21:49:24 +03:00
seroy
aacc712195 Merge branch 'master' of https://github.com/SectorLabs/django-localized-fields into extra-fields 2017-06-19 21:40:11 +03:00
seroy
d1790f1fc1 added missing 'r' in type LocalizedStringValue 2017-06-19 17:17:24 +03:00
Swen Kooij
c75c1764e2 Improve test case for bulk_create 2017-05-31 11:38:53 +03:00
Swen Kooij
6736b3b32d Simplify test case for bulk_create 2017-05-31 11:33:20 +03:00
Swen Kooij
b97a7f3c23 Fix crash when using LocalizedUniqueSlugField in a bulk_create 2017-05-31 11:31:04 +03:00
Swen Kooij
1b036dc1de Add simple test to verify LocalizedField can be used in bulk_create 2017-05-31 11:19:53 +03:00
Swen Kooij
ea7733670d Bump version to 4.1 2017-05-30 16:38:08 +03:00
Swen Kooij
a8dc4fe837 Fix bug/typo in LocalizedField.from_db_value 2017-05-30 13:48:43 +03:00
Swen Kooij
3c8bea0fc3 Add test for LocalizedRef in combination with ArrayAgg 2017-05-30 13:47:48 +03:00
Swen Kooij
2741a6a2a2 Add extra tests for LocalizedRef 2017-05-30 13:45:43 +03:00
Swen Kooij
06f7ee15f0 Add LocalizedRef expression for extracting the value in the current language 2017-05-30 13:38:27 +03:00
Swen Kooij
e5214b07ae Fix aggregation not expanding into a actual list 2017-05-30 13:07:07 +03:00
Swen Kooij
2d5fe0be05 Fix documentation on INSTALLED_APPS 2017-05-26 17:38:49 +03:00
Swen Kooij
4305696f1b Fix pep8 issue, use two spaces before inline comment 2017-05-26 17:05:52 +03:00
Swen Kooij
8c73c9ab77 Fix some mistakes in the README 2017-05-26 17:05:05 +03:00
Swen Kooij
92a53bc3d7 Fix various pep8/flake8/pylint errors 2017-05-25 19:40:03 +03:00
Swen Kooij
5a4f449363 Fix support for ArrayAgg 2017-05-25 19:23:52 +03:00
Swen Kooij
0fa79ddbb0 Bump version to 4.0 2017-05-25 19:16:35 +03:00
Swen Kooij
2205f9c6a4 Move LocalizedValueTest into dedicated file 2017-05-25 19:16:04 +03:00
Swen Kooij
84c267330f Update docs on new import style 2017-05-25 19:11:39 +03:00
Swen Kooij
a1a02552b7 Shorten names for everything 2017-05-25 19:06:44 +03:00
Swen Kooij
bb84d7577c BREAKING CHANGE: Empty out __init__
It is bad practice to do this in Django. If somebody imports something
from the package before Django is loaded, you get a 'Apps aren't loaded yet.'
exception.
2017-05-25 18:51:11 +03:00
Swen Kooij
5e0343801f Restore LocalizedModel to its former glory as an convient way to get all the mixins 2017-05-25 18:50:20 +03:00
Swen Kooij
2df2ec8b36 Move LocalizedValueDescriptor into its own file 2017-05-25 18:45:21 +03:00
Swen Kooij
3fcaece894 Add missing docs to LocalizedField.contribute_to_class 2017-05-25 18:14:41 +03:00
Swen Kooij
093a9d58f2 Fix to_python not working with non-json values 2017-05-25 18:11:58 +03:00
Swen Kooij
cff22855c2 Revert "LocalizedUniqueSlugField refactored"
This reverts commit 03df76d6d795f4453629bf526cc04a86c6393adc.
2017-05-25 17:23:39 +03:00
seroy
db93b93046 added test for LocalizedFileField and LocalizedFileFieldForm 2017-04-24 20:30:44 +03:00
seroy
a352388243 refactored LocalizedFieldFile.save method 2017-04-24 20:29:09 +03:00
Swen Kooij
bf2995fd27 Merge pull request #16 from MELScience/deserialization
Ability to deserialize string value
2017-04-14 17:19:17 +03:00
seroy
8ba08c389c changed indentation 2017-04-13 16:15:13 +03:00
seroy
f1798b0cc6 added ability to deserialize string value 2017-04-13 11:53:56 +03:00
seroy
fc80462ce7 added test for str parameter to_python method 2017-04-13 11:41:24 +03:00
seroy
24079a2fcb added description of LocalizedCharField, LocalizedTextField and LocalizedFileField 2017-04-13 11:11:52 +03:00
seroy
0f4c74a9b2 added comments and deleted extra code 2017-04-13 11:07:40 +03:00
Swen Kooij
366dceaddc Merge pull request #14 from MELScience/app_registry_not_ready
fixed "Apps aren't loaded yet." exception
2017-04-13 09:35:38 +03:00
seroy
5b93c5ec8f added style for AdminLocalizedFileFieldWidget 2017-04-12 22:10:12 +03:00
seroy
817c7e13fe added new LocalizedCharField, LocalizedTextField and LocalizedFileField fields 2017-04-12 21:34:19 +03:00
seroy
8591af1f2a fixed "Apps aren't loaded yet." exception 2017-04-12 14:11:29 +03:00
seroy
c55001ac12 Added test for "Apps aren't loaded yet." exception 2017-04-12 13:55:42 +03:00
Swen Kooij
23c6f975d8 Merge remote-tracking branch 'beer/admin_integration' 2017-04-03 14:56:43 +03:00
Swen Kooij
a9037b603a Merge remote-tracking branch 'beer/uniqueslug'
# Conflicts:
#	README.rst
#	tests/fake_model.py
2017-04-03 14:54:25 +03:00
Swen Kooij
8b08e5a467 Merge remote-tracking branch 'beer/model_inheritance_free'
# Conflicts:
#	localized_fields/fields/localized_field.py
#	localized_fields/models.py
#	tests/fake_model.py
2017-04-03 14:49:27 +03:00
Swen Kooij
bf90131e4f Merge pull request #12 from SectorLabs/experimental
Add experimental features flag for LocalizedField
2017-03-24 15:13:55 +02:00
Bogdan Hopulele
8bdfee0666 Implement review suggestions 2017-03-24 14:48:33 +02:00
Bogdan Hopulele
add3c1e1d4 Bump up the library version 2017-03-24 14:39:06 +02:00
Bogdan Hopulele
8ba33695f9 Export LocalizedModel 2017-03-24 14:37:43 +02:00
seroy
78594541e1 fixed for new instance don't call refresh_from_db 2017-03-23 20:49:39 +03:00
Bogdan Hopulele
52145ca7d3 Add experimental features flag for LocalizedField
With the flag set, LocalizedField will return None if there is no
database value.
2017-03-23 17:32:37 +02:00
Swen Kooij
5df44b0d62 Merge pull request #7 from MELScience/import_error
fixed "Apps aren't loaded yet." exception
2017-03-22 17:48:18 +02:00
Swen Kooij
5aef1d791b Merge pull request #9 from MELScience/bleach_import
fixed ImportError if django-bleach not installed
2017-03-22 17:45:25 +02:00
seroy
03df76d6d7 LocalizedUniqueSlugField refactored 2017-03-18 22:58:11 +03:00
seroy
465e35ba8a fixed ImportError if django-bleach not installed 2017-03-18 19:04:51 +03:00
seroy
9278e99b18 import fixed 2017-03-18 18:44:26 +03:00
seroy
9754134298 fixed "Apps aren't loaded yet." exception 2017-03-18 18:26:33 +03:00
seroy
f0c7a72078 LocalizedModel keeped for backwards compatibility 2017-03-18 17:51:31 +03:00
seroy
7f4dfbae1f changed style of active and inactive tabs 2017-03-17 18:22:19 +03:00
seroy
d07da55215 Advanced django admin integration 2017-03-17 06:22:31 +03:00
seroy
340dde18cd no need inheritance from LocalizedModel anymore. Introduction of LocalizedValueDescriptor 2017-03-13 00:50:34 +03:00
Swen Kooij
3951266747 Bump version to 3.5 2017-03-09 14:32:48 +02:00
Swen Kooij
b5f4c43d6b LocalizedValue is now cast-able to dict 2017-03-09 14:32:33 +02:00
Swen Kooij
3d08475468 __eq__ should only compare same types
unless it's a string
2017-03-09 11:59:21 +02:00
Swen Kooij
b3d7092b91 Bump version to 3.4 2017-02-28 15:00:13 +02:00
Swen Kooij
97a785e9b0 Upgrade django-postgres-extra to 1.4 2017-02-28 14:59:58 +02:00
Swen Kooij
97c14fd2ba Bump version to 3.3 2017-02-24 14:35:57 +02:00
Swen Kooij
6cb4cdf52e Now inheriting from PostgresModel for upserts 2017-02-24 14:35:29 +02:00
Swen Kooij
bcb2ff0092 Bump version to 3.2 2017-02-24 12:29:27 +02:00
Swen Kooij
43a48403e9 LocalizedField should not set required twice 2017-02-24 12:28:56 +02:00
Swen Kooij
f453c44a73 Bump version to 3.1 2017-02-23 16:54:55 +02:00
Swen Kooij
3cb8b04195 Update REAMDE on back-end changes 2017-02-23 16:54:34 +02:00
Swen Kooij
3f8fc77c4d Remove pylint plugins, Scrutinizer doesn't like it 2017-02-23 16:48:44 +02:00
Swen Kooij
9245c85e5d Scrutinizer should use PostgreSQL 9.6 2017-02-23 16:43:12 +02:00
Swen Kooij
818a0a2fe3 Test requirements should inherit from base.txt 2017-02-23 16:38:43 +02:00
Swen Kooij
c206005cae Add django-postgres-extra 1.2 as a dependency 2017-02-23 16:25:27 +02:00
Swen Kooij
3850c34374 Add scrutinizer-ci configuration 2017-02-23 16:24:52 +02:00
Swen Kooij
99c8830f10 Bump version number to 3.0 2017-02-21 17:37:20 +02:00
Swen Kooij
d7bd217a90 Ported to use django-postgres-extra 2017-02-21 17:36:56 +02:00
Swen Kooij
b9a4d3be2c Bump version number to 2.9.2 2017-02-16 10:20:09 +02:00
Swen Kooij
6d7a937eac Don't regenerate slug if not needed 2017-02-16 10:13:10 +02:00
Swen Kooij
2e9b83e49b Bump version to 2.9.1 2017-02-16 09:36:34 +02:00
Swen Kooij
679dcafef6 Pop kwargs after calling super constructor 2017-02-16 09:36:26 +02:00
Swen Kooij
ad2ef34546 Bump version number to 2.9 2017-02-15 19:29:29 +02:00
Swen Kooij
1317023160 Bump version number to 2.8 2017-02-15 19:13:40 +02:00
Swen Kooij
ca6b1c88fa Add option include_time to LocalizedUniqueSlugField 2017-02-15 19:13:19 +02:00
Swen Kooij
64c3c06612 Bumped version number to 2.7 2017-02-09 14:57:25 +02:00
Swen Kooij
b121dfc2d7 Added __eq__ operator to LocalizedValue 2017-02-09 14:57:08 +02:00
Swen Kooij
d529da8886 Fixed bug with with missing populate_from 2017-02-03 11:14:37 +02:00
Swen Kooij
ca879087ea Bumped version to 2.6 2017-02-03 10:41:05 +02:00
Swen Kooij
302a64a02c Updated base classes in documentation 2017-02-03 10:40:37 +02:00
Swen Kooij
bb11253207 Moved retry mechanism to mixin 2017-02-03 10:35:39 +02:00
Swen Kooij
5db87763fb Rename LocalizedMagicSlugField to LocalizedUniqueSlugField 2017-02-03 10:27:30 +02:00
98 changed files with 6136 additions and 2134 deletions

190
.circleci/config.yml Normal file
View File

@ -0,0 +1,190 @@
version: 2
jobs:
test-python36:
docker:
- image: python:3.6-alpine
- image: postgres:12.0
environment:
POSTGRES_DB: 'localizedfields'
POSTGRES_USER: 'localizedfields'
POSTGRES_PASSWORD: 'localizedfields'
steps:
- checkout
- run:
name: Install packages
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
- run:
name: Install Python packages
command: pip install --progress-bar off .[test]
- run:
name: Run tests
command: tox -e 'py36-dj{20,21,22,30,31,32}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results:
path: reports
test-python37:
docker:
- image: python:3.7-alpine
- image: postgres:12.0
environment:
POSTGRES_DB: 'localizedfields'
POSTGRES_USER: 'localizedfields'
POSTGRES_PASSWORD: 'localizedfields'
steps:
- checkout
- run:
name: Install packages
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
- run:
name: Install Python packages
command: pip install --progress-bar off .[test]
- run:
name: Run tests
command: tox -e 'py37-dj{20,21,22,30,31,32}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results:
path: reports
test-python38:
docker:
- image: python:3.8-alpine
- image: postgres:12.0
environment:
POSTGRES_DB: 'localizedfields'
POSTGRES_USER: 'localizedfields'
POSTGRES_PASSWORD: 'localizedfields'
steps:
- checkout
- run:
name: Install packages
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
- run:
name: Install Python packages
command: pip install --progress-bar off .[test]
- run:
name: Run tests
command: tox -e 'py38-dj{20,21,22,30,31,32,40,41,42}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results:
path: reports
test-python39:
docker:
- image: python:3.9-alpine
- image: postgres:12.0
environment:
POSTGRES_DB: 'localizedfields'
POSTGRES_USER: 'localizedfields'
POSTGRES_PASSWORD: 'localizedfields'
steps:
- checkout
- run:
name: Install packages
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
- run:
name: Install Python packages
command: pip install --progress-bar off .[test]
- run:
name: Run tests
command: tox -e 'py39-dj{30,31,32,40,41,42}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results:
path: reports
test-python310:
docker:
- image: python:3.10-alpine
- image: postgres:12.0
environment:
POSTGRES_DB: 'localizedfields'
POSTGRES_USER: 'localizedfields'
POSTGRES_PASSWORD: 'localizedfields'
steps:
- checkout
- run:
name: Install packages
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
- run:
name: Install Python packages
command: pip install --progress-bar off .[test]
- run:
name: Run tests
command: tox -e 'py310-dj{32,40,41,42,50}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results:
path: reports
test-python311:
docker:
- image: python:3.11-alpine
- image: postgres:12.0
environment:
POSTGRES_DB: 'localizedfields'
POSTGRES_USER: 'localizedfields'
POSTGRES_PASSWORD: 'localizedfields'
steps:
- checkout
- run:
name: Install packages
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
- run:
name: Install Python packages
command: pip install --progress-bar off .[test]
- run:
name: Run tests
command: tox -e 'py311-dj{42,50}'
environment:
DATABASE_URL: 'postgres://localizedfields:localizedfields@localhost:5432/localizedfields'
- store_test_results:
path: reports
analysis:
docker:
- image: python:3.8-alpine
steps:
- checkout
- run:
name: Install packages
command: apk add postgresql-libs gcc musl-dev postgresql-dev git
- run:
name: Install Python packages
command: pip install --progress-bar off .[analysis]
- run:
name: Verify formatting / linting
command: python setup.py verify
workflows:
version: 2
build:
jobs:
- test-python36
- test-python37
- test-python38
- test-python39
- test-python310
- test-python311
- analysis

View File

@ -1,5 +1,3 @@
[run] [run]
include = localized_fields/* include = localized_fields/*
omit = *migrations*, *tests* omit = *migrations*, *tests*
plugins =
django_coverage_plugin

View File

@ -1,6 +0,0 @@
# Ignore virtual environments
env/
# Ignore Python byte code cache
*.pyc
__pycache__

13
.gitignore vendored
View File

@ -1,17 +1,28 @@
# Ignore virtual environments # Ignore virtual environments
env/ env/
.env/
venv/
# Ignore Python byte code cache # Ignore Python byte code cache
*.pyc *.pyc
__pycache__ __pycache__
.cache/
# Ignore coverage reports # Ignore coverage reports
.coverage .coverage
htmlcov reports/
# Ignore build results # Ignore build results
*.egg-info/ *.egg-info/
dist/ dist/
build/
pip-wheel-metadata
# Ignore stupid .DS_Store # Ignore stupid .DS_Store
.DS_Store .DS_Store
# Ignore PyCharm
.idea/
# Ignore tox environments
.tox/

View File

@ -1,6 +1,3 @@
[MASTER]
load-plugins=pylint_common, pylint_django
[FORMAT] [FORMAT]
max-line-length=120 max-line-length=120

10
.readthedocs.yml Normal file
View File

@ -0,0 +1,10 @@
version: 2
sphinx:
builder: html
configuration: docs/source/conf.py
python:
version: 3.7
install:
- requirements: requirements/docs.txt

View File

@ -1,18 +0,0 @@
env:
- DJANGO_SETTINGS_MODULE=settings
sudo: true
before_install:
- sudo apt-get update -qq
- sudo apt-get install -qq build-essential gettext python-dev zlib1g-dev libpq-dev xvfb
- sudo apt-get install -qq libtiff4-dev libjpeg8-dev libfreetype6-dev liblcms1-dev libwebp-dev
- sudo apt-get install -qq graphviz-dev python-setuptools python3-dev python-virtualenv python-pip
- sudo apt-get install -qq firefox automake libtool libreadline6 libreadline6-dev libreadline-dev
- sudo apt-get install -qq libsqlite3-dev libxml2 libxml2-dev libssl-dev libbz2-dev wget curl llvm
language: python
python:
- "3.5"
services:
- postgresql
install: "pip install -r requirements/test.txt"
script:
- coverage run manage.py test

View File

@ -1,2 +1,5 @@
include LICENSE include LICENSE
include README.rst include README.rst
recursive-include localized_fields/static *
recursive-include localized_fields/templates *
recursive-exclude tests *

58
README.md Normal file
View File

@ -0,0 +1,58 @@
| | | |
|--------------------|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| :white_check_mark: | **Tests** | [![CircleCI](https://circleci.com/gh/SectorLabs/django-localized-fields/tree/master.svg?style=svg)](https://circleci.com/gh/SectorLabs/django-localized-fields/tree/master) |
| :memo: | **License** | [![License](https://img.shields.io/:license-mit-blue.svg)](http://doge.mit-license.org) |
| :package: | **PyPi** | [![PyPi](https://badge.fury.io/py/django-localized-fields.svg)](https://pypi.python.org/pypi/django-localized-fields) |
| <img src="https://cdn.iconscout.com/icon/free/png-256/django-1-282754.png" width="22px" height="22px" align="center" /> | **Django Versions** | 2.0, 2.1, 2.2, 3.0, 3.1, 3.2, 4.0, 4.1, 4.2, 5.0 |
| <img src="http://www.iconarchive.com/download/i73027/cornmanthe3rd/plex/Other-python.ico" width="22px" height="22px" align="center" /> | **Python Versions** | 3.6, 3.7, 3.8, 3.9, 3.10, 3.11 |
| :book: | **Documentation** | [Read The Docs](https://django-localized-fields.readthedocs.io) |
| :warning: | **Upgrade** | [Upgrade fom v5.x](https://django-localized-fields.readthedocs.io/en/latest/releases.html#v6-0)
| :checkered_flag: | **Installation** | [Installation Guide](https://django-localized-fields.readthedocs.io/en/latest/installation.html) |
`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` since Django 1.10.
---
:warning: **This README is for v6. See the `v5.x` branch for v5.x.**
---
## Working with the code
### Prerequisites
* PostgreSQL 12 or newer.
* Django 2.0 or newer.
* Python 3.6 or newer.
### Getting started
1. Clone the repository:
λ git clone https://github.com/SectorLabs/django-localized-fields.git
2. Create a virtual environment:
λ cd django-localized-fields
λ virtualenv env
λ source env/bin/activate
3. Create a postgres user for use in tests (skip if your default user is a postgres superuser):
λ createuser --superuser localized_fields --pwprompt
λ export DATABASE_URL=postgres://localized_fields:<password>@localhost/localized_fields
Hint: if you're using virtualenvwrapper, you might find it beneficial to put
the ``export`` line in ``$VIRTUAL_ENV/bin/postactivate`` so that it's always
available when using this virtualenv.
4. Install the development/test dependencies:
λ pip install ".[test]" ".[analysis]"
5. Run the tests:
λ tox
7. Auto-format code, sort imports and auto-fix linting errors:
λ python setup.py fix

View File

@ -1,280 +0,0 @@
django-localized-fields
=======================
.. image:: https://scrutinizer-ci.com/g/SectorLabs/django-localized-fields/badges/quality-score.png
:target: https://scrutinizer-ci.com/g/SectorLabs/django-localized-fields/
.. image:: https://scrutinizer-ci.com/g/SectorLabs/django-localized-fields/badges/coverage.png
:target: https://scrutinizer-ci.com/g/SectorLabs/django-localized-fields/
.. image:: https://travis-ci.com/SectorLabs/django-localized-fields.svg?token=sFgxhDFpypxkMcvhRoSz&branch=master
:target: https://travis-ci.com/SectorLabs/django-localized-fields
.. image:: https://badge.fury.io/py/django-localized-fields.svg
: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.
This package requires Python 3.5 or newer and Django 1.10 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
------------
1. Install the package from PyPi:
.. code-block:: bash
$ pip install django-localized-fields
2. Add ``localized_fields`` and ``django.contrib.postgres`` to your ``INSTALLED_APPS``:
.. code-block:: bash
INSTALLED_APPS = [
....
'django.contrib.postgres',
'localized_fields'
]
3. Set the database engine to ``localized_fields.db_backend``:
.. code-block:: python
DATABASES = {
'default': {
...
'ENGINE': 'localized_fields.db_backend'
}
}
3. Set ``LANGUAGES` and `LANGUAGE_CODE`` in your settings:
.. code-block:: python
LANGUAGE_CODE = 'en' # default language
LANGUAGES = (
('en', 'English'),
('nl', 'Dutch'),
('ro', 'Romanian')
)
Usage
-----
Preparation
^^^^^^^^^^^
Inherit your model from ``LocalizedModel`` and declare fields on your model as ``LocalizedField``:
.. code-block:: python
from localized_fields.models import LocalizedModel
from localized_fields.fields import LocalizedField
class MyModel(LocalizedModel):
title = LocalizedField()
``django-localized-fields`` integrates with Django's i18n system, in order for certain languages to be available you have to correctly configure the ``LANGUAGES`` and ``LANGUAGE_CODE`` settings:
.. code-block:: python
LANGUAGE_CODE = 'en' # default language
LANGUAGES = (
('en', 'English'),
('nl', 'Dutch'),
('ro', 'Romanian')
)
All the ``LocalizedField`` you define now will be available in the configured languages.
Basic usage
^^^^^^^^^^^
.. code-block:: python
new = MyModel()
new.title.en = 'english title'
new.title.nl = 'dutch title'
new.title.ro = 'romanian title'
new.save()
By changing the active language you can control which language is presented:
.. code-block:: python
from django.utils import translation
translation.activate('nl')
print(new.title) # prints 'dutch title'
translation.activate('en')
print(new.title) # prints 'english title'
Or get it in a specific language:
.. code-block:: python
print(new.title.get('en')) # prints 'english title'
print(new.title.get('ro')) # prints 'romanian title'
print(new.title.get()) # whatever language is the primary one
You can also explicitly set a value in a certain language:
.. code-block:: python
new.title.set('en', 'other english title')
new.title.set('nl', 'other dutch title')
new.title.ro = 'other romanian title'
Constraints
^^^^^^^^^^^
**Required/Optional**
At the moment, it is not possible to select two languages to be marked as required. The constraint is **not** enforced on a database level.
* Make the primary language **required** and the others optional (this is the **default**):
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(required=True)
* Make all languages optional:
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(null=True)
**Uniqueness**
By default the values stored in a ``LocalizedField`` are *not unique*. You can enforce uniqueness for certain languages. This uniqueness constraint is enforced on a database level using a ``UNIQUE INDEX``.
* Enforce uniqueness for one or more languages:
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(uniqueness=['en', 'ro'])
* Enforce uniqueness for **all** languages:
.. code-block:: python
from localized_fields import get_language_codes
class MyModel(models.Model):
title = LocalizedField(uniqueness=get_language_codes())
* Enforce uniqueness for one ore more languages **together** (similar to Django's ``unique_together``):
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(uniqueness=[('en', 'ro')])
* Enforce uniqueness for **all** languages **together**:
.. code-block:: python
from localized_fields import get_language_codes
class MyModel(models.Model):
title = LocalizedField(uniqueness=[(*get_language_codes())])
Other fields
^^^^^^^^^^^^
Besides ``LocalizedField``, there's also:
* ``LocalizedMagicSlugField``
Successor of ``LocalizedAutoSlugField`` that fixes concurrency issues and enforces
uniqueness of slugs on a database level. Usage is the exact same:
.. code-block:: python
from localized_fields.models import LocalizedModel
from localized_fields.fields import (LocalizedField,
LocalizedMagicSlugField)
class MyModel(LocalizedModel):
title = LocalizedField()
slug = LocalizedMagicSlugField(populate_from='title')
* ``LocalizedAutoSlugField``
Automatically creates a slug for every language from the specified field.
Currently only supports ``populate_from``. Example usage:
.. code-block:: python
from localized_fields.models import LocalizedModel
from localized_fields.fields import (LocalizedField,
LocalizedAutoSlugField)
class MyModel(LocalizedModel):
title = LocalizedField()
slug = LocalizedAutoSlugField(populate_from='title')
This implementation is **NOT** concurrency safe, prefer ``LocalizedMagicSlugField``.
* ``LocalizedBleachField``
Automatically bleaches the content of the field.
* django-bleach
Example usage:
.. code-block:: python
from localized_fields.models import LocalizedModel
from localized_fields.fields import (LocalizedField,
LocalizedBleachField)
class MyModel(LocalizedModel):
title = LocalizedField()
description = LocalizedBleachField()
Frequently asked questions (FAQ)
--------------------------------
1. Why do I need to change the database back-end/engine?
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.
4. 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``.
5. 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``.
7. 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.
8. I am using PostgreSQL <8.4, can I use this?
No. The ``hstore`` data type was introduced in PostgreSQL 8.4.
9. 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 :)

1
docs/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
build/

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

35
docs/make.bat Normal file
View File

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

10
docs/source/conf.py Normal file
View File

@ -0,0 +1,10 @@
import sphinx_rtd_theme
project = 'django-localized-fields'
copyright = '2019, Sector Labs'
author = 'Sector Labs'
extensions = ["sphinx_rtd_theme"]
templates_path = ['_templates']
exclude_patterns = []
html_theme = "sphinx_rtd_theme"
html_static_path = ['_static']

100
docs/source/constraints.rst Normal file
View File

@ -0,0 +1,100 @@
.. _unique_together: https://docs.djangoproject.com/en/2.2/ref/models/options/#unique-together
Constraints
===========
All constraints are enforced by PostgreSQL. Constraints can be applied to all fields documented in :ref:`Fields <fields>`.
.. warning::
Don't forget to run ``python manage.py makemigrations`` after modifying constraints.
Required/optional
-----------------
* Default language required and all other languages optional:
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField()
* All languages are optional and the field itself can be ``None``:
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(blank=True, null=True, required=False)
* All languages are optional but the field itself cannot be ``None``:
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(blank=False, null=False, required=False)
* Make specific languages required:
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(blank=False, null=False, required=['en', 'ro'])
* Make all languages required:
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(blank=False, null=False, required=True)
Uniqueness
----------
.. note::
Uniqueness is enforced by PostgreSQL by creating unique indexes on hstore keys. Keep this in mind when setting up unique constraints. If you already have a unique constraint in place, you do not have to add an additional index as uniqueness is enforced by creating an index.
* Enforce uniqueness for one or more languages:
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(uniqueness=['en', 'ro'])
* Enforce uniqueness for all languages:
.. code-block:: python
from localized_fields.util import get_language_codes
class MyModel(models.Model):
title = LocalizedField(uniqueness=get_language_codes())
* Enforce uniqueness for one or more languages together:
.. code-block:: python
class MyModel(models.Model):
title = LocalizedField(uniqueness=[('en', 'ro')])
This is similar to Django's `unique_together`_.
* Enforce uniqueness for all languages together:
.. code-block:: python
from localized_fields.util import get_language_codes
class MyModel(models.Model):
title = LocalizedField(uniqueness=[(*get_language_codes())])

View File

@ -0,0 +1,19 @@
Django Admin
------------
To display ``LocalizedFields`` as a tab bar in Django Admin; inherit your admin model class from ``LocalizedFieldsAdminMixin``:
.. code-block:: python
from django.contrib import admin
from myapp.models import MyLocalizedModel
from localized_fields.admin import LocalizedFieldsAdminMixin
class MyLocalizedModelAdmin(LocalizedFieldsAdminMixin, admin.ModelAdmin):
"""Any admin options you need go here"""
admin.site.register(MyLocalizedModel, MyLocalizedModelAdmin)
.. image:: _static/django_admin_widget.png

99
docs/source/fields.rst Normal file
View File

@ -0,0 +1,99 @@
.. _django.db.models.CharField: https://docs.djangoproject.com/en/2.2/ref/models/fields/#charfield
.. _django.db.models.TextField: https://docs.djangoproject.com/en/2.2/ref/models/fields/#django.db.models.TextField
.. _django.db.models.IntegerField: https://docs.djangoproject.com/en/2.2/ref/models/fields/#integerfield
.. _django.db.models.FileField: https://docs.djangoproject.com/en/2.2/ref/models/fields/#filefield
.. _fields:
Localized fields
================
LocalizedField
--------------
Base localized fields. Stores content as strings of arbitrary lengths.
.. code-block:: python
from localized_fields.fields import LocalizedField
LocalizedCharField
------------------
Localized version of `django.db.models.CharField`_.
Use this for single-line text content. Uses ``<input type="text" />`` when rendered as a widget.
.. code-block:: python
from localized_fields.fields import LocalizedCharField
Follows the same convention as `django.db.models.CharField`_ to store empty strings for "no data" and not NULL.
LocalizedTextField
------------------
Localized version of `django.db.models.TextField`_.
Use this for multi-line text content.
.. code-block:: python
from localized_fields.fields import LocalizedTextField
Follows the same convention as `django.db.models.TextField`_ to store empty strings for "no data" and not NULL.
LocalizedFileField
------------------
Localized version of `django.db.models.FileField`_.
.. code-block:: python
from localized_fields.fields import LocalizedFileField
def my_directory_path(instance, filename, lang):
# file will be uploaded to MEDIA_ROOT/<lang>/<id>_<filename>
return '{0}/{0}_{1}'.format(lang, instance.id, filename)
class MyModel(models.Model):
file1 = LocalizedFileField(upload_to='uploads/{lang}/')
file2 = LocalizedFileField(upload_to=my_directory_path)
The ``upload_to`` supports the ``{lang}}`` placeholder for string formatting or as function argument (in case if upload_to is a callable).
In a template, you can access the files for different languages:
.. code-block:: html
{# For current active language: #}
{{ model.file.url }} {# output file url #}
{{ model.file.name }} {# output file name #}
{# Or get it in a specific language: #}
{{ model.file.ro.url }} {# output file url for romanian language #}
{{ model.file.ro.name }} {# output file name for romanian language #}
To get the file instance for the current language:
.. code-block:: python
model.file.localized()
LocalizedIntegerField
---------------------
Localized version of `django.db.models.IntegerField`_.
.. code-block:: python
from localized_fields.fields import LocalizedIntegerField
Although the underlying PostgreSQL data type for ``LocalizedField`` is hstore (which only stores strings). ``LocalizedIntegerField`` takes care of making sure that input values are integers and casts the values back to integers when querying them.

44
docs/source/filtering.rst Normal file
View File

@ -0,0 +1,44 @@
Filtering localized content
===========================
.. note::
All examples below assume a model declared like this:
.. code-block:: python
from localized_fields.models import LocalizedModel
from localized_fields.fields import LocalizedField
class MyModel(LocalizedModel):
title = LocalizedField()
Active language
----------------
.. code-block:: python
from django.utils import translation
# filter in english
translation.activate("en")
MyModel.objects.filter(title="test")
# filter in dutch
translation.activate("nl")
MyModel.objects.filter(title="test")
Specific language
-----------------
.. code-block:: python
MyModel.objects.filter(title__en="test")
MyModel.objects.filter(title__nl="test")
# do it dynamically, where the language code is a var
lang_code = "nl"
MyModel.objects.filter(**{"title_%s" % lang_code: "test"})

22
docs/source/index.rst Normal file
View File

@ -0,0 +1,22 @@
Welcome
=======
``django-localized-fields`` is a Django library that provides fields to store localized content (content in various languages) in a PostgreSQL database. It does this by utilizing the PostgreSQL ``hstore`` type, which is available in Django as ``HStoreField`` since Django 1.10.
This package requires Python 3.6 or newer, Django 2.0 or newer and PostgreSQL 10 or newer.
.. toctree::
:maxdepth: 2
:caption: Overview
installation
quick_start
constraints
fields
saving
querying
filtering
localized_value
django_admin
settings
releases

View File

@ -0,0 +1,73 @@
.. _installation:
Installation
============
1. Install the package from PyPi:
.. code-block:: bash
$ pip install django-localized-fields
2. Add ``django.contrib.postgres``, ``psqlextra`` and ``localized_fields`` to your ``INSTALLED_APPS``:
.. code-block:: python
INSTALLED_APPS = [
...
"django.contrib.postgres",
"psqlextra",
"localized_fields",
]
3. Set the database engine to ``psqlextra.backend``:
.. code-block:: python
DATABASES = {
"default": {
...
"ENGINE": "psqlextra.backend",
],
}
.. note::
Already using a custom back-end? Set ``POSTGRES_EXTRA_DB_BACKEND_BASE`` to your custom back-end. See django-postgres-extra's documentation for more details: `Using a custom database back-end <https://django-postgres-extra.readthedocs.io/en/latest/db-engine/#using-a-custom-database-back-end>`_.
4. Set ``LANGUAGES`` and ``LANGUAGE_CODE``:
.. code-block:: python
LANGUAGE_CODE = "en" # default language
LANGUAGES = (
("en", "English"), # default language
("ar", "Arabic"),
("ro", "Romanian"),
)
.. warning::
Make sure that the language specified in ``LANGUAGE_CODE`` is the first language in the ``LANGUAGES`` list. Django and many third party packages assume that the default language is the first one in the list.
5. Apply migrations to enable the HStore extension:
.. code-block:: bash
$ python manage.py migrate
.. note::
Migrations might fail to be applied if the PostgreSQL user applying the migration is not a super user. Enabling/creating extensions requires superuser permission. Not a superuser? Ask your database administrator to create the ``hstore`` extension on your PostgreSQL server manually using the following statement:
.. code-block:: sql
CREATE EXTENSION IF NOT EXISTS hstore;
Then, fake apply the migration to tell Django that the migration was applied already:
.. code-block:: bash
python manage.py migrate localized_fields --fake

View File

@ -0,0 +1,79 @@
Working with localized values
=============================
.. note::
All examples below assume a model declared like this:
.. code-block:: python
from localized_fields.models import LocalizedModel
from localized_fields.fields import LocalizedField
class MyModel(LocalizedModel):
title = LocalizedField()
Localized content is represented by ``localized_fields.value.LocalizedValue``. Which is essentially a dictionary where the key is the language and the value the content in the respective language.
.. code-block:: python
from localized_fields.value import LocalizedValue
obj = MyModel.objects.first()
assert isistance(obj.title, LocalizedValue) # True
With fallback
-------------
.. seealso::
Configure :ref:`LOCALIZED_FIELDS_FALLBACKS <LOCALIZED_FIELDS_FALLBACKS>` to control the fallback behaviour.
Active language
***************
.. code-block:: python
# gets content in Arabic, falls back to next language
# if not availble
translation.activate('ar')
obj.title.translate()
# alternative: cast to string
title_ar = str(obj.title)
Specific language
*****************
.. code-block:: python
# gets content in Arabic, falls back to next language
# if not availble
obj.title.translate('ar')
Without fallback
----------------
Specific language
*****************
.. code-block:: python
# gets content in Dutch, None if not available
# no fallback to secondary languages here!
obj.title.nl
Specific language dynamically
*****************************
.. code-block:: python
# gets content in Dutch, None if not available
# no fallback to secondary languages here!
obj.title.get('nl')

59
docs/source/querying.rst Normal file
View File

@ -0,0 +1,59 @@
Querying localized content
==========================
.. note::
All examples below assume a model declared like this:
.. code-block:: python
from localized_fields.models import LocalizedModel
from localized_fields.fields import LocalizedField
class MyModel(LocalizedModel):
title = LocalizedField()
Active language
---------------
Only need a value in a specific language? Use the ``LocalizedRef`` expression to query a value in the currently active language:
.. code-block:: python
from localized_fields.expressions import LocalizedRef
MyModel.objects.create(title=dict(en="Hello", nl="Hallo"))
translation.activate("nl")
english_title = (
MyModel
.objects
.annotate(title=LocalizedRef("title"))
.values_list("title", flat=True)
.first()
)
print(english_title) # prints "Hallo"
Specific language
-----------------
.. code-block:: python
from localized_fields.expressions import LocalizedRef
result = (
MyModel
.objects
.values(
'title__en',
'title__nl',
)
.first()
)
print(result['title__en'])
print(result['title__nl'])

140
docs/source/quick_start.rst Normal file
View File

@ -0,0 +1,140 @@
.. _django.utils.translation.override: https://docs.djangoproject.com/en/2.2/ref/utils/#django.utils.translation.override
.. _django.db.models.TextField: https://docs.djangoproject.com/en/2.2/ref/models/fields/#django.db.models.TextField
.. _LANGUAGES: https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-LANGUAGE_CODE
.. _LANGUAGE_CODE: https://docs.djangoproject.com/en/2.2/ref/settings/#languages
Quick start
===========
.. warning::
This assumes you have followed the :ref:`Installation guide <installation>`.
``django-localized-fields`` provides various model field types to store content in multiple languages. The most basic of them all is ``LocalizedField`` which stores text of arbitrary length (like `django.db.models.TextField`_).
Declaring a model
-----------------
.. code-block:: python
from localized_fields.models import LocalizedModel
from localized_fields.fields import LocalizedField
class MyModel(LocalizedModel):
title = LocalizedField()
This creates a model with one localized field. Inside the ``LocalizedField``, strings can be stored in multiple languages. There are more fields like this for different data types (integers, images etc). ``LocalizedField`` is the most basic of them all.
Saving localized content
------------------------
You can now save text in ``MyModel.title`` in all languages you defined in the `LANGUAGES`_ setting. A short example:
.. code-block:: python
newobj = MyModel()
newobj.title.en = "Hello"
newobj.title.ar = "مرحبا"
newobj.title.nl = "Hallo"
newobj.save()
There are various other ways of saving localized content. For example, all fields can be set at once by assigning a ``dict``:
.. code-block:: python
newobj = MyModel()
newobj.title = dict(en="Hello", ar="مرحبا", nl="Hallo")
newobj.save()
This also works when using the ``create`` function:
.. code-block:: python
newobj = MyModel.objects.create(title=dict(en="Hello", ar="مرحبا", nl="Hallo"))
Need to set the content dynamically? Use the ``set`` function:
.. code-block:: python
newobj = MyModel()
newobj.title.set("en", "Hello")
.. note::
Localized field values (``localized_fields.value.LocalizedValue``) act like dictionaries. In fact, ``LocalizedValue`` extends ``dict``. Anything that works on a ``dict`` works on ``LocalizedValue``.
Retrieving localized content
----------------------------
When querying, the currently active language is taken into account. If there is no active language set, the default language is returned (set by the `LANGUAGE_CODE`_ setting).
.. code-block:: python
from django.utils import translation
obj = MyModel.objects.first()
print(obj.title) # prints "Hello"
translation.activate("ar")
print(obj.title) # prints "مرحبا"
str(obj.title) # same as printing, forces translation to active language
translation.activate("nl")
print(obj.title) # prints "Hallo"
.. note::
Use `django.utils.translation.override`_ to change the language for just a block of code rather than setting the language globally:
.. code-block:: python
from django.utils import translation
with translation.override("nl"):
print(obj.title) # prints "Hallo"
Fallback
********
If there is no content for the currently active language, a fallback kicks in where the content will be returned in the next language. The fallback order is controlled by the order set in the `LANGUAGES`_ setting.
.. code-block:: python
obj = MyModel.objects.create(dict(en="Hallo", ar="مرحبا"))
translation.activate("nl")
print(obj.title) # prints "مرحبا" because there"s no content in NL
.. seealso::
Use the :ref:`LOCALIZED_FIELDS_FALLBACKS <LOCALIZED_FIELDS_FALLBACKS>` setting to control the fallback behaviour.
Cast to str
***********
Want to get the value in the currently active language without casting to ``str``? (For null-able fields for example). Use the ``.translate()`` function:
.. code-block:: python
obj = MyModel.objects.create(dict(en="Hallo", ar="مرحبا"))
str(obj.title) == obj.title.translate() # True
.. note::
``str(..)`` is guarenteed to return a string. If the value is ``None``, ``str(..)`` returns an empty string. ``translate()`` would return ``None``. This is because Python forces the ``__str__`` function to return a string.
.. code-block:: python
obj = MyModel.objects.create(dict(en="Hallo"))
translation.activate('nl')
str(obj.title) # ""
obj.title.translate() # None

22
docs/source/releases.rst Normal file
View File

@ -0,0 +1,22 @@
Releases
========
v6.0
----
Breaking changes
****************
* Removes support for Python 3.6 and earlier.
* Removes support for PostgreSQL 9.6 and earlier.
* Sets ``LOCALIZED_FIELDS_EXPERIMENTAL`` to ``True`` by default.
Bug fixes
*********
* Fixes a bug where ``LocalizedIntegerField`` could not be used in ``order_by``.
Other
*****
* ``LocalizedValue.translate()`` can now takes an optional ``language`` parameter.

82
docs/source/saving.rst Normal file
View File

@ -0,0 +1,82 @@
Saving localized content
========================
.. note::
All examples below assume a model declared like this:
.. code-block:: python
from localized_fields.models import LocalizedModel
from localized_fields.fields import LocalizedField
class MyModel(LocalizedModel):
title = LocalizedField()
Individual assignment
*********************
.. code-block:: python
obj = MyModel()
obj.title.en = 'Hello'
obj.title.nl = 'Hallo'
obj.save()
Individual dynamic assignment
*****************************
.. code-block:: python
obj = MyModel()
obj.title.set('en', 'Hello')
obj.title.set('nl', 'Hallo')
obj.save()
Multiple assignment
*******************
.. code-block:: python
obj = MyModel()
obj.title = dict(en='Hello', nl='Hallo')
obj.save()
obj = MyModel(title=dict(en='Hello', nl='Hallo'))
obj.save()
obj = MyModel.objects.create(title=dict(en='Hello', nl='Hallo'))
Default language assignment
***************************
.. code-block:: python
obj = MyModel()
obj.title = 'Hello' # assumes value is in default language
obj.save()
obj = MyModel(title='Hello') # assumes value is in default language
obj.save()
obj = MyModel.objects.create(title='title') # assumes value is in default language
Array assignment
****************
.. code-block:: python
obj = MyModel()
obj.title = ['Hello', 'Hallo'] # order according to LANGUAGES
obj.save()
obj = MyModel(title=['Hello', 'Hallo']) # order according to LANGUAGES
obj.save()
obj = MyModel.objects.create(title=['Hello', 'Hallo']) # order according to LANGUAGES

39
docs/source/settings.rst Normal file
View File

@ -0,0 +1,39 @@
.. _LANGUAGES: https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-LANGUAGE_CODE
.. _LANGUAGE_CODE: https://docs.djangoproject.com/en/2.2/ref/settings/#languages
Settings
========
.. LOCALIZED_FIELDS_EXPERIMENTAL:
* ``LOCALIZED_FIELDS_EXPERIMENTAL``
.. note::
Disabled in v5.x and earlier. Enabled by default since v6.0.
When enabled:
* ``LocalizedField`` will return ``None`` instead of an empty ``LocalizedValue`` if there is no database value.
* ``LocalizedField`` lookups will by the currently active language instead of an exact match by dict.
.. _LOCALIZED_FIELDS_FALLBACKS:
* ``LOCALIZED_FIELDS_FALLBACKS``
List of language codes which define the order in which fallbacks should happen. If a value is not available in a specific language, we'll try to pick the value in the next language in the list.
.. warning::
If this setting is not configured, the default behaviour is to fall back to the value in the **default language**. It is recommended to configure this setting to get predictible fallback behaviour that suits your use case.
Use the same language codes as you used for configuring the `LANGUAGES`_ and `LANGUAGE_CODE`_ setting.
.. code-block:: python
LOCALIZED_FIELDS_FALLBACKS = {
"en": ["nl", "ar"], # if trying to get EN, but not available, try NL and then AR
"nl": ["en", "ar"], # if trying to get NL, but not available, try EN and then AR
"ar": ["en", "nl"], # if trying to get AR, but not available, try EN and then NL
}

View File

@ -1,18 +1,4 @@
from .util import get_language_codes import django
from .forms import LocalizedFieldForm, LocalizedFieldWidget
from .fields import (LocalizedField, LocalizedBleachField,
LocalizedAutoSlugField, LocalizedMagicSlugField)
from .localized_value import LocalizedValue
from .models import LocalizedModel
__all__ = [ if django.VERSION < (3, 2):
'get_language_codes', default_app_config = "localized_fields.apps.LocalizedFieldsConfig"
'LocalizedField',
'LocalizedValue',
'LocalizedAutoSlugField',
'LocalizedMagicSlugField',
'LocalizedBleachField',
'LocalizedFieldWidget',
'LocalizedFieldForm',
'LocalizedModel'
]

36
localized_fields/admin.py Normal file
View File

@ -0,0 +1,36 @@
from . import widgets
from .fields import (
LocalizedBooleanField,
LocalizedCharField,
LocalizedField,
LocalizedFileField,
LocalizedTextField,
)
FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS = {
LocalizedField: {"widget": widgets.AdminLocalizedFieldWidget},
LocalizedCharField: {"widget": widgets.AdminLocalizedCharFieldWidget},
LocalizedTextField: {"widget": widgets.AdminLocalizedFieldWidget},
LocalizedFileField: {"widget": widgets.AdminLocalizedFileFieldWidget},
LocalizedBooleanField: {"widget": widgets.AdminLocalizedBooleanFieldWidget},
}
class LocalizedFieldsAdminMixin:
"""Mixin for making the fancy widgets work in Django Admin."""
class Media:
css = {"all": ("localized_fields/localized-fields-admin.css",)}
js = (
"admin/js/jquery.init.js",
"localized_fields/localized-fields-admin.js",
)
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedFieldsAdminMixin."""
super().__init__(*args, **kwargs)
overrides = FORMFIELD_FOR_LOCALIZED_FIELDS_DEFAULTS.copy()
overrides.update(self.formfield_overrides)
self.formfield_overrides = overrides

View File

@ -1,5 +1,21 @@
import inspect
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
from . import lookups
from .fields import LocalizedField
from .lookups import LocalizedLookupMixin
class LocalizedFieldsConfig(AppConfig): class LocalizedFieldsConfig(AppConfig):
name = 'localized_fields' name = "localized_fields"
def ready(self):
if getattr(settings, "LOCALIZED_FIELDS_EXPERIMENTAL", True):
for _, clazz in inspect.getmembers(lookups):
if not inspect.isclass(clazz) or clazz is LocalizedLookupMixin:
continue
if issubclass(clazz, LocalizedLookupMixin):
LocalizedField.register_lookup(clazz)

View File

@ -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')

View File

@ -0,0 +1,66 @@
from django.conf import settings
from django.utils import translation
class LocalizedValueDescriptor:
"""The descriptor for the localized value attribute on the model instance.
Returns a :see:LocalizedValue when accessed so you can do stuff like::
>>> from myapp.models import MyModel
>>> instance = MyModel()
>>> instance.value.en = 'English value'
Assigns a strings to active language key in :see:LocalizedValue on
assignment so you can do::
>>> from django.utils import translation
>>> from myapp.models import MyModel
>>> translation.activate('nl')
>>> instance = MyModel()
>>> instance.title = 'dutch title'
>>> print(instance.title.nl) # prints 'dutch title'
"""
def __init__(self, field):
"""Initializes a new instance of :see:LocalizedValueDescriptor."""
self.field = field
def __get__(self, instance, cls=None):
if instance is None:
return self
# This is slightly complicated, so worth an explanation.
# `instance.localizedvalue` needs to ultimately return some instance of
# `LocalizedValue`, probably a subclass.
# The instance dict contains whatever was originally assigned
# in __set__.
if self.field.name in instance.__dict__:
value = instance.__dict__[self.field.name]
elif not instance._state.adding:
instance.refresh_from_db(fields=[self.field.name])
value = getattr(instance, self.field.name)
else:
value = None
if value is None:
attr = self.field.attr_class()
instance.__dict__[self.field.name] = attr
if isinstance(value, dict):
attr = self.field.attr_class(value)
instance.__dict__[self.field.name] = attr
return instance.__dict__[self.field.name]
def __set__(self, instance, value):
if isinstance(value, str):
language = translation.get_language() or settings.LANGUAGE_CODE
self.__get__(instance).set(
language, value
) # pylint: disable=no-member
else:
instance.__dict__[self.field.name] = value

View File

@ -0,0 +1,24 @@
from django.conf import settings
from django.utils import translation
from psqlextra import expressions
class LocalizedRef(expressions.HStoreRef):
"""Expression that selects the value in a field only in the currently
active language."""
def __init__(self, name: str, lang: str = None):
"""Initializes a new instance of :see:LocalizedRef.
Arguments:
name:
The field/column to select from.
lang:
The language to get the field/column in.
If not specified, the currently active language
is used.
"""
language = lang or translation.get_language() or settings.LANGUAGE_CODE
super().__init__(name, language)

View File

@ -1,12 +1,28 @@
from .localized_field import LocalizedField from .autoslug_field import LocalizedAutoSlugField
from .localized_autoslug_field import LocalizedAutoSlugField from .boolean_field import LocalizedBooleanField
from .localized_magicslug_field import LocalizedMagicSlugField from .char_field import LocalizedCharField
from .localized_bleach_field import LocalizedBleachField from .field import LocalizedField
from .file_field import LocalizedFileField
from .float_field import LocalizedFloatField
from .integer_field import LocalizedIntegerField
from .text_field import LocalizedTextField
from .uniqueslug_field import LocalizedUniqueSlugField
__all__ = [ __all__ = [
'LocalizedField', "LocalizedField",
'LocalizedAutoSlugField', "LocalizedAutoSlugField",
'LocalizedMagicSlugField', "LocalizedUniqueSlugField",
'LocalizedBleachField', "LocalizedCharField",
"LocalizedTextField",
"LocalizedFileField",
"LocalizedIntegerField",
"LocalizedFloatField",
"LocalizedBooleanField",
] ]
try:
from .bleach_field import LocalizedBleachField
__all__ += ["LocalizedBleachField"]
except ImportError:
pass

View File

@ -0,0 +1,186 @@
import warnings
from datetime import datetime
from typing import Callable, Tuple, Union
from django import forms
from django.conf import settings
from django.utils import translation
from django.utils.text import slugify
from ..util import resolve_object_property
from ..value import LocalizedValue
from .field import LocalizedField
class LocalizedAutoSlugField(LocalizedField):
"""Automatically provides slugs for a localized field upon saving."""
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedAutoSlugField."""
warnings.warn(
"LocalizedAutoSlug is deprecated and will be removed in the next major version.",
DeprecationWarning,
)
self.populate_from = kwargs.pop("populate_from", None)
self.include_time = kwargs.pop("include_time", False)
super(LocalizedAutoSlugField, self).__init__(*args, **kwargs)
def deconstruct(self):
"""Deconstructs the field into something the database can store."""
name, path, args, kwargs = super(
LocalizedAutoSlugField, self
).deconstruct()
kwargs["populate_from"] = self.populate_from
kwargs["include_time"] = self.include_time
return name, path, args, kwargs
def formfield(self, **kwargs):
"""Gets the form field associated with this field.
Because this is a slug field which is automatically populated,
it should be hidden from the form.
"""
defaults = {"form_class": forms.CharField, "required": False}
defaults.update(kwargs)
form_field = super().formfield(**defaults)
form_field.widget = forms.HiddenInput()
return form_field
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.
"""
slugs = LocalizedValue()
for lang_code, value in self._get_populate_values(instance):
if not value:
continue
if self.include_time:
value += "-%s" % datetime.now().microsecond
def is_unique(slug: str, language: str) -> bool:
"""Gets whether the specified slug is unique."""
unique_filter = {"%s__%s" % (self.name, language): slug}
return (
not type(instance).objects.filter(**unique_filter).exists()
)
slug = self._make_unique_slug(
slugify(value, allow_unicode=True), lang_code, is_unique
)
slugs.set(lang_code, slug)
setattr(instance, self.name, slugs)
return slugs
@staticmethod
def _make_unique_slug(
slug: str, language: str, is_unique: Callable[[str], bool]
) -> str:
"""Guarentees that the specified slug is unique by appending a number
until it is unique.
Arguments:
slug:
The slug to make unique.
is_unique:
Function that can be called to verify
whether the generate slug is unique.
Returns:
A guarenteed unique slug.
"""
index = 1
unique_slug = slug
while not is_unique(unique_slug, language):
unique_slug = "%s-%d" % (slug, index)
index += 1
return unique_slug
def _get_populate_values(self, instance) -> Tuple[str, str]:
"""Gets all values (for each language) from the specified's instance's
`populate_from` field.
Arguments:
instance:
The instance to get the values from.
Returns:
A list of (lang_code, value) tuples.
"""
return [
(
lang_code,
self._get_populate_from_value(
instance, self.populate_from, lang_code
),
)
for lang_code, _ in settings.LANGUAGES
]
@staticmethod
def _get_populate_from_value(
instance, field_name: Union[str, Tuple[str]], language: str
):
"""Gets the value to create a slug from in the specified language.
Arguments:
instance:
The model that the field resides on.
field_name:
The name of the field to generate a slug for.
language:
The language to generate the slug for.
Returns:
The text to generate a slug for.
"""
if callable(field_name):
return field_name(instance)
def get_field_value(name):
value = resolve_object_property(instance, name)
with translation.override(language):
return str(value)
if isinstance(field_name, tuple) or isinstance(field_name, list):
value = "-".join(
[
value
for value in [get_field_value(name) for name in field_name]
if value
]
)
return value
return get_field_value(field_name)

View File

@ -0,0 +1,74 @@
import html
from django.conf import settings
from .field import LocalizedField
class LocalizedBleachField(LocalizedField):
"""Custom version of :see:BleachField that is actually a
:see:LocalizedField."""
DEFAULT_SHOULD_ESCAPE = True
def __init__(self, *args, escape=True, **kwargs):
"""Initializes a new instance of :see:LocalizedBleachField."""
self.escape = escape
super().__init__(*args, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
if self.escape != self.DEFAULT_SHOULD_ESCAPE:
kwargs["escape"] = self.escape
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.
"""
# the bleach library vendors dependencies and the html5lib
# dependency is incompatible with python 3.9, until that's
# fixed, you cannot use LocalizedBleachField with python 3.9
# sympton:
# ImportError: cannot import name 'Mapping' from 'collections'
try:
import bleach
from django_bleach.utils import get_bleach_default_options
except ImportError:
raise UserWarning(
"LocalizedBleachField is not compatible with Python 3.9 yet."
)
localized_value = getattr(instance, self.attname)
if not localized_value:
return None
for lang_code, _ in settings.LANGUAGES:
value = localized_value.get(lang_code)
if not value:
continue
cleaned_value = bleach.clean(
value if self.escape else html.unescape(value),
**get_bleach_default_options()
)
localized_value.set(
lang_code,
cleaned_value if self.escape else html.unescape(cleaned_value),
)
return localized_value

View File

@ -0,0 +1,110 @@
from typing import Dict, Optional, Union
from django.conf import settings
from django.db.utils import IntegrityError
from ..forms import LocalizedBooleanFieldForm
from ..value import LocalizedBooleanValue, LocalizedValue
from .field import LocalizedField
class LocalizedBooleanField(LocalizedField):
"""Stores booleans as a localized value."""
attr_class = LocalizedBooleanValue
@classmethod
def from_db_value(cls, value, *_) -> Optional[LocalizedBooleanValue]:
db_value = super().from_db_value(value)
if db_value is None:
return db_value
if isinstance(db_value, str):
if db_value.lower() == "true":
return True
return False
if not isinstance(db_value, LocalizedValue):
return db_value
return cls._convert_localized_value(db_value)
def to_python(
self, value: Union[Dict[str, str], str, None]
) -> LocalizedBooleanValue:
"""Converts the value from a database value into a Python value."""
db_value = super().to_python(value)
return self._convert_localized_value(db_value)
def get_prep_value(self, value: LocalizedBooleanValue) -> dict:
"""Gets the value in a format to store into the database."""
# apply default values
default_values = LocalizedBooleanValue(self.default)
if isinstance(value, LocalizedBooleanValue):
for lang_code, _ in settings.LANGUAGES:
local_value = value.get(lang_code)
if local_value is None:
value.set(lang_code, default_values.get(lang_code, None))
prepped_value = super().get_prep_value(value)
if prepped_value is None:
return None
# make sure all values are proper values to be converted to bool
for lang_code, _ in settings.LANGUAGES:
local_value = prepped_value[lang_code]
if local_value is not None and local_value.lower() not in (
"false",
"true",
):
raise IntegrityError(
'non-boolean value in column "%s.%s" violates '
"boolean constraint" % (self.name, lang_code)
)
# convert to a string before saving because the underlying
# type is hstore, which only accept strings
prepped_value[lang_code] = (
str(local_value) if local_value is not None else None
)
return prepped_value
def formfield(self, **kwargs):
"""Gets the form field associated with this field."""
defaults = {"form_class": LocalizedBooleanFieldForm}
defaults.update(kwargs)
return super().formfield(**defaults)
@staticmethod
def _convert_localized_value(
value: LocalizedValue,
) -> LocalizedBooleanValue:
"""Converts from :see:LocalizedValue to :see:LocalizedBooleanValue."""
integer_values = {}
for lang_code, _ in settings.LANGUAGES:
local_value = value.get(lang_code, None)
if isinstance(local_value, str):
if local_value.lower() == "false":
local_value = False
elif local_value.lower() == "true":
local_value = True
else:
raise ValueError(
f"Could not convert value {local_value} to boolean."
)
integer_values[lang_code] = local_value
elif local_value is not None:
raise TypeError(
f"Expected value of type str instead of {type(local_value)}."
)
return LocalizedBooleanValue(integer_values)

View File

@ -0,0 +1,14 @@
from ..forms import LocalizedCharFieldForm
from ..value import LocalizedStringValue
from .field import LocalizedField
class LocalizedCharField(LocalizedField):
attr_class = LocalizedStringValue
def formfield(self, **kwargs):
"""Gets the form field associated with this field."""
defaults = {"form_class": LocalizedCharFieldForm}
defaults.update(kwargs)
return super().formfield(**defaults)

View File

@ -0,0 +1,235 @@
import json
from typing import List, Optional, Union
from django.conf import settings
from django.db.utils import IntegrityError
from psqlextra.fields import HStoreField
from ..descriptor import LocalizedValueDescriptor
from ..forms import LocalizedFieldForm
from ..value import LocalizedValue
class LocalizedField(HStoreField):
"""A field that has the same value in multiple languages.
Internally this is stored as a :see:HStoreField where there is a key
for every language.
"""
Meta = None
# The class to wrap instance attributes in. Accessing to field attribute in
# model instance will always return an instance of attr_class.
attr_class = LocalizedValue
# The descriptor to use for accessing the attribute off of the class.
descriptor_class = LocalizedValueDescriptor
def __init__(
self,
*args,
required: Optional[Union[bool, List[str]]] = None,
blank: bool = False,
**kwargs
):
"""Initializes a new instance of :see:LocalizedField."""
if (required is None and blank) or required is False:
self.required = []
elif required is None and not blank:
self.required = [settings.LANGUAGE_CODE]
elif required is True:
self.required = [lang_code for lang_code, _ in settings.LANGUAGES]
else:
self.required = required
super(LocalizedField, self).__init__(
*args, required=self.required, blank=blank, **kwargs
)
def contribute_to_class(self, model, name, **kwargs):
"""Adds this field to the specifed model.
Arguments:
cls:
The model to add the field to.
name:
The name of the field to add.
"""
super(LocalizedField, self).contribute_to_class(model, name, **kwargs)
setattr(model, self.name, self.descriptor_class(self))
@classmethod
def from_db_value(cls, value, *_) -> Optional[LocalizedValue]:
"""Turns the specified database value into its Python equivalent.
Arguments:
value:
The value that is stored in the database and
needs to be converted to its Python equivalent.
Returns:
A :see:LocalizedValue instance containing the
data extracted from the database.
"""
if not value:
if getattr(settings, "LOCALIZED_FIELDS_EXPERIMENTAL", True):
return None
else:
return cls.attr_class()
# we can get a list if an aggregation expression was used..
# if we the expression was flattened when only one key was selected
# then we don't wrap each value in a localized value, otherwise we do
if isinstance(value, list):
result = []
for inner_val in value:
if isinstance(inner_val, dict):
if inner_val is None:
result.append(None)
else:
result.append(cls.attr_class(inner_val))
else:
result.append(inner_val)
return result
# this is for when you select an individual key, it will be string,
# not a dictionary, we'll give it to you as a flat value, not as a
# localized value instance
if not isinstance(value, dict):
return value
return cls.attr_class(value)
def to_python(self, value: Union[dict, str, None]) -> LocalizedValue:
"""Turns the specified database value into its Python equivalent.
Arguments:
value:
The value that is stored in the database and
needs to be converted to its Python equivalent.
Returns:
A :see:LocalizedValue instance containing the
data extracted from the database.
"""
# first let the base class handle the deserialization, this is in case we
# get specified a json string representing a dict
try:
deserialized_value = super(LocalizedField, self).to_python(value)
except json.JSONDecodeError:
deserialized_value = value
if not deserialized_value:
return self.attr_class()
return self.attr_class(deserialized_value)
def get_prep_value(self, value: LocalizedValue) -> dict:
"""Turns the specified value into something the database can store.
If an illegal value (non-LocalizedValue instance) is
specified, we'll treat it as an empty :see:LocalizedValue
instance, on which the validation will fail.
Dictonaries are converted into :see:LocalizedValue instances.
Arguments:
value:
The :see:LocalizedValue instance to serialize
into a data type that the database can understand.
Returns:
A dictionary containing a key for every language,
extracted from the specified value.
"""
if isinstance(value, dict):
value = LocalizedValue(value)
# default to None if this is an unknown type
if not isinstance(value, LocalizedValue) and value:
value = None
if value:
cleaned_value = self.clean(value)
self.validate(cleaned_value)
else:
cleaned_value = value
return super(LocalizedField, self).get_prep_value(
cleaned_value.__dict__ if cleaned_value else None
)
def clean(self, value, *_):
"""Cleans the specified value into something we can store in the
database.
For example, when all the language fields are
left empty, and the field is allowed to be null,
we will store None instead of empty keys.
Arguments:
value:
The value to clean.
Returns:
The cleaned value, ready for database storage.
"""
if not value or not isinstance(value, LocalizedValue):
return None
# are any of the language fiels None/empty?
is_all_null = True
for lang_code, _ in settings.LANGUAGES:
if value.get(lang_code) is not None:
is_all_null = False
break
# all fields have been left empty and we support
# null values, let's return null to represent that
if is_all_null and self.null:
return None
return value
def validate(self, value: LocalizedValue, *_):
"""Validates that the values has been filled in for all required
languages.
Exceptions are raises in order to notify the user
of invalid values.
Arguments:
value:
The value to validate.
"""
if self.null:
return
for lang in self.required:
lang_val = getattr(value, settings.LANGUAGE_CODE)
if lang_val is None:
raise IntegrityError(
'null value in column "%s.%s" violates '
"not-null constraint" % (self.name, lang)
)
def formfield(self, **kwargs):
"""Gets the form field associated with this field."""
defaults = dict(
form_class=LocalizedFieldForm,
required=False if self.blank else self.required,
)
defaults.update(kwargs)
return super().formfield(**defaults)

View File

@ -0,0 +1,162 @@
import datetime
import json
import posixpath
from django.core.files import File
from django.core.files.storage import default_storage
from django.db.models.fields.files import FieldFile
from django.utils.encoding import force_str
from localized_fields.fields import LocalizedField
from localized_fields.fields.field import LocalizedValueDescriptor
from localized_fields.value import LocalizedValue
from ..forms import LocalizedFileFieldForm
from ..value import LocalizedFileValue
class LocalizedFieldFile(FieldFile):
def __init__(self, instance, field, name, lang):
super().__init__(instance, field, name)
self.lang = lang
def save(self, name, content, save=True):
name = self.field.generate_filename(self.instance, name, self.lang)
self.name = self.storage.save(
name, content, max_length=self.field.max_length
)
self._committed = True
if save:
self.instance.save()
save.alters_data = True
def delete(self, save=True):
if not self:
return
if hasattr(self, "_file"):
self.close()
del self.file
self.storage.delete(self.name)
self.name = None
self._committed = False
if save:
self.instance.save()
delete.alters_data = True
class LocalizedFileValueDescriptor(LocalizedValueDescriptor):
def __get__(self, instance, cls=None):
value = super().__get__(instance, cls)
for lang, file in value.__dict__.items():
if isinstance(file, str) or file is None:
file = self.field.value_class(instance, self.field, file, lang)
value.set(lang, file)
elif isinstance(file, File) and not isinstance(
file, LocalizedFieldFile
):
file_copy = self.field.value_class(
instance, self.field, file.name, lang
)
file_copy.file = file
file_copy._committed = False
value.set(lang, file_copy)
elif isinstance(file, LocalizedFieldFile) and not hasattr(
file, "field"
):
file.instance = instance
file.field = self.field
file.storage = self.field.storage
file.lang = lang
# Make sure that the instance is correct.
elif (
isinstance(file, LocalizedFieldFile)
and instance is not file.instance
):
file.instance = instance
file.lang = lang
return value
class LocalizedFileField(LocalizedField):
descriptor_class = LocalizedFileValueDescriptor
attr_class = LocalizedFileValue
value_class = LocalizedFieldFile
def __init__(
self, verbose_name=None, name=None, upload_to="", storage=None, **kwargs
):
self.storage = storage or default_storage
self.upload_to = upload_to
super().__init__(verbose_name, name, **kwargs)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
kwargs["upload_to"] = self.upload_to
if self.storage is not default_storage:
kwargs["storage"] = self.storage
return name, path, args, kwargs
def get_prep_value(self, value):
"""Returns field's value prepared for saving into a database."""
if isinstance(value, LocalizedValue):
prep_value = LocalizedValue()
for k, v in value.__dict__.items():
if v is None:
prep_value.set(k, "")
else:
# Need to convert File objects provided via a form to
# unicode for database insertion
prep_value.set(k, str(v))
return super().get_prep_value(prep_value)
return super().get_prep_value(value)
def pre_save(self, model_instance, add):
"""Returns field's value just before saving."""
value = super().pre_save(model_instance, add)
if isinstance(value, LocalizedValue):
for file in value.__dict__.values():
if file and not file._committed:
file.save(file.name, file, save=False)
return value
def generate_filename(self, instance, filename, lang):
if callable(self.upload_to):
filename = self.upload_to(instance, filename, lang)
else:
now = datetime.datetime.now()
dirname = force_str(now.strftime(force_str(self.upload_to)))
dirname = dirname.format(lang=lang)
filename = posixpath.join(dirname, filename)
return self.storage.generate_filename(filename)
def save_form_data(self, instance, data):
if isinstance(data, LocalizedValue):
for k, v in data.__dict__.items():
if v is not None and not v:
data.set(k, "")
setattr(instance, self.name, data)
def formfield(self, **kwargs):
defaults = {"form_class": LocalizedFileFieldForm}
defaults.update(kwargs)
return super().formfield(**defaults)
def value_to_string(self, obj):
value = self.value_from_object(obj)
if isinstance(value, LocalizedFileValue):
return json.dumps({k: v.name for k, v in value.__dict__.items()})
else:
return super().value_to_string(obj)

View File

@ -0,0 +1,95 @@
from typing import Dict, Optional, Union
from django.conf import settings
from django.db.utils import IntegrityError
from ..forms import LocalizedIntegerFieldForm
from ..value import LocalizedFloatValue, LocalizedValue
from .field import LocalizedField
class LocalizedFloatField(LocalizedField):
"""Stores float as a localized value."""
attr_class = LocalizedFloatValue
@classmethod
def from_db_value(cls, value, *_) -> Optional[LocalizedFloatValue]:
db_value = super().from_db_value(value)
if db_value is None:
return db_value
# if we were used in an expression somehow then it might be
# that we're returning an individual value or an array, so
# we should not convert that into an :see:LocalizedFloatValue
if not isinstance(db_value, LocalizedValue):
return db_value
return cls._convert_localized_value(db_value)
def to_python(
self, value: Union[Dict[str, int], int, None]
) -> LocalizedFloatValue:
"""Converts the value from a database value into a Python value."""
db_value = super().to_python(value)
return self._convert_localized_value(db_value)
def get_prep_value(self, value: LocalizedFloatValue) -> dict:
"""Gets the value in a format to store into the database."""
# apply default values
default_values = LocalizedFloatValue(self.default)
if isinstance(value, LocalizedFloatValue):
for lang_code, _ in settings.LANGUAGES:
local_value = value.get(lang_code)
if local_value is None:
value.set(lang_code, default_values.get(lang_code, None))
prepped_value = super().get_prep_value(value)
if prepped_value is None:
return None
# make sure all values are proper floats
for lang_code, _ in settings.LANGUAGES:
local_value = prepped_value[lang_code]
try:
if local_value is not None:
float(local_value)
except (TypeError, ValueError):
raise IntegrityError(
'non-float value in column "%s.%s" violates '
"float constraint" % (self.name, lang_code)
)
# convert to a string before saving because the underlying
# type is hstore, which only accept strings
prepped_value[lang_code] = (
str(local_value) if local_value is not None else None
)
return prepped_value
def formfield(self, **kwargs):
"""Gets the form field associated with this field."""
defaults = {"form_class": LocalizedIntegerFieldForm}
defaults.update(kwargs)
return super().formfield(**defaults)
@staticmethod
def _convert_localized_value(value: LocalizedValue) -> LocalizedFloatValue:
"""Converts from :see:LocalizedValue to :see:LocalizedFloatValue."""
float_values = {}
for lang_code, _ in settings.LANGUAGES:
local_value = value.get(lang_code, None)
if local_value is None or local_value.strip() == "":
local_value = None
try:
float_values[lang_code] = float(local_value)
except (ValueError, TypeError):
float_values[lang_code] = None
return LocalizedFloatValue(float_values)

View File

@ -0,0 +1,123 @@
from typing import Dict, Optional, Union
from django.conf import settings
from django.contrib.postgres.fields.hstore import KeyTransform
from django.db.utils import IntegrityError
from ..forms import LocalizedIntegerFieldForm
from ..value import LocalizedIntegerValue, LocalizedValue
from .field import LocalizedField
class LocalizedIntegerFieldKeyTransform(KeyTransform):
"""Transform that selects a single key from a hstore value and casts it to
an integer."""
def as_sql(self, compiler, connection):
sql, params = super().as_sql(compiler, connection)
return f"{sql}::integer", params
class LocalizedIntegerField(LocalizedField):
"""Stores integers as a localized value."""
attr_class = LocalizedIntegerValue
def get_transform(self, name):
"""Gets the transformation to apply when selecting this value.
This is where the SQL expression to grab a single is added and
the cast to integer so that sorting by a hstore value works as
expected.
"""
def _transform(*args, **kwargs):
return LocalizedIntegerFieldKeyTransform(name, *args, **kwargs)
return _transform
@classmethod
def from_db_value(cls, value, *_) -> Optional[LocalizedIntegerValue]:
db_value = super().from_db_value(value)
if db_value is None:
return db_value
if isinstance(db_value, str):
return int(db_value)
# if we were used in an expression somehow then it might be
# that we're returning an individual value or an array, so
# we should not convert that into an :see:LocalizedIntegerValue
if not isinstance(db_value, LocalizedValue):
return db_value
return cls._convert_localized_value(db_value)
def to_python(
self, value: Union[Dict[str, int], int, None]
) -> LocalizedIntegerValue:
"""Converts the value from a database value into a Python value."""
db_value = super().to_python(value)
return self._convert_localized_value(db_value)
def get_prep_value(self, value: LocalizedIntegerValue) -> dict:
"""Gets the value in a format to store into the database."""
# apply default values
default_values = LocalizedIntegerValue(self.default)
if isinstance(value, LocalizedIntegerValue):
for lang_code, _ in settings.LANGUAGES:
local_value = value.get(lang_code)
if local_value is None:
value.set(lang_code, default_values.get(lang_code, None))
prepped_value = super().get_prep_value(value)
if prepped_value is None:
return None
# make sure all values are proper integers
for lang_code, _ in settings.LANGUAGES:
local_value = prepped_value[lang_code]
try:
if local_value is not None:
int(local_value)
except (TypeError, ValueError):
raise IntegrityError(
'non-integer value in column "%s.%s" violates '
"integer constraint" % (self.name, lang_code)
)
# convert to a string before saving because the underlying
# type is hstore, which only accept strings
prepped_value[lang_code] = (
str(local_value) if local_value is not None else None
)
return prepped_value
def formfield(self, **kwargs):
"""Gets the form field associated with this field."""
defaults = {"form_class": LocalizedIntegerFieldForm}
defaults.update(kwargs)
return super().formfield(**defaults)
@staticmethod
def _convert_localized_value(
value: LocalizedValue,
) -> LocalizedIntegerValue:
"""Converts from :see:LocalizedValue to :see:LocalizedIntegerValue."""
integer_values = {}
for lang_code, _ in settings.LANGUAGES:
local_value = value.get(lang_code, None)
if local_value is None or local_value.strip() == "":
local_value = None
try:
integer_values[lang_code] = int(local_value)
except (ValueError, TypeError):
integer_values[lang_code] = None
return LocalizedIntegerValue(integer_values)

View File

@ -1,144 +0,0 @@
from typing import Callable
from django import forms
from django.conf import settings
from django.utils.text import slugify
from .localized_field import LocalizedField
from ..localized_value import LocalizedValue
class LocalizedAutoSlugField(LocalizedField):
"""Automatically provides slugs for a localized
field upon saving."""
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedAutoSlugField."""
self.populate_from = kwargs.pop('populate_from', None)
super(LocalizedAutoSlugField, self).__init__(
*args,
**kwargs
)
def deconstruct(self):
"""Deconstructs the field into something the database
can store."""
name, path, args, kwargs = super(
LocalizedAutoSlugField, self).deconstruct()
kwargs['populate_from'] = self.populate_from
return name, path, args, kwargs
def formfield(self, **kwargs):
"""Gets the form field associated with this field.
Because this is a slug field which is automatically
populated, it should be hidden from the form.
"""
defaults = {
'form_class': forms.CharField,
'required': False
}
defaults.update(kwargs)
form_field = super().formfield(**defaults)
form_field.widget = forms.HiddenInput()
return form_field
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.
"""
slugs = LocalizedValue()
for lang_code, _ in settings.LANGUAGES:
value = self._get_populate_from_value(
instance,
self.populate_from,
lang_code
)
if not value:
continue
def is_unique(slug: str, language: str) -> bool:
"""Gets whether the specified slug is unique."""
unique_filter = {
'%s__%s' % (self.name, language): slug
}
return not type(instance).objects.filter(**unique_filter).exists()
slug = self._make_unique_slug(
slugify(value, allow_unicode=True),
lang_code,
is_unique
)
slugs.set(lang_code, slug)
setattr(instance, self.name, slugs)
return slugs
@staticmethod
def _make_unique_slug(slug: str, language: str, is_unique: Callable[[str], bool]) -> str:
"""Guarentees that the specified slug is unique by appending
a number until it is unique.
Arguments:
slug:
The slug to make unique.
is_unique:
Function that can be called to verify
whether the generate slug is unique.
Returns:
A guarenteed unique slug.
"""
index = 1
unique_slug = slug
while not is_unique(unique_slug, language):
unique_slug = '%s-%d' % (slug, index)
index += 1
return unique_slug
@staticmethod
def _get_populate_from_value(instance, field_name: str, language: str):
"""Gets the value to create a slug from in the specified language.
Arguments:
instance:
The model that the field resides on.
field_name:
The name of the field to generate a slug for.
language:
The language to generate the slug for.
Returns:
The text to generate a slug for.
"""
value = getattr(instance, field_name, None)
return value.get(language)

View File

@ -1,39 +0,0 @@
import bleach
from django.conf import settings
from django_bleach.utils import get_bleach_default_options
from .localized_field import LocalizedField
class LocalizedBleachField(LocalizedField):
"""Custom version of :see:BleachField that
is actually a :see:LocalizedField."""
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.
"""
localized_value = getattr(instance, self.attname)
if not localized_value:
return None
for lang_code, _ in settings.LANGUAGES:
value = localized_value.get(lang_code)
if not value:
continue
localized_value.set(
lang_code,
bleach.clean(value, **get_bleach_default_options())
)
return localized_value

View File

@ -1,173 +0,0 @@
from django.conf import settings
from django.contrib.postgres.fields import HStoreField
from django.db.utils import IntegrityError
from localized_fields import LocalizedFieldForm
from ..localized_value import LocalizedValue
class LocalizedField(HStoreField):
"""A field that has the same value in multiple languages.
Internally this is stored as a :see:HStoreField where there
is a key for every language."""
Meta = None
def __init__(self, *args, uniqueness=None, **kwargs):
"""Initializes a new instance of :see:LocalizedValue."""
super(LocalizedField, self).__init__(*args, **kwargs)
self.uniqueness = uniqueness
@staticmethod
def from_db_value(value, *_):
"""Turns the specified database value into its Python
equivalent.
Arguments:
value:
The value that is stored in the database and
needs to be converted to its Python equivalent.
Returns:
A :see:LocalizedValue instance containing the
data extracted from the database.
"""
if not value:
return LocalizedValue()
return LocalizedValue(value)
def to_python(self, value: dict) -> LocalizedValue:
"""Turns the specified database value into its Python
equivalent.
Arguments:
value:
The value that is stored in the database and
needs to be converted to its Python equivalent.
Returns:
A :see:LocalizedValue instance containing the
data extracted from the database.
"""
if not value or not isinstance(value, dict):
return LocalizedValue()
return LocalizedValue(value)
def get_prep_value(self, value: LocalizedValue) -> dict:
"""Turns the specified value into something the database
can store.
If an illegal value (non-LocalizedValue instance) is
specified, we'll treat it as an empty :see:LocalizedValue
instance, on which the validation will fail.
Arguments:
value:
The :see:LocalizedValue instance to serialize
into a data type that the database can understand.
Returns:
A dictionary containing a key for every language,
extracted from the specified value.
"""
# default to None if this is an unknown type
if not isinstance(value, LocalizedValue) and value:
value = None
if value:
cleaned_value = self.clean(value)
self.validate(cleaned_value)
else:
cleaned_value = value
return super(LocalizedField, self).get_prep_value(
cleaned_value.__dict__ if cleaned_value else None
)
def clean(self, value, *_):
"""Cleans the specified value into something we
can store in the database.
For example, when all the language fields are
left empty, and the field is allows to be null,
we will store None instead of empty keys.
Arguments:
value:
The value to clean.
Returns:
The cleaned value, ready for database storage.
"""
if not value or not isinstance(value, LocalizedValue):
return None
# are any of the language fiels None/empty?
is_all_null = True
for lang_code, _ in settings.LANGUAGES:
if value.get(lang_code):
is_all_null = False
break
# all fields have been left empty and we support
# null values, let's return null to represent that
if is_all_null and self.null:
return None
return value
def validate(self, value: LocalizedValue, *_):
"""Validates that the value for the primary language
has been filled in.
Exceptions are raises in order to notify the user
of invalid values.
Arguments:
value:
The value to validate.
"""
if self.null:
return
primary_lang_val = getattr(value, settings.LANGUAGE_CODE)
if not primary_lang_val:
raise IntegrityError(
'null value in column "%s.%s" violates not-null constraint' % (
self.name,
settings.LANGUAGE_CODE
)
)
def formfield(self, **kwargs):
"""Gets the form field associated with this field."""
defaults = {
'form_class': LocalizedFieldForm
}
defaults.update(kwargs)
return super().formfield(**defaults)
def deconstruct(self):
"""Gets the values to pass to :see:__init__ when
re-creating this object."""
name, path, args, kwargs = super(
LocalizedField, self).deconstruct()
if self.uniqueness:
kwargs['uniqueness'] = self.uniqueness
return name, path, args, kwargs

View File

@ -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

View File

@ -0,0 +1,12 @@
from ..forms import LocalizedTextFieldForm
from .char_field import LocalizedCharField
class LocalizedTextField(LocalizedCharField):
def formfield(self, **kwargs):
"""Gets the form field associated with this field."""
defaults = {"form_class": LocalizedTextFieldForm}
defaults.update(kwargs)
return super().formfield(**defaults)

View File

@ -0,0 +1,124 @@
from datetime import datetime
from django.core.exceptions import ImproperlyConfigured
from django.utils.text import slugify
from ..mixins import AtomicSlugRetryMixin
from ..util import get_language_codes
from ..value import LocalizedValue
from .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.
By default, this creates a new slug if the field(s) specified
in `populate_from` are changed. Set `immutable=True` to get
immutable slugs.
"""
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedUniqueSlugField."""
kwargs["uniqueness"] = kwargs.pop("uniqueness", get_language_codes())
self.enabled = kwargs.pop("enabled", True)
self.immutable = kwargs.pop("immutable", False)
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
if self.enabled is False:
kwargs["enabled"] = self.enabled
if self.immutable is True:
kwargs["immutable"] = self.immutable
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 self.enabled:
return getattr(instance, self.name)
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, value in self._get_populate_values(instance):
if not value:
continue
slug = slugify(value, allow_unicode=True)
current_slug = getattr(instance, self.name).get(lang_code)
if current_slug and self.immutable:
slugs.set(lang_code, current_slug)
continue
# verify whether it's needed to re-generate a slug,
# if not, re-use the same slug
if instance.pk is not None:
if current_slug is not None:
current_slug_end_index = current_slug.rfind("-")
stripped_slug = current_slug[0:current_slug_end_index]
if slug == stripped_slug:
slugs.set(lang_code, current_slug)
continue
if self.include_time:
slug += "-%d" % datetime.now().microsecond
retries = getattr(instance, "retries", 0)
if retries > 0:
# do not add another - if we already added time
if not self.include_time:
slug += "-"
slug += "%d" % retries
slugs.set(lang_code, slug)
setattr(instance, self.name, slugs)
return slugs

View File

@ -1,77 +1,68 @@
from typing import List from typing import List, Union
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.forms import MultiWidget from django.core.exceptions import ValidationError
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from .localized_value import LocalizedValue from .value import (
LocalizedBooleanValue,
LocalizedFileValue,
class LocalizedFieldWidget(MultiWidget): LocalizedIntegerValue,
"""Widget that has an input box for every language.""" LocalizedStringValue,
LocalizedValue,
def __init__(self, *args, **kwargs): )
"""Initializes a new instance of :see:LocalizedFieldWidget.""" from .widgets import (
AdminLocalizedBooleanFieldWidget,
widgets = [] AdminLocalizedIntegerFieldWidget,
LocalizedCharFieldWidget,
for _ in settings.LANGUAGES: LocalizedFieldWidget,
widgets.append(forms.Textarea()) LocalizedFileWidget,
)
super(LocalizedFieldWidget, self).__init__(widgets, *args, **kwargs)
def decompress(self, value: LocalizedValue) -> List[str]:
"""Decompresses the specified value so
it can be spread over the internal widgets.
Arguments:
value:
The :see:LocalizedValue to display in this
widget.
Returns:
All values to display in the inner widgets.
"""
result = []
for lang_code, _ in settings.LANGUAGES:
if value:
result.append(value.get(lang_code))
else:
result.append(None)
return result
class LocalizedFieldForm(forms.MultiValueField): class LocalizedFieldForm(forms.MultiValueField):
"""Form for a localized field, allows editing """Form for a localized field, allows editing the field in multiple
the field in multiple languages.""" languages."""
widget = LocalizedFieldWidget() widget = LocalizedFieldWidget
field_class = forms.fields.CharField
value_class = LocalizedValue
def __init__(self, *args, **kwargs): def __init__(
self, *args, required: Union[bool, List[str]] = False, **kwargs
):
"""Initializes a new instance of :see:LocalizedFieldForm.""" """Initializes a new instance of :see:LocalizedFieldForm."""
fields = [] fields = []
# Do not print initial value in html in the form of a hidden input. This will result in loss of information
kwargs["show_hidden_initial"] = False
for lang_code, _ in settings.LANGUAGES: for lang_code, _ in settings.LANGUAGES:
field_options = {'required': False} field_options = dict(
required=required
if lang_code == settings.LANGUAGE_CODE: if type(required) is bool
field_options['required'] = True else (lang_code in required),
label=lang_code,
field_options['label'] = lang_code )
fields.append(forms.fields.CharField(**field_options)) fields.append(self.field_class(**field_options))
super(LocalizedFieldForm, self).__init__( super(LocalizedFieldForm, self).__init__(
fields, fields,
require_all_fields=False required=required if type(required) is bool else True,
require_all_fields=False,
*args,
**kwargs
) )
# set 'required' attribute for each widget separately
for field, widget in zip(self.fields, self.widget.widgets):
widget.is_required = field.required
def compress(self, value: List[str]) -> LocalizedValue: def compress(self, value: List[str]) -> LocalizedValue:
"""Compresses the values from individual fields """Compresses the values from individual fields into a single
into a single :see:LocalizedValue instance. :see:LocalizedValue instance.
Arguments: Arguments:
value: value:
@ -82,9 +73,134 @@ class LocalizedFieldForm(forms.MultiValueField):
the value in several languages. the value in several languages.
""" """
localized_value = LocalizedValue() localized_value = self.value_class()
for (lang_code, _), value in zip(settings.LANGUAGES, value): for (lang_code, _), value in zip(settings.LANGUAGES, value):
localized_value.set(lang_code, value) localized_value.set(lang_code, value)
return localized_value return localized_value
class LocalizedCharFieldForm(LocalizedFieldForm):
"""Form for a localized char field, allows editing the field in multiple
languages."""
widget = LocalizedCharFieldWidget
value_class = LocalizedStringValue
class LocalizedTextFieldForm(LocalizedFieldForm):
"""Form for a localized text field, allows editing the field in multiple
languages."""
value_class = LocalizedStringValue
class LocalizedIntegerFieldForm(LocalizedFieldForm):
"""Form for a localized integer field, allows editing the field in multiple
languages."""
widget = AdminLocalizedIntegerFieldWidget
value_class = LocalizedIntegerValue
class LocalizedBooleanFieldForm(LocalizedFieldForm, forms.BooleanField):
"""Form for a localized boolean field, allows editing the field in multiple
languages."""
widget = AdminLocalizedBooleanFieldWidget
field_class = forms.fields.BooleanField
value_class = LocalizedBooleanValue
class LocalizedFileFieldForm(LocalizedFieldForm, forms.FileField):
"""Form for a localized file field, allows editing the field in multiple
languages."""
widget = LocalizedFileWidget
field_class = forms.fields.FileField
value_class = LocalizedFileValue
def clean(self, value, initial=None):
"""Most part of this method is a copy of
django.forms.MultiValueField.clean, with the exception of initial value
handling (this need for correct processing FileField's).
All original comments saved.
"""
if initial is None:
initial = [None for x in range(0, len(value))]
else:
if not isinstance(initial, list):
initial = self.widget.decompress(initial)
clean_data = []
errors = []
if not value or isinstance(value, (list, tuple)):
is_empty = [v for v in value if v not in self.empty_values]
if (not value or not is_empty) and (not initial or not is_empty):
if self.required:
raise ValidationError(
self.error_messages["required"], code="required"
)
else:
raise ValidationError(
self.error_messages["invalid"], code="invalid"
)
for i, field in enumerate(self.fields):
try:
field_value = value[i]
except IndexError:
field_value = None
try:
field_initial = initial[i]
except IndexError:
field_initial = None
if (
field_value in self.empty_values
and field_initial in self.empty_values
):
if self.require_all_fields:
# Raise a 'required' error if the MultiValueField is
# required and any field is empty.
if self.required:
raise ValidationError(
self.error_messages["required"], code="required"
)
elif field.required:
# Otherwise, add an 'incomplete' error to the list of
# collected errors and skip field cleaning, if a required
# field is empty.
if field.error_messages["incomplete"] not in errors:
errors.append(field.error_messages["incomplete"])
continue
try:
clean_data.append(field.clean(field_value, field_initial))
except ValidationError as e:
# Collect all validation errors in a single list, which we'll
# raise at the end of clean(), rather than raising a single
# exception for the first error we encounter. Skip duplicates.
errors.extend(m for m in e.error_list if m not in errors)
if errors:
raise ValidationError(errors)
out = self.compress(clean_data)
self.validate(out)
self.run_validators(out)
return out
def bound_data(self, data, initial):
bound_data = []
if initial is None:
initial = [None for x in range(0, len(data))]
else:
if not isinstance(initial, list):
initial = self.widget.decompress(initial)
for d, i in zip(data, initial):
if d in (None, FILE_INPUT_CONTRADICTION):
bound_data.append(i)
else:
bound_data.append(d)
return bound_data

View File

@ -1,132 +0,0 @@
"""This module is unused, but should be contributed to Django."""
from typing import List
from django.db import models
class HStoreIndex(models.Index):
"""Allows creating a index on a specific HStore index.
Note: pieces of code in this class have been copied
from the base class. There was no way around this."""
def __init__(self, field: str, keys: List[str], unique: bool=False,
name: str=''):
"""Initializes a new instance of :see:HStoreIndex.
Arguments:
field:
Name of the hstore field for
which's keys to create a index for.
keys:
The name of the hstore keys to
create the index on.
unique:
Whether this index should
be marked as UNIQUE.
name:
The name of the index. If left
empty, one will be generated.
"""
self.field = field
self.keys = keys
self.unique = unique
# this will eventually set self.name
super(HStoreIndex, self).__init__(
fields=[field],
name=name
)
def get_sql_create_template_values(self, model, schema_editor, using):
"""Gets the values for the SQL template.
Arguments:
model:
The model this index applies to.
schema_editor:
The schema editor to modify the schema.
using:
Optional: "USING" statement.
Returns:
Dictionary of keys to pass into the SQL template.
"""
fields = [model._meta.get_field(field_name) for field_name, order in self.fields_orders]
tablespace_sql = schema_editor._get_index_tablespace_sql(model, fields)
quote_name = schema_editor.quote_name
columns = [
'(%s->\'%s\')' % (self.field, key)
for key in self.keys
]
return {
'table': quote_name(model._meta.db_table),
'name': quote_name(self.name),
'columns': ', '.join(columns),
'using': using,
'extra': tablespace_sql,
}
def create_sql(self, model, schema_editor, using=''):
"""Gets the SQL to execute when creating the index.
Arguments:
model:
The model this index applies to.
schema_editor:
The schema editor to modify the schema.
using:
Optional: "USING" statement.
Returns:
SQL string to execute to create this index.
"""
sql_create_index = schema_editor.sql_create_index
if self.unique:
sql_create_index = sql_create_index.replace('CREATE', 'CREATE UNIQUE')
sql_parameters = self.get_sql_create_template_values(model, schema_editor, using)
return sql_create_index % sql_parameters
def remove_sql(self, model, schema_editor):
"""Gets the SQL to execute to remove this index.
Arguments:
model:
The model this index applies to.
schema_editor:
The schema editor to modify the schema.
Returns:
SQL string to execute to remove this index.
"""
quote_name = schema_editor.quote_name
return schema_editor.sql_delete_index % {
'table': quote_name(model._meta.db_table),
'name': quote_name(self.name),
}
def deconstruct(self):
"""Gets the values to pass to :see:__init__ when
re-creating this object."""
path = '%s.%s' % (self.__class__.__module__, self.__class__.__name__)
return (path, (), {
'field': self.field,
'keys': self.keys,
'unique': self.unique,
'name': self.name
})

View File

@ -1,82 +0,0 @@
from django.conf import settings
from django.utils import translation
class LocalizedValue:
"""Represents the value of a :see:LocalizedField."""
def __init__(self, keys: dict=None):
"""Initializes a new instance of :see:LocalizedValue.
Arguments:
keys:
The keys to initialize this value with. Every
key contains the value of this field in a
different language.
"""
if isinstance(keys, str):
setattr(self, settings.LANGUAGE_CODE, keys)
else:
for lang_code, _ in settings.LANGUAGES:
value = keys.get(lang_code) if keys else None
setattr(self, lang_code, value)
def get(self, language: str=None) -> str:
"""Gets the underlying value in the specified or
primary language.
Arguments:
language:
The language to get the value in.
Returns:
The value in the current language, or
the primary language in case no language
was specified.
"""
language = language or settings.LANGUAGE_CODE
return getattr(self, language, None)
def set(self, language: str, value: str):
"""Sets the value in the specified language.
Arguments:
language:
The language to set the value in.
value:
The value to set.
"""
setattr(self, language, value)
return self
def deconstruct(self) -> dict:
"""Deconstructs this value into a primitive type.
Returns:
A dictionary with all the localized values
contained in this instance.
"""
path = 'localized_fields.fields.LocalizedValue'
return path, [self.__dict__], {}
def __str__(self) -> str:
"""Gets the value in the current language, or falls
back to the primary language if there's no value
in the current language."""
value = self.get(translation.get_language())
if not value:
value = self.get(settings.LANGUAGE_CODE)
return value or ''
def __repr__(self): # pragma: no cover
"""Gets a textual representation of this object."""
return 'LocalizedValue<%s> 0x%s' % (self.__dict__, id(self))

170
localized_fields/lookups.py Normal file
View File

@ -0,0 +1,170 @@
from django.conf import settings
from django.contrib.postgres.fields.hstore import KeyTransform
from django.contrib.postgres.lookups import (
SearchLookup,
TrigramSimilar,
Unaccent,
)
from django.db.models import TextField, Transform
from django.db.models.expressions import Col, Func, Value
from django.db.models.functions import Coalesce
from django.db.models.lookups import (
Contains,
EndsWith,
Exact,
IContains,
IEndsWith,
IExact,
In,
IRegex,
IsNull,
IStartsWith,
Regex,
StartsWith,
)
from django.utils import translation
from psqlextra.expressions import HStoreColumn
from .fields import LocalizedField
try:
from django.db.models.functions import NullIf
except ImportError:
# for Django < 2.2
class NullIf(Func):
function = "NULLIF"
arity = 2
class LocalizedLookupMixin:
def process_lhs(self, qn, connection):
# If the LHS is already a reference to a specific hstore key, there
# is nothing to be done since it already references as specific language.
if isinstance(self.lhs, HStoreColumn) or isinstance(
self.lhs, KeyTransform
):
return super().process_lhs(qn, connection)
# If this is something custom expression, we don't really know how to
# handle that, so we better do nothing.
if not isinstance(self.lhs, Col):
return super().process_lhs(qn, connection)
# Select the key for the current language. We do this so that
#
# myfield__<lookup>=
#
# Is converted into:
#
# myfield__<lookup>__<current language>=
language = translation.get_language() or settings.LANGUAGE_CODE
self.lhs = KeyTransform(language, self.lhs)
return super().process_lhs(qn, connection)
def get_prep_lookup(self):
# Django 4.0 removed the ability for isnull fields to be something
# other than a bool We should NOT convert them to strings
if isinstance(self.rhs, bool):
return self.rhs
return str(self.rhs)
class LocalizedSearchLookup(LocalizedLookupMixin, SearchLookup):
pass
class LocalizedUnaccent(LocalizedLookupMixin, Unaccent):
pass
class LocalizedTrigramSimilair(LocalizedLookupMixin, TrigramSimilar):
pass
class LocalizedExact(LocalizedLookupMixin, Exact):
pass
class LocalizedIExact(LocalizedLookupMixin, IExact):
pass
class LocalizedIn(LocalizedLookupMixin, In):
pass
class LocalizedContains(LocalizedLookupMixin, Contains):
pass
class LocalizedIContains(LocalizedLookupMixin, IContains):
pass
class LocalizedStartsWith(LocalizedLookupMixin, StartsWith):
pass
class LocalizedIStartsWith(LocalizedLookupMixin, IStartsWith):
pass
class LocalizedEndsWith(LocalizedLookupMixin, EndsWith):
pass
class LocalizedIEndsWith(LocalizedLookupMixin, IEndsWith):
pass
class LocalizedIsNullWith(LocalizedLookupMixin, IsNull):
pass
class LocalizedRegexWith(LocalizedLookupMixin, Regex):
pass
class LocalizedIRegexWith(LocalizedLookupMixin, IRegex):
pass
@LocalizedField.register_lookup
class ActiveRefLookup(Transform):
output_field = TextField()
lookup_name = "active_ref"
arity = None
def as_sql(self, compiler, connection):
language = translation.get_language() or settings.LANGUAGE_CODE
return KeyTransform(language, self.lhs).as_sql(compiler, connection)
@LocalizedField.register_lookup
class TranslatedRefLookup(Transform):
output_field = TextField()
lookup_name = "translated_ref"
arity = None
def as_sql(self, compiler, connection):
language = translation.get_language()
fallback_config = getattr(settings, "LOCALIZED_FIELDS_FALLBACKS", {})
target_languages = fallback_config.get(language, [])
if not target_languages and language != settings.LANGUAGE_CODE:
target_languages.append(settings.LANGUAGE_CODE)
if language:
target_languages.insert(0, language)
if len(target_languages) > 1:
return Coalesce(
*[
NullIf(KeyTransform(language, self.lhs), Value(""))
for language in target_languages
]
).as_sql(compiler, connection)
return KeyTransform(target_languages[0], self.lhs).as_sql(
compiler, connection
)

View File

@ -0,0 +1,11 @@
# Generated by Django 2.1 on 2018-08-27 08:05
from django.contrib.postgres.operations import HStoreExtension
from django.db import migrations
class Migration(migrations.Migration):
dependencies = []
operations = [HStoreExtension()]

View File

@ -0,0 +1,34 @@
from django.conf import settings
from django.db import transaction
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()

View File

@ -1,66 +1,17 @@
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 .mixins import AtomicSlugRetryMixin
from .localized_value import LocalizedValue
class LocalizedModel(models.Model): class LocalizedModel(AtomicSlugRetryMixin, PostgresModel):
"""A model that contains localized fields.""" """Turns a model into a model that contains LocalizedField's.
For basic localisation functionality, it isn't needed to inherit
from LocalizedModel. However, for certain features, this is required.
It is definitely needed for :see:LocalizedUniqueSlugField, unless you
manually inherit from AtomicSlugRetryMixin.
"""
class Meta: class Meta:
abstract = True abstract = True
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedModel.
Here we set all the fields that are of :see:LocalizedField
to an instance of :see:LocalizedValue in case they are none
so that the user doesn't explicitely have to do so."""
super(LocalizedModel, self).__init__(*args, **kwargs)
for field in self._meta.get_fields():
if not isinstance(field, LocalizedField):
continue
value = getattr(self, field.name, None)
if not isinstance(value, LocalizedValue):
if isinstance(value, dict):
value = LocalizedValue(value)
else:
value = LocalizedValue()
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()

View File

@ -0,0 +1,53 @@
.localized-fields-widget {
display: inline-block;
}
.localized-fields-widget.tabs {
display: block;
margin: 0;
border-bottom: 1px solid #eee;
}
.localized-fields-widget.tabs .localized-fields-widget.tab {
display: inline-block;
margin-left: 5px;
border: 1px solid #79aec8;
border-bottom: none;
border-radius: 4px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
background: #79aec8;
color: #fff;
font-weight: 400;
opacity: 0.5;
}
.localized-fields-widget.tabs .localized-fields-widget.tab:first-child {
margin-left: 0;
}
.localized-fields-widget.tabs .localized-fields-widget.tab:hover {
background: #417690;
border-color: #417690;
opacity: 1;
}
.localized-fields-widget.tabs .localized-fields-widget.tab label {
padding: 5px 10px;
display: inline;
text-decoration: none;
color: #fff;
width: initial;
cursor: pointer;
}
.localized-fields-widget.tabs .localized-fields-widget.tab.active,
.localized-fields-widget.tabs .localized-fields-widget.tab.active:hover {
background: #79aec8;
border-color: #79aec8;
opacity: 1;
}
.localized-fields-widget p.file-upload {
margin-left: 0;
}

View File

@ -0,0 +1,35 @@
(function($) {
var syncTabs = function(lang) {
$('.localized-fields-widget.tab label:contains("'+lang+'")').each(function(){
$(this).parents('.localized-fields-widget[role="tabs"]').find('.localized-fields-widget.tab').removeClass('active');
$(this).parents('.localized-fields-widget.tab').addClass('active');
$(this).parents('.localized-fields-widget[role="tabs"]').children('.localized-fields-widget [role="tabpanel"]').hide();
$('#'+$(this).attr('for')).show();
});
}
$(function (){
$('.localized-fields-widget [role="tabpanel"]').hide();
// set first tab as active
$('.localized-fields-widget[role="tabs"]').each(function () {
$(this).find('.localized-fields-widget.tab:first').addClass('active');
$('#'+$(this).find('.localized-fields-widget.tab:first label').attr('for')).show();
});
// try set active last selected tab
if (window.sessionStorage) {
var lang = window.sessionStorage.getItem('localized-field-lang');
if (lang) {
syncTabs(lang);
}
}
$('.localized-fields-widget.tab label').click(function(event) {
event.preventDefault();
syncTabs(this.innerText);
if (window.sessionStorage) {
window.sessionStorage.setItem('localized-field-lang', this.innerText);
}
return false;
});
});
})(django.jQuery)

View File

@ -0,0 +1,16 @@
{% with widget_id=widget.attrs.id %}
<div class="localized-fields-widget" role="tabs" data-synctabs="translation">
<ul class="localized-fields-widget tabs">
{% for widget in widget.subwidgets %}
<li class="localized-fields-widget tab">
<label for="{{ widget_id }}_{{ widget.lang_code }}">{{ widget.lang_name|capfirst }}</label>
</li>
{% endfor %}
</ul>
{% for widget in widget.subwidgets %}
<div role="tabpanel" id="{{ widget_id }}_{{ widget.lang_code }}">
{% include widget.template_name %}
</div>
{% endfor %}
</div>
{% endwith %}

View File

@ -0,0 +1,16 @@
{% with widget_id=widget.attrs.id %}
<div class="localized-fields-widget" role="tabs" data-synctabs="translation">
<ul class="localized-fields-widget tabs">
{% for widget in widget.subwidgets %}
<li class="localized-fields-widget tab">
<label for="{{ widget_id }}_{{ widget.lang_code }}">{{ widget.lang_name|capfirst }}</label>
</li>
{% endfor %}
</ul>
{% for widget in widget.subwidgets %}
<div role="tabpanel" id="{{ widget_id }}_{{ widget.lang_code }}">
{% include widget.template_name %}
</div>
{% endfor %}
</div>
{% endwith %}

View File

@ -15,7 +15,27 @@ def get_language_codes() -> List[str]:
in your project. in your project.
""" """
return [ return [lang_code for lang_code, _ in settings.LANGUAGES]
lang_code
for lang_code, _ in settings.LANGUAGES
] def resolve_object_property(obj, path: str):
"""Resolves the value of a property on an object.
Is able to resolve nested properties. For example,
a path can be specified:
'other.beer.name'
Raises:
AttributeError:
In case the property could not be resolved.
Returns:
The value of the specified property.
"""
value = obj
for path_part in path.split("."):
value = getattr(value, path_part)
return value

321
localized_fields/value.py Normal file
View File

@ -0,0 +1,321 @@
from collections.abc import Iterable
from typing import Optional
import deprecation
from django.conf import settings
from django.utils import translation
class LocalizedValue(dict):
"""Represents the value of a :see:LocalizedField."""
default_value = None
def __init__(self, keys: dict = None):
"""Initializes a new instance of :see:LocalizedValue.
Arguments:
keys:
The keys to initialize this value with. Every
key contains the value of this field in a
different language.
"""
super().__init__({})
self._interpret_value(keys)
def get(self, language: str = None, default: str = None) -> str:
"""Gets the underlying value in the specified or primary language.
Arguments:
language:
The language to get the value in.
Returns:
The value in the current language, or
the primary language in case no language
was specified.
"""
language = language or settings.LANGUAGE_CODE
value = super().get(language, default)
return value if value is not None else default
def set(self, language: str, value: str):
"""Sets the value in the specified language.
Arguments:
language:
The language to set the value in.
value:
The value to set.
"""
self[language] = value
self.__dict__.update(self)
return self
def deconstruct(self) -> dict:
"""Deconstructs this value into a primitive type.
Returns:
A dictionary with all the localized values
contained in this instance.
"""
path = "localized_fields.value.%s" % self.__class__.__name__
return path, [self.__dict__], {}
def _interpret_value(self, value):
"""Interprets a value passed in the constructor as a
:see:LocalizedValue.
If string:
Assumes it's the default language.
If dict:
Each key is a language and the value a string
in that language.
If list:
Recurse into to apply rules above.
Arguments:
value:
The value to interpret.
"""
for lang_code, _ in settings.LANGUAGES:
self.set(lang_code, self.default_value)
if callable(value):
value = value()
if isinstance(value, str):
self.set(settings.LANGUAGE_CODE, value)
elif isinstance(value, dict):
for lang_code, _ in settings.LANGUAGES:
lang_value = value.get(lang_code, self.default_value)
self.set(lang_code, lang_value)
elif isinstance(value, Iterable):
for val in value:
self._interpret_value(val)
def translate(self, language: Optional[str] = None) -> Optional[str]:
"""Gets the value in the specified language (or active language).
Arguments:
language:
The language to get the value in. If not specified,
the currently active language is used.
Returns:
The value in the specified (or active) language. If no value
is available in the specified language, the value is returned
in one of the fallback languages.
"""
target_language = (
language or translation.get_language() or settings.LANGUAGE_CODE
)
fallback_config = getattr(settings, "LOCALIZED_FIELDS_FALLBACKS", {})
target_languages = fallback_config.get(
target_language, [settings.LANGUAGE_CODE]
)
for lang_code in [target_language] + target_languages:
value = self.get(lang_code)
if value:
return value or None
return None
def is_empty(self) -> bool:
"""Gets whether all the languages contain the default value."""
for lang_code, _ in settings.LANGUAGES:
if self.get(lang_code) != self.default_value:
return False
return True
def __str__(self) -> str:
"""Gets the value in the current language or falls back to the next
language if there's no value in the current language."""
return self.translate() 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
"""Gets a textual representation of this object."""
return "%s<%s> 0x%s" % (
self.__class__.__name__,
self.__dict__,
id(self),
)
class LocalizedStringValue(LocalizedValue):
default_value = ""
class LocalizedFileValue(LocalizedValue):
def __getattr__(self, name: str):
"""Proxies access to attributes to attributes of LocalizedFile."""
value = self.get(translation.get_language())
if hasattr(value, name):
return getattr(value, name)
raise AttributeError(
"'{}' object has no attribute '{}'".format(
self.__class__.__name__, name
)
)
def __str__(self) -> str:
"""Returns string representation of value."""
return str(super().__str__())
@deprecation.deprecated(
deprecated_in="4.6",
removed_in="5.0",
current_version="4.6",
details="Use the translate() function instead.",
)
def localized(self):
"""Returns value for current language."""
return self.get(translation.get_language())
class LocalizedBooleanValue(LocalizedValue):
def translate(self):
"""Gets the value in the current language, or in the configured fallbck
language."""
value = super().translate()
if value is None or (isinstance(value, str) and value.strip() == ""):
return None
if isinstance(value, bool):
return value
if value.lower() == "true":
return True
return False
def __bool__(self):
"""Gets the value in the current language as a boolean."""
value = self.translate()
return value
def __str__(self):
"""Returns string representation of value."""
value = self.translate()
return str(value) if value is not None else ""
class LocalizedNumericValue(LocalizedValue):
def __int__(self):
"""Gets the value in the current language as an integer."""
value = self.translate()
if value is None:
return self.default_value
return int(value)
def __str__(self) -> str:
"""Returns string representation of value."""
value = self.translate()
return str(value) if value is not None else ""
def __float__(self):
"""Gets the value in the current language as a float."""
value = self.translate()
if value is None:
return self.default_value
return float(value)
class LocalizedIntegerValue(LocalizedNumericValue):
"""All values are integers."""
default_value = None
def translate(self):
"""Gets the value in the current language, or in the configured fallbck
language."""
value = super().translate()
if value is None or (isinstance(value, str) and value.strip() == ""):
return None
return int(value)
class LocalizedFloatValue(LocalizedNumericValue):
"""All values are floats."""
default_value = None
def translate(self):
"""Gets the value in the current language, or in the configured
fallback language."""
value = super().translate()
if value is None or (isinstance(value, str) and value.strip() == ""):
return None
return float(value)

144
localized_fields/widgets.py Normal file
View File

@ -0,0 +1,144 @@
import copy
from typing import List
from django import forms
from django.conf import settings
from django.contrib.admin import widgets
from .value import LocalizedValue
class LocalizedFieldWidget(forms.MultiWidget):
"""Widget that has an input box for every language."""
template_name = "localized_fields/multiwidget.html"
widget = forms.Textarea
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedFieldWidget."""
initial_widgets = [copy.copy(self.widget) for _ in settings.LANGUAGES]
super().__init__(initial_widgets, *args, **kwargs)
for ((lang_code, lang_name), widget) in zip(
settings.LANGUAGES, self.widgets
):
widget.attrs["lang"] = lang_code
widget.lang_code = lang_code
widget.lang_name = lang_name
def decompress(self, value: LocalizedValue) -> List[str]:
"""Decompresses the specified value so it can be spread over the
internal widgets.
Arguments:
value:
The :see:LocalizedValue to display in this
widget.
Returns:
All values to display in the inner widgets.
"""
result = []
for lang_code, _ in settings.LANGUAGES:
if value:
result.append(value.get(lang_code))
else:
result.append(None)
return result
def get_context(self, name, value, attrs):
context = super(forms.MultiWidget, self).get_context(name, value, attrs)
if self.is_localized:
for widget in self.widgets:
widget.is_localized = self.is_localized
# value is a list of values, each corresponding to a widget
# in self.widgets.
if not isinstance(value, list):
value = self.decompress(value)
final_attrs = context["widget"]["attrs"]
input_type = final_attrs.pop("type", None)
id_ = final_attrs.get("id")
subwidgets = []
for i, widget in enumerate(self.widgets):
if input_type is not None:
widget.input_type = input_type
widget_name = "%s_%s" % (name, i)
try:
widget_value = value[i]
except IndexError:
widget_value = None
if id_:
widget_attrs = final_attrs.copy()
widget_attrs["id"] = "%s_%s" % (id_, i)
else:
widget_attrs = final_attrs
widget_attrs = self.build_widget_attrs(
widget, widget_value, widget_attrs
)
widget_context = widget.get_context(
widget_name, widget_value, widget_attrs
)["widget"]
widget_context.update(
dict(lang_code=widget.lang_code, lang_name=widget.lang_name)
)
subwidgets.append(widget_context)
context["widget"]["subwidgets"] = subwidgets
return context
@staticmethod
def build_widget_attrs(widget, value, attrs):
attrs = dict(attrs) # Copy attrs to avoid modifying the argument.
if (
not widget.use_required_attribute(value) or not widget.is_required
) and "required" in attrs:
del attrs["required"]
return attrs
class LocalizedCharFieldWidget(LocalizedFieldWidget):
"""Widget that has an input box for every language."""
widget = forms.TextInput
class LocalizedFileWidget(LocalizedFieldWidget):
"""Widget that has an file input box for every language."""
widget = forms.ClearableFileInput
class AdminLocalizedFieldWidget(LocalizedFieldWidget):
template_name = "localized_fields/admin/widget.html"
widget = widgets.AdminTextareaWidget
class AdminLocalizedBooleanFieldWidget(LocalizedFieldWidget):
widget = forms.Select
def __init__(self, *args, **kwargs):
"""Initializes a new instance of :see:LocalizedBooleanFieldWidget."""
super().__init__(*args, **kwargs)
for widget in self.widgets:
widget.choices = [("False", False), ("True", True)]
class AdminLocalizedCharFieldWidget(AdminLocalizedFieldWidget):
widget = widgets.AdminTextInputWidget
class AdminLocalizedFileFieldWidget(AdminLocalizedFieldWidget):
widget = widgets.AdminFileWidget
class AdminLocalizedIntegerFieldWidget(AdminLocalizedFieldWidget):
widget = widgets.AdminIntegerFieldWidget

2
pyproject.toml Normal file
View File

@ -0,0 +1,2 @@
[tool.black]
line-length = 80

7
pytest.ini Normal file
View File

@ -0,0 +1,7 @@
[pytest]
DJANGO_SETTINGS_MODULE=settings
testpaths=tests
addopts=-m "not benchmark"
junit_family=legacy
filterwarnings=
ignore::DeprecationWarning:localized_fields.fields.autoslug_field

View File

@ -1,15 +0,0 @@
coverage==4.2
Django==1.10.2
django-autoslug==1.9.3
django-bleach==0.3.0
django-coverage-plugin==1.3.1
psycopg2==2.6.2
pylint==1.6.4
pylint-common==0.2.2
pylint-django==0.7.2
pylint-plugin-utils==0.2.4
coverage==4.2
django-coverage-plugin==1.3.1
flake8==3.0.4
pep8==1.7.0
dj-database-url==0.4.1

View File

@ -1,3 +1,4 @@
import django
import dj_database_url import dj_database_url
DEBUG = True DEBUG = True
@ -8,10 +9,10 @@ SECRET_KEY = 'this is my secret key' # NOQA
TEST_RUNNER = 'django.test.runner.DiscoverRunner' TEST_RUNNER = 'django.test.runner.DiscoverRunner'
DATABASES = { 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 = (
@ -21,9 +22,44 @@ LANGUAGES = (
) )
INSTALLED_APPS = ( INSTALLED_APPS = (
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.admin',
'django.contrib.messages',
'localized_fields',
'tests', 'tests',
) )
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
]
# See: https://github.com/psycopg/psycopg2/issues/1293
if django.VERSION >= (3, 1):
USE_TZ = True
USE_I18N = True
TIME_ZONE = 'UTC'
# set to a lower number than the default, since # set to a lower number than the default, since
# we want the tests to be fast, default is 100 # we want the tests to be fast, default is 100
LOCALIZED_FIELDS_MAX_RETRIES = 3 LOCALIZED_FIELDS_MAX_RETRIES = 3
LOCALIZED_FIELDS_EXPERIMENTAL = False

View File

@ -1,7 +1,12 @@
[flake8] [flake8]
max-line-length = 120 ignore = E252,E501,W503
exclude = env,.tox,.git,config/settings,*/migrations/*,*/static/CACHE/*,docs,node_modules exclude = env,.tox,.git,config/settings,*/migrations/*,*/static/CACHE/*,docs,node_modules
[pep8] [isort]
max-line-length = 120 line_length=80
exclude=env,.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules multi_line_output=3
lines_between_types=1
include_trailing_comma=True
not_skip=__init__.py
known_standard_library=dataclasses
known_third_party=django_bleach,bleach,pytest

202
setup.py
View File

@ -1,31 +1,195 @@
import distutils.cmd
import os import os
import subprocess
from setuptools import find_packages, setup from setuptools import find_packages, setup
with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme:
class BaseCommand(distutils.cmd.Command):
user_options = []
def initialize_options(self):
pass
def finalize_options(self):
pass
def create_command(text, commands):
"""Creates a custom setup.py command."""
class CustomCommand(BaseCommand):
description = text
def run(self):
for cmd in commands:
subprocess.check_call(cmd)
return CustomCommand
with open(
os.path.join(os.path.dirname(__file__), "README.md"), encoding="utf-8"
) as readme:
README = readme.read() README = readme.read()
setup( setup(
name='django-localized-fields', name="django-localized-fields",
version='2.5', version="6.8b5",
packages=find_packages(), packages=find_packages(exclude=["tests"]),
include_package_data=True, include_package_data=True,
license='MIT License', license="MIT License",
description='Implementation of localized model fields using PostgreSQL HStore fields.', description="Implementation of localized model fields using PostgreSQL HStore fields.",
long_description=README, long_description=README,
url='https://github.com/SectorLabs/django-localized-fields', long_description_content_type="text/markdown",
author='Sector Labs', url="https://github.com/SectorLabs/django-localized-fields",
author_email='open-source@sectorlabs.ro', author="Sector Labs",
keywords=['django', 'localized', 'language', 'models', 'fields'], author_email="open-source@sectorlabs.ro",
keywords=[
"django",
"localized",
"language",
"models",
"fields",
"postgres",
"hstore",
"i18n",
],
classifiers=[ classifiers=[
'Environment :: Web Environment', "Environment :: Web Environment",
'Framework :: Django', "Framework :: Django",
'Intended Audience :: Developers', "Intended Audience :: Developers",
'License :: OSI Approved :: MIT License', "License :: OSI Approved :: MIT License",
'Operating System :: OS Independent', "Operating System :: OS Independent",
'Programming Language :: Python', "Programming Language :: Python",
'Programming Language :: Python :: 3.5', "Programming Language :: Python :: 3.6",
'Topic :: Internet :: WWW/HTTP', "Programming Language :: Python :: 3.7",
'Topic :: Internet :: WWW/HTTP :: Dynamic Content', "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
],
python_requires=">=3.6",
install_requires=[
"Django>=2.0",
"django-postgres-extra>=2.0,<3.0",
"deprecation>=2.0.7",
],
extras_require={
':python_version <= "3.6"': ["dataclasses"],
"docs": ["Sphinx==2.2.0", "sphinx-rtd-theme==0.4.3"],
"test": [
"tox==3.28.0",
"pytest==7.0.1",
"pytest-django==4.5.2",
"pytest-cov==2.12.1",
"dj-database-url==0.5.0",
"django-autoslug==1.9.9",
"django-bleach==0.9.0",
"psycopg2==2.9.8",
],
"analysis": [
"black==22.3.0",
"flake8==3.7.7",
"autoflake==1.3",
"autopep8==1.4.4",
"isort==4.3.20",
"sl-docformatter==1.4",
"click==8.0.2",
],
},
cmdclass={
"lint": create_command(
"Lints the code",
[["flake8", "setup.py", "localized_fields", "tests"]],
),
"lint_fix": create_command(
"Lints the code",
[
[
"autoflake",
"--remove-all-unused-imports",
"-i",
"-r",
"setup.py",
"localized_fields",
"tests",
],
[
"autopep8",
"-i",
"-r",
"setup.py",
"localized_fields",
"tests",
],
],
),
"format": create_command(
"Formats the code",
[["black", "setup.py", "localized_fields", "tests"]],
),
"format_verify": create_command(
"Checks if the code is auto-formatted",
[["black", "--check", "setup.py", "localized_fields", "tests"]],
),
"format_docstrings": create_command(
"Auto-formats doc strings", [["docformatter", "-r", "-i", "."]]
),
"format_docstrings_verify": create_command(
"Verifies that doc strings are properly formatted",
[["docformatter", "-r", "-c", "."]],
),
"sort_imports": create_command(
"Automatically sorts imports",
[
["isort", "setup.py"],
["isort", "-rc", "localized_fields"],
["isort", "-rc", "tests"],
],
),
"sort_imports_verify": create_command(
"Verifies all imports are properly sorted.",
[
["isort", "-c", "setup.py"],
["isort", "-c", "-rc", "localized_fields"],
["isort", "-c", "-rc", "tests"],
],
),
"fix": create_command(
"Automatically format code and fix linting errors",
[
["python", "setup.py", "format"],
["python", "setup.py", "format_docstrings"],
["python", "setup.py", "sort_imports"],
["python", "setup.py", "lint_fix"],
],
),
"verify": create_command(
"Verifies whether the code is auto-formatted and has no linting errors",
[
["python", "setup.py", "format_verify"],
["python", "setup.py", "format_docstrings_verify"],
["python", "setup.py", "sort_imports_verify"],
["python", "setup.py", "lint"],
],
),
"test": create_command(
"Runs all the tests",
[
[
"pytest",
"--cov=localized_fields",
"--cov-report=term",
"--cov-report=xml:reports/xml",
"--cov-report=html:reports/html",
"--junitxml=reports/junit/tests.xml",
"--reuse-db",
] ]
],
),
},
) )

12
tests/data.py Normal file
View File

@ -0,0 +1,12 @@
from django.conf import settings
def get_init_values() -> dict:
"""Gets a test dictionary containing a key for every language."""
keys = {}
for lang_code, lang_name in settings.LANGUAGES:
keys[lang_code] = "value in %s" % lang_name
return keys

View File

@ -1,31 +1,35 @@
import uuid
from django.contrib.postgres.operations import HStoreExtension
from django.db import connection, migrations 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 localized_fields import LocalizedModel from localized_fields.models import LocalizedModel
def define_fake_model(name='TestModel', fields=None): def define_fake_model(fields=None, model_base=LocalizedModel, meta_options={}):
name = str(uuid.uuid4()).replace("-", "")[:8]
attributes = { attributes = {
'app_label': 'localized_fields', "app_label": "tests",
'__module__': __name__, "__module__": __name__,
'__name__': name "__name__": name,
"Meta": type("Meta", (object,), meta_options),
} }
if fields: if fields:
attributes.update(fields) attributes.update(fields)
model = type(name, (LocalizedModel,), attributes) model = type(name, (model_base,), attributes)
return model return model
def get_fake_model(name='TestModel', fields=None): def get_fake_model(fields=None, model_base=LocalizedModel, meta_options={}):
"""Creates a fake model to use during unit tests.""" """Creates a fake model to use during unit tests."""
model = define_fake_model(name, fields) model = define_fake_model(fields, model_base, meta_options)
class TestProject: class TestProject:
def clone(self, *_args, **_kwargs): def clone(self, *_args, **_kwargs):
return self return self
@ -39,7 +43,8 @@ def get_fake_model(name='TestModel', fields=None):
with connection.schema_editor() as schema_editor: with connection.schema_editor() as schema_editor:
migration_executor = MigrationExecutor(schema_editor.connection) migration_executor = MigrationExecutor(schema_editor.connection)
migration_executor.apply_migration( migration_executor.apply_migration(
TestProject(), TestMigration('eh', 'localized_fields')) TestProject(), TestMigration("eh", "postgres_extra")
)
schema_editor.create_model(model) schema_editor.create_model(model)

81
tests/test_admin.py Normal file
View File

@ -0,0 +1,81 @@
from django.apps import apps
from django.contrib import admin
from django.contrib.admin.checks import check_admin_app
from django.db import models
from django.test import TestCase
from localized_fields.admin import LocalizedFieldsAdminMixin
from localized_fields.fields import LocalizedField
from tests.fake_model import get_fake_model
class LocalizedFieldsAdminMixinTestCase(TestCase):
"""Tests the :see:LocalizedFieldsAdminMixin class."""
TestModel = None
TestRelModel = None
@classmethod
def setUpClass(cls):
"""Creates the test model in the database."""
super(LocalizedFieldsAdminMixinTestCase, cls).setUpClass()
cls.TestRelModel = get_fake_model({"description": LocalizedField()})
cls.TestModel = get_fake_model(
{
"title": LocalizedField(),
"rel": models.ForeignKey(
cls.TestRelModel, on_delete=models.CASCADE
),
}
)
def tearDown(self):
if admin.site.is_registered(self.TestModel):
admin.site.unregister(self.TestModel)
if admin.site.is_registered(self.TestRelModel):
admin.site.unregister(self.TestRelModel)
@classmethod
def test_model_admin(cls):
"""Tests whether :see:LocalizedFieldsAdminMixin mixin are works with
admin.ModelAdmin."""
@admin.register(cls.TestModel)
class TestModelAdmin(LocalizedFieldsAdminMixin, admin.ModelAdmin):
pass
assert len(check_admin_app(apps.get_app_configs())) == 0
@classmethod
def test_stackedmodel_admin(cls):
"""Tests whether :see:LocalizedFieldsAdminMixin mixin are works with
admin.StackedInline."""
class TestModelStackedInline(
LocalizedFieldsAdminMixin, admin.StackedInline
):
model = cls.TestModel
@admin.register(cls.TestRelModel)
class TestRelModelAdmin(admin.ModelAdmin):
inlines = [TestModelStackedInline]
assert len(check_admin_app(apps.get_app_configs())) == 0
@classmethod
def test_tabularmodel_admin(cls):
"""Tests whether :see:LocalizedFieldsAdminMixin mixin are works with
admin.TabularInline."""
class TestModelTabularInline(
LocalizedFieldsAdminMixin, admin.TabularInline
):
model = cls.TestModel
@admin.register(cls.TestRelModel)
class TestRelModelAdmin(admin.ModelAdmin):
inlines = [TestModelTabularInline]
assert len(check_admin_app(apps.get_app_configs())) == 0

130
tests/test_bleach_field.py Normal file
View File

@ -0,0 +1,130 @@
"""isort:skip_file."""
import sys
import html
import pytest
from django.conf import settings
from django.test import TestCase
from localized_fields.fields import LocalizedBleachField
from localized_fields.value import LocalizedValue
try:
import bleach
from django_bleach.utils import get_bleach_default_options
except ImportError:
if sys.version_info >= (3, 9):
pytest.skip("feature not ready for python 3.9", allow_module_level=True)
class ModelTest:
"""Used to declare a bleach-able field on."""
def __init__(self, value):
"""Initializes a new instance of :see:ModelTest.
Arguments:
The value to initialize with.
"""
self.value = value
class LocalizedBleachFieldTestCase(TestCase):
"""Tests the :see:LocalizedBleachField class."""
def test_pre_save(self):
"""Tests whether the :see:pre_save function bleaches all values in a
:see:LocalizedValue."""
value = self._get_test_value()
model, field = self._get_test_model(value)
bleached_value = field.pre_save(model, False)
self._validate(value, bleached_value)
def test_pre_save_none(self):
"""Tests whether the :see:pre_save function works properly when
specifying :see:None."""
model, field = self._get_test_model(None)
bleached_value = field.pre_save(model, False)
assert not bleached_value
def test_pre_save_none_values(self):
"""Tests whether the :see:pre_save function works properly when one of
the languages has no text and is None."""
value = self._get_test_value()
value.set(settings.LANGUAGE_CODE, None)
model, field = self._get_test_model(value)
bleached_value = field.pre_save(model, False)
self._validate(value, bleached_value)
def test_pre_save_do_not_escape(self):
"""Tests whether the :see:pre_save function works properly when field
escape argument is set to False."""
value = self._get_test_value()
model, field = self._get_test_model(value, escape=False)
bleached_value = field.pre_save(model, False)
self._validate(value, bleached_value, False)
@staticmethod
def _get_test_model(value, escape=True):
"""Gets a test model and an artificially constructed
:see:LocalizedBleachField instance to test with."""
model = ModelTest(value)
field = LocalizedBleachField(escape=escape)
field.attname = "value"
return model, field
@staticmethod
def _get_test_value():
"""Gets a :see:LocalizedValue instance for testing."""
value = LocalizedValue()
for lang_code, lang_name in settings.LANGUAGES:
value.set(lang_code, "<script>%s</script>" % lang_name)
return value
@staticmethod
def _validate(non_bleached_value, bleached_value, escaped_value=True):
"""Validates whether the specified non-bleached value ended up being
correctly bleached.
Arguments:
non_bleached_value:
The value before bleaching.
bleached_value:
The value after bleaching.
"""
for lang_code, _ in settings.LANGUAGES:
if not non_bleached_value.get(lang_code):
assert not bleached_value.get(lang_code)
continue
cleaned_value = bleach.clean(
non_bleached_value.get(lang_code)
if escaped_value
else html.unescape(non_bleached_value.get(lang_code)),
get_bleach_default_options(),
)
expected_value = (
cleaned_value if escaped_value else html.unescape(cleaned_value)
)
assert bleached_value.get(lang_code) == expected_value

211
tests/test_boolean_field.py Normal file
View File

@ -0,0 +1,211 @@
from django.conf import settings
from django.db import connection
from django.db.utils import IntegrityError
from django.test import TestCase
from django.utils import translation
from localized_fields.fields import LocalizedBooleanField
from localized_fields.value import LocalizedBooleanValue
from .fake_model import get_fake_model
class LocalizedBooleanFieldTestCase(TestCase):
"""Tests whether the :see:LocalizedBooleanField and
:see:LocalizedIntegerValue works properly."""
TestModel = None
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.TestModel = get_fake_model({"translated": LocalizedBooleanField()})
def test_basic(self):
"""Tests the basics of storing boolean values."""
obj = self.TestModel()
for lang_code, _ in settings.LANGUAGES:
obj.translated.set(lang_code, False)
obj.save()
obj = self.TestModel.objects.all().first()
for lang_code, _ in settings.LANGUAGES:
assert obj.translated.get(lang_code) is False
def test_primary_language_required(self):
"""Tests whether the primary language is required by default and all
other languages are optional."""
# not filling in anything should raise IntegrityError,
# the primary language is required
with self.assertRaises(IntegrityError):
obj = self.TestModel()
obj.save()
# when filling all other languages besides the primary language
# should still raise an error because the primary is always required
with self.assertRaises(IntegrityError):
obj = self.TestModel()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
continue
obj.translated.set(lang_code, True)
obj.save()
def test_default_value_none(self):
"""Tests whether the default value for optional languages is
NoneType."""
obj = self.TestModel()
obj.translated.set(settings.LANGUAGE_CODE, True)
obj.save()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
continue
assert obj.translated.get(lang_code) is None
def test_translate(self):
"""Tests whether casting the value to a boolean results in the value
being returned in the currently active language as a boolean."""
obj = self.TestModel()
for lang_code, _ in settings.LANGUAGES:
obj.translated.set(lang_code, True)
obj.save()
obj.refresh_from_db()
for lang_code, _ in settings.LANGUAGES:
with translation.override(lang_code):
assert bool(obj.translated) is True
assert obj.translated.translate() is True
def test_translate_primary_fallback(self):
"""Tests whether casting the value to a boolean results in the value
being returned in the active language and falls back to the primary
language if there is no value in that language."""
obj = self.TestModel()
obj.translated.set(settings.LANGUAGE_CODE, True)
secondary_language = settings.LANGUAGES[-1][0]
assert obj.translated.get(secondary_language) is None
with translation.override(secondary_language):
assert obj.translated.translate() is True
assert bool(obj.translated) is True
def test_get_default_value(self):
"""Tests whether getting the value in a specific language properly
returns the specified default in case it is not available."""
obj = self.TestModel()
obj.translated.set(settings.LANGUAGE_CODE, True)
secondary_language = settings.LANGUAGES[-1][0]
assert obj.translated.get(secondary_language) is None
assert obj.translated.get(secondary_language, False) is False
def test_completely_optional(self):
"""Tests whether having all languages optional works properly."""
model = get_fake_model(
{
"translated": LocalizedBooleanField(
null=True, required=[], blank=True
)
}
)
obj = model()
obj.save()
for lang_code, _ in settings.LANGUAGES:
assert getattr(obj.translated, lang_code) is None
def test_store_string(self):
"""Tests whether the field properly raises an error when trying to
store a non-boolean."""
for lang_code, _ in settings.LANGUAGES:
obj = self.TestModel()
with self.assertRaises(IntegrityError):
obj.translated.set(lang_code, "haha")
obj.save()
def test_none_if_illegal_value_stored(self):
"""Tests whether None is returned for a language if the value stored in
the database is not a boolean."""
obj = self.TestModel()
obj.translated.set(settings.LANGUAGE_CODE, False)
obj.save()
with connection.cursor() as cursor:
table_name = self.TestModel._meta.db_table
cursor.execute("update %s set translated = 'en=>haha'" % table_name)
with self.assertRaises(ValueError):
obj.refresh_from_db()
def test_default_value(self):
"""Tests whether a default is properly set when specified."""
model = get_fake_model(
{
"translated": LocalizedBooleanField(
default={settings.LANGUAGE_CODE: True}
)
}
)
obj = model.objects.create()
assert obj.translated.get(settings.LANGUAGE_CODE) is True
obj = model()
for lang_code, _ in settings.LANGUAGES:
obj.translated.set(lang_code, None)
obj.save()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
assert obj.translated.get(lang_code) is True
else:
assert obj.translated.get(lang_code) is None
def test_default_value_update(self):
"""Tests whether a default is properly set when specified during
updates."""
model = get_fake_model(
{
"translated": LocalizedBooleanField(
default={settings.LANGUAGE_CODE: True}, null=True
)
}
)
obj = model.objects.create(
translated=LocalizedBooleanValue({settings.LANGUAGE_CODE: False})
)
assert obj.translated.get(settings.LANGUAGE_CODE) is False
model.objects.update(
translated=LocalizedBooleanValue({settings.LANGUAGE_CODE: None})
)
obj.refresh_from_db()
assert obj.translated.get(settings.LANGUAGE_CODE) is True
def test_callable_default_value(self):
output = {"en": True}
def func():
return output
model = get_fake_model({"test": LocalizedBooleanField(default=func)})
obj = model.objects.create()
assert obj.test["en"] == output["en"]

50
tests/test_bulk.py Normal file
View File

@ -0,0 +1,50 @@
from django.db import models
from django.test import TestCase
from localized_fields.fields import LocalizedField, LocalizedUniqueSlugField
from .fake_model import get_fake_model
class LocalizedBulkTestCase(TestCase):
"""Tests bulk operations with data structures provided by the django-
localized-fields library."""
@staticmethod
def test_localized_bulk_insert():
"""Tests whether bulk inserts work properly when using a
:see:LocalizedUniqueSlugField in the model."""
model = get_fake_model(
{
"name": LocalizedField(),
"slug": LocalizedUniqueSlugField(
populate_from="name", include_time=True
),
"score": models.IntegerField(),
}
)
to_create = [
model(
name={"en": "english name 1", "ro": "romanian name 1"}, score=1
),
model(
name={"en": "english name 2", "ro": "romanian name 2"}, score=2
),
model(
name={"en": "english name 3", "ro": "romanian name 3"}, score=3
),
]
model.objects.bulk_create(to_create)
assert model.objects.all().count() == 3
for obj in to_create:
obj_db = model.objects.filter(
name__en=obj.name.en, name__ro=obj.name.ro, score=obj.score
).first()
assert obj_db
assert len(obj_db.slug.en) >= len(obj_db.name.en)
assert len(obj_db.slug.ro) >= len(obj_db.name.ro)

View File

@ -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

92
tests/test_expressions.py Normal file
View File

@ -0,0 +1,92 @@
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.db import models
from django.test import TestCase
from django.utils import translation
from localized_fields.expressions import LocalizedRef
from localized_fields.fields import LocalizedField
from localized_fields.value import LocalizedValue
from .fake_model import get_fake_model
class LocalizedExpressionsTestCase(TestCase):
"""Tests whether expressions properly work with :see:LocalizedField."""
TestModel1 = None
TestModel2 = None
@classmethod
def setUpClass(cls):
"""Creates the test model in the database."""
super(LocalizedExpressionsTestCase, cls).setUpClass()
cls.TestModel1 = get_fake_model(
{"name": models.CharField(null=False, blank=False, max_length=255)}
)
cls.TestModel2 = get_fake_model(
{
"text": LocalizedField(),
"other": models.ForeignKey(
cls.TestModel1,
related_name="features",
on_delete=models.CASCADE,
),
}
)
@classmethod
def test_localized_ref(cls):
"""Tests whether the :see:LocalizedRef expression properly works."""
obj = cls.TestModel1.objects.create(name="bla bla")
for i in range(0, 10):
cls.TestModel2.objects.create(
text=LocalizedValue(
dict(
en="text_%d_en" % i,
ro="text_%d_ro" % i,
nl="text_%d_nl" % i,
)
),
other=obj,
)
def create_queryset(ref):
return cls.TestModel1.objects.annotate(mytexts=ref).values_list(
"mytexts", flat=True
)
# assert that it properly selects the currently active language
for lang_code, _ in settings.LANGUAGES:
translation.activate(lang_code)
queryset = create_queryset(LocalizedRef("features__text"))
for index, value in enumerate(queryset):
assert translation.get_language() in value
assert str(index) in value
# ensure that the default language is used in case no
# language is active at all
translation.deactivate_all()
queryset = create_queryset(LocalizedRef("features__text"))
for index, value in enumerate(queryset):
assert settings.LANGUAGE_CODE in value
assert str(index) in value
# ensures that overriding the language works properly
queryset = create_queryset(LocalizedRef("features__text", "ro"))
for index, value in enumerate(queryset):
assert "ro" in value
assert str(index) in value
# ensures that using this in combination with ArrayAgg works properly
queryset = create_queryset(
ArrayAgg(LocalizedRef("features__text", "ro"))
).first()
assert isinstance(queryset, list)
for value in queryset:
assert "ro" in value

267
tests/test_field.py Normal file
View File

@ -0,0 +1,267 @@
import json
from django.conf import settings
from django.db import models
from django.db.utils import IntegrityError
from django.test import TestCase
from localized_fields.fields import LocalizedField
from localized_fields.forms import LocalizedFieldForm
from localized_fields.value import LocalizedValue
from .data import get_init_values
from .fake_model import get_fake_model
class LocalizedFieldTestCase(TestCase):
"""Tests the :see:LocalizedField class."""
@staticmethod
def test_init():
"""Tests whether the :see:__init__ function correctly handles
parameters."""
field = LocalizedField(blank=True)
assert field.required == []
field = LocalizedField(blank=False)
assert field.required == [settings.LANGUAGE_CODE]
field = LocalizedField(required=True)
assert field.required == [
lang_code for lang_code, _ in settings.LANGUAGES
]
field = LocalizedField(required=False)
assert field.required == []
@staticmethod
def test_from_db_value():
"""Tests whether the :see:from_db_value function produces the expected
:see:LocalizedValue."""
input_data = get_init_values()
localized_value = LocalizedField().from_db_value(input_data)
for lang_code, _ in settings.LANGUAGES:
assert getattr(localized_value, lang_code) == input_data[lang_code]
@staticmethod
def test_from_db_value_none():
"""Tests whether the :see:from_db_value function correctly handles None
values."""
localized_value = LocalizedField().from_db_value(None)
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
def test_from_db_value_none_return_none(self):
"""Tests whether the :see:from_db_value function correctly handles None
values when LOCALIZED_FIELDS_EXPERIMENTAL is set to True."""
with self.settings(LOCALIZED_FIELDS_EXPERIMENTAL=True):
localized_value = LocalizedField.from_db_value(None)
assert localized_value is None
@staticmethod
def test_to_python():
"""Tests whether the :see:to_python function produces the expected
:see:LocalizedValue."""
input_data = get_init_values()
localized_value = LocalizedField().to_python(input_data)
for language, value in input_data.items():
assert localized_value.get(language) == value
@staticmethod
def test_to_python_non_json():
"""Tests whether the :see:to_python function properly handles a string
that is not JSON."""
localized_value = LocalizedField().to_python("my value")
assert localized_value.get() == "my value"
@staticmethod
def test_to_python_none():
"""Tests whether the :see:to_python function produces the expected
:see:LocalizedValue instance when it is passes None."""
localized_value = LocalizedField().to_python(None)
assert localized_value
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_to_python_non_dict():
"""Tests whether the :see:to_python function produces the expected
:see:LocalizedValue when it is passed a non-dictionary value."""
localized_value = LocalizedField().to_python(list())
assert localized_value
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_to_python_str():
"""Tests whether the :see:to_python function produces the expected
:see:LocalizedValue when it is passed serialized string value."""
serialized_str = json.dumps(get_init_values())
localized_value = LocalizedField().to_python(serialized_str)
assert isinstance(localized_value, LocalizedValue)
for language, value in get_init_values().items():
assert localized_value.get(language) == value
assert getattr(localized_value, language) == value
@staticmethod
def test_get_prep_value():
"""Tests whether the :see:get_prep_value function produces the expected
dictionary."""
input_data = get_init_values()
localized_value = LocalizedValue(input_data)
output_data = LocalizedField().get_prep_value(localized_value)
for language, value in input_data.items():
assert language in output_data
assert output_data.get(language) == value
@staticmethod
def test_get_prep_value_none():
"""Tests whether the :see:get_prep_value function produces the expected
output when it is passed None."""
output_data = LocalizedField().get_prep_value(None)
assert not output_data
@staticmethod
def test_get_prep_value_no_localized_value():
"""Tests whether the :see:get_prep_value function produces the expected
output when it is passed a non-LocalizedValue value."""
output_data = LocalizedField().get_prep_value(["huh"])
assert not output_data
def test_get_prep_value_clean(self):
"""Tests whether the :see:get_prep_value produces None as the output
when it is passed an empty, but valid LocalizedValue value but, only
when null=True."""
localized_value = LocalizedValue()
with self.assertRaises(IntegrityError):
LocalizedField(null=False).get_prep_value(localized_value)
assert not LocalizedField(null=True).get_prep_value(localized_value)
assert not LocalizedField().clean(None)
assert not LocalizedField().clean(["huh"])
@staticmethod
def test_formfield():
"""Tests whether the :see:formfield function correctly returns a valid
form."""
assert isinstance(LocalizedField().formfield(), LocalizedFieldForm)
# case optional filling
field = LocalizedField(blank=True, required=[])
assert not field.formfield().required
for field in field.formfield().fields:
assert not field.required
# case required for any language
field = LocalizedField(blank=False, required=[])
assert field.formfield().required
for field in field.formfield().fields:
assert not field.required
# case required for specific languages
required_langs = ["ro", "nl"]
field = LocalizedField(blank=False, required=required_langs)
assert field.formfield().required
for field in field.formfield().fields:
if field.label in required_langs:
assert field.required
else:
assert not field.required
# case required for all languages
field = LocalizedField(blank=False, required=True)
assert field.formfield().required
for field in field.formfield().fields:
assert field.required
def test_descriptor_user_defined_primary_key(self):
"""Tests that descriptor works even when primary key is user
defined."""
model = get_fake_model(
dict(
slug=models.SlugField(primary_key=True), title=LocalizedField()
)
)
obj = model.objects.create(slug="test", title="test")
assert obj.title == "test"
def test_required_all(self):
"""Tests whether passing required=True properly validates that all
languages are filled in."""
model = get_fake_model(dict(title=LocalizedField(required=True)))
with self.assertRaises(IntegrityError):
model.objects.create(title=dict(ro="romanian", nl="dutch"))
with self.assertRaises(IntegrityError):
model.objects.create(title=dict(nl="dutch"))
with self.assertRaises(IntegrityError):
model.objects.create(title=dict(random="random"))
with self.assertRaises(IntegrityError):
model.objects.create(title=dict())
with self.assertRaises(IntegrityError):
model.objects.create(title=None)
with self.assertRaises(IntegrityError):
model.objects.create(title="")
with self.assertRaises(IntegrityError):
model.objects.create(title=" ")
def test_required_some(self):
"""Tests whether passing an array to required, properly validates
whether the specified languages are marked as required."""
model = get_fake_model(
dict(title=LocalizedField(required=["nl", "ro"]))
)
with self.assertRaises(IntegrityError):
model.objects.create(title=dict(ro="romanian", nl="dutch"))
with self.assertRaises(IntegrityError):
model.objects.create(title=dict(nl="dutch"))
with self.assertRaises(IntegrityError):
model.objects.create(title=dict(random="random"))
with self.assertRaises(IntegrityError):
model.objects.create(title=dict())
with self.assertRaises(IntegrityError):
model.objects.create(title=None)
with self.assertRaises(IntegrityError):
model.objects.create(title="")
with self.assertRaises(IntegrityError):
model.objects.create(title=" ")

166
tests/test_file_field.py Normal file
View File

@ -0,0 +1,166 @@
import json
import os
import pickle
import shutil
import tempfile as sys_tempfile
from django import forms
from django.core.files import temp as tempfile
from django.core.files.base import ContentFile, File
from django.test import TestCase, override_settings
from localized_fields.fields import LocalizedFileField
from localized_fields.fields.file_field import LocalizedFieldFile
from localized_fields.forms import LocalizedFileFieldForm
from localized_fields.value import LocalizedFileValue, LocalizedValue
from localized_fields.widgets import LocalizedFileWidget
from .fake_model import get_fake_model
MEDIA_ROOT = sys_tempfile.mkdtemp()
@override_settings(MEDIA_ROOT=MEDIA_ROOT)
class LocalizedFileFieldTestCase(TestCase):
"""Tests the localized slug classes."""
@classmethod
def setUpClass(cls):
"""Creates the test models in the database."""
super().setUpClass()
cls.FileFieldModel = get_fake_model({"file": LocalizedFileField()})
if not os.path.isdir(MEDIA_ROOT):
os.makedirs(MEDIA_ROOT)
@classmethod
def tearDownClass(cls):
super().tearDownClass()
shutil.rmtree(MEDIA_ROOT)
@classmethod
def test_assign(cls):
"""Tests whether the :see:LocalizedFileValueDescriptor works
properly."""
temp_file = tempfile.NamedTemporaryFile(dir=MEDIA_ROOT)
instance = cls.FileFieldModel()
instance.file = {"en": temp_file.name}
assert isinstance(instance.file.en, LocalizedFieldFile)
assert instance.file.en.name == temp_file.name
field_dump = pickle.dumps(instance.file)
instance = cls.FileFieldModel()
instance.file = pickle.loads(field_dump)
assert instance.file.en.field == instance._meta.get_field("file")
assert instance.file.en.instance == instance
assert isinstance(instance.file.en, LocalizedFieldFile)
instance = cls.FileFieldModel()
instance.file = {"en": ContentFile("test", "testfilename")}
assert isinstance(instance.file.en, LocalizedFieldFile)
assert instance.file.en.name == "testfilename"
another_instance = cls.FileFieldModel()
another_instance.file = {"ro": instance.file.en}
assert another_instance == another_instance.file.ro.instance
assert another_instance.file.ro.lang == "ro"
@classmethod
def test_save_form_data(cls):
"""Tests whether the :see:save_form_data function correctly set a valid
value."""
instance = cls.FileFieldModel()
data = LocalizedFileValue({"en": False})
instance._meta.get_field("file").save_form_data(instance, data)
assert instance.file.en == ""
@classmethod
def test_pre_save(cls):
"""Tests whether the :see:pre_save function works properly."""
instance = cls.FileFieldModel()
instance.file = {"en": ContentFile("test", "testfilename")}
instance._meta.get_field("file").pre_save(instance, False)
assert instance.file.en._committed is True
@classmethod
def test_file_methods(cls):
"""Tests whether the :see:LocalizedFieldFile.delete method works
correctly."""
temp_file = File(tempfile.NamedTemporaryFile())
instance = cls.FileFieldModel()
# Calling delete on an unset FileField should not call the file deletion
# process, but fail silently
instance.file.en.delete()
instance.file.en.save("testfilename", temp_file)
assert instance.file.en.name == "testfilename"
instance.file.en.delete()
assert instance.file.en.name is None
@classmethod
def test_generate_filename(cls):
"""Tests whether the :see:LocalizedFieldFile.generate_filename method
works correctly."""
instance = cls.FileFieldModel()
field = instance._meta.get_field("file")
field.upload_to = "{lang}/"
filename = field.generate_filename(instance, "test", "en")
assert filename == "en/test"
field.upload_to = lambda instance, filename, lang: "%s_%s" % (
lang,
filename,
)
filename = field.generate_filename(instance, "test", "en")
assert filename == "en_test"
@classmethod
@override_settings(LANGUAGES=(("en", "English"),))
def test_value_to_string(cls):
"""Tests whether the :see:LocalizedFileField class's
:see:value_to_string function works properly."""
temp_file = File(tempfile.NamedTemporaryFile())
instance = cls.FileFieldModel()
field = cls.FileFieldModel._meta.get_field("file")
field.upload_to = ""
instance.file.en.save("testfilename", temp_file)
expected_value_to_string = json.dumps({"en": "testfilename"})
assert field.value_to_string(instance) == expected_value_to_string
@staticmethod
def test_get_prep_value():
"""Tests whether the :see:get_prep_value function returns correctly
value."""
value = LocalizedValue({"en": None})
assert LocalizedFileField().get_prep_value(None) is None
assert isinstance(LocalizedFileField().get_prep_value(value), dict)
assert LocalizedFileField().get_prep_value(value)["en"] == ""
@staticmethod
def test_formfield():
"""Tests whether the :see:formfield function correctly returns a valid
form."""
form_field = LocalizedFileField().formfield()
assert isinstance(form_field, LocalizedFileFieldForm)
assert isinstance(form_field, forms.FileField)
assert isinstance(form_field.widget, LocalizedFileWidget)
@staticmethod
def test_deconstruct():
"""Tests whether the :see:LocalizedFileField class's :see:deconstruct
function works properly."""
name, path, args, kwargs = LocalizedFileField().deconstruct()
assert "upload_to" in kwargs
assert "storage" not in kwargs
name, path, args, kwargs = LocalizedFileField(
storage="test"
).deconstruct()
assert "storage" in kwargs

View File

@ -0,0 +1,40 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.test import TestCase
from localized_fields.forms import LocalizedFileFieldForm
class LocalizedFileFieldFormTestCase(TestCase):
"""Tests the workings of the :see:LocalizedFileFieldForm class."""
def test_clean(self):
"""Tests whether the :see:clean function is working properly."""
formfield = LocalizedFileFieldForm(required=True)
with self.assertRaises(ValidationError):
formfield.clean([])
with self.assertRaises(ValidationError):
formfield.clean([], {"en": None})
with self.assertRaises(ValidationError):
formfield.clean("badvalue")
with self.assertRaises(ValidationError):
value = [FILE_INPUT_CONTRADICTION] * len(settings.LANGUAGES)
formfield.clean(value)
formfield = LocalizedFileFieldForm(required=False)
formfield.clean([""] * len(settings.LANGUAGES))
formfield.clean(["", ""], ["", ""])
def test_bound_data(self):
"""Tests whether the :see:bound_data function is returns correctly
value."""
formfield = LocalizedFileFieldForm()
assert formfield.bound_data([""], None) == [""]
initial = dict([(lang, "") for lang, _ in settings.LANGUAGES])
value = [None] * len(settings.LANGUAGES)
expected_value = [""] * len(settings.LANGUAGES)
assert formfield.bound_data(value, initial) == expected_value

26
tests/test_file_widget.py Normal file
View File

@ -0,0 +1,26 @@
from django.test import TestCase
from localized_fields.value import LocalizedFileValue
from localized_fields.widgets import LocalizedFileWidget
class LocalizedFileWidgetTestCase(TestCase):
"""Tests the workings of the :see:LocalizedFiledWidget class."""
@staticmethod
def test_get_context():
"""Tests whether the :see:get_context correctly handles 'required'
attribute, separately for each subwidget."""
widget = LocalizedFileWidget()
widget.widgets[0].is_required = True
widget.widgets[1].is_required = True
widget.widgets[2].is_required = False
context = widget.get_context(
name="test",
value=LocalizedFileValue(dict(en="test")),
attrs=dict(required=True),
)
assert "required" not in context["widget"]["subwidgets"][0]["attrs"]
assert context["widget"]["subwidgets"][1]["attrs"]["required"]
assert "required" not in context["widget"]["subwidgets"][2]["attrs"]

172
tests/test_float_field.py Normal file
View File

@ -0,0 +1,172 @@
from django.conf import settings
from django.db import connection
from django.db.utils import IntegrityError
from django.test import TestCase
from django.utils import translation
from localized_fields.fields import LocalizedFloatField
from .fake_model import get_fake_model
class LocalizedFloatFieldTestCase(TestCase):
"""Tests whether the :see:LocalizedFloatField and :see:LocalizedFloatValue
works properly."""
TestModel = None
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.TestModel = get_fake_model({"score": LocalizedFloatField()})
def test_basic(self):
"""Tests the basics of storing float values."""
obj = self.TestModel()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
obj.score.set(lang_code, index + 1.0)
obj.save()
obj = self.TestModel.objects.all().first()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
assert obj.score.get(lang_code) == index + 1.0
def test_primary_language_required(self):
"""Tests whether the primary language is required by default and all
other languages are optiona."""
# not filling in anything should raise IntegrityError,
# the primary language is required
with self.assertRaises(IntegrityError):
obj = self.TestModel()
obj.save()
# when filling all other languages besides the primary language
# should still raise an error because the primary is always required
with self.assertRaises(IntegrityError):
obj = self.TestModel()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
continue
obj.score.set(lang_code, 23.0)
obj.save()
def test_default_value_none(self):
"""Tests whether the default value for optional languages is
NoneType."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 1234.0)
obj.save()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
continue
assert obj.score.get(lang_code) is None
def test_translate(self):
"""Tests whether casting the value to an float results in the value
being returned in the currently active language as an float."""
obj = self.TestModel()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
obj.score.set(lang_code, index + 1.0)
obj.save()
obj.refresh_from_db()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
with translation.override(lang_code):
assert float(obj.score) == index + 1.0
assert obj.score.translate() == index + 1.0
def test_translate_primary_fallback(self):
"""Tests whether casting the value to an float results in the value
begin returned in the active language and falls back to the primary
language if there is no value in that language."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 25.0)
secondary_language = settings.LANGUAGES[-1][0]
assert obj.score.get(secondary_language) is None
with translation.override(secondary_language):
assert obj.score.translate() == 25.0
assert float(obj.score) == 25.0
def test_get_default_value(self):
"""Tests whether getting the value in a specific language properly
returns the specified default in case it is not available."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 25.0)
secondary_language = settings.LANGUAGES[-1][0]
assert obj.score.get(secondary_language) is None
assert obj.score.get(secondary_language, 1337.0) == 1337.0
def test_completely_optional(self):
"""Tests whether having all languages optional works properly."""
model = get_fake_model(
{"score": LocalizedFloatField(null=True, required=[], blank=True)}
)
obj = model()
obj.save()
for lang_code, _ in settings.LANGUAGES:
assert getattr(obj.score, lang_code) is None
def test_store_string(self):
"""Tests whether the field properly raises an error when trying to
store a non-float."""
for lang_code, _ in settings.LANGUAGES:
obj = self.TestModel()
with self.assertRaises(IntegrityError):
obj.score.set(lang_code, "haha")
obj.save()
def test_none_if_illegal_value_stored(self):
"""Tests whether None is returned for a language if the value stored in
the database is not an float."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 25.0)
obj.save()
with connection.cursor() as cursor:
table_name = self.TestModel._meta.db_table
cursor.execute("update %s set score = 'en=>haha'" % table_name)
obj.refresh_from_db()
assert obj.score.get(settings.LANGUAGE_CODE) is None
def test_default_value(self):
"""Tests whether a default is properly set when specified."""
model = get_fake_model(
{
"score": LocalizedFloatField(
default={settings.LANGUAGE_CODE: 75.0}
)
}
)
obj = model.objects.create()
assert obj.score.get(settings.LANGUAGE_CODE) == 75.0
obj = model()
for lang_code, _ in settings.LANGUAGES:
obj.score.set(lang_code, None)
obj.save()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
assert obj.score.get(lang_code) == 75.0
else:
assert obj.score.get(lang_code) is None

51
tests/test_form.py Normal file
View File

@ -0,0 +1,51 @@
from django.conf import settings
from django.test import TestCase
from localized_fields.forms import LocalizedFieldForm
class LocalizedFieldFormTestCase(TestCase):
"""Tests the workings of the :see:LocalizedFieldForm class."""
@staticmethod
def test_init():
"""Tests whether the constructor correctly creates a field for every
language."""
# case required for specific language
form = LocalizedFieldForm(required=[settings.LANGUAGE_CODE])
for (lang_code, _), field in zip(settings.LANGUAGES, form.fields):
assert field.label == lang_code
if lang_code == settings.LANGUAGE_CODE:
assert field.required
else:
assert not field.required
# case required for all languages
form = LocalizedFieldForm(required=True)
assert form.required
for field in form.fields:
assert field.required
# case optional filling
form = LocalizedFieldForm(required=False)
assert not form.required
for field in form.fields:
assert not field.required
# case required for any language
form = LocalizedFieldForm(required=[])
assert form.required
for field in form.fields:
assert not field.required
@staticmethod
def test_compress():
"""Tests whether the :see:compress function is working properly."""
input_value = [lang_name for _, lang_name in settings.LANGUAGES]
output_value = LocalizedFieldForm().compress(input_value)
for lang_code, lang_name in settings.LANGUAGES:
assert output_value.get(lang_code) == lang_name

239
tests/test_integer_field.py Normal file
View File

@ -0,0 +1,239 @@
import django
from django.conf import settings
from django.db import connection
from django.db.utils import IntegrityError
from django.test import TestCase
from django.utils import translation
from localized_fields.fields import LocalizedIntegerField
from localized_fields.value import LocalizedIntegerValue
from .fake_model import get_fake_model
class LocalizedIntegerFieldTestCase(TestCase):
"""Tests whether the :see:LocalizedIntegerField and
:see:LocalizedIntegerValue works properly."""
TestModel = None
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.TestModel = get_fake_model({"score": LocalizedIntegerField()})
def test_basic(self):
"""Tests the basics of storing integer values."""
obj = self.TestModel()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
obj.score.set(lang_code, index + 1)
obj.save()
obj = self.TestModel.objects.all().first()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
assert obj.score.get(lang_code) == index + 1
def test_primary_language_required(self):
"""Tests whether the primary language is required by default and all
other languages are optiona."""
# not filling in anything should raise IntegrityError,
# the primary language is required
with self.assertRaises(IntegrityError):
obj = self.TestModel()
obj.save()
# when filling all other languages besides the primary language
# should still raise an error because the primary is always required
with self.assertRaises(IntegrityError):
obj = self.TestModel()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
continue
obj.score.set(lang_code, 23)
obj.save()
def test_default_value_none(self):
"""Tests whether the default value for optional languages is
NoneType."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 1234)
obj.save()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
continue
assert obj.score.get(lang_code) is None
def test_translate(self):
"""Tests whether casting the value to an integer results in the value
being returned in the currently active language as an integer."""
obj = self.TestModel()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
obj.score.set(lang_code, index + 1)
obj.save()
obj.refresh_from_db()
for index, (lang_code, _) in enumerate(settings.LANGUAGES):
with translation.override(lang_code):
assert int(obj.score) == index + 1
assert obj.score.translate() == index + 1
def test_translate_primary_fallback(self):
"""Tests whether casting the value to an integer results in the value
begin returned in the active language and falls back to the primary
language if there is no value in that language."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 25)
secondary_language = settings.LANGUAGES[-1][0]
assert obj.score.get(secondary_language) is None
with translation.override(secondary_language):
assert obj.score.translate() == 25
assert int(obj.score) == 25
def test_get_default_value(self):
"""Tests whether getting the value in a specific language properly
returns the specified default in case it is not available."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 25)
secondary_language = settings.LANGUAGES[-1][0]
assert obj.score.get(secondary_language) is None
assert obj.score.get(secondary_language, 1337) == 1337
def test_completely_optional(self):
"""Tests whether having all languages optional works properly."""
model = get_fake_model(
{"score": LocalizedIntegerField(null=True, required=[], blank=True)}
)
obj = model()
obj.save()
for lang_code, _ in settings.LANGUAGES:
assert getattr(obj.score, lang_code) is None
def test_store_string(self):
"""Tests whether the field properly raises an error when trying to
store a non-integer."""
for lang_code, _ in settings.LANGUAGES:
obj = self.TestModel()
with self.assertRaises(IntegrityError):
obj.score.set(lang_code, "haha")
obj.save()
def test_none_if_illegal_value_stored(self):
"""Tests whether None is returned for a language if the value stored in
the database is not an integer."""
obj = self.TestModel()
obj.score.set(settings.LANGUAGE_CODE, 25)
obj.save()
with connection.cursor() as cursor:
table_name = self.TestModel._meta.db_table
cursor.execute("update %s set score = 'en=>haha'" % table_name)
obj.refresh_from_db()
assert obj.score.get(settings.LANGUAGE_CODE) is None
def test_default_value(self):
"""Tests whether a default is properly set when specified."""
model = get_fake_model(
{
"score": LocalizedIntegerField(
default={settings.LANGUAGE_CODE: 75}
)
}
)
obj = model.objects.create()
assert obj.score.get(settings.LANGUAGE_CODE) == 75
obj = model()
for lang_code, _ in settings.LANGUAGES:
obj.score.set(lang_code, None)
obj.save()
for lang_code, _ in settings.LANGUAGES:
if lang_code == settings.LANGUAGE_CODE:
assert obj.score.get(lang_code) == 75
else:
assert obj.score.get(lang_code) is None
def test_default_value_update(self):
"""Tests whether a default is properly set when specified during
updates."""
model = get_fake_model(
{
"score": LocalizedIntegerField(
default={settings.LANGUAGE_CODE: 75}, null=True
)
}
)
obj = model.objects.create(
score=LocalizedIntegerValue({settings.LANGUAGE_CODE: 35})
)
assert obj.score.get(settings.LANGUAGE_CODE) == 35
model.objects.update(
score=LocalizedIntegerValue({settings.LANGUAGE_CODE: None})
)
obj.refresh_from_db()
assert obj.score.get(settings.LANGUAGE_CODE) == 75
def test_callable_default_value(self):
output = {"en": 5}
def func():
return output
model = get_fake_model({"test": LocalizedIntegerField(default=func)})
obj = model.objects.create()
assert obj.test["en"] == output["en"]
def test_order_by(self):
"""Tests whether ordering by a :see:LocalizedIntegerField key works
expected."""
# using key transforms (score__en) in order_by(..) is only
# supported since Django 2.1
# https://github.com/django/django/commit/2162f0983de0dfe2178531638ce7ea56f54dd4e7#diff-0edd853580d56db07e4020728d59e193
if django.VERSION < (2, 1):
return
model = get_fake_model(
{
"score": LocalizedIntegerField(
default={settings.LANGUAGE_CODE: 1337}, null=True
)
}
)
model.objects.create(score=dict(en=982))
model.objects.create(score=dict(en=382))
model.objects.create(score=dict(en=1331))
res = list(
model.objects.values_list("score__en", flat=True).order_by(
"-score__en"
)
)
assert res == [1331, 982, 382]

51
tests/test_isnull.py Normal file
View File

@ -0,0 +1,51 @@
import django
import pytest
from django.test import TestCase
from localized_fields.fields import LocalizedField
from localized_fields.value import LocalizedValue
from .fake_model import get_fake_model
class LocalizedIsNullLookupsTestCase(TestCase):
"""Tests whether ref lookups properly work with."""
TestModel1 = None
@classmethod
def setUpClass(cls):
"""Creates the test model in the database."""
super(LocalizedIsNullLookupsTestCase, cls).setUpClass()
cls.TestModel = get_fake_model(
{"text": LocalizedField(null=True, required=[])}
)
cls.TestModel.objects.create(
text=LocalizedValue(dict(en="text_en", ro="text_ro", nl="text_nl"))
)
cls.TestModel.objects.create(
text=None,
)
def test_isnull_lookup_valid_values(self):
"""Test whether isnull properly works with valid values."""
assert self.TestModel.objects.filter(text__isnull=True).exists()
assert self.TestModel.objects.filter(text__isnull=False).exists()
def test_isnull_lookup_null(self):
"""Test whether isnull crashes with None as value."""
with pytest.raises(ValueError):
assert self.TestModel.objects.filter(text__isnull=None).exists()
def test_isnull_lookup_string(self):
"""Test whether isnull properly works with string values on the
corresponding Django version."""
if django.VERSION < (4, 0):
assert self.TestModel.objects.filter(text__isnull="True").exists()
else:
with pytest.raises(ValueError):
assert self.TestModel.objects.filter(
text__isnull="True"
).exists()

View File

@ -1,103 +0,0 @@
from django.conf import settings
from django.test import TestCase
from django_bleach.utils import get_bleach_default_options
import bleach
from localized_fields import LocalizedBleachField, LocalizedValue
class TestModel:
"""Used to declare a bleach-able field on."""
def __init__(self, value):
"""Initializes a new instance of :see:TestModel.
Arguments:
The value to initialize with.
"""
self.value = value
class LocalizedBleachFieldTestCase(TestCase):
"""Tests the :see:LocalizedBleachField class."""
def test_pre_save(self):
"""Tests whether the :see:pre_save function
bleaches all values in a :see:LocalizedValue."""
value = self._get_test_value()
model, field = self._get_test_model(value)
bleached_value = field.pre_save(model, False)
self._validate(value, bleached_value)
def test_pre_save_none(self):
"""Tests whether the :see:pre_save function
works properly when specifying :see:None."""
model, field = self._get_test_model(None)
bleached_value = field.pre_save(model, False)
assert not bleached_value
def test_pre_save_none_values(self):
"""Tests whether the :see:pre_save function
works properly when one of the languages has
no text and is None."""
value = self._get_test_value()
value.set(settings.LANGUAGE_CODE, None)
model, field = self._get_test_model(value)
bleached_value = field.pre_save(model, False)
self._validate(value, bleached_value)
@staticmethod
def _get_test_model(value):
"""Gets a test model and a artifically
constructed :see:LocalizedBleachField
instance to test with."""
model = TestModel(value)
field = LocalizedBleachField()
field.attname = 'value'
return model, field
@staticmethod
def _get_test_value():
"""Gets a :see:LocalizedValue instance for testing."""
value = LocalizedValue()
for lang_code, lang_name in settings.LANGUAGES:
value.set(lang_code, '<script>%s</script>' % lang_name)
return value
@staticmethod
def _validate(non_bleached_value, bleached_value):
"""Validates whether the specified non-bleached
value ended up being correctly bleached.
Arguments:
non_bleached_value:
The value before bleaching.
bleached_value:
The value after bleaching.
"""
for lang_code, _ in settings.LANGUAGES:
if not non_bleached_value.get(lang_code):
assert not bleached_value.get(lang_code)
continue
expected_value = bleach.clean(
non_bleached_value.get(lang_code),
get_bleach_default_options()
)
assert bleached_value.get(lang_code) == expected_value

View File

@ -1,265 +0,0 @@
from django.conf import settings
from django.test import TestCase
from django.utils import translation
from django.db.utils import IntegrityError
from localized_fields import LocalizedField, LocalizedValue, LocalizedFieldForm
def get_init_values() -> dict:
"""Gets a test dictionary containing a key
for every language."""
keys = {}
for lang_code, lang_name in settings.LANGUAGES:
keys[lang_code] = 'value in %s' % lang_name
return keys
class LocalizedValueTestCase(TestCase):
"""Tests the :see:LocalizedValue class."""
@staticmethod
def tearDown():
"""Assures that the current language
is set back to the default."""
translation.activate(settings.LANGUAGE_CODE)
@staticmethod
def test_init():
"""Tests whether the __init__ function
of the :see:LocalizedValue class works
as expected."""
keys = get_init_values()
value = LocalizedValue(keys)
for lang_code, _ in settings.LANGUAGES:
assert getattr(value, lang_code, None) == keys[lang_code]
@staticmethod
def test_init_default_values():
"""Tests wehther the __init__ function
of the :see:LocalizedValue accepts the
default value or an empty dict properly."""
value = LocalizedValue()
for lang_code, _ in settings.LANGUAGES:
assert getattr(value, lang_code) is None
@staticmethod
def test_get_explicit():
"""Tests whether the the :see:LocalizedValue
class's :see:get function works properly
when specifying an explicit value."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
assert localized_value.get(language) == value
@staticmethod
def test_get_default_language():
"""Tests whether the :see:LocalizedValue
class's see:get function properly
gets the value in the default language."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, _ in keys.items():
translation.activate(language)
assert localized_value.get() == keys[settings.LANGUAGE_CODE]
@staticmethod
def test_set():
"""Tests whether the :see:LocalizedValue
class's see:set function works properly."""
localized_value = LocalizedValue()
for language, value in get_init_values():
localized_value.set(language, value)
assert localized_value.get(language) == value
assert getattr(localized_value, language) == value
@staticmethod
def test_str():
"""Tests whether the :see:LocalizedValue
class's __str__ works properly."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
translation.activate(language)
assert str(localized_value) == value
@staticmethod
def test_str_fallback():
"""Tests whether the :see:LocalizedValue
class's __str__'s fallback functionality
works properly."""
test_value = 'myvalue'
localized_value = LocalizedValue({
settings.LANGUAGE_CODE: test_value
})
other_language = settings.LANGUAGES[-1][0]
# make sure that, by default it returns
# the value in the default language
assert str(localized_value) == test_value
# make sure that it falls back to the
# primary language when there's no value
# available in the current language
translation.activate(other_language)
assert str(localized_value) == test_value
# make sure that it's just __str__ falling
# back and that for the other language
# there's no actual value
assert localized_value.get(other_language) != test_value
@staticmethod
def test_deconstruct():
"""Tests whether the :see:LocalizedValue
class's :see:deconstruct function works properly."""
keys = get_init_values()
value = LocalizedValue(keys)
path, args, kwargs = value.deconstruct()
assert args[0] == keys
@staticmethod
def test_construct_string():
"""Tests whether the :see:LocalizedValue's constructor
assumes the primary language when passing a single string."""
value = LocalizedValue('beer')
assert value.get(settings.LANGUAGE_CODE) == 'beer'
class LocalizedFieldTestCase(TestCase):
"""Tests the :see:LocalizedField class."""
@staticmethod
def test_from_db_value():
"""Tests whether the :see:from_db_value function
produces the expected :see:LocalizedValue."""
input_data = get_init_values()
localized_value = LocalizedField.from_db_value(input_data)
for lang_code, _ in settings.LANGUAGES:
assert getattr(localized_value, lang_code) == input_data[lang_code]
@staticmethod
def test_from_db_value_none():
"""Tests whether the :see:from_db_valuei function
correctly handles None values."""
localized_value = LocalizedField.from_db_value(None)
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_to_python():
"""Tests whether the :see:to_python function
produces the expected :see:LocalizedValue."""
input_data = get_init_values()
localized_value = LocalizedField().to_python(input_data)
for language, value in input_data.items():
assert localized_value.get(language) == value
@staticmethod
def test_to_python_none():
"""Tests whether the :see:to_python function
produces the expected :see:LocalizedValue
instance when it is passes None."""
localized_value = LocalizedField().to_python(None)
assert localized_value
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_to_python_non_dict():
"""Tests whether the :see:to_python function produces
the expected :see:LocalizedValue when it is
passed a non-dictionary value."""
localized_value = LocalizedField().to_python(list())
assert localized_value
for lang_code, _ in settings.LANGUAGES:
assert localized_value.get(lang_code) is None
@staticmethod
def test_get_prep_value():
""""Tests whether the :see:get_prep_value function
produces the expected dictionary."""
input_data = get_init_values()
localized_value = LocalizedValue(input_data)
output_data = LocalizedField().get_prep_value(localized_value)
for language, value in input_data.items():
assert language in output_data
assert output_data.get(language) == value
@staticmethod
def test_get_prep_value_none():
"""Tests whether the :see:get_prep_value function
produces the expected output when it is passed None."""
output_data = LocalizedField().get_prep_value(None)
assert not output_data
@staticmethod
def test_get_prep_value_no_localized_value():
"""Tests whether the :see:get_prep_value function
produces the expected output when it is passed a
non-LocalizedValue value."""
output_data = LocalizedField().get_prep_value(['huh'])
assert not output_data
def test_get_prep_value_clean(self):
"""Tests whether the :see:get_prep_value produces
None as the output when it is passed an empty, but
valid LocalizedValue value but, only when null=True."""
localized_value = LocalizedValue()
with self.assertRaises(IntegrityError):
LocalizedField(null=False).get_prep_value(localized_value)
assert not LocalizedField(null=True).get_prep_value(localized_value)
assert not LocalizedField().clean(None)
assert not LocalizedField().clean(['huh'])
@staticmethod
def test_formfield():
"""Tests whether the :see:formfield function
correctly returns a valid form."""
assert isinstance(
LocalizedField().formfield(),
LocalizedFieldForm
)

View File

@ -1,34 +0,0 @@
from django.conf import settings
from django.test import TestCase
from localized_fields import LocalizedFieldForm
class LocalizedFieldFormTestCase(TestCase):
"""Tests the workings of the :see:LocalizedFieldForm class."""
@staticmethod
def test_init():
"""Tests whether the constructor correctly
creates a field for every language."""
form = LocalizedFieldForm()
for (lang_code, _), field in zip(settings.LANGUAGES, form.fields):
assert field.label == lang_code
if lang_code == settings.LANGUAGE_CODE:
assert field.required
else:
assert not field.required
@staticmethod
def test_compress():
"""Tests whether the :see:compress function
is working properly."""
input_value = [lang_name for _, lang_name in settings.LANGUAGES]
output_value = LocalizedFieldForm().compress(input_value)
for lang_code, lang_name in settings.LANGUAGES:
assert output_value.get(lang_code) == lang_name

View File

@ -1,43 +0,0 @@
from django.conf import settings
from django.test import TestCase
from localized_fields import LocalizedFieldWidget, LocalizedValue
class LocalizedFieldWidgetTestCase(TestCase):
"""Tests the workings of the :see:LocalizedFieldWidget class."""
@staticmethod
def test_widget_creation():
"""Tests whether a widget is created for every
language correctly."""
widget = LocalizedFieldWidget()
assert len(widget.widgets) == len(settings.LANGUAGES)
@staticmethod
def test_decompress():
"""Tests whether a :see:LocalizedValue instance
can correctly be "decompressed" over the available
widgets."""
localized_value = LocalizedValue()
for lang_code, lang_name in settings.LANGUAGES:
localized_value.set(lang_code, lang_name)
widget = LocalizedFieldWidget()
decompressed_values = widget.decompress(localized_value)
for (lang_code, _), value in zip(settings.LANGUAGES, decompressed_values):
assert localized_value.get(lang_code) == value
@staticmethod
def test_decompress_none():
"""Tests whether the :see:LocalizedFieldWidget correctly
handles :see:None."""
widget = LocalizedFieldWidget()
decompressed_values = widget.decompress(None)
for _, value in zip(settings.LANGUAGES, decompressed_values):
assert not value

View File

@ -1,54 +0,0 @@
from django.test import TestCase
from localized_fields import LocalizedField, LocalizedValue
from .fake_model import get_fake_model
class LocalizedModelTestCase(TestCase):
"""Tests whether the :see:LocalizedModel class."""
TestModel = None
@classmethod
def setUpClass(cls):
"""Creates the test model in the database."""
super(LocalizedModelTestCase, cls).setUpClass()
cls.TestModel = get_fake_model(
'LocalizedModelTestCase',
{
'title': LocalizedField()
}
)
@classmethod
def test_defaults(cls):
"""Tests whether all :see:LocalizedField
fields are assigned an empty :see:LocalizedValue
instance when the model is instanitiated."""
obj = cls.TestModel()
assert isinstance(obj.title, LocalizedValue)
@classmethod
def test_model_init_kwargs(cls):
"""Tests whether all :see:LocalizedField
fields are assigned an empty :see:LocalizedValue
instance when the model is instanitiated."""
data = {
'title': {
'en': 'english_title',
'ro': 'romanian_title',
'nl': 'dutch_title'
}
}
obj = cls.TestModel(**data)
assert isinstance(obj.title, LocalizedValue)
assert obj.title.en == 'english_title'
assert obj.title.ro == 'romanian_title'
assert obj.title.nl == 'dutch_title'

View File

@ -1,178 +0,0 @@
from django import forms
from django.conf import settings
from django.test import TestCase
from django.db.utils import IntegrityError
from django.utils.text import slugify
from localized_fields import (LocalizedField, LocalizedAutoSlugField,
LocalizedMagicSlugField)
from .fake_model import get_fake_model
class LocalizedSlugFieldTestCase(TestCase):
"""Tests the localized slug classes."""
AutoSlugModel = None
MagicSlugModel = None
@classmethod
def setUpClass(cls):
"""Creates the test models in the database."""
super(LocalizedSlugFieldTestCase, cls).setUpClass()
cls.AutoSlugModel = get_fake_model(
'LocalizedAutoSlugFieldTestModel',
{
'title': LocalizedField(),
'slug': LocalizedAutoSlugField(populate_from='title')
}
)
cls.MagicSlugModel = get_fake_model(
'LocalizedMagicSlugFieldTestModel',
{
'title': LocalizedField(),
'slug': LocalizedMagicSlugField(populate_from='title')
}
)
@classmethod
def test_populate_auto(cls):
cls._test_populate(cls.AutoSlugModel)
@classmethod
def test_populate_magic(cls):
cls._test_populate(cls.MagicSlugModel)
@classmethod
def test_populate_multiple_languages_auto(cls):
cls._test_populate_multiple_languages(cls.AutoSlugModel)
@classmethod
def test_populate_multiple_languages_magic(cls):
cls._test_populate_multiple_languages(cls.MagicSlugModel)
@classmethod
def test_unique_slug_auto(cls):
cls._test_unique_slug(cls.AutoSlugModel)
@classmethod
def test_unique_slug_magic(cls):
cls._test_unique_slug(cls.MagicSlugModel)
def test_unique_slug_magic_max_retries(self):
"""Tests whether the magic slug implementation doesn't
try to find a slug forever and gives up after a while."""
title = 'mymagictitle'
obj = self.MagicSlugModel()
obj.title.en = title
obj.save()
with self.assertRaises(IntegrityError):
for _ in range(0, settings.LOCALIZED_FIELDS_MAX_RETRIES + 1):
another_obj = self.MagicSlugModel()
another_obj.title.en = title
another_obj.save()
@classmethod
def test_unique_slug_utf_auto(cls):
cls._test_unique_slug_utf(cls.AutoSlugModel)
@classmethod
def test_unique_slug_utf_magic(cls):
cls._test_unique_slug_utf(cls.MagicSlugModel)
@classmethod
def test_deconstruct_auto(cls):
cls._test_deconstruct(LocalizedAutoSlugField)
@classmethod
def test_deconstruct_magic(cls):
cls._test_deconstruct(LocalizedMagicSlugField)
@classmethod
def test_formfield_auto(cls):
cls._test_formfield(LocalizedAutoSlugField)
@classmethod
def test_formfield_magic(cls):
cls._test_formfield(LocalizedMagicSlugField)
@staticmethod
def _test_populate(model):
"""Tests whether the populating feature works correctly."""
obj = model()
obj.title.en = 'this is my title'
obj.save()
assert obj.slug.get('en') == slugify(obj.title)
@staticmethod
def _test_populate_multiple_languages(model):
"""Tests whether the populating feature correctly
works for all languages."""
obj = model()
for lang_code, lang_name in settings.LANGUAGES:
obj.title.set(lang_code, 'title %s' % lang_name)
obj.save()
for lang_code, lang_name in settings.LANGUAGES:
assert obj.slug.get(lang_code) == 'title-%s' % lang_name.lower()
@staticmethod
def _test_unique_slug(model):
"""Tests whether unique slugs are properly generated."""
title = 'mymagictitle'
obj = model()
obj.title.en = title
obj.save()
for i in range(1, settings.LOCALIZED_FIELDS_MAX_RETRIES - 1):
another_obj = model()
another_obj.title.en = title
another_obj.save()
assert another_obj.slug.en == '%s-%d' % (title, i)
@staticmethod
def _test_unique_slug_utf(model):
"""Tests whether generating a slug works
when the value consists completely out
of non-ASCII characters."""
obj = model()
obj.title.en = 'مكاتب للايجار بشارع بورسعيد'
obj.save()
assert obj.slug.en == 'مكاتب-للايجار-بشارع-بورسعيد'
@staticmethod
def _test_deconstruct(field_type):
"""Tests whether the :see:deconstruct
function properly retains options
specified in the constructor."""
field = field_type(populate_from='title')
_, _, _, kwargs = field.deconstruct()
assert 'populate_from' in kwargs
assert kwargs['populate_from'] == field.populate_from
@staticmethod
def _test_formfield(field_type):
"""Tests whether the :see:formfield method
returns a valid form field that is hidden."""
form_field = field_type(populate_from='title').formfield()
assert isinstance(form_field, forms.CharField)
assert isinstance(form_field.widget, forms.HiddenInput)

131
tests/test_lookups.py Normal file
View File

@ -0,0 +1,131 @@
from django.apps import apps
from django.conf import settings
from django.test import TestCase, override_settings
from django.utils import translation
from localized_fields.expressions import LocalizedRef
from localized_fields.fields import LocalizedField
from localized_fields.value import LocalizedValue
from .fake_model import get_fake_model
@override_settings(LOCALIZED_FIELDS_EXPERIMENTAL=True)
class LocalizedLookupsTestCase(TestCase):
"""Tests whether localized lookups properly work with."""
TestModel1 = None
@classmethod
def setUpClass(cls):
"""Creates the test model in the database."""
super(LocalizedLookupsTestCase, cls).setUpClass()
# reload app as setting has changed
config = apps.get_app_config("localized_fields")
config.ready()
cls.TestModel = get_fake_model({"text": LocalizedField()})
def test_localized_lookup(self):
"""Tests whether localized lookup properly works."""
self.TestModel.objects.create(
text=LocalizedValue(dict(en="text_en", ro="text_ro", nl="text_nl"))
)
# assert that it properly lookups the currently active language
for lang_code, _ in settings.LANGUAGES:
translation.activate(lang_code)
assert self.TestModel.objects.filter(
text="text_" + lang_code
).exists()
# ensure that the default language is used in case no
# language is active at all
translation.deactivate_all()
assert self.TestModel.objects.filter(text="text_en").exists()
# ensure that hstore lookups still work
assert self.TestModel.objects.filter(text__ro="text_ro").exists()
def test_localized_lookup_specific_isnull(self):
self.TestModel.objects.create(
text=LocalizedValue(dict(en="text_en", ro="text_ro", nl=None))
)
translation.activate("nl")
assert (
self.TestModel.objects.annotate(text_localized=LocalizedRef("text"))
.filter(text_localized__isnull=True)
.exists()
)
class LocalizedRefLookupsTestCase(TestCase):
"""Tests whether ref lookups properly work with."""
TestModel1 = None
@classmethod
def setUpClass(cls):
"""Creates the test model in the database."""
super(LocalizedRefLookupsTestCase, cls).setUpClass()
cls.TestModel = get_fake_model({"text": LocalizedField()})
cls.TestModel.objects.create(
text=LocalizedValue(dict(en="text_en", ro="text_ro", nl="text_nl"))
)
def test_active_ref_lookup(self):
"""Tests whether active_ref lookup properly works."""
# assert that it properly lookups the currently active language
for lang_code, _ in settings.LANGUAGES:
translation.activate(lang_code)
assert self.TestModel.objects.filter(
text__active_ref=f"text_{lang_code}"
).exists()
# ensure that the default language is used in case no
# language is active at all
translation.deactivate_all()
assert self.TestModel.objects.filter(
text__active_ref="text_en"
).exists()
def test_translated_ref_lookup(self):
"""Tests whether translated_ref lookup properly works."""
# assert that it properly lookups the currently active language
for lang_code, _ in settings.LANGUAGES:
translation.activate(lang_code)
assert self.TestModel.objects.filter(
text__translated_ref=f"text_{lang_code}"
).exists()
# ensure that the default language is used in case no
# language is active at all
translation.deactivate_all()
assert self.TestModel.objects.filter(
text__translated_ref="text_en"
).exists()
fallbacks = {"cs": ["ru", "ro"], "pl": ["nl", "ro"]}
with override_settings(LOCALIZED_FIELDS_FALLBACKS=fallbacks):
with translation.override("cs"):
assert self.TestModel.objects.filter(
text__translated_ref="text_ro"
).exists()
with translation.override("pl"):
assert self.TestModel.objects.filter(
text__translated_ref="text_nl"
).exists()
# ensure that the default language is used in case no fallback is set
with translation.override("ru"):
assert self.TestModel.objects.filter(
text__translated_ref="text_en"
).exists()

47
tests/test_model.py Normal file
View File

@ -0,0 +1,47 @@
from django.test import TestCase
from localized_fields.fields import LocalizedField
from localized_fields.value import LocalizedValue
from .fake_model import get_fake_model
class LocalizedModelTestCase(TestCase):
"""Tests whether the :see:LocalizedModel class."""
TestModel = None
@classmethod
def setUpClass(cls):
"""Creates the test model in the database."""
super(LocalizedModelTestCase, cls).setUpClass()
cls.TestModel = get_fake_model({"title": LocalizedField()})
@classmethod
def test_defaults(cls):
"""Tests whether all :see:LocalizedField fields are assigned an empty
:see:LocalizedValue instance when the model is instanitiated."""
obj = cls.TestModel()
assert isinstance(obj.title, LocalizedValue)
@classmethod
def test_model_init_kwargs(cls):
"""Tests whether all :see:LocalizedField fields are assigned an empty
:see:LocalizedValue instance when the model is instanitiated."""
data = {
"title": {
"en": "english_title",
"ro": "romanian_title",
"nl": "dutch_title",
}
}
obj = cls.TestModel(**data)
assert isinstance(obj.title, LocalizedValue)
assert obj.title.en == "english_title"
assert obj.title.ro == "romanian_title"
assert obj.title.nl == "dutch_title"

39
tests/test_query_set.py Normal file
View File

@ -0,0 +1,39 @@
from django.test import TestCase
from localized_fields.fields import LocalizedField
from .fake_model import get_fake_model
class LocalizedQuerySetTestCase(TestCase):
"""Tests query sets with models containing :see:LocalizedField."""
Model = None
@classmethod
def setUpClass(cls):
"""Creates the test models in the database."""
super(LocalizedQuerySetTestCase, cls).setUpClass()
cls.Model = get_fake_model({"title": LocalizedField()})
@classmethod
def test_assign_raw_dict(cls):
inst = cls.Model()
inst.title = dict(en="Bread", ro="Paine")
inst.save()
inst = cls.Model.objects.get(pk=inst.pk)
assert inst.title.en == "Bread"
assert inst.title.ro == "Paine"
@classmethod
def test_assign_raw_dict_update(cls):
inst = cls.Model.objects.create(title=dict(en="Bread", ro="Paine"))
cls.Model.objects.update(title=dict(en="Beer", ro="Bere"))
inst = cls.Model.objects.get(pk=inst.pk)
assert inst.title.en == "Beer"
assert inst.title.ro == "Bere"

317
tests/test_slug_fields.py Normal file
View File

@ -0,0 +1,317 @@
import copy
import pytest
from django import forms
from django.conf import settings
from django.db import models
from django.db.utils import IntegrityError
from django.test import TestCase
from django.utils.text import slugify
from localized_fields.fields import LocalizedField, LocalizedUniqueSlugField
from .fake_model import get_fake_model
class LocalizedSlugFieldTestCase(TestCase):
"""Tests the localized slug classes."""
AutoSlugModel = None
Model = None
@classmethod
def setUpClass(cls):
"""Creates the test models in the database."""
super(LocalizedSlugFieldTestCase, cls).setUpClass()
cls.Model = get_fake_model(
{
"title": LocalizedField(),
"name": models.CharField(max_length=255),
"slug": LocalizedUniqueSlugField(populate_from="title"),
}
)
@staticmethod
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(
{
"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(
{
"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
@classmethod
def test_unique_slug_update(cls):
obj = cls.Model.objects.create(
title={settings.LANGUAGE_CODE: "mytitle"}
)
assert obj.slug.get() == "mytitle"
obj.title.set(settings.LANGUAGE_CODE, "othertitle")
obj.save()
assert obj.slug.get() == "othertitle"
@classmethod
def test_unique_slug_unique_max_retries(cls):
"""Tests whether the unique slug implementation doesn't try to find a
slug forever and gives up after a while."""
title = "myuniquetitle"
obj = cls.Model()
obj.title.en = title
obj.save()
with cls.assertRaises(cls, IntegrityError):
for _ in range(0, settings.LOCALIZED_FIELDS_MAX_RETRIES + 1):
another_obj = cls.Model()
another_obj.title.en = title
another_obj.save()
@classmethod
def test_populate(cls):
"""Tests whether the populating feature works correctly."""
obj = cls.Model()
obj.title.en = "this is my title"
obj.save()
assert obj.slug.get("en") == slugify(obj.title)
@classmethod
def test_populate_callable(cls):
"""Tests whether the populating feature works correctly when you
specify a callable."""
def generate_slug(instance):
return instance.title
get_fake_model(
{
"title": LocalizedField(),
"slug": LocalizedUniqueSlugField(populate_from=generate_slug),
}
)
obj = cls.Model()
for lang_code, lang_name in settings.LANGUAGES:
obj.title.set(lang_code, "title %s" % lang_name)
obj.save()
for lang_code, lang_name in settings.LANGUAGES:
assert obj.slug.get(lang_code) == "title-%s" % lang_name.lower()
@staticmethod
def test_populate_multiple_from_fields():
"""Tests whether populating the slug from multiple fields works
correctly."""
model = get_fake_model(
{
"title": LocalizedField(),
"name": models.CharField(max_length=255),
"slug": LocalizedUniqueSlugField(
populate_from=("title", "name")
),
}
)
obj = model()
for lang_code, lang_name in settings.LANGUAGES:
obj.name = "swen"
obj.title.set(lang_code, "title %s" % lang_name)
obj.save()
for lang_code, lang_name in settings.LANGUAGES:
assert (
obj.slug.get(lang_code) == "title-%s-swen" % lang_name.lower()
)
@staticmethod
def test_populate_multiple_from_fields_fk():
"""Tests whether populating the slug from multiple fields works
correctly."""
model_fk = get_fake_model({"name": LocalizedField()})
model = get_fake_model(
{
"title": LocalizedField(),
"other": models.ForeignKey(model_fk, on_delete=models.CASCADE),
"slug": LocalizedUniqueSlugField(
populate_from=("title", "other.name")
),
}
)
other = model_fk.objects.create(name={settings.LANGUAGE_CODE: "swen"})
obj = model()
for lang_code, lang_name in settings.LANGUAGES:
obj.other_id = other.id
obj.title.set(lang_code, "title %s" % lang_name)
obj.save()
for lang_code, lang_name in settings.LANGUAGES:
assert (
obj.slug.get(lang_code) == "title-%s-swen" % lang_name.lower()
)
@classmethod
def test_populate_multiple_languages(cls):
"""Tests whether the populating feature correctly works for all
languages."""
obj = cls.Model()
for lang_code, lang_name in settings.LANGUAGES:
obj.title.set(lang_code, "title %s" % lang_name)
obj.save()
for lang_code, lang_name in settings.LANGUAGES:
assert obj.slug.get(lang_code) == "title-%s" % lang_name.lower()
@classmethod
def test_disable(cls):
"""Tests whether disabling auto-slugging works."""
Model = get_fake_model(
{
"title": LocalizedField(),
"slug": LocalizedUniqueSlugField(
populate_from="title", enabled=False
),
}
)
obj = Model()
obj.title = "test"
# should raise IntegrityError because auto-slugging
# is disabled and the slug field is NULL
with pytest.raises(IntegrityError):
obj.save()
@classmethod
def test_allows_override_when_immutable(cls):
"""Tests whether setting a value manually works and does not get
overriden."""
Model = get_fake_model(
{
"title": LocalizedField(),
"name": models.CharField(max_length=255),
"slug": LocalizedUniqueSlugField(
populate_from="title", immutable=True
),
}
)
obj = Model()
for lang_code, lang_name in settings.LANGUAGES:
obj.slug.set(lang_code, "my value %s" % lang_code)
obj.title.set(lang_code, "my title %s" % lang_code)
obj.save()
for lang_code, lang_name in settings.LANGUAGES:
assert obj.slug.get(lang_code) == "my value %s" % lang_code
@classmethod
def test_unique_slug(cls):
"""Tests whether unique slugs are properly generated."""
title = "myuniquetitle"
obj = cls.Model()
obj.title.en = title
obj.save()
for i in range(1, settings.LOCALIZED_FIELDS_MAX_RETRIES - 1):
another_obj = cls.Model()
another_obj.title.en = title
another_obj.save()
assert another_obj.slug.en == "%s-%d" % (title, i)
@classmethod
def test_unique_slug_utf(cls):
"""Tests whether generating a slug works when the value consists
completely out of non-ASCII characters."""
obj = cls.Model()
obj.title.en = "مكاتب للايجار بشارع بورسعيد"
obj.save()
assert obj.slug.en == "مكاتب-للايجار-بشارع-بورسعيد"
@staticmethod
def test_deconstruct():
"""Tests whether the :see:deconstruct function properly retains options
specified in the constructor."""
field = LocalizedUniqueSlugField(
enabled=False, immutable=True, populate_from="title"
)
_, _, _, kwargs = field.deconstruct()
assert not kwargs["enabled"]
assert kwargs["immutable"]
assert kwargs["populate_from"] == field.populate_from
@staticmethod
def test_formfield():
"""Tests whether the :see:formfield method returns a valid form field
that is hidden."""
form_field = LocalizedUniqueSlugField(populate_from="title").formfield()
assert isinstance(form_field, forms.CharField)
assert isinstance(form_field.widget, forms.HiddenInput)

224
tests/test_value.py Normal file
View File

@ -0,0 +1,224 @@
from django.conf import settings
from django.db.models import F
from django.test import TestCase, override_settings
from django.utils import translation
from localized_fields.value import LocalizedValue
from .data import get_init_values
class LocalizedValueTestCase(TestCase):
"""Tests the :see:LocalizedValue class."""
@staticmethod
def tearDown():
"""Assures that the current language is set back to the default."""
translation.activate(settings.LANGUAGE_CODE)
@staticmethod
def test_init():
"""Tests whether the __init__ function of the :see:LocalizedValue class
works as expected."""
keys = get_init_values()
value = LocalizedValue(keys)
for lang_code, _ in settings.LANGUAGES:
assert getattr(value, lang_code, None) == keys[lang_code]
@staticmethod
def test_init_default_values():
"""Tests whether the __init__ function of the :see:LocalizedValue
accepts the default value or an empty dict properly."""
value = LocalizedValue()
for lang_code, _ in settings.LANGUAGES:
assert getattr(value, lang_code) is None
@staticmethod
def test_is_empty():
"""Tests whether a newly constructed :see:LocalizedValue without any
content is considered "empty"."""
value = LocalizedValue()
assert value.is_empty()
value.set(settings.LANGUAGE_CODE, "my value")
assert not value.is_empty()
@staticmethod
def test_init_array():
"""Tests whether the __init__ function of :see:LocalizedValue properly
handles an array.
Arrays can be passed to LocalizedValue as a result of a ArrayAgg
operation.
"""
value = LocalizedValue(["my value"])
assert value.get(settings.LANGUAGE_CODE) == "my value"
@staticmethod
def test_get_explicit():
"""Tests whether the the :see:LocalizedValue class's :see:get function
works properly when specifying an explicit value."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
assert localized_value.get(language) == value
@staticmethod
def test_get_default_language():
"""Tests whether the :see:LocalizedValue class's see:get function
properly gets the value in the default language."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, _ in keys.items():
translation.activate(language)
assert localized_value.get() == keys[settings.LANGUAGE_CODE]
@staticmethod
def test_set():
"""Tests whether the :see:LocalizedValue class's see:set function works
properly."""
localized_value = LocalizedValue()
for language, value in get_init_values():
localized_value.set(language, value)
assert localized_value.get(language) == value
assert getattr(localized_value, language) == 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
def test_translate():
"""Tests whether the :see:LocalizedValue class's __str__ works
properly."""
keys = get_init_values()
localized_value = LocalizedValue(keys)
for language, value in keys.items():
translation.activate(language)
assert localized_value.translate() == value
@staticmethod
def test_translate_fallback():
"""Tests whether the :see:LocalizedValue class's translate()'s fallback
functionality works properly."""
test_value = "myvalue"
localized_value = LocalizedValue({settings.LANGUAGE_CODE: test_value})
other_language = settings.LANGUAGES[-1][0]
# make sure that, by default it returns
# the value in the default language
assert localized_value.translate() == test_value
# make sure that it falls back to the
# primary language when there's no value
# available in the current language
translation.activate(other_language)
assert localized_value.translate() == test_value
# make sure that it's just __str__ falling
# back and that for the other language
# there's no actual value
assert localized_value.get(other_language) != test_value
@staticmethod
def test_translate_none():
"""Tests whether the :see:LocalizedValue class's translate() method
properly returns None when there is no value."""
# with no value, we always expect it to return None
localized_value = LocalizedValue()
assert localized_value.translate() is None
assert str(localized_value) == ""
# with no value for the default language, the default
# behavior is to return None, unless a custom fallback
# chain is configured, which there is not for this test
other_language = settings.LANGUAGES[-1][0]
localized_value = LocalizedValue({other_language: "hey"})
translation.activate(settings.LANGUAGE_CODE)
assert localized_value.translate() is None
assert str(localized_value) == ""
@staticmethod
def test_translate_fallback_custom_fallback():
"""Tests whether the :see:LocalizedValue class's translate()'s fallback
functionality properly respects the LOCALIZED_FIELDS_FALLBACKS
setting."""
fallbacks = {"nl": ["ro"]}
localized_value = LocalizedValue(
{settings.LANGUAGE_CODE: settings.LANGUAGE_CODE, "ro": "ro"}
)
with override_settings(LOCALIZED_FIELDS_FALLBACKS=fallbacks):
with translation.override("nl"):
assert localized_value.translate() == "ro"
@staticmethod
def test_translate_custom_language():
"""Tests whether the :see:LocalizedValue class's translate() ignores
the active language when one is specified explicitely."""
localized_value = LocalizedValue(
{settings.LANGUAGE_CODE: settings.LANGUAGE_CODE, "ro": "ro"}
)
with translation.override("en"):
assert localized_value.translate("ro") == "ro"
@staticmethod
def test_deconstruct():
"""Tests whether the :see:LocalizedValue class's :see:deconstruct
function works properly."""
keys = get_init_values()
value = LocalizedValue(keys)
path, args, kwargs = value.deconstruct()
assert args[0] == keys
@staticmethod
def test_construct_string():
"""Tests whether the :see:LocalizedValue's constructor assumes the
primary language when passing a single string."""
value = LocalizedValue("beer")
assert value.get(settings.LANGUAGE_CODE) == "beer"
@staticmethod
def test_construct_expression():
"""Tests whether passing expressions as values works properly and are
not converted to string."""
value = LocalizedValue(dict(en=F("other")))
assert isinstance(value.en, F)

85
tests/test_widget.py Normal file
View File

@ -0,0 +1,85 @@
import re
from django.conf import settings
from django.test import TestCase
from localized_fields.value import LocalizedValue
from localized_fields.widgets import LocalizedFieldWidget
class LocalizedFieldWidgetTestCase(TestCase):
"""Tests the workings of the :see:LocalizedFieldWidget class."""
@staticmethod
def test_widget_creation():
"""Tests whether a widget is created for every language correctly."""
widget = LocalizedFieldWidget()
assert len(widget.widgets) == len(settings.LANGUAGES)
assert len(set(widget.widgets)) == len(widget.widgets)
@staticmethod
def test_decompress():
"""Tests whether a :see:LocalizedValue instance can correctly be
"decompressed" over the available widgets."""
localized_value = LocalizedValue()
for lang_code, lang_name in settings.LANGUAGES:
localized_value.set(lang_code, lang_name)
widget = LocalizedFieldWidget()
decompressed_values = widget.decompress(localized_value)
for (lang_code, _), value in zip(
settings.LANGUAGES, decompressed_values
):
assert localized_value.get(lang_code) == value
@staticmethod
def test_decompress_none():
"""Tests whether the :see:LocalizedFieldWidget correctly handles
:see:None."""
widget = LocalizedFieldWidget()
decompressed_values = widget.decompress(None)
for _, value in zip(settings.LANGUAGES, decompressed_values):
assert not value
@staticmethod
def test_get_context_required():
"""Tests whether the :see:get_context correctly handles 'required'
attribute, separately for each subwidget."""
widget = LocalizedFieldWidget()
widget.widgets[0].is_required = True
widget.widgets[1].is_required = False
context = widget.get_context(
name="test", value=LocalizedValue(), attrs=dict(required=True)
)
assert context["widget"]["subwidgets"][0]["attrs"]["required"]
assert "required" not in context["widget"]["subwidgets"][1]["attrs"]
@staticmethod
def test_get_context_langs():
"""Tests whether the :see:get_context contains 'lang_code' and
'lang_name' attribute for each subwidget."""
widget = LocalizedFieldWidget()
context = widget.get_context(
name="test", value=LocalizedValue(), attrs=dict()
)
subwidgets_context = context["widget"]["subwidgets"]
for widget, context in zip(widget.widgets, subwidgets_context):
assert "lang_code" in context
assert "lang_name" in context
assert widget.lang_code == context["lang_code"]
assert widget.lang_name == context["lang_name"]
@staticmethod
def test_render():
"""Tests whether the :see:LocalizedFieldWidget correctly render."""
widget = LocalizedFieldWidget()
output = widget.render(name="title", value=None)
assert bool(re.search(r"<label (.|\n|\t)*>\w+<\/label>", output))

20
tox.ini Normal file
View File

@ -0,0 +1,20 @@
[tox]
envlist = py36-dj{20,21,22,30,31,32}, py37-dj{20,21,22,30,31,32}, py38-dj{20,21,22,30,31,32,40,41,42}, py39-dj{30,31,32,40,41,42}, py310-dj{32,40,41,42,50}, py311-dj{42,50}
[testenv]
deps =
dj20: Django>=2.0,<2.1
dj21: Django>=2.1,<2.2
dj22: Django>=2.2,<2.3
dj30: Django>=3.0,<3.0.2
dj31: Django>=3.1,<3.2
dj32: Django>=3.2,<4.0
dj40: Django>=4.0,<4.1
dj41: Django>=4.1,<4.2
dj42: Django>=4.2,<5.0
dj50: Django>=5.0,<5.1
.[test]
setenv =
DJANGO_SETTINGS_MODULE=settings
passenv = DATABASE_URL
commands = python setup.py test