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/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")
|