Skip to content

Commit 5df3f3a

Browse files
committed
Semantic support for FK+M2M registration
1 parent bb2bf2f commit 5df3f3a

File tree

18 files changed

+590
-227
lines changed

18 files changed

+590
-227
lines changed

docs/test_doc_semantic_models.py

Lines changed: 128 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
from django.db.models import (
2+
BooleanField,
23
CASCADE,
34
CharField,
45
ForeignKey,
6+
ManyToManyField,
57
Model,
68
)
79

10+
import iommi
811
from iommi import (
912
register_factory,
10-
Style,
13+
register_related_factory,
14+
register_related_multiple_factory,
1115
)
12-
from iommi.style_bootstrap5 import bootstrap5
16+
from iommi.shortcut import with_defaults
17+
from tests.helpers import req
1318

1419

1520
def test_semantic_models():
@@ -26,76 +31,135 @@ def test_semantic_models():
2631
"""
2732

2833
# @test
29-
class Location(Model):
34+
class Role(Model):
3035
pass
31-
3236
# @end
3337

3438
class User(Model):
39+
is_active = BooleanField()
3540
name = CharField()
3641
person_number = CharField()
37-
birth_place = ForeignKey(Location, on_delete=CASCADE)
3842
manager = ForeignKey('self', on_delete=CASCADE)
43+
roles = ManyToManyField(Role, blank=True)
3944

40-
# language=rst
41-
"""
42-
The issue with that is that the semantic meaning of each field is hidden behind the name, and not in the type. The `name`
43-
and `person_number` fields have the same type but should be handled differently. Since iommi shortcut registrations are
44-
based on the type, you can't customize the parsing or rendering of the `person_number` or `birth_place` fields on
45-
the project level via the :ref:`style`.
46-
47-
Moreover in this example the type information alone is not enough for other customization. For `ForeignKey`, by default
48-
in iommi you'll get a select2 drop-down to select from all items in that table. This is not good UX for a location, and
49-
it's probably not good UX for a manager field either as that should most likely exclude non-active users, and/or limited
50-
to users with a certain role.
51-
52-
For `CharField`, the default is to present a text field, but in the above model we want a Swedish "person number", which
53-
has a specific storage format, can accept a variety of input formats that can be unambiguously parsed, and even has a
54-
checksum that can be used to validate that the user input is correct.
55-
56-
A solution to this is to create an additional specialized type to specify semantic model fields:
57-
58-
"""
59-
60-
class PersonNumberField(CharField):
61-
pass
62-
63-
# language=rst
64-
"""
65-
This field can then be registered in iommi:
66-
67-
"""
68-
register_factory(PersonNumberField, shortcut_name='person_number')
69-
70-
# language=rst
71-
"""
72-
For foreign key fields it would be cumbersome to make custom classes, so registrations are done slightly differently:
73-
74-
"""
75-
76-
register_foreign_key_factory(Location, shortcut_name='location')
77-
register_foreign_key_factory(User, shortcut_name='user')
78-
79-
# language=rst
80-
"""
81-
You will then need to add shortcuts for these in your subclasses of `Column`, `Field`, and `Filter`. These can start out empty, and configuration can be done in the style definition:
82-
83-
"""
45+
# @test
46+
try:
47+
# @end
48+
49+
# language=rst
50+
"""
51+
The issue with that is that the semantic meaning of each field is hidden behind the name, and not in the type. The `name`
52+
and `person_number` fields have the same type but should be handled differently. Since iommi shortcut registrations are
53+
based on the type, you can't customize the parsing or rendering of the `person_number` or `birth_place` fields on
54+
the project level via the :ref:`style`.
55+
56+
Moreover in this example the type information alone is not enough for other customization. For `ForeignKey`, by default
57+
in iommi you'll get a select2 drop-down to select from all items in that table. This is not good UX for a location, and
58+
it's probably not good UX for a manager field either as that should most likely exclude non-active users, and/or limited
59+
to users with a certain role.
60+
61+
For `CharField`, the default is to present a text field, but in the above model we want a Swedish "person number", which
62+
has a specific storage format, can accept a variety of input formats that can be unambiguously parsed, and even has a
63+
checksum that can be used to validate that the user input is correct.
64+
65+
A solution to this is to create an additional specialized type to specify semantic model fields:
66+
67+
"""
68+
69+
class PersonNumberField(CharField):
70+
pass
71+
72+
# language=rst
73+
"""
74+
This field can then be registered in iommi:
75+
76+
"""
77+
register_factory(PersonNumberField, shortcut_name='person_number')
78+
79+
# language=rst
80+
"""
81+
Then change the model to use `PersonNumberField`:
82+
"""
83+
84+
class User(Model):
85+
is_active = BooleanField()
86+
name = CharField()
87+
person_number = PersonNumberField()
88+
manager = ForeignKey('self', on_delete=CASCADE)
89+
90+
# @test
91+
# Fix Django model registry issue: redefining a model class causes ForeignKey('self')
92+
# to resolve to the old class, so we manually fix the reference.
93+
User._meta.get_field('manager').remote_field.model = User
94+
# @end
95+
96+
# language=rst
97+
"""
98+
For foreign key, and many-to-many fields it would be cumbersome to make custom classes, so registrations are done slightly differently:
99+
100+
101+
"""
102+
103+
# foreign key and one-to-one
104+
# related, related_multiple
105+
register_related_factory(User, shortcut_name='user')
106+
# many-to-many and one-to-many (aka reverse foreign key)
107+
register_related_multiple_factory(Role, shortcut_name='roles')
108+
109+
# @test
110+
person_number__parse = lambda **_: None
111+
# @end
112+
113+
# language=rst
114+
"""
115+
You will then need to add shortcuts for these in your subclasses of `Column`, `Field`, and `Filter`.
116+
"""
117+
118+
class Field(iommi.Field):
119+
@classmethod
120+
@with_defaults(
121+
parse=person_number__parse,
122+
)
123+
def person_number(cls, **kwargs):
124+
return cls.text(**kwargs)
125+
126+
@classmethod
127+
@with_defaults(
128+
choices=lambda **_: User.objects.filter(is_active=True),
129+
)
130+
def user(cls, **kwargs):
131+
return cls.foreign_key(**kwargs)
132+
133+
@classmethod
134+
@with_defaults()
135+
def roles(cls, **kwargs):
136+
return cls.many_to_many(**kwargs)
137+
138+
# language=rst
139+
"""
140+
...and similar for `Field` and `Filter`.
141+
142+
You can also add configuration via the `Style` machinery. This is useful for reusable apps.
143+
144+
Semantic models requires a little bit more initial setup, but for commonly used field types, it will make new views correct by default and super easy to setup.
145+
"""
146+
147+
# @test
148+
class Form(iommi.Form):
149+
class Meta:
150+
member_class = Field
151+
152+
f = Form.create(auto__model=User).bind(request=req('get'))
153+
assert str(f.fields.manager.choices.query) == str(User.objects.filter(is_active=True).query)
154+
# @end
84155

85156
# @test
86-
person_number__parse = lambda **_: None
157+
finally:
158+
from iommi.form import (
159+
_related_field_factory_by_model,
160+
_related_multiple_field_factory_by_model,
161+
)
162+
_related_field_factory_by_model.pop(User)
163+
164+
_related_multiple_field_factory_by_model.pop(Role)
87165
# @end
88-
89-
your_style = Style(
90-
bootstrap5,
91-
Field=dict(
92-
shortcuts=dict(
93-
person_number__parse=person_number__parse,
94-
),
95-
),
96-
)
97-
98-
# language=rst
99-
"""
100-
It requires a little bit more initial setup, but for commonly used field types, it will make new views correct by default and super easy to set up.
101-
"""

examples/examples/apps.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.apps import AppConfig
22

3+
from iommi import register_related_factory
34
from iommi.path import register_path_decoding
45

56

@@ -19,3 +20,4 @@ def ready(self):
1920
register_search_fields(model=Album, search_fields=['name'], allow_non_unique=True)
2021
register_path_decoding(artist_pk=Artist)
2122
register_path_decoding(album_pk=Album)
23+
register_related_factory(Artist, shortcut_name='artist')

examples/examples/iommi.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from django.template import Template
2+
3+
import iommi
4+
from iommi.shortcut import with_defaults
5+
6+
7+
class Page(iommi.Page):
8+
pass
9+
10+
11+
class Action(iommi.Action):
12+
pass
13+
14+
15+
class Field(iommi.Field):
16+
@classmethod
17+
@with_defaults()
18+
def artist(cls, **kwargs):
19+
return cls.foreign_key(**kwargs)
20+
21+
22+
class Form(iommi.Form):
23+
class Meta:
24+
member_class = Field
25+
page_class = Page
26+
action_class = Action
27+
28+
29+
class Filter(iommi.Filter):
30+
@classmethod
31+
@with_defaults()
32+
def artist(cls, **kwargs):
33+
return cls.foreign_key(**kwargs)
34+
35+
36+
class Query(iommi.Query):
37+
class Meta:
38+
member_class = Filter
39+
form_class = Form
40+
41+
42+
class Column(iommi.Column):
43+
@classmethod
44+
@with_defaults()
45+
def artist(cls, **kwargs):
46+
return cls.foreign_key(**kwargs)
47+
48+
49+
class Table(iommi.Table):
50+
class Meta:
51+
member_class = Column
52+
form_class = Form
53+
query_class = Query
54+
page_class = Page
55+
action_class = Action
56+
57+
58+
class EditColumn(iommi.EditColumn):
59+
@classmethod
60+
@with_defaults()
61+
def artist(cls, **kwargs):
62+
return cls.foreign_key(**kwargs)
63+
64+
65+
class EditTable(iommi.EditTable):
66+
class Meta:
67+
member_class = EditColumn
68+
form_class = Form
69+
query_class = Query
70+
page_class = Page
71+
action_class = Action
72+
73+
74+
class Menu(iommi.Menu):
75+
pass
76+
77+
78+
class MenuItem(iommi.MenuItem):
79+
pass

examples/examples/main_menu.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
1+
from examples import (
2+
playground,
3+
storybook,
4+
supernaut,
5+
)
6+
from examples.iommi import Form
17
from examples.models import (
28
Album,
39
Artist,
410
Track,
511
)
6-
from iommi import Form
712
from iommi.admin import Admin
8-
from iommi.main_menu import (
13+
from iommi.experimental.main_menu import (
914
EXTERNAL,
1015
M,
1116
MainMenu,
1217
)
1318

14-
from examples import (
15-
playground,
16-
storybook,
17-
supernaut,
18-
)
19-
2019
main_menu = MainMenu(
2120
items=dict(
2221
albums=M(

examples/examples/supernaut.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,15 @@
44
)
55
from django.utils.translation import gettext_lazy as _
66

7-
from iommi import (
7+
from examples.iommi import (
88
Action,
99
Column,
1010
Form,
11-
Menu,
12-
MenuItem,
1311
Page,
1412
Table,
15-
html,
1613
)
14+
from iommi import html
1715
from iommi.path import register_path_decoding
18-
1916
from .models import (
2017
Album,
2118
Artist,
@@ -103,7 +100,7 @@ class ArtistPage(Page):
103100
title = html.h1(lambda params, **_: params.artist.name)
104101

105102
albums = AlbumTable(auto__model=Album, rows=lambda params, **_: Album.objects.filter(artist=params.artist))
106-
tracks = TrackTable(auto__model=Track, rows=lambda params, **_: Track.objects.filter(album__artist=params.artist))
103+
# tracks = TrackTable(auto__model=Track, rows=lambda params, **_: Track.objects.filter(album__artist=params.artist))
107104

108105

109106
class AlbumPage(Page):
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{% extends "iommi/base.html" %}
2+
3+
{% block iommi_head_contents_last %}
4+
{% if request.iommi_example_source_css %}
5+
<style>{{ request.iommi_example_source_css }}</style>
6+
{% endif %}
7+
{% endblock %}
8+
9+
{% block iommi_top %}
10+
{% if request.iommi_example_source %}
11+
{{ request.iommi_example_source }}
12+
{% endif %}
13+
{% endblock %}

0 commit comments

Comments
 (0)