diff --git a/CHANGELOG.md b/CHANGELOG.md index 175c7af..f5e55d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.0.0-rc.12 : 22.09.2024 + +- **Added**: `FormField` population strategy + ## 1.0.0-rc.11 : 16.08.2024 - **Fixed**: Proper manipulation with `BaseStrategy` instances during population diff --git a/README.md b/README.md index df559b7..18e0fdc 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ DJANGO_API_FORMS_POPULATION_STRATEGIES = { 'django_api_forms.fields.FormFieldList': 'django_api_forms.population_strategies.IgnoreStrategy', 'django_api_forms.fields.FileField': 'django_api_forms.population_strategies.IgnoreStrategy', 'django_api_forms.fields.ImageField': 'django_api_forms.population_strategies.IgnoreStrategy', - 'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.IgnoreStrategy', + 'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.FormFieldStrategy', 'django.forms.models.ModelMultipleChoiceField': 'django_api_forms.population_strategies.IgnoreStrategy', 'django.forms.models.ModelChoiceField': 'django_api_forms.population_strategies.ModelChoiceFieldStrategy' } @@ -134,7 +134,7 @@ DJANGO_API_FORMS_PARSERS = { } ``` -**Django API Forms equivalent + validation** +**Django API Forms equivalent + validation + population** ```python from enum import Enum @@ -143,6 +143,7 @@ from django.core.exceptions import ValidationError from django.forms import fields from django_api_forms import FieldList, FormField, FormFieldList, DictionaryField, EnumField, AnyField, Form +from tests.testapp.models import Artist, Album class AlbumType(Enum): @@ -170,7 +171,7 @@ class SongForm(Form): class AlbumForm(Form): title = fields.CharField(max_length=100) year = fields.IntegerField() - artist = FormField(form=ArtistForm) + artist = FormField(form=ArtistForm, model=Artist) songs = FormFieldList(form=SongForm) type = EnumField(enum=AlbumType, required=True) metadata = DictionaryField(value_field=fields.DateTimeField()) @@ -180,7 +181,7 @@ class AlbumForm(Form): raise ValidationError("Year 1992 is forbidden!", 'forbidden-value') if 'param' not in self.extras: self.add_error( - ('param', ), + ('param',), ValidationError("You can use extra optional arguments in form validation!", code='param-where') ) return self.cleaned_data['year'] @@ -195,7 +196,6 @@ class AlbumForm(Form): return self.cleaned_data - """ Django view example """ @@ -208,6 +208,14 @@ def create_album(request): # Cleaned valid payload payload = form.cleaned_data print(payload) + + # Populate cleaned data into Django model + album = Album() + form.populate(album) + + # Save populated objects + album.artist.save() + album.save() ``` If you want example with whole Django project, check out repository created by [pawl](https://github.com/pawl) diff --git a/django_api_forms/fields.py b/django_api_forms/fields.py index b4d308b..51dc01c 100644 --- a/django_api_forms/fields.py +++ b/django_api_forms/fields.py @@ -1,10 +1,10 @@ +import re import typing import warnings from base64 import b64decode from enum import Enum from io import BytesIO from mimetypes import guess_type -import re from django.core.exceptions import ValidationError from django.core.files import File @@ -86,15 +86,19 @@ def to_python(self, value) -> typing.List: class FormField(Field): - def __init__(self, form: typing.Type, **kwargs): + def __init__(self, form: typing.Type, model=None, **kwargs): self._form = form - + self._model = model super().__init__(**kwargs) @property def form(self): return self._form + @property + def model(self): + return self._model + def to_python(self, value) -> typing.Union[typing.Dict, None]: if not value: return {} @@ -142,7 +146,7 @@ def to_python(self, value): result.append(form.cleaned_data) else: for error in form.errors: - error.prepend((position, )) + error.prepend((position,)) errors.append(error) if errors: @@ -208,7 +212,7 @@ def to_python(self, value) -> dict: key = self._key_field.clean(key) result[key] = self._value_field.clean(item) except ValidationError as e: - errors[key] = DetailValidationError(e, (key, )) + errors[key] = DetailValidationError(e, (key,)) if errors: raise ValidationError(errors) diff --git a/django_api_forms/population_strategies.py b/django_api_forms/population_strategies.py index b927ba3..040928f 100644 --- a/django_api_forms/population_strategies.py +++ b/django_api_forms/population_strategies.py @@ -1,3 +1,6 @@ +import copy + + class BaseStrategy: def __call__(self, field, obj, key: str, value): setattr(obj, key, value) @@ -34,3 +37,22 @@ def __call__(self, field, obj, key: str, value): if key.endswith(postfix_to_remove): model_key = key[:-len(postfix_to_remove)] setattr(obj, model_key, value) + + +class FormFieldStrategy(BaseStrategy): + def __call__(self, field, obj, key: str, value): + model = field.model + if model: + from django_api_forms.settings import Settings + + model = model() + form = field.form + + form.cleaned_data = value + form.fields = copy.deepcopy(getattr(form, 'base_fields')) + form.settings = Settings() + form.errors = None + + populated_model = form.populate(form, model) + + setattr(obj, key, populated_model) diff --git a/django_api_forms/settings.py b/django_api_forms/settings.py index 7067c97..de427df 100644 --- a/django_api_forms/settings.py +++ b/django_api_forms/settings.py @@ -5,7 +5,7 @@ 'django_api_forms.fields.FormFieldList': 'django_api_forms.population_strategies.IgnoreStrategy', 'django_api_forms.fields.FileField': 'django_api_forms.population_strategies.IgnoreStrategy', 'django_api_forms.fields.ImageField': 'django_api_forms.population_strategies.IgnoreStrategy', - 'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.IgnoreStrategy', + 'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.FormFieldStrategy', 'django.forms.models.ModelMultipleChoiceField': 'django_api_forms.population_strategies.IgnoreStrategy', 'django.forms.models.ModelChoiceField': 'django_api_forms.population_strategies.ModelChoiceFieldStrategy' }, diff --git a/django_api_forms/version.py b/django_api_forms/version.py index 86d23c4..a748fea 100644 --- a/django_api_forms/version.py +++ b/django_api_forms/version.py @@ -1 +1 @@ -__version__ = '1.0.0-rc.10' +__version__ = '1.0.0-rc.12' diff --git a/docs/example.md b/docs/example.md index b939bb0..539a288 100644 --- a/docs/example.md +++ b/docs/example.md @@ -7,7 +7,7 @@ DJANGO_API_FORMS_POPULATION_STRATEGIES = { 'django_api_forms.fields.FormFieldList': 'django_api_forms.population_strategies.IgnoreStrategy', 'django_api_forms.fields.FileField': 'django_api_forms.population_strategies.IgnoreStrategy', 'django_api_forms.fields.ImageField': 'django_api_forms.population_strategies.IgnoreStrategy', - 'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.IgnoreStrategy', + 'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.FormFieldStrategy', 'django.forms.models.ModelMultipleChoiceField': 'django_api_forms.population_strategies.IgnoreStrategy', 'django.forms.models.ModelChoiceField': 'django_api_forms.population_strategies.ModelChoiceFieldStrategy' } @@ -78,6 +78,7 @@ from django.core.exceptions import ValidationError from django.forms import fields from django_api_forms import FieldList, FormField, FormFieldList, DictionaryField, EnumField, AnyField, Form +from tests.testapp.models import Artist, Album class AlbumType(Enum): @@ -105,7 +106,7 @@ class SongForm(Form): class AlbumForm(Form): title = fields.CharField(max_length=100) year = fields.IntegerField() - artist = FormField(form=ArtistForm) + artist = FormField(form=ArtistForm, model=Artist) songs = FormFieldList(form=SongForm) type = EnumField(enum=AlbumType, required=True) metadata = DictionaryField(value_field=fields.DateTimeField()) @@ -141,4 +142,12 @@ def create_album(request): # Cleaned valid payload payload = form.cleaned_data print(payload) + + # Populate cleaned data into Django model + album = Album() + form.populate(album) + + # Save populated objects + album.artist.save() + album.save() ``` diff --git a/docs/fields.md b/docs/fields.md index 5edf4be..f8f8081 100644 --- a/docs/fields.md +++ b/docs/fields.md @@ -101,6 +101,8 @@ Field used for embedded objects represented as another API form. - Normalizes to: A Python dictionary - Required arguments: - `form`: Type of a nested form +- Optional arguments: + - `model`: Datastructure(Django model) instance for population **JSON example** @@ -124,6 +126,7 @@ Field used for embedded objects represented as another API form. ```python from django_api_forms import Form, FormField, FieldList from django.forms import fields +from tests.testapp.models import Artist class ArtistForm(Form): @@ -135,7 +138,7 @@ class ArtistForm(Form): class AlbumForm(Form): title = fields.CharField(max_length=100) year = fields.IntegerField() - artist = FormField(form=ArtistForm) + artist = FormField(form=ArtistForm, model=Artist) ``` ## FormFieldList diff --git a/docs/tutorial.md b/docs/tutorial.md index f8e1249..d5c3b4e 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -8,11 +8,11 @@ images/files, nesting). - payload parsing (according to the `Content-Type` HTTP header) - data validation and normalisation (using [Django validators](https://docs.djangoproject.com/en/4.1/ref/validators/) -or custom `clean_` method) + or custom `clean_` method) - BASE64 file/image upload - construction of the basic validation response - filling objects attributes (if possible, see exceptions) using `setattr` function (super handy for Django database -models) + models) ## Construction @@ -24,6 +24,7 @@ any extra argument into `Form.create_from_request(request, param1=request.GET.ge ```python from tests.testapp.forms import AlbumForm + def my_view(request): form = AlbumForm.create_from_request(request=request, param=request.GET.get('param')) ``` @@ -80,13 +81,13 @@ class BandForm(Form): This process is much more simple than in classic Django form. It consists of: 1. Iterating over form attributes: - - calling `Field.clean(value)` method - - calling `Form.clean_` method - - calling `Form.add_error((field_name, ), error)` in case of failures in clean methods - - if field is marked as dirty, normalized attribute is saved to `Form.clean_data` property + - calling `Field.clean(value)` method + - calling `Form.clean_` method + - calling `Form.add_error((field_name, ), error)` in case of failures in clean methods + - if field is marked as dirty, normalized attribute is saved to `Form.clean_data` property 2. Calling `Form.clean` method which returns final normalized values which will be presented in `Form.clean_data` -(feel free to override it, by default does nothing, useful for conditional validation, you can still add errors -using `Form.add_error()`). `Form.clean` is only called when there are no errors from previous section. + (feel free to override it, by default does nothing, useful for conditional validation, you can still add errors + using `Form.add_error()`). `Form.clean` is only called when there are no errors from previous section. Normalized data are available in `Form.clean_data` property (keys suppose to correspond with values from `Form.dirty`). Extra optional arguments are available in `Form.extras` property (keys suppose to correspond with values @@ -106,13 +107,14 @@ from django.forms import fields from django.core.exceptions import ValidationError from django_api_forms import Form + class BookForm(Form): title = fields.CharField(max_length=100) year = fields.IntegerField() def clean_title(self): if self.cleaned_data['title'] == "The Hitchhiker's Guide to the Galaxy": - self.add_error(('title', ), ValidationError("Too cool!", code='too-cool')) + self.add_error(('title',), ValidationError("Too cool!", code='too-cool')) if 'param' not in self.extras: raise ValidationError("You can use extra optional arguments in form validation!") @@ -125,7 +127,7 @@ class BookForm(Form): if 'param' not in self.extras: self.add_error( - ('param', ), + ('param',), ValidationError("You can use extra optional arguments in form validation!", code='param-where') ) # The last chance to do some touchy touchy with the self.clean_data @@ -150,6 +152,7 @@ can use it like this: from tests.testapp.forms import AlbumForm from tests.testapp.models import Album + def my_view(request): form = AlbumForm.create_from_request(request) @@ -173,7 +176,7 @@ DJANGO_API_FORMS_POPULATION_STRATEGIES = { 'django_api_forms.fields.FormFieldList': 'django_api_forms.population_strategies.IgnoreStrategy', 'django_api_forms.fields.FileField': 'django_api_forms.population_strategies.IgnoreStrategy', 'django_api_forms.fields.ImageField': 'django_api_forms.population_strategies.IgnoreStrategy', - 'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.IgnoreStrategy', + 'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.FormFieldStrategy', 'django.forms.models.ModelMultipleChoiceField': 'django_api_forms.population_strategies.IgnoreStrategy', 'django.forms.models.ModelChoiceField': 'django_api_forms.population_strategies.ModelChoiceFieldStrategy' } @@ -205,18 +208,44 @@ from django_api_forms import Form from tests.testapp.models import Artist + class MyFormNoPostfix(Form): artist = ModelChoiceField(queryset=Artist.objects.all()) + class MyFormFieldName(Form): artist_name = ModelChoiceField( queryset=Artist.objects.all(), to_field_name='name' ) + class MyFormWithId(Form): artist_id = ModelChoiceField(queryset=Artist.objects.all()) ``` +#### FormFieldStrategy + +If the `model` argument is omitted, the `FormFieldStrategy` will behave same as the `IgnoreStrategy` +If a `model` argument is provided when declaring a `FormField`, the data from the nested JSON object is used to +populate an instance of the specified Django model. + +```python +from django.forms import fields + +from django_api_forms import FieldList, FormField, Form +from tests.testapp.models import Artist + + +class ArtistForm(Form): + name = fields.CharField(required=True, max_length=100) + genres = FieldList(field=fields.CharField(max_length=30)) + members = fields.IntegerField() + + +class AlbumForm(Form): + artist = FormField(form=ArtistForm, model=Artist) +``` + ### Customization #### Creating custom strategy @@ -236,8 +265,9 @@ class ExampleStrategy(BaseStrategy): #### Override strategy -You can override settings population strategies by creating your own population strategy in specific local `From` class using -`Meta` class with optional attributes `field_type_strategy = {}` or `field_strategy = {}`: +You can override settings population strategies by creating your own population strategy in specific local `From` class +using `Meta` class with optional attributes `field_type_strategy = {}` or `field_strategy = {}`: + - `field_type_strategy`: Dictionary for overriding populate strategy on `Form` type attributes - `field_strategy`: Dictionary for overriding populate strategies on `Form` attributes @@ -276,6 +306,7 @@ from django_api_forms import Form, FormField, EnumField, DictionaryField from tests.testapp.models import Album, Artist from tests.testapp.forms import ArtistForm + class AlbumForm(Form): title = fields.CharField(max_length=100) year = fields.IntegerField() diff --git a/pyproject.toml b/pyproject.toml index 0fd2499..b8fdf05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-api-forms" -version = "1.0.0-rc.11" +version = "1.0.0-rc.12" description = "Declarative Django request validation for RESTful APIs" authors = [ "Jakub Dubec ", diff --git a/tests/test_population_formfield.py b/tests/test_population_formfield.py new file mode 100644 index 0000000..e4f872b --- /dev/null +++ b/tests/test_population_formfield.py @@ -0,0 +1,73 @@ +from django.forms import fields +from django.test import RequestFactory, TestCase + +from django_api_forms import Form, FieldList, AnyField, FormField, EnumField +from tests.testapp.models import Album, Song, Artist + + +class ArtistForm(Form): + name = fields.CharField(required=True, max_length=100) + genres = FieldList(field=fields.CharField(max_length=30)) + members = fields.IntegerField() + + +class AlbumForm(Form): + title = fields.CharField(max_length=100) + year = fields.IntegerField() + type = EnumField(enum=Album.AlbumType, required=True) + artist = FormField(form=ArtistForm, model=Artist) + + +class SongForm(Form): + title = fields.CharField(required=True, max_length=100) + duration = fields.DurationField(required=True) + metadata = AnyField(required=False) + album = FormField(form=AlbumForm, model=Album) + + +class ValidationTests(TestCase): + def test_valid(self): + rf = RequestFactory() + + data = { + "title": "The Quirky Tune", + "duration": "00:03:28", + "metadata": { + "genre": "Comedy Rock", + "mood": "Happy", + "lyricist": "Joe Jokester" + }, + "album": { + "title": "Laughter and Chords", + "year": 2021, + "type": "vinyl", + "artist": { + "name": "The Chuckle Squad", + "genres": ["Comedy Rock", "Parody"], + "members": 6, + } + } + } + + request = rf.post('/foo/bar', data=data, content_type='application/json') + + form = SongForm.create_from_request(request) + + self.assertTrue(form.is_valid()) + + song = Song() + form.populate(song) + + song.album.artist.save() + song.album.save() + song.save() + + self.assertIsInstance(song.album, Album) + self.assertIsInstance(song.album.artist, Artist) + + self.assertEqual(song.album.title, data['album']['title']) + self.assertEqual(song.album.artist.name, data['album']['artist']['name']) + + self.assertEqual(song.pk, 1) + self.assertEqual(song.album.pk, 1) + self.assertEqual(song.album.artist.pk, 1)