Metadata-Version: 2.4
Name: dj-dynamic-settings
Version: 0.4.0rc1
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
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: license
Dynamic: license-file
Dynamic: maintainer
Dynamic: maintainer-email
Dynamic: platform
Dynamic: project-url
Dynamic: requires-dist
Dynamic: summary

# 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
```

### Caching and cross-server invalidation

A value read through `dj_dynamic_settings.conf.settings` is served from the
first layer that has it:

1. **In-process holder** — a dict held on the `Settings` singleton. No I/O.
2. **Shared cache** — the Django cache backend named by `CACHE_ALIAS`.
3. **Database** — the `Setting` row, or the registered class' `default`.

Saving or deleting a `Setting` drops its shared cache entry and increments a
global version counter kept in the same cache. Other processes read that counter
periodically; when it has moved they discard their holder, so a change made on
one server reaches the rest without a restart.

| Setting | Default | Description |
| --- | --- | --- |
| `DYNAMIC_SETTINGS_USE_CACHE` | `True` | Use the shared cache and the in-process holder. When `False`, every read goes to the database, nothing is held, and no invalidation is needed. |
| `DYNAMIC_SETTINGS_CACHE_ALIAS` | `"default"` | Which entry of Django's `CACHES` to use. |
| `DYNAMIC_SETTINGS_CACHE_TIMEOUT` | `3600` | TTL in seconds of a cached setting value. |
| `DYNAMIC_SETTINGS_CACHE_KEY_PREFIX` | `"dynamic-settings"` | Prefix of every cache key. Two projects sharing one cache backend must not share this value. |
| `DYNAMIC_SETTINGS_VERSION_CHECK_ENABLED` | `True` | Invalidate the holder from the global version counter. |
| `DYNAMIC_SETTINGS_VERSION_CHECK_INTERVAL` | `30` | Seconds between two version checks in one process. |
| `DYNAMIC_SETTINGS_VERSION_KEY` | `"global_version"` | Cache key suffix of the version counter. |
| `DYNAMIC_SETTINGS_LOCAL_VERSION_CACHE_ENABLED` | `True` | Share the observed version between the processes of one machine through a file, to cut cache round-trips. |
| `DYNAMIC_SETTINGS_LOCAL_VERSION_CACHE_PATH` | `"/tmp/dynamic_settings"` | Directory of that file. Must be writable by the application user. |
| `DYNAMIC_SETTINGS_LOCAL_VERSION_CACHE_TTL` | `30` | Seconds a version read from that file is trusted before the shared cache is consulted again. |

#### How stale a read can be

Propagation is not immediate, and the bound depends on where the change was
made:

| Change made | Usually visible elsewhere within |
| --- | --- |
| Same machine | `VERSION_CHECK_INTERVAL` (default 30s) |
| Another machine, local version cache on | `VERSION_CHECK_INTERVAL + LOCAL_VERSION_CACHE_TTL` (default 60s) |
| Another machine, local version cache off | `VERSION_CHECK_INTERVAL` (default 30s) |

The two terms are independent: a process consults the version at most once per
`VERSION_CHECK_INTERVAL`, and the version it then reads from the local file may
itself have been written up to `LOCAL_VERSION_CACHE_TTL` ago. A write publishes
the new version to its own machine's file immediately, which is why the local
term drops out for changes made there. These are the ordinary bounds rather than
hard guarantees: a process that happens to read the file while another is
refreshing it keeps the previous version for one further interval.

If the cache backend is unreachable the check is skipped and the process keeps
serving the values it already holds, rather than failing every settings read.

> **Do not set `VERSION_CHECK_ENABLED` to `False` on a multi-process deployment.**
> With the check off, a process only clears its holder for a write it performed
> itself, so every other worker serves its held values until it restarts. It is
> safe only where a single process performs all writes and all reads.

#### Local version cache

The local cache is an optimisation, never a source of truth. It holds one small
binary record — the version counter and its expiry — in
`LOCAL_VERSION_CACHE_PATH`, read through `mmap` and written under an exclusive
`flock`, which requires a POSIX platform. Processes that share that filesystem
read the file instead of the shared cache for `LOCAL_VERSION_CACHE_TTL` seconds,
turning their version checks into one cache round-trip per machine. The file
name carries a digest of `CACHE_KEY_PREFIX`, so two projects sharing a directory
do not overwrite each other's counter.

A record that is torn, expired or unreadable is rejected — the record carries a
checksum — and the version is read from the shared cache instead. An unwritable
path degrades the same way, so the local cache can be lost or disabled at any
time without affecting correctness.

### 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).
