This repository has been archived by the owner on Jun 24, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 16
/
crud_view.py
274 lines (217 loc) · 9.19 KB
/
crud_view.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
from functools import wraps
from logging import getLogger
from mongosql import CrudViewMixin, StrictCrudHelper, StrictCrudHelperSettingsDict, saves_relations
from . import models
from flask import request, g, jsonify
from flask_jsontools import jsonapi, RestfulView
logger = getLogger(__name__)
def passthrough_decorator(f):
""" A no-op decorator.
It's only purpose is to see whether @saves_relations() works even when decorated with something else.
"""
@wraps(f)
def wrapper(*args, **kwargs):
return f(*args, **kwargs)
return wrapper
class RestfulModelView(RestfulView, CrudViewMixin):
""" Base view class for all other views """
crudhelper = None
# RestfulView needs that for routing
primary_key = None
decorators = (jsonapi,)
# Every response will have either { article: ... } or { articles: [...] }
# Stick to the DRY principle: store the key name once
entity_name = None
entity_names = None
# Implement the method that fetches the Query Object for this request
def _get_query_object(self):
""" Get Query Object from request
:rtype: dict | None
"""
return (request.get_json() or {}).get('query', None)
# CrudViewMixin demands: needs to be able to get a session so that it can run a query
def _get_db_session(self):
""" Get database Session
:rtype: sqlalchemy.orm.Session
"""
return g.db
# This is our method: it plucks an instance using the current projection
# This is just convenience: if the user has requested
def _return_instance(self, instance):
""" Modify a returned instance """
return self._mongoquery.pluck_instance(instance)
def _save_hook(self, new: models.Article, prev: models.Article = None):
# There's one special case for a failure: title='z'.
# This is how unit-tests can test exceptions
if new.title == 'z':
# Simulate a bug
raise RuntimeError(
'This method inexplicably fails when title="z"'
)
super()._save_hook(new, prev)
# region CRUD methods
def list(self):
""" List method: GET /article/ """
# List results
results = self._method_list()
# Format response
# NOTE: can't return map(), because it's not JSON serializable
return {self.entity_names: results}
def _method_list_result__groups(self, dicts):
""" Format the result from GET /article/ when the result is a list of dicts (GROUP BY) """
return list(dicts) # our JSON serializer does not like generators. Have to make it into a list
def _method_list_result__entities(self, entities):
""" Format the result from GET /article/ when the result is a list of sqlalchemy entities """
# Pluck results: apply projection to the result set
# This is just our good manners: if the client has requested certain fields, we return only those they requested.
# Even if our code loads some more columns (and it does!), the client will always get what they requested.
return list(map(self._return_instance, entities))
def get(self, id):
item = self._method_get(id=id)
return {self.entity_name: self._return_instance(item)}
def create(self):
# Trying to save many objects at once?
if self.entity_names in request.get_json():
return self.save_many()
# Saving only one object
input_entity_dict = request.get_json()[self.entity_name]
instance = self._method_create(input_entity_dict)
ssn = self._get_db_session()
ssn.add(instance)
ssn.commit()
return {self.entity_name: self._return_instance(instance)}
def save_many(self):
# Get the input
input_json = request.get_json()
entity_dicts = input_json[self.entity_names]
# Process
results = self._method_create_or_update_many(entity_dicts)
# Save
ssn = self._get_db_session()
ssn.add_all(res.instance for res in results if res.instance is not None)
ssn.commit()
# Log every error
for res in results:
if res.error:
logger.exception(str(res.error), exc_info=res.error)
# Results
return {
# Entities
self.entity_names: [
# Each one goes through self._return_instance()
self._return_instance(res.instance) if res.instance else None
for res in results
],
# Errors
'errors': {
res.ordinal_number: str(res.error)
for res in results
if res.error
},
}
def update(self, id):
input_entity_dict = request.get_json()[self.entity_name]
instance = self._method_update(input_entity_dict, id=id)
ssn = self._get_db_session()
ssn.add(instance)
ssn.commit()
return {self.entity_name: self._return_instance(instance)}
def delete(self, id):
instance = self._method_delete(id=id)
ssn = self._get_db_session()
ssn.delete(instance)
ssn.commit()
return {self.entity_name: self._return_instance(instance)}
# endregion
class ArticleView(RestfulModelView):
""" Full-featured CRUD view """
# First, configure a CrudHelper
crudhelper = StrictCrudHelper(
# The model to work with
models.Article,
**StrictCrudHelperSettingsDict(
# Read-only fields, as a callable (just because)
ro_fields=lambda: ('id', 'uid',),
legacy_fields=('removed_column',),
# MongoQuery settings
aggregate_columns=('id', 'data',), # have to explicitly enable aggregation for columns
query_defaults=dict(
sort=('id-',),
),
writable_properties=True,
max_items=2,
# Related entities configuration
allowed_relations=('user', 'comments'),
related={
'user': dict(
# Exclude @property by default
default_exclude=('user_calculated',),
allowed_relations=('comments',),
related={
'comments': dict(
# Exclude @property by default
default_exclude=('comment_calc',),
# No further joins
join_enabled=False,
)
}
),
'comments': dict(
# Exclude @property by default
default_exclude=('comment_calc',),
# No further joins
join_enabled=False,
),
},
)
)
# ensure_loaded: always load these columns and relationships
# This is necessary in case some custom code relies on it
ensure_loaded = ('data', 'comments') # that's a weird requirement, but since the user is supposed to use projections, it will be excluded
primary_key = ('id',)
decorators = (jsonapi,)
entity_name = 'article'
entity_names = 'articles'
def _method_create(self, entity_dict: dict) -> object:
instance = super()._method_create(entity_dict)
instance.uid = 3 # Manually set ro field value, because the client can't
return instance
# Our completely custom stuff
@passthrough_decorator # no-op to demonstrate that it still works
@saves_relations('comments')
def save_comments(self, new, prev=None, comments=None):
# Just store it in the class for unit-test to find it
self.__class__._save_comments__args = dict(new=new, prev=prev, comments=comments)
@passthrough_decorator # no-op to demonstrate that it still works
@saves_relations('user', 'comments')
def save_relations(self, new, prev=None, user=None, comments=None):
# Just store it in the class for unit-test to find it
self.__class__._save_relations__args = dict(new=new, prev=prev, user=user, comments=comments)
@saves_relations('removed_column')
def save_removed_column(self, new, prev=None, removed_column=None):
# Store
self.__class__._save_removed_column = dict(removed_column=removed_column)
_save_comments__args = None
_save_relations__args = None
_save_removed_column = None
class GirlWatcherView(RestfulModelView):
crudhelper = StrictCrudHelper(
models.GirlWatcher,
**StrictCrudHelperSettingsDict(
# Read-only fields, as a callable (just because)
ro_fields=('id', 'favorite_id',),
allowed_relations=('good', 'best')
)
)
primary_key = ('id',)
decorators = (jsonapi,)
entity_name = 'girlwatcher'
entity_names = 'girlwatchers'
def _return_instance(self, instance):
instance = super()._return_instance(instance)
# TypeError: Object of type _AssociationList is not JSON serializable
for k in ('good_names', 'best_names'):
if k in instance:
# Convert this _AssociationList() object into a real list
instance[k] = list(instance[k])
return instance