Metadata-Version: 2.1
Name: dj-dynamic-settings
Version: 0.3.1
Summary: Stay informed of it
Home-page: https://bitbucket.org/akinonteam/dj-dynamic-settings/
Author: Akinon
Author-email: dev@akinon.com
Maintainer: Akinon
Maintainer-email: dev@akinon.com
License: MIT
Project-URL: Documentation, https://bitbucket.org/akinonteam/dj-dynamic-settings/
Project-URL: Source Code, https://bitbucket.org/akinonteam/dj-dynamic-settings/
Platform: any
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Environment :: Web Environment
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 2.7
Classifier: Programming Language :: Python :: 3.4
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Framework :: Django
Classifier: Framework :: Django :: 1.10
Classifier: Framework :: Django :: 1.11
Classifier: Framework :: Django :: 2.0
Classifier: Framework :: Django :: 2.1
Classifier: Framework :: Django :: 2.2
Classifier: Framework :: Django :: 3.0
Classifier: Framework :: Django :: 3.2
Classifier: Framework :: Django :: 4.0
Classifier: Framework :: Django :: 4.1
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Description-Content-Type: text/markdown
License-File: LICENSE.txt
Requires-Dist: cryptography

# Django Dynamic Settings

[![Build status](https://img.shields.io/bitbucket/pipelines/akinonteam/dj-dynamic-settings)](https://bitbucket.org/akinonteam/dj-dynamic-settings/addon/pipelines/home)
![PyPI](https://img.shields.io/pypi/v/dj-dynamic-settings)
![PyPI - Django version](https://img.shields.io/pypi/djversions/dj-dynamic-settings)
![PyPI - Python version](https://img.shields.io/pypi/pyversions/dj-dynamic-settings)
![PyPI - License](https://img.shields.io/pypi/l/dj-dynamic-settings)

Django Dynamic Settings allows you to create & use dynamic settings backed by a database.

## Installation

Installation using pip:

```
pip install dj-dynamic-settings
```

`dj_dynamic_settings` app has to be added to `INSTALLED_APPS` and `migrate` command has to be run.

```python
INSTALLED_APPS = (
    # other apps here...
    "dj_dynamic_settings",
)
```

`dj_dynamic_settings.urls` must be included to a desired url path.
```python
urlpatterns = [
    ...,
    url(r"^api/v1/", include("dj_dynamic_settings.urls")),
]
```

Setting class must be defined & registered. Please make sure that this class' module 
runs whenever the application runs.
```python
from dj_dynamic_settings.registry import BaseSetting, registry
from dj_dynamic_settings.validators import TypeValidator


@registry.register
class FeatureActive(BaseSetting):
    key = "FEATURE_ACTIVE"
    validators = [TypeValidator(bool)]
    default = False
    description = "Flag for Feature X"
```

Create `Setting` instance using view.

```python
import requests

requests.post(
    url="https://your-app.com/api/v1/dynamic_settings/",
    headers={
        "Authorization": "Token <secret-login-token>",
    },
    json={
        "key": "FEATURE_ACTIVE",
        "value": True,
        "is_active": True,
    }
)
```

Access this setting as in `django.conf.settings`

```python
from dj_dynamic_settings.conf import settings


settings.FEATURE_ACTIVE  # True
```

### Create / Update Triggers

To fire a callback method when a specific setting value updated or created, you can implement `post_save_actions` in `BaseSetting` inherited class

Following example shows how to implement `post_save_actions` method.

The callback method will be called with following kwargs: 

```
key=instance.key
value=instance.value
created=created # is create operation
```

Note: `post_save_actions` returns an array, so you can add multiple callback methods. These callback methods will be called synchronously. 

```python
class PostUpdateTestConfiguration(BaseSetting):
    key = "X_FEATURE_POST_UPDATE"
    validators = [...]

    @classmethod
    def post_save_actions(cls):
        return [
            on_data_updated,
        ]

def on_data_updated(*args, **kwargs):
    pass
```


### Testing Tools

#### override_settings()

You can override a setting for a test method or test class.

```python
from dj_dynamic_settings.utils import override_settings
from django.test import TestCase

@override_settings(SOME_SETTING="some_setting")
class FeatureTestCase(TestCase):

    @override_settings(SOME_OTHER_SETTING="SOME_OTHER_SETTING")
    def test_feature(self):
        # Some stuff
        pass

    
    def test_feature_x(self):
        with override_settings(SOME_OTHER_SETTING="SOME_OTHER_SETTING"):
            # Some stuff
            pass
```

### Selective Field-Level Encryption

dj-dynamic-settings supports selective Fernet encryption of sensitive field values. Configuration is two-pronged:

1. **Code-defined defaults** via Django setting:
```python
DYNAMIC_SETTINGS_ENCRYPTED_FIELDS = ["password", "api_key", "token"]
```

2. **Runtime** via the `/encrypted_keys/` REST API or admin tooling. Admins POST/DELETE entries against the `EncryptedKey` endpoint to add/remove encrypted fields without a code deploy.

Both sources are merged via a `frozenset` union — duplicates dedup automatically.

Setting `value` JSON content is scanned recursively:
- If the Setting's own `key` is in the active field-name set, the entire value is encrypted (scalar case).
- Inside dicts (at any nesting depth), keys matching the active set have their values encrypted.
- Inside lists, each element is recursed.

Decryption is **transparent** — your code reads `settings.X_FOO` and gets plaintext, no changes required:

```python
from dj_dynamic_settings.conf import settings

settings.X_PAYMENT_GATEWAY  # returns plaintext, decrypted on the fly
```

Cache (Django cache framework) stores **ciphertext only**; decryption happens at return time. API GET responses replace encrypted fields with `"***ENCRYPTED***"` (configurable via `DYNAMIC_SETTINGS_ENCRYPTION_MASK`).

Writes treat the mask string as a sentinel: when a PATCH/PUT echoes the mask back for a field (e.g. the client copied a GET response and changed only one field), the stored encrypted value is preserved instead of re-encrypting the mask text. A mask sent for a path that has no stored encrypted value (including on create) is rejected with a validation error. Consequently the mask string itself cannot be stored as a literal value; override `DYNAMIC_SETTINGS_ENCRYPTION_MASK` if that literal is ever needed.

Encryption key:
- Default: derived from Django's `SECRET_KEY` via SHA256 → urlsafe base64. Zero setup required.
- Optional override: set `DYNAMIC_SETTINGS_ENCRYPTION_KEY` to a stable 32-byte urlsafe base64 Fernet key. Recommended if `SECRET_KEY` is ever rotated.

When an `EncryptedKey` row is created or deleted through the API, all existing Settings are scanned and re-encrypted or decrypted symmetrically, atomically within the request. See **Encrypting existing data** below for the non-API path and the immutability of `field_name`.

### Encrypting existing data

The `DYNAMIC_SETTINGS_ENCRYPTED_FIELDS` setting is the source of truth for which
fields are encrypted at rest. Reconcile the `EncryptedKey` table to match it with:

```bash
python manage.py sync_encrypted_fields            # apply the reconcile
python manage.py sync_encrypted_fields --dry-run  # print the plan, write nothing
```

The command mirrors the table to the list, in one transaction:

- A field newly added to the list → an `EncryptedKey` row is created and all
  existing matching `Setting` data is encrypted.
- A field removed from the list → existing data is **decrypted at rest** and the
  row is deleted (a WARNING is logged for each decrypted field).

Run it once per deploy, after `migrate`. On Akinon Cloud, add it to the
`akinon.json` release script:

```json
"release": "python manage.py migrate --no-input && python manage.py sync_encrypted_fields"
```

> **The list is authoritative for the entire `EncryptedKey` table.** Any row whose
> `field_name` is not in `DYNAMIC_SETTINGS_ENCRYPTED_FIELDS` is removed and its data
> decrypted on the next run — including rows created through the
> `POST /encrypted_keys` API. A project that runs this command should manage
> encrypted fields exclusively through the settings list, not the API.

> **Removing a field decrypts data at rest.** An accidental edit to the list will
> decrypt sensitive values on the next deploy. Use `--dry-run` to preview, and
> review list changes carefully.

`field_name` is immutable on an existing `EncryptedKey` (a PATCH/PUT changing it
returns HTTP 400).


