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.

@@ -0,0 +1,92 @@
1
+ """
2
+ Django-Flex: Flexible Query Language for Django
3
+
4
+ A Django-native query builder that enables frontends to send JSON query
5
+ specifications to the backend, supporting field selection, filtering,
6
+ pagination, and comprehensive security controls.
7
+
8
+ Example:
9
+ from django_flex import FlexQuery
10
+
11
+ result = FlexQuery(Entry).execute({
12
+ 'fields': 'id, author.name, status',
13
+ 'filters': {'status': 'confirmed'},
14
+ 'limit': 20,
15
+ })
16
+ """
17
+
18
+ __version__ = "26.1.0"
19
+ __author__ = "Nehemiah Jacob"
20
+
21
+ # Core query execution
22
+ from django_flex.query import FlexQuery, execute_query
23
+
24
+ # Field utilities
25
+ from django_flex.fields import (
26
+ parse_fields,
27
+ expand_fields,
28
+ get_model_fields,
29
+ get_model_relations,
30
+ extract_relations,
31
+ )
32
+
33
+ # Filter utilities
34
+ from django_flex.filters import (
35
+ parse_filter_key,
36
+ build_q_object,
37
+ extract_filter_keys,
38
+ OPERATORS,
39
+ )
40
+
41
+ # Permissions
42
+ from django_flex.permissions import (
43
+ FlexPermission,
44
+ check_permission,
45
+ check_filter_permission,
46
+ check_order_permission,
47
+ )
48
+
49
+ # Views
50
+ from django_flex.views import FlexQueryView
51
+
52
+ # Decorators
53
+ from django_flex.decorators import flex_query
54
+
55
+ # Response utilities
56
+ from django_flex.response import FlexResponse, build_nested_response
57
+
58
+ # Configuration
59
+ from django_flex.conf import flex_settings
60
+
61
+ __all__ = [
62
+ # Version
63
+ "__version__",
64
+ # Query
65
+ "FlexQuery",
66
+ "execute_query",
67
+ # Fields
68
+ "parse_fields",
69
+ "expand_fields",
70
+ "get_model_fields",
71
+ "get_model_relations",
72
+ "extract_relations",
73
+ # Filters
74
+ "parse_filter_key",
75
+ "build_q_object",
76
+ "extract_filter_keys",
77
+ "OPERATORS",
78
+ # Permissions
79
+ "FlexPermission",
80
+ "check_permission",
81
+ "check_filter_permission",
82
+ "check_order_permission",
83
+ # Views
84
+ "FlexQueryView",
85
+ # Decorators
86
+ "flex_query",
87
+ # Response
88
+ "FlexResponse",
89
+ "build_nested_response",
90
+ # Settings
91
+ "flex_settings",
92
+ ]
django_flex/conf.py ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ Django-Flex Settings
3
+
4
+ Configuration is read from Django settings under the DJANGO_FLEX key.
5
+ All settings have sensible defaults.
6
+
7
+ Example:
8
+ # settings.py
9
+ DJANGO_FLEX = {
10
+ 'DEFAULT_LIMIT': 50,
11
+ 'MAX_LIMIT': 200,
12
+ 'MAX_RELATION_DEPTH': 2,
13
+ 'PERMISSIONS': {...},
14
+ }
15
+ """
16
+
17
+ from django.conf import settings
18
+
19
+ DEFAULTS = {
20
+ # Pagination
21
+ "DEFAULT_LIMIT": 50,
22
+ "MAX_LIMIT": 200,
23
+ # Security
24
+ "MAX_RELATION_DEPTH": 2,
25
+ "REQUIRE_AUTHENTICATION": True,
26
+ "AUDIT_QUERIES": False,
27
+ # Model permissions
28
+ "PERMISSIONS": {},
29
+ # Response
30
+ "RESPONSE_CODES": {
31
+ "OK": "FLEX_OK",
32
+ "OK_QUERY": "FLEX_OK_QUERY",
33
+ "LIMIT_CLAMPED": "FLEX_LIMIT_CLAMPED",
34
+ "NOT_FOUND": "FLEX_NOT_FOUND",
35
+ "MODEL_NOT_FOUND": "FLEX_MODEL_NOT_FOUND",
36
+ "PERMISSION_DENIED": "FLEX_PERMISSION_DENIED",
37
+ "INVALID_FIELD": "FLEX_INVALID_FIELD",
38
+ "INVALID_FILTER": "FLEX_INVALID_FILTER",
39
+ },
40
+ }
41
+
42
+
43
+ class FlexSettings:
44
+ """
45
+ A settings object that allows django-flex settings to be accessed as
46
+ properties. For example:
47
+
48
+ from django_flex.conf import flex_settings
49
+ print(flex_settings.DEFAULT_LIMIT)
50
+
51
+ Settings can be overridden in Django settings.py under DJANGO_FLEX key.
52
+ """
53
+
54
+ def __init__(self, defaults=None):
55
+ self.defaults = defaults or DEFAULTS
56
+ self._cached_attrs = set()
57
+
58
+ @property
59
+ def user_settings(self):
60
+ if not hasattr(self, "_user_settings"):
61
+ self._user_settings = getattr(settings, "DJANGO_FLEX", {})
62
+ return self._user_settings
63
+
64
+ def __getattr__(self, attr):
65
+ if attr not in self.defaults:
66
+ raise AttributeError(f"Invalid django-flex setting: '{attr}'")
67
+
68
+ try:
69
+ # Check if present in user settings
70
+ val = self.user_settings[attr]
71
+ except KeyError:
72
+ # Fall back to defaults
73
+ val = self.defaults[attr]
74
+
75
+ # Cache the result
76
+ self._cached_attrs.add(attr)
77
+ setattr(self, attr, val)
78
+ return val
79
+
80
+ def reload(self):
81
+ """Reload settings (useful for testing)."""
82
+ for attr in self._cached_attrs:
83
+ try:
84
+ delattr(self, attr)
85
+ except AttributeError:
86
+ pass
87
+ self._cached_attrs.clear()
88
+ if hasattr(self, "_user_settings"):
89
+ delattr(self, "_user_settings")
90
+
91
+
92
+ flex_settings = FlexSettings(DEFAULTS)
@@ -0,0 +1,182 @@
1
+ """
2
+ Django-Flex Decorators
3
+
4
+ Provides function decorators for adding flexible query capabilities
5
+ to Django views.
6
+
7
+ Features:
8
+ - flex_query decorator for function-based views
9
+ - Automatic queryset filtering and field selection
10
+ - Permission integration
11
+ """
12
+
13
+ from functools import wraps
14
+ import json
15
+
16
+ from django.http import JsonResponse
17
+
18
+ from django_flex.query import FlexQuery
19
+ from django_flex.response import FlexResponse
20
+
21
+
22
+ def flex_query(
23
+ model,
24
+ allowed_fields=None,
25
+ allowed_filters=None,
26
+ allowed_ordering=None,
27
+ require_auth=True,
28
+ allowed_actions=None,
29
+ ):
30
+ """
31
+ Decorator for adding flexible query capabilities to a view.
32
+
33
+ The decorated function receives additional arguments:
34
+ - queryset: Pre-filtered queryset based on permissions
35
+ - fields: Validated list of field paths
36
+ - query_spec: The parsed query specification
37
+
38
+ Args:
39
+ model: Django model class
40
+ allowed_fields: List of allowed field patterns (default: ["*"])
41
+ allowed_filters: List of allowed filter keys (default: [])
42
+ allowed_ordering: List of allowed order_by values (default: [])
43
+ require_auth: Whether authentication is required (default: True)
44
+ allowed_actions: List of allowed actions (default: ["get", "query"])
45
+
46
+ Example:
47
+ from django_flex import flex_query
48
+ from myapp.models import Entry
49
+
50
+ @flex_query(
51
+ model=Entry,
52
+ allowed_fields=['id', 'rating', 'author.name'],
53
+ allowed_filters=['rating', 'rating.in', 'author.name.icontains'],
54
+ allowed_ordering=['created_at', '-created_at'],
55
+ )
56
+ def entry_list(request, queryset, fields, query_spec):
57
+ # queryset is pre-filtered
58
+ # fields is the validated list of fields
59
+ # Use FlexResponse to build response
60
+ from django_flex import FlexResponse, build_nested_response
61
+
62
+ results = {}
63
+ for obj in queryset:
64
+ results[str(obj.pk)] = build_nested_response(obj, fields)
65
+
66
+ return JsonResponse(FlexResponse.ok_query(results=results).to_dict())
67
+ """
68
+ allowed_fields = allowed_fields or ["*"]
69
+ allowed_filters = allowed_filters or []
70
+ allowed_ordering = allowed_ordering or []
71
+ allowed_actions = allowed_actions or ["get", "query"]
72
+
73
+ def decorator(view_func):
74
+ @wraps(view_func)
75
+ def wrapped_view(request, *args, **kwargs):
76
+ # Check authentication
77
+ if require_auth:
78
+ if not hasattr(request, "user") or not request.user.is_authenticated:
79
+ return JsonResponse(
80
+ FlexResponse.error("PERMISSION_DENIED", "Authentication required").to_dict(),
81
+ status=401,
82
+ )
83
+
84
+ # Parse query spec
85
+ if request.method == "POST":
86
+ try:
87
+ query_spec = json.loads(request.body)
88
+ except json.JSONDecodeError:
89
+ return JsonResponse(
90
+ FlexResponse.error("INVALID_FILTER", "Invalid JSON body").to_dict(),
91
+ status=400,
92
+ )
93
+ else:
94
+ query_spec = _parse_query_params(request)
95
+
96
+ # Determine action
97
+ action = "get" if "id" in query_spec else "query"
98
+ if action not in allowed_actions:
99
+ return JsonResponse(
100
+ FlexResponse.error("PERMISSION_DENIED", f"Action '{action}' not allowed").to_dict(),
101
+ status=403,
102
+ )
103
+
104
+ # Build permissions for this view
105
+ user = request.user if hasattr(request, "user") else None
106
+ role = _get_user_role(user)
107
+
108
+ permissions = {
109
+ model.__name__.lower(): {
110
+ role: {
111
+ "rows": lambda u: __import__("django.db.models", fromlist=["Q"]).Q(),
112
+ "fields": allowed_fields,
113
+ "filters": allowed_filters,
114
+ "order_by": allowed_ordering,
115
+ "ops": allowed_actions,
116
+ },
117
+ "exclude": [],
118
+ }
119
+ }
120
+
121
+ # Execute query
122
+ query = FlexQuery(model)
123
+ query.set_permissions(permissions)
124
+ result = query.execute(query_spec, user=user, action=action)
125
+
126
+ if not result.success:
127
+ status = 404 if result.code == "NOT_FOUND" else 403 if result.code == "PERMISSION_DENIED" else 400
128
+ return JsonResponse(result.to_dict(), status=status)
129
+
130
+ # If successful, we can also pass the raw queryset and fields to the view
131
+ # for custom processing (optional pattern)
132
+ return view_func(request, result=result, query_spec=query_spec, *args, **kwargs)
133
+
134
+ return wrapped_view
135
+
136
+ return decorator
137
+
138
+
139
+ def _parse_query_params(request):
140
+ """Parse query specification from GET parameters."""
141
+ spec = {}
142
+ if "fields" in request.GET:
143
+ spec["fields"] = request.GET["fields"]
144
+ if "filters" in request.GET:
145
+ try:
146
+ spec["filters"] = json.loads(request.GET["filters"])
147
+ except json.JSONDecodeError:
148
+ spec["filters"] = {}
149
+ if "limit" in request.GET:
150
+ try:
151
+ spec["limit"] = int(request.GET["limit"])
152
+ except ValueError:
153
+ pass
154
+ if "offset" in request.GET:
155
+ try:
156
+ spec["offset"] = int(request.GET["offset"])
157
+ except ValueError:
158
+ pass
159
+ if "order_by" in request.GET:
160
+ spec["order_by"] = request.GET["order_by"]
161
+ if "id" in request.GET:
162
+ try:
163
+ spec["id"] = int(request.GET["id"])
164
+ except ValueError:
165
+ spec["id"] = request.GET["id"]
166
+ return spec
167
+
168
+
169
+ def _get_user_role(user):
170
+ """Get role using Django's built-in auth system."""
171
+ if user is None or not user.is_authenticated:
172
+ return "anonymous"
173
+ if user.is_superuser:
174
+ return "superuser"
175
+ if user.is_staff:
176
+ return "staff"
177
+ # Use Django groups
178
+ if hasattr(user, "groups"):
179
+ group = user.groups.first()
180
+ if group:
181
+ return group.name.lower()
182
+ return "authenticated"
django_flex/fields.py ADDED
@@ -0,0 +1,234 @@
1
+ """
2
+ Django-Flex Field Utilities
3
+
4
+ Handles field parsing, expansion, and model introspection for
5
+ flexible queries.
6
+
7
+ Features:
8
+ - Parse comma-separated field strings
9
+ - Expand wildcards (* and relation.*)
10
+ - Model field introspection
11
+ - Relation extraction for select_related optimization
12
+ """
13
+
14
+ from django_flex.conf import flex_settings
15
+
16
+
17
+ def parse_fields(fields_str):
18
+ """
19
+ Parse comma-separated field string into list of field specs.
20
+
21
+ Args:
22
+ fields_str: Comma-separated field string (e.g., "name, email")
23
+
24
+ Returns:
25
+ List of field specs
26
+
27
+ Examples:
28
+ >>> parse_fields("name, email")
29
+ ['name', 'email']
30
+ >>> parse_fields("id, author.name, author.email")
31
+ ['id', 'author.name', 'author.email']
32
+ >>> parse_fields("*, author.*")
33
+ ['*', 'author.*']
34
+ >>> parse_fields("")
35
+ ['*']
36
+ >>> parse_fields(None)
37
+ ['*']
38
+ """
39
+ if not fields_str:
40
+ return ["*"] # Default to all fields
41
+
42
+ return [f.strip() for f in fields_str.split(",") if f.strip()]
43
+
44
+
45
+ def get_model_fields(model):
46
+ """
47
+ Get list of concrete field names for a model.
48
+
49
+ Only returns fields with database columns (excludes reverse relations,
50
+ many-to-many through tables, etc.).
51
+
52
+ Args:
53
+ model: Django model class
54
+
55
+ Returns:
56
+ List of field names
57
+
58
+ Example:
59
+ >>> get_model_fields(User)
60
+ ['id', 'email', 'username', 'first_name', 'last_name', ...]
61
+ """
62
+ fields = []
63
+ for field in model._meta.get_fields():
64
+ # Only include concrete fields (not relations or reverse relations)
65
+ if hasattr(field, "column") and field.column:
66
+ fields.append(field.name)
67
+ return fields
68
+
69
+
70
+ def get_model_relations(model):
71
+ """
72
+ Get dict of relation_name -> related_model for a model.
73
+
74
+ Only returns forward relations (ForeignKey, OneToOneField) that
75
+ can be used in select_related.
76
+
77
+ Args:
78
+ model: Django model class
79
+
80
+ Returns:
81
+ Dict mapping relation name to related model class
82
+
83
+ Example:
84
+ >>> get_model_relations(Entry)
85
+ {'author': <class 'Author'>, 'blog': <class 'Blog'>}
86
+ """
87
+ relations = {}
88
+ for field in model._meta.get_fields():
89
+ if hasattr(field, "related_model") and field.related_model:
90
+ # Forward relations (ForeignKey, OneToOneField)
91
+ if hasattr(field, "column"):
92
+ relations[field.name] = field.related_model
93
+ return relations
94
+
95
+
96
+ def expand_wildcard(model, prefix=""):
97
+ """
98
+ Expand wildcard into concrete field names.
99
+
100
+ Args:
101
+ model: Django model class
102
+ prefix: Optional prefix for nested relations
103
+
104
+ Returns:
105
+ List of field names (optionally prefixed)
106
+
107
+ Examples:
108
+ >>> expand_wildcard(Author, "")
109
+ ['id', 'name', 'email', 'phone']
110
+ >>> expand_wildcard(Author, "author")
111
+ ['author.id', 'author.name', 'author.email', 'author.phone']
112
+ """
113
+ fields = get_model_fields(model)
114
+ if prefix:
115
+ return [f"{prefix}.{f}" for f in fields]
116
+ return fields
117
+
118
+
119
+ def filter_safe_wildcard(model_name, fields, permissions=None):
120
+ """
121
+ Remove excluded fields from expanded wildcard list.
122
+
123
+ Uses the per-model 'exclude' configuration from permissions to
124
+ prevent sensitive fields from being exposed via wildcard expansion.
125
+
126
+ Args:
127
+ model_name: Model name for looking up exclusions
128
+ fields: List of field names to filter
129
+ permissions: Optional permissions dict (uses settings if not provided)
130
+
131
+ Returns:
132
+ List with excluded fields removed
133
+ """
134
+ if permissions is None:
135
+ permissions = flex_settings.PERMISSIONS
136
+
137
+ model_name = model_name.lower()
138
+ if model_name not in permissions:
139
+ return fields
140
+
141
+ exclude = permissions[model_name].get("exclude", [])
142
+ return [f for f in fields if f not in exclude]
143
+
144
+
145
+ def expand_fields(model, field_specs, model_name=None, permissions=None):
146
+ """
147
+ Expand field specs, handling wildcards and nested relations.
148
+
149
+ Args:
150
+ model: Django model class
151
+ field_specs: List of field specs (e.g., ["*", "author.name"])
152
+ model_name: Model name for exclude lookup (uses model.__name__ if not provided)
153
+ permissions: Optional permissions dict
154
+
155
+ Returns:
156
+ List of fully qualified field paths (deduplicated, excluded fields removed)
157
+
158
+ Examples:
159
+ >>> expand_fields(Entry, ["*"])
160
+ ['id', 'status', 'author_id', 'created_at', ...]
161
+ >>> expand_fields(Entry, ["id", "author.*"])
162
+ ['id', 'author.id', 'author.name', 'author.email', ...]
163
+ """
164
+ if model_name is None:
165
+ model_name = model.__name__
166
+
167
+ if permissions is None:
168
+ permissions = flex_settings.PERMISSIONS
169
+
170
+ expanded = []
171
+ relations = get_model_relations(model)
172
+
173
+ for spec in field_specs:
174
+ if spec == "*":
175
+ # Expand base wildcard to model's concrete fields (minus excluded ones)
176
+ base_fields = get_model_fields(model)
177
+ safe_fields = filter_safe_wildcard(model_name, base_fields, permissions)
178
+ expanded.extend(safe_fields)
179
+ elif spec.endswith(".*"):
180
+ # Relation wildcard: author.* -> author.id, author.name, etc.
181
+ relation_path = spec[:-2]
182
+ parts = relation_path.split(".")
183
+
184
+ # Navigate to the related model
185
+ current_model = model
186
+ for part in parts:
187
+ rel_map = get_model_relations(current_model)
188
+ if part in rel_map:
189
+ current_model = rel_map[part]
190
+ else:
191
+ break
192
+ else:
193
+ # Successfully navigated, expand the relation's fields (minus excluded ones)
194
+ rel_fields = get_model_fields(current_model)
195
+ # Use the related model's name for exclusion lookup
196
+ safe_rel_fields = filter_safe_wildcard(current_model.__name__, rel_fields, permissions)
197
+ expanded.extend([f"{relation_path}.{f}" for f in safe_rel_fields])
198
+ else:
199
+ # Regular field or dotted field path
200
+ expanded.append(spec)
201
+
202
+ # Deduplicate while preserving order
203
+ return list(dict.fromkeys(expanded))
204
+
205
+
206
+ def extract_relations(field_paths):
207
+ """
208
+ Extract relation paths from field paths for select_related.
209
+
210
+ This enables automatic N+1 prevention by identifying which relations
211
+ need to be eagerly loaded.
212
+
213
+ Args:
214
+ field_paths: List of field paths (may include nested paths)
215
+
216
+ Returns:
217
+ Set of relation paths for select_related (using Django's __ notation)
218
+
219
+ Examples:
220
+ >>> extract_relations(["id", "status"])
221
+ set()
222
+ >>> extract_relations(["id", "author.name"])
223
+ {'author'}
224
+ >>> extract_relations(["id", "author.blog.city"])
225
+ {'author__blog'}
226
+ """
227
+ relations = set()
228
+ for path in field_paths:
229
+ parts = path.split(".")
230
+ if len(parts) > 1:
231
+ # Convert author.blog.city -> author__blog (exclude last part which is the field)
232
+ relation_path = "__".join(parts[:-1])
233
+ relations.add(relation_path)
234
+ return relations