Skip to content

Commit e2f9a1f

Browse files
committed
feat: add configurations - model_join (default to True), sensitive_fields, model_recursive (default to False)
1 parent df877ca commit e2f9a1f

File tree

8 files changed

+159
-33
lines changed

8 files changed

+159
-33
lines changed

easy/controller/base.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,27 @@
99

1010
class CrudAPIController(ControllerBase, CrudAPI, metaclass=CrudApiMetaclass):
1111
"""
12-
Base APIController for auto creating CRUD APIs
12+
Base APIController for auto creating CRUD APIs, configurable via Meta class
13+
APIs auto genrated:
14+
Creat
15+
PUT /{id} - Create a single Object
1316
14-
GET /{id} - Retrieve a single Object
15-
PUT /{id} - Create a single Object
16-
PATCH /{id} - Update fields for an Object
17-
DELETE /{id} - Delete a single Object
18-
GET / - Retrieve multiple Object, paginated
17+
Read
18+
GET /{id} - Retrieve a single Object
19+
GET / - Retrieve multiple Object, paginated, support filtering
20+
21+
Update
22+
PATCH /{id} - Update a single Object
23+
24+
Delete
25+
DELETE /{id} - Delete a single Object
26+
27+
Configuration:
28+
model: django model
29+
model_fields: fields to be included in Schema, default to "__all__"
30+
model_exclude: fields to be excluded in Schema
31+
model_join: retrieve all m2m/FK fields, default to True
32+
model_recursive: recursively retrieve FK models, defaul to False
1933
"""
2034

2135
...

easy/controller/meta.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
import uuid
44
from abc import ABCMeta
5-
from typing import Any, Optional, Tuple, Type, Union
5+
from typing import Any, List, Optional, Tuple, Type, Union
66

77
from django.db import models
88
from django.http import HttpRequest
@@ -27,6 +27,10 @@ def __init__(self, service=None): # type: ignore
2727
self.service = service
2828
super().__init__(model=self.model)
2929

30+
# Critical to set Meta
31+
if hasattr(self, "Meta"):
32+
self.model.Meta = self.Meta # type: ignore
33+
3034
# Define Controller APIs for auto generation
3135
async def get_obj(self, request: HttpRequest, id: int) -> Any:
3236
"""
@@ -83,15 +87,18 @@ async def get_objs(
8387
class CrudApiMetaclass(ABCMeta):
8488
def __new__(mcs, name: str, bases: Tuple[Type[Any], ...], attrs: dict) -> Any:
8589
# Get configs from Meta
86-
temp_base: Type = type.__new__(type, "object", (), {})
8790
temp_cls: Type = super(CrudApiMetaclass, mcs).__new__(
88-
mcs, name, (temp_base,), attrs
91+
mcs, name, (object,), attrs
8992
)
9093
temp_opts: ModelOptions = ModelOptions(getattr(temp_cls, "Meta", None))
94+
opts_model: Optional[Type[models.Model]] = temp_opts.model
9195
opts_fields_exclude: Optional[str] = temp_opts.model_exclude
9296
opts_fields: Optional[str] = temp_opts.model_fields
93-
opts_model: Optional[Type[models.Model]] = temp_opts.model
9497
opts_recursive: Optional[bool] = temp_opts.model_recursive
98+
opts_join: Optional[bool] = temp_opts.model_join
99+
opts_sensitive_fields: Optional[
100+
Union[str, List[str]]
101+
] = temp_opts.sensitive_fields
95102

96103
base_cls_attrs = {
97104
"get_obj": http_get("/{id}", summary="Get a single object")(
@@ -117,7 +124,6 @@ class Config:
117124
model_fields = "__all__"
118125
else:
119126
model_fields = opts_fields if opts_fields else "__all__"
120-
model_recursive = opts_recursive
121127

122128
async def add_obj( # type: ignore
123129
self, request: HttpRequest, data: DataSchema
@@ -137,7 +143,7 @@ async def patch_obj( # type: ignore
137143
) -> Any:
138144
"""
139145
PATCH /{id}
140-
Update a single field for a Object
146+
Update a single object
141147
"""
142148
if await self.service.patch_obj(id=id, payload=data.dict()):
143149
return BaseApiResponse("Updated.")
@@ -169,21 +175,35 @@ async def patch_obj( # type: ignore
169175
mcs, name, (new_base,), attrs
170176
)
171177

172-
new_cls.model = opts_model
173-
new_cls.model_exclude = opts_fields_exclude
174-
new_cls.model_fields = opts_fields
175-
new_cls.model_recursive = opts_recursive
176-
178+
if opts_model:
179+
setattr(opts_model.Meta, "model_exclude", opts_fields_exclude)
180+
setattr(opts_model.Meta, "model_fields", opts_fields)
181+
setattr(opts_model.Meta, "model_recursive", opts_recursive)
182+
setattr(opts_model.Meta, "model_join", opts_join)
183+
setattr(opts_model.Meta, "sensitive_fields", opts_sensitive_fields)
184+
setattr(new_cls, "model", opts_model)
177185
return new_cls
178186

179187

180188
class ModelOptions:
181189
def __init__(self, options: object = None):
190+
"""
191+
Configuration:
192+
model: django model
193+
model_fields: fields to be included in Schema, default to "__all__"
194+
model_exclude: fields to be excluded in Schema
195+
model_join: retrieve all m2m/FK fields, default to True
196+
model_recursive: recursively retrieve FK models, default to False
197+
"""
182198
self.model: Optional[Type[models.Model]] = getattr(options, "model", None)
183199
self.model_fields: Optional[Union[str]] = getattr(options, "model_fields", None)
184200
self.model_exclude: Optional[Union[str]] = getattr(
185201
options, "model_exclude", None
186202
)
203+
self.model_join: Optional[Union[bool]] = getattr(options, "model_join", True)
187204
self.model_recursive: Optional[Union[bool]] = getattr(
188205
options, "model_recursive", False
189206
)
207+
self.sensitive_fields: Optional[Union[str, List[str]]] = getattr(
208+
options, "sensitive_fields", ["token", "password"]
209+
)

easy/domain/orm.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ def __init__(self, model: Type[models.Model]):
1919
if isinstance(_field, models.ManyToManyField)
2020
)
2121

22+
# @property
23+
# def model_join(self):
24+
# """If configured to retrieve m2m by join"""
25+
# if hasattr(self.model, "Meta"):
26+
# return getattr(self.model.Meta, "model_join", True)
27+
# else:
28+
# return True
29+
2230
def _separate_payload(self, payload: Dict) -> Tuple[Dict, Dict]:
2331
m2m_fields = {}
2432
local_fields = {}
@@ -118,7 +126,7 @@ def _crud_get_objs_all(self, maximum: int = None, **filters: Any) -> Any:
118126
return qs
119127

120128
def _crud_filter(self, **kwargs: Any) -> QuerySet:
121-
return self.model.objects.filter(**kwargs)
129+
return self.model.objects.filter(**kwargs) # pragma: no cover
122130

123131
def _crud_filter_exclude(self, **kwargs: Any) -> QuerySet:
124132
return self.model.objects.all().exclude(**kwargs)

easy/domain/serializers.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ def serialize_foreign_key(
7272
except Exception as exc: # pragma: no cover
7373
logger.error(f"serialize_foreign_key error - {obj}", exc_info=exc)
7474
return {field.name: None}
75+
if hasattr(obj, "Meta") and getattr(obj.Meta, "model_recursive", False):
76+
return {field.name: serialize_model_instance(related_instance, referrers)}
7577
return {field.name: field_value}
76-
# TODO: recursive
77-
# return {field.name: serialize_model_instance(related_instance, referrers)}
7878

7979

8080
def serialize_many_relationship(
@@ -90,8 +90,10 @@ def serialize_many_relationship(
9090
for k, v in obj._prefetched_objects_cache.items(): # type: ignore
9191
field_name = k if hasattr(obj, k) else k + "_set"
9292
if v:
93-
# TODO: configurable recursive for m2m output
94-
out[field_name] = serialize_queryset(v, referrers + (obj,))
93+
if hasattr(obj, "Meta") and getattr(obj.Meta, "model_join", True):
94+
out[field_name] = serialize_queryset(v, referrers + (obj,))
95+
else:
96+
out[field_name] = [o.pk for o in v]
9597
else:
9698
out[field_name] = []
9799
except Exception as exc: # pragma: no cover
@@ -101,15 +103,21 @@ def serialize_many_relationship(
101103

102104
def serialize_value_field(obj: models.Model, field: Any) -> Dict[Any, Any]:
103105
"""Serializes regular 'jsonable' field (Char, Int, etc.) of Django model instance"""
104-
# TODO: configurable sensitive fields
105-
if field.name in ["password"]:
106+
sensitive_list: List = [
107+
"password",
108+
]
109+
if hasattr(obj, "Meta"):
110+
sensitive_fields = getattr(obj.Meta, "sensitive_fields", None)
111+
if sensitive_fields:
112+
sensitive_list.extend(sensitive_fields)
113+
sensitive_list = list(set(sensitive_list))
114+
if field.name in sensitive_list:
106115
return {}
107116
return {field.name: getattr(obj, field.name)}
108117

109118

110119
def serialize_django_native_data(data: Any) -> Any:
111120
out = data
112-
# TODO: need to protect sensitive fields
113121
# Queryset
114122
if is_queryset(data):
115123
out = serialize_queryset(data)

easy/services/crud.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ async def del_obj(self, id: int) -> Any:
2929
async def add_obj(self, **payload: Any) -> Any:
3030
return await sync_to_async(self._crud_add_obj)(**payload)
3131

32-
async def filter_objs(self, **payload: Any) -> Any:
33-
return await sync_to_async(self._crud_filter)(**payload)
32+
# async def filter_objs(self, **payload: Any) -> Any:
33+
# return await sync_to_async(self._crud_filter)(**payload)
3434

3535
async def filter_exclude_objs(self, **payload: Any) -> Any:
3636
return await sync_to_async(self._crud_filter_exclude)(**payload)

tests/demo_app/controllers.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,39 @@ def __init__(self, service: EventService):
3131
class Meta:
3232
model = Event
3333
model_fields = "__all__"
34+
model_join = True
35+
36+
37+
@api_controller("unittest", permissions=[BaseApiPermission])
38+
class RecursiveAPIController(AutoGenCrudAPIController):
39+
"""
40+
For unit testings of no recursive configuration
41+
"""
42+
43+
class Meta:
44+
model = Event
45+
model_fields = "__all__"
46+
model_join = True
47+
model_recursive = True
48+
49+
50+
@api_controller("unittest", permissions=[BaseApiPermission])
51+
class AutoGenCrudNoJoinAPIController(CrudAPIController):
52+
"""
53+
For unit testings of the following auto generated APIs:
54+
get/create/patch/delete/filter/filter_exclude
55+
"""
56+
57+
def __init__(self, service: EventService):
58+
super().__init__(service)
59+
self.service = service
60+
61+
class Meta:
62+
model = Event
63+
model_fields = "__all__"
64+
model_join = False
65+
model_recursive = True
66+
sensitive_fields = ["password", "sensitive_info"]
3467

3568

3669
@api_controller("unittest", permissions=[BaseApiPermission])

tests/demo_app/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class Meta:
99

1010
class Category(TestBaseModel):
1111
title = models.CharField(max_length=100)
12+
status = models.PositiveSmallIntegerField(default=1, null=True)
1213

1314

1415
class Client(TestBaseModel):
@@ -20,6 +21,7 @@ class Client(TestBaseModel):
2021

2122
class Type(TestBaseModel):
2223
name = models.CharField(max_length=50, null=True)
24+
status = models.PositiveSmallIntegerField(default=1, null=True)
2325

2426

2527
class Event(TestBaseModel):
@@ -41,5 +43,7 @@ class Event(TestBaseModel):
4143

4244
type = models.ForeignKey(Type, on_delete=models.CASCADE, null=True)
4345

46+
sensitive_info = models.CharField(max_length=100, null=True)
47+
4448
def __str__(self):
4549
return self.title

tests/demo_app/test_async_auto_crud_apis.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
from tests.demo_app.controllers import (
99
AutoGenCrudAPIController,
10+
AutoGenCrudNoJoinAPIController,
1011
AutoGenCrudSomeFieldsAPIController,
1112
EventSchema,
13+
RecursiveAPIController,
1214
)
1315
from tests.demo_app.models import Category, Client, Event, Type
1416

@@ -56,6 +58,21 @@ async def test_crud_default_get_all(self, transactional_db, easy_api_client):
5658
event_schema = json.loads(EventSchema.from_orm(event).json())
5759
assert event_schema["start_date"] == data[0]["start_date"]
5860

61+
# Recursive = False
62+
client = easy_api_client(RecursiveAPIController)
63+
response = await client.get(
64+
"/", query=dict(maximum=100, filters=json.dumps(dict(id__gte=1)))
65+
)
66+
assert response.status_code == 200
67+
68+
data = response.json().get("data")
69+
assert data[0]["title"] == "AsyncAdminAPIEvent_get_all"
70+
assert data[0]["type"]["id"] == type.id
71+
assert data[0]["category"]["status"] == 1
72+
73+
# Back to AutoGenCrudAPIController
74+
client = easy_api_client(AutoGenCrudAPIController)
75+
5976
response = await client.get(
6077
"/",
6178
query=dict(
@@ -179,12 +196,17 @@ async def test_crud_default_patch(self, transactional_db, easy_api_client):
179196
name="Client F for Unit Testings", key="F"
180197
)
181198

199+
category = await sync_to_async(Category.objects.create)(
200+
title="Category for Unit Testings", status=2
201+
)
202+
182203
new_data = dict(
183204
id=event.pk,
184205
title=f"{object_data['title']}_patch",
185206
start_date=str((datetime.now() + timedelta(days=10)).date()),
186207
end_date=str((datetime.now() + timedelta(days=20)).date()),
187208
owner=[client_e.pk, client_f.pk],
209+
category=category.pk,
188210
)
189211

190212
response = await client.patch(
@@ -205,10 +227,27 @@ async def test_crud_default_patch(self, transactional_db, easy_api_client):
205227
f"/{event.pk}",
206228
)
207229
assert response.status_code == 200
208-
assert response.json().get("data")["title"] == "AsyncAdminAPIEvent_patch"
209-
assert response.json().get("data")["start_date"] == str(
210-
(datetime.now() + timedelta(days=10)).date()
211-
)
212-
assert response.json().get("data")["end_date"] == str(
213-
(datetime.now() + timedelta(days=20)).date()
230+
data = response.json().get("data")
231+
print("======> Auto join - data/ data owner /data lead_owner")
232+
print(data)
233+
assert len(data["owner"]) == 2
234+
assert len(data["lead_owner"]) == 0
235+
assert data["owner"][0]["name"] == "Client E for Unit Testings"
236+
assert data["owner"][1]["name"] == "Client F for Unit Testings"
237+
238+
assert data["title"] == "AsyncAdminAPIEvent_patch"
239+
assert data["start_date"] == str((datetime.now() + timedelta(days=10)).date())
240+
assert data["end_date"] == str((datetime.now() + timedelta(days=20)).date())
241+
242+
client = easy_api_client(AutoGenCrudNoJoinAPIController)
243+
244+
# No auto join
245+
response = await client.get(
246+
f"/{event.pk}",
214247
)
248+
assert response.status_code == 200
249+
print("======> data/ data owner /data lead_owner")
250+
data = response.json().get("data")
251+
print(data)
252+
print(data["owner"])
253+
assert data["owner"] == [8, 9]

0 commit comments

Comments
 (0)