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/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)
@@ -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())