GeneralManager 0.16.1__py3-none-any.whl → 0.18.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 GeneralManager might be problematic. Click here for more details.
- general_manager/__init__.py +11 -1
- general_manager/_types/api.py +0 -1
- general_manager/_types/bucket.py +0 -1
- general_manager/_types/cache.py +0 -1
- general_manager/_types/factory.py +0 -1
- general_manager/_types/general_manager.py +0 -1
- general_manager/_types/interface.py +0 -1
- general_manager/_types/manager.py +0 -1
- general_manager/_types/measurement.py +0 -1
- general_manager/_types/permission.py +0 -1
- general_manager/_types/rule.py +0 -1
- general_manager/_types/utils.py +0 -1
- general_manager/api/__init__.py +13 -1
- general_manager/api/graphql.py +897 -147
- general_manager/api/graphql_subscription_consumer.py +432 -0
- general_manager/api/mutation.py +85 -23
- general_manager/api/property.py +39 -13
- general_manager/apps.py +336 -40
- general_manager/bucket/__init__.py +10 -1
- general_manager/bucket/calculationBucket.py +155 -53
- general_manager/bucket/databaseBucket.py +157 -45
- general_manager/bucket/groupBucket.py +133 -44
- general_manager/cache/__init__.py +10 -1
- general_manager/cache/dependencyIndex.py +303 -53
- general_manager/cache/signals.py +9 -2
- general_manager/factory/__init__.py +10 -1
- general_manager/factory/autoFactory.py +55 -13
- general_manager/factory/factories.py +110 -40
- general_manager/factory/factoryMethods.py +122 -34
- general_manager/interface/__init__.py +10 -1
- general_manager/interface/baseInterface.py +129 -36
- general_manager/interface/calculationInterface.py +35 -18
- general_manager/interface/databaseBasedInterface.py +71 -45
- general_manager/interface/databaseInterface.py +96 -38
- general_manager/interface/models.py +5 -5
- general_manager/interface/readOnlyInterface.py +94 -20
- general_manager/manager/__init__.py +10 -1
- general_manager/manager/generalManager.py +25 -16
- general_manager/manager/groupManager.py +21 -7
- general_manager/manager/meta.py +84 -16
- general_manager/measurement/__init__.py +10 -1
- general_manager/measurement/measurement.py +289 -95
- general_manager/measurement/measurementField.py +42 -31
- general_manager/permission/__init__.py +10 -1
- general_manager/permission/basePermission.py +120 -38
- general_manager/permission/managerBasedPermission.py +72 -21
- general_manager/permission/mutationPermission.py +14 -9
- general_manager/permission/permissionChecks.py +14 -12
- general_manager/permission/permissionDataManager.py +24 -11
- general_manager/permission/utils.py +34 -6
- general_manager/public_api_registry.py +36 -10
- general_manager/rule/__init__.py +10 -1
- general_manager/rule/handler.py +133 -44
- general_manager/rule/rule.py +178 -39
- general_manager/utils/__init__.py +10 -1
- general_manager/utils/argsToKwargs.py +34 -9
- general_manager/utils/filterParser.py +22 -7
- general_manager/utils/formatString.py +1 -0
- general_manager/utils/pathMapping.py +23 -15
- general_manager/utils/public_api.py +33 -2
- general_manager/utils/testing.py +49 -42
- {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/METADATA +3 -1
- generalmanager-0.18.0.dist-info/RECORD +77 -0
- {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/licenses/LICENSE +1 -1
- generalmanager-0.16.1.dist-info/RECORD +0 -76
- {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/WHEEL +0 -0
- {generalmanager-0.16.1.dist-info → generalmanager-0.18.0.dist-info}/top_level.txt +0 -0
|
@@ -10,43 +10,53 @@ import pint
|
|
|
10
10
|
from general_manager.measurement.measurement import Measurement, ureg, currency_units
|
|
11
11
|
from django.db.backends.base.base import BaseDatabaseWrapper
|
|
12
12
|
from django.db.models import Lookup, Transform
|
|
13
|
-
from typing import Any,
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MeasurementFieldNotEditableError(ValidationError):
|
|
17
|
+
"""Raised when attempting to modify a non-editable MeasurementField."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, field_name: str) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Initialize the exception indicating an attempt to assign to a non-editable measurement field.
|
|
22
|
+
|
|
23
|
+
Parameters:
|
|
24
|
+
field_name (str): Name of the field that was attempted to be modified; used to compose the error message.
|
|
25
|
+
"""
|
|
26
|
+
super().__init__(f"{field_name} is not editable.")
|
|
14
27
|
|
|
15
28
|
|
|
16
29
|
class MeasurementField(models.Field):
|
|
17
30
|
description = "Stores a measurement (value + unit) but exposes a single field API"
|
|
18
31
|
|
|
19
|
-
empty_values:
|
|
32
|
+
empty_values: tuple[object, ...] = (None, "", [], (), {})
|
|
20
33
|
|
|
21
34
|
def __init__(
|
|
22
35
|
self,
|
|
23
36
|
base_unit: str,
|
|
24
|
-
*args:
|
|
37
|
+
*args: Any,
|
|
25
38
|
null: bool = False,
|
|
26
39
|
blank: bool = False,
|
|
27
40
|
editable: bool = True,
|
|
28
|
-
**kwargs:
|
|
41
|
+
**kwargs: Any,
|
|
29
42
|
) -> None:
|
|
30
43
|
"""
|
|
31
44
|
Configure a measurement field backed by separate value and unit columns.
|
|
32
45
|
|
|
33
46
|
Parameters:
|
|
34
|
-
base_unit (str): Canonical unit used
|
|
35
|
-
args
|
|
36
|
-
null (bool): If True, the measurement may be stored as NULL.
|
|
37
|
-
blank (bool): If True, forms may
|
|
38
|
-
editable (bool): If False, assignments through the API
|
|
39
|
-
kwargs
|
|
40
|
-
|
|
41
|
-
Returns:
|
|
42
|
-
None
|
|
47
|
+
base_unit (str): Canonical unit used to normalise stored measurements.
|
|
48
|
+
*args: Positional arguments forwarded to the base Field implementation.
|
|
49
|
+
null (bool): If True, the measurement may be stored as NULL in the database.
|
|
50
|
+
blank (bool): If True, forms may accept an empty value for this field.
|
|
51
|
+
editable (bool): If False, assignments through the model API are rejected.
|
|
52
|
+
**kwargs: Additional keyword arguments forwarded to the base Field implementation.
|
|
43
53
|
"""
|
|
44
54
|
self.base_unit = base_unit
|
|
45
55
|
self.base_dimension = ureg.parse_expression(self.base_unit).dimensionality
|
|
46
56
|
|
|
47
57
|
self.editable = editable
|
|
48
|
-
self.value_field: models.
|
|
49
|
-
self.unit_field: models.
|
|
58
|
+
self.value_field: models.Field[Any, Any]
|
|
59
|
+
self.unit_field: models.Field[Any, Any]
|
|
50
60
|
if null:
|
|
51
61
|
self.value_field = models.DecimalField(
|
|
52
62
|
max_digits=30,
|
|
@@ -78,7 +88,13 @@ class MeasurementField(models.Field):
|
|
|
78
88
|
blank=blank,
|
|
79
89
|
)
|
|
80
90
|
|
|
81
|
-
|
|
91
|
+
options: dict[str, Any] = {
|
|
92
|
+
**kwargs,
|
|
93
|
+
"null": null,
|
|
94
|
+
"blank": blank,
|
|
95
|
+
"editable": editable,
|
|
96
|
+
}
|
|
97
|
+
super().__init__(*args, **options)
|
|
82
98
|
|
|
83
99
|
def contribute_to_class(
|
|
84
100
|
self,
|
|
@@ -88,16 +104,13 @@ class MeasurementField(models.Field):
|
|
|
88
104
|
**kwargs: object,
|
|
89
105
|
) -> None:
|
|
90
106
|
"""
|
|
91
|
-
Attach the measurement field and its backing
|
|
107
|
+
Attach the measurement field and its backing value and unit fields to the model and install the descriptor.
|
|
92
108
|
|
|
93
109
|
Parameters:
|
|
94
|
-
cls
|
|
95
|
-
name
|
|
96
|
-
private_only
|
|
97
|
-
kwargs
|
|
98
|
-
|
|
99
|
-
Returns:
|
|
100
|
-
None
|
|
110
|
+
cls: Model class receiving the field.
|
|
111
|
+
name: Attribute name to use on the model for this field.
|
|
112
|
+
private_only: Whether the field should be treated as private.
|
|
113
|
+
kwargs: Additional options forwarded to the base implementation.
|
|
101
114
|
"""
|
|
102
115
|
super().contribute_to_class(cls, name, private_only=private_only, **kwargs)
|
|
103
116
|
self.concrete = False
|
|
@@ -289,20 +302,18 @@ class MeasurementField(models.Field):
|
|
|
289
302
|
value: Measurement | str | None,
|
|
290
303
|
) -> None:
|
|
291
304
|
"""
|
|
292
|
-
|
|
305
|
+
Set a measurement on a model instance after validating editability, type, and unit compatibility.
|
|
293
306
|
|
|
294
307
|
Parameters:
|
|
295
308
|
instance (models.Model): Model instance receiving the value.
|
|
296
|
-
value (Measurement | str | None): Measurement
|
|
297
|
-
|
|
298
|
-
Returns:
|
|
299
|
-
None
|
|
309
|
+
value (Measurement | str | None): A Measurement, a string parseable to a Measurement, or None to clear the field.
|
|
300
310
|
|
|
301
311
|
Raises:
|
|
302
|
-
|
|
312
|
+
MeasurementFieldNotEditableError: If the field is not editable.
|
|
313
|
+
ValidationError: If the value is not a Measurement (or valid parseable string), if currency unit rules are violated, or if the unit is incompatible with the field's base unit.
|
|
303
314
|
"""
|
|
304
315
|
if not self.editable:
|
|
305
|
-
raise
|
|
316
|
+
raise MeasurementFieldNotEditableError(self.name)
|
|
306
317
|
if value is None:
|
|
307
318
|
setattr(instance, self.value_attr, None)
|
|
308
319
|
setattr(instance, self.unit_attr, None)
|
|
@@ -12,10 +12,19 @@ __all__ = list(PERMISSION_EXPORTS)
|
|
|
12
12
|
_MODULE_MAP = PERMISSION_EXPORTS
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
|
-
from general_manager._types.permission import * # noqa:
|
|
15
|
+
from general_manager._types.permission import * # noqa: F403
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def __getattr__(name: str) -> Any:
|
|
19
|
+
"""
|
|
20
|
+
Resolve and return a lazily-exposed public attribute for this module.
|
|
21
|
+
|
|
22
|
+
Parameters:
|
|
23
|
+
name (str): The attribute name being accessed on the module.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Any: The resolved attribute value associated with `name`.
|
|
27
|
+
"""
|
|
19
28
|
return resolve_export(
|
|
20
29
|
name,
|
|
21
30
|
module_all=__all__,
|
|
@@ -2,20 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
|
-
from typing import TYPE_CHECKING, Any, Literal
|
|
6
|
-
from general_manager.permission.permissionChecks import
|
|
7
|
-
permission_functions,
|
|
8
|
-
permission_filter,
|
|
9
|
-
)
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast
|
|
6
|
+
from general_manager.permission.permissionChecks import permission_functions
|
|
10
7
|
|
|
11
|
-
from django.contrib.auth.models import AnonymousUser, AbstractUser
|
|
8
|
+
from django.contrib.auth.models import AnonymousUser, AbstractBaseUser, AbstractUser
|
|
12
9
|
from general_manager.permission.permissionDataManager import PermissionDataManager
|
|
13
|
-
from general_manager.permission.utils import
|
|
10
|
+
from general_manager.permission.utils import (
|
|
11
|
+
validatePermissionString,
|
|
12
|
+
PermissionNotFoundError,
|
|
13
|
+
)
|
|
14
|
+
import logging
|
|
14
15
|
|
|
15
16
|
if TYPE_CHECKING:
|
|
16
17
|
from general_manager.manager.generalManager import GeneralManager
|
|
17
18
|
from general_manager.manager.meta import GeneralManagerMeta
|
|
18
19
|
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
UserLike: TypeAlias = AbstractBaseUser | AnonymousUser
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PermissionCheckError(PermissionError):
|
|
26
|
+
"""Raised when permission evaluation fails for a user."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, user: UserLike, errors: list[str]) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Initialize a PermissionCheckError carrying the requesting user's identity and permission failure details.
|
|
31
|
+
|
|
32
|
+
Parameters:
|
|
33
|
+
user (UserLike): The user for whom permission evaluation failed; if the user has an `id`, it is included in the error message, otherwise the user is labeled "anonymous".
|
|
34
|
+
errors (list[str]): A list of error messages describing individual permission failures.
|
|
35
|
+
"""
|
|
36
|
+
user_id = getattr(user, "id", None)
|
|
37
|
+
user_label = "anonymous" if user_id is None else f"id={user_id}"
|
|
38
|
+
super().__init__(
|
|
39
|
+
f"Permission denied for user {user_label} with errors: {errors}."
|
|
40
|
+
)
|
|
41
|
+
|
|
19
42
|
|
|
20
43
|
class BasePermission(ABC):
|
|
21
44
|
"""Abstract base class defining CRUD permission checks for managers."""
|
|
@@ -23,7 +46,7 @@ class BasePermission(ABC):
|
|
|
23
46
|
def __init__(
|
|
24
47
|
self,
|
|
25
48
|
instance: PermissionDataManager | GeneralManager | GeneralManagerMeta,
|
|
26
|
-
request_user:
|
|
49
|
+
request_user: UserLike,
|
|
27
50
|
) -> None:
|
|
28
51
|
"""Initialise the permission context for a specific manager and user."""
|
|
29
52
|
self._instance = instance
|
|
@@ -35,7 +58,7 @@ class BasePermission(ABC):
|
|
|
35
58
|
return self._instance
|
|
36
59
|
|
|
37
60
|
@property
|
|
38
|
-
def request_user(self) ->
|
|
61
|
+
def request_user(self) -> UserLike:
|
|
39
62
|
"""Return the user being evaluated for permission checks."""
|
|
40
63
|
return self._request_user
|
|
41
64
|
|
|
@@ -44,9 +67,21 @@ class BasePermission(ABC):
|
|
|
44
67
|
cls,
|
|
45
68
|
data: dict[str, Any],
|
|
46
69
|
manager: type[GeneralManager],
|
|
47
|
-
request_user:
|
|
70
|
+
request_user: UserLike | Any,
|
|
48
71
|
) -> None:
|
|
49
|
-
"""
|
|
72
|
+
"""
|
|
73
|
+
Validate that the requesting user is allowed to create each attribute in the provided payload.
|
|
74
|
+
|
|
75
|
+
Checks create permission for every key in `data` using the given `manager`. If any attribute is not permitted, raises a PermissionCheckError that includes the evaluated user and a list of denial messages.
|
|
76
|
+
|
|
77
|
+
Parameters:
|
|
78
|
+
data (dict[str, Any]): Mapping of attribute names to the values intended for creation.
|
|
79
|
+
manager (type[GeneralManager]): Manager class that defines the model/schema against which permissions are checked.
|
|
80
|
+
request_user (UserLike | Any): User instance or user id (will be resolved to a user or AnonymousUser).
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
PermissionCheckError: If one or more attributes in `data` are denied for the resolved `request_user`.
|
|
84
|
+
"""
|
|
50
85
|
request_user = cls.getUserWithId(request_user)
|
|
51
86
|
errors = []
|
|
52
87
|
permission_data = PermissionDataManager(permission_data=data, manager=manager)
|
|
@@ -54,22 +89,31 @@ class BasePermission(ABC):
|
|
|
54
89
|
for key in data.keys():
|
|
55
90
|
is_allowed = Permission.checkPermission("create", key)
|
|
56
91
|
if not is_allowed:
|
|
57
|
-
|
|
92
|
+
logger.debug(
|
|
58
93
|
f"Permission denied for {key} with value {data[key]} for user {request_user}"
|
|
59
94
|
)
|
|
95
|
+
errors.append(f"Create permission denied for attribute '{key}'")
|
|
60
96
|
if errors:
|
|
61
|
-
raise
|
|
62
|
-
f"Permission denied for user {request_user} with errors: {errors}"
|
|
63
|
-
)
|
|
97
|
+
raise PermissionCheckError(request_user, errors)
|
|
64
98
|
|
|
65
99
|
@classmethod
|
|
66
100
|
def checkUpdatePermission(
|
|
67
101
|
cls,
|
|
68
102
|
data: dict[str, Any],
|
|
69
103
|
old_manager_instance: GeneralManager,
|
|
70
|
-
request_user:
|
|
104
|
+
request_user: UserLike | Any,
|
|
71
105
|
) -> None:
|
|
72
|
-
"""
|
|
106
|
+
"""
|
|
107
|
+
Validate whether the request_user can update the given fields on an existing manager instance.
|
|
108
|
+
|
|
109
|
+
Parameters:
|
|
110
|
+
data (dict[str, Any]): Mapping of attribute names to new values to be applied.
|
|
111
|
+
old_manager_instance (GeneralManager): Existing manager instance whose current state is used to evaluate update permissions.
|
|
112
|
+
request_user (UserLike | Any): User instance or user id; non-user values will be resolved to a User or AnonymousUser via getUserWithId.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
PermissionCheckError: Raised with a list of error messages when one or more fields are not permitted to be updated.
|
|
116
|
+
"""
|
|
73
117
|
request_user = cls.getUserWithId(request_user)
|
|
74
118
|
|
|
75
119
|
errors = []
|
|
@@ -80,21 +124,31 @@ class BasePermission(ABC):
|
|
|
80
124
|
for key in data.keys():
|
|
81
125
|
is_allowed = Permission.checkPermission("update", key)
|
|
82
126
|
if not is_allowed:
|
|
83
|
-
|
|
127
|
+
logger.debug(
|
|
84
128
|
f"Permission denied for {key} with value {data[key]} for user {request_user}"
|
|
85
129
|
)
|
|
130
|
+
errors.append(f"Update permission denied for attribute '{key}'")
|
|
86
131
|
if errors:
|
|
87
|
-
raise
|
|
88
|
-
f"Permission denied for user {request_user} with errors: {errors}"
|
|
89
|
-
)
|
|
132
|
+
raise PermissionCheckError(request_user, errors)
|
|
90
133
|
|
|
91
134
|
@classmethod
|
|
92
135
|
def checkDeletePermission(
|
|
93
136
|
cls,
|
|
94
137
|
manager_instance: GeneralManager,
|
|
95
|
-
request_user:
|
|
138
|
+
request_user: UserLike | Any,
|
|
96
139
|
) -> None:
|
|
97
|
-
"""
|
|
140
|
+
"""
|
|
141
|
+
Validate that the request_user has delete permission for every attribute of the given manager instance.
|
|
142
|
+
|
|
143
|
+
This resolves the provided request_user to a User/AnonymousUser, evaluates delete permission for each attribute present on manager_instance, collects any denied attributes into error messages, and raises PermissionCheckError if any permissions are denied.
|
|
144
|
+
|
|
145
|
+
Parameters:
|
|
146
|
+
manager_instance (GeneralManager): The manager object whose attributes will be checked for delete permission.
|
|
147
|
+
request_user (UserLike | Any): The user (or user id) to evaluate; non-user values will be resolved to AnonymousUser.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
PermissionCheckError: If one or more attributes are not permitted for deletion by request_user. The exception carries the user and the list of denial messages.
|
|
151
|
+
"""
|
|
98
152
|
request_user = cls.getUserWithId(request_user)
|
|
99
153
|
|
|
100
154
|
errors = []
|
|
@@ -103,22 +157,32 @@ class BasePermission(ABC):
|
|
|
103
157
|
for key in manager_instance.__dict__.keys():
|
|
104
158
|
is_allowed = Permission.checkPermission("delete", key)
|
|
105
159
|
if not is_allowed:
|
|
106
|
-
|
|
160
|
+
logger.debug(
|
|
107
161
|
f"Permission denied for {key} with value {getattr(manager_instance, key)} for user {request_user}"
|
|
108
162
|
)
|
|
163
|
+
errors.append(f"Delete permission denied for attribute '{key}'")
|
|
109
164
|
if errors:
|
|
110
|
-
raise
|
|
111
|
-
f"Permission denied for user {request_user} with errors: {errors}"
|
|
112
|
-
)
|
|
165
|
+
raise PermissionCheckError(request_user, errors)
|
|
113
166
|
|
|
114
167
|
@staticmethod
|
|
115
168
|
def getUserWithId(
|
|
116
|
-
user: Any |
|
|
117
|
-
) ->
|
|
118
|
-
"""
|
|
119
|
-
|
|
169
|
+
user: Any | UserLike,
|
|
170
|
+
) -> UserLike:
|
|
171
|
+
"""
|
|
172
|
+
Resolve a user identifier or user-like object to a Django User or AnonymousUser instance.
|
|
173
|
+
|
|
174
|
+
If the input is already an AbstractBaseUser or AnonymousUser, it is returned unchanged. If the input is a primary key (or other value used to look up a User by id), the corresponding User is returned; if no such User exists, an AnonymousUser is returned.
|
|
175
|
+
|
|
176
|
+
Parameters:
|
|
177
|
+
user (Any | UserLike): A user object or a value to look up a User by primary key.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
UserLike: The resolved User instance, or an AnonymousUser when no matching User is found.
|
|
181
|
+
"""
|
|
182
|
+
from django.contrib.auth import get_user_model
|
|
120
183
|
|
|
121
|
-
|
|
184
|
+
User = get_user_model()
|
|
185
|
+
if isinstance(user, (AbstractBaseUser, AnonymousUser)):
|
|
122
186
|
return user
|
|
123
187
|
try:
|
|
124
188
|
return User.objects.get(id=user)
|
|
@@ -129,14 +193,14 @@ class BasePermission(ABC):
|
|
|
129
193
|
def checkPermission(
|
|
130
194
|
self,
|
|
131
195
|
action: Literal["create", "read", "update", "delete"],
|
|
132
|
-
|
|
196
|
+
attribute: str,
|
|
133
197
|
) -> bool:
|
|
134
198
|
"""
|
|
135
199
|
Determine whether the given action is permitted on the specified attribute.
|
|
136
200
|
|
|
137
201
|
Parameters:
|
|
138
202
|
action (Literal["create", "read", "update", "delete"]): Operation being checked.
|
|
139
|
-
|
|
203
|
+
attribute (str): Attribute name subject to the permission check.
|
|
140
204
|
|
|
141
205
|
Returns:
|
|
142
206
|
bool: True when the action is allowed.
|
|
@@ -152,13 +216,27 @@ class BasePermission(ABC):
|
|
|
152
216
|
def _getPermissionFilter(
|
|
153
217
|
self, permission: str
|
|
154
218
|
) -> dict[Literal["filter", "exclude"], dict[str, str]]:
|
|
155
|
-
"""
|
|
219
|
+
"""
|
|
220
|
+
Resolve the filter/exclude constraints associated with a permission expression.
|
|
221
|
+
|
|
222
|
+
Parameters:
|
|
223
|
+
permission (str): Permission expression of the form "<function_name>[:config,...]"; the leading name selects a permission function and the optional colon-separated values are passed as configuration.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
dict: A mapping with keys "filter" and "exclude", each a dict[str, str] describing query constraints to apply. If the resolved permission provides no constraints, both will be empty dicts.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
PermissionNotFoundError: If no permission function matches the leading name in `permission`.
|
|
230
|
+
"""
|
|
156
231
|
permission_function, *config = permission.split(":")
|
|
157
232
|
if permission_function not in permission_functions:
|
|
158
|
-
raise
|
|
233
|
+
raise PermissionNotFoundError(permission)
|
|
159
234
|
permission_filter = permission_functions[permission_function][
|
|
160
235
|
"permission_filter"
|
|
161
|
-
](
|
|
236
|
+
](
|
|
237
|
+
cast(AbstractUser | AnonymousUser, self.request_user),
|
|
238
|
+
config,
|
|
239
|
+
)
|
|
162
240
|
if permission_filter is None:
|
|
163
241
|
return {"filter": {}, "exclude": {}}
|
|
164
242
|
return permission_filter
|
|
@@ -176,4 +254,8 @@ class BasePermission(ABC):
|
|
|
176
254
|
Returns:
|
|
177
255
|
bool: True when every sub-permission evaluates to True for the current user.
|
|
178
256
|
"""
|
|
179
|
-
return validatePermissionString(
|
|
257
|
+
return validatePermissionString(
|
|
258
|
+
permission,
|
|
259
|
+
self.instance,
|
|
260
|
+
cast(AbstractUser | AnonymousUser, self.request_user),
|
|
261
|
+
)
|
|
@@ -19,6 +19,47 @@ type permission_type = Literal[
|
|
|
19
19
|
]
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
class InvalidBasedOnConfigurationError(ValueError):
|
|
23
|
+
"""Raised when the configured `__based_on__` attribute is missing or invalid."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, attribute_name: str) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Initialize the exception for an invalid or missing based-on configuration attribute.
|
|
28
|
+
|
|
29
|
+
Parameters:
|
|
30
|
+
attribute_name (str): Name of the configured `__based_on__` attribute that is missing or invalid.
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(
|
|
33
|
+
f"Based on configuration '{attribute_name}' is not valid or does not exist."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InvalidBasedOnTypeError(TypeError):
|
|
38
|
+
"""Raised when the `__based_on__` attribute does not resolve to a GeneralManager."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, attribute_name: str) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Initialize the exception indicating that the configured based-on attribute does not resolve to a GeneralManager.
|
|
43
|
+
|
|
44
|
+
Parameters:
|
|
45
|
+
attribute_name (str): Name of the configured based-on attribute that failed type validation; included in the exception message.
|
|
46
|
+
"""
|
|
47
|
+
super().__init__(f"Based on object {attribute_name} is not a GeneralManager.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class UnknownPermissionActionError(ValueError):
|
|
51
|
+
"""Raised when an unsupported permission action is encountered."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, action: str) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Initialize the exception for an unsupported permission action.
|
|
56
|
+
|
|
57
|
+
Parameters:
|
|
58
|
+
action (str): The permission action name that is not recognized; used to build the exception message "Action {action} not found."
|
|
59
|
+
"""
|
|
60
|
+
super().__init__(f"Action {action} not found.")
|
|
61
|
+
|
|
62
|
+
|
|
22
63
|
class notExistent:
|
|
23
64
|
pass
|
|
24
65
|
|
|
@@ -72,33 +113,33 @@ class ManagerBasedPermission(BasePermission):
|
|
|
72
113
|
|
|
73
114
|
def __getBasedOnPermission(self) -> Optional[BasePermission]:
|
|
74
115
|
"""
|
|
75
|
-
|
|
116
|
+
Resolve and return a BasePermission instance from the manager attribute named by the class-level `__based_on__` configuration.
|
|
117
|
+
|
|
118
|
+
If `__based_on__` is None or not configured on this class, returns None. If the referenced attribute exists on the target instance but is None, resets permissions to skip based-on evaluation and returns None. If the referenced attribute resolves to a manager that exposes a valid `Permission` subclass, constructs and returns that permission with the corresponding manager instance and the current request user.
|
|
76
119
|
|
|
77
120
|
Returns:
|
|
78
|
-
BasePermission | None:
|
|
121
|
+
BasePermission | None: The resolved permission instance for the related manager, or `None` when no based-on permission applies.
|
|
79
122
|
|
|
80
123
|
Raises:
|
|
81
|
-
|
|
82
|
-
|
|
124
|
+
InvalidBasedOnConfigurationError: If the configured `__based_on__` attribute does not exist on the target instance.
|
|
125
|
+
InvalidBasedOnTypeError: If the configured attribute exists but does not resolve to a `GeneralManager` or subclass.
|
|
83
126
|
"""
|
|
84
127
|
from general_manager.manager.generalManager import GeneralManager
|
|
85
128
|
|
|
86
|
-
__based_on__ =
|
|
129
|
+
__based_on__ = self.__based_on__
|
|
87
130
|
if __based_on__ is None:
|
|
88
131
|
return None
|
|
89
132
|
|
|
90
133
|
basis_object = getattr(self.instance, __based_on__, notExistent)
|
|
91
134
|
if basis_object is notExistent:
|
|
92
|
-
raise
|
|
93
|
-
f"Based on configuration '{__based_on__}' is not valid or does not exist."
|
|
94
|
-
)
|
|
135
|
+
raise InvalidBasedOnConfigurationError(__based_on__)
|
|
95
136
|
if basis_object is None:
|
|
96
137
|
self.__setPermissions(skip_based_on=True)
|
|
97
138
|
return None
|
|
98
139
|
if not isinstance(basis_object, GeneralManager) and not (
|
|
99
140
|
isinstance(basis_object, type) and issubclass(basis_object, GeneralManager)
|
|
100
141
|
):
|
|
101
|
-
raise
|
|
142
|
+
raise InvalidBasedOnTypeError(__based_on__)
|
|
102
143
|
|
|
103
144
|
Permission = getattr(basis_object, "Permission", None)
|
|
104
145
|
|
|
@@ -126,21 +167,24 @@ class ManagerBasedPermission(BasePermission):
|
|
|
126
167
|
def checkPermission(
|
|
127
168
|
self,
|
|
128
169
|
action: permission_type,
|
|
129
|
-
|
|
170
|
+
attribute: str,
|
|
130
171
|
) -> bool:
|
|
131
172
|
"""
|
|
132
|
-
Determine whether the user
|
|
173
|
+
Determine whether the request user is allowed to perform a CRUD action on a specific attribute.
|
|
133
174
|
|
|
134
175
|
Parameters:
|
|
135
|
-
action (permission_type): CRUD
|
|
136
|
-
|
|
176
|
+
action (permission_type): CRUD action to evaluate ("create", "read", "update", "delete").
|
|
177
|
+
attribute (str): Name of the attribute to check permission for.
|
|
137
178
|
|
|
138
179
|
Returns:
|
|
139
|
-
bool: True
|
|
180
|
+
bool: True if the action is permitted on the attribute, False otherwise.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
UnknownPermissionActionError: If `action` is not one of "create", "read", "update", or "delete".
|
|
140
184
|
"""
|
|
141
185
|
if (
|
|
142
186
|
self.__based_on_permission
|
|
143
|
-
and not self.__based_on_permission.checkPermission(action,
|
|
187
|
+
and not self.__based_on_permission.checkPermission(action, attribute)
|
|
144
188
|
):
|
|
145
189
|
return False
|
|
146
190
|
|
|
@@ -153,11 +197,11 @@ class ManagerBasedPermission(BasePermission):
|
|
|
153
197
|
elif action == "delete":
|
|
154
198
|
permissions = self.__delete__
|
|
155
199
|
else:
|
|
156
|
-
raise
|
|
200
|
+
raise UnknownPermissionActionError(action)
|
|
157
201
|
|
|
158
202
|
has_attribute_permissions = (
|
|
159
|
-
|
|
160
|
-
and action in self.__attribute_permissions[
|
|
203
|
+
attribute in self.__attribute_permissions
|
|
204
|
+
and action in self.__attribute_permissions[attribute]
|
|
161
205
|
)
|
|
162
206
|
|
|
163
207
|
if not has_attribute_permissions:
|
|
@@ -167,7 +211,7 @@ class ManagerBasedPermission(BasePermission):
|
|
|
167
211
|
attribute_permission = True
|
|
168
212
|
else:
|
|
169
213
|
attribute_permission = self.__checkSpecificPermission(
|
|
170
|
-
self.__attribute_permissions[
|
|
214
|
+
self.__attribute_permissions[attribute][action]
|
|
171
215
|
)
|
|
172
216
|
|
|
173
217
|
permission = self.__checkSpecificPermission(permissions)
|
|
@@ -189,8 +233,15 @@ class ManagerBasedPermission(BasePermission):
|
|
|
189
233
|
def getPermissionFilter(
|
|
190
234
|
self,
|
|
191
235
|
) -> list[dict[Literal["filter", "exclude"], dict[str, str]]]:
|
|
192
|
-
"""
|
|
193
|
-
|
|
236
|
+
"""
|
|
237
|
+
Builds queryset filter and exclude mappings derived from this permission configuration.
|
|
238
|
+
|
|
239
|
+
If a based-on permission exists, its filters and excludes are included with each key prefixed by the name in __based_on__. Then appends filters produced from this class's read permissions via _getPermissionFilter.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
list[dict[Literal["filter", "exclude"], dict[str, str]]]: A list of dictionaries each containing "filter" and "exclude" mappings where keys are queryset lookups and values are lookup values.
|
|
243
|
+
"""
|
|
244
|
+
__based_on__ = self.__based_on__
|
|
194
245
|
filters: list[dict[Literal["filter", "exclude"], dict[str, str]]] = []
|
|
195
246
|
|
|
196
247
|
if self.__based_on_permission is not None:
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
from django.contrib.auth.models import AbstractUser, AnonymousUser
|
|
5
5
|
from typing import Any
|
|
6
|
-
from general_manager.permission.basePermission import
|
|
6
|
+
from general_manager.permission.basePermission import (
|
|
7
|
+
BasePermission,
|
|
8
|
+
PermissionCheckError,
|
|
9
|
+
)
|
|
7
10
|
|
|
8
11
|
from general_manager.permission.permissionDataManager import PermissionDataManager
|
|
9
12
|
from general_manager.permission.utils import validatePermissionString
|
|
@@ -57,14 +60,14 @@ class MutationPermission:
|
|
|
57
60
|
request_user: AbstractUser | AnonymousUser | Any,
|
|
58
61
|
) -> None:
|
|
59
62
|
"""
|
|
60
|
-
Validate that
|
|
63
|
+
Validate that the given user is authorized to perform the mutation described by `data`.
|
|
61
64
|
|
|
62
65
|
Parameters:
|
|
63
|
-
data (dict[str, Any]): Mutation payload.
|
|
64
|
-
request_user (AbstractUser | AnonymousUser | Any):
|
|
66
|
+
data (dict[str, Any]): Mutation payload mapping field names to values.
|
|
67
|
+
request_user (AbstractUser | AnonymousUser | Any): A user object or a user identifier; if an identifier is provided it will be resolved to a user.
|
|
65
68
|
|
|
66
69
|
Raises:
|
|
67
|
-
|
|
70
|
+
PermissionCheckError: Raised with the `request_user` and a list of field-level error messages when one or more fields fail their permission checks.
|
|
68
71
|
"""
|
|
69
72
|
errors = []
|
|
70
73
|
if not isinstance(request_user, (AbstractUser, AnonymousUser)):
|
|
@@ -76,20 +79,22 @@ class MutationPermission:
|
|
|
76
79
|
f"Permission denied for {key} with value {data[key]} for user {request_user}"
|
|
77
80
|
)
|
|
78
81
|
if errors:
|
|
79
|
-
raise
|
|
82
|
+
raise PermissionCheckError(request_user, errors)
|
|
80
83
|
|
|
81
84
|
def checkPermission(
|
|
82
85
|
self,
|
|
83
86
|
attribute: str,
|
|
84
87
|
) -> bool:
|
|
85
88
|
"""
|
|
86
|
-
|
|
89
|
+
Determine whether the request user is allowed to modify a specific attribute in the mutation payload.
|
|
90
|
+
|
|
91
|
+
Updates the instance's cached overall permission result based on the class-level mutate permissions.
|
|
87
92
|
|
|
88
93
|
Parameters:
|
|
89
|
-
attribute (str):
|
|
94
|
+
attribute (str): Name of the attribute to validate.
|
|
90
95
|
|
|
91
96
|
Returns:
|
|
92
|
-
|
|
97
|
+
True if modification of the attribute is allowed, False otherwise.
|
|
93
98
|
"""
|
|
94
99
|
|
|
95
100
|
has_attribute_permissions = attribute in self.__attribute_permissions
|