diff --git a/pyproject.toml b/pyproject.toml index 485c05b..4107cf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ classifiers = [ dependencies = [ "django>=3.2", "nh3", + "typing-extensions", ] urls.Changelog = "https://github.com/marksweb/django-nh3/blob/main/CHANGELOG.rst" diff --git a/src/django_nh3/models.py b/src/django_nh3/models.py index 7ca0b20..3674eea 100644 --- a/src/django_nh3/models.py +++ b/src/django_nh3/models.py @@ -1,5 +1,4 @@ -from __future__ import annotations - +import warnings from collections.abc import Callable from typing import Any @@ -9,11 +8,12 @@ from django.db.models import Expression, Model from django.forms import Field as FormField from django.utils.safestring import mark_safe +from typing_extensions import deprecated from . import forms -class Nh3Field(models.TextField): +class Nh3FieldMixin: def __init__( self, attributes: dict[str, set[str]] = {}, @@ -42,28 +42,28 @@ def formfield( """Makes the field for a ModelForm""" # If field doesn't have any choices add kwargs expected by Nh3Field. - if not self.choices: + if not self.choices: # type: ignore[attr-defined] kwargs.update( { - "max_length": self.max_length, + "max_length": self.max_length, # type: ignore[attr-defined] "attributes": self.nh3_options.get("attributes"), "attribute_filter": self.nh3_options.get("attribute_filter"), "clean_content_tags": self.nh3_options.get("clean_content_tags"), "link_rel": self.nh3_options.get("link_rel"), "strip_comments": self.nh3_options.get("strip_comments"), "tags": self.nh3_options.get("tags"), - "required": not self.blank, + "required": not self.blank, # type: ignore[attr-defined] } ) - return super().formfield(form_class=form_class, **kwargs) + return super().formfield(form_class=form_class, **kwargs) # type: ignore[misc] def pre_save(self, model_instance: Model, add: bool) -> Any: - data = getattr(model_instance, self.attname) + data = getattr(model_instance, self.attname) # type: ignore[attr-defined] if data is None: return data clean_value = nh3.clean(data, **self.nh3_options) if data else "" - setattr(model_instance, self.attname, mark_safe(clean_value)) + setattr(model_instance, self.attname, mark_safe(clean_value)) # type: ignore[attr-defined] return clean_value def from_db_value( @@ -77,3 +77,28 @@ def from_db_value( # Values are sanitised before saving, so any value returned from the DB # is safe to render unescaped. return mark_safe(value) + + +class Nh3TextField(Nh3FieldMixin, models.TextField): + pass + + +class Nh3CharField(Nh3FieldMixin, models.CharField): + pass + + +@deprecated("Use Nh3TextField instead") +class Nh3Field(Nh3FieldMixin, models.TextField): + """ + .. deprecated:: 0.2.0 + Use :class:`Nh3TextField` instead. + """ + + def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] + warnings.warn( + "Nh3Field is deprecated and will be removed in a future version. " + "Use Nh3TextField instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) diff --git a/tests/forms.py b/tests/forms.py new file mode 100644 index 0000000..72c36ce --- /dev/null +++ b/tests/forms.py @@ -0,0 +1,40 @@ +from django.forms import ModelForm + +from .models import ( + Nh3CharFieldContent, + Nh3CharFieldNullableContent, + Nh3TextFieldContent, + Nh3TextFieldNullableContent, +) + + +class Nh3CharFieldContentModelForm(ModelForm): + """NH3 test model form""" + + class Meta: + model = Nh3CharFieldContent + fields = ["content"] + + +class Nh3CharFieldNullableContentModelForm(ModelForm): + """NH3 test model form""" + + class Meta: + model = Nh3CharFieldNullableContent + fields = ["choice"] + + +class Nh3TextFieldContentModelForm(ModelForm): + """NH3 test model form""" + + class Meta: + model = Nh3TextFieldContent + fields = ["content"] + + +class Nh3TextFieldNullableContentModelForm(ModelForm): + """NH3 test model form""" + + class Meta: + model = Nh3TextFieldNullableContent + fields = ["choice"] diff --git a/tests/migrations/0001_initial.py b/tests/migrations/0001_initial.py index f6686b2..b06e700 100644 --- a/tests/migrations/0001_initial.py +++ b/tests/migrations/0001_initial.py @@ -11,7 +11,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name="Nh3Content", + name="Nh3CharFieldContent", fields=[ ( "id", @@ -22,16 +22,46 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("content", django_nh3.models.Nh3Field()), - ("blank_field", django_nh3.models.Nh3Field(blank=True)), + ("content", django_nh3.models.Nh3CharField(max_length=1000)), + ( + "blank_field", + django_nh3.models.Nh3CharField(blank=True, max_length=1000), + ), ( "null_field", - django_nh3.models.Nh3Field(blank=True, null=True), + django_nh3.models.Nh3CharField( + blank=True, null=True, max_length=1000 + ), ), ], ), migrations.CreateModel( - name="Nh3NullableContent", + name="Nh3TextFieldContent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("content", django_nh3.models.Nh3TextField(max_length=1000)), + ( + "blank_field", + django_nh3.models.Nh3TextField(blank=True, max_length=1000), + ), + ( + "null_field", + django_nh3.models.Nh3TextField( + blank=True, null=True, max_length=1000 + ), + ), + ], + ), + migrations.CreateModel( + name="Nh3CharFieldNullableContent", fields=[ ( "id", @@ -44,11 +74,46 @@ class Migration(migrations.Migration): ), ( "choice", - django_nh3.models.Nh3Field( - choices=[("f", "first choice"), ("s", "second choice")] + django_nh3.models.Nh3CharField( + blank=True, + choices=[("f", "first choice"), ("s", "second choice")], + max_length=1000, + ), + ), + ( + "content", + django_nh3.models.Nh3CharField( + blank=True, null=True, max_length=1000 + ), + ), + ], + ), + migrations.CreateModel( + name="Nh3TextFieldNullableContent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "choice", + django_nh3.models.Nh3TextField( + blank=True, + choices=[("f", "first choice"), ("s", "second choice")], + max_length=1000, + ), + ), + ( + "content", + django_nh3.models.Nh3TextField( + blank=True, null=True, max_length=1000 ), ), - ("content", django_nh3.models.Nh3Field(blank=True, null=True)), ], ), ] diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 0000000..0b44b22 --- /dev/null +++ b/tests/models.py @@ -0,0 +1,41 @@ +from django.db import models + +from django_nh3.models import Nh3CharField, Nh3TextField + + +class Nh3CharFieldContent(models.Model): + """NH3 test model""" + + content = Nh3CharField( + strip_comments=True, + max_length=1000, + ) + blank_field = Nh3CharField(blank=True, max_length=1000) + null_field = Nh3CharField(blank=True, null=True, max_length=1000) + + +class Nh3CharFieldNullableContent(models.Model): + """NH3 test model""" + + CHOICES = (("f", "first choice"), ("s", "second choice")) + choice = Nh3CharField(choices=CHOICES, blank=True, max_length=1000) + content = Nh3CharField(blank=True, null=True, max_length=1000) + + +class Nh3TextFieldContent(models.Model): + """NH3 test model""" + + content = Nh3TextField( + strip_comments=True, + max_length=1000, + ) + blank_field = Nh3TextField(blank=True, max_length=1000) + null_field = Nh3TextField(blank=True, null=True, max_length=1000) + + +class Nh3TextFieldNullableContent(models.Model): + """NH3 test model""" + + CHOICES = (("f", "first choice"), ("s", "second choice")) + choice = Nh3TextField(choices=CHOICES, blank=True, max_length=1000) + content = Nh3TextField(blank=True, null=True, max_length=1000) diff --git a/tests/test_models.py b/tests/test_models_charfield.py similarity index 64% rename from tests/test_models.py rename to tests/test_models_charfield.py index 885abe5..e6b4e81 100644 --- a/tests/test_models.py +++ b/tests/test_models_charfield.py @@ -1,46 +1,11 @@ -from django.db import models -from django.forms import ModelForm from django.test import TestCase from django.utils.safestring import SafeString -from django_nh3.models import Nh3Field +from .forms import Nh3CharFieldContentModelForm, Nh3CharFieldNullableContentModelForm +from .models import Nh3CharFieldContent, Nh3CharFieldNullableContent -class Nh3Content(models.Model): - """NH3 test model""" - - content = Nh3Field( - strip_comments=True, - ) - blank_field = Nh3Field(blank=True) - null_field = Nh3Field(blank=True, null=True) - - -class Nh3ContentModelForm(ModelForm): - """NH3 test model form""" - - class Meta: - model = Nh3Content - fields = ["content"] - - -class Nh3NullableContent(models.Model): - """NH3 test model""" - - CHOICES = (("f", "first choice"), ("s", "second choice")) - choice = Nh3Field(choices=CHOICES, blank=True) - content = Nh3Field(blank=True, null=True) - - -class Nh3NullableContentModelForm(ModelForm): - """NH3 test model form""" - - class Meta: - model = Nh3NullableContent - fields = ["choice"] - - -class TestNh3ModelField(TestCase): +class TestNh3ModelCharField(TestCase): """Test model field""" def test_cleaning(self): @@ -57,32 +22,32 @@ def test_cleaning(self): } for key, value in test_data.items(): - obj = Nh3Content.objects.create(content=value) + obj = Nh3CharFieldContent.objects.create(content=value) self.assertEqual(obj.content, expected_values[key]) def test_retrieved_values_are_template_safe(self): - obj = Nh3Content.objects.create(content="some content") + obj = Nh3CharFieldContent.objects.create(content="some content") obj.refresh_from_db() self.assertIsInstance(obj.content, SafeString) - obj = Nh3Content.objects.create(content="") + obj = Nh3CharFieldContent.objects.create(content="") obj.refresh_from_db() self.assertIsInstance(obj.content, SafeString) def test_saved_values_are_template_safe(self): - obj = Nh3Content(content="some content") + obj = Nh3CharFieldContent(content="some content") obj.save() self.assertIsInstance(obj.content, SafeString) - obj = Nh3Content(content="") + obj = Nh3CharFieldContent(content="") obj.save() self.assertIsInstance(obj.content, SafeString) def test_saved_none_values_are_none(self): - obj = Nh3Content(null_field=None) + obj = Nh3CharFieldContent(null_field=None) obj.save() self.assertIsNone(obj.null_field) -class TestNh3NullableModelField(TestCase): +class TestNh3CharFieldNullableModelField(TestCase): """Test model field""" def test_cleaning(self): @@ -101,11 +66,11 @@ def test_cleaning(self): } for key, value in test_data.items(): - obj = Nh3NullableContent.objects.create(content=value) + obj = Nh3CharFieldNullableContent.objects.create(content=value) self.assertEqual(obj.content, expected_values[key]) -class TestNh3ModelFormField(TestCase): +class TestNh3CharFieldModelFormField(TestCase): """Test model form field""" def test_cleaning(self): @@ -122,7 +87,7 @@ def test_cleaning(self): } for key, value in test_data.items(): - form = Nh3ContentModelForm(data={"content": value}) + form = Nh3CharFieldContentModelForm(data={"content": value}) self.assertTrue(form.is_valid()) obj = form.save() self.assertEqual(obj.content, expected_values[key]) @@ -131,17 +96,17 @@ def test_stripped_comments(self): """Content field strips comments so ensure they aren't allowed""" self.assertFalse( - Nh3ContentModelForm( + Nh3CharFieldContentModelForm( data={"content": ""} ).is_valid() ) def test_field_choices(self): """Content field strips comments so ensure they aren't allowed""" - test_data = dict(Nh3NullableContent.CHOICES) + test_data = dict(Nh3CharFieldNullableContent.CHOICES) for key, value in test_data.items(): - form = Nh3NullableContentModelForm(data={"choice": key}) + form = Nh3CharFieldNullableContentModelForm(data={"choice": key}) self.assertTrue(form.is_valid()) obj = form.save() self.assertEqual(obj.get_choice_display(), value) diff --git a/tests/test_models_textfield.py b/tests/test_models_textfield.py new file mode 100644 index 0000000..fc525c8 --- /dev/null +++ b/tests/test_models_textfield.py @@ -0,0 +1,112 @@ +from django.test import TestCase +from django.utils.safestring import SafeString + +from .forms import Nh3TextFieldContentModelForm, Nh3TextFieldNullableContentModelForm +from .models import Nh3TextFieldContent, Nh3TextFieldNullableContent + + +class TestNh3TextFieldModelField(TestCase): + """Test model field""" + + def test_cleaning(self): + """Test values are sanitized""" + test_data = { + "html_data": "