oxutils 0.1.7__py3-none-any.whl → 0.1.10__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.
Files changed (55) hide show
  1. oxutils/__init__.py +2 -1
  2. oxutils/constants.py +4 -0
  3. oxutils/jwt/auth.py +150 -1
  4. oxutils/jwt/models.py +32 -1
  5. oxutils/jwt/utils.py +45 -0
  6. oxutils/logger/receivers.py +10 -6
  7. oxutils/models/base.py +102 -0
  8. oxutils/models/fields.py +79 -0
  9. oxutils/oxiliere/apps.py +6 -1
  10. oxutils/oxiliere/authorization.py +45 -0
  11. oxutils/oxiliere/caches.py +8 -7
  12. oxutils/oxiliere/checks.py +31 -0
  13. oxutils/oxiliere/constants.py +3 -0
  14. oxutils/oxiliere/context.py +18 -0
  15. oxutils/oxiliere/exceptions.py +16 -0
  16. oxutils/oxiliere/management/commands/grant_tenant_owners.py +19 -0
  17. oxutils/oxiliere/management/commands/init_oxiliere_system.py +20 -8
  18. oxutils/oxiliere/middleware.py +29 -13
  19. oxutils/oxiliere/models.py +130 -19
  20. oxutils/oxiliere/permissions.py +6 -5
  21. oxutils/oxiliere/schemas.py +13 -4
  22. oxutils/oxiliere/signals.py +5 -0
  23. oxutils/oxiliere/utils.py +18 -0
  24. oxutils/pagination/__init__.py +0 -0
  25. oxutils/pagination/cursor.py +367 -0
  26. oxutils/permissions/__init__.py +0 -0
  27. oxutils/permissions/actions.py +57 -0
  28. oxutils/permissions/admin.py +3 -0
  29. oxutils/permissions/apps.py +10 -0
  30. oxutils/permissions/caches.py +19 -0
  31. oxutils/permissions/checks.py +188 -0
  32. oxutils/permissions/constants.py +0 -0
  33. oxutils/permissions/controllers.py +344 -0
  34. oxutils/permissions/exceptions.py +60 -0
  35. oxutils/permissions/management/__init__.py +0 -0
  36. oxutils/permissions/management/commands/__init__.py +0 -0
  37. oxutils/permissions/management/commands/load_permission_preset.py +112 -0
  38. oxutils/permissions/migrations/0001_initial.py +112 -0
  39. oxutils/permissions/migrations/0002_alter_grant_role.py +19 -0
  40. oxutils/permissions/migrations/0003_alter_grant_options_alter_group_options_and_more.py +33 -0
  41. oxutils/permissions/migrations/__init__.py +0 -0
  42. oxutils/permissions/models.py +171 -0
  43. oxutils/permissions/perms.py +95 -0
  44. oxutils/permissions/queryset.py +92 -0
  45. oxutils/permissions/schemas.py +276 -0
  46. oxutils/permissions/services.py +663 -0
  47. oxutils/permissions/tests.py +3 -0
  48. oxutils/permissions/utils.py +628 -0
  49. oxutils/settings.py +1 -0
  50. oxutils/users/migrations/0002_alter_user_first_name_alter_user_last_name.py +23 -0
  51. oxutils/users/models.py +2 -0
  52. oxutils/utils.py +25 -0
  53. {oxutils-0.1.7.dist-info → oxutils-0.1.10.dist-info}/METADATA +1 -1
  54. {oxutils-0.1.7.dist-info → oxutils-0.1.10.dist-info}/RECORD +55 -19
  55. {oxutils-0.1.7.dist-info → oxutils-0.1.10.dist-info}/WHEEL +0 -0
@@ -0,0 +1,367 @@
1
+ """
2
+ Cursor pagination for django ninja & ninja extra
3
+
4
+ forked from django-ninja-cursor-pagination
5
+ https://github.com/kitware-resonant/django-ninja-cursor-pagination
6
+
7
+
8
+ adapted for django ninja extra by @prosper-groups-soft for Oxiliere
9
+ https://github.com/prosper-groups-soft
10
+ """
11
+
12
+
13
+ from base64 import b64decode, b64encode
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+ from urllib import parse
17
+ from django.db.models import QuerySet
18
+ from django.http import HttpRequest
19
+ from django.utils.translation import gettext as _
20
+ from ninja import Field, Schema
21
+ from ninja.pagination import PaginationBase
22
+ from pydantic import field_validator
23
+
24
+
25
+ @dataclass
26
+ class Cursor:
27
+ offset: int = 0
28
+ reverse: bool = False
29
+ position: str | None = None
30
+
31
+
32
+ def _clamp(val: int, min_: int, max_: int) -> int:
33
+ return max(min_, min(val, max_))
34
+
35
+
36
+ def _reverse_order(order: tuple) -> tuple:
37
+ # Reverse the ordering specification for a Django ORM query.
38
+ # Given an order_by tuple such as `('-created_at', 'uuid')` reverse the
39
+ # ordering and return a new tuple, eg. `('created_at', '-uuid')`.
40
+ def invert(x: str) -> str:
41
+ return x[1:] if x.startswith("-") else f"-{x}"
42
+
43
+ return tuple(invert(item) for item in order)
44
+
45
+
46
+ def _replace_query_param(url: str, key: str, val: str) -> str:
47
+ scheme, netloc, path, query, fragment = parse.urlsplit(url)
48
+ query_dict = parse.parse_qs(query, keep_blank_values=True)
49
+ query_dict[key] = [val]
50
+ query = parse.urlencode(sorted(query_dict.items()), doseq=True)
51
+ return parse.urlunsplit((scheme, netloc, path, query, fragment))
52
+
53
+
54
+ def _get_http_request(request) -> HttpRequest:
55
+ """
56
+ Normalize the incoming request object to a Django HttpRequest.
57
+
58
+ Supports both direct HttpRequest instances and ninja-extra ControllerBase
59
+ wrappers (where the HttpRequest is available as request.context.request).
60
+ """
61
+ if isinstance(request, HttpRequest):
62
+ return request
63
+
64
+ if hasattr(request, "context") and hasattr(request.context, "request"):
65
+ return request.context.request
66
+
67
+ raise TypeError(
68
+ f"Unsupported request type for pagination: {type(request)!r}"
69
+ )
70
+
71
+
72
+
73
+ class CursorPagination(PaginationBase):
74
+ class Input(Schema):
75
+ limit: int | None = Field(
76
+ None,
77
+ description=_("Number of results to return per page."),
78
+ )
79
+ cursor: str | None = Field(
80
+ None,
81
+ description=_("The pagination cursor value."),
82
+ validate_default=True,
83
+ )
84
+
85
+ @field_validator("cursor")
86
+ @classmethod
87
+ def decode_cursor(cls, encoded_cursor: str | None) -> Cursor:
88
+ if encoded_cursor is None:
89
+ return Cursor()
90
+
91
+ try:
92
+ encoded_cursor = parse.unquote(encoded_cursor)
93
+ querystring = b64decode(encoded_cursor).decode()
94
+ tokens = parse.parse_qs(querystring, keep_blank_values=True)
95
+
96
+ offset = int(tokens.get("o", ["0"])[0])
97
+ offset = _clamp(offset, 0, CursorPagination._offset_cutoff)
98
+
99
+ reverse = tokens.get("r", ["0"])[0]
100
+ reverse = bool(int(reverse))
101
+
102
+ position = tokens.get("p", [None])[0]
103
+ except (TypeError, ValueError) as e:
104
+ raise ValueError(_("Invalid cursor.")) from e
105
+
106
+ return Cursor(offset=offset, reverse=reverse, position=position)
107
+
108
+ class Output(Schema):
109
+ results: list[Any] = Field(description=_("The page of objects."))
110
+ count: int = Field(
111
+ description=_("The total number of results across all pages."),
112
+ )
113
+ next: str | None = Field(
114
+ description=_("URL of next page of results if there is one."),
115
+ )
116
+ previous: str | None = Field(
117
+ description=_("URL of previous page of results if there is one."),
118
+ )
119
+
120
+ items_attribute = "results"
121
+ default_ordering = ("-id",)
122
+ max_page_size = 100
123
+ _offset_cutoff = 100 # limit to protect against possibly malicious queries
124
+
125
+ def paginate_queryset(
126
+ self,
127
+ queryset: QuerySet,
128
+ pagination: Input,
129
+ request: Any,
130
+ **params,
131
+ ) -> dict:
132
+ limit = _clamp(pagination.limit or self.max_page_size, 0, self.max_page_size)
133
+ request = _get_http_request(request)
134
+
135
+ if not queryset.query.order_by:
136
+ queryset = queryset.order_by(*self.default_ordering)
137
+
138
+ order = queryset.query.order_by
139
+ total_count = queryset.count()
140
+
141
+ base_url = request.build_absolute_uri()
142
+ cursor = pagination.cursor
143
+
144
+ if cursor.reverse:
145
+ queryset = queryset.order_by(*_reverse_order(order))
146
+
147
+ if cursor.position is not None:
148
+ values = cursor.position.split("|")
149
+ fields = [f.lstrip("-") for f in order]
150
+
151
+ filters = {}
152
+ for i, field in enumerate(fields):
153
+ if i < len(values) - 1:
154
+ filters[field] = values[i]
155
+ else:
156
+ op = "lt" if order[i].startswith("-") ^ cursor.reverse else "gt"
157
+ filters[f"{field}__{op}"] = values[i]
158
+
159
+ queryset = queryset.filter(**filters)
160
+
161
+
162
+ # If we have an offset cursor then offset the entire page by that amount.
163
+ # We also always fetch an extra item in order to determine if there is a
164
+ # page following on from this one.
165
+ results = list(queryset[cursor.offset : cursor.offset + limit + 1])
166
+ page = list(results[:limit])
167
+
168
+ # Determine the position of the final item following the page.
169
+ if len(results) > len(page):
170
+ has_following_position = True
171
+ following_position = self._get_position_from_instance(results[-1], order)
172
+ else:
173
+ has_following_position = False
174
+ following_position = None
175
+
176
+ if cursor.reverse:
177
+ # If we have a reverse queryset, then the query ordering was in reverse
178
+ # so we need to reverse the items again before returning them to the user.
179
+ page.reverse()
180
+
181
+ has_next = (cursor.position is not None) or (cursor.offset > 0)
182
+ has_previous = has_following_position
183
+ next_position = cursor.position if has_next else None
184
+ previous_position = following_position if has_previous else None
185
+ else:
186
+ has_next = has_following_position
187
+ has_previous = (cursor.position is not None) or (cursor.offset > 0)
188
+ next_position = following_position if has_next else None
189
+ previous_position = cursor.position if has_previous else None
190
+
191
+ return {
192
+ "results": page,
193
+ "count": total_count,
194
+ "next": self.next_link(
195
+ base_url=base_url,
196
+ page=page,
197
+ cursor=cursor,
198
+ order=order,
199
+ has_previous=has_previous,
200
+ limit=limit,
201
+ next_position=next_position,
202
+ previous_position=previous_position,
203
+ )
204
+ if has_next
205
+ else None,
206
+ "previous": self.previous_link(
207
+ base_url=base_url,
208
+ page=page,
209
+ cursor=cursor,
210
+ order=order,
211
+ has_next=has_next,
212
+ limit=limit,
213
+ next_position=next_position,
214
+ previous_position=previous_position,
215
+ )
216
+ if has_previous
217
+ else None,
218
+ }
219
+
220
+ def _encode_cursor(self, cursor: Cursor, base_url: str) -> str:
221
+ tokens = {}
222
+ if cursor.offset != 0:
223
+ tokens["o"] = str(cursor.offset)
224
+ if cursor.reverse:
225
+ tokens["r"] = "1"
226
+ if cursor.position is not None:
227
+ tokens["p"] = cursor.position
228
+
229
+ querystring = parse.urlencode(tokens, doseq=True)
230
+ encoded = b64encode(querystring.encode()).decode()
231
+ return _replace_query_param(base_url, "cursor", encoded)
232
+
233
+ def next_link( # noqa: PLR0913
234
+ self,
235
+ *,
236
+ base_url: str,
237
+ page: list,
238
+ cursor: Cursor,
239
+ order: tuple,
240
+ has_previous: bool,
241
+ limit: int,
242
+ next_position: str,
243
+ previous_position: str,
244
+ ) -> str:
245
+ if page and cursor.reverse and cursor.offset:
246
+ # If we're reversing direction and we have an offset cursor
247
+ # then we cannot use the first position we find as a marker.
248
+ compare = self._get_position_from_instance(page[-1], order)
249
+ else:
250
+ compare = next_position
251
+ offset = 0
252
+
253
+ has_item_with_unique_position = False
254
+ for item in reversed(page):
255
+ position = self._get_position_from_instance(item, order)
256
+ if position != compare:
257
+ # The item in this position and the item following it
258
+ # have different positions. We can use this position as
259
+ # our marker.
260
+ has_item_with_unique_position = True
261
+ break
262
+
263
+ # The item in this position has the same position as the item
264
+ # following it, we can't use it as a marker position, so increment
265
+ # the offset and keep seeking to the previous item.
266
+ compare = position
267
+ offset += 1 # noqa: SIM113
268
+
269
+ if page and not has_item_with_unique_position:
270
+ # There were no unique positions in the page.
271
+ if not has_previous:
272
+ # We are on the first page.
273
+ # Our cursor will have an offset equal to the page size,
274
+ # but no position to filter against yet.
275
+ offset = limit
276
+ position = None
277
+ elif cursor.reverse:
278
+ # The change in direction will introduce a paging artifact,
279
+ # where we end up skipping forward a few extra items.
280
+ offset = 0
281
+ position = previous_position
282
+ else:
283
+ # Use the position from the existing cursor and increment
284
+ # it's offset by the page size.
285
+ offset = cursor.offset + limit
286
+ position = previous_position
287
+
288
+ if not page:
289
+ position = next_position
290
+
291
+ next_cursor = Cursor(offset=offset, reverse=False, position=position)
292
+ return self._encode_cursor(next_cursor, base_url)
293
+
294
+ def previous_link( # noqa: PLR0913
295
+ self,
296
+ *,
297
+ base_url: str,
298
+ page: list,
299
+ cursor: Cursor,
300
+ order: tuple,
301
+ has_next: bool,
302
+ limit: int,
303
+ next_position: str,
304
+ previous_position: str,
305
+ ):
306
+ if page and not cursor.reverse and cursor.offset:
307
+ # If we're reversing direction and we have an offset cursor
308
+ # then we cannot use the first position we find as a marker.
309
+ compare = self._get_position_from_instance(page[0], order)
310
+ else:
311
+ compare = previous_position
312
+ offset = 0
313
+
314
+ has_item_with_unique_position = False
315
+ for item in page:
316
+ position = self._get_position_from_instance(item, order)
317
+ if position != compare:
318
+ # The item in this position and the item following it
319
+ # have different positions. We can use this position as
320
+ # our marker.
321
+ has_item_with_unique_position = True
322
+ break
323
+
324
+ # The item in this position has the same position as the item
325
+ # following it, we can't use it as a marker position, so increment
326
+ # the offset and keep seeking to the previous item.
327
+ compare = position
328
+ offset += 1 # noqa: SIM113
329
+
330
+ if page and not has_item_with_unique_position:
331
+ # There were no unique positions in the page.
332
+ if not has_next:
333
+ # We are on the final page.
334
+ # Our cursor will have an offset equal to the page size,
335
+ # but no position to filter against yet.
336
+ offset = limit
337
+ position = None
338
+ elif cursor.reverse:
339
+ # Use the position from the existing cursor and increment
340
+ # it's offset by the page size.
341
+ offset = cursor.offset + limit
342
+ position = next_position
343
+ else:
344
+ # The change in direction will introduce a paging artifact,
345
+ # where we end up skipping back a few extra items.
346
+ offset = 0
347
+ position = next_position
348
+
349
+ if not page:
350
+ position = previous_position
351
+
352
+ cursor = Cursor(offset=offset, reverse=True, position=position)
353
+ return self._encode_cursor(cursor, base_url)
354
+
355
+ def _get_position_from_instance(self, instance, ordering) -> str:
356
+ values: list[str] = []
357
+
358
+ for field in ordering:
359
+ name = field.lstrip("-")
360
+ value = (
361
+ instance[name]
362
+ if isinstance(instance, dict)
363
+ else getattr(instance, name)
364
+ )
365
+ values.append(str(value))
366
+
367
+ return "|".join(values)
File without changes
@@ -0,0 +1,57 @@
1
+ # actions.py
2
+
3
+ READ = "r"
4
+ WRITE = "w"
5
+ DELETE = "d"
6
+ UPDATE = "u"
7
+ APPROVE = "a"
8
+
9
+ ACTIONS = [READ, WRITE, DELETE, UPDATE, APPROVE]
10
+
11
+
12
+ ACTION_HIERARCHY = {
13
+ "r": set(), # read
14
+ "w": {"r"}, # write ⇒ read
15
+ "u": {"r"}, # update ⇒ read
16
+ "d": {"r", "w"}, # delete ⇒ write ⇒ read
17
+ "a": {"r"}, # approve ⇒ read
18
+ }
19
+
20
+
21
+ def collapse_actions(actions: list[str]) -> set[str]:
22
+ """
23
+ ['d','w','r'] -> {'d'}
24
+ ['w','r'] -> {'w'}
25
+ ['r'] -> {'r'}
26
+ """
27
+ actions = set(actions)
28
+ roots = set(actions)
29
+
30
+ # Remove all implied actions from roots
31
+ for action in list(roots):
32
+ if action in ACTION_HIERARCHY:
33
+ implied = ACTION_HIERARCHY[action]
34
+ roots -= implied
35
+
36
+ return roots
37
+
38
+
39
+ def expand_actions(actions: list[str]) -> list[str]:
40
+ """
41
+ ['w'] -> ['w', 'r']
42
+ ['d'] -> ['d', 'w', 'r']
43
+ ['a', 'w'] -> ['a', 'w', 'r']
44
+ """
45
+ expanded = set(actions)
46
+
47
+ stack = list(actions)
48
+ while stack:
49
+ action = stack.pop()
50
+ implied = ACTION_HIERARCHY.get(action, set())
51
+
52
+ for a in implied:
53
+ if a not in expanded:
54
+ expanded.add(a)
55
+ stack.append(a)
56
+
57
+ return sorted(expanded)
@@ -0,0 +1,3 @@
1
+ from django.contrib import admin
2
+
3
+ # Register your models here.
@@ -0,0 +1,10 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class PermissionsConfig(AppConfig):
5
+ default_auto_field = 'django.db.models.BigAutoField'
6
+ name = 'oxutils.permissions'
7
+
8
+ def ready(self):
9
+ """Import checks when app is ready."""
10
+ from . import checks # noqa
@@ -0,0 +1,19 @@
1
+ from django.conf import settings
2
+
3
+
4
+
5
+ CACHE_CHECK_PERMISSION = getattr(settings, 'CACHE_CHECK_PERMISSION', False)
6
+
7
+ if CACHE_CHECK_PERMISSION:
8
+ from cacheops import cached_as
9
+ from .models import Grant
10
+ from .utils import check
11
+
12
+ @cached_as(Grant, timeout=60*5)
13
+ def cache_check(user, scope, actions, group = None, **context):
14
+ return check(user, scope, actions, group, **context)
15
+ else:
16
+ from .utils import check
17
+
18
+ def cache_check(user, scope, actions, group = None, **context):
19
+ return check(user, scope, actions, group, **context)
@@ -0,0 +1,188 @@
1
+ """
2
+ Django system checks for permissions configuration.
3
+
4
+ Example configuration in settings.py:
5
+
6
+ ACCESS_MANAGER_SCOPE = "access"
7
+ ACCESS_MANAGER_GROUP = "manager" # or None
8
+ ACCESS_MANAGER_CONTEXT = {}
9
+
10
+ CACHE_CHECK_PERMISSION = False
11
+
12
+ ACCESS_SCOPES = [
13
+ "users",
14
+ "articles",
15
+ "comments"
16
+ ]
17
+
18
+ PERMISSION_PRESET = {
19
+ "roles": [...],
20
+ "group": [...],
21
+ "role_grants": [...]
22
+ }
23
+ """
24
+
25
+ from django.conf import settings
26
+ from django.core.checks import Error, Warning, register, Tags
27
+
28
+
29
+ @register(Tags.security)
30
+ def check_permission_settings(app_configs, **kwargs):
31
+ """
32
+ Validate permission-related settings.
33
+
34
+ Checks:
35
+ - ACCESS_MANAGER_SCOPE is defined
36
+ - ACCESS_MANAGER_GROUP is defined (can be None)
37
+ - ACCESS_MANAGER_CONTEXT is defined
38
+ - ACCESS_SCOPES is defined
39
+ - PERMISSION_PRESET is defined
40
+ - ACCESS_MANAGER_SCOPE exists in ACCESS_SCOPES
41
+ - ACCESS_MANAGER_GROUP exists in PERMISSION_PRESET groups (if not None)
42
+ """
43
+ errors = []
44
+
45
+ # Check ACCESS_MANAGER_SCOPE
46
+ if not hasattr(settings, 'ACCESS_MANAGER_SCOPE'):
47
+ errors.append(
48
+ Error(
49
+ 'ACCESS_MANAGER_SCOPE is not defined',
50
+ hint='Add ACCESS_MANAGER_SCOPE = "access" to your settings',
51
+ id='permissions.E001',
52
+ )
53
+ )
54
+
55
+ # Check ACCESS_MANAGER_GROUP
56
+ if not hasattr(settings, 'ACCESS_MANAGER_GROUP'):
57
+ errors.append(
58
+ Error(
59
+ 'ACCESS_MANAGER_GROUP is not defined',
60
+ hint='Add ACCESS_MANAGER_GROUP = "manager" or None to your settings',
61
+ id='permissions.E002',
62
+ )
63
+ )
64
+
65
+ # Check ACCESS_MANAGER_CONTEXT
66
+ if not hasattr(settings, 'ACCESS_MANAGER_CONTEXT'):
67
+ errors.append(
68
+ Error(
69
+ 'ACCESS_MANAGER_CONTEXT is not defined',
70
+ hint='Add ACCESS_MANAGER_CONTEXT = {} to your settings',
71
+ id='permissions.E003',
72
+ )
73
+ )
74
+
75
+ # Check ACCESS_SCOPES
76
+ if not hasattr(settings, 'ACCESS_SCOPES'):
77
+ errors.append(
78
+ Error(
79
+ 'ACCESS_SCOPES is not defined',
80
+ hint='Add ACCESS_SCOPES = ["users", "articles", ...] to your settings',
81
+ id='permissions.E004',
82
+ )
83
+ )
84
+ else:
85
+ # Validate ACCESS_SCOPES is a list
86
+ if not isinstance(settings.ACCESS_SCOPES, list):
87
+ errors.append(
88
+ Error(
89
+ 'ACCESS_SCOPES must be a list',
90
+ hint='Set ACCESS_SCOPES = ["users", "articles", ...]',
91
+ id='permissions.E005',
92
+ )
93
+ )
94
+
95
+ # Check PERMISSION_PRESET
96
+ if not hasattr(settings, 'PERMISSION_PRESET'):
97
+ errors.append(
98
+ Warning(
99
+ 'PERMISSION_PRESET is not defined',
100
+ hint='Add PERMISSION_PRESET dict to your settings or use load_permission_preset',
101
+ id='permissions.W001',
102
+ )
103
+ )
104
+ else:
105
+ # Validate PERMISSION_PRESET structure
106
+ preset = settings.PERMISSION_PRESET
107
+ if not isinstance(preset, dict):
108
+ errors.append(
109
+ Error(
110
+ 'PERMISSION_PRESET must be a dictionary',
111
+ id='permissions.E006',
112
+ )
113
+ )
114
+ else:
115
+ # Check required keys
116
+ required_keys = ['roles', 'group', 'role_grants']
117
+ for key in required_keys:
118
+ if key not in preset:
119
+ errors.append(
120
+ Error(
121
+ f'PERMISSION_PRESET is missing required key: {key}',
122
+ hint=f'Add "{key}" key to PERMISSION_PRESET',
123
+ id=f'permissions.E007',
124
+ )
125
+ )
126
+
127
+ # Cross-validation: ACCESS_MANAGER_SCOPE in ACCESS_SCOPES
128
+ if (hasattr(settings, 'ACCESS_MANAGER_SCOPE') and
129
+ hasattr(settings, 'ACCESS_SCOPES') and
130
+ isinstance(settings.ACCESS_SCOPES, list)):
131
+
132
+ if settings.ACCESS_MANAGER_SCOPE not in settings.ACCESS_SCOPES:
133
+ errors.append(
134
+ Error(
135
+ f'ACCESS_MANAGER_SCOPE "{settings.ACCESS_MANAGER_SCOPE}" is not in ACCESS_SCOPES',
136
+ hint=f'Add "{settings.ACCESS_MANAGER_SCOPE}" to ACCESS_SCOPES list',
137
+ id='permissions.E008',
138
+ )
139
+ )
140
+
141
+ # Cross-validation: ACCESS_MANAGER_GROUP in PERMISSION_PRESET groups
142
+ if (hasattr(settings, 'ACCESS_MANAGER_GROUP') and
143
+ settings.ACCESS_MANAGER_GROUP is not None and
144
+ hasattr(settings, 'PERMISSION_PRESET') and
145
+ isinstance(settings.PERMISSION_PRESET, dict) and
146
+ 'group' in settings.PERMISSION_PRESET):
147
+
148
+ group_slugs = [g.get('slug') for g in settings.PERMISSION_PRESET.get('group', [])]
149
+
150
+ if settings.ACCESS_MANAGER_GROUP not in group_slugs:
151
+ errors.append(
152
+ Error(
153
+ f'ACCESS_MANAGER_GROUP "{settings.ACCESS_MANAGER_GROUP}" is not in PERMISSION_PRESET groups',
154
+ hint=f'Add a group with slug "{settings.ACCESS_MANAGER_GROUP}" to PERMISSION_PRESET["group"]',
155
+ id='permissions.E009',
156
+ )
157
+ )
158
+
159
+ # Validate ACCESS_MANAGER_CONTEXT is a dict
160
+ if hasattr(settings, 'ACCESS_MANAGER_CONTEXT'):
161
+ if not isinstance(settings.ACCESS_MANAGER_CONTEXT, dict):
162
+ errors.append(
163
+ Error(
164
+ 'ACCESS_MANAGER_CONTEXT must be a dictionary',
165
+ hint='Set ACCESS_MANAGER_CONTEXT = {}',
166
+ id='permissions.E010',
167
+ )
168
+ )
169
+
170
+ # Check CACHE_CHECK_PERMISSION and cacheops dependency
171
+ if hasattr(settings, 'CACHE_CHECK_PERMISSION') and settings.CACHE_CHECK_PERMISSION:
172
+ if not hasattr(settings, 'INSTALLED_APPS'):
173
+ errors.append(
174
+ Error(
175
+ 'INSTALLED_APPS is not defined',
176
+ id='permissions.E011',
177
+ )
178
+ )
179
+ elif 'cacheops' not in settings.INSTALLED_APPS:
180
+ errors.append(
181
+ Error(
182
+ 'CACHE_CHECK_PERMISSION is True but cacheops is not in INSTALLED_APPS',
183
+ hint='Add "cacheops" to INSTALLED_APPS or set CACHE_CHECK_PERMISSION = False',
184
+ id='permissions.E012',
185
+ )
186
+ )
187
+
188
+ return errors
File without changes