Skip to content

Commit fe68d29

Browse files
authored
feat: Add form field validation from model field (#56)
* Add pypi actions * bump version * feat: Move field validation to form field * Bump version * Fix syntax error in test action * For action fixes * Update pypi actions
1 parent 73007eb commit fe68d29

17 files changed

+127
-98
lines changed

.github/workflows/lint.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ jobs:
2222
python -m pip install --upgrade pip
2323
pip install ruff
2424
- name: Run Ruff
25-
run: ruff djangocms_attributes_field tests
25+
run: ruff check djangocms_attributes_field tests

.github/workflows/publish-to-live-pypi.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ jobs:
99
build-n-publish:
1010
name: Build and publish Python 🐍 distributions 📦 to pypi
1111
runs-on: ubuntu-latest
12+
environment:
13+
name: pypi
14+
url: https://pypi.org/p/djangocms-attributes_field
1215
permissions:
1316
id-token: write
1417
steps:
1518
- uses: actions/checkout@v3
1619
- name: Set up Python 3.10
17-
uses: actions/setup-python@v4
20+
uses: actions/setup-python@v5
1821
with:
19-
python-version: '3.10'
22+
python-version: '3.12'
2023

2124
- name: Install pypa/build
2225
run: >-
@@ -36,6 +39,3 @@ jobs:
3639
- name: Publish distribution 📦 to PyPI
3740
if: startsWith(github.ref, 'refs/tags')
3841
uses: pypa/gh-action-pypi-publish@release/v1
39-
with:
40-
user: __token__
41-
password: ${{ secrets.PYPI_API_TOKEN }}

.github/workflows/publish-to-test-pypi.yml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@ jobs:
99
build-n-publish:
1010
name: Build and publish Python 🐍 distributions 📦 to TestPyPI
1111
runs-on: ubuntu-latest
12+
environment:
13+
name: pypi
14+
url: https://test.pypi.org/p/djangocms-attributes_field
15+
permissions:
16+
id-token: write
1217
steps:
1318
- uses: actions/checkout@v3
1419
- name: Set up Python 3.10
15-
uses: actions/setup-python@v4
20+
uses: actions/setup-python@v5
1621
with:
17-
python-version: '3.10'
22+
python-version: '3.12'
1823

1924
- name: Install pypa/build
2025
run: >-
@@ -34,7 +39,5 @@ jobs:
3439
- name: Publish distribution 📦 to Test PyPI
3540
uses: pypa/gh-action-pypi-publish@release/v1
3641
with:
37-
user: __token__
38-
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
3942
repository_url: https://test.pypi.org/legacy/
4043
skip_existing: true

.github/workflows/test.yml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,21 @@ jobs:
88
strategy:
99
fail-fast: false
1010
matrix:
11-
python-version: [ 3.9, "3.10", "3.11"] # latest release minus two
11+
python-version: [ "3.9", "3.10", "3.11", "3.12"] # latest release minus two
1212
requirements-file: [
13-
dj32_cms311.txt,
14-
dj40_cms311.txt,
15-
dj41_cms311.txt,
1613
dj42_cms311.txt,
17-
dj42_cms41.txt
14+
dj42_cms41.txt,
15+
dj50_cms41.txt,
16+
dj51_cms41.txt
1817
]
1918
os: [
2019
ubuntu-20.04,
2120
]
21+
exclude:
22+
- python-version: "3.9"
23+
requirements-file: dj50_cms41.txt
24+
- python-version: "3.9"
25+
requirements-file: dj51_cms41.txt
2226

2327
steps:
2428
- uses: actions/checkout@v1
@@ -34,7 +38,7 @@ jobs:
3438
python setup.py install
3539
3640
- name: Run coverage
37-
run: coverage run setup.py test
41+
run: coverage run tests/settings.py
3842

3943
- name: Upload Coverage to Codecov
4044
uses: codecov/codecov-action@v1

CHANGELOG.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22
Changelog
33
=========
44

5+
4.0.0 (2024-11-19)
6+
==================
7+
8+
* Moved validation to from AttributeField to AttributeFormField
9+
* Added djangocms_attributes_fields.fields.default_excluded_keys to include
10+
keys that can execute javascript
11+
* Added tests for Django 5.0, 5.1
12+
* Dropped support for Django 3.2, 4.0, 4.1
13+
* Dropped support for Python 3.6, 3.7, 3.8
14+
515
3.0.0 (2023-05-24)
616
==================
717

README.rst

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ There is an optional parameter that can be used when declaring the field: ::
9191
``excluded_keys`` : This is a list of strings that will not be accepted as
9292
valid keys
9393

94+
Since version 4, the following keys are always excluded (see
95+
``djangocms_attributes_fields.fields.default_excluded_keys``) to avoid
96+
unwanted execution of javascript: ::
97+
98+
["src", "href", "data", "action", "on*"]
99+
100+
``'on*'`` represents any key that starts with ``'on'``.
94101

95102
property: [field_name]_str
96103
++++++++++++++++++++++++++
@@ -160,7 +167,7 @@ You can run tests by executing::
160167
virtualenv env
161168
source env/bin/activate
162169
pip install -r tests/requirements.txt
163-
python setup.py test
170+
python tests/settings.py
164171

165172

166173
.. |pypi| image:: https://badge.fury.io/py/djangocms-attributes-field.svg
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '3.0.0'
1+
__version__ = '4.0.0'

djangocms_attributes_field/fields.py

Lines changed: 49 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@
1414
regex_key_validator = RegexValidator(regex=r'^[a-z][-a-z0-9_:]*\Z',
1515
flags=re.IGNORECASE, code='invalid')
1616

17+
default_excluded_keys = [
18+
"src", "href", "data", "action", "on*",
19+
]
1720

1821
class AttributesFormField(forms.CharField):
1922
empty_values = [None, '']
2023

2124
def __init__(self, *args, **kwargs):
2225
kwargs.setdefault('widget', AttributesWidget)
26+
self.excluded_keys = kwargs.pop('excluded_keys', []) + default_excluded_keys
2327
super().__init__(*args, **kwargs)
2428

2529
def to_python(self, value):
@@ -37,6 +41,49 @@ def validate(self, value):
3741
# This is required in older django versions.
3842
if value in self.empty_values and self.required:
3943
raise forms.ValidationError(self.error_messages['required'], code='required')
44+
if isinstance(value, dict):
45+
for key, val in value.items():
46+
self.validate_key(key)
47+
self.validate_value(key, val)
48+
49+
def validate_key(self, key):
50+
"""
51+
A key must start with a letter, but can otherwise contain letters,
52+
numbers, dashes, colons or underscores. It must not also be part of
53+
`excluded_keys` as configured in the field.
54+
55+
:param key: (str) The key to validate
56+
"""
57+
# Verify the key is not one of `excluded_keys`.
58+
for excluded_key in self.excluded_keys:
59+
if key.lower() == excluded_key or excluded_key.endswith("*") and key.lower().startswith(excluded_key[:-1]):
60+
raise ValidationError(
61+
_('"{key}" is excluded by configuration and cannot be used as '
62+
'a key.').format(key=key))
63+
# Also check that it fits our permitted syntax
64+
try:
65+
regex_key_validator(key)
66+
except ValidationError:
67+
# Seems silly to catch one then raise another ValidationError, but
68+
# the RegExValidator doesn't use placeholders in its error message.
69+
raise ValidationError(
70+
_('"{key}" is not a valid key. Keys must start with at least '
71+
'one letter and consist only of the letters, numbers, '
72+
'underscores or hyphens.').format(key=key))
73+
74+
def validate_value(self, key, value):
75+
"""
76+
A value can be anything that can be JSON-ified.
77+
78+
:param key: (str) The key of the value
79+
:param value: (str) The value to validate
80+
"""
81+
try:
82+
json.dumps(value)
83+
except (TypeError, ValueError):
84+
raise ValidationError(
85+
_('The value for the key "{key}" is invalid. Please enter a '
86+
'value that can be represented in JSON.').format(key=key))
4087

4188

4289
class AttributesField(models.Field):
@@ -64,7 +111,7 @@ def __init__(self, *args, **kwargs):
64111
kwargs['default'] = kwargs.get('default', dict)
65112
excluded_keys = kwargs.pop('excluded_keys', [])
66113
# Note we accept uppercase letters in the param, but the comparison
67-
# is not case sensitive. So, we coerce the input to lowercase here.
114+
# is not case-sensitive. So, we coerce the input to lowercase here.
68115
self.excluded_keys = [key.lower() for key in excluded_keys]
69116
super().__init__(*args, **kwargs)
70117
self.validate(self.get_default(), None)
@@ -75,6 +122,7 @@ def formfield(self, **kwargs):
75122
'widget': AttributesWidget
76123
}
77124
defaults.update(**kwargs)
125+
defaults["excluded_keys"] = self.excluded_keys
78126
return super().formfield(**defaults)
79127

80128
def from_db_value(self, value,
@@ -134,48 +182,6 @@ def validate(self, value, model_instance):
134182
except ValueError:
135183
raise ValidationError(self.error_messages['invalid'] % value)
136184

137-
for key, val in value.items():
138-
self.validate_key(key)
139-
self.validate_value(key, val)
140-
141-
def validate_key(self, key):
142-
"""
143-
A key must start with a letter, but can otherwise contain letters,
144-
numbers, dashes, colons or underscores. It must not also be part of
145-
`excluded_keys` as configured in the field.
146-
147-
:param key: (str) The key to validate
148-
"""
149-
# Verify the key is not one of `excluded_keys`.
150-
if key.lower() in self.excluded_keys:
151-
raise ValidationError(
152-
_('"{key}" is excluded by configuration and cannot be used as '
153-
'a key.').format(key=key))
154-
# Also check that it fits our permitted syntax
155-
try:
156-
regex_key_validator(key)
157-
except ValidationError:
158-
# Seems silly to catch one then raise another ValidationError, but
159-
# the RegExValidator doesn't use placeholders in its error message.
160-
raise ValidationError(
161-
_('"{key}" is not a valid key. Keys must start with at least '
162-
'one letter and consist only of the letters, numbers, '
163-
'underscores or hyphens.').format(key=key))
164-
165-
def validate_value(self, key, value):
166-
"""
167-
A value can be anything that can be JSON-ified.
168-
169-
:param key: (str) The key of the value
170-
:param value: (str) The value to validate
171-
"""
172-
try:
173-
json.dumps(value)
174-
except (TypeError, ValueError):
175-
raise ValidationError(
176-
_('The value for the key "{key}" is invalid. Please enter a '
177-
'value that can be represented in JSON.').format(key=key))
178-
179185
def value_to_string(self, obj):
180186
return self.value_from_object(obj)
181187

tests/requirements/dj32_cms310.txt

Lines changed: 0 additions & 4 deletions
This file was deleted.

tests/requirements/dj32_cms311.txt

Lines changed: 0 additions & 4 deletions
This file was deleted.

tests/requirements/dj32_cms39.txt

Lines changed: 0 additions & 4 deletions
This file was deleted.

tests/requirements/dj40_cms311.txt

Lines changed: 0 additions & 4 deletions
This file was deleted.

tests/requirements/dj41_cms311.txt

Lines changed: 0 additions & 4 deletions
This file was deleted.

tests/requirements/dj50_cms41.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-r base.txt
2+
3+
Django>=5.0,<5.1
4+
django-cms>=4.1,<4.2

tests/requirements/dj51_cms41.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-r base.txt
2+
3+
Django>=5.1,<5.2
4+
django-cms>=4.1,<4.2

tests/test_fields.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class Noop:
1515
class KeyValidationTests(TestCase):
1616

1717
def test_validate_key(self):
18-
field = AttributesField()
18+
field = AttributesFormField()
1919
# Normal, expected patterns
2020
try:
2121
field.validate_key('target')
@@ -48,21 +48,35 @@ def test_validate_key(self):
4848

4949
def test_excluded_keys(self):
5050
# First prove that the keys we're about to test would normally pass
51-
field = AttributesField()
51+
field = AttributesFormField()
5252
try:
53-
field.validate_key('href')
54-
field.validate_key('src')
53+
54+
field.validate_key('title')
55+
field.validate_key('data-test')
5556
except ValidationError:
5657
self.fail('Keys that pass have failed.')
5758

5859
# Now show that they no longer pass if explicitly exclude
59-
field = AttributesField(excluded_keys=['href', 'src', ])
60+
field = AttributesFormField(excluded_keys=['title', 'data-test', ])
6061

6162
with self.assertRaises(ValidationError):
62-
field.validate_key('href')
63+
field.validate_key('title')
6364
with self.assertRaises(ValidationError):
6465
field.validate_key('src')
6566

67+
def test_default_excluded_keys(self):
68+
from djangocms_attributes_field.fields import default_excluded_keys
69+
70+
field = AttributesFormField()
71+
for key in default_excluded_keys:
72+
with self.subTest(key=key):
73+
with self.assertRaises(ValidationError):
74+
field.validate_key(key)
75+
76+
with self.subTest(key="onsomething"):
77+
with self.assertRaises(ValidationError):
78+
field.validate_key(key)
79+
6680

6781
class AttributesFieldsTestCase(TestCase):
6882

tox.ini

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
[tox]
22
envlist =
3-
flake8
4-
isort
5-
py{35,36,37,38}-dj{22}-cms{37,38}
6-
py{36,37,38}-dj{30}-cms{37,38}
7-
py{36,37,38}-dj{31}-cms{38}
3+
py{39,310,311}-dj{42}-cms{311,41}
4+
py{310,311,312}-dj{50,51}-cms{41}
85

96
skip_missing_interpreters=True
107

@@ -40,15 +37,15 @@ known_django = django
4037
[testenv]
4138
deps =
4239
-r{toxinidir}/tests/requirements/base.txt
43-
dj22: Django>=2.2,<3.0
44-
dj30: Django>=3.0,<3.1
45-
dj31: Django>=3.1,<3.2
46-
cms37: django-cms>=3.7,<3.8
47-
cms38: django-cms>=3.8,<3.9
40+
dj42: Django>=4.2,<5.0
41+
dj50: Django>=5.0,<5.1
42+
dj51: Django>=5.1,<5.2
43+
cms311: django-cms>=3.11,<4
44+
cms41: django-cms>=4.1,<4.2
4845
commands =
4946
{envpython} --version
5047
{env:COMMAND:coverage} erase
51-
{env:COMMAND:coverage} run setup.py test
48+
{env:COMMAND:coverage} run tests/settings.py
5249
{env:COMMAND:coverage} report
5350

5451
[testenv:flake8]

0 commit comments

Comments
 (0)