Skip to content

Commit ba7acc7

Browse files
committed
Introduces FormField population strategy
1 parent 508f3be commit ba7acc7

11 files changed

+176
-29
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 1.0.0-rc.12 : 22.09.2024
4+
5+
- **Added**: FormField model declaration
6+
37
## 1.0.0-rc.11 : 16.08.2024
48

59
- **Fixed**: Proper manipulation with `BaseStrategy` instances during population

README.md

+13-5
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ DJANGO_API_FORMS_POPULATION_STRATEGIES = {
7575
'django_api_forms.fields.FormFieldList': 'django_api_forms.population_strategies.IgnoreStrategy',
7676
'django_api_forms.fields.FileField': 'django_api_forms.population_strategies.IgnoreStrategy',
7777
'django_api_forms.fields.ImageField': 'django_api_forms.population_strategies.IgnoreStrategy',
78-
'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.IgnoreStrategy',
78+
'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.FormFieldStrategy',
7979
'django.forms.models.ModelMultipleChoiceField': 'django_api_forms.population_strategies.IgnoreStrategy',
8080
'django.forms.models.ModelChoiceField': 'django_api_forms.population_strategies.ModelChoiceFieldStrategy'
8181
}
@@ -134,7 +134,7 @@ DJANGO_API_FORMS_PARSERS = {
134134
}
135135
```
136136

137-
**Django API Forms equivalent + validation**
137+
**Django API Forms equivalent + validation + population**
138138

139139
```python
140140
from enum import Enum
@@ -143,6 +143,7 @@ from django.core.exceptions import ValidationError
143143
from django.forms import fields
144144

145145
from django_api_forms import FieldList, FormField, FormFieldList, DictionaryField, EnumField, AnyField, Form
146+
from tests.testapp.models import Artist, Album
146147

147148

148149
class AlbumType(Enum):
@@ -170,7 +171,7 @@ class SongForm(Form):
170171
class AlbumForm(Form):
171172
title = fields.CharField(max_length=100)
172173
year = fields.IntegerField()
173-
artist = FormField(form=ArtistForm)
174+
artist = FormField(form=ArtistForm, model=Artist)
174175
songs = FormFieldList(form=SongForm)
175176
type = EnumField(enum=AlbumType, required=True)
176177
metadata = DictionaryField(value_field=fields.DateTimeField())
@@ -180,7 +181,7 @@ class AlbumForm(Form):
180181
raise ValidationError("Year 1992 is forbidden!", 'forbidden-value')
181182
if 'param' not in self.extras:
182183
self.add_error(
183-
('param', ),
184+
('param',),
184185
ValidationError("You can use extra optional arguments in form validation!", code='param-where')
185186
)
186187
return self.cleaned_data['year']
@@ -195,7 +196,6 @@ class AlbumForm(Form):
195196
return self.cleaned_data
196197

197198

198-
199199
"""
200200
Django view example
201201
"""
@@ -208,6 +208,14 @@ def create_album(request):
208208
# Cleaned valid payload
209209
payload = form.cleaned_data
210210
print(payload)
211+
212+
# Populate cleaned data into Django model
213+
album = Album()
214+
form.populate(album)
215+
216+
# Save populated objects
217+
album.save()
218+
album.artist.save()
211219
```
212220

213221
If you want example with whole Django project, check out repository created by [pawl](https://github.com/pawl)

django_api_forms/fields.py

+9-5
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1+
import re
12
import typing
23
import warnings
34
from base64 import b64decode
45
from enum import Enum
56
from io import BytesIO
67
from mimetypes import guess_type
7-
import re
88

99
from django.core.exceptions import ValidationError
1010
from django.core.files import File
@@ -86,15 +86,19 @@ def to_python(self, value) -> typing.List:
8686

8787

8888
class FormField(Field):
89-
def __init__(self, form: typing.Type, **kwargs):
89+
def __init__(self, form: typing.Type, model=None, **kwargs):
9090
self._form = form
91-
91+
self._model = model
9292
super().__init__(**kwargs)
9393

9494
@property
9595
def form(self):
9696
return self._form
9797

98+
@property
99+
def model(self):
100+
return self._model
101+
98102
def to_python(self, value) -> typing.Union[typing.Dict, None]:
99103
if not value:
100104
return {}
@@ -142,7 +146,7 @@ def to_python(self, value):
142146
result.append(form.cleaned_data)
143147
else:
144148
for error in form.errors:
145-
error.prepend((position, ))
149+
error.prepend((position,))
146150
errors.append(error)
147151

148152
if errors:
@@ -208,7 +212,7 @@ def to_python(self, value) -> dict:
208212
key = self._key_field.clean(key)
209213
result[key] = self._value_field.clean(item)
210214
except ValidationError as e:
211-
errors[key] = DetailValidationError(e, (key, ))
215+
errors[key] = DetailValidationError(e, (key,))
212216

213217
if errors:
214218
raise ValidationError(errors)

django_api_forms/population_strategies.py

+22
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import copy
2+
3+
14
class BaseStrategy:
25
def __call__(self, field, obj, key: str, value):
36
setattr(obj, key, value)
@@ -34,3 +37,22 @@ def __call__(self, field, obj, key: str, value):
3437
if key.endswith(postfix_to_remove):
3538
model_key = key[:-len(postfix_to_remove)]
3639
setattr(obj, model_key, value)
40+
41+
42+
class FormFieldStrategy(BaseStrategy):
43+
def __call__(self, field, obj, key: str, value):
44+
model = field.model
45+
if model:
46+
from django_api_forms.settings import Settings
47+
48+
model = model()
49+
form = field.form
50+
51+
form.cleaned_data = value
52+
form.fields = copy.deepcopy(getattr(form, 'base_fields'))
53+
form.settings = Settings()
54+
form.errors = None
55+
56+
populated_model = form.populate(form, model)
57+
58+
setattr(obj, key, populated_model)

django_api_forms/settings.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
'django_api_forms.fields.FormFieldList': 'django_api_forms.population_strategies.IgnoreStrategy',
66
'django_api_forms.fields.FileField': 'django_api_forms.population_strategies.IgnoreStrategy',
77
'django_api_forms.fields.ImageField': 'django_api_forms.population_strategies.IgnoreStrategy',
8-
'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.IgnoreStrategy',
8+
'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.FormFieldStrategy',
99
'django.forms.models.ModelMultipleChoiceField': 'django_api_forms.population_strategies.IgnoreStrategy',
1010
'django.forms.models.ModelChoiceField': 'django_api_forms.population_strategies.ModelChoiceFieldStrategy'
1111
},

django_api_forms/version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '1.0.0-rc.10'
1+
__version__ = '1.0.0-rc.12'

docs/example.md

+12-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ DJANGO_API_FORMS_POPULATION_STRATEGIES = {
77
'django_api_forms.fields.FormFieldList': 'django_api_forms.population_strategies.IgnoreStrategy',
88
'django_api_forms.fields.FileField': 'django_api_forms.population_strategies.IgnoreStrategy',
99
'django_api_forms.fields.ImageField': 'django_api_forms.population_strategies.IgnoreStrategy',
10-
'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.IgnoreStrategy',
10+
'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.FormFieldStrategy',
1111
'django.forms.models.ModelMultipleChoiceField': 'django_api_forms.population_strategies.IgnoreStrategy',
1212
'django.forms.models.ModelChoiceField': 'django_api_forms.population_strategies.ModelChoiceFieldStrategy'
1313
}
@@ -78,6 +78,7 @@ from django.core.exceptions import ValidationError
7878
from django.forms import fields
7979

8080
from django_api_forms import FieldList, FormField, FormFieldList, DictionaryField, EnumField, AnyField, Form
81+
from tests.testapp.models import Artist, Album
8182

8283

8384
class AlbumType(Enum):
@@ -105,7 +106,7 @@ class SongForm(Form):
105106
class AlbumForm(Form):
106107
title = fields.CharField(max_length=100)
107108
year = fields.IntegerField()
108-
artist = FormField(form=ArtistForm)
109+
artist = FormField(form=ArtistForm, model=Artist)
109110
songs = FormFieldList(form=SongForm)
110111
type = EnumField(enum=AlbumType, required=True)
111112
metadata = DictionaryField(value_field=fields.DateTimeField())
@@ -141,4 +142,13 @@ def create_album(request):
141142
# Cleaned valid payload
142143
payload = form.cleaned_data
143144
print(payload)
145+
146+
# Populate cleaned data into Django model
147+
album = Album()
148+
form.populate(album)
149+
150+
# Save populated objects
151+
album.save()
152+
album.artist.save()
153+
144154
```

docs/fields.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ Field used for embedded objects represented as another API form.
101101
- Normalizes to: A Python dictionary
102102
- Required arguments:
103103
- `form`: Type of a nested form
104+
- Optional arguments:
105+
- `model`: Datastructure(Django model) instance for population
104106

105107
**JSON example**
106108

@@ -124,6 +126,7 @@ Field used for embedded objects represented as another API form.
124126
```python
125127
from django_api_forms import Form, FormField, FieldList
126128
from django.forms import fields
129+
from tests.testapp.models import Artist
127130

128131

129132
class ArtistForm(Form):
@@ -135,7 +138,7 @@ class ArtistForm(Form):
135138
class AlbumForm(Form):
136139
title = fields.CharField(max_length=100)
137140
year = fields.IntegerField()
138-
artist = FormField(form=ArtistForm)
141+
artist = FormField(form=ArtistForm, model=Artist)
139142
```
140143

141144
## FormFieldList

docs/tutorial.md

+44-13
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ images/files, nesting).
88

99
- payload parsing (according to the `Content-Type` HTTP header)
1010
- data validation and normalisation (using [Django validators](https://docs.djangoproject.com/en/4.1/ref/validators/)
11-
or custom `clean_` method)
11+
or custom `clean_` method)
1212
- BASE64 file/image upload
1313
- construction of the basic validation response
1414
- filling objects attributes (if possible, see exceptions) using `setattr` function (super handy for Django database
15-
models)
15+
models)
1616

1717
## Construction
1818

@@ -24,6 +24,7 @@ any extra argument into `Form.create_from_request(request, param1=request.GET.ge
2424
```python
2525
from tests.testapp.forms import AlbumForm
2626

27+
2728
def my_view(request):
2829
form = AlbumForm.create_from_request(request=request, param=request.GET.get('param'))
2930
```
@@ -80,13 +81,13 @@ class BandForm(Form):
8081
This process is much more simple than in classic Django form. It consists of:
8182

8283
1. Iterating over form attributes:
83-
- calling `Field.clean(value)` method
84-
- calling `Form.clean_<field_name>` method
85-
- calling `Form.add_error((field_name, ), error)` in case of failures in clean methods
86-
- if field is marked as dirty, normalized attribute is saved to `Form.clean_data` property
84+
- calling `Field.clean(value)` method
85+
- calling `Form.clean_<field_name>` method
86+
- calling `Form.add_error((field_name, ), error)` in case of failures in clean methods
87+
- if field is marked as dirty, normalized attribute is saved to `Form.clean_data` property
8788
2. Calling `Form.clean` method which returns final normalized values which will be presented in `Form.clean_data`
88-
(feel free to override it, by default does nothing, useful for conditional validation, you can still add errors
89-
using `Form.add_error()`). `Form.clean` is only called when there are no errors from previous section.
89+
(feel free to override it, by default does nothing, useful for conditional validation, you can still add errors
90+
using `Form.add_error()`). `Form.clean` is only called when there are no errors from previous section.
9091

9192
Normalized data are available in `Form.clean_data` property (keys suppose to correspond with values from `Form.dirty`).
9293
Extra optional arguments are available in `Form.extras` property (keys suppose to correspond with values
@@ -106,13 +107,14 @@ from django.forms import fields
106107
from django.core.exceptions import ValidationError
107108
from django_api_forms import Form
108109

110+
109111
class BookForm(Form):
110112
title = fields.CharField(max_length=100)
111113
year = fields.IntegerField()
112114

113115
def clean_title(self):
114116
if self.cleaned_data['title'] == "The Hitchhiker's Guide to the Galaxy":
115-
self.add_error(('title', ), ValidationError("Too cool!", code='too-cool'))
117+
self.add_error(('title',), ValidationError("Too cool!", code='too-cool'))
116118

117119
if 'param' not in self.extras:
118120
raise ValidationError("You can use extra optional arguments in form validation!")
@@ -125,7 +127,7 @@ class BookForm(Form):
125127

126128
if 'param' not in self.extras:
127129
self.add_error(
128-
('param', ),
130+
('param',),
129131
ValidationError("You can use extra optional arguments in form validation!", code='param-where')
130132
)
131133
# The last chance to do some touchy touchy with the self.clean_data
@@ -150,6 +152,7 @@ can use it like this:
150152
from tests.testapp.forms import AlbumForm
151153
from tests.testapp.models import Album
152154

155+
153156
def my_view(request):
154157
form = AlbumForm.create_from_request(request)
155158

@@ -173,7 +176,7 @@ DJANGO_API_FORMS_POPULATION_STRATEGIES = {
173176
'django_api_forms.fields.FormFieldList': 'django_api_forms.population_strategies.IgnoreStrategy',
174177
'django_api_forms.fields.FileField': 'django_api_forms.population_strategies.IgnoreStrategy',
175178
'django_api_forms.fields.ImageField': 'django_api_forms.population_strategies.IgnoreStrategy',
176-
'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.IgnoreStrategy',
179+
'django_api_forms.fields.FormField': 'django_api_forms.population_strategies.FormFieldStrategy',
177180
'django.forms.models.ModelMultipleChoiceField': 'django_api_forms.population_strategies.IgnoreStrategy',
178181
'django.forms.models.ModelChoiceField': 'django_api_forms.population_strategies.ModelChoiceFieldStrategy'
179182
}
@@ -205,18 +208,44 @@ from django_api_forms import Form
205208

206209
from tests.testapp.models import Artist
207210

211+
208212
class MyFormNoPostfix(Form):
209213
artist = ModelChoiceField(queryset=Artist.objects.all())
210214

215+
211216
class MyFormFieldName(Form):
212217
artist_name = ModelChoiceField(
213218
queryset=Artist.objects.all(), to_field_name='name'
214219
)
215220

221+
216222
class MyFormWithId(Form):
217223
artist_id = ModelChoiceField(queryset=Artist.objects.all())
218224
```
219225

226+
#### FormFieldStrategy
227+
228+
If the `model` argument is omitted, the `FormFieldStrategy` will behave same as the `IgnoreStrategy`
229+
If a `model` argument is provided when declaring a `FormField`, the data from the nested JSON object is used to
230+
populate an instance of the specified Django model.
231+
232+
```python
233+
from django.forms import fields
234+
235+
from django_api_forms import FieldList, FormField, Form
236+
from tests.testapp.models import Artist
237+
238+
239+
class ArtistForm(Form):
240+
name = fields.CharField(required=True, max_length=100)
241+
genres = FieldList(field=fields.CharField(max_length=30))
242+
members = fields.IntegerField()
243+
244+
245+
class AlbumForm(Form):
246+
artist = FormField(form=ArtistForm, model=Artist)
247+
```
248+
220249
### Customization
221250

222251
#### Creating custom strategy
@@ -236,8 +265,9 @@ class ExampleStrategy(BaseStrategy):
236265

237266
#### Override strategy
238267

239-
You can override settings population strategies by creating your own population strategy in specific local `From` class using
240-
`Meta` class with optional attributes `field_type_strategy = {}` or `field_strategy = {}`:
268+
You can override settings population strategies by creating your own population strategy in specific local `From` class
269+
using `Meta` class with optional attributes `field_type_strategy = {}` or `field_strategy = {}`:
270+
241271
- `field_type_strategy`: Dictionary for overriding populate strategy on `Form` type attributes
242272
- `field_strategy`: Dictionary for overriding populate strategies on `Form` attributes
243273

@@ -276,6 +306,7 @@ from django_api_forms import Form, FormField, EnumField, DictionaryField
276306
from tests.testapp.models import Album, Artist
277307
from tests.testapp.forms import ArtistForm
278308

309+
279310
class AlbumForm(Form):
280311
title = fields.CharField(max_length=100)
281312
year = fields.IntegerField()

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-api-forms"
3-
version = "1.0.0-rc.11"
3+
version = "1.0.0-rc.12"
44
description = "Declarative Django request validation for RESTful APIs"
55
authors = [
66
"Jakub Dubec <[email protected]>",

0 commit comments

Comments
 (0)