Skip to content

FormField population strategy #69

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down Expand Up @@ -134,7 +134,7 @@ DJANGO_API_FORMS_PARSERS = {
}
```

**Django API Forms equivalent + validation**
**Django API Forms equivalent + validation + population**

```python
from enum import Enum
Expand All @@ -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):
Expand Down Expand Up @@ -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())
Expand All @@ -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']
Expand All @@ -195,7 +196,6 @@ class AlbumForm(Form):
return self.cleaned_data



"""
Django view example
"""
Expand All @@ -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)
Expand Down
14 changes: 9 additions & 5 deletions django_api_forms/fields.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions django_api_forms/population_strategies.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import copy


class BaseStrategy:
def __call__(self, field, obj, key: str, value):
setattr(obj, key, value)
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion django_api_forms/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
},
Expand Down
2 changes: 1 addition & 1 deletion django_api_forms/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '1.0.0-rc.10'
__version__ = '1.0.0-rc.12'
13 changes: 11 additions & 2 deletions docs/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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()
```
5 changes: 4 additions & 1 deletion docs/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand All @@ -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):
Expand All @@ -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
Expand Down
57 changes: 44 additions & 13 deletions docs/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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'))
```
Expand Down Expand Up @@ -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_<field_name>` 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_<field_name>` 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
Expand All @@ -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!")
Expand All @@ -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
Expand All @@ -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)

Expand All @@ -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'
}
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>",
Expand Down
Loading
Loading