oxutils 0.1.6__py3-none-any.whl → 0.1.14__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.
- oxutils/__init__.py +2 -2
- oxutils/audit/migrations/0001_initial.py +2 -2
- oxutils/audit/models.py +2 -2
- oxutils/constants.py +6 -0
- oxutils/jwt/auth.py +150 -1
- oxutils/jwt/models.py +81 -0
- oxutils/jwt/tokens.py +69 -0
- oxutils/jwt/utils.py +45 -0
- oxutils/logger/__init__.py +10 -0
- oxutils/logger/receivers.py +10 -6
- oxutils/logger/settings.py +2 -2
- oxutils/models/base.py +102 -0
- oxutils/models/fields.py +79 -0
- oxutils/oxiliere/apps.py +9 -1
- oxutils/oxiliere/authorization.py +45 -0
- oxutils/oxiliere/caches.py +13 -11
- oxutils/oxiliere/checks.py +31 -0
- oxutils/oxiliere/constants.py +3 -0
- oxutils/oxiliere/context.py +16 -0
- oxutils/oxiliere/exceptions.py +16 -0
- oxutils/oxiliere/management/commands/grant_tenant_owners.py +19 -0
- oxutils/oxiliere/management/commands/init_oxiliere_system.py +30 -11
- oxutils/oxiliere/middleware.py +65 -11
- oxutils/oxiliere/models.py +146 -9
- oxutils/oxiliere/permissions.py +28 -35
- oxutils/oxiliere/schemas.py +16 -6
- oxutils/oxiliere/signals.py +5 -0
- oxutils/oxiliere/utils.py +36 -1
- oxutils/pagination/cursor.py +367 -0
- oxutils/permissions/__init__.py +0 -0
- oxutils/permissions/actions.py +57 -0
- oxutils/permissions/admin.py +3 -0
- oxutils/permissions/apps.py +10 -0
- oxutils/permissions/caches.py +33 -0
- oxutils/permissions/checks.py +188 -0
- oxutils/permissions/constants.py +0 -0
- oxutils/permissions/controllers.py +344 -0
- oxutils/permissions/exceptions.py +60 -0
- oxutils/permissions/management/__init__.py +0 -0
- oxutils/permissions/management/commands/__init__.py +0 -0
- oxutils/permissions/management/commands/load_permission_preset.py +112 -0
- oxutils/permissions/migrations/0001_initial.py +112 -0
- oxutils/permissions/migrations/0002_alter_grant_role.py +19 -0
- oxutils/permissions/migrations/0003_alter_grant_options_alter_group_options_and_more.py +33 -0
- oxutils/permissions/migrations/__init__.py +0 -0
- oxutils/permissions/models.py +171 -0
- oxutils/permissions/perms.py +201 -0
- oxutils/permissions/queryset.py +92 -0
- oxutils/permissions/schemas.py +276 -0
- oxutils/permissions/services.py +663 -0
- oxutils/permissions/tests.py +3 -0
- oxutils/permissions/utils.py +784 -0
- oxutils/settings.py +14 -194
- oxutils/users/apps.py +1 -1
- oxutils/users/migrations/0001_initial.py +47 -0
- oxutils/users/migrations/0002_alter_user_first_name_alter_user_last_name.py +23 -0
- oxutils/users/migrations/0003_user_photo.py +18 -0
- oxutils/users/models.py +3 -0
- oxutils/utils.py +25 -0
- {oxutils-0.1.6.dist-info → oxutils-0.1.14.dist-info}/METADATA +14 -11
- oxutils-0.1.14.dist-info/RECORD +123 -0
- oxutils/jwt/client.py +0 -123
- oxutils/jwt/constants.py +0 -1
- oxutils/s3/settings.py +0 -34
- oxutils/s3/storages.py +0 -130
- oxutils-0.1.6.dist-info/RECORD +0 -88
- /oxutils/{s3 → pagination}/__init__.py +0 -0
- {oxutils-0.1.6.dist-info → oxutils-0.1.14.dist-info}/WHEEL +0 -0
oxutils/oxiliere/permissions.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
from
|
|
2
|
-
from
|
|
3
|
-
from oxutils.
|
|
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,
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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,
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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,
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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,
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
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,
|
|
91
|
-
|
|
92
|
-
service_token = request.headers.get(
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
oxutils/oxiliere/schemas.py
CHANGED
|
@@ -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.
|
|
7
|
-
|
|
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:
|
|
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=
|
|
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
|
-
|
|
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
|
oxutils/oxiliere/utils.py
CHANGED
|
@@ -1,4 +1,32 @@
|
|
|
1
|
-
|
|
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)
|