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
|
@@ -5,6 +5,7 @@ import time
|
|
|
5
5
|
import ast
|
|
6
6
|
import re
|
|
7
7
|
import logging
|
|
8
|
+
from datetime import date, datetime
|
|
8
9
|
|
|
9
10
|
from django.core.cache import cache
|
|
10
11
|
from general_manager.cache.signals import post_data_change, pre_data_change
|
|
@@ -32,6 +33,22 @@ type Dependency = Tuple[general_manager_name, filter_type, str]
|
|
|
32
33
|
|
|
33
34
|
logger = logging.getLogger(__name__)
|
|
34
35
|
|
|
36
|
+
|
|
37
|
+
class DependencyLockTimeoutError(TimeoutError):
|
|
38
|
+
"""Raised when the dependency index lock cannot be acquired within the timeout."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, operation: str) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Error raised when acquiring the dependency index lock times out.
|
|
43
|
+
|
|
44
|
+
Parameters:
|
|
45
|
+
operation (str): Name or description of the operation during which lock acquisition timed out.
|
|
46
|
+
"""
|
|
47
|
+
super().__init__(
|
|
48
|
+
f"Timed out acquiring dependency index lock during {operation}."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
35
52
|
# -----------------------------------------------------------------------------
|
|
36
53
|
# CONFIG
|
|
37
54
|
# -----------------------------------------------------------------------------
|
|
@@ -39,6 +56,7 @@ INDEX_KEY = "dependency_index" # Cache key storing the complete dependency inde
|
|
|
39
56
|
LOCK_KEY = "dependency_index_lock" # Cache key used for the dependency lock
|
|
40
57
|
LOCK_TIMEOUT = 5 # Lock TTL in seconds
|
|
41
58
|
UNDEFINED = object() # Sentinel for undefined values
|
|
59
|
+
ACTIONS: tuple[Literal["filter"], Literal["exclude"]] = ("filter", "exclude")
|
|
42
60
|
|
|
43
61
|
|
|
44
62
|
# -----------------------------------------------------------------------------
|
|
@@ -112,23 +130,22 @@ def record_dependencies(
|
|
|
112
130
|
],
|
|
113
131
|
) -> None:
|
|
114
132
|
"""
|
|
115
|
-
Register cache
|
|
133
|
+
Register a cache key as dependent on the given manager-level filters, exclusions, or identifications.
|
|
116
134
|
|
|
117
135
|
Parameters:
|
|
118
|
-
cache_key (str):
|
|
136
|
+
cache_key (str): The cache key to associate with the declared dependencies.
|
|
119
137
|
dependencies (Iterable[tuple[str, Literal["filter", "exclude", "identification"], str]]):
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
None
|
|
138
|
+
Iterable of tuples describing each dependency as (manager_name, action, identifier).
|
|
139
|
+
- For `filter` and `exclude`, `identifier` is a string representation of a mapping of lookups to values.
|
|
140
|
+
- For `identification`, `identifier` is the identifying value (treated as a lookup on `id`).
|
|
124
141
|
|
|
125
142
|
Raises:
|
|
126
|
-
|
|
143
|
+
DependencyLockTimeoutError: If a lock cannot be acquired within the configured timeout while updating the index.
|
|
127
144
|
"""
|
|
128
145
|
start = time.time()
|
|
129
146
|
while not acquire_lock():
|
|
130
147
|
if time.time() - start > LOCK_TIMEOUT:
|
|
131
|
-
raise
|
|
148
|
+
raise DependencyLockTimeoutError("record_dependencies")
|
|
132
149
|
time.sleep(0.05)
|
|
133
150
|
|
|
134
151
|
try:
|
|
@@ -138,6 +155,11 @@ def record_dependencies(
|
|
|
138
155
|
action_key = cast(Literal["filter", "exclude"], action)
|
|
139
156
|
params = ast.literal_eval(identifier)
|
|
140
157
|
section = idx[action_key].setdefault(model_name, {})
|
|
158
|
+
if len(params) > 1:
|
|
159
|
+
cache_dependencies = section.setdefault(
|
|
160
|
+
"__cache_dependencies__", {}
|
|
161
|
+
)
|
|
162
|
+
cache_dependencies.setdefault(cache_key, set()).add(identifier)
|
|
141
163
|
for lookup, val in params.items():
|
|
142
164
|
lookup_map = section.setdefault(lookup, {})
|
|
143
165
|
val_key = repr(val)
|
|
@@ -161,29 +183,31 @@ def record_dependencies(
|
|
|
161
183
|
# -----------------------------------------------------------------------------
|
|
162
184
|
def remove_cache_key_from_index(cache_key: str) -> None:
|
|
163
185
|
"""
|
|
164
|
-
Remove a cache
|
|
186
|
+
Remove a cache key from all dependency mappings in the stored dependency index.
|
|
165
187
|
|
|
166
|
-
|
|
167
|
-
cache_key (str): Cache key that should be expunged from the index.
|
|
188
|
+
Acquires the dependency lock to update and persist the index; if the lock cannot be obtained within LOCK_TIMEOUT the operation fails.
|
|
168
189
|
|
|
169
|
-
|
|
170
|
-
|
|
190
|
+
Parameters:
|
|
191
|
+
cache_key (str): The cache key to expunge from all recorded filter, exclude, and identification mappings.
|
|
171
192
|
|
|
172
193
|
Raises:
|
|
173
|
-
|
|
194
|
+
DependencyLockTimeoutError: If the dependency lock cannot be acquired within LOCK_TIMEOUT.
|
|
174
195
|
"""
|
|
175
196
|
start = time.time()
|
|
176
197
|
while not acquire_lock():
|
|
177
198
|
if time.time() - start > LOCK_TIMEOUT:
|
|
178
|
-
raise
|
|
199
|
+
raise DependencyLockTimeoutError("remove_cache_key_from_index")
|
|
179
200
|
time.sleep(0.05)
|
|
180
201
|
|
|
181
202
|
try:
|
|
182
203
|
idx = get_full_index()
|
|
183
|
-
for action in
|
|
184
|
-
action_section = idx
|
|
204
|
+
for action in ACTIONS:
|
|
205
|
+
action_section = idx[action]
|
|
185
206
|
for mname, model_section in list(action_section.items()):
|
|
207
|
+
cache_dependencies = model_section.get("__cache_dependencies__", {})
|
|
186
208
|
for lookup, lookup_map in list(model_section.items()):
|
|
209
|
+
if lookup.startswith("__"):
|
|
210
|
+
continue
|
|
187
211
|
for val_key, key_set in list(lookup_map.items()):
|
|
188
212
|
if cache_key in key_set:
|
|
189
213
|
key_set.remove(cache_key)
|
|
@@ -191,6 +215,10 @@ def remove_cache_key_from_index(cache_key: str) -> None:
|
|
|
191
215
|
del lookup_map[val_key]
|
|
192
216
|
if not lookup_map:
|
|
193
217
|
del model_section[lookup]
|
|
218
|
+
if cache_dependencies:
|
|
219
|
+
cache_dependencies.pop(cache_key, None)
|
|
220
|
+
if not cache_dependencies:
|
|
221
|
+
model_section.pop("__cache_dependencies__", None)
|
|
194
222
|
if not model_section:
|
|
195
223
|
del action_section[mname]
|
|
196
224
|
set_full_index(idx)
|
|
@@ -221,15 +249,10 @@ def capture_old_values(
|
|
|
221
249
|
**kwargs: object,
|
|
222
250
|
) -> None:
|
|
223
251
|
"""
|
|
224
|
-
|
|
252
|
+
Record the current values of fields referenced by tracked filters on the given manager instance before it changes.
|
|
225
253
|
|
|
226
254
|
Parameters:
|
|
227
|
-
|
|
228
|
-
instance (GeneralManager | None): Manager instance about to change.
|
|
229
|
-
**kwargs: Additional signal metadata.
|
|
230
|
-
|
|
231
|
-
Returns:
|
|
232
|
-
None
|
|
255
|
+
instance (GeneralManager | None): Manager instance about to change; if provided, this function sets instance._old_values to a mapping of lookup keys to their current values for use by post-change invalidation logic.
|
|
233
256
|
"""
|
|
234
257
|
if instance is None:
|
|
235
258
|
return
|
|
@@ -237,8 +260,16 @@ def capture_old_values(
|
|
|
237
260
|
idx = get_full_index()
|
|
238
261
|
# get all lookups for this model
|
|
239
262
|
lookups = set()
|
|
240
|
-
for action in
|
|
241
|
-
|
|
263
|
+
for action in ACTIONS:
|
|
264
|
+
model_section = idx[action].get(manager_name)
|
|
265
|
+
if isinstance(model_section, dict):
|
|
266
|
+
lookups |= {
|
|
267
|
+
lookup
|
|
268
|
+
for lookup in model_section.keys()
|
|
269
|
+
if isinstance(lookup, str) and not lookup.startswith("__")
|
|
270
|
+
}
|
|
271
|
+
elif isinstance(model_section, list):
|
|
272
|
+
lookups |= set(model_section)
|
|
242
273
|
if lookups and instance.identification:
|
|
243
274
|
# save old values for later comparison
|
|
244
275
|
vals: dict[str, object] = {}
|
|
@@ -251,7 +282,7 @@ def capture_old_values(
|
|
|
251
282
|
break
|
|
252
283
|
current = getattr(current, attr, None)
|
|
253
284
|
vals[lookup] = current
|
|
254
|
-
|
|
285
|
+
instance._old_values = vals
|
|
255
286
|
|
|
256
287
|
|
|
257
288
|
@receiver(post_data_change)
|
|
@@ -262,41 +293,150 @@ def generic_cache_invalidation(
|
|
|
262
293
|
**kwargs: object,
|
|
263
294
|
) -> None:
|
|
264
295
|
"""
|
|
265
|
-
Invalidate
|
|
296
|
+
Invalidate cache entries whose recorded dependencies are affected by changes to a GeneralManager instance.
|
|
266
297
|
|
|
267
|
-
|
|
268
|
-
sender (type[GeneralManager]): Manager class that triggered the signal.
|
|
269
|
-
instance (GeneralManager): Updated manager instance.
|
|
270
|
-
old_relevant_values (dict[str, Any]): Previously captured values for tracked lookups.
|
|
271
|
-
**kwargs: Additional signal metadata.
|
|
298
|
+
Uses the dependency index to compare previously captured values against the instance's current values for tracked lookups, evaluates both simple and composite dependency conditions for "filter" and "exclude" actions, and for any dependency that warrants invalidation it deletes the corresponding cache entry and removes its references from the index.
|
|
272
299
|
|
|
273
|
-
|
|
274
|
-
|
|
300
|
+
Parameters:
|
|
301
|
+
sender (type[GeneralManager]): Manager class that emitted the signal.
|
|
302
|
+
instance (GeneralManager): The manager instance that was changed.
|
|
303
|
+
old_relevant_values (dict[str, Any]): Mapping of lookup paths (joined by "__") to their values as captured before the change; used to compare old vs. new values for invalidation decisions.
|
|
275
304
|
"""
|
|
276
305
|
manager_name = sender.__name__
|
|
277
306
|
idx = get_full_index()
|
|
278
307
|
|
|
308
|
+
def _safe_literal_eval(value: Any) -> Any:
|
|
309
|
+
if isinstance(value, str):
|
|
310
|
+
try:
|
|
311
|
+
return ast.literal_eval(value)
|
|
312
|
+
except (ValueError, SyntaxError):
|
|
313
|
+
return value
|
|
314
|
+
return value
|
|
315
|
+
|
|
316
|
+
def _coerce_to_type(sample: Any, raw: Any) -> Any | None:
|
|
317
|
+
"""
|
|
318
|
+
Coerces a raw value to match the type and semantics of a sample value.
|
|
319
|
+
|
|
320
|
+
Attempts to convert `raw` into the same type as `sample`. Handles:
|
|
321
|
+
- datetimes: parses ISO-like strings, preserves or aligns timezone info with `sample`,
|
|
322
|
+
- dates: parses ISO date strings,
|
|
323
|
+
- booleans: recognizes common textual and numeric boolean representations,
|
|
324
|
+
- other types: attempts to call the sample's type on `raw`.
|
|
325
|
+
|
|
326
|
+
Parameters:
|
|
327
|
+
sample: A value whose type and semantics should be used as the target.
|
|
328
|
+
raw: The input value to coerce.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
The coerced value of the same type as `sample`, or `None` if `raw` cannot be sensibly converted.
|
|
332
|
+
"""
|
|
333
|
+
if sample is None:
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
if isinstance(sample, datetime):
|
|
337
|
+
if isinstance(raw, datetime):
|
|
338
|
+
parsed = raw
|
|
339
|
+
elif isinstance(raw, str):
|
|
340
|
+
candidate = raw.replace("Z", "+00:00")
|
|
341
|
+
candidate = candidate.replace(" ", "T", 1)
|
|
342
|
+
try:
|
|
343
|
+
parsed = datetime.fromisoformat(candidate)
|
|
344
|
+
except ValueError:
|
|
345
|
+
return None
|
|
346
|
+
else:
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
if sample.tzinfo and parsed.tzinfo is None:
|
|
350
|
+
parsed = parsed.replace(tzinfo=sample.tzinfo)
|
|
351
|
+
elif not sample.tzinfo and parsed.tzinfo is not None:
|
|
352
|
+
parsed = parsed.replace(tzinfo=None)
|
|
353
|
+
return parsed
|
|
354
|
+
|
|
355
|
+
if isinstance(sample, date) and not isinstance(sample, datetime):
|
|
356
|
+
if isinstance(raw, date) and not isinstance(raw, datetime):
|
|
357
|
+
return raw
|
|
358
|
+
if isinstance(raw, str):
|
|
359
|
+
try:
|
|
360
|
+
return date.fromisoformat(raw)
|
|
361
|
+
except ValueError:
|
|
362
|
+
return None
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
# Booleans: avoid bool("False") == True
|
|
366
|
+
if isinstance(sample, bool):
|
|
367
|
+
if isinstance(raw, bool):
|
|
368
|
+
return raw
|
|
369
|
+
if isinstance(raw, (int,)):
|
|
370
|
+
return bool(raw)
|
|
371
|
+
if isinstance(raw, str):
|
|
372
|
+
s = raw.strip().lower()
|
|
373
|
+
if s in {"true", "1", "yes", "y", "t"}:
|
|
374
|
+
return True
|
|
375
|
+
if s in {"false", "0", "no", "n", "f"}:
|
|
376
|
+
return False
|
|
377
|
+
return None
|
|
378
|
+
try:
|
|
379
|
+
return type(sample)(raw) # type: ignore
|
|
380
|
+
except (TypeError, ValueError):
|
|
381
|
+
if isinstance(raw, type(sample)):
|
|
382
|
+
return raw
|
|
383
|
+
return None
|
|
384
|
+
|
|
279
385
|
def matches(op: str, value: Any, val_key: Any) -> bool:
|
|
386
|
+
"""
|
|
387
|
+
Evaluate whether a given value satisfies a lookup operation described by `op` and `val_key`.
|
|
388
|
+
|
|
389
|
+
Supports operators:
|
|
390
|
+
- "eq": equality; attempts to interpret `val_key` as a literal and coerce it to `value`'s type before comparing.
|
|
391
|
+
- "in": membership; expects `val_key` to be a literal iterable and checks if any coerced element equals `value`.
|
|
392
|
+
- "gt", "gte", "lt", "lte": numeric/date comparisons; `val_key` is interpreted as a literal and coerced to `value`'s type.
|
|
393
|
+
- "contains", "startswith", "endswith": string containment/prefix/suffix checks; both sides are compared as strings.
|
|
394
|
+
- "regex": treats `val_key` as a regular expression pattern and tests it against the string form of `value`.
|
|
395
|
+
|
|
396
|
+
Behavior notes:
|
|
397
|
+
- If `value` is None the function returns `False`.
|
|
398
|
+
- Literal parsing of `val_key` is attempted via AST literal evaluation; if parsing or coercion fails, the function falls back to conservative comparisons or returns `False` where appropriate.
|
|
399
|
+
- Regex patterns that fail to compile are treated as non-matching.
|
|
400
|
+
|
|
401
|
+
Parameters:
|
|
402
|
+
op (str): The lookup operator name (one of the supported operators above).
|
|
403
|
+
value (Any): The runtime value to test.
|
|
404
|
+
val_key (Any): The stored comparison key (often a string representation) to interpret for the comparison.
|
|
405
|
+
|
|
406
|
+
Returns:
|
|
407
|
+
bool: `True` if the comparison defined by `op` and `val_key` matches `value`, `False` otherwise.
|
|
408
|
+
"""
|
|
280
409
|
if value is None:
|
|
281
410
|
return False
|
|
282
411
|
|
|
283
412
|
# eq
|
|
284
413
|
if op == "eq":
|
|
285
|
-
|
|
414
|
+
literal_val = _safe_literal_eval(val_key)
|
|
415
|
+
comparable = _coerce_to_type(value, literal_val)
|
|
416
|
+
if comparable is None:
|
|
417
|
+
return repr(value) == val_key
|
|
418
|
+
return value == comparable
|
|
286
419
|
|
|
287
420
|
# in
|
|
288
421
|
if op == "in":
|
|
289
422
|
try:
|
|
290
423
|
seq = ast.literal_eval(val_key)
|
|
291
|
-
|
|
292
|
-
except:
|
|
424
|
+
except (ValueError, SyntaxError):
|
|
293
425
|
return False
|
|
426
|
+
for item in seq:
|
|
427
|
+
comparable = _coerce_to_type(value, item)
|
|
428
|
+
if comparable is not None:
|
|
429
|
+
if value == comparable:
|
|
430
|
+
return True
|
|
431
|
+
elif repr(value) == repr(item):
|
|
432
|
+
return True
|
|
433
|
+
return False
|
|
294
434
|
|
|
295
435
|
# range comparisons
|
|
296
436
|
if op in ("gt", "gte", "lt", "lte"):
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
437
|
+
literal_val = _safe_literal_eval(val_key)
|
|
438
|
+
thr = _coerce_to_type(value, literal_val)
|
|
439
|
+
if thr is None:
|
|
300
440
|
return False
|
|
301
441
|
if op == "gt":
|
|
302
442
|
return value > thr
|
|
@@ -311,7 +451,7 @@ def generic_cache_invalidation(
|
|
|
311
451
|
if op in ("contains", "startswith", "endswith", "regex"):
|
|
312
452
|
try:
|
|
313
453
|
literal = ast.literal_eval(val_key)
|
|
314
|
-
except
|
|
454
|
+
except (ValueError, SyntaxError):
|
|
315
455
|
literal = val_key
|
|
316
456
|
|
|
317
457
|
# ensure we always work with strings to avoid TypeErrors
|
|
@@ -325,16 +465,110 @@ def generic_cache_invalidation(
|
|
|
325
465
|
# regex: treat the stored key as the regex pattern
|
|
326
466
|
if op == "regex":
|
|
327
467
|
try:
|
|
328
|
-
|
|
468
|
+
pattern_source = (
|
|
469
|
+
literal if isinstance(literal, str) else str(literal)
|
|
470
|
+
)
|
|
471
|
+
pattern = re.compile(pattern_source)
|
|
329
472
|
except re.error:
|
|
330
473
|
return False
|
|
331
474
|
return bool(pattern.search(text))
|
|
332
475
|
|
|
333
476
|
return False
|
|
334
477
|
|
|
335
|
-
|
|
336
|
-
|
|
478
|
+
def current_value_for_path(path: list[str]) -> Any:
|
|
479
|
+
"""
|
|
480
|
+
Fetches the current value from the captured `instance` by following a sequence of attribute names.
|
|
481
|
+
|
|
482
|
+
Parameters:
|
|
483
|
+
path (list[str]): Ordered attribute names to traverse on the instance (e.g., ["user", "profile", "email"]).
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
The value found at the end of the attribute path, or `None` if any attribute along the path is missing.
|
|
487
|
+
"""
|
|
488
|
+
current: object = instance
|
|
489
|
+
for attr in path:
|
|
490
|
+
current = getattr(current, attr, UNDEFINED)
|
|
491
|
+
if current is UNDEFINED:
|
|
492
|
+
return None
|
|
493
|
+
return current
|
|
494
|
+
|
|
495
|
+
def evaluate_composite(
|
|
496
|
+
cache_key: str,
|
|
497
|
+
lookup_key: str,
|
|
498
|
+
action: Literal["filter", "exclude"],
|
|
499
|
+
model_section: dict[str, dict[str, set[str]]],
|
|
500
|
+
) -> bool | None:
|
|
501
|
+
"""
|
|
502
|
+
Determine whether a composite dependency (multiple lookup params grouped under a single identifier)
|
|
503
|
+
for a given cache key and lookup should cause cache invalidation.
|
|
504
|
+
|
|
505
|
+
Parameters:
|
|
506
|
+
cache_key (str): The cache key being evaluated.
|
|
507
|
+
lookup_key (str): The specific lookup (operator and attribute path joined by `"__"`) that prompted evaluation.
|
|
508
|
+
action (Literal["filter", "exclude"]): The dependency action context; "filter" treats a match as cause for invalidation,
|
|
509
|
+
"exclude" treats a change in match membership as cause for invalidation.
|
|
510
|
+
model_section (dict[str, dict[str, set[str]]]): The index section for the model containing lookup maps and an
|
|
511
|
+
optional "__cache_dependencies__" mapping from cache keys to sets of identifier strings (each identifier
|
|
512
|
+
encodes multiple lookup parameters).
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
bool | None: `True` if the composite dependency indicates the cache entry should be invalidated,
|
|
516
|
+
`False` if it indicates no invalidation is required, or `None` if there are no composite identifiers
|
|
517
|
+
registered for `cache_key`.
|
|
518
|
+
"""
|
|
519
|
+
cache_dependencies = model_section.get("__cache_dependencies__", {})
|
|
520
|
+
identifiers = cache_dependencies.get(cache_key) if cache_dependencies else None
|
|
521
|
+
if not identifiers:
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
for identifier in identifiers:
|
|
525
|
+
params = ast.literal_eval(identifier)
|
|
526
|
+
if lookup_key not in params:
|
|
527
|
+
continue
|
|
528
|
+
old_all = True
|
|
529
|
+
new_all = True
|
|
530
|
+
for param_lookup, expected in params.items():
|
|
531
|
+
parts_param = param_lookup.split("__")
|
|
532
|
+
if parts_param[-1] in (
|
|
533
|
+
"gt",
|
|
534
|
+
"gte",
|
|
535
|
+
"lt",
|
|
536
|
+
"lte",
|
|
537
|
+
"in",
|
|
538
|
+
"contains",
|
|
539
|
+
"startswith",
|
|
540
|
+
"endswith",
|
|
541
|
+
"regex",
|
|
542
|
+
):
|
|
543
|
+
op_param = parts_param[-1]
|
|
544
|
+
attr_path_param = parts_param[:-1]
|
|
545
|
+
else:
|
|
546
|
+
op_param = "eq"
|
|
547
|
+
attr_path_param = parts_param
|
|
548
|
+
expected_key = repr(expected)
|
|
549
|
+
old_val_param = old_relevant_values.get("__".join(attr_path_param))
|
|
550
|
+
new_val_param = current_value_for_path(attr_path_param)
|
|
551
|
+
if not matches(op_param, old_val_param, expected_key):
|
|
552
|
+
old_all = False
|
|
553
|
+
if not matches(op_param, new_val_param, expected_key):
|
|
554
|
+
new_all = False
|
|
555
|
+
if not old_all and not new_all and action == "filter":
|
|
556
|
+
break
|
|
557
|
+
if action == "filter":
|
|
558
|
+
if old_all or new_all:
|
|
559
|
+
return True
|
|
560
|
+
else: # exclude
|
|
561
|
+
if old_all != new_all:
|
|
562
|
+
return True
|
|
563
|
+
return False
|
|
564
|
+
|
|
565
|
+
for action in ACTIONS:
|
|
566
|
+
model_section = idx[action].get(manager_name)
|
|
567
|
+
if not isinstance(model_section, dict):
|
|
568
|
+
continue
|
|
337
569
|
for lookup, lookup_map in model_section.items():
|
|
570
|
+
if lookup.startswith("__"):
|
|
571
|
+
continue
|
|
338
572
|
# 1) get operator and attribute path
|
|
339
573
|
parts = lookup.split("__")
|
|
340
574
|
if parts[-1] in (
|
|
@@ -371,20 +605,36 @@ def generic_cache_invalidation(
|
|
|
371
605
|
|
|
372
606
|
if action == "filter":
|
|
373
607
|
# Filter: invalidate if new match or old match
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
608
|
+
for ck in list(cache_keys):
|
|
609
|
+
composite_decision = evaluate_composite(
|
|
610
|
+
ck, lookup, action, model_section
|
|
377
611
|
)
|
|
378
|
-
|
|
612
|
+
should_invalidate = (
|
|
613
|
+
composite_decision
|
|
614
|
+
if composite_decision is not None
|
|
615
|
+
else (new_match or old_match)
|
|
616
|
+
)
|
|
617
|
+
if should_invalidate:
|
|
618
|
+
logger.info(
|
|
619
|
+
f"Invalidate cache key {ck} for filter {lookup} with value {val_key}"
|
|
620
|
+
)
|
|
379
621
|
invalidate_cache_key(ck)
|
|
380
622
|
remove_cache_key_from_index(ck)
|
|
381
623
|
|
|
382
624
|
else: # action == 'exclude'
|
|
383
625
|
# Excludes: invalidate only if matches changed
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
626
|
+
for ck in list(cache_keys):
|
|
627
|
+
composite_decision = evaluate_composite(
|
|
628
|
+
ck, lookup, action, model_section
|
|
629
|
+
)
|
|
630
|
+
should_invalidate = (
|
|
631
|
+
composite_decision
|
|
632
|
+
if composite_decision is not None
|
|
633
|
+
else (old_match != new_match)
|
|
387
634
|
)
|
|
388
|
-
|
|
635
|
+
if should_invalidate:
|
|
636
|
+
logger.info(
|
|
637
|
+
f"Invalidate cache key {ck} for exclude {lookup} with value {val_key}"
|
|
638
|
+
)
|
|
389
639
|
invalidate_cache_key(ck)
|
|
390
640
|
remove_cache_key_from_index(ck)
|
general_manager/cache/signals.py
CHANGED
|
@@ -27,14 +27,16 @@ def dataChange(func: Callable[P, R]) -> Callable[P, R]:
|
|
|
27
27
|
@wraps(func)
|
|
28
28
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
29
29
|
"""
|
|
30
|
-
|
|
30
|
+
Emit pre_data_change and post_data_change signals around the wrapped function call.
|
|
31
|
+
|
|
32
|
+
Emits a pre_data_change signal before invoking the wrapped function and a post_data_change signal afterwards. Signals are sent with `sender`, `instance`, and `action`; the post-change signal also includes `old_relevant_values`. After signaling, the wrapper removes the `_old_values` attribute from the pre-change instance if it exists.
|
|
31
33
|
|
|
32
34
|
Parameters:
|
|
33
35
|
*args: Positional arguments forwarded to the wrapped function.
|
|
34
36
|
**kwargs: Keyword arguments forwarded to the wrapped function.
|
|
35
37
|
|
|
36
38
|
Returns:
|
|
37
|
-
R:
|
|
39
|
+
R: The result returned by the wrapped function.
|
|
38
40
|
"""
|
|
39
41
|
action = func.__name__
|
|
40
42
|
if func.__name__ == "create":
|
|
@@ -66,6 +68,11 @@ def dataChange(func: Callable[P, R]) -> Callable[P, R]:
|
|
|
66
68
|
old_relevant_values=old_relevant_values,
|
|
67
69
|
**kwargs,
|
|
68
70
|
)
|
|
71
|
+
if instance_before is not None:
|
|
72
|
+
try:
|
|
73
|
+
delattr(instance_before, "_old_values")
|
|
74
|
+
except AttributeError:
|
|
75
|
+
pass
|
|
69
76
|
return result
|
|
70
77
|
|
|
71
78
|
return wrapper
|
|
@@ -12,10 +12,19 @@ __all__ = list(FACTORY_EXPORTS)
|
|
|
12
12
|
_MODULE_MAP = FACTORY_EXPORTS
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
|
-
from general_manager._types.factory import * # noqa:
|
|
15
|
+
from general_manager._types.factory import * # noqa: F403
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def __getattr__(name: str) -> Any:
|
|
19
|
+
"""
|
|
20
|
+
Dynamically resolve and return a named export from this module.
|
|
21
|
+
|
|
22
|
+
Parameters:
|
|
23
|
+
name (str): The attribute name to resolve.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Any: The resolved attribute object corresponding to `name`.
|
|
27
|
+
"""
|
|
19
28
|
return resolve_export(
|
|
20
29
|
name,
|
|
21
30
|
module_all=__all__,
|
|
@@ -15,6 +15,40 @@ if TYPE_CHECKING:
|
|
|
15
15
|
modelsModel = TypeVar("modelsModel", bound=models.Model)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
class InvalidGeneratedObjectError(TypeError):
|
|
19
|
+
"""Raised when factory generation produces non-model instances."""
|
|
20
|
+
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Initialize the exception indicating a generated object is not a Django model instance.
|
|
24
|
+
|
|
25
|
+
Sets a default error message explaining that the generated object is not a Django model instance.
|
|
26
|
+
"""
|
|
27
|
+
super().__init__("Generated object is not a Django model instance.")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InvalidAutoFactoryModelError(TypeError):
|
|
31
|
+
"""Raised when the factory metadata does not reference a Django model class."""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Raised when an AutoFactory target model is not a Django model class.
|
|
36
|
+
|
|
37
|
+
The exception carries a default message explaining that `_meta.model` must be a Django model class.
|
|
38
|
+
"""
|
|
39
|
+
super().__init__("AutoFactory requires _meta.model to be a Django model class.")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UndefinedAdjustmentMethodError(ValueError):
|
|
43
|
+
"""Raised when an adjustment method is required but not configured."""
|
|
44
|
+
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Initialize the UndefinedAdjustmentMethodError with the default message indicating that a generate/adjustment function is not configured.
|
|
48
|
+
"""
|
|
49
|
+
super().__init__("_adjustmentMethod is not defined.")
|
|
50
|
+
|
|
51
|
+
|
|
18
52
|
class AutoFactory(DjangoModelFactory[modelsModel]):
|
|
19
53
|
"""Factory that auto-populates model fields based on interface metadata."""
|
|
20
54
|
|
|
@@ -28,18 +62,26 @@ class AutoFactory(DjangoModelFactory[modelsModel]):
|
|
|
28
62
|
cls, strategy: Literal["build", "create"], params: dict[str, Any]
|
|
29
63
|
) -> models.Model | list[models.Model]:
|
|
30
64
|
"""
|
|
31
|
-
Generate and populate model instances
|
|
65
|
+
Generate and populate model instances using automatically derived field values.
|
|
32
66
|
|
|
33
67
|
Parameters:
|
|
34
|
-
strategy (Literal["build", "create"]):
|
|
35
|
-
params (dict[str, Any]): Field values supplied by the caller.
|
|
68
|
+
strategy (Literal["build", "create"]): Either "build" (unsaved instance) or "create" (saved instance).
|
|
69
|
+
params (dict[str, Any]): Field values supplied by the caller; any missing non-auto fields will be populated automatically.
|
|
36
70
|
|
|
37
71
|
Returns:
|
|
38
|
-
models.Model | list[models.Model]:
|
|
72
|
+
models.Model | list[models.Model]: A generated model instance or a list of generated model instances.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
InvalidAutoFactoryModelError: If the factory target `_meta.model` is not a Django model class.
|
|
76
|
+
InvalidGeneratedObjectError: If an element of a generated list is not a Django model instance.
|
|
39
77
|
"""
|
|
40
78
|
model = cls._meta.model
|
|
41
|
-
|
|
42
|
-
|
|
79
|
+
try:
|
|
80
|
+
is_model = isinstance(model, type) and issubclass(model, models.Model)
|
|
81
|
+
except TypeError:
|
|
82
|
+
is_model = False
|
|
83
|
+
if not is_model:
|
|
84
|
+
raise InvalidAutoFactoryModelError
|
|
43
85
|
field_name_list, to_ignore_list = cls.interface.handleCustomFields(model)
|
|
44
86
|
|
|
45
87
|
fields = [
|
|
@@ -71,7 +113,7 @@ class AutoFactory(DjangoModelFactory[modelsModel]):
|
|
|
71
113
|
if isinstance(obj, list):
|
|
72
114
|
for item in obj:
|
|
73
115
|
if not isinstance(item, models.Model):
|
|
74
|
-
raise
|
|
116
|
+
raise InvalidGeneratedObjectError()
|
|
75
117
|
cls._handleManyToManyFieldsAfterCreation(item, params)
|
|
76
118
|
else:
|
|
77
119
|
cls._handleManyToManyFieldsAfterCreation(obj, params)
|
|
@@ -191,21 +233,21 @@ class AutoFactory(DjangoModelFactory[modelsModel]):
|
|
|
191
233
|
cls, use_creation_method: bool, params: dict[str, Any]
|
|
192
234
|
) -> models.Model | list[models.Model]:
|
|
193
235
|
"""
|
|
194
|
-
Create or build instance(s) using the configured adjustment method.
|
|
236
|
+
Create or build model instance(s) using the configured adjustment method.
|
|
195
237
|
|
|
196
238
|
Parameters:
|
|
197
|
-
use_creation_method (bool):
|
|
198
|
-
params (dict[str, Any]):
|
|
239
|
+
use_creation_method (bool): If True, created records are validated and saved; if False, unsaved instances are returned.
|
|
240
|
+
params (dict[str, Any]): Keyword arguments forwarded to the adjustment method to produce record dict(s).
|
|
199
241
|
|
|
200
242
|
Returns:
|
|
201
|
-
models.Model | list[models.Model]:
|
|
243
|
+
models.Model | list[models.Model]: A single model instance or a list of instances — saved instances when `use_creation_method` is True, unsaved otherwise.
|
|
202
244
|
|
|
203
245
|
Raises:
|
|
204
|
-
|
|
246
|
+
UndefinedAdjustmentMethodError: If no adjustment method has been configured on the factory.
|
|
205
247
|
"""
|
|
206
248
|
model_cls = cls._meta.model
|
|
207
249
|
if cls._adjustmentMethod is None:
|
|
208
|
-
raise
|
|
250
|
+
raise UndefinedAdjustmentMethodError()
|
|
209
251
|
records = cls._adjustmentMethod(**params)
|
|
210
252
|
if isinstance(records, dict):
|
|
211
253
|
if use_creation_method:
|