django-flex 26.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of django-flex might be problematic. Click here for more details.
- django_flex/__init__.py +92 -0
- django_flex/conf.py +92 -0
- django_flex/decorators.py +182 -0
- django_flex/fields.py +234 -0
- django_flex/filters.py +207 -0
- django_flex/middleware.py +143 -0
- django_flex/permissions.py +365 -0
- django_flex/query.py +290 -0
- django_flex/response.py +212 -0
- django_flex/views.py +236 -0
- django_flex-26.1.0.dist-info/METADATA +300 -0
- django_flex-26.1.0.dist-info/RECORD +15 -0
- django_flex-26.1.0.dist-info/WHEEL +5 -0
- django_flex-26.1.0.dist-info/licenses/LICENSE +21 -0
- django_flex-26.1.0.dist-info/top_level.txt +1 -0
django_flex/query.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django-Flex Core Query Engine
|
|
3
|
+
|
|
4
|
+
The main query execution engine that ties together field parsing,
|
|
5
|
+
filter building, and permission checking.
|
|
6
|
+
|
|
7
|
+
Provides:
|
|
8
|
+
- FlexQuery class for OOP-style queries
|
|
9
|
+
- execute_query function for procedural usage
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from django.apps import apps
|
|
13
|
+
|
|
14
|
+
from django_flex.conf import flex_settings
|
|
15
|
+
from django_flex.fields import parse_fields, expand_fields, extract_relations
|
|
16
|
+
from django_flex.filters import build_q_object, extract_filter_keys
|
|
17
|
+
from django_flex.permissions import (
|
|
18
|
+
check_permission,
|
|
19
|
+
check_filter_permission,
|
|
20
|
+
check_order_permission,
|
|
21
|
+
)
|
|
22
|
+
from django_flex.response import FlexResponse, build_nested_response
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_model_by_name(model_name):
|
|
26
|
+
"""
|
|
27
|
+
Get Django model class by name (case-insensitive).
|
|
28
|
+
|
|
29
|
+
Searches all installed apps for a matching model.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
model_name: Model name to find (case-insensitive)
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Model class or None if not found
|
|
36
|
+
"""
|
|
37
|
+
for app_config in apps.get_app_configs():
|
|
38
|
+
for model in app_config.get_models():
|
|
39
|
+
if model.__name__.lower() == model_name.lower():
|
|
40
|
+
return model
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FlexQuery:
|
|
45
|
+
"""
|
|
46
|
+
Flexible query builder for Django models.
|
|
47
|
+
|
|
48
|
+
Provides a fluent interface for building and executing queries
|
|
49
|
+
with field selection, filtering, and pagination.
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
# Direct usage
|
|
53
|
+
result = FlexQuery(Entry).execute({
|
|
54
|
+
'fields': 'id, author.name, status',
|
|
55
|
+
'filters': {'status': 'confirmed'},
|
|
56
|
+
'limit': 20,
|
|
57
|
+
}, user=request.user)
|
|
58
|
+
|
|
59
|
+
# With model name
|
|
60
|
+
result = FlexQuery('entry').execute({...}, user=request.user)
|
|
61
|
+
|
|
62
|
+
# Chained configuration
|
|
63
|
+
query = FlexQuery(Entry)
|
|
64
|
+
query.set_user(request.user)
|
|
65
|
+
query.set_permissions(custom_permissions)
|
|
66
|
+
result = query.execute({...})
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, model):
|
|
70
|
+
"""
|
|
71
|
+
Initialize a FlexQuery.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
model: Django model class or model name string
|
|
75
|
+
"""
|
|
76
|
+
if isinstance(model, str):
|
|
77
|
+
self.model_name = model.lower()
|
|
78
|
+
self.model = get_model_by_name(model)
|
|
79
|
+
else:
|
|
80
|
+
self.model = model
|
|
81
|
+
self.model_name = model.__name__.lower()
|
|
82
|
+
|
|
83
|
+
self.user = None
|
|
84
|
+
self.permissions = None
|
|
85
|
+
|
|
86
|
+
def set_user(self, user):
|
|
87
|
+
"""Set the user for permission checking."""
|
|
88
|
+
self.user = user
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
def set_permissions(self, permissions):
|
|
92
|
+
"""Set custom permissions configuration."""
|
|
93
|
+
self.permissions = permissions
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def execute(self, query_spec, user=None, action=None):
|
|
97
|
+
"""
|
|
98
|
+
Execute a query with the given specification.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
query_spec: Dict with query parameters (fields, filters, etc.)
|
|
102
|
+
user: Optional user (overrides set_user)
|
|
103
|
+
action: Optional action override (defaults to 'get' for id, 'query' for no id)
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
FlexResponse with query results
|
|
107
|
+
"""
|
|
108
|
+
user = user or self.user
|
|
109
|
+
permissions = self.permissions
|
|
110
|
+
|
|
111
|
+
if self.model is None:
|
|
112
|
+
return FlexResponse.error("MODEL_NOT_FOUND")
|
|
113
|
+
|
|
114
|
+
# Determine action
|
|
115
|
+
if action:
|
|
116
|
+
pass
|
|
117
|
+
elif "id" in query_spec:
|
|
118
|
+
action = "get"
|
|
119
|
+
else:
|
|
120
|
+
action = "query"
|
|
121
|
+
|
|
122
|
+
if action == "get":
|
|
123
|
+
return self._execute_get(query_spec, user, permissions)
|
|
124
|
+
else:
|
|
125
|
+
return self._execute_query(query_spec, user, permissions)
|
|
126
|
+
|
|
127
|
+
def _execute_get(self, query_spec, user, permissions):
|
|
128
|
+
"""Execute single object retrieval."""
|
|
129
|
+
# Parse fields
|
|
130
|
+
field_specs = parse_fields(query_spec.get("fields", "*"))
|
|
131
|
+
expanded_fields = expand_fields(self.model, field_specs, permissions=permissions)
|
|
132
|
+
|
|
133
|
+
# Check permissions
|
|
134
|
+
try:
|
|
135
|
+
if user:
|
|
136
|
+
row_filter, validated_fields = check_permission(user, self.model_name, "get", expanded_fields, permissions)
|
|
137
|
+
else:
|
|
138
|
+
row_filter = None
|
|
139
|
+
validated_fields = expanded_fields
|
|
140
|
+
except PermissionError as e:
|
|
141
|
+
return FlexResponse.error("PERMISSION_DENIED", str(e))
|
|
142
|
+
|
|
143
|
+
# Extract relations for select_related (avoid N+1)
|
|
144
|
+
relations = extract_relations(validated_fields)
|
|
145
|
+
|
|
146
|
+
# Build queryset
|
|
147
|
+
try:
|
|
148
|
+
queryset = self.model.objects.all()
|
|
149
|
+
if row_filter:
|
|
150
|
+
queryset = queryset.filter(row_filter)
|
|
151
|
+
if relations:
|
|
152
|
+
queryset = queryset.select_related(*relations)
|
|
153
|
+
obj = queryset.get(pk=query_spec["id"])
|
|
154
|
+
except self.model.DoesNotExist:
|
|
155
|
+
return FlexResponse.error("NOT_FOUND")
|
|
156
|
+
|
|
157
|
+
# Build response
|
|
158
|
+
response_data = build_nested_response(obj, validated_fields)
|
|
159
|
+
return FlexResponse.ok(**response_data)
|
|
160
|
+
|
|
161
|
+
def _execute_query(self, query_spec, user, permissions):
|
|
162
|
+
"""Execute paginated query retrieval."""
|
|
163
|
+
# Parse fields
|
|
164
|
+
field_specs = parse_fields(query_spec.get("fields", "*"))
|
|
165
|
+
expanded_fields = expand_fields(self.model, field_specs, permissions=permissions)
|
|
166
|
+
|
|
167
|
+
# Check permissions
|
|
168
|
+
try:
|
|
169
|
+
if user:
|
|
170
|
+
row_filter, validated_fields = check_permission(user, self.model_name, "query", expanded_fields, permissions)
|
|
171
|
+
else:
|
|
172
|
+
row_filter = None
|
|
173
|
+
validated_fields = expanded_fields
|
|
174
|
+
except PermissionError as e:
|
|
175
|
+
return FlexResponse.error("PERMISSION_DENIED", str(e))
|
|
176
|
+
|
|
177
|
+
# Validate filter keys
|
|
178
|
+
filters = query_spec.get("filters", {})
|
|
179
|
+
try:
|
|
180
|
+
if user:
|
|
181
|
+
filter_keys = extract_filter_keys(filters)
|
|
182
|
+
check_filter_permission(user, self.model_name, filter_keys, permissions)
|
|
183
|
+
except PermissionError as e:
|
|
184
|
+
return FlexResponse.error("PERMISSION_DENIED", str(e))
|
|
185
|
+
|
|
186
|
+
# Validate order_by
|
|
187
|
+
order_by = query_spec.get("order_by")
|
|
188
|
+
try:
|
|
189
|
+
if user:
|
|
190
|
+
check_order_permission(user, self.model_name, order_by, permissions)
|
|
191
|
+
except PermissionError as e:
|
|
192
|
+
return FlexResponse.error("PERMISSION_DENIED", str(e))
|
|
193
|
+
|
|
194
|
+
# Extract relations for select_related
|
|
195
|
+
relations = extract_relations(validated_fields)
|
|
196
|
+
|
|
197
|
+
# Build query
|
|
198
|
+
user_filter = build_q_object(filters)
|
|
199
|
+
queryset = self.model.objects.all()
|
|
200
|
+
if row_filter:
|
|
201
|
+
queryset = queryset.filter(row_filter & user_filter)
|
|
202
|
+
else:
|
|
203
|
+
queryset = queryset.filter(user_filter)
|
|
204
|
+
|
|
205
|
+
# Apply select_related
|
|
206
|
+
if relations:
|
|
207
|
+
queryset = queryset.select_related(*relations)
|
|
208
|
+
|
|
209
|
+
# Ordering
|
|
210
|
+
if order_by:
|
|
211
|
+
order_by_django = order_by.replace(".", "__")
|
|
212
|
+
queryset = queryset.order_by(order_by_django)
|
|
213
|
+
|
|
214
|
+
# Pagination with limit clamping
|
|
215
|
+
requested_limit = query_spec.get("limit", flex_settings.DEFAULT_LIMIT)
|
|
216
|
+
max_limit = flex_settings.MAX_LIMIT
|
|
217
|
+
limit = min(requested_limit, max_limit)
|
|
218
|
+
limit_clamped = requested_limit > max_limit
|
|
219
|
+
offset = query_spec.get("offset", 0)
|
|
220
|
+
|
|
221
|
+
# Fetch limit + 1 to check for more results (avoids expensive COUNT query)
|
|
222
|
+
paginated = list(queryset[offset : offset + limit + 1])
|
|
223
|
+
|
|
224
|
+
# Determine if there are more results
|
|
225
|
+
has_more = len(paginated) > limit
|
|
226
|
+
if has_more:
|
|
227
|
+
paginated = paginated[:limit] # Drop the extra row
|
|
228
|
+
|
|
229
|
+
# Build results dict keyed by id
|
|
230
|
+
results = {}
|
|
231
|
+
for obj in paginated:
|
|
232
|
+
obj_data = build_nested_response(obj, validated_fields)
|
|
233
|
+
results[str(obj.pk)] = obj_data
|
|
234
|
+
|
|
235
|
+
# Build pagination info
|
|
236
|
+
pagination = {
|
|
237
|
+
"offset": offset,
|
|
238
|
+
"limit": limit,
|
|
239
|
+
"has_more": has_more,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# Build next cursor if more results exist
|
|
243
|
+
if has_more:
|
|
244
|
+
pagination["next"] = {
|
|
245
|
+
"fields": query_spec.get("fields", "*"),
|
|
246
|
+
"filters": filters,
|
|
247
|
+
"limit": limit,
|
|
248
|
+
"offset": offset + limit,
|
|
249
|
+
}
|
|
250
|
+
if order_by:
|
|
251
|
+
pagination["next"]["order_by"] = order_by
|
|
252
|
+
|
|
253
|
+
# Return warning if limit was clamped
|
|
254
|
+
if limit_clamped:
|
|
255
|
+
return FlexResponse.warning_response(
|
|
256
|
+
"LIMIT_CLAMPED",
|
|
257
|
+
results=results,
|
|
258
|
+
pagination=pagination,
|
|
259
|
+
requested_limit=requested_limit,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return FlexResponse.ok_query(results=results, pagination=pagination)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def execute_query(model, query_spec, user=None, permissions=None):
|
|
266
|
+
"""
|
|
267
|
+
Execute a flexible query on a model.
|
|
268
|
+
|
|
269
|
+
Convenience function that wraps FlexQuery.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
model: Django model class or model name string
|
|
273
|
+
query_spec: Dict with query parameters
|
|
274
|
+
user: Optional user for permission checking
|
|
275
|
+
permissions: Optional custom permissions
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
FlexResponse with query results
|
|
279
|
+
|
|
280
|
+
Example:
|
|
281
|
+
result = execute_query(Entry, {
|
|
282
|
+
'fields': 'id, author.name',
|
|
283
|
+
'filters': {'status': 'confirmed'},
|
|
284
|
+
'limit': 20,
|
|
285
|
+
}, user=request.user)
|
|
286
|
+
"""
|
|
287
|
+
query = FlexQuery(model)
|
|
288
|
+
if permissions:
|
|
289
|
+
query.set_permissions(permissions)
|
|
290
|
+
return query.execute(query_spec, user=user)
|
django_flex/response.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django-Flex Response Utilities
|
|
3
|
+
|
|
4
|
+
Handles response building and serialization for flexible queries.
|
|
5
|
+
|
|
6
|
+
Features:
|
|
7
|
+
- Nested response construction from field paths
|
|
8
|
+
- Automatic serialization of common types
|
|
9
|
+
- Response code management
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from django_flex.conf import flex_settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_field_value(obj, field_path):
|
|
16
|
+
"""
|
|
17
|
+
Get value from object following dot notation path.
|
|
18
|
+
|
|
19
|
+
Safely traverses nested objects and returns None if any
|
|
20
|
+
part of the path is missing.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
obj: Model instance
|
|
24
|
+
field_path: Dot-notation path (e.g., "author.name")
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Value at the path, or None if not found
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
>>> get_field_value(entry, "status")
|
|
31
|
+
"confirmed"
|
|
32
|
+
>>> get_field_value(entry, "author.name")
|
|
33
|
+
"Aisha Khan"
|
|
34
|
+
>>> get_field_value(entry, "author.blog.city")
|
|
35
|
+
"Sydney"
|
|
36
|
+
"""
|
|
37
|
+
parts = field_path.split(".")
|
|
38
|
+
value = obj
|
|
39
|
+
|
|
40
|
+
for part in parts:
|
|
41
|
+
if value is None:
|
|
42
|
+
return None
|
|
43
|
+
value = getattr(value, part, None)
|
|
44
|
+
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def serialize_value(value):
|
|
49
|
+
"""
|
|
50
|
+
Serialize a value for JSON response.
|
|
51
|
+
|
|
52
|
+
Handles common Django types:
|
|
53
|
+
- DateField, DateTimeField -> ISO format string
|
|
54
|
+
- ForeignKey -> primary key string
|
|
55
|
+
- UUID -> string
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
value: Value to serialize
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
JSON-serializable value
|
|
62
|
+
"""
|
|
63
|
+
if value is None:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
# DateTime/Date
|
|
67
|
+
if hasattr(value, "isoformat"):
|
|
68
|
+
return value.isoformat()
|
|
69
|
+
|
|
70
|
+
# UUID
|
|
71
|
+
if hasattr(value, "hex"):
|
|
72
|
+
return str(value)
|
|
73
|
+
|
|
74
|
+
# Related object not in fields list - just use pk
|
|
75
|
+
if hasattr(value, "pk"):
|
|
76
|
+
return str(value.pk)
|
|
77
|
+
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_nested_response(obj, field_paths):
|
|
82
|
+
"""
|
|
83
|
+
Build nested dict response from object and field paths.
|
|
84
|
+
|
|
85
|
+
Constructs a nested dictionary structure from a flat list of
|
|
86
|
+
dot-notation field paths.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
obj: Model instance
|
|
90
|
+
field_paths: List of field paths (e.g., ["id", "author.name"])
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Nested dictionary with field values
|
|
94
|
+
|
|
95
|
+
Example:
|
|
96
|
+
>>> build_nested_response(entry, ["id", "status", "author.name", "author.email"])
|
|
97
|
+
{
|
|
98
|
+
"id": "1",
|
|
99
|
+
"status": "confirmed",
|
|
100
|
+
"author": {
|
|
101
|
+
"name": "Aisha Khan",
|
|
102
|
+
"email": "aisha@example.com"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
"""
|
|
106
|
+
if obj is None:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
result = {}
|
|
110
|
+
|
|
111
|
+
for field_path in field_paths:
|
|
112
|
+
parts = field_path.split(".")
|
|
113
|
+
value = get_field_value(obj, field_path)
|
|
114
|
+
value = serialize_value(value)
|
|
115
|
+
|
|
116
|
+
# Build nested structure
|
|
117
|
+
current = result
|
|
118
|
+
for i, part in enumerate(parts[:-1]):
|
|
119
|
+
if part not in current:
|
|
120
|
+
current[part] = {}
|
|
121
|
+
current = current[part]
|
|
122
|
+
|
|
123
|
+
current[parts[-1]] = value
|
|
124
|
+
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class FlexResponse:
|
|
129
|
+
"""
|
|
130
|
+
Response builder for django-flex queries.
|
|
131
|
+
|
|
132
|
+
Provides a consistent response format with success status,
|
|
133
|
+
response code, and data.
|
|
134
|
+
|
|
135
|
+
Example:
|
|
136
|
+
>>> response = FlexResponse(code="OK", data={"id": 1, "name": "Test"})
|
|
137
|
+
>>> response.to_dict()
|
|
138
|
+
{"success": True, "code": "FLEX_OK", "id": 1, "name": "Test"}
|
|
139
|
+
|
|
140
|
+
>>> response = FlexResponse.error("NOT_FOUND")
|
|
141
|
+
>>> response.to_dict()
|
|
142
|
+
{"success": False, "code": "FLEX_NOT_FOUND"}
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(self, code="OK", warning=False, error_message=None, **data):
|
|
146
|
+
"""
|
|
147
|
+
Initialize a FlexResponse.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
code: Response code key (e.g., "OK", "NOT_FOUND")
|
|
151
|
+
warning: Whether this is a warning response
|
|
152
|
+
error_message: Optional error message for error responses
|
|
153
|
+
**data: Additional data to include in response
|
|
154
|
+
"""
|
|
155
|
+
self.code = code
|
|
156
|
+
self.warning = warning
|
|
157
|
+
self.error_message = error_message
|
|
158
|
+
self.data = data
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def success(self):
|
|
162
|
+
"""Whether the response indicates success."""
|
|
163
|
+
return self.code in ("OK", "OK_QUERY", "LIMIT_CLAMPED")
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def ok(cls, **data):
|
|
167
|
+
"""Create a successful response."""
|
|
168
|
+
return cls(code="OK", **data)
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def ok_query(cls, results, pagination=None, **data):
|
|
172
|
+
"""Create a successful query response."""
|
|
173
|
+
response_data = {"results": results}
|
|
174
|
+
if pagination:
|
|
175
|
+
response_data["pagination"] = pagination
|
|
176
|
+
response_data.update(data)
|
|
177
|
+
return cls(code="OK_QUERY", **response_data)
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def error(cls, code, message=None):
|
|
181
|
+
"""Create an error response."""
|
|
182
|
+
return cls(code=code, error_message=message)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def warning_response(cls, code, **data):
|
|
186
|
+
"""Create a warning response (success with warning flag)."""
|
|
187
|
+
return cls(code=code, warning=True, **data)
|
|
188
|
+
|
|
189
|
+
def to_dict(self):
|
|
190
|
+
"""Convert response to dictionary for JSON serialization."""
|
|
191
|
+
codes = flex_settings.RESPONSE_CODES
|
|
192
|
+
|
|
193
|
+
result = {
|
|
194
|
+
"success": self.success,
|
|
195
|
+
"code": codes.get(self.code, self.code),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if self.warning:
|
|
199
|
+
result["warning"] = True
|
|
200
|
+
|
|
201
|
+
if self.error_message:
|
|
202
|
+
result["error"] = self.error_message
|
|
203
|
+
|
|
204
|
+
result.update(self.data)
|
|
205
|
+
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
def to_json_response(self):
|
|
209
|
+
"""Convert to Django JsonResponse."""
|
|
210
|
+
from django.http import JsonResponse
|
|
211
|
+
|
|
212
|
+
return JsonResponse(self.to_dict())
|