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/filters.py ADDED
@@ -0,0 +1,207 @@
1
+ """
2
+ Django-Flex Filter Utilities
3
+
4
+ Handles filter parsing and Q object construction for flexible queries.
5
+
6
+ Supports:
7
+ - Simple equality filters
8
+ - Django ORM operators (lt, gte, icontains, etc.)
9
+ - Composable filters (and, or, not)
10
+ - Nested relation filtering
11
+ """
12
+
13
+ from django.db.models import Q
14
+
15
+
16
+ # Django ORM operators supported in filters
17
+ OPERATORS = {
18
+ # Comparisons
19
+ "lt",
20
+ "lte",
21
+ "gt",
22
+ "gte",
23
+ "exact",
24
+ "iexact",
25
+ "in",
26
+ "isnull",
27
+ "range",
28
+ # Text
29
+ "contains",
30
+ "icontains",
31
+ "startswith",
32
+ "istartswith",
33
+ "endswith",
34
+ "iendswith",
35
+ "regex",
36
+ "iregex",
37
+ # Date/Time
38
+ "date",
39
+ "year",
40
+ "month",
41
+ "day",
42
+ "week_day",
43
+ "hour",
44
+ "minute",
45
+ "second",
46
+ }
47
+
48
+
49
+ def parse_filter_key(key):
50
+ """
51
+ Parse a filter key into (field_path, operator).
52
+
53
+ Converts dot notation to Django's double underscore format and
54
+ extracts any operator suffix.
55
+
56
+ Args:
57
+ key: Filter key string (e.g., "author.name.icontains")
58
+
59
+ Returns:
60
+ Tuple of (field_path, operator) where operator may be None
61
+
62
+ Examples:
63
+ >>> parse_filter_key("status")
64
+ ('status', None)
65
+ >>> parse_filter_key("author.name")
66
+ ('author__name', None)
67
+ >>> parse_filter_key("author.name.icontains")
68
+ ('author__name', 'icontains')
69
+ >>> parse_filter_key("author.address.zip.lt")
70
+ ('author__address__zip', 'lt')
71
+ """
72
+ parts = key.split(".")
73
+
74
+ # Check if last part is an operator
75
+ if parts[-1] in OPERATORS:
76
+ operator = parts[-1]
77
+ field_parts = parts[:-1]
78
+ else:
79
+ operator = None
80
+ field_parts = parts
81
+
82
+ # Convert dot notation to Django's double underscore
83
+ field_path = "__".join(field_parts)
84
+
85
+ return field_path, operator
86
+
87
+
88
+ def build_q_object(filters):
89
+ """
90
+ Build Django Q object from filter specification.
91
+
92
+ Supports:
93
+ - Simple equality: {"status": "confirmed"}
94
+ - Operators: {"status.in": ["confirmed", "completed"]}
95
+ - Composable: {"or": {...}, "and": {...}, "not": {...}}
96
+ - Mixed combinations of the above
97
+
98
+ Args:
99
+ filters: Dict of filter specifications
100
+
101
+ Returns:
102
+ Django Q object representing the filter
103
+
104
+ Examples:
105
+ >>> build_q_object({"status": "confirmed"})
106
+ <Q: (AND: ('status', 'confirmed'))>
107
+
108
+ >>> build_q_object({"price.gte": 100, "price.lte": 500})
109
+ <Q: (AND: ('price__gte', 100), ('price__lte', 500))>
110
+
111
+ >>> build_q_object({"or": {"status": "pending", "status": "confirmed"}})
112
+ <Q: (OR: ('status', 'pending'), ('status', 'confirmed'))>
113
+ """
114
+ if not filters:
115
+ return Q()
116
+
117
+ q_objects = []
118
+
119
+ for key, value in filters.items():
120
+ if key == "or":
121
+ # OR composition
122
+ if isinstance(value, dict):
123
+ sub_q = Q()
124
+ for sub_key, sub_value in value.items():
125
+ field_path, operator = parse_filter_key(sub_key)
126
+ if operator:
127
+ sub_q |= Q(**{f"{field_path}__{operator}": sub_value})
128
+ else:
129
+ sub_q |= Q(**{field_path: sub_value})
130
+ q_objects.append(sub_q)
131
+ elif isinstance(value, list):
132
+ # OR as list of conditions
133
+ sub_q = Q()
134
+ for item in value:
135
+ sub_q |= build_q_object(item)
136
+ q_objects.append(sub_q)
137
+ elif key == "and":
138
+ # AND composition (explicit)
139
+ if isinstance(value, dict):
140
+ sub_q = build_q_object(value)
141
+ q_objects.append(sub_q)
142
+ elif isinstance(value, list):
143
+ sub_q = Q()
144
+ for item in value:
145
+ sub_q &= build_q_object(item)
146
+ q_objects.append(sub_q)
147
+ elif key == "not":
148
+ # NOT composition
149
+ sub_q = ~build_q_object(value)
150
+ q_objects.append(sub_q)
151
+ else:
152
+ # Regular field filter
153
+ field_path, operator = parse_filter_key(key)
154
+ if operator:
155
+ q_objects.append(Q(**{f"{field_path}__{operator}": value}))
156
+ else:
157
+ q_objects.append(Q(**{field_path: value}))
158
+
159
+ # Combine all with AND (default)
160
+ result = Q()
161
+ for q in q_objects:
162
+ result &= q
163
+
164
+ return result
165
+
166
+
167
+ def extract_filter_keys(filters, keys=None):
168
+ """
169
+ Recursively extract all filter keys from a filter specification.
170
+
171
+ Used for permission validation to ensure all filter keys are allowed.
172
+
173
+ Args:
174
+ filters: Dict of filter specification
175
+ keys: Running list of keys (internal use)
176
+
177
+ Returns:
178
+ List of filter keys (e.g., ["name", "status.in", "blog.name.icontains"])
179
+
180
+ Examples:
181
+ >>> extract_filter_keys({"name": "Test", "status": "active"})
182
+ ['name', 'status']
183
+ >>> extract_filter_keys({"or": {"name": "A", "status": "B"}})
184
+ ['name', 'status']
185
+ """
186
+ if keys is None:
187
+ keys = []
188
+
189
+ if not filters:
190
+ return keys
191
+
192
+ for key, value in filters.items():
193
+ if key in ("or", "and"):
194
+ # Handle dict or list of sub-filters
195
+ if isinstance(value, dict):
196
+ extract_filter_keys(value, keys)
197
+ elif isinstance(value, list):
198
+ for item in value:
199
+ extract_filter_keys(item, keys)
200
+ elif key == "not":
201
+ # NOT composition
202
+ extract_filter_keys(value, keys)
203
+ else:
204
+ # Regular filter key
205
+ keys.append(key)
206
+
207
+ return keys
@@ -0,0 +1,143 @@
1
+ """
2
+ Django-Flex Middleware
3
+
4
+ Optional middleware for handling flexible queries through a
5
+ centralized endpoint.
6
+
7
+ Features:
8
+ - Single endpoint for all flex queries
9
+ - Automatic model routing
10
+ - Request/response logging (optional)
11
+ """
12
+
13
+ import json
14
+ import logging
15
+
16
+ from django.http import JsonResponse
17
+
18
+ from django_flex.query import FlexQuery, get_model_by_name
19
+ from django_flex.response import FlexResponse
20
+ from django_flex.conf import flex_settings
21
+
22
+
23
+ logger = logging.getLogger("django_flex")
24
+
25
+
26
+ class FlexQueryMiddleware:
27
+ """
28
+ Middleware for handling flexible queries through a single endpoint.
29
+
30
+ This middleware intercepts requests to a configured URL path and
31
+ handles them as flex queries.
32
+
33
+ Setup:
34
+ # settings.py
35
+ MIDDLEWARE = [
36
+ ...
37
+ 'django_flex.middleware.FlexQueryMiddleware',
38
+ ]
39
+
40
+ DJANGO_FLEX = {
41
+ 'MIDDLEWARE_PATH': '/api/flex/',
42
+ 'PERMISSIONS': {...},
43
+ }
44
+
45
+ Usage:
46
+ # Client sends POST to /api/flex/
47
+ {
48
+ "_model": "entry",
49
+ "_action": "query",
50
+ "fields": "id, author.name",
51
+ "filters": {"status": "confirmed"},
52
+ "limit": 20
53
+ }
54
+ """
55
+
56
+ def __init__(self, get_response):
57
+ self.get_response = get_response
58
+ self.path = getattr(flex_settings, "MIDDLEWARE_PATH", "/api/flex/")
59
+
60
+ def __call__(self, request):
61
+ # Check if this is a flex query request
62
+ if request.path == self.path and request.method == "POST":
63
+ return self.handle_flex_query(request)
64
+
65
+ return self.get_response(request)
66
+
67
+ def handle_flex_query(self, request):
68
+ """Handle a flex query request."""
69
+ # Check authentication if required
70
+ if flex_settings.REQUIRE_AUTHENTICATION:
71
+ if not hasattr(request, "user") or not request.user.is_authenticated:
72
+ return JsonResponse(
73
+ FlexResponse.error("PERMISSION_DENIED", "Authentication required").to_dict(),
74
+ status=401,
75
+ )
76
+
77
+ # Parse request body
78
+ try:
79
+ body = json.loads(request.body)
80
+ except json.JSONDecodeError:
81
+ return JsonResponse(
82
+ FlexResponse.error("INVALID_FILTER", "Invalid JSON body").to_dict(),
83
+ status=400,
84
+ )
85
+
86
+ # Extract model and action
87
+ model_name = body.get("_model")
88
+ action = body.get("_action", "query")
89
+
90
+ if not model_name:
91
+ return JsonResponse(
92
+ FlexResponse.error("MODEL_NOT_FOUND", "Missing '_model' in request").to_dict(),
93
+ status=400,
94
+ )
95
+
96
+ # Normalize action (legacy support)
97
+ if action == "get+" or action == "list":
98
+ action = "query"
99
+
100
+ # Get model
101
+ model = get_model_by_name(model_name)
102
+ if model is None:
103
+ return JsonResponse(
104
+ FlexResponse.error("MODEL_NOT_FOUND", f"Model '{model_name}' not found").to_dict(),
105
+ status=404,
106
+ )
107
+
108
+ # Build query spec (remove internal keys)
109
+ query_spec = {k: v for k, v in body.items() if not k.startswith("_")}
110
+
111
+ # Add id if present in body
112
+ if "id" in body:
113
+ query_spec["id"] = body["id"]
114
+
115
+ # Log query if auditing enabled
116
+ if flex_settings.AUDIT_QUERIES:
117
+ user = getattr(request, "user", None)
118
+ logger.info(
119
+ "flex_query",
120
+ extra={
121
+ "user": str(user) if user else "anonymous",
122
+ "model": model_name,
123
+ "action": action,
124
+ "query": query_spec,
125
+ },
126
+ )
127
+
128
+ # Execute query
129
+ user = request.user if hasattr(request, "user") else None
130
+ query = FlexQuery(model)
131
+ result = query.execute(query_spec, user=user, action=action)
132
+
133
+ # Determine HTTP status
134
+ if result.success:
135
+ status = 200
136
+ elif result.code == "NOT_FOUND":
137
+ status = 404
138
+ elif result.code == "PERMISSION_DENIED":
139
+ status = 403
140
+ else:
141
+ status = 400
142
+
143
+ return JsonResponse(result.to_dict(), status=status)
@@ -0,0 +1,365 @@
1
+ """
2
+ Django-Flex Permission System
3
+
4
+ Provides row-level, field-level, and operation-level access control
5
+ following the Principle of Least Privilege (deny by default, explicitly grant).
6
+
7
+ Integrates with Django's built-in auth system:
8
+ - User.groups for role-based access
9
+ - User.is_superuser for admin bypass
10
+ - User.is_staff for staff-level access
11
+
12
+ Features:
13
+ - Row-level: Which rows can the user see (Q filters)
14
+ - Field-level: Which fields can the user access (including relations)
15
+ - Operation-level: Which actions can the user perform (get, query, create, update, delete)
16
+ - Filter-level: Which fields can the user filter on
17
+ - Order-level: Which fields can the user order by
18
+
19
+ Usage:
20
+ from django_flex import check_permission
21
+
22
+ row_filter, allowed_fields = check_permission(
23
+ user, "entry", "query", requested_fields
24
+ )
25
+ queryset = Entry.objects.filter(row_filter)
26
+ """
27
+
28
+ from django.db.models import Q
29
+
30
+ from django_flex.conf import flex_settings
31
+ from django_flex.filters import OPERATORS
32
+
33
+
34
+ class FlexPermission:
35
+ """
36
+ Base permission class for django-flex.
37
+
38
+ Subclass this to create custom permission logic, similar to
39
+ Django REST Framework's permission classes.
40
+
41
+ Example:
42
+ class IsOwnerPermission(FlexPermission):
43
+ def has_permission(self, request, model_name, action):
44
+ return request.user.is_authenticated
45
+
46
+ def get_row_filter(self, request, model_name):
47
+ return Q(owner=request.user)
48
+
49
+ def get_allowed_fields(self, request, model_name):
50
+ return ['*', 'owner.name']
51
+ """
52
+
53
+ def has_permission(self, request, model_name, action):
54
+ """Check if request has permission for action on model."""
55
+ return True
56
+
57
+ def get_row_filter(self, request, model_name):
58
+ """Return Q object to filter rows user can access."""
59
+ return Q()
60
+
61
+ def get_allowed_fields(self, request, model_name):
62
+ """Return list of allowed field patterns."""
63
+ return ["*"]
64
+
65
+ def get_allowed_filters(self, request, model_name):
66
+ """Return list of allowed filter keys."""
67
+ return []
68
+
69
+ def get_allowed_ordering(self, request, model_name):
70
+ """Return list of allowed order_by values."""
71
+ return []
72
+
73
+
74
+ def field_matches_pattern(field, pattern):
75
+ """
76
+ Check if a field matches an allowed pattern.
77
+
78
+ Args:
79
+ field: Field name to check
80
+ pattern: Pattern to match against
81
+
82
+ Returns:
83
+ True if field matches pattern
84
+
85
+ Examples:
86
+ >>> field_matches_pattern("name", "*")
87
+ True
88
+ >>> field_matches_pattern("author.name", "author.*")
89
+ True
90
+ >>> field_matches_pattern("author.name", "author.name")
91
+ True
92
+ >>> field_matches_pattern("author.email", "author.name")
93
+ False
94
+ """
95
+ if pattern == "*":
96
+ # Wildcard only matches base fields, not relations
97
+ return "." not in field
98
+
99
+ if pattern.endswith(".*"):
100
+ # Relation wildcard: author.* matches author.name, author.email
101
+ prefix = pattern[:-2]
102
+ return field.startswith(prefix + ".")
103
+
104
+ # Exact match
105
+ return field == pattern
106
+
107
+
108
+ def fields_allowed(requested_fields, allowed_patterns):
109
+ """
110
+ Check if all requested fields are allowed by the patterns.
111
+
112
+ Args:
113
+ requested_fields: List of field names to check
114
+ allowed_patterns: List of allowed patterns
115
+
116
+ Returns:
117
+ Tuple of (is_allowed, denied_field)
118
+ """
119
+ for field in requested_fields:
120
+ allowed = False
121
+ for pattern in allowed_patterns:
122
+ if field_matches_pattern(field, pattern):
123
+ allowed = True
124
+ break
125
+ if not allowed:
126
+ return False, field
127
+ return True, None
128
+
129
+
130
+ def get_user_role(user, permissions=None):
131
+ """
132
+ Get the user's role using Django's built-in auth system.
133
+
134
+ Role resolution order:
135
+ 1. superuser -> 'superuser' (bypasses all checks)
136
+ 2. staff -> 'staff'
137
+ 3. First matching group name (lowercase)
138
+ 4. 'authenticated' if logged in
139
+ 5. None if anonymous
140
+
141
+ Args:
142
+ user: Django User instance
143
+ permissions: Optional permissions config (for custom role lookup)
144
+
145
+ Returns:
146
+ Role name (string) or None
147
+ """
148
+ if user is None:
149
+ return None
150
+
151
+ if not user.is_authenticated:
152
+ return None
153
+
154
+ if user.is_superuser:
155
+ return "superuser"
156
+
157
+ if user.is_staff:
158
+ return "staff"
159
+
160
+ # Use Django's built-in groups
161
+ if hasattr(user, "groups"):
162
+ # Get the first group name as the role
163
+ group = user.groups.first()
164
+ if group:
165
+ return group.name.lower()
166
+
167
+ # Fallback for authenticated users with no group
168
+ return "authenticated"
169
+
170
+
171
+ def check_permission(user, model_name, action, requested_fields, permissions=None):
172
+ """
173
+ Check if user has permission to perform action on model with requested fields.
174
+
175
+ This is the main permission check function. It validates:
176
+ 1. User is authenticated
177
+ 2. User has a valid role
178
+ 3. Model is configured in permissions
179
+ 4. Role has access to the model
180
+ 5. Action is allowed for the role
181
+ 6. All requested fields are allowed
182
+
183
+ Args:
184
+ user: Django User instance
185
+ model_name: Model name (lowercase)
186
+ action: Action name (get, query, create, update, delete)
187
+ requested_fields: List of field paths to access
188
+ permissions: Optional permissions dict (uses settings if not provided)
189
+
190
+ Returns:
191
+ Tuple of (row_filter, fields) - Q object for row filtering, validated fields list
192
+
193
+ Raises:
194
+ PermissionError: If permission denied
195
+ """
196
+ if permissions is None:
197
+ permissions = flex_settings.PERMISSIONS
198
+
199
+ model_name = model_name.lower()
200
+
201
+ # Check if user is authenticated
202
+ if user is None or not user.is_authenticated:
203
+ raise PermissionError("Authentication required")
204
+
205
+ # Superuser bypasses all checks
206
+ if user.is_superuser:
207
+ return Q(), requested_fields
208
+
209
+ # Get user's role
210
+ role = get_user_role(user, permissions)
211
+ if not role:
212
+ raise PermissionError("No role could be determined for user")
213
+
214
+ # Check if model is accessible
215
+ if model_name not in permissions:
216
+ raise PermissionError(f"Access denied: model '{model_name}' not configured")
217
+
218
+ model_perms = permissions[model_name]
219
+
220
+ # Check if role has access to this model
221
+ if role not in model_perms:
222
+ raise PermissionError(f"Access denied: role '{role}' cannot access '{model_name}'")
223
+
224
+ perm = model_perms[role]
225
+
226
+ # Check if operation is allowed
227
+ allowed_ops = perm.get("ops", perm.get("operations", []))
228
+ if action not in allowed_ops:
229
+ raise PermissionError(f"Access denied: operation '{action}' not allowed on '{model_name}'")
230
+
231
+ # Check if all requested fields are allowed
232
+ is_allowed, denied_field = fields_allowed(requested_fields, perm["fields"])
233
+ if not is_allowed:
234
+ raise PermissionError(f"Access denied: field '{denied_field}' not accessible")
235
+
236
+ # Return row filter
237
+ row_filter_fn = perm.get("rows")
238
+ if row_filter_fn:
239
+ row_filter = row_filter_fn(user)
240
+ else:
241
+ row_filter = Q()
242
+
243
+ return row_filter, requested_fields
244
+
245
+
246
+ def check_filter_permission(user, model_name, filter_keys, permissions=None):
247
+ """
248
+ Check if user can filter on the given filter keys.
249
+
250
+ Args:
251
+ user: Django User instance
252
+ model_name: Model name (lowercase)
253
+ filter_keys: List of filter keys (e.g., ["name", "status.in", "owner.name.icontains"])
254
+ permissions: Optional permissions dict
255
+
256
+ Raises:
257
+ PermissionError: If filter key not allowed
258
+ """
259
+ if not filter_keys:
260
+ return
261
+
262
+ if permissions is None:
263
+ permissions = flex_settings.PERMISSIONS
264
+
265
+ model_name = model_name.lower()
266
+
267
+ # Check if user is authenticated
268
+ if user is None or not user.is_authenticated:
269
+ raise PermissionError("Authentication required")
270
+
271
+ # Superuser bypasses all checks
272
+ if user.is_superuser:
273
+ return
274
+
275
+ # Get user's role
276
+ role = get_user_role(user, permissions)
277
+ if not role:
278
+ raise PermissionError("No role could be determined for user")
279
+
280
+ # Check if model is accessible
281
+ if model_name not in permissions:
282
+ raise PermissionError(f"Access denied: model '{model_name}' not configured")
283
+
284
+ model_perms = permissions[model_name]
285
+
286
+ # Check if role has access to this model
287
+ if role not in model_perms:
288
+ raise PermissionError(f"Access denied: role '{role}' cannot access '{model_name}'")
289
+
290
+ perm = model_perms[role]
291
+
292
+ # Get allowed filters (default to empty = no filtering allowed)
293
+ allowed_filters = perm.get("filters", [])
294
+
295
+ max_depth = flex_settings.MAX_RELATION_DEPTH
296
+
297
+ for key in filter_keys:
298
+ # Skip composite operators (or, and, not) - they're handled recursively
299
+ if key in ("or", "and", "not"):
300
+ continue
301
+
302
+ # Check relation depth
303
+ parts = key.split(".")
304
+ # Remove operator suffix if present
305
+ if parts[-1] in OPERATORS:
306
+ parts = parts[:-1]
307
+ if len(parts) > max_depth:
308
+ raise PermissionError(f"Filter denied: '{key}' exceeds max relation depth of {max_depth}")
309
+
310
+ # Check if filter key is allowed
311
+ if key not in allowed_filters:
312
+ raise PermissionError(f"Filter denied: '{key}' not allowed for filtering")
313
+
314
+
315
+ def check_order_permission(user, model_name, order_by, permissions=None):
316
+ """
317
+ Check if user can order by the given field.
318
+
319
+ Args:
320
+ user: Django User instance
321
+ model_name: Model name (lowercase)
322
+ order_by: Order by field (e.g., "name", "-created_at", "owner.name")
323
+ permissions: Optional permissions dict
324
+
325
+ Raises:
326
+ PermissionError: If order_by not allowed
327
+ """
328
+ if not order_by:
329
+ return
330
+
331
+ if permissions is None:
332
+ permissions = flex_settings.PERMISSIONS
333
+
334
+ model_name = model_name.lower()
335
+
336
+ # Check if user is authenticated
337
+ if user is None or not user.is_authenticated:
338
+ raise PermissionError("Authentication required")
339
+
340
+ # Superuser bypasses all checks
341
+ if user.is_superuser:
342
+ return
343
+
344
+ # Get user's role
345
+ role = get_user_role(user, permissions)
346
+ if not role:
347
+ raise PermissionError("No role could be determined for user")
348
+
349
+ # Check if model is accessible
350
+ if model_name not in permissions:
351
+ raise PermissionError(f"Access denied: model '{model_name}' not configured")
352
+
353
+ model_perms = permissions[model_name]
354
+
355
+ # Check if role has access to this model
356
+ if role not in model_perms:
357
+ raise PermissionError(f"Access denied: role '{role}' cannot access '{model_name}'")
358
+
359
+ perm = model_perms[role]
360
+
361
+ # Get allowed order_by fields (default to empty = no ordering allowed)
362
+ allowed_order = perm.get("order_by", [])
363
+
364
+ if order_by not in allowed_order:
365
+ raise PermissionError(f"Order denied: '{order_by}' not allowed for ordering")