oxutils 0.1.6__py3-none-any.whl → 0.1.12__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 (67) hide show
  1. oxutils/__init__.py +2 -2
  2. oxutils/audit/migrations/0001_initial.py +2 -2
  3. oxutils/audit/models.py +2 -2
  4. oxutils/constants.py +6 -0
  5. oxutils/jwt/auth.py +150 -1
  6. oxutils/jwt/models.py +81 -0
  7. oxutils/jwt/tokens.py +69 -0
  8. oxutils/jwt/utils.py +45 -0
  9. oxutils/logger/__init__.py +10 -0
  10. oxutils/logger/receivers.py +10 -6
  11. oxutils/logger/settings.py +2 -2
  12. oxutils/models/base.py +102 -0
  13. oxutils/models/fields.py +79 -0
  14. oxutils/oxiliere/apps.py +9 -1
  15. oxutils/oxiliere/authorization.py +45 -0
  16. oxutils/oxiliere/caches.py +13 -11
  17. oxutils/oxiliere/checks.py +31 -0
  18. oxutils/oxiliere/constants.py +3 -0
  19. oxutils/oxiliere/context.py +16 -0
  20. oxutils/oxiliere/exceptions.py +16 -0
  21. oxutils/oxiliere/management/commands/grant_tenant_owners.py +19 -0
  22. oxutils/oxiliere/management/commands/init_oxiliere_system.py +30 -11
  23. oxutils/oxiliere/middleware.py +65 -11
  24. oxutils/oxiliere/models.py +146 -9
  25. oxutils/oxiliere/permissions.py +28 -35
  26. oxutils/oxiliere/schemas.py +16 -6
  27. oxutils/oxiliere/signals.py +5 -0
  28. oxutils/oxiliere/utils.py +36 -1
  29. oxutils/pagination/cursor.py +367 -0
  30. oxutils/permissions/__init__.py +0 -0
  31. oxutils/permissions/actions.py +57 -0
  32. oxutils/permissions/admin.py +3 -0
  33. oxutils/permissions/apps.py +10 -0
  34. oxutils/permissions/caches.py +19 -0
  35. oxutils/permissions/checks.py +188 -0
  36. oxutils/permissions/constants.py +0 -0
  37. oxutils/permissions/controllers.py +344 -0
  38. oxutils/permissions/exceptions.py +60 -0
  39. oxutils/permissions/management/__init__.py +0 -0
  40. oxutils/permissions/management/commands/__init__.py +0 -0
  41. oxutils/permissions/management/commands/load_permission_preset.py +112 -0
  42. oxutils/permissions/migrations/0001_initial.py +112 -0
  43. oxutils/permissions/migrations/0002_alter_grant_role.py +19 -0
  44. oxutils/permissions/migrations/0003_alter_grant_options_alter_group_options_and_more.py +33 -0
  45. oxutils/permissions/migrations/__init__.py +0 -0
  46. oxutils/permissions/models.py +171 -0
  47. oxutils/permissions/perms.py +95 -0
  48. oxutils/permissions/queryset.py +92 -0
  49. oxutils/permissions/schemas.py +276 -0
  50. oxutils/permissions/services.py +663 -0
  51. oxutils/permissions/tests.py +3 -0
  52. oxutils/permissions/utils.py +628 -0
  53. oxutils/settings.py +14 -194
  54. oxutils/users/apps.py +1 -1
  55. oxutils/users/migrations/0001_initial.py +47 -0
  56. oxutils/users/migrations/0002_alter_user_first_name_alter_user_last_name.py +23 -0
  57. oxutils/users/models.py +2 -0
  58. oxutils/utils.py +25 -0
  59. {oxutils-0.1.6.dist-info → oxutils-0.1.12.dist-info}/METADATA +14 -11
  60. oxutils-0.1.12.dist-info/RECORD +122 -0
  61. oxutils/jwt/client.py +0 -123
  62. oxutils/jwt/constants.py +0 -1
  63. oxutils/s3/settings.py +0 -34
  64. oxutils/s3/storages.py +0 -130
  65. oxutils-0.1.6.dist-info/RECORD +0 -88
  66. /oxutils/{s3 → pagination}/__init__.py +0 -0
  67. {oxutils-0.1.6.dist-info → oxutils-0.1.12.dist-info}/WHEEL +0 -0
@@ -1,6 +1,8 @@
1
- from ninja.permissions import BasePermission
2
- from django.conf import settings
3
- from oxutils.oxiliere.models import TenantUser
1
+ from ninja_extra.permissions import BasePermission
2
+ from oxutils.oxiliere.utils import get_tenant_user_model
3
+ from oxutils.constants import OXILIERE_SERVICE_TOKEN
4
+ from oxutils.jwt.tokens import OxilierServiceToken
5
+
4
6
 
5
7
 
6
8
  class TenantPermission(BasePermission):
@@ -8,7 +10,7 @@ class TenantPermission(BasePermission):
8
10
  Vérifie que l'utilisateur a accès au tenant actuel.
9
11
  L'utilisateur doit être authentifié et avoir un lien avec le tenant.
10
12
  """
11
- def has_permission(self, request, view):
13
+ def has_permission(self, request, **kwargs):
12
14
  if not request.user or not request.user.is_authenticated:
13
15
  return False
14
16
 
@@ -16,9 +18,9 @@ class TenantPermission(BasePermission):
16
18
  return False
17
19
 
18
20
  # Vérifier que l'utilisateur a accès à ce tenant
19
- return TenantUser.objects.filter(
20
- tenant=request.tenant,
21
- user=request.user
21
+ return get_tenant_user_model().objects.filter(
22
+ tenant__pk=request.tenant.pk,
23
+ user__pk=request.user.pk
22
24
  ).exists()
23
25
 
24
26
 
@@ -26,17 +28,16 @@ class TenantOwnerPermission(BasePermission):
26
28
  """
27
29
  Vérifie que l'utilisateur est propriétaire (owner) du tenant actuel.
28
30
  """
29
- def has_permission(self, request, view):
31
+ def has_permission(self, request, **kwargs):
30
32
  if not request.user or not request.user.is_authenticated:
31
33
  return False
32
34
 
33
35
  if not hasattr(request, 'tenant'):
34
36
  return False
35
37
 
36
- # Vérifier que l'utilisateur est owner du tenant
37
- return TenantUser.objects.filter(
38
- tenant=request.tenant,
39
- user=request.user,
38
+ return get_tenant_user_model().objects.filter(
39
+ tenant__pk=request.tenant.pk,
40
+ user__pk=request.user.pk,
40
41
  is_owner=True
41
42
  ).exists()
42
43
 
@@ -45,22 +46,17 @@ class TenantAdminPermission(BasePermission):
45
46
  """
46
47
  Vérifie que l'utilisateur est admin ou owner du tenant actuel.
47
48
  """
48
- def has_permission(self, request, view):
49
+ def has_permission(self, request, **kwargs):
49
50
  if not request.user or not request.user.is_authenticated:
50
51
  return False
51
52
 
52
53
  if not hasattr(request, 'tenant'):
53
54
  return False
54
55
 
55
- # Vérifier que l'utilisateur est admin ou owner du tenant
56
- return TenantUser.objects.filter(
57
- tenant=request.tenant,
58
- user=request.user,
56
+ return get_tenant_user_model().objects.filter(
57
+ tenant__pk=request.tenant.pk,
58
+ user__pk=request.user.pk,
59
59
  is_admin=True
60
- ).exists() or TenantUser.objects.filter(
61
- tenant=request.tenant,
62
- user=request.user,
63
- is_owner=True
64
60
  ).exists()
65
61
 
66
62
 
@@ -69,16 +65,16 @@ class TenantUserPermission(BasePermission):
69
65
  Vérifie que l'utilisateur est un membre du tenant actuel.
70
66
  Alias de TenantPermission pour plus de clarté sémantique.
71
67
  """
72
- def has_permission(self, request, view):
68
+ def has_permission(self, request, **kwargs):
73
69
  if not request.user or not request.user.is_authenticated:
74
70
  return False
75
71
 
76
72
  if not hasattr(request, 'tenant'):
77
73
  return False
78
74
 
79
- return TenantUser.objects.filter(
80
- tenant=request.tenant,
81
- user=request.user
75
+ return get_tenant_user_model().objects.filter(
76
+ tenant__pk=request.tenant.pk,
77
+ user__pk=request.user.pk
82
78
  ).exists()
83
79
 
84
80
 
@@ -87,18 +83,15 @@ class OxiliereServicePermission(BasePermission):
87
83
  Vérifie que la requête provient d'un service interne Oxiliere.
88
84
  Utilise un token de service ou une clé API spéciale.
89
85
  """
90
- def has_permission(self, request, view):
91
- # Vérifier le header de service
92
- service_token = request.headers.get('X-Oxiliere-Service-Token')
86
+ def has_permission(self, request, **kwargs):
87
+ custom = 'HTTP_' + OXILIERE_SERVICE_TOKEN.upper().replace('-', '_')
88
+ service_token = request.headers.get(OXILIERE_SERVICE_TOKEN) or request.META.get(custom)
93
89
 
94
90
  if not service_token:
95
91
  return False
96
92
 
97
- # Comparer avec le token configuré dans settings
98
- expected_token = getattr(settings, 'OXILIERE_SERVICE_TOKEN', None)
99
-
100
- if not expected_token:
93
+ try:
94
+ OxilierServiceToken(token=service_token)
95
+ return True
96
+ except Exception:
101
97
  return False
102
-
103
- return service_token == expected_token
104
-
@@ -1,10 +1,13 @@
1
1
  from typing import Optional
2
+ from uuid import UUID
2
3
  from ninja import Schema
3
4
  from django.db import transaction
4
5
  from django.contrib.auth import get_user_model
5
6
  from django_tenants.utils import get_tenant_model
6
- from oxutils.oxiliere.models import TenantUser
7
- from oxutils.oxiliere.utils import oxid_to_schema_name
7
+ from oxutils.oxiliere.utils import (
8
+ get_tenant_user_model,
9
+ )
10
+ from oxutils.oxiliere.authorization import grant_manager_access_to_owners
8
11
  import structlog
9
12
 
10
13
  logger = structlog.get_logger(__name__)
@@ -20,7 +23,9 @@ class TenantSchema(Schema):
20
23
 
21
24
 
22
25
  class TenantOwnerSchema(Schema):
23
- oxi_id: str
26
+ oxi_id: UUID
27
+ first_name: Optional[str] = None
28
+ last_name: Optional[str] = None
24
29
  email: str
25
30
 
26
31
 
@@ -33,33 +38,38 @@ class CreateTenantSchema(Schema):
33
38
  def create_tenant(self):
34
39
  UserModel = get_user_model()
35
40
  TenantModel = get_tenant_model()
41
+ TenantUserModel = get_tenant_user_model()
36
42
 
37
43
  if TenantModel.objects.filter(oxi_id=self.tenant.oxi_id).exists():
38
44
  logger.info("tenant_exists", oxi_id=self.tenant.oxi_id)
39
45
  raise ValueError("Tenant with oxi_id {} already exists".format(self.tenant.oxi_id))
40
46
 
41
- user = UserModel.objects.get_or_create(
47
+ user, _ = UserModel.objects.get_or_create(
42
48
  oxi_id=self.owner.oxi_id,
43
49
  defaults={
50
+ 'id': self.owner.oxi_id,
44
51
  'email': self.owner.email,
52
+ 'first_name': self.owner.first_name,
53
+ 'last_name': self.owner.last_name
45
54
  }
46
55
  )
47
56
 
48
57
  tenant = TenantModel.objects.create(
49
58
  name=self.tenant.name,
50
- schema_name=oxid_to_schema_name(self.tenant.oxi_id),
59
+ schema_name=self.tenant.oxi_id,
51
60
  oxi_id=self.tenant.oxi_id,
52
61
  subscription_plan=self.tenant.subscription_plan,
53
62
  subscription_status=self.tenant.subscription_status,
54
63
  subscription_end_date=self.tenant.subscription_end_date,
55
64
  )
56
65
 
57
- TenantUser.objects.create(
66
+ TenantUserModel.objects.create(
58
67
  tenant=tenant,
59
68
  user=user,
60
69
  is_owner=True,
61
70
  is_admin=True,
62
71
  )
63
72
 
73
+ grant_manager_access_to_owners(tenant)
64
74
  logger.info("tenant_created", oxi_id=self.tenant.oxi_id)
65
75
  return tenant
@@ -0,0 +1,5 @@
1
+ from django.dispatch import Signal
2
+
3
+
4
+ tenant_user_removed = Signal()
5
+ tenant_user_added = Signal()
oxutils/oxiliere/utils.py CHANGED
@@ -1,4 +1,32 @@
1
- # utils.py
1
+ from typing import Any
2
+ import uuid
3
+ from django.apps import apps
4
+ from django.conf import settings
5
+ from .constants import OXI_SYSTEM_TENANT
6
+
7
+
8
+
9
+
10
+ def get_model(setting: str) -> Any:
11
+ try:
12
+ value = getattr(settings, setting)
13
+ except AttributeError:
14
+ raise ValueError(f"Model `{setting}` is not a valid Tenant model.")
15
+
16
+ return apps.get_model(value)
17
+
18
+
19
+ def get_tenant_model() -> Any:
20
+ return get_model('TENANT_MODEL')
21
+
22
+ def get_tenant_user_model() -> Any:
23
+ return get_model('TENANT_USER_MODEL')
24
+
25
+ def is_system_tenant(tenant: Any) -> bool:
26
+ return tenant.oxi_id == get_system_tenant_oxi_id()
27
+
28
+ def get_system_tenant_oxi_id():
29
+ return getattr(settings, 'OXI_SYSTEM_TENANT', OXI_SYSTEM_TENANT)
2
30
 
3
31
  def oxid_to_schema_name(oxid: str) -> str:
4
32
  """
@@ -37,6 +65,13 @@ def oxid_to_schema_name(oxid: str) -> str:
37
65
  return schema_name
38
66
 
39
67
 
68
+ def generate_schema_name(oxi_id: str, suffix: str = None) -> str:
69
+ cleaned = oxid_to_schema_name(oxi_id)
70
+ if suffix:
71
+ return f"{cleaned}_{suffix}"
72
+ return f"{cleaned}_{uuid.uuid4().hex[:8]}"
73
+
74
+
40
75
  def update_tenant_user(oxi_org_id: str, oxi_user_id: str, data: dict):
41
76
  if not data or isinstance(data, dict) == False: return
42
77
  if not oxi_org_id or not oxi_user_id: return
@@ -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