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/__init__.py
ADDED
|
@@ -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
|