django-gc 1.0.1__tar.gz
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.
- django_gc-1.0.1/LICENSE +21 -0
- django_gc-1.0.1/PKG-INFO +136 -0
- django_gc-1.0.1/README.md +100 -0
- django_gc-1.0.1/django_gc/__init__.py +45 -0
- django_gc-1.0.1/django_gc/admins/__init__.py +4 -0
- django_gc-1.0.1/django_gc/admins/global_config.py +48 -0
- django_gc-1.0.1/django_gc/admins/global_config_category.py +45 -0
- django_gc-1.0.1/django_gc/apps.py +21 -0
- django_gc-1.0.1/django_gc/conf.py +101 -0
- django_gc-1.0.1/django_gc/dto/__init__.py +4 -0
- django_gc-1.0.1/django_gc/dto/definitions.py +33 -0
- django_gc-1.0.1/django_gc/dto/global_config_cache_value.py +16 -0
- django_gc-1.0.1/django_gc/enums/__init__.py +4 -0
- django_gc-1.0.1/django_gc/enums/choices_enum.py +36 -0
- django_gc-1.0.1/django_gc/enums/setting_types.py +68 -0
- django_gc-1.0.1/django_gc/exceptions.py +77 -0
- django_gc-1.0.1/django_gc/fields.py +35 -0
- django_gc-1.0.1/django_gc/forms/__init__.py +4 -0
- django_gc-1.0.1/django_gc/forms/global_config_categories.py +21 -0
- django_gc-1.0.1/django_gc/forms/global_configs.py +147 -0
- django_gc-1.0.1/django_gc/migrations/0001_initial.py +54 -0
- django_gc-1.0.1/django_gc/migrations/__init__.py +0 -0
- django_gc-1.0.1/django_gc/models/__init__.py +4 -0
- django_gc-1.0.1/django_gc/models/global_config.py +68 -0
- django_gc-1.0.1/django_gc/models/global_config_category.py +24 -0
- django_gc-1.0.1/django_gc/py.typed +0 -0
- django_gc-1.0.1/django_gc/services/__init__.py +43 -0
- django_gc-1.0.1/django_gc/services/cache.py +97 -0
- django_gc-1.0.1/django_gc/services/global_config_initialization_service.py +146 -0
- django_gc-1.0.1/django_gc/services/global_config_service.py +269 -0
- django_gc-1.0.1/django_gc/signals/__init__.py +11 -0
- django_gc-1.0.1/django_gc/signals/callbacks.py +15 -0
- django_gc-1.0.1/django_gc/signals/initialize_global_configs.py +17 -0
- django_gc-1.0.1/django_gc/signals/normalize_global_config_value.py +30 -0
- django_gc-1.0.1/django_gc/signals/prevent_global_config_deletion.py +15 -0
- django_gc-1.0.1/django_gc/signals/refresh_global_configs_cache.py +19 -0
- django_gc-1.0.1/django_gc/singleton.py +17 -0
- django_gc-1.0.1/django_gc/tasks/__init__.py +3 -0
- django_gc-1.0.1/django_gc/tasks/refresh_global_configs_cache.py +19 -0
- django_gc-1.0.1/django_gc/utils.py +15 -0
- django_gc-1.0.1/django_gc.egg-info/PKG-INFO +136 -0
- django_gc-1.0.1/django_gc.egg-info/SOURCES.txt +54 -0
- django_gc-1.0.1/django_gc.egg-info/dependency_links.txt +1 -0
- django_gc-1.0.1/django_gc.egg-info/requires.txt +5 -0
- django_gc-1.0.1/django_gc.egg-info/top_level.txt +1 -0
- django_gc-1.0.1/pyproject.toml +87 -0
- django_gc-1.0.1/setup.cfg +4 -0
- django_gc-1.0.1/tests/test_admin.py +77 -0
- django_gc-1.0.1/tests/test_encryption.py +32 -0
- django_gc-1.0.1/tests/test_global_config_service.py +271 -0
- django_gc-1.0.1/tests/test_global_config_signals.py +66 -0
- django_gc-1.0.1/tests/test_initialization.py +137 -0
- django_gc-1.0.1/tests/test_json_parse.py +34 -0
- django_gc-1.0.1/tests/test_models.py +62 -0
- django_gc-1.0.1/tests/test_task.py +28 -0
- django_gc-1.0.1/tests/test_types.py +86 -0
django_gc-1.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RDD Lab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
django_gc-1.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-gc
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Typed Django runtime configuration stored in the database and served from cache
|
|
5
|
+
Author-email: Raman Marozau <r.morozov@rdd-lab.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://django-gc.rdd-lab.com/
|
|
8
|
+
Project-URL: Documentation, https://django-gc.rdd-lab.com/
|
|
9
|
+
Project-URL: Issues, https://github.com/RDDLab/Django-GC/issues
|
|
10
|
+
Project-URL: Repository, https://github.com/RDDLab/Django-GC
|
|
11
|
+
Project-URL: Changelog, https://github.com/RDDLab/Django-GC/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: django,config,settings,cache,admin
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Framework :: Django
|
|
18
|
+
Classifier: Framework :: Django :: 6.0
|
|
19
|
+
Classifier: Programming Language :: Python
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.12
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: django>=6.0
|
|
32
|
+
Requires-Dist: cryptography>=44.0.0
|
|
33
|
+
Provides-Extra: celery
|
|
34
|
+
Requires-Dist: celery>=5.3; extra == "celery"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
[](https://opensource.org/licenses/MIT)
|
|
40
|
+
[](https://docs.astral.sh/ruff)
|
|
41
|
+
[](https://pyrefly.org)
|
|
42
|
+
[](https://pypi.org/project/django-gc/)
|
|
43
|
+
[](https://pypi.python.org/pypi/django-gc/)
|
|
44
|
+
[](https://pypi.org/project/django-gc/)
|
|
45
|
+
[](https://pypi.python.org/pypi/django-gc)
|
|
46
|
+
[](https://pypistats.org/packages/django-gc)
|
|
47
|
+
[](https://pypi.python.org/pypi/django-gc)
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
[](https://github.com/RDDLab/Django-GC/actions/workflows/testing.yml)
|
|
52
|
+
[](https://codecov.io/gh/RDDLab/Django-GC)
|
|
53
|
+
[](https://results.pre-commit.ci/latest/github/RDDLab/Django-GC/main)
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
**Documentation**: <a href="https://django-gc.rdd-lab.com/" target="_blank">https://django-gc.rdd-lab.com/</a>
|
|
58
|
+
|
|
59
|
+
**Source Code**: <a href="https://github.com/RDDLab/Django-GC" target="_blank">https://github.com/RDDLab/Django-GC</a>
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
# Django-GC
|
|
64
|
+
|
|
65
|
+
Django-GC stores typed runtime settings in the database and serves them from Django's cache. Application code never reads a value through the ORM. The project owns the keys; this package is the mechanism.
|
|
66
|
+
|
|
67
|
+
## Features
|
|
68
|
+
|
|
69
|
+
- **Code-declared keys** — categories and definitions live in Django settings and are synchronized after migrate.
|
|
70
|
+
- **Typed values** — strings, numbers, dates, arrays, choices, JSON, UUID, and optional secure secrets.
|
|
71
|
+
- **Full cache snapshot** — `get_value()` never falls back to a single-row ORM read.
|
|
72
|
+
- **Stock Django Admin** — edit values with standard `ModelAdmin` and Django's built-in change history.
|
|
73
|
+
- **Optional Celery** — periodic full snapshot refresh.
|
|
74
|
+
- **Fully typed** — ships `py.typed`; compatible with pyrefly and standard type checkers.
|
|
75
|
+
|
|
76
|
+
## Installation
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install django-gc
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
INSTALLED_APPS = [
|
|
84
|
+
'django_gc',
|
|
85
|
+
'django.contrib.admin',
|
|
86
|
+
# ...
|
|
87
|
+
]
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Periodic refresh is optional:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pip install 'django-gc[celery]'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Quick start
|
|
97
|
+
|
|
98
|
+
In Django `settings`, set categories, keys, and the encryption key. Then migrate and read values through the service:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from enum import StrEnum
|
|
102
|
+
|
|
103
|
+
from django_gc import (
|
|
104
|
+
CategoryDefinition,
|
|
105
|
+
SettingDefinition,
|
|
106
|
+
SettingType,
|
|
107
|
+
get_value,
|
|
108
|
+
set_value,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SettingKey(StrEnum):
|
|
113
|
+
MAINTENANCE_ENABLED = 'maintenance-enabled'
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
GLOBAL_CONFIG_CATEGORIES = [
|
|
117
|
+
CategoryDefinition(id=1, code='platform', name='Platform'),
|
|
118
|
+
]
|
|
119
|
+
GLOBAL_CONFIG_DEFINITIONS = [
|
|
120
|
+
SettingDefinition(
|
|
121
|
+
key=SettingKey.MAINTENANCE_ENABLED,
|
|
122
|
+
description='Maintenance mode',
|
|
123
|
+
default_value=False,
|
|
124
|
+
value_type=SettingType.BOOLEAN,
|
|
125
|
+
category_id=1,
|
|
126
|
+
),
|
|
127
|
+
]
|
|
128
|
+
GLOBAL_CONFIG_ENCRYPTION_KEY = 'replace-with-a-long-random-secret'
|
|
129
|
+
|
|
130
|
+
enabled = get_value(SettingKey.MAINTENANCE_ENABLED)
|
|
131
|
+
set_value(SettingKey.MAINTENANCE_ENABLED, True)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Documentation
|
|
135
|
+
|
|
136
|
+
**https://django-gc.rdd-lab.com/**
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
[](https://docs.astral.sh/ruff)
|
|
5
|
+
[](https://pyrefly.org)
|
|
6
|
+
[](https://pypi.org/project/django-gc/)
|
|
7
|
+
[](https://pypi.python.org/pypi/django-gc/)
|
|
8
|
+
[](https://pypi.org/project/django-gc/)
|
|
9
|
+
[](https://pypi.python.org/pypi/django-gc)
|
|
10
|
+
[](https://pypistats.org/packages/django-gc)
|
|
11
|
+
[](https://pypi.python.org/pypi/django-gc)
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
[](https://github.com/RDDLab/Django-GC/actions/workflows/testing.yml)
|
|
16
|
+
[](https://codecov.io/gh/RDDLab/Django-GC)
|
|
17
|
+
[](https://results.pre-commit.ci/latest/github/RDDLab/Django-GC/main)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
**Documentation**: <a href="https://django-gc.rdd-lab.com/" target="_blank">https://django-gc.rdd-lab.com/</a>
|
|
22
|
+
|
|
23
|
+
**Source Code**: <a href="https://github.com/RDDLab/Django-GC" target="_blank">https://github.com/RDDLab/Django-GC</a>
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
# Django-GC
|
|
28
|
+
|
|
29
|
+
Django-GC stores typed runtime settings in the database and serves them from Django's cache. Application code never reads a value through the ORM. The project owns the keys; this package is the mechanism.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- **Code-declared keys** — categories and definitions live in Django settings and are synchronized after migrate.
|
|
34
|
+
- **Typed values** — strings, numbers, dates, arrays, choices, JSON, UUID, and optional secure secrets.
|
|
35
|
+
- **Full cache snapshot** — `get_value()` never falls back to a single-row ORM read.
|
|
36
|
+
- **Stock Django Admin** — edit values with standard `ModelAdmin` and Django's built-in change history.
|
|
37
|
+
- **Optional Celery** — periodic full snapshot refresh.
|
|
38
|
+
- **Fully typed** — ships `py.typed`; compatible with pyrefly and standard type checkers.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install django-gc
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
INSTALLED_APPS = [
|
|
48
|
+
'django_gc',
|
|
49
|
+
'django.contrib.admin',
|
|
50
|
+
# ...
|
|
51
|
+
]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Periodic refresh is optional:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install 'django-gc[celery]'
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
In Django `settings`, set categories, keys, and the encryption key. Then migrate and read values through the service:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from enum import StrEnum
|
|
66
|
+
|
|
67
|
+
from django_gc import (
|
|
68
|
+
CategoryDefinition,
|
|
69
|
+
SettingDefinition,
|
|
70
|
+
SettingType,
|
|
71
|
+
get_value,
|
|
72
|
+
set_value,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SettingKey(StrEnum):
|
|
77
|
+
MAINTENANCE_ENABLED = 'maintenance-enabled'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
GLOBAL_CONFIG_CATEGORIES = [
|
|
81
|
+
CategoryDefinition(id=1, code='platform', name='Platform'),
|
|
82
|
+
]
|
|
83
|
+
GLOBAL_CONFIG_DEFINITIONS = [
|
|
84
|
+
SettingDefinition(
|
|
85
|
+
key=SettingKey.MAINTENANCE_ENABLED,
|
|
86
|
+
description='Maintenance mode',
|
|
87
|
+
default_value=False,
|
|
88
|
+
value_type=SettingType.BOOLEAN,
|
|
89
|
+
category_id=1,
|
|
90
|
+
),
|
|
91
|
+
]
|
|
92
|
+
GLOBAL_CONFIG_ENCRYPTION_KEY = 'replace-with-a-long-random-secret'
|
|
93
|
+
|
|
94
|
+
enabled = get_value(SettingKey.MAINTENANCE_ENABLED)
|
|
95
|
+
set_value(SettingKey.MAINTENANCE_ENABLED, True)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Documentation
|
|
99
|
+
|
|
100
|
+
**https://django-gc.rdd-lab.com/**
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from django_gc.dto import CategoryDefinition, SettingDefinition
|
|
4
|
+
from django_gc.enums import SettingType
|
|
5
|
+
from django_gc.exceptions import (
|
|
6
|
+
GlobalConfigCreationForbiddenError,
|
|
7
|
+
GlobalConfigDeletionForbiddenError,
|
|
8
|
+
GlobalConfigError,
|
|
9
|
+
GlobalConfigLockNotOwnedError,
|
|
10
|
+
GlobalConfigNotFoundError,
|
|
11
|
+
GlobalConfigParseError,
|
|
12
|
+
GlobalConfigRefreshLockTimeoutError,
|
|
13
|
+
GlobalConfigSnapshotIncompleteError,
|
|
14
|
+
GlobalConfigValueError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
'CategoryDefinition',
|
|
19
|
+
'GlobalConfigCreationForbiddenError',
|
|
20
|
+
'GlobalConfigDeletionForbiddenError',
|
|
21
|
+
'GlobalConfigError',
|
|
22
|
+
'GlobalConfigLockNotOwnedError',
|
|
23
|
+
'GlobalConfigNotFoundError',
|
|
24
|
+
'GlobalConfigParseError',
|
|
25
|
+
'GlobalConfigRefreshLockTimeoutError',
|
|
26
|
+
'GlobalConfigSnapshotIncompleteError',
|
|
27
|
+
'GlobalConfigValueError',
|
|
28
|
+
'SettingDefinition',
|
|
29
|
+
'SettingType',
|
|
30
|
+
'get_value',
|
|
31
|
+
'is_cache_ready',
|
|
32
|
+
'refresh_cache',
|
|
33
|
+
'set_value',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def __getattr__(name: str) -> Any:
|
|
38
|
+
"""
|
|
39
|
+
Лениво отдать runtime API после загрузки Django apps.
|
|
40
|
+
"""
|
|
41
|
+
if name in {'get_value', 'is_cache_ready', 'refresh_cache', 'set_value'}:
|
|
42
|
+
from django_gc import services
|
|
43
|
+
|
|
44
|
+
return getattr(services, name)
|
|
45
|
+
raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, override
|
|
2
|
+
|
|
3
|
+
from django.contrib import admin
|
|
4
|
+
from django.http import HttpRequest
|
|
5
|
+
from django.utils.translation import gettext_lazy as _
|
|
6
|
+
|
|
7
|
+
from django_gc.forms import GlobalConfigForm
|
|
8
|
+
from django_gc.models import GlobalConfig
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
_GlobalConfigAdmin = admin.ModelAdmin[GlobalConfig]
|
|
12
|
+
else:
|
|
13
|
+
_GlobalConfigAdmin = admin.ModelAdmin
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@admin.register(GlobalConfig)
|
|
17
|
+
class GlobalConfigAdmin(_GlobalConfigAdmin):
|
|
18
|
+
"""
|
|
19
|
+
Управлять значениями GlobalConfig без изменения состава системных ключей.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
form = GlobalConfigForm
|
|
23
|
+
list_display = ['key', 'description', 'category']
|
|
24
|
+
list_select_related = ['category']
|
|
25
|
+
list_filter = ['category', 'value_type', 'is_read_only']
|
|
26
|
+
search_fields = ['key', 'description']
|
|
27
|
+
ordering = ['category', 'description']
|
|
28
|
+
readonly_fields = ['key', 'description', 'is_read_only', 'category', 'created_at', 'updated_at']
|
|
29
|
+
fieldsets = [
|
|
30
|
+
(_('Общая информация'), {'classes': ['wide'], 'fields': ['key', 'category', 'value_type', 'description']}),
|
|
31
|
+
(_('Параметры'), {'classes': ['wide'], 'fields': ['is_read_only']}),
|
|
32
|
+
(_('Значение'), {'classes': ['wide'], 'fields': ['value']}),
|
|
33
|
+
(_('Даты'), {'classes': ['wide'], 'fields': ['created_at', 'updated_at']}),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
@override
|
|
37
|
+
def has_add_permission(self, request: HttpRequest) -> bool:
|
|
38
|
+
"""
|
|
39
|
+
Разрешить создание ключей только через definitions.
|
|
40
|
+
"""
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
def has_delete_permission(self, request: HttpRequest, obj: GlobalConfig | None = None) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Запретить удаление системных ключей.
|
|
47
|
+
"""
|
|
48
|
+
return False
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, override
|
|
2
|
+
|
|
3
|
+
from django.contrib import admin
|
|
4
|
+
from django.http import HttpRequest
|
|
5
|
+
from django.utils.translation import gettext_lazy as _
|
|
6
|
+
|
|
7
|
+
from django_gc.forms import GlobalConfigCategoryForm
|
|
8
|
+
from django_gc.models import GlobalConfigCategory
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
_GlobalConfigCategoryAdmin = admin.ModelAdmin[GlobalConfigCategory]
|
|
12
|
+
else:
|
|
13
|
+
_GlobalConfigCategoryAdmin = admin.ModelAdmin
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@admin.register(GlobalConfigCategory)
|
|
17
|
+
class GlobalConfigCategoryAdmin(_GlobalConfigCategoryAdmin):
|
|
18
|
+
"""
|
|
19
|
+
Разрешить переименование системных категорий без изменения их состава.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
form = GlobalConfigCategoryForm
|
|
23
|
+
list_display = ['id', 'code', 'name']
|
|
24
|
+
search_fields = ['code', 'name']
|
|
25
|
+
ordering = ['id']
|
|
26
|
+
readonly_fields = ['id', 'code', 'created_at', 'updated_at']
|
|
27
|
+
fieldsets = [
|
|
28
|
+
(_('Идентификаторы'), {'classes': ['wide'], 'fields': ['id', 'code']}),
|
|
29
|
+
(_('Категория'), {'classes': ['wide'], 'fields': ['name']}),
|
|
30
|
+
(_('Даты'), {'classes': ['wide'], 'fields': ['created_at', 'updated_at']}),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
def has_add_permission(self, request: HttpRequest) -> bool:
|
|
35
|
+
"""
|
|
36
|
+
Разрешить создание категорий только через definitions.
|
|
37
|
+
"""
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
@override
|
|
41
|
+
def has_delete_permission(self, request: HttpRequest, obj: GlobalConfigCategory | None = None) -> bool:
|
|
42
|
+
"""
|
|
43
|
+
Запретить удаление системных категорий.
|
|
44
|
+
"""
|
|
45
|
+
return False
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from typing import override
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig
|
|
4
|
+
from django.utils.translation import gettext_lazy as _
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Config(AppConfig):
|
|
8
|
+
"""
|
|
9
|
+
Приложение типизированных runtime-настроек.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
default_auto_field = 'django.db.models.BigAutoField'
|
|
13
|
+
name = 'django_gc'
|
|
14
|
+
verbose_name = _('Управление настройками')
|
|
15
|
+
|
|
16
|
+
@override
|
|
17
|
+
def ready(self) -> None:
|
|
18
|
+
"""
|
|
19
|
+
Подключить сигналы и регистрацию Admin.
|
|
20
|
+
"""
|
|
21
|
+
from django_gc import admins, signals
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar, Token
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
|
|
11
|
+
from django_gc.dto import CategoryDefinition, SettingDefinition
|
|
12
|
+
|
|
13
|
+
_REFRESH_SUPPRESSED: ContextVar[bool] = ContextVar('global_config_refresh_suppressed', default=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_cache_alias() -> str:
|
|
17
|
+
"""
|
|
18
|
+
Вернуть алиас Django cache для снимка GlobalConfig.
|
|
19
|
+
"""
|
|
20
|
+
return str(getattr(settings, 'GLOBAL_CONFIG_CACHE_ALIAS', 'default'))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_category_definitions() -> Sequence[CategoryDefinition]:
|
|
24
|
+
"""
|
|
25
|
+
Прочитать объявленные проектом категории.
|
|
26
|
+
"""
|
|
27
|
+
return tuple(getattr(settings, 'GLOBAL_CONFIG_CATEGORIES', ()))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_setting_definitions() -> Sequence[SettingDefinition]:
|
|
31
|
+
"""
|
|
32
|
+
Прочитать объявленные проектом ключи.
|
|
33
|
+
"""
|
|
34
|
+
return tuple(getattr(settings, 'GLOBAL_CONFIG_DEFINITIONS', ()))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_required_setting_keys() -> set[str]:
|
|
38
|
+
"""
|
|
39
|
+
Получить системный состав обязательного полного снимка.
|
|
40
|
+
"""
|
|
41
|
+
return {str(item.key) for item in get_setting_definitions()}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_encryption_key() -> str | None:
|
|
45
|
+
"""
|
|
46
|
+
Прочитать ключ шифрования SECURE-значений.
|
|
47
|
+
"""
|
|
48
|
+
key = getattr(settings, 'GLOBAL_CONFIG_ENCRYPTION_KEY', None)
|
|
49
|
+
if key is None or key == '':
|
|
50
|
+
return None
|
|
51
|
+
return str(key)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_fernet(*, key: str) -> Fernet:
|
|
55
|
+
"""
|
|
56
|
+
Построить Fernet из произвольной секретной строки.
|
|
57
|
+
"""
|
|
58
|
+
digest = hashlib.sha256(key.encode('utf-8')).digest()
|
|
59
|
+
return Fernet(key=base64.urlsafe_b64encode(digest))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def encrypt_value(*, text: str) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Зашифровать SECURE-значение, если задан GLOBAL_CONFIG_ENCRYPTION_KEY.
|
|
65
|
+
"""
|
|
66
|
+
key = get_encryption_key()
|
|
67
|
+
if key is None:
|
|
68
|
+
return text
|
|
69
|
+
return _build_fernet(key=key).encrypt(data=text.encode('utf-8')).decode('ascii')
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def decrypt_value(*, text: str) -> str | None:
|
|
73
|
+
"""
|
|
74
|
+
Расшифровать SECURE-значение, если задан GLOBAL_CONFIG_ENCRYPTION_KEY.
|
|
75
|
+
"""
|
|
76
|
+
key = get_encryption_key()
|
|
77
|
+
if key is None:
|
|
78
|
+
return text
|
|
79
|
+
try:
|
|
80
|
+
return _build_fernet(key=key).decrypt(token=text.encode('ascii')).decode('utf-8')
|
|
81
|
+
except (InvalidToken, ValueError):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_refresh_suppressed() -> bool:
|
|
86
|
+
"""
|
|
87
|
+
Проверить, нужно ли пропустить post_save refresh.
|
|
88
|
+
"""
|
|
89
|
+
return _REFRESH_SUPPRESSED.get()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@contextmanager
|
|
93
|
+
def suppress_cache_refresh() -> Any:
|
|
94
|
+
"""
|
|
95
|
+
Подавить per-row refresh на время синхронизации definitions.
|
|
96
|
+
"""
|
|
97
|
+
token: Token[bool] = _REFRESH_SUPPRESSED.set(True)
|
|
98
|
+
try:
|
|
99
|
+
yield
|
|
100
|
+
finally:
|
|
101
|
+
_REFRESH_SUPPRESSED.reset(token)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from django_gc.enums import SettingType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class CategoryDefinition:
|
|
10
|
+
"""
|
|
11
|
+
Описать системную категорию GlobalConfig для инициализации.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
id: int
|
|
15
|
+
code: str
|
|
16
|
+
name: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SettingDefinition:
|
|
21
|
+
"""
|
|
22
|
+
Описать управляемый кодом ключ GlobalConfig для инициализации.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
key: str | StrEnum
|
|
26
|
+
description: str
|
|
27
|
+
default_value: Any
|
|
28
|
+
value_type: SettingType
|
|
29
|
+
category_id: int
|
|
30
|
+
nullable: bool = False
|
|
31
|
+
is_read_only: bool = False
|
|
32
|
+
variables: dict[str, Any] | None = None
|
|
33
|
+
is_always_update: bool = False
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from django_gc.enums import SettingType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class GlobalConfigCacheValueDTO:
|
|
9
|
+
"""
|
|
10
|
+
Типизированное значение снимка GlobalConfig.
|
|
11
|
+
|
|
12
|
+
Для SettingType.SECURE поле value содержит только зашифрованное значение.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
value: Any
|
|
16
|
+
value_type: SettingType
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
from django.utils.functional import Promise
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ChoicesEnumMixin(Enum):
|
|
7
|
+
"""
|
|
8
|
+
Дать Django choices из членов enum с методом ``label()``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def label(self) -> Promise:
|
|
12
|
+
"""
|
|
13
|
+
Вернуть человекочитаемую подпись члена enum.
|
|
14
|
+
"""
|
|
15
|
+
raise NotImplementedError
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def values(cls) -> set[object]:
|
|
19
|
+
"""
|
|
20
|
+
Вернуть множество значений членов.
|
|
21
|
+
"""
|
|
22
|
+
return {item.value for item in cls}
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def labels(cls) -> set[str]:
|
|
26
|
+
"""
|
|
27
|
+
Вернуть множество строковых подписей членов.
|
|
28
|
+
"""
|
|
29
|
+
return {str(item.label()) for item in cls}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def choices(cls) -> list[tuple[object, Promise]]:
|
|
33
|
+
"""
|
|
34
|
+
Вернуть пары value/label для поля Django.
|
|
35
|
+
"""
|
|
36
|
+
return [(member.value, member.label()) for member in cls]
|