Compare commits

...

71 commits

Author SHA1 Message Date
Alexandr Artemyev
22f56b3064
Update Code of Conduct (#661)
Some checks are pending
Docs / docs (push) Waiting to run
Test / ruff-format (push) Waiting to run
Test / ruff-lint (push) Waiting to run
Test / build (3.10) (push) Waiting to run
Test / build (3.11) (push) Waiting to run
Test / build (3.12) (push) Waiting to run
Test / build (3.13) (push) Waiting to run
Test / build (3.14) (push) Waiting to run
Test / build (3.8) (push) Waiting to run
Test / build (3.9) (push) Waiting to run
Ref: #660
2026-03-16 09:38:32 -05:00
Philipp Thumfart
c908b05740
Added missing logic to reset multi select (#659)
Some checks failed
Docs / docs (push) Has been cancelled
Test / ruff-format (push) Has been cancelled
Test / ruff-lint (push) Has been cancelled
Test / build (3.10) (push) Has been cancelled
Test / build (3.11) (push) Has been cancelled
Test / build (3.12) (push) Has been cancelled
Test / build (3.13) (push) Has been cancelled
Test / build (3.14) (push) Has been cancelled
Test / build (3.8) (push) Has been cancelled
Test / build (3.9) (push) Has been cancelled
2026-03-14 21:27:39 +05:00
Philipp Thumfart
7e75db3ebc
Fixed latent none return bug (#658)
Some checks failed
Docs / docs (push) Has been cancelled
Test / ruff-format (push) Has been cancelled
Test / ruff-lint (push) Has been cancelled
Test / build (3.10) (push) Has been cancelled
Test / build (3.11) (push) Has been cancelled
Test / build (3.12) (push) Has been cancelled
Test / build (3.13) (push) Has been cancelled
Test / build (3.14) (push) Has been cancelled
Test / build (3.8) (push) Has been cancelled
Test / build (3.9) (push) Has been cancelled
2026-03-11 09:58:20 -05:00
Philipp Thumfart
4ac1e546c7
Added async support (#656)
Some checks failed
Docs / docs (push) Has been cancelled
Test / ruff-format (push) Has been cancelled
Test / ruff-lint (push) Has been cancelled
Test / build (3.10) (push) Has been cancelled
Test / build (3.11) (push) Has been cancelled
Test / build (3.12) (push) Has been cancelled
Test / build (3.13) (push) Has been cancelled
Test / build (3.14) (push) Has been cancelled
Test / build (3.8) (push) Has been cancelled
Test / build (3.9) (push) Has been cancelled
* Added async logic

* Added tests and fixed async deadlock on aset

* Used abstract base class for backend to simplify code coverage

* Reordered try except block

* Added explicit thread safety

* Fixed linting error

* Worked on redis init block

* Fixed async test setup

* Added tests for redis instantiation

* Fixed linting errors
2026-03-04 16:37:37 -06:00
pre-commit-ci[bot]
675c9446cb
[pre-commit.ci] pre-commit autoupdate (#655)
Some checks failed
Docs / docs (push) Has been cancelled
Test / ruff-format (push) Has been cancelled
Test / ruff-lint (push) Has been cancelled
Test / build (3.10) (push) Has been cancelled
Test / build (3.11) (push) Has been cancelled
Test / build (3.12) (push) Has been cancelled
Test / build (3.13) (push) Has been cancelled
Test / build (3.14) (push) Has been cancelled
Test / build (3.8) (push) Has been cancelled
Test / build (3.9) (push) Has been cancelled
updates:
- [github.com/asottile/pyupgrade: v3.20.0 → v3.21.2](https://github.com/asottile/pyupgrade/compare/v3.20.0...v3.21.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-01-30 14:53:23 -06:00
Rémy HUBSCHER
d2b8ca12d5
Merge pull request #654 from vincentdavis/fix_version_docs 2025-12-30 15:48:12 +01:00
Vincent Davis
886c00c1a2 Display correct version in docs 2025-12-30 08:25:26 -06:00
Rémy HUBSCHER
9c3bcbf247
Merge pull request #651 from jazzband/dependabot/github_actions/github-actions-76468cb07f 2025-12-03 12:04:22 +01:00
dependabot[bot]
c30403302f
chore(ci): bump actions/checkout from 5 to 6 in the github-actions group
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 17:16:53 +00:00
Ivan
520e20844a
create autofill_values dict with full_cachekey straight away (#650) 2025-11-24 12:16:02 +05:00
Ivan
a5f01e72bb
Use only method to avoid requesting unused field (#649)
dont query key field since it is not needed
2025-11-23 22:45:16 +05:00
Rémy HUBSCHER
03fe0457ef
Merge pull request #648 from IgorCode/patch-1 2025-11-12 11:40:32 +01:00
Igor Jerosimić
1614466319
Fix collapse setting for Django 5.1+ #647 2025-11-10 11:01:24 +01:00
Rémy HUBSCHER
777bdb8442
Merge pull request #646 from justmobilize/fix-django-5-1-tests 2025-11-04 17:42:55 +01:00
Justin Myers
19889239ad Fix Django 5.1+ admin tests 2025-11-04 08:31:05 -08:00
Rémy HUBSCHER
6931c45c7b
Merge pull request #645 from jazzband/fix/tests 2025-10-21 10:29:44 +02:00
Rémy Hubscher
0f74be9035
Fix linter 2025-10-21 10:28:35 +02:00
Rémy Hubscher
27b1263119
Fix formatting 2025-10-21 10:24:49 +02:00
Rémy Hubscher
f8208af706
Fix imports 2025-10-21 10:23:38 +02:00
Rémy HUBSCHER
e002f4b187
Merge pull request #644 from jazzband/feat/add-test-matrix 2025-10-21 10:09:03 +02:00
Rémy Hubscher
0717cb8d06
Remove 3.14 from Django 5.1 2025-10-21 10:08:18 +02:00
Rémy Hubscher
ae94604872
Merge branch 'mgmt-cmd-respect-db-prefix' 2025-10-21 10:01:47 +02:00
Rémy HUBSCHER
7030cbb39f
Merge pull request #634 from christherama/mgmt-cmd-respect-db-prefix 2025-10-21 10:01:13 +02:00
Rémy HUBSCHER
889090ca7a
Merge pull request #643 from justmobilize/fix-collapse-in-django-5-1 2025-10-21 10:00:20 +02:00
Rémy Hubscher
f7c3d5f736
Add Django 5.1, 5.2 and Python 3.14 test 2025-10-21 09:56:59 +02:00
Rémy HUBSCHER
5c8df725f8
Merge branch 'master' into fix-collapse-in-django-5-1 2025-10-21 09:55:29 +02:00
Rémy HUBSCHER
6a66c636c4
Merge pull request #637 from mahdirahimi1999/feature/django-5.2-support 2025-10-21 09:54:41 +02:00
Rémy HUBSCHER
6209bbaaa8
Merge pull request #642 from dtcooper/master 2025-10-07 22:22:59 +02:00
Justin Myers
93274106a2 Fix collapse in Django 5.1+ 2025-10-07 09:04:23 -07:00
David Cooper
0f88fe83fa Use extra_context view kwarg in changelist_view()
Currently the `extra_context` kwarg is not used in `changelist_view`, it
is completely ignored. However, using it is a common idiom in a
`ModelAdmin` view method as a way to provide extra context variables to
an overridden template.

This is useful if one wants extra context variables in an overridden
`admin/constance/change_list.html` template.
2025-10-07 11:23:04 -04:00
Rémy HUBSCHER
c6b2c44671
Merge pull request #641 from jazzband/chore/run-black 2025-10-07 11:59:21 +02:00
Rémy Hubscher
6d01cffbc0
Use double quotes 2025-10-07 11:26:18 +02:00
Rémy Hubscher
6f12039ae7
Run ruff format 2025-10-07 11:22:24 +02:00
Rémy Hubscher
efe8516efb
Fix missing import 2025-10-07 11:05:48 +02:00
Rémy HUBSCHER
c89ee7c25d
Merge pull request #629 from Dacid99/docs-fixes 2025-10-07 10:57:28 +02:00
Rémy HUBSCHER
eff57684c6
Merge pull request #639 from jazzband/dependabot/github_actions/github-actions-6a14be197d 2025-10-07 10:55:28 +02:00
dependabot[bot]
461b98371f
chore(ci): bump the github-actions group across 1 directory with 2 updates
Bumps the github-actions group with 2 updates in the / directory: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-python](https://github.com/actions/setup-python).


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

Updates `actions/setup-python` from 5 to 6
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-07 08:50:48 +00:00
Rémy HUBSCHER
781e95bbbf
Merge pull request #632 from jazzband/dependabot/github_actions/dot-github/workflows/pypa/gh-action-pypi-publish-1.13.0 2025-10-07 10:49:43 +02:00
Rémy HUBSCHER
87fe8c6471
Merge pull request #627 from jazzband/pre-commit-ci-update-config 2025-10-07 10:49:17 +02:00
pre-commit-ci[bot]
c1e2360093
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/pre-commit/pre-commit-hooks: v5.0.0 → v6.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v5.0.0...v6.0.0)
- [github.com/asottile/pyupgrade: v3.19.1 → v3.20.0](https://github.com/asottile/pyupgrade/compare/v3.19.1...v3.20.0)
2025-10-06 17:43:44 +00:00
Mahdi
4044d7dad9 Add Django 5.1 and 5.2 support
- Add Django 5.1 and 5.2 classifiers to pyproject.toml
- Update changelog to reflect Django 5.1 and 5.2 support
2025-10-04 17:02:23 +03:30
Chris Ramacciotti
c21ea01528 Respects database prefix when removing stale keys 2025-09-19 22:54:40 -05:00
dependabot[bot]
9d4a1fbc25
chore(deps): bump pypa/gh-action-pypi-publish in /.github/workflows
Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.12.4 to 1.13.0.
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.12.4...v1.13.0)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-version: 1.13.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-04 15:46:28 +00:00
david
8563ba5ec6 Add documentation around the usage of collection types as field types 2025-08-02 21:31:39 +02:00
david
5a6a278c70 Fix for setting rst tags 2025-08-02 21:28:29 +02:00
Alexandr Artemyev
6970708e05
Django 5.2 tests (#623) 2025-05-20 00:20:14 +05:00
Rémy HUBSCHER
22bdb011db
Merge pull request #609 from pfouque/fix_unittest
Fix override_config test decorator on Django 5.2
2025-02-04 22:11:22 +01:00
Pascal F
8193866157
Ruff format 2025-02-04 16:49:33 +01:00
Pascal F
756ff419ac
Add Django 5.2 to the test matrix 2025-02-04 16:49:33 +01:00
Pascal F
e61c1ed68c
Fix override_config on Django 5.2 2025-02-04 16:49:33 +01:00
dependabot[bot]
3a4071e622
chore(ci): bump pypa/gh-action-pypi-publish in the github-actions group (#611)
Bumps the github-actions group with 1 update: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `pypa/gh-action-pypi-publish` from 1.12.3 to 1.12.4
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.12.3...v1.12.4)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-03 13:29:28 +05:00
Alexandr Artemyev
4b928c24f9
Update pyproject.toml 2025-01-27 14:21:22 +05:00
Pascal Fouque
2ae381e58e
Fix is_composite_pk on Django 5.2 (#608) 2025-01-27 14:12:47 +05:00
Alexandr Artemyev
f213eb4b79
Fix ruff 2025-01-14 21:40:57 +05:00
Fernando Karchiloff
745fb6f01e
Add get_values_for_keys function to utils (#607)
* Add get_values_for_keys function to utils
* Error text improvement and formatting
2025-01-14 21:31:32 +05:00
Max Lyaskovskiy
0dc1321af3
Updated supported constant types (#584)
---------

Co-authored-by: Rémy HUBSCHER <hubscher.remy@gmail.com>
2025-01-08 13:46:06 +05:00
Rémy HUBSCHER
1522245b82
Merge pull request #606 from atodorov/add-django51-to-ci
Add Django 5.1 to the testing matrix
2025-01-08 09:33:41 +01:00
Alexander Todorov
1451943b7d Add Django 5.1 to the testing matrix 2025-01-07 19:11:42 +02:00
Rotzbua
82bd56650d
feat(docs): improve command line (#605)
update option descriptions according to current code
2025-01-07 08:36:24 -06:00
Rotzbua
f044a7d0fe
fix(docs): use sphinx note function (#604) 2025-01-07 08:34:32 -06:00
Rémy HUBSCHER
d846d70ac4
Merge pull request #603 from jazzband/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2025-01-07 08:51:14 +01:00
pre-commit-ci[bot]
bc8b3bfb43
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v3.17.0 → v3.19.1](https://github.com/asottile/pyupgrade/compare/v3.17.0...v3.19.1)
2025-01-06 17:54:19 +00:00
dependabot[bot]
9c30b1e778
chore(ci): bump pypa/gh-action-pypi-publish in the github-actions group (#602)
Bumps the github-actions group with 1 update: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `pypa/gh-action-pypi-publish` from 1.12.2 to 1.12.3
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.12.2...v1.12.3)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-06 17:21:02 +05:00
Mirat Can Bayrak
7de0840fe2
Update django.po for Turkish translations/ (#601) 2024-12-23 12:58:23 +05:00
Rotzbua
d9c9c3e81f
feat(tests): add python 3.13 to tests (#600) 2024-12-09 12:43:06 +05:00
dependabot[bot]
bb50d5e4c5
chore(ci): bump the github-actions group with 2 updates (#599)
Bumps the github-actions group with 2 updates: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) and [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `pypa/gh-action-pypi-publish` from 1.11.0 to 1.12.2
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.11.0...v1.12.2)

Updates `codecov/codecov-action` from 4 to 5
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-02 11:53:19 +05:00
dependabot[bot]
415d82c6c0
chore(ci): bump pypa/gh-action-pypi-publish in the github-actions group (#596)
Bumps the github-actions group with 1 update: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `pypa/gh-action-pypi-publish` from 1.10.2 to 1.11.0
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.10.2...v1.11.0)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-04 08:09:45 -06:00
Alexandr Artemyev
a604a5ea9f
Fix #593 (#597) 2024-11-04 08:09:19 -06:00
pre-commit-ci[bot]
d4b41234da
[pre-commit.ci] pre-commit autoupdate (#590)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.6.0 → v5.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.6.0...v5.0.0)
- [github.com/asottile/pyupgrade: v3.16.0 → v3.17.0](https://github.com/asottile/pyupgrade/compare/v3.16.0...v3.17.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-10-07 15:46:28 -05:00
dependabot[bot]
9689fd5efc
chore(ci): bump pypa/gh-action-pypi-publish in the github-actions group (#589)
Bumps the github-actions group with 1 update: [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish).


Updates `pypa/gh-action-pypi-publish` from 1.10.0 to 1.10.2
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.10.0...v1.10.2)

---
updated-dependencies:
- dependency-name: pypa/gh-action-pypi-publish
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-01 16:02:52 -05:00
Alexandr Artemyev
d624ee8730
Better already migrated detection (#588) 2024-09-17 14:59:11 +05:00
64 changed files with 2036 additions and 784 deletions

View file

@ -6,10 +6,10 @@ jobs:
docs: docs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: '3.12' python-version: '3.12'
cache: 'pip' cache: 'pip'

View file

@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
- name: Install dependencies - name: Install dependencies
run: | run: |
@ -30,7 +30,7 @@ jobs:
- name: Upload packages to Jazzband - name: Upload packages to Jazzband
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@v1.10.0 uses: pypa/gh-action-pypi-publish@v1.13.0
with: with:
user: jazzband user: jazzband
password: ${{ secrets.JAZZBAND_RELEASE_KEY }} password: ${{ secrets.JAZZBAND_RELEASE_KEY }}

View file

@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 1 timeout-minutes: 1
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: chartboost/ruff-action@v1 - uses: chartboost/ruff-action@v1
with: with:
version: 0.5.0 version: 0.5.0
@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 1 timeout-minutes: 1
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: chartboost/ruff-action@v1 - uses: chartboost/ruff-action@v1
with: with:
version: 0.5.0 version: 0.5.0
@ -28,13 +28,13 @@ jobs:
fail-fast: false fail-fast: false
max-parallel: 5 max-parallel: 5
matrix: matrix:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache: 'pip' cache: 'pip'
@ -49,6 +49,6 @@ jobs:
tox -v tox -v
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v5
with: with:
name: Python ${{ matrix.python-version }} name: Python ${{ matrix.python-version }}

1
.gitignore vendored
View file

@ -10,3 +10,4 @@ test.db
coverage.xml coverage.xml
docs/_build docs/_build
.idea .idea
constance/_version.py

View file

@ -5,13 +5,13 @@ repos:
- id: python-check-blanket-noqa - id: python-check-blanket-noqa
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0 rev: v6.0.0
hooks: hooks:
- id: check-merge-conflict - id: check-merge-conflict
- id: check-yaml - id: check-yaml
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.16.0 rev: v3.21.2
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [ --py38-plus ] args: [ --py38-plus ]

View file

@ -14,3 +14,5 @@ sphinx:
python: python:
install: install:
- requirements: docs/requirements.txt - requirements: docs/requirements.txt
- method: pip
path: .

View file

@ -32,6 +32,7 @@ Merijn Bertels <merijn.bertels@gmail.com>
Omer Katz <omer.drow@gmail.com> Omer Katz <omer.drow@gmail.com>
Petr Knap <dev@petrknap.cz> Petr Knap <dev@petrknap.cz>
Philip Neustrom <philipn@gmail.com> Philip Neustrom <philipn@gmail.com>
Philipp Thumfart <philipp@thumfart.eu>
Pierre-Olivier Marec <pomarec@free.fr> Pierre-Olivier Marec <pomarec@free.fr>
Roman Krejcik <farin@farin.cz> Roman Krejcik <farin@farin.cz>
Silvan Spross <silvan.spross@gmail.com> Silvan Spross <silvan.spross@gmail.com>

View file

@ -1,46 +1,3 @@
# Code of Conduct # Django Constance Code of Conduct
As contributors and maintainers of the Jazzband projects, and in the interest of The django-constance project utilizes the [Django Commons Code of Conduct](https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md).
fostering an open and welcoming community, we pledge to respect all people who
contribute through reporting issues, posting feature requests, updating documentation,
submitting pull requests or patches, and other activities.
We are committed to making participation in the Jazzband a harassment-free experience
for everyone, regardless of the level of experience, gender, gender identity and
expression, sexual orientation, disability, personal appearance, body size, race,
ethnicity, age, religion, or nationality.
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery
- Personal attacks
- Trolling or insulting/derogatory comments
- Public or private harassment
- Publishing other's private information, such as physical or electronic addresses,
without explicit permission
- Other unethical or unprofessional conduct
The Jazzband roadies have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are not
aligned to this Code of Conduct, or to ban temporarily or permanently any contributor
for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
By adopting this Code of Conduct, the roadies commit themselves to fairly and
consistently applying these principles to every aspect of managing the jazzband
projects. Roadies who do not follow or enforce the Code of Conduct may be permanently
removed from the Jazzband roadies.
This code of conduct applies both within project spaces and in public spaces when an
individual is representing the project or its community.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and
investigated and will result in a response that is deemed necessary and appropriate to
the circumstances. Roadies are obligated to maintain confidentiality with regard to the
reporter of an incident.
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version
1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version]
[homepage]: https://contributor-covenant.org
[version]: https://contributor-covenant.org/version/1/3/0/

View file

@ -1,3 +1,4 @@
import json
from collections import OrderedDict from collections import OrderedDict
from datetime import date from datetime import date
from datetime import datetime from datetime import datetime
@ -25,7 +26,7 @@ config = LazyConfig()
class ConstanceAdmin(admin.ModelAdmin): class ConstanceAdmin(admin.ModelAdmin):
change_list_template = 'admin/constance/change_list.html' change_list_template = "admin/constance/change_list.html"
change_list_form = ConstanceForm change_list_form = ConstanceForm
def __init__(self, model, admin_site): def __init__(self, model, admin_site):
@ -33,10 +34,10 @@ class ConstanceAdmin(admin.ModelAdmin):
super().__init__(model, admin_site) super().__init__(model, admin_site)
def get_urls(self): def get_urls(self):
info = f'{self.model._meta.app_label}_{self.model._meta.module_name}' info = f"{self.model._meta.app_label}_{self.model._meta.module_name}"
return [ return [
path('', self.admin_site.admin_view(self.changelist_view), name=f'{info}_changelist'), path("", self.admin_site.admin_view(self.changelist_view), name=f"{info}_changelist"),
path('', self.admin_site.admin_view(self.changelist_view), name=f'{info}_add'), path("", self.admin_site.admin_view(self.changelist_view), name=f"{info}_add"),
] ]
def get_config_value(self, name, options, form, initial): def get_config_value(self, name, options, form, initial):
@ -52,23 +53,28 @@ class ConstanceAdmin(admin.ModelAdmin):
form_field = form[name] form_field = form[name]
config_value = { config_value = {
'name': name, "name": name,
'default': localize(default), "default": localize(default),
'raw_default': default, "raw_default": default,
'help_text': _(help_text), "help_text": _(help_text),
'value': localize(value), "value": localize(value),
'modified': localize(value) != localize(default), "modified": localize(value) != localize(default),
'form_field': form_field, "form_field": form_field,
'is_date': isinstance(default, date), "is_date": isinstance(default, date),
'is_datetime': isinstance(default, datetime), "is_datetime": isinstance(default, datetime),
'is_checkbox': isinstance(form_field.field.widget, forms.CheckboxInput), "is_checkbox": isinstance(form_field.field.widget, forms.CheckboxInput),
'is_file': isinstance(form_field.field.widget, forms.FileInput), "is_multi_select": isinstance(
form_field.field.widget, (forms.SelectMultiple, forms.CheckboxSelectMultiple)
),
"is_file": isinstance(form_field.field.widget, forms.FileInput),
} }
if config_value["is_multi_select"]:
config_value["json_default"] = json.dumps(default if isinstance(default, list) else [default])
if field_type and field_type in settings.ADDITIONAL_FIELDS: if field_type and field_type in settings.ADDITIONAL_FIELDS:
serialized_default = form[name].field.prepare_value(default) serialized_default = form[name].field.prepare_value(default)
config_value['default'] = serialized_default config_value["default"] = serialized_default
config_value['raw_default'] = serialized_default config_value["raw_default"] = serialized_default
config_value['value'] = form[name].field.prepare_value(value) config_value["value"] = form[name].field.prepare_value(value)
return config_value return config_value
@ -85,26 +91,27 @@ class ConstanceAdmin(admin.ModelAdmin):
initial = get_values() initial = get_values()
form_cls = self.get_changelist_form(request) form_cls = self.get_changelist_form(request)
form = form_cls(initial=initial, request=request) form = form_cls(initial=initial, request=request)
if request.method == 'POST' and request.user.has_perm('constance.change_config'): if request.method == "POST" and request.user.has_perm("constance.change_config"):
form = form_cls(data=request.POST, files=request.FILES, initial=initial, request=request) form = form_cls(data=request.POST, files=request.FILES, initial=initial, request=request)
if form.is_valid(): if form.is_valid():
form.save() form.save()
messages.add_message(request, messages.SUCCESS, _('Live settings updated successfully.')) messages.add_message(request, messages.SUCCESS, _("Live settings updated successfully."))
return HttpResponseRedirect('.') return HttpResponseRedirect(".")
messages.add_message(request, messages.ERROR, _('Failed to update live settings.')) messages.add_message(request, messages.ERROR, _("Failed to update live settings."))
context = dict( context = {
self.admin_site.each_context(request), **self.admin_site.each_context(request),
config_values=[], **(extra_context or {}),
title=self.model._meta.app_config.verbose_name, "config_values": [],
app_label='constance', "title": self.model._meta.app_config.verbose_name,
opts=self.model._meta, "app_label": "constance",
form=form, "opts": self.model._meta,
media=self.media + form.media, "form": form,
icon_type='svg', "media": self.media + form.media,
django_version=get_version(), "icon_type": "svg",
) "django_version": get_version(),
}
for name, options in settings.CONFIG.items(): for name, options in settings.CONFIG.items():
context['config_values'].append(self.get_config_value(name, options, form, initial)) context["config_values"].append(self.get_config_value(name, options, form, initial))
if settings.CONFIG_FIELDSETS: if settings.CONFIG_FIELDSETS:
if isinstance(settings.CONFIG_FIELDSETS, dict): if isinstance(settings.CONFIG_FIELDSETS, dict):
@ -112,11 +119,11 @@ class ConstanceAdmin(admin.ModelAdmin):
else: else:
fieldset_items = settings.CONFIG_FIELDSETS fieldset_items = settings.CONFIG_FIELDSETS
context['fieldsets'] = [] context["fieldsets"] = []
for fieldset_title, fieldset_data in fieldset_items: for fieldset_title, fieldset_data in fieldset_items:
if isinstance(fieldset_data, dict): if isinstance(fieldset_data, dict):
fields_list = fieldset_data['fields'] fields_list = fieldset_data["fields"]
collapse = fieldset_data.get('collapse', False) collapse = fieldset_data.get("collapse", False)
else: else:
fields_list = fieldset_data fields_list = fieldset_data
collapse = False collapse = False
@ -124,8 +131,8 @@ class ConstanceAdmin(admin.ModelAdmin):
absent_fields = [field for field in fields_list if field not in settings.CONFIG] absent_fields = [field for field in fields_list if field not in settings.CONFIG]
if any(absent_fields): if any(absent_fields):
raise ValueError( raise ValueError(
'CONSTANCE_CONFIG_FIELDSETS contains field(s) that does not exist(s): {}'.format( "CONSTANCE_CONFIG_FIELDSETS contains field(s) that does not exist(s): {}".format(
', '.join(absent_fields) ", ".join(absent_fields)
) )
) )
@ -135,16 +142,16 @@ class ConstanceAdmin(admin.ModelAdmin):
options = settings.CONFIG.get(name) options = settings.CONFIG.get(name)
if options: if options:
config_values.append(self.get_config_value(name, options, form, initial)) config_values.append(self.get_config_value(name, options, form, initial))
fieldset_context = {'title': fieldset_title, 'config_values': config_values} fieldset_context = {"title": fieldset_title, "config_values": config_values}
if collapse: if collapse:
fieldset_context['collapse'] = True fieldset_context["collapse"] = True
context['fieldsets'].append(fieldset_context) context["fieldsets"].append(fieldset_context)
if not isinstance(settings.CONFIG_FIELDSETS, (OrderedDict, tuple)): if not isinstance(settings.CONFIG_FIELDSETS, (OrderedDict, tuple)):
context['fieldsets'].sort(key=itemgetter('title')) context["fieldsets"].sort(key=itemgetter("title"))
if not isinstance(settings.CONFIG, OrderedDict): if not isinstance(settings.CONFIG, OrderedDict):
context['config_values'].sort(key=itemgetter('name')) context["config_values"].sort(key=itemgetter("name"))
request.current_app = self.admin_site.name request.current_app = self.admin_site.name
return TemplateResponse(request, self.change_list_template, context) return TemplateResponse(request, self.change_list_template, context)
@ -162,19 +169,20 @@ class ConstanceAdmin(admin.ModelAdmin):
class Config: class Config:
class Meta: class Meta:
app_label = 'constance' app_label = "constance"
object_name = 'Config' object_name = "Config"
concrete_model = None concrete_model = None
model_name = module_name = 'config' model_name = module_name = "config"
verbose_name_plural = _('config') verbose_name_plural = _("config")
abstract = False abstract = False
swapped = False swapped = False
is_composite_pk = False
def get_ordered_objects(self): def get_ordered_objects(self):
return False return False
def get_change_permission(self): def get_change_permission(self):
return f'change_{self.model_name}' return f"change_{self.model_name}"
@property @property
def app_config(self): def app_config(self):
@ -182,11 +190,11 @@ class Config:
@property @property
def label(self): def label(self):
return f'{self.app_label}.{self.object_name}' return f"{self.app_label}.{self.object_name}"
@property @property
def label_lower(self): def label_lower(self):
return f'{self.app_label}.{self.model_name}' return f"{self.app_label}.{self.model_name}"
_meta = Meta() _meta = Meta()

View file

@ -6,9 +6,9 @@ from constance.checks import check_fieldsets
class ConstanceConfig(AppConfig): class ConstanceConfig(AppConfig):
name = 'constance' name = "constance"
verbose_name = _('Constance') verbose_name = _("Constance")
default_auto_field = 'django.db.models.AutoField' default_auto_field = "django.db.models.AutoField"
def ready(self): def ready(self):
checks.register(check_fieldsets, 'constance') checks.register(check_fieldsets, "constance")

View file

@ -1,21 +1,50 @@
"""Defines the base constance backend.""" """Defines the base constance backend."""
from abc import ABC
from abc import abstractmethod
class Backend:
class Backend(ABC):
@abstractmethod
def get(self, key): def get(self, key):
""" """
Get the key from the backend store and return the value. Get the key from the backend store and return the value.
Return None if not found. Return None if not found.
""" """
raise NotImplementedError ...
@abstractmethod
async def aget(self, key):
"""
Get the key from the backend store and return the value.
Return None if not found.
"""
...
@abstractmethod
def mget(self, keys): def mget(self, keys):
""" """
Get the keys from the backend store and return a list of the values. Get the keys from the backend store and return a dict mapping
Return an empty list if not found. each found key to its value. Return an empty dict if no keys
are provided or none are found.
""" """
raise NotImplementedError ...
@abstractmethod
async def amget(self, keys):
"""
Get the keys from the backend store and return a dict mapping
each found key to its value. Return an empty dict if no keys
are provided or none are found.
"""
...
@abstractmethod
def set(self, key, value): def set(self, key, value):
"""Add the value to the backend store given the key.""" """Add the value to the backend store given the key."""
raise NotImplementedError ...
@abstractmethod
async def aset(self, key, value):
"""Add the value to the backend store given the key."""
...

View file

@ -22,7 +22,7 @@ class DatabaseBackend(Backend):
self._model = Constance self._model = Constance
self._prefix = settings.DATABASE_PREFIX self._prefix = settings.DATABASE_PREFIX
self._autofill_timeout = settings.DATABASE_CACHE_AUTOFILL_TIMEOUT self._autofill_timeout = settings.DATABASE_CACHE_AUTOFILL_TIMEOUT
self._autofill_cachekey = 'autofilled' self._autofill_cachekey = "autofilled"
if self._model._meta.app_config is None: if self._model._meta.app_config is None:
raise ImproperlyConfigured( raise ImproperlyConfigured(
@ -34,9 +34,9 @@ class DatabaseBackend(Backend):
self._cache = caches[settings.DATABASE_CACHE_BACKEND] self._cache = caches[settings.DATABASE_CACHE_BACKEND]
if isinstance(self._cache, LocMemCache): if isinstance(self._cache, LocMemCache):
raise ImproperlyConfigured( raise ImproperlyConfigured(
'The CONSTANCE_DATABASE_CACHE_BACKEND setting refers to a ' "The CONSTANCE_DATABASE_CACHE_BACKEND setting refers to a "
f"subclass of Django's local-memory backend ({settings.DATABASE_CACHE_BACKEND!r}). Please " f"subclass of Django's local-memory backend ({settings.DATABASE_CACHE_BACKEND!r}). Please "
'set it to a backend that supports cross-process caching.' "set it to a backend that supports cross-process caching."
) )
else: else:
self._cache = None self._cache = None
@ -45,7 +45,7 @@ class DatabaseBackend(Backend):
post_save.connect(self.clear, sender=self._model) post_save.connect(self.clear, sender=self._model)
def add_prefix(self, key): def add_prefix(self, key):
return f'{self._prefix}{key}' return f"{self._prefix}{key}"
def autofill(self): def autofill(self):
if not self._autofill_timeout or not self._cache: if not self._autofill_timeout or not self._cache:
@ -53,22 +53,23 @@ class DatabaseBackend(Backend):
full_cachekey = self.add_prefix(self._autofill_cachekey) full_cachekey = self.add_prefix(self._autofill_cachekey)
if self._cache.get(full_cachekey): if self._cache.get(full_cachekey):
return return
autofill_values = {} autofill_values = {full_cachekey: 1}
autofill_values[full_cachekey] = 1 for key, value in self.mget(settings.CONFIG).items():
for key, value in self.mget(settings.CONFIG):
autofill_values[self.add_prefix(key)] = value autofill_values[self.add_prefix(key)] = value
self._cache.set_many(autofill_values, timeout=self._autofill_timeout) self._cache.set_many(autofill_values, timeout=self._autofill_timeout)
def mget(self, keys): def mget(self, keys):
result = {}
if not keys: if not keys:
return return result
keys = {self.add_prefix(key): key for key in keys} keys = {self.add_prefix(key): key for key in keys}
try: try:
stored = self._model._default_manager.filter(key__in=keys) stored = self._model._default_manager.filter(key__in=keys)
for const in stored: for const in stored:
yield keys[const.key], loads(const.value) result[keys[const.key]] = loads(const.value)
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
pass pass
return result
def get(self, key): def get(self, key):
key = self.add_prefix(key) key = self.add_prefix(key)
@ -79,13 +80,53 @@ class DatabaseBackend(Backend):
self.autofill() self.autofill()
value = self._cache.get(key) value = self._cache.get(key)
if value is None: if value is None:
match = self._model._default_manager.filter(key=key).first() match = self._model._default_manager.filter(key=key).only("value").first()
if match: if match:
value = loads(match.value) value = loads(match.value)
if self._cache: if self._cache:
self._cache.add(key, value) self._cache.add(key, value)
return value return value
async def aget(self, key):
from asgiref.sync import sync_to_async
prefixed_key = self.add_prefix(key)
value = None
if self._cache:
value = await self._cache.aget(prefixed_key)
if value is None:
await sync_to_async(self.autofill, thread_sensitive=True)()
value = await self._cache.aget(prefixed_key)
if value is None:
match = await self._model._default_manager.filter(key=prefixed_key).only("value").afirst()
if match:
value = loads(match.value)
if self._cache:
await self._cache.aadd(prefixed_key, value)
return value
async def amget(self, keys):
if not keys:
return {}
prefixed_keys_map = {self.add_prefix(key): key for key in keys}
results = {}
if self._cache:
cache_results = await self._cache.aget_many(prefixed_keys_map.keys())
for prefixed_key, value in cache_results.items():
results[prefixed_keys_map[prefixed_key]] = value
missing_prefixed_keys = [k for k in prefixed_keys_map if prefixed_keys_map[k] not in results]
if missing_prefixed_keys:
try:
async for const in self._model._default_manager.filter(key__in=missing_prefixed_keys):
results[prefixed_keys_map[const.key]] = loads(const.value)
except (OperationalError, ProgrammingError):
pass
return results
def set(self, key, value): def set(self, key, value):
key = self.add_prefix(key) key = self.add_prefix(key)
created = False created = False
@ -111,7 +152,7 @@ class DatabaseBackend(Backend):
if not created: if not created:
old_value = loads(constance.value) old_value = loads(constance.value)
constance.value = dumps(value) constance.value = dumps(value)
constance.save(update_fields=['value']) constance.save(update_fields=["value"])
else: else:
old_value = None old_value = None
@ -120,6 +161,13 @@ class DatabaseBackend(Backend):
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value) signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
async def aset(self, key, value):
from asgiref.sync import sync_to_async
# We use sync_to_async because Django's transaction.atomic() and database connections are thread-local.
# This ensures the operation runs in the correct database thread until native async transactions are supported.
return await sync_to_async(self.set, thread_sensitive=True)(key, value)
def clear(self, sender, instance, created, **kwargs): def clear(self, sender, instance, created, **kwargs):
if self._cache and not created: if self._cache and not created:
keys = [self.add_prefix(k) for k in settings.CONFIG] keys = [self.add_prefix(k) for k in settings.CONFIG]

View file

@ -19,19 +19,28 @@ class MemoryBackend(Backend):
with self._lock: with self._lock:
return self._storage.get(key) return self._storage.get(key)
async def aget(self, key):
# Memory operations are fast enough that we don't need true async here
return self.get(key)
def mget(self, keys): def mget(self, keys):
if not keys: if not keys:
return None return {}
result = []
with self._lock: with self._lock:
for key in keys: return {key: self._storage[key] for key in keys if key in self._storage}
value = self._storage.get(key)
if value is not None: async def amget(self, keys):
result.append((key, value)) if not keys:
return result return {}
with self._lock:
return {key: self._storage[key] for key in keys if key in self._storage}
def set(self, key, value): def set(self, key, value):
with self._lock: with self._lock:
old_value = self._storage.get(key) old_value = self._storage.get(key)
self._storage[key] = value self._storage[key] = value
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value) signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
async def aset(self, key, value):
# Memory operations are fast enough that we don't need true async here
self.set(key, value)

View file

@ -1,3 +1,4 @@
import asyncio
from threading import RLock from threading import RLock
from time import monotonic from time import monotonic
@ -17,20 +18,45 @@ class RedisBackend(Backend):
super().__init__() super().__init__()
self._prefix = settings.REDIS_PREFIX self._prefix = settings.REDIS_PREFIX
connection_cls = settings.REDIS_CONNECTION_CLASS connection_cls = settings.REDIS_CONNECTION_CLASS
if connection_cls is not None: async_connection_cls = settings.REDIS_ASYNC_CONNECTION_CLASS
if connection_cls:
self._rd = utils.import_module_attr(connection_cls)() self._rd = utils.import_module_attr(connection_cls)()
else: else:
try: try:
import redis import redis
except ImportError: except ImportError:
raise ImproperlyConfigured('The Redis backend requires redis-py to be installed.') from None raise ImproperlyConfigured("The Redis backend requires redis-py to be installed.") from None
if isinstance(settings.REDIS_CONNECTION, str):
self._rd = redis.from_url(settings.REDIS_CONNECTION)
else: else:
self._rd = redis.Redis(**settings.REDIS_CONNECTION) if isinstance(settings.REDIS_CONNECTION, str):
self._rd = redis.from_url(settings.REDIS_CONNECTION)
else:
self._rd = redis.Redis(**settings.REDIS_CONNECTION)
if async_connection_cls:
self._ard = utils.import_module_attr(async_connection_cls)()
else:
try:
import redis.asyncio as aredis
except ImportError:
# We set this to none instead of raising an error to indicate that async support is not available
# without breaking existing sync usage.
self._ard = None
else:
if isinstance(settings.REDIS_CONNECTION, str):
self._ard = aredis.from_url(settings.REDIS_CONNECTION)
else:
self._ard = aredis.Redis(**settings.REDIS_CONNECTION)
def add_prefix(self, key): def add_prefix(self, key):
return f'{self._prefix}{key}' return f"{self._prefix}{key}"
def _check_async_support(self):
if self._ard is None:
raise ImproperlyConfigured(
"Async support for the Redis backend requires redis>=4.2.0 "
"or a custom CONSTANCE_REDIS_ASYNC_CONNECTION_CLASS to be configured."
)
def get(self, key): def get(self, key):
value = self._rd.get(self.add_prefix(key)) value = self._rd.get(self.add_prefix(key))
@ -38,23 +64,50 @@ class RedisBackend(Backend):
return loads(value) return loads(value)
return None return None
async def aget(self, key):
self._check_async_support()
value = await self._ard.get(self.add_prefix(key))
if value:
return loads(value)
return None
def mget(self, keys): def mget(self, keys):
if not keys: if not keys:
return return {}
prefixed_keys = [self.add_prefix(key) for key in keys] prefixed_keys = [self.add_prefix(key) for key in keys]
for key, value in zip(keys, self._rd.mget(prefixed_keys)): return {key: loads(value) for key, value in zip(keys, self._rd.mget(prefixed_keys)) if value}
if value:
yield key, loads(value) async def amget(self, keys):
if not keys:
return {}
self._check_async_support()
prefixed_keys = [self.add_prefix(key) for key in keys]
values = await self._ard.mget(prefixed_keys)
return {key: loads(value) for key, value in zip(keys, values) if value}
def set(self, key, value): def set(self, key, value):
old_value = self.get(key) old_value = self.get(key)
self._rd.set(self.add_prefix(key), dumps(value)) self._rd.set(self.add_prefix(key), dumps(value))
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value) signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
async def _aset_internal(self, key, value, old_value):
"""
Internal set operation. Separated to allow subclasses to provide old_value
without going through self.aget() which may have locking behavior.
"""
self._check_async_support()
await self._ard.set(self.add_prefix(key), dumps(value))
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
async def aset(self, key, value):
old_value = await self.aget(key)
await self._aset_internal(key, value, old_value)
class CachingRedisBackend(RedisBackend): class CachingRedisBackend(RedisBackend):
_sentinel = object() _sentinel = object()
_lock = RLock() _lock = RLock()
_async_lock = None # Lazy-initialized asyncio.Lock
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@ -62,6 +115,12 @@ class CachingRedisBackend(RedisBackend):
self._cache = {} self._cache = {}
self._sentinel = object() self._sentinel = object()
def _get_async_lock(self):
# Lazily create the asyncio lock to avoid issues with event loops
if self._async_lock is None:
self._async_lock = asyncio.Lock()
return self._async_lock
def _has_expired(self, value): def _has_expired(self, value):
return value[0] <= monotonic() return value[0] <= monotonic()
@ -79,15 +138,82 @@ class CachingRedisBackend(RedisBackend):
return value[1] return value[1]
async def _aget_unlocked(self, key):
"""
Get value with cache support but without acquiring lock.
Caller must already hold the lock.
"""
value = self._cache.get(key, self._sentinel)
if value is self._sentinel or self._has_expired(value):
new_value = await super().aget(key)
self._cache_value(key, new_value)
return new_value
return value[1]
async def aget(self, key):
value = self._cache.get(key, self._sentinel)
if value is self._sentinel or self._has_expired(value):
async with self._get_async_lock():
# Double-check after acquiring lock, then delegate to unlocked version
return await self._aget_unlocked(key)
return value[1]
def set(self, key, value): def set(self, key, value):
with self._lock: with self._lock:
super().set(key, value) super().set(key, value)
self._cache_value(key, value) self._cache_value(key, value)
async def aset(self, key, value):
async with self._get_async_lock():
# Use unlocked version since we already hold the lock
old_value = await self._aget_unlocked(key)
# Use internal method to avoid lock recursion (super().aset calls self.aget)
await self._aset_internal(key, value, old_value)
self._cache_value(key, value)
def mget(self, keys): def mget(self, keys):
if not keys: if not keys:
return return {}
result = {}
for key in keys: for key in keys:
value = self.get(key) value = self.get(key)
if value is not None: if value is not None:
yield key, value result[key] = value
return result
async def amget(self, keys):
if not keys:
return {}
results = {}
missing_keys = []
# First, check the local cache for all keys
for key in keys:
value = self._cache.get(key, self._sentinel)
if value is not self._sentinel and not self._has_expired(value):
results[key] = value[1]
else:
missing_keys.append(key)
# Fetch missing keys from Redis
if missing_keys:
async with self._get_async_lock():
# Re-check cache for keys that might have been fetched while waiting for lock
still_missing = []
for key in missing_keys:
value = self._cache.get(key, self._sentinel)
if value is not self._sentinel and not self._has_expired(value):
results[key] = value[1]
else:
still_missing.append(key)
if still_missing:
fetched = await super().amget(still_missing)
for key, value in fetched.items():
self._cache_value(key, value)
results[key] = value
return results

View file

@ -1,31 +1,149 @@
import asyncio
import warnings
from . import settings from . import settings
from . import utils from . import utils
class AsyncValueProxy:
def __init__(self, key, config, default):
self._key = key
self._config = config
self._default = default
self._value = None
self._fetched = False
def __await__(self):
return self._get_value().__await__()
async def _get_value(self):
if not self._fetched:
result = await self._config._backend.aget(self._key)
if result is None:
result = self._default
await self._config.aset(self._key, result)
self._value = result
self._fetched = True
return self._value
def _get_sync_value(self):
warnings.warn(
f"Synchronous access to Constance setting '{self._key}' inside an async loop. "
f"Use 'await config.{self._key}' instead.",
RuntimeWarning,
stacklevel=3,
)
return self._config._get_sync_value(self._key, self._default)
def __str__(self):
return str(self._get_sync_value())
def __repr__(self):
return repr(self._get_sync_value())
def __int__(self):
return int(self._get_sync_value())
def __float__(self):
return float(self._get_sync_value())
def __bool__(self):
return bool(self._get_sync_value())
def __eq__(self, other):
return self._get_sync_value() == other
def __ne__(self, other):
return self._get_sync_value() != other
def __lt__(self, other):
return self._get_sync_value() < other
def __le__(self, other):
return self._get_sync_value() <= other
def __gt__(self, other):
return self._get_sync_value() > other
def __ge__(self, other):
return self._get_sync_value() >= other
def __getitem__(self, key):
return self._get_sync_value()[key]
def __iter__(self):
return iter(self._get_sync_value())
def __len__(self):
return len(self._get_sync_value())
def __contains__(self, item):
return item in self._get_sync_value()
def __hash__(self):
return hash(self._get_sync_value())
def __add__(self, other):
return self._get_sync_value() + other
def __sub__(self, other):
return self._get_sync_value() - other
def __mul__(self, other):
return self._get_sync_value() * other
def __truediv__(self, other):
return self._get_sync_value() / other
class Config: class Config:
"""The global config wrapper that handles the backend.""" """The global config wrapper that handles the backend."""
def __init__(self): def __init__(self):
super().__setattr__('_backend', utils.import_module_attr(settings.BACKEND)()) super().__setattr__("_backend", utils.import_module_attr(settings.BACKEND)())
def _get_sync_value(self, key, default):
result = self._backend.get(key)
if result is None:
result = default
setattr(self, key, default)
return result
def __getattr__(self, key): def __getattr__(self, key):
if key == "_backend":
return super().__getattribute__(key)
try: try:
if len(settings.CONFIG[key]) not in (2, 3): if len(settings.CONFIG[key]) not in (2, 3):
raise AttributeError(key) raise AttributeError(key)
default = settings.CONFIG[key][0] default = settings.CONFIG[key][0]
except KeyError as e: except KeyError as e:
raise AttributeError(key) from e raise AttributeError(key) from e
result = self._backend.get(key)
if result is None: try:
result = default asyncio.get_running_loop()
setattr(self, key, default) except RuntimeError:
return result return self._get_sync_value(key, default)
return result return AsyncValueProxy(key, self, default)
def __setattr__(self, key, value): def __setattr__(self, key, value):
if key == "_backend":
super().__setattr__(key, value)
return
if key not in settings.CONFIG: if key not in settings.CONFIG:
raise AttributeError(key) raise AttributeError(key)
self._backend.set(key, value) self._backend.set(key, value)
return
async def aset(self, key, value):
if key not in settings.CONFIG:
raise AttributeError(key)
await self._backend.aset(key, value)
async def amget(self, keys):
backend_values = await self._backend.amget(keys)
# Merge with defaults like utils.get_values_for_keys
default_initial = {name: settings.CONFIG[name][0] for name in keys if name in settings.CONFIG}
return dict(default_initial, **backend_values)
def __dir__(self): def __dir__(self):
return settings.CONFIG.keys() return settings.CONFIG.keys()

View file

@ -14,22 +14,22 @@ def check_fieldsets(*args, **kwargs) -> list[CheckMessage]:
errors = [] errors = []
if hasattr(settings, 'CONFIG_FIELDSETS') and settings.CONFIG_FIELDSETS: if hasattr(settings, "CONFIG_FIELDSETS") and settings.CONFIG_FIELDSETS:
missing_keys, extra_keys = get_inconsistent_fieldnames() missing_keys, extra_keys = get_inconsistent_fieldnames()
if missing_keys: if missing_keys:
check = checks.Warning( check = checks.Warning(
_('CONSTANCE_CONFIG_FIELDSETS is missing field(s) that exists in CONSTANCE_CONFIG.'), _("CONSTANCE_CONFIG_FIELDSETS is missing field(s) that exists in CONSTANCE_CONFIG."),
hint=', '.join(sorted(missing_keys)), hint=", ".join(sorted(missing_keys)),
obj='settings.CONSTANCE_CONFIG', obj="settings.CONSTANCE_CONFIG",
id='constance.E001', id="constance.E001",
) )
errors.append(check) errors.append(check)
if extra_keys: if extra_keys:
check = checks.Warning( check = checks.Warning(
_('CONSTANCE_CONFIG_FIELDSETS contains extra field(s) that does not exist in CONFIG.'), _("CONSTANCE_CONFIG_FIELDSETS contains extra field(s) that does not exist in CONFIG."),
hint=', '.join(sorted(extra_keys)), hint=", ".join(sorted(extra_keys)),
obj='settings.CONSTANCE_CONFIG', obj="settings.CONSTANCE_CONFIG",
id='constance.E002', id="constance.E002",
) )
errors.append(check) errors.append(check)
return errors return errors
@ -53,8 +53,8 @@ def get_inconsistent_fieldnames() -> tuple[set, set]:
for _fieldset_title, fields_list in fieldset_items: for _fieldset_title, fields_list in fieldset_items:
# fields_list can be a dictionary, when a fieldset is defined as collapsible # fields_list can be a dictionary, when a fieldset is defined as collapsible
# https://django-constance.readthedocs.io/en/latest/#fieldsets-collapsing # https://django-constance.readthedocs.io/en/latest/#fieldsets-collapsing
if isinstance(fields_list, dict) and 'fields' in fields_list: if isinstance(fields_list, dict) and "fields" in fields_list:
fields_list = fields_list['fields'] fields_list = fields_list["fields"]
unique_field_names.update(fields_list) unique_field_names.update(fields_list)
if not unique_field_names: if not unique_field_names:
return unique_field_names, unique_field_names return unique_field_names, unique_field_names

View file

@ -14,7 +14,7 @@ from typing import TypeVar
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_DISCRIMINATOR = 'default' DEFAULT_DISCRIMINATOR = "default"
class JSONEncoder(json.JSONEncoder): class JSONEncoder(json.JSONEncoder):
@ -24,11 +24,11 @@ class JSONEncoder(json.JSONEncoder):
for discriminator, (t, _, encoder) in _codecs.items(): for discriminator, (t, _, encoder) in _codecs.items():
if isinstance(o, t): if isinstance(o, t):
return _as(discriminator, encoder(o)) return _as(discriminator, encoder(o))
raise TypeError(f'Object of type {o.__class__.__name__} is not JSON serializable') raise TypeError(f"Object of type {o.__class__.__name__} is not JSON serializable")
def _as(discriminator: str, v: Any) -> dict[str, Any]: def _as(discriminator: str, v: Any) -> dict[str, Any]:
return {'__type__': discriminator, '__value__': v} return {"__type__": discriminator, "__value__": v}
def dumps(obj, _dumps=json.dumps, cls=JSONEncoder, default_kwargs=None, **kwargs): def dumps(obj, _dumps=json.dumps, cls=JSONEncoder, default_kwargs=None, **kwargs):
@ -44,7 +44,7 @@ def loads(s, _loads=json.loads, *, first_level=True, **kwargs):
"""Deserialize json string to object.""" """Deserialize json string to object."""
if first_level: if first_level:
return _loads(s, object_hook=object_hook, **kwargs) return _loads(s, object_hook=object_hook, **kwargs)
if isinstance(s, dict) and '__type__' not in s and '__value__' not in s: if isinstance(s, dict) and "__type__" not in s and "__value__" not in s:
return {k: loads(v, first_level=False) for k, v in s.items()} return {k: loads(v, first_level=False) for k, v in s.items()}
if isinstance(s, list): if isinstance(s, list):
return list(loads(v, first_level=False) for v in s) return list(loads(v, first_level=False) for v in s)
@ -53,20 +53,20 @@ def loads(s, _loads=json.loads, *, first_level=True, **kwargs):
def object_hook(o: dict) -> Any: def object_hook(o: dict) -> Any:
"""Hook function to perform custom deserialization.""" """Hook function to perform custom deserialization."""
if o.keys() == {'__type__', '__value__'}: if o.keys() == {"__type__", "__value__"}:
if o['__type__'] == DEFAULT_DISCRIMINATOR: if o["__type__"] == DEFAULT_DISCRIMINATOR:
return o['__value__'] return o["__value__"]
codec = _codecs.get(o['__type__']) codec = _codecs.get(o["__type__"])
if not codec: if not codec:
raise ValueError(f'Unsupported type: {o["__type__"]}') raise ValueError(f"Unsupported type: {o['__type__']}")
return codec[1](o['__value__']) return codec[1](o["__value__"])
if '__type__' not in o and '__value__' not in o: if "__type__" not in o and "__value__" not in o:
return o return o
logger.error('Cannot deserialize object: %s', o) logger.error("Cannot deserialize object: %s", o)
raise ValueError(f'Invalid object: {o}') raise ValueError(f"Invalid object: {o}")
T = TypeVar('T') T = TypeVar("T")
class Encoder(Protocol[T]): class Encoder(Protocol[T]):
@ -79,9 +79,9 @@ class Decoder(Protocol[T]):
def register_type(t: type[T], discriminator: str, encoder: Encoder[T], decoder: Decoder[T]): def register_type(t: type[T], discriminator: str, encoder: Encoder[T], decoder: Decoder[T]):
if not discriminator: if not discriminator:
raise ValueError('Discriminator must be specified') raise ValueError("Discriminator must be specified")
if _codecs.get(discriminator) or discriminator == DEFAULT_DISCRIMINATOR: if _codecs.get(discriminator) or discriminator == DEFAULT_DISCRIMINATOR:
raise ValueError(f'Type with discriminator {discriminator} is already registered') raise ValueError(f"Type with discriminator {discriminator} is already registered")
_codecs[discriminator] = (t, decoder, encoder) _codecs[discriminator] = (t, decoder, encoder)
@ -90,12 +90,12 @@ _codecs: dict[str, tuple[type, Decoder, Encoder]] = {}
def _register_default_types(): def _register_default_types():
# NOTE: datetime should be registered before date, because datetime is also instance of date. # NOTE: datetime should be registered before date, because datetime is also instance of date.
register_type(datetime, 'datetime', datetime.isoformat, datetime.fromisoformat) register_type(datetime, "datetime", datetime.isoformat, datetime.fromisoformat)
register_type(date, 'date', lambda o: o.isoformat(), lambda o: datetime.fromisoformat(o).date()) register_type(date, "date", lambda o: o.isoformat(), lambda o: datetime.fromisoformat(o).date())
register_type(time, 'time', lambda o: o.isoformat(), time.fromisoformat) register_type(time, "time", lambda o: o.isoformat(), time.fromisoformat)
register_type(Decimal, 'decimal', str, Decimal) register_type(Decimal, "decimal", str, Decimal)
register_type(uuid.UUID, 'uuid', lambda o: o.hex, uuid.UUID) register_type(uuid.UUID, "uuid", lambda o: o.hex, uuid.UUID)
register_type(timedelta, 'timedelta', lambda o: o.total_seconds(), lambda o: timedelta(seconds=o)) register_type(timedelta, "timedelta", lambda o: o.total_seconds(), lambda o: timedelta(seconds=o))
_register_default_types() _register_default_types()

View file

@ -12,4 +12,4 @@ def config(request):
) )
""" """
return {'config': constance.config} return {"config": constance.config}

View file

@ -25,27 +25,27 @@ from .checks import get_inconsistent_fieldnames
config = LazyConfig() config = LazyConfig()
NUMERIC_WIDGET = forms.TextInput(attrs={'size': 10}) NUMERIC_WIDGET = forms.TextInput(attrs={"size": 10})
INTEGER_LIKE = (fields.IntegerField, {'widget': NUMERIC_WIDGET}) INTEGER_LIKE = (fields.IntegerField, {"widget": NUMERIC_WIDGET})
STRING_LIKE = ( STRING_LIKE = (
fields.CharField, fields.CharField,
{ {
'widget': forms.Textarea(attrs={'rows': 3}), "widget": forms.Textarea(attrs={"rows": 3}),
'required': False, "required": False,
}, },
) )
FIELDS = { FIELDS = {
bool: (fields.BooleanField, {'required': False}), bool: (fields.BooleanField, {"required": False}),
int: INTEGER_LIKE, int: INTEGER_LIKE,
Decimal: (fields.DecimalField, {'widget': NUMERIC_WIDGET}), Decimal: (fields.DecimalField, {"widget": NUMERIC_WIDGET}),
str: STRING_LIKE, str: STRING_LIKE,
datetime: (fields.SplitDateTimeField, {'widget': widgets.AdminSplitDateTime}), datetime: (fields.SplitDateTimeField, {"widget": widgets.AdminSplitDateTime}),
timedelta: (fields.DurationField, {'widget': widgets.AdminTextInputWidget}), timedelta: (fields.DurationField, {"widget": widgets.AdminTextInputWidget}),
date: (fields.DateField, {'widget': widgets.AdminDateWidget}), date: (fields.DateField, {"widget": widgets.AdminDateWidget}),
time: (fields.TimeField, {'widget': widgets.AdminTimeWidget}), time: (fields.TimeField, {"widget": widgets.AdminTimeWidget}),
float: (fields.FloatField, {'widget': NUMERIC_WIDGET}), float: (fields.FloatField, {"widget": NUMERIC_WIDGET}),
} }
@ -58,12 +58,12 @@ def parse_additional_fields(fields):
field[0] = import_string(field[0]) field[0] = import_string(field[0])
if 'widget' in field[1]: if "widget" in field[1]:
klass = import_string(field[1]['widget']) klass = import_string(field[1]["widget"])
field[1]['widget'] = klass(**(field[1].get('widget_kwargs', {}) or {})) field[1]["widget"] = klass(**(field[1].get("widget_kwargs", {}) or {}))
if 'widget_kwargs' in field[1]: if "widget_kwargs" in field[1]:
del field[1]['widget_kwargs'] del field[1]["widget_kwargs"]
fields[key] = field fields[key] = field
@ -80,7 +80,7 @@ class ConstanceForm(forms.Form):
super().__init__(*args, initial=initial, **kwargs) super().__init__(*args, initial=initial, **kwargs)
version_hash = hashlib.sha256() version_hash = hashlib.sha256()
only_view = request and not request.user.has_perm('constance.change_config') only_view = request and not request.user.has_perm("constance.change_config")
if only_view: if only_view:
messages.warning( messages.warning(
request, request,
@ -94,13 +94,13 @@ class ConstanceForm(forms.Form):
if config_type not in settings.ADDITIONAL_FIELDS and not isinstance(default, config_type): if config_type not in settings.ADDITIONAL_FIELDS and not isinstance(default, config_type):
raise ImproperlyConfigured( raise ImproperlyConfigured(
_( _(
'Default value type must be ' "Default value type must be "
'equal to declared config ' "equal to declared config "
'parameter type. Please fix ' "parameter type. Please fix "
'the default value of ' "the default value of "
"'%(name)s'." "'%(name)s'."
) )
% {'name': name} % {"name": name}
) )
else: else:
config_type = type(default) config_type = type(default)
@ -109,19 +109,19 @@ class ConstanceForm(forms.Form):
raise ImproperlyConfigured( raise ImproperlyConfigured(
_( _(
"Constance doesn't support " "Constance doesn't support "
'config values of the type ' "config values of the type "
'%(config_type)s. Please fix ' "%(config_type)s. Please fix "
"the value of '%(name)s'." "the value of '%(name)s'."
) )
% {'config_type': config_type, 'name': name} % {"config_type": config_type, "name": name}
) )
field_class, kwargs = FIELDS[config_type] field_class, kwargs = FIELDS[config_type]
if only_view: if only_view:
kwargs['disabled'] = True kwargs["disabled"] = True
self.fields[name] = field_class(label=name, **kwargs) self.fields[name] = field_class(label=name, **kwargs)
version_hash.update(smart_bytes(initial.get(name, ''))) version_hash.update(smart_bytes(initial.get(name, "")))
self.initial['version'] = version_hash.hexdigest() self.initial["version"] = version_hash.hexdigest()
def save(self): def save(self):
for file_field in self.files: for file_field in self.files:
@ -142,18 +142,14 @@ class ConstanceForm(forms.Form):
setattr(config, name, new) setattr(config, name, new)
def clean_version(self): def clean_version(self):
value = self.cleaned_data['version'] value = self.cleaned_data["version"]
if settings.IGNORE_ADMIN_VERSION_CHECK: if settings.IGNORE_ADMIN_VERSION_CHECK:
return value return value
if value != self.initial['version']: if value != self.initial["version"]:
raise forms.ValidationError( raise forms.ValidationError(
_( _("The settings have been modified by someone else. Please reload the form and resubmit your changes.")
'The settings have been modified '
'by someone else. Please reload the '
'form and resubmit your changes.'
)
) )
return value return value
@ -166,7 +162,7 @@ class ConstanceForm(forms.Form):
missing_keys, extra_keys = get_inconsistent_fieldnames() missing_keys, extra_keys = get_inconsistent_fieldnames()
if missing_keys or extra_keys: if missing_keys or extra_keys:
raise forms.ValidationError( raise forms.ValidationError(
_('CONSTANCE_CONFIG_FIELDSETS is missing field(s) that exists in CONSTANCE_CONFIG.') _("CONSTANCE_CONFIG_FIELDSETS is missing field(s) that exists in CONSTANCE_CONFIG.")
) )
return cleaned_data return cleaned_data

View file

@ -69,7 +69,7 @@ msgstr "constances"
#: constance/management/commands/constance.py:32 #: constance/management/commands/constance.py:32
msgid "Get/Set In-database config settings handled by Constance" msgid "Get/Set In-database config settings handled by Constance"
msgstr "" msgstr "Constance tarafından veritabanında barındırılan ayarları görüntüle/değiştir"
#: constance/templates/admin/constance/change_list.html:60 #: constance/templates/admin/constance/change_list.html:60
msgid "Save" msgid "Save"
@ -93,7 +93,7 @@ msgstr "Değer"
#: constance/templates/admin/constance/includes/results_list.html:9 #: constance/templates/admin/constance/includes/results_list.html:9
msgid "Is modified" msgid "Is modified"
msgstr "Değiştiril mi" msgstr "Değiştirildi mi"
#: constance/templates/admin/constance/includes/results_list.html:22 #: constance/templates/admin/constance/includes/results_list.html:22
msgid "Current file" msgid "Current file"

View file

@ -26,36 +26,36 @@ def _set_constance_value(key, value):
class Command(BaseCommand): class Command(BaseCommand):
help = _('Get/Set In-database config settings handled by Constance') help = _("Get/Set In-database config settings handled by Constance")
GET = 'get' GET = "get"
SET = 'set' SET = "set"
LIST = 'list' LIST = "list"
REMOVE_STALE_KEYS = 'remove_stale_keys' REMOVE_STALE_KEYS = "remove_stale_keys"
def add_arguments(self, parser): def add_arguments(self, parser):
subparsers = parser.add_subparsers(dest='command') subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser(self.LIST, help='list all Constance keys and their values') subparsers.add_parser(self.LIST, help="list all Constance keys and their values")
parser_get = subparsers.add_parser(self.GET, help='get the value of a Constance key') parser_get = subparsers.add_parser(self.GET, help="get the value of a Constance key")
parser_get.add_argument('key', help='name of the key to get', metavar='KEY') parser_get.add_argument("key", help="name of the key to get", metavar="KEY")
parser_set = subparsers.add_parser(self.SET, help='set the value of a Constance key') parser_set = subparsers.add_parser(self.SET, help="set the value of a Constance key")
parser_set.add_argument('key', help='name of the key to set', metavar='KEY') parser_set.add_argument("key", help="name of the key to set", metavar="KEY")
# use nargs='+' so that we pass a list to MultiValueField (eg SplitDateTimeField) # use nargs='+' so that we pass a list to MultiValueField (eg SplitDateTimeField)
parser_set.add_argument('value', help='value to set', metavar='VALUE', nargs='+') parser_set.add_argument("value", help="value to set", metavar="VALUE", nargs="+")
subparsers.add_parser( subparsers.add_parser(
self.REMOVE_STALE_KEYS, self.REMOVE_STALE_KEYS,
help='delete all Constance keys and their values if they are not in settings.CONSTANCE_CONFIG (stale keys)', help="delete all Constance keys and their values if they are not in settings.CONSTANCE_CONFIG (stale keys)",
) )
def handle(self, command, key=None, value=None, *args, **options): def handle(self, command, key=None, value=None, *args, **options):
if command == self.GET: if command == self.GET:
try: try:
self.stdout.write(str(getattr(config, key)), ending='\n') self.stdout.write(str(getattr(config, key)), ending="\n")
except AttributeError as e: except AttributeError as e:
raise CommandError(f'{key} is not defined in settings.CONSTANCE_CONFIG') from e raise CommandError(f"{key} is not defined in settings.CONSTANCE_CONFIG") from e
elif command == self.SET: elif command == self.SET:
try: try:
if len(value) == 1: if len(value) == 1:
@ -63,21 +63,22 @@ class Command(BaseCommand):
value = value[0] value = value[0]
_set_constance_value(key, value) _set_constance_value(key, value)
except KeyError as e: except KeyError as e:
raise CommandError(f'{key} is not defined in settings.CONSTANCE_CONFIG') from e raise CommandError(f"{key} is not defined in settings.CONSTANCE_CONFIG") from e
except ValidationError as e: except ValidationError as e:
raise CommandError(', '.join(e)) from e raise CommandError(", ".join(e)) from e
elif command == self.LIST: elif command == self.LIST:
for k, v in get_values().items(): for k, v in get_values().items():
self.stdout.write(f'{k}\t{v}', ending='\n') self.stdout.write(f"{k}\t{v}", ending="\n")
elif command == self.REMOVE_STALE_KEYS: elif command == self.REMOVE_STALE_KEYS:
actual_keys = settings.CONSTANCE_CONFIG.keys() prefix = getattr(settings, "CONSTANCE_DATABASE_PREFIX", "")
actual_keys = [f"{prefix}{key}" for key in settings.CONSTANCE_CONFIG]
stale_records = Constance.objects.exclude(key__in=actual_keys) stale_records = Constance.objects.exclude(key__in=actual_keys)
if stale_records: if stale_records:
self.stdout.write('The following record will be deleted:', ending='\n') self.stdout.write("The following record will be deleted:", ending="\n")
else: else:
self.stdout.write('There are no stale records in the database.', ending='\n') self.stdout.write("There are no stale records in the database.", ending="\n")
for stale_record in stale_records: for stale_record in stale_records:
self.stdout.write(f'{stale_record.key}\t{stale_record.value}', ending='\n') self.stdout.write(f"{stale_record.key}\t{stale_record.value}", ending="\n")
stale_records.delete() stale_records.delete()
else: else:
raise CommandError('Invalid command') raise CommandError("Invalid command")

View file

@ -9,16 +9,16 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Constance', name="Constance",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
('key', models.CharField(max_length=255, unique=True)), ("key", models.CharField(max_length=255, unique=True)),
('value', models.TextField(blank=True, editable=False, null=True)), ("value", models.TextField(blank=True, editable=False, null=True)),
], ],
options={ options={
'verbose_name': 'constance', "verbose_name": "constance",
'verbose_name_plural': 'constances', "verbose_name_plural": "constances",
'permissions': [('change_config', 'Can change config'), ('view_config', 'Can view config')], "permissions": [("change_config", "Can change config"), ("view_config", "Can view config")],
}, },
), ),
] ]

View file

@ -12,19 +12,19 @@ def _migrate_from_old_table(apps, schema_editor) -> None:
On new installations just ignore error that table does not exist. On new installations just ignore error that table does not exist.
""" """
connection = schema_editor.connection connection = schema_editor.connection
quoted_string = ', '.join([connection.ops.quote_name(item) for item in ['id', 'key', 'value']]) quoted_string = ", ".join([connection.ops.quote_name(item) for item in ["id", "key", "value"]])
old_table_name = 'constance_config' old_table_name = "constance_config"
with connection.cursor() as cursor: with connection.cursor() as cursor:
if old_table_name not in connection.introspection.table_names(): if old_table_name not in connection.introspection.table_names():
logger.info('Old table does not exist, skipping') logger.info("Old table does not exist, skipping")
return return
cursor.execute( cursor.execute(
f'INSERT INTO constance_constance ( {quoted_string} ) SELECT {quoted_string} FROM {old_table_name}', # noqa: S608 f"INSERT INTO constance_constance ( {quoted_string} ) SELECT {quoted_string} FROM {old_table_name}", # noqa: S608
[], [],
) )
cursor.execute(f'DROP TABLE {old_table_name}', []) cursor.execute(f"DROP TABLE {old_table_name}", [])
Constance = apps.get_model('constance', 'Constance') Constance = apps.get_model("constance", "Constance")
sequence_sql = connection.ops.sequence_reset_sql(no_style(), [Constance]) sequence_sql = connection.ops.sequence_reset_sql(no_style(), [Constance])
with connection.cursor() as cursor: with connection.cursor() as cursor:
for sql in sequence_sql: for sql in sequence_sql:
@ -32,7 +32,7 @@ def _migrate_from_old_table(apps, schema_editor) -> None:
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [('constance', '0001_initial')] dependencies = [("constance", "0001_initial")]
atomic = False atomic = False

View file

@ -1,3 +1,4 @@
import json
import logging import logging
import pickle import pickle
from base64 import b64decode from base64 import b64decode
@ -11,19 +12,33 @@ from constance.codecs import dumps
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def is_already_migrated(value):
try:
data = json.loads(value)
if isinstance(data, dict) and set(data.keys()) == {"__type__", "__value__"}:
return True
except (json.JSONDecodeError, TypeError, UnicodeDecodeError):
return False
return False
def import_module_attr(path): def import_module_attr(path):
package, module = path.rsplit('.', 1) package, module = path.rsplit(".", 1)
return getattr(import_module(package), module) return getattr(import_module(package), module)
def migrate_pickled_data(apps, schema_editor) -> None: # pragma: no cover def migrate_pickled_data(apps, schema_editor) -> None: # pragma: no cover
Constance = apps.get_model('constance', 'Constance') Constance = apps.get_model("constance", "Constance")
for constance in Constance.objects.exclude(value=None): for constance in Constance.objects.exclude(value=None):
constance.value = dumps(pickle.loads(b64decode(constance.value.encode()))) # noqa: S301 if not is_already_migrated(constance.value):
constance.save(update_fields=['value']) constance.value = dumps(pickle.loads(b64decode(constance.value.encode()))) # noqa: S301
constance.save(update_fields=["value"])
if settings.BACKEND in ('constance.backends.redisd.RedisBackend', 'constance.backends.redisd.CachingRedisBackend'): if settings.BACKEND in (
"constance.backends.redisd.RedisBackend",
"constance.backends.redisd.CachingRedisBackend",
):
import redis import redis
_prefix = settings.REDIS_PREFIX _prefix = settings.REDIS_PREFIX
@ -37,23 +52,16 @@ def migrate_pickled_data(apps, schema_editor) -> None: # pragma: no cover
_rd = redis.Redis(**settings.REDIS_CONNECTION) _rd = redis.Redis(**settings.REDIS_CONNECTION)
redis_migrated_data = {} redis_migrated_data = {}
for key in settings.CONFIG: for key in settings.CONFIG:
prefixed_key = f'{_prefix}{key}' prefixed_key = f"{_prefix}{key}"
value = _rd.get(prefixed_key) value = _rd.get(prefixed_key)
if value is not None: if value is not None and not is_already_migrated(value):
try: redis_migrated_data[prefixed_key] = dumps(pickle.loads(value)) # noqa: S301
redis_migrated_data[prefixed_key] = dumps(pickle.loads(value)) # noqa: S301
except pickle.UnpicklingError as e:
if value.startswith(b'{"__'):
# Seems like we're facing already migrated data
# Might be related to defaults and when config was accessed while django inits for migration
continue
raise e
for prefixed_key, value in redis_migrated_data.items(): for prefixed_key, value in redis_migrated_data.items():
_rd.set(prefixed_key, value) _rd.set(prefixed_key, value)
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [('constance', '0002_migrate_from_old_table')] dependencies = [("constance", "0002_migrate_from_old_table")]
operations = [ operations = [
migrations.RunPython(migrate_pickled_data), migrations.RunPython(migrate_pickled_data),

View file

@ -7,11 +7,11 @@ class Constance(models.Model):
value = models.TextField(null=True, blank=True, editable=False) value = models.TextField(null=True, blank=True, editable=False)
class Meta: class Meta:
verbose_name = _('constance') verbose_name = _("constance")
verbose_name_plural = _('constances') verbose_name_plural = _("constances")
permissions = [ permissions = [
('change_config', 'Can change config'), ("change_config", "Can change config"),
('view_config', 'Can view config'), ("view_config", "Can view config"),
] ]
def __str__(self): def __str__(self):

View file

@ -1,29 +1,31 @@
from django.conf import settings from django.conf import settings
BACKEND = getattr(settings, 'CONSTANCE_BACKEND', 'constance.backends.redisd.RedisBackend') BACKEND = getattr(settings, "CONSTANCE_BACKEND", "constance.backends.redisd.RedisBackend")
CONFIG = getattr(settings, 'CONSTANCE_CONFIG', {}) CONFIG = getattr(settings, "CONSTANCE_CONFIG", {})
CONFIG_FIELDSETS = getattr(settings, 'CONSTANCE_CONFIG_FIELDSETS', {}) CONFIG_FIELDSETS = getattr(settings, "CONSTANCE_CONFIG_FIELDSETS", {})
ADDITIONAL_FIELDS = getattr(settings, 'CONSTANCE_ADDITIONAL_FIELDS', {}) ADDITIONAL_FIELDS = getattr(settings, "CONSTANCE_ADDITIONAL_FIELDS", {})
FILE_ROOT = getattr(settings, 'CONSTANCE_FILE_ROOT', '') FILE_ROOT = getattr(settings, "CONSTANCE_FILE_ROOT", "")
DATABASE_CACHE_BACKEND = getattr(settings, 'CONSTANCE_DATABASE_CACHE_BACKEND', None) DATABASE_CACHE_BACKEND = getattr(settings, "CONSTANCE_DATABASE_CACHE_BACKEND", None)
DATABASE_CACHE_AUTOFILL_TIMEOUT = getattr(settings, 'CONSTANCE_DATABASE_CACHE_AUTOFILL_TIMEOUT', 60 * 60 * 24) DATABASE_CACHE_AUTOFILL_TIMEOUT = getattr(settings, "CONSTANCE_DATABASE_CACHE_AUTOFILL_TIMEOUT", 60 * 60 * 24)
DATABASE_PREFIX = getattr(settings, 'CONSTANCE_DATABASE_PREFIX', '') DATABASE_PREFIX = getattr(settings, "CONSTANCE_DATABASE_PREFIX", "")
REDIS_PREFIX = getattr(settings, 'CONSTANCE_REDIS_PREFIX', 'constance:') REDIS_PREFIX = getattr(settings, "CONSTANCE_REDIS_PREFIX", "constance:")
REDIS_CACHE_TIMEOUT = getattr(settings, 'CONSTANCE_REDIS_CACHE_TIMEOUT', 60) REDIS_CACHE_TIMEOUT = getattr(settings, "CONSTANCE_REDIS_CACHE_TIMEOUT", 60)
REDIS_CONNECTION_CLASS = getattr(settings, 'CONSTANCE_REDIS_CONNECTION_CLASS', None) REDIS_CONNECTION_CLASS = getattr(settings, "CONSTANCE_REDIS_CONNECTION_CLASS", None)
REDIS_CONNECTION = getattr(settings, 'CONSTANCE_REDIS_CONNECTION', {}) REDIS_ASYNC_CONNECTION_CLASS = getattr(settings, "CONSTANCE_REDIS_ASYNC_CONNECTION_CLASS", None)
SUPERUSER_ONLY = getattr(settings, 'CONSTANCE_SUPERUSER_ONLY', True) REDIS_CONNECTION = getattr(settings, "CONSTANCE_REDIS_CONNECTION", {})
IGNORE_ADMIN_VERSION_CHECK = getattr(settings, 'CONSTANCE_IGNORE_ADMIN_VERSION_CHECK', False) SUPERUSER_ONLY = getattr(settings, "CONSTANCE_SUPERUSER_ONLY", True)
IGNORE_ADMIN_VERSION_CHECK = getattr(settings, "CONSTANCE_IGNORE_ADMIN_VERSION_CHECK", False)

View file

@ -12,6 +12,17 @@
if (fieldType === 'checkbox') { if (fieldType === 'checkbox') {
field.prop('checked', this.dataset.default === 'true'); field.prop('checked', this.dataset.default === 'true');
} else if (fieldType === 'multi-select') {
const defaults = JSON.parse(this.dataset.default);
const stringDefaults = defaults.map(function(v) { return String(v); });
// CheckboxSelectMultiple: individual checkboxes inside a wrapper
field.find('input[type="checkbox"]').each(function() {
$(this).prop('checked', stringDefaults.indexOf($(this).val()) !== -1);
});
// SelectMultiple: <select multiple> element
field.find('option').each(function() {
$(this).prop('selected', stringDefaults.indexOf($(this).val()) !== -1);
});
} else if (fieldType === 'date') { } else if (fieldType === 'date') {
const defaultDate = new Date(this.dataset.default * 1000); const defaultDate = new Date(this.dataset.default * 1000);
$('#' + this.dataset.fieldId).val(defaultDate.strftime(get_format('DATE_INPUT_FORMATS')[0])); $('#' + this.dataset.fieldId).val(defaultDate.strftime(get_format('DATE_INPUT_FORMATS')[0]));

View file

@ -49,10 +49,13 @@
{% if fieldsets %} {% if fieldsets %}
{% for fieldset in fieldsets %} {% for fieldset in fieldsets %}
<fieldset class="module{% if fieldset.collapse %} collapse{% endif %}"> <fieldset class="module{% if fieldset.collapse %} collapse{% endif %}">
<h2>{{ fieldset.title }}</h2> {% if django_version >= "5.1" and fieldset.collapse %}<details><summary>{% endif %}
<h2 class="fieldset-heading">{{ fieldset.title }}</h2>
{% if django_version >= "5.1" and fieldset.collapse %}</summary>{% endif %}
{% with config_values=fieldset.config_values %} {% with config_values=fieldset.config_values %}
{% include "admin/constance/includes/results_list.html" %} {% include "admin/constance/includes/results_list.html" %}
{% endwith %} {% endwith %}
{% if django_version >= "5.1" and fieldset.collapse %}</details>{% endif %}
</fieldset> </fieldset>
{% endfor %} {% endfor %}
{% else %} {% else %}

View file

@ -31,12 +31,14 @@
data-field-id="{{ item.form_field.auto_id }}" data-field-id="{{ item.form_field.auto_id }}"
data-field-type="{% spaceless %} data-field-type="{% spaceless %}
{% if item.is_checkbox %}checkbox {% if item.is_checkbox %}checkbox
{% elif item.is_multi_select %}multi-select
{% elif item.is_datetime %}datetime {% elif item.is_datetime %}datetime
{% elif item.is_date %}date {% elif item.is_date %}date
{% endif %} {% endif %}
{% endspaceless %}" {% endspaceless %}"
data-default="{% spaceless %} data-default="{% spaceless %}
{% if item.is_checkbox %}{% if item.raw_default %} true {% else %} false {% endif %} {% if item.is_checkbox %}{% if item.raw_default %} true {% else %} false {% endif %}
{% elif item.is_multi_select %}{{ item.json_default }}
{% elif item.is_date %}{{ item.raw_default|date:"U" }} {% elif item.is_date %}{{ item.raw_default|date:"U" }}
{% elif item.is_datetime %}{{ item.raw_default|date:"U" }} {% elif item.is_datetime %}{{ item.raw_default|date:"U" }}
{% else %}{{ item.default }} {% else %}{{ item.default }}

View file

@ -1,3 +1,3 @@
from .unittest import override_config # pragma: no cover from .unittest import override_config # pragma: no cover
__all__ = ['override_config'] __all__ = ["override_config"]

View file

@ -14,16 +14,16 @@ from constance import config as constance_config
@pytest.hookimpl(trylast=True) @pytest.hookimpl(trylast=True)
def pytest_configure(config): # pragma: no cover def pytest_configure(config): # pragma: no cover
"""Register override_config marker.""" """Register override_config marker."""
config.addinivalue_line('markers', ('override_config(**kwargs): mark test to override django-constance config')) config.addinivalue_line("markers", ("override_config(**kwargs): mark test to override django-constance config"))
@pytest.hookimpl(hookwrapper=True) @pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item): # pragma: no cover def pytest_runtest_call(item): # pragma: no cover
"""Validate constance override marker params. Run test with overridden config.""" """Validate constance override marker params. Run test with overridden config."""
marker = item.get_closest_marker('override_config') marker = item.get_closest_marker("override_config")
if marker is not None: if marker is not None:
if marker.args: if marker.args:
pytest.fail('Constance override can not not accept positional args') pytest.fail("Constance override can not not accept positional args")
with override_config(**marker.kwargs): with override_config(**marker.kwargs):
yield yield
else: else:
@ -59,7 +59,7 @@ class override_config(ContextDecorator):
self.disable() self.disable()
@pytest.fixture(name='override_config') @pytest.fixture(name="override_config")
def _override_config(): def _override_config():
"""Make override_config available as a function fixture.""" """Make override_config available as a function fixture."""
return override_config return override_config

View file

@ -1,11 +1,12 @@
from functools import wraps from functools import wraps
from django import VERSION as DJANGO_VERSION
from django.test import SimpleTestCase from django.test import SimpleTestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from constance import config from constance import config
__all__ = ('override_config',) __all__ = ("override_config",)
class override_config(override_settings): class override_config(override_settings):
@ -23,7 +24,7 @@ class override_config(override_settings):
"""Modify the decorated function to override config values.""" """Modify the decorated function to override config values."""
if isinstance(test_func, type): if isinstance(test_func, type):
if not issubclass(test_func, SimpleTestCase): if not issubclass(test_func, SimpleTestCase):
raise Exception('Only subclasses of Django SimpleTestCase can be decorated with override_config') raise Exception("Only subclasses of Django SimpleTestCase can be decorated with override_config")
return self.modify_test_case(test_func) return self.modify_test_case(test_func)
@wraps(test_func) @wraps(test_func)
@ -44,9 +45,20 @@ class override_config(override_settings):
original_pre_setup = test_case._pre_setup original_pre_setup = test_case._pre_setup
original_post_teardown = test_case._post_teardown original_post_teardown = test_case._post_teardown
def _pre_setup(inner_self): if DJANGO_VERSION < (5, 2):
self.enable()
original_pre_setup(inner_self) def _pre_setup(inner_self):
self.enable()
original_pre_setup(inner_self)
else:
@classmethod
def _pre_setup(cls):
# NOTE: Django 5.2 turned this as a classmethod
# https://github.com/django/django/pull/18514/files
self.enable()
original_pre_setup()
def _post_teardown(inner_self): def _post_teardown(inner_self):
original_post_teardown(inner_self) original_post_teardown(inner_self)

View file

@ -7,7 +7,7 @@ config = LazyConfig()
def import_module_attr(path): def import_module_attr(path):
package, module = path.rsplit('.', 1) package, module = path.rsplit(".", 1)
return getattr(import_module(package), module) return getattr(import_module(package), module)
@ -19,4 +19,58 @@ def get_values():
# First load a mapping between config name and default value # First load a mapping between config name and default value
default_initial = ((name, options[0]) for name, options in settings.CONFIG.items()) default_initial = ((name, options[0]) for name, options in settings.CONFIG.items())
# Then update the mapping with actually values from the backend # Then update the mapping with actually values from the backend
return dict(default_initial, **dict(config._backend.mget(settings.CONFIG))) return dict(default_initial, **config._backend.mget(settings.CONFIG))
async def aget_values():
"""
Get dictionary of values from the backend asynchronously
:return:
"""
default_initial = {name: options[0] for name, options in settings.CONFIG.items()}
backend_values = await config.amget(settings.CONFIG.keys())
return dict(default_initial, **backend_values)
def get_values_for_keys(keys):
"""
Retrieve values for specified keys from the backend.
:param keys: List of keys to retrieve.
:return: Dictionary with values for the specified keys.
:raises AttributeError: If any key is not found in the configuration.
"""
if not isinstance(keys, (list, tuple, set)):
raise TypeError("keys must be a list, tuple, or set of strings")
# Prepare default initial mapping
default_initial = {name: options[0] for name, options in settings.CONFIG.items() if name in keys}
# Check if all keys are present in the default_initial mapping
missing_keys = [key for key in keys if key not in default_initial]
if missing_keys:
raise AttributeError(f'"{", ".join(missing_keys)}" keys not found in configuration.')
# Merge default values and backend values, prioritizing backend values
return dict(default_initial, **config._backend.mget(keys))
async def aget_values_for_keys(keys):
"""
Retrieve values for specified keys from the backend asynchronously.
:param keys: List of keys to retrieve.
:return: Dictionary with values for the specified keys.
:raises AttributeError: If any key is not found in the configuration.
"""
if not isinstance(keys, (list, tuple, set)):
raise TypeError("keys must be a list, tuple, or set of strings")
default_initial = {name: options[0] for name, options in settings.CONFIG.items() if name in keys}
missing_keys = [key for key in keys if key not in default_initial]
if missing_keys:
raise AttributeError(f'"{", ".join(missing_keys)}" keys not found in configuration.')
backend_values = await config.amget(keys)
return dict(default_initial, **backend_values)

View file

@ -16,7 +16,7 @@ v4.0.0 (2024/08/21)
* Migrate JavaScript to ES2015 * Migrate JavaScript to ES2015
* Fix documentation build * Fix documentation build
* Add linters and formatters (using `ruff`) * Add linters and formatters (using `ruff`)
* Prepare for Django 5.1 support * Add support for Django 5.1 and 5.2
* Migrate from `setup.py` to `pyproject.toml` * Migrate from `setup.py` to `pyproject.toml`
* Bump `tox` * Bump `tox`
* Declare support for Python 3.12 * Declare support for Python 3.12

View file

@ -4,62 +4,73 @@
# https://www.sphinx-doc.org/en/master/usage/configuration.html # https://www.sphinx-doc.org/en/master/usage/configuration.html
import os import os
import re
import sys import sys
from datetime import datetime from datetime import datetime
def get_version(): def get_version():
with open('../pyproject.toml') as f: # Try to get version from installed package metadata
for line in f: try:
match = re.match(r'version = "(.*)"', line) from importlib.metadata import PackageNotFoundError
if match: from importlib.metadata import version
return match.group(1)
return '0.0.0' return version("django-constance")
except (ImportError, PackageNotFoundError):
pass
# Fall back to setuptools_scm generated version file
try:
from constance._version import __version__
return __version__
except ImportError:
pass
return "0.0.0"
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
# If extensions (or modules to document with autodoc) are in another directory, # If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the # add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here. # documentation root, use os.path.abspath to make it absolute, like shown here.
sys.path.insert(0, os.path.abspath('extensions')) sys.path.insert(0, os.path.abspath("extensions"))
sys.path.insert(0, os.path.abspath('..')) sys.path.insert(0, os.path.abspath(".."))
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'django-constance' project = "django-constance"
project_copyright = datetime.now().year.__str__() + ', Jazzband' project_copyright = datetime.now().year.__str__() + ", Jazzband"
# The full version, including alpha/beta/rc tags # The full version, including alpha/beta/rc tags
release = get_version() release = get_version()
# The short X.Y version # The short X.Y version
version = '.'.join(release.split('.')[:3]) version = ".".join(release.split(".")[:3])
# -- General configuration ------------------------------------------------ # -- General configuration ------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = [ extensions = [
'sphinx.ext.intersphinx', "sphinx.ext.intersphinx",
'sphinx.ext.todo', "sphinx.ext.todo",
'sphinx_search.extension', "sphinx_search.extension",
'settings', "settings",
] ]
templates_path = ['_templates'] templates_path = ["_templates"]
source_suffix = '.rst' source_suffix = ".rst"
root_doc = 'index' root_doc = "index"
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
pygments_style = 'sphinx' pygments_style = "sphinx"
html_last_updated_fmt = '' html_last_updated_fmt = ""
# -- Options for HTML output ------------------------------------------------- # -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinx_rtd_theme' html_theme = "sphinx_rtd_theme"
html_static_path = ['_static'] html_static_path = ["_static"]
htmlhelp_basename = 'django-constancedoc' htmlhelp_basename = "django-constancedoc"
# -- Options for LaTeX output --------------------------------------------- # -- Options for LaTeX output ---------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-latex-output # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-latex-output
@ -67,31 +78,31 @@ htmlhelp_basename = 'django-constancedoc'
latex_elements = {} latex_elements = {}
latex_documents = [ latex_documents = [
('index', 'django-constance.tex', 'django-constance Documentation', 'Jazzband', 'manual'), ("index", "django-constance.tex", "django-constance Documentation", "Jazzband", "manual"),
] ]
# -- Options for manual page output --------------------------------------- # -- Options for manual page output ---------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-manual-page-output # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-manual-page-output
man_pages = [('index', 'django-constance', 'django-constance Documentation', ['Jazzband'], 1)] man_pages = [("index", "django-constance", "django-constance Documentation", ["Jazzband"], 1)]
# -- Options for Texinfo output ------------------------------------------- # -- Options for Texinfo output -------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-texinfo-output # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-texinfo-output
texinfo_documents = [ texinfo_documents = [
( (
'index', "index",
'django-constance', "django-constance",
'django-constance Documentation', "django-constance Documentation",
'Jazzband', "Jazzband",
'django-constance', "django-constance",
'One line description of project.', "One line description of project.",
'Miscellaneous', "Miscellaneous",
), ),
] ]
# Example configuration for intersphinx: refer to the Python standard library. # Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = { intersphinx_mapping = {
'python': ('https://docs.python.org/3', None), "python": ("https://docs.python.org/3", None),
'django': ('https://docs.djangoproject.com/en/dev/', 'https://docs.djangoproject.com/en/dev/_objects/'), "django": ("https://docs.djangoproject.com/en/dev/", "https://docs.djangoproject.com/en/dev/_objects/"),
} }

View file

@ -1,6 +1,6 @@
def setup(app): def setup(app):
app.add_crossref_type( app.add_crossref_type(
directivename='setting', directivename="setting",
rolename='setting', rolename="setting",
indextemplate='pair: %s; setting', indextemplate="pair: %s; setting",
) )

View file

@ -89,7 +89,7 @@ Custom fields
You can set the field type with the third value in the ``CONSTANCE_CONFIG`` tuple. You can set the field type with the third value in the ``CONSTANCE_CONFIG`` tuple.
The value can be one of the supported types or a string matching a key in your :setting:``CONSTANCE_ADDITIONAL_FIELDS`` The value can be one of the supported types or a string matching a key in your :setting:`CONSTANCE_ADDITIONAL_FIELDS`
The supported types are: The supported types are:
@ -99,11 +99,16 @@ The supported types are:
* ``Decimal`` * ``Decimal``
* ``str`` * ``str``
* ``datetime`` * ``datetime``
* ``timedelta``
* ``date`` * ``date``
* ``time`` * ``time``
* ``list`` * ``list``
* ``dict`` * ``dict``
.. note::
To be able to use ``list`` and ``dict`` you need to set a widget and form field for these types as it is ambiguous what types shall be stored in the collection object.
You can do so with :setting:`CONSTANCE_ADDITIONAL_FIELDS` as explained below.
For example, to force a value to be handled as a string: For example, to force a value to be handled as a string:
.. code-block:: python .. code-block:: python
@ -111,7 +116,7 @@ For example, to force a value to be handled as a string:
'THE_ANSWER': (42, 'Answer to the Ultimate Question of Life, ' 'THE_ANSWER': (42, 'Answer to the Ultimate Question of Life, '
'The Universe, and Everything', str), 'The Universe, and Everything', str),
Custom field types are supported using the dictionary :setting:``CONSTANCE_ADDITIONAL_FIELDS``. Custom field types are supported using the dictionary :setting:`CONSTANCE_ADDITIONAL_FIELDS`.
This is a mapping between a field label and a sequence (list or tuple). The first item in the sequence is the string This is a mapping between a field label and a sequence (list or tuple). The first item in the sequence is the string
path of a field class, and the (optional) second item is a dictionary used to configure the field. path of a field class, and the (optional) second item is a dictionary used to configure the field.
@ -119,7 +124,7 @@ path of a field class, and the (optional) second item is a dictionary used to co
The ``widget`` and ``widget_kwargs`` keys in the field config dictionary can be used to configure the widget used in admin, The ``widget`` and ``widget_kwargs`` keys in the field config dictionary can be used to configure the widget used in admin,
the other values will be passed as kwargs to the field's ``__init__()`` the other values will be passed as kwargs to the field's ``__init__()``
Note: Use later evaluated strings instead of direct classes for the field and widget classes: .. note:: Use later evaluated strings instead of direct classes for the field and widget classes:
.. code-block:: python .. code-block:: python
@ -164,6 +169,21 @@ Images and files are uploaded to ``MEDIA_ROOT`` by default. You can specify a su
This will result in files being placed in ``media/constance`` within your ``BASE_DIR``. You can use deeper nesting in this setting (e.g. ``constance/images``) but other relative path components (e.g. ``../``) will be rejected. This will result in files being placed in ``media/constance`` within your ``BASE_DIR``. You can use deeper nesting in this setting (e.g. ``constance/images``) but other relative path components (e.g. ``../``) will be rejected.
In case you want to store a list of ``int`` values in the constance config, a working setup is
.. code-block:: python
CONSTANCE_ADDITIONAL_FIELDS = {
list: ["django.forms.fields.JSONField", {"widget": "django.forms.Textarea"}],
}
CONSTANCE_CONFIG = {
'KEY': ([0, 10, 20], 'A list of integers', list),
}
Make sure to use the ``JSONField`` for this purpose as user input in the admin page may be understood and saved as ``str`` otherwise.
Ordered Fields in Django Admin Ordered Fields in Django Admin
------------------------------ ------------------------------
@ -272,6 +292,36 @@ object and accessing the variables with attribute lookups::
if config.THE_ANSWER == 42: if config.THE_ANSWER == 42:
answer_the_question() answer_the_question()
Asynchronous usage
^^^^^^^^^^^^^^^^^^
If you are using Django's asynchronous features (like async views), you can ``await`` the settings directly on the standard ``config`` object::
from constance import config
async def my_async_view(request):
# Accessing settings is awaitable
if await config.THE_ANSWER == 42:
return await answer_the_question_async()
async def update_settings():
# Updating settings asynchronously
await config.aset('THE_ANSWER', 43)
# Bulk retrieval is supported as well
values = await config.amget(['THE_ANSWER', 'SITE_NAME'])
Performance and Safety
~~~~~~~~~~~~~~~~~~~~~~
While synchronous access (e.g., ``config.THE_ANSWER``) still works inside async views for some backends, it is highly discouraged:
* **Blocking:** Synchronous access blocks the event loop, reducing the performance of your entire application.
* **Safety Guards:** For the Database backend, Django's safety guards will raise a ``SynchronousOnlyOperation`` error if you attempt to access a setting synchronously from an async thread.
* **Automatic Detection:** Constance will emit a ``RuntimeWarning`` if it detects synchronous access inside an asynchronous event loop, helping you identify and fix these performance bottlenecks.
For peak performance, especially with the Redis backend, always use the ``await`` syntax which leverages native asynchronous drivers.
Django templates Django templates
^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^
@ -313,64 +363,93 @@ any other variable, e.g.:
Command Line Command Line
^^^^^^^^^^^^ ^^^^^^^^^^^^
Constance settings can be get/set on the command line with the manage command `constance` Constance settings can be get/set on the command line with the manage command :command:`constance`.
Available options are: Available options are:
list - output all values in a tab-separated format:: .. program:: constance
$ ./manage.py constance list .. option:: list
THE_ANSWER 42
SITE_NAME My Title
get KEY - output a single values:: list all Constance keys and their values
$ ./manage.py constance get THE_ANSWER .. code-block:: console
42
set KEY VALUE - set a single value:: $ ./manage.py constance list
THE_ANSWER 42
SITE_NAME My Title
$ ./manage.py constance set SITE_NAME "Another Title" .. option:: get <KEY>
If the value contains spaces it should be wrapped in quotes. get the value of a Constance key
.. note:: Set values are validated as per in admin, an error will be raised if validation fails: .. code-block:: console
E.g., given this config as per the example app:: $ ./manage.py constance get THE_ANSWER
42
CONSTANCE_CONFIG = { .. option:: set <KEY> <VALUE>
...
'DATE_ESTABLISHED': (date(1972, 11, 30), "the shop's first opening"),
}
Setting an invalid date will fail as follow:: set the value of a Constance key
$ ./manage.py constance set DATE_ESTABLISHED '1999-12-00' .. code-block:: console
CommandError: Enter a valid date.
$ ./manage.py constance set SITE_NAME "Another Title"
If the value contains spaces it should be wrapped in quotes.
.. note:: Set values are validated as per in admin, an error will be raised if validation fails:
E.g., given this config as per the example app:
.. code-block:: python
CONSTANCE_CONFIG = {
...
'DATE_ESTABLISHED': (date(1972, 11, 30), "the shop's first opening"),
}
Setting an invalid date will fail as follow:
.. code-block:: console
$ ./manage.py constance set DATE_ESTABLISHED '1999-12-00'
CommandError: Enter a valid date.
.. note:: If the admin field is a `MultiValueField`, then the separate field values need to be provided as separate arguments. .. note:: If the admin field is a :class:`MultiValueField`, then the separate field values need to be provided as separate arguments.
E.g., a datetime using `SplitDateTimeField`::
CONSTANCE_CONFIG = { E.g., a datetime using :class:`SplitDateTimeField`:
'DATETIME_VALUE': (datetime(2010, 8, 23, 11, 29, 24), 'time of the first commit'),
}
Then this works (and the quotes are optional):: .. code-block:: python
./manage.py constance set DATETIME_VALUE '2011-09-24' '12:30:25' CONSTANCE_CONFIG = {
'DATETIME_VALUE': (datetime(2010, 8, 23, 11, 29, 24), 'time of the first commit'),
}
This doesn't work:: Then this works (and the quotes are optional):
./manage.py constance set DATETIME_VALUE '2011-09-24 12:30:25' .. code-block:: console
CommandError: Enter a list of values.
./manage.py constance set DATETIME_VALUE '2011-09-24' '12:30:25'
This doesn't work:
.. code-block:: console
./manage.py constance set DATETIME_VALUE '2011-09-24 12:30:25'
CommandError: Enter a list of values.
remove_stale_keys - delete all stale records in database:: .. option:: remove_stale_keys
$ ./manage.py constance remove_stale_keys delete all Constance keys and their values if they are not in settings.CONSTANCE_CONFIG (stale keys)
Record is considered stale if it exists in database but absent in config .. code-block:: console
$ ./manage.py constance remove_stale_keys
Record is considered stale if it exists in database but absent in config.
Editing Editing
------- -------

View file

@ -90,8 +90,9 @@ If you want to use override as a context manager or decorator, consider using
def test_override_context_manager(): def test_override_context_manager():
... ...
Pytest fixture as function or method parameter ( Pytest fixture as function or method parameter.
NOTE: no import needed as fixture is available globally)
.. note:: No import needed as fixture is available globally.
.. code-block:: python .. code-block:: python

View file

@ -7,10 +7,10 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Brand', name="Brand",
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(max_length=75)), ("name", models.CharField(max_length=75)),
], ],
), ),
] ]

View file

@ -7,23 +7,23 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Shelf', name="Shelf",
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(max_length=75)), ("name", models.CharField(max_length=75)),
], ],
options={ options={
'verbose_name_plural': 'shelves', "verbose_name_plural": "shelves",
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Supply', name="Supply",
fields=[ fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(max_length=75)), ("name", models.CharField(max_length=75)),
], ],
options={ options={
'verbose_name_plural': 'supplies', "verbose_name_plural": "supplies",
}, },
), ),
] ]

View file

@ -5,11 +5,11 @@ class Shelf(models.Model):
name = models.CharField(max_length=75) name = models.CharField(max_length=75)
class Meta: class Meta:
verbose_name_plural = 'shelves' verbose_name_plural = "shelves"
class Supply(models.Model): class Supply(models.Model):
name = models.CharField(max_length=75) name = models.CharField(max_length=75)
class Meta: class Meta:
verbose_name_plural = 'supplies' verbose_name_plural = "supplies"

View file

@ -13,7 +13,7 @@ class JsonField(fields.CharField):
def widget_attrs(self, widget: widgets.Widget): def widget_attrs(self, widget: widgets.Widget):
attrs = super().widget_attrs(widget) attrs = super().widget_attrs(widget)
attrs['rows'] = self.rows attrs["rows"] = self.rows
return attrs return attrs
def to_python(self, value): def to_python(self, value):

View file

@ -21,7 +21,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SITE_ID = 1 SITE_ID = 1
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'hdx64#m+lnc_0ffoyehbk&7gk1&*9uar$pcfcm-%$km#p0$k=6' SECRET_KEY = "hdx64#m+lnc_0ffoyehbk&7gk1&*9uar$pcfcm-%$km#p0$k=6"
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
@ -32,122 +32,122 @@ ALLOWED_HOSTS = []
# Application definition # Application definition
INSTALLED_APPS = ( INSTALLED_APPS = (
'django.contrib.admin', "django.contrib.admin",
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.sites', "django.contrib.sites",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.staticfiles', "django.contrib.staticfiles",
'cheeseshop.apps.catalog', "cheeseshop.apps.catalog",
'cheeseshop.apps.storage', "cheeseshop.apps.storage",
'constance', "constance",
) )
MIDDLEWARE = ( MIDDLEWARE = (
'django.middleware.common.CommonMiddleware', "django.middleware.common.CommonMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
) )
ROOT_URLCONF = 'cheeseshop.urls' ROOT_URLCONF = "cheeseshop.urls"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [], "DIRS": [],
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.debug', "django.template.context_processors.debug",
'django.template.context_processors.request', "django.template.context_processors.request",
'django.contrib.auth.context_processors.auth', "django.contrib.auth.context_processors.auth",
'django.contrib.messages.context_processors.messages', "django.contrib.messages.context_processors.messages",
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'cheeseshop.wsgi.application' WSGI_APPLICATION = "cheeseshop.wsgi.application"
# Database # Database
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases # https://docs.djangoproject.com/en/4.1/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.sqlite3', "ENGINE": "django.db.backends.sqlite3",
'NAME': '/tmp/cheeseshop.db', "NAME": "/tmp/cheeseshop.db",
} }
} }
CONSTANCE_REDIS_CONNECTION = { CONSTANCE_REDIS_CONNECTION = {
'host': 'localhost', "host": "localhost",
'port': 6379, "port": 6379,
'db': 0, "db": 0,
} }
CONSTANCE_ADDITIONAL_FIELDS = { CONSTANCE_ADDITIONAL_FIELDS = {
'yes_no_null_select': [ "yes_no_null_select": [
'django.forms.fields.ChoiceField', "django.forms.fields.ChoiceField",
{'widget': 'django.forms.Select', 'choices': ((None, '-----'), ('yes', 'Yes'), ('no', 'No'))}, {"widget": "django.forms.Select", "choices": ((None, "-----"), ("yes", "Yes"), ("no", "No"))},
], ],
'email': ('django.forms.fields.EmailField',), "email": ("django.forms.fields.EmailField",),
'json_field': ['cheeseshop.fields.JsonField'], "json_field": ["cheeseshop.fields.JsonField"],
'image_field': ['django.forms.ImageField', {}], "image_field": ["django.forms.ImageField", {}],
} }
CONSTANCE_CONFIG = { CONSTANCE_CONFIG = {
'BANNER': ('The National Cheese Emporium', 'name of the shop'), "BANNER": ("The National Cheese Emporium", "name of the shop"),
'OWNER': ('Mr. Henry Wensleydale', 'owner of the shop'), "OWNER": ("Mr. Henry Wensleydale", "owner of the shop"),
'OWNER_EMAIL': ('henry@example.com', 'contact email for owner', 'email'), "OWNER_EMAIL": ("henry@example.com", "contact email for owner", "email"),
'MUSICIANS': (4, 'number of musicians inside the shop'), "MUSICIANS": (4, "number of musicians inside the shop"),
'DATE_ESTABLISHED': (date(1972, 11, 30), "the shop's first opening"), "DATE_ESTABLISHED": (date(1972, 11, 30), "the shop's first opening"),
'MY_SELECT_KEY': ('yes', 'select yes or no', 'yes_no_null_select'), "MY_SELECT_KEY": ("yes", "select yes or no", "yes_no_null_select"),
'MULTILINE': ('Line one\nLine two', 'multiline string'), "MULTILINE": ("Line one\nLine two", "multiline string"),
'JSON_DATA': ( "JSON_DATA": (
{'a': 1_000, 'b': 'test', 'max': 30_000_000}, {"a": 1_000, "b": "test", "max": 30_000_000},
'Some test data for json', "Some test data for json",
'json_field', "json_field",
), ),
'LOGO': ( "LOGO": (
'', "",
'Logo image file', "Logo image file",
'image_field', "image_field",
), ),
} }
CONSTANCE_CONFIG_FIELDSETS = { CONSTANCE_CONFIG_FIELDSETS = {
'Cheese shop general info': [ "Cheese shop general info": [
'BANNER', "BANNER",
'OWNER', "OWNER",
'OWNER_EMAIL', "OWNER_EMAIL",
'MUSICIANS', "MUSICIANS",
'DATE_ESTABLISHED', "DATE_ESTABLISHED",
'LOGO', "LOGO",
], ],
'Awkward test settings': ['MY_SELECT_KEY', 'MULTILINE', 'JSON_DATA'], "Awkward test settings": ["MY_SELECT_KEY", "MULTILINE", "JSON_DATA"],
} }
CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend' CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend"
CACHES = { CACHES = {
'default': { "default": {
'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache', "BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
'LOCATION': '127.0.0.1:11211', "LOCATION": "127.0.0.1:11211",
} }
} }
CONSTANCE_DATABASE_CACHE_BACKEND = 'default' CONSTANCE_DATABASE_CACHE_BACKEND = "default"
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/4.1/topics/i18n/ # https://docs.djangoproject.com/en/4.1/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = "en-us"
TIME_ZONE = 'America/Chicago' TIME_ZONE = "America/Chicago"
USE_I18N = True USE_I18N = True
@ -159,12 +159,12 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.1/howto/static-files/ # https://docs.djangoproject.com/en/4.1/howto/static-files/
STATIC_URL = '/static/' STATIC_URL = "/static/"
MEDIA_URL = '/media/' MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MEDIA_ROOT = os.path.join(BASE_DIR, "media")
CONSTANCE_FILE_ROOT = 'constance' CONSTANCE_FILE_ROOT = "constance"
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' DEFAULT_AUTO_FIELD = "django.db.models.AutoField"

View file

@ -6,7 +6,7 @@ from django.urls import re_path
admin.autodiscover() admin.autodiscover()
urlpatterns = [ urlpatterns = [
re_path('admin/', admin.site.urls), re_path("admin/", admin.site.urls),
] ]
if settings.DEBUG: if settings.DEBUG:

View file

@ -2,6 +2,6 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cheeseshop.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cheeseshop.settings")
application = get_wsgi_application() application = get_wsgi_application()

View file

@ -2,8 +2,8 @@
import os import os
import sys import sys
if __name__ == '__main__': if __name__ == "__main__":
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'cheeseshop.settings') os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cheeseshop.settings")
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line

View file

@ -19,6 +19,8 @@ classifiers = [
"Framework :: Django", "Framework :: Django",
"Framework :: Django :: 4.2", "Framework :: Django :: 4.2",
"Framework :: Django :: 5.0", "Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Framework :: Django :: 5.2",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: BSD License", "License :: OSI Approved :: BSD License",
"Natural Language :: English", "Natural Language :: English",
@ -31,6 +33,7 @@ classifiers = [
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: CPython",
"Topic :: Utilities", "Topic :: Utilities",
] ]
@ -49,6 +52,9 @@ documentation = "https://django-constance.readthedocs.io/en/latest/"
repository = "https://github.com/jazzband/django-constance/" repository = "https://github.com/jazzband/django-constance/"
changelog = "https://github.com/jazzband/django-constance/releases/" changelog = "https://github.com/jazzband/django-constance/releases/"
[tool.setuptools]
license-files = [] # see https://github.com/pypa/twine/issues/1216#issuecomment-2609745412
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["constance*"] include = ["constance*"]
@ -60,7 +66,6 @@ line-length = 120
indent-width = 4 indent-width = 4
[tool.ruff.format] [tool.ruff.format]
quote-style = "single"
indent-style = "space" indent-style = "space"
skip-magic-trailing-comma = false skip-magic-trailing-comma = false
line-ending = "auto" line-ending = "auto"

View file

@ -1,13 +1,15 @@
from django.test import TestCase from django.test import TestCase
from django.test import TransactionTestCase
from constance import settings from constance import settings
from constance.base import Config
from tests.storage import StorageTestsMixin from tests.storage import StorageTestsMixin
class TestDatabase(StorageTestsMixin, TestCase): class TestDatabase(StorageTestsMixin, TestCase):
def setUp(self): def setUp(self):
self.old_backend = settings.BACKEND self.old_backend = settings.BACKEND
settings.BACKEND = 'constance.backends.database.DatabaseBackend' settings.BACKEND = "constance.backends.database.DatabaseBackend"
super().setUp() super().setUp()
def test_database_queries(self): def test_database_queries(self):
@ -25,3 +27,122 @@ class TestDatabase(StorageTestsMixin, TestCase):
def tearDown(self): def tearDown(self):
settings.BACKEND = self.old_backend settings.BACKEND = self.old_backend
class TestDatabaseWithCache(StorageTestsMixin, TestCase):
def setUp(self):
self.old_backend = settings.BACKEND
settings.BACKEND = "constance.backends.database.DatabaseBackend"
self.old_cache_backend = settings.DATABASE_CACHE_BACKEND
settings.DATABASE_CACHE_BACKEND = "default"
super().setUp()
self.config._backend._cache.clear()
def test_database_queries(self):
# Read and set to default value
with self.assertNumQueries(6):
self.assertEqual(self.config.INT_VALUE, 1)
# Read again
with self.assertNumQueries(0):
self.assertEqual(self.config.INT_VALUE, 1)
# Set value
with self.assertNumQueries(3):
self.config.INT_VALUE = 15
def tearDown(self):
settings.BACKEND = self.old_backend
settings.DATABASE_CACHE_BACKEND = self.old_cache_backend
class TestDatabaseAsync(TransactionTestCase):
def setUp(self):
self.old_backend = settings.BACKEND
settings.BACKEND = "constance.backends.database.DatabaseBackend"
self.config = Config()
def tearDown(self):
settings.BACKEND = self.old_backend
async def test_aget_returns_none_for_missing_key(self):
result = await self.config._backend.aget("INT_VALUE")
self.assertIsNone(result)
async def test_aget_returns_value(self):
await self.config._backend.aset("INT_VALUE", 42)
result = await self.config._backend.aget("INT_VALUE")
self.assertEqual(result, 42)
async def test_aset_stores_value(self):
await self.config._backend.aset("INT_VALUE", 99)
result = await self.config._backend.aget("INT_VALUE")
self.assertEqual(result, 99)
async def test_amget_returns_empty_for_no_keys(self):
result = await self.config._backend.amget([])
self.assertEqual(result, {})
async def test_amget_returns_values(self):
await self.config._backend.aset("INT_VALUE", 10)
await self.config._backend.aset("BOOL_VALUE", value=True)
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
self.assertEqual(result, {"INT_VALUE": 10, "BOOL_VALUE": True})
class TestDatabaseWithCacheAsync(TransactionTestCase):
def setUp(self):
self.old_backend = settings.BACKEND
settings.BACKEND = "constance.backends.database.DatabaseBackend"
self.old_cache_backend = settings.DATABASE_CACHE_BACKEND
settings.DATABASE_CACHE_BACKEND = "default"
self.config = Config()
self.config._backend._cache.clear()
def tearDown(self):
settings.BACKEND = self.old_backend
settings.DATABASE_CACHE_BACKEND = self.old_cache_backend
async def test_aget_returns_none_for_missing_key(self):
result = await self.config._backend.aget("INT_VALUE")
self.assertIsNone(result)
async def test_aget_returns_value_from_cache(self):
# First set a value using async
await self.config._backend.aset("INT_VALUE", 42)
# Clear cache and re-fetch to test aget path
self.config._backend._cache.clear()
result = await self.config._backend.aget("INT_VALUE")
self.assertEqual(result, 42)
async def test_aget_populates_cache(self):
await self.config._backend.aset("INT_VALUE", 42)
self.config._backend._cache.clear()
# aget should populate the cache
result = await self.config._backend.aget("INT_VALUE")
self.assertEqual(result, 42)
async def test_aset_stores_value(self):
await self.config._backend.aset("INT_VALUE", 99)
result = await self.config._backend.aget("INT_VALUE")
self.assertEqual(result, 99)
async def test_amget_returns_empty_for_no_keys(self):
result = await self.config._backend.amget([])
self.assertEqual(result, {})
async def test_amget_returns_values(self):
await self.config._backend.aset("INT_VALUE", 10)
await self.config._backend.aset("BOOL_VALUE", value=True)
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
self.assertEqual(result, {"INT_VALUE": 10, "BOOL_VALUE": True})
async def test_amget_uses_cache(self):
# Set values using async and ensure they're cached
await self.config._backend.aset("INT_VALUE", 10)
await self.config._backend.aset("BOOL_VALUE", value=True)
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
self.assertEqual(result["INT_VALUE"], 10)
self.assertEqual(result["BOOL_VALUE"], True)

View file

@ -1,16 +1,58 @@
from django.test import TestCase from django.test import TestCase
from django.test import TransactionTestCase
from constance import settings from constance import settings
from constance.base import Config
from tests.storage import StorageTestsMixin from tests.storage import StorageTestsMixin
class TestMemory(StorageTestsMixin, TestCase): class TestMemory(StorageTestsMixin, TestCase):
def setUp(self): def setUp(self):
self.old_backend = settings.BACKEND self.old_backend = settings.BACKEND
settings.BACKEND = 'constance.backends.memory.MemoryBackend' settings.BACKEND = "constance.backends.memory.MemoryBackend"
super().setUp() super().setUp()
self.config._backend._storage = {} self.config._backend._storage = {}
def tearDown(self): def tearDown(self):
self.config._backend._storage = {} self.config._backend._storage = {}
settings.BACKEND = self.old_backend settings.BACKEND = self.old_backend
def test_mget_empty_keys(self):
result = self.config._backend.mget([])
self.assertEqual(result, {})
class TestMemoryAsync(TransactionTestCase):
def setUp(self):
self.old_backend = settings.BACKEND
settings.BACKEND = "constance.backends.memory.MemoryBackend"
self.config = Config()
self.config._backend._storage = {}
def tearDown(self):
self.config._backend._storage = {}
settings.BACKEND = self.old_backend
async def test_aget_returns_none_for_missing_key(self):
result = await self.config._backend.aget("INT_VALUE")
self.assertIsNone(result)
async def test_aget_returns_value(self):
self.config._backend.set("INT_VALUE", 42)
result = await self.config._backend.aget("INT_VALUE")
self.assertEqual(result, 42)
async def test_aset_stores_value(self):
await self.config._backend.aset("INT_VALUE", 99)
result = self.config._backend.get("INT_VALUE")
self.assertEqual(result, 99)
async def test_amget_returns_empty_for_no_keys(self):
result = await self.config._backend.amget([])
self.assertEqual(result, {})
async def test_amget_returns_values(self):
self.config._backend.set("INT_VALUE", 10)
self.config._backend.set("BOOL_VALUE", value=True)
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
self.assertEqual(result, {"INT_VALUE": 10, "BOOL_VALUE": True})

View file

@ -1,11 +1,17 @@
from unittest import mock
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase from django.test import TestCase
from django.test import TransactionTestCase
from constance import settings from constance import settings
from constance.backends.redisd import RedisBackend
from constance.base import Config
from tests.storage import StorageTestsMixin from tests.storage import StorageTestsMixin
class TestRedis(StorageTestsMixin, TestCase): class TestRedis(StorageTestsMixin, TestCase):
_BACKEND = 'constance.backends.redisd.RedisBackend' _BACKEND = "constance.backends.redisd.RedisBackend"
def setUp(self): def setUp(self):
self.old_backend = settings.BACKEND self.old_backend = settings.BACKEND
@ -17,6 +23,239 @@ class TestRedis(StorageTestsMixin, TestCase):
self.config._backend._rd.clear() self.config._backend._rd.clear()
settings.BACKEND = self.old_backend settings.BACKEND = self.old_backend
def test_mget_empty_keys(self):
result = self.config._backend.mget([])
self.assertEqual(result, {})
class TestCachingRedis(TestRedis): class TestCachingRedis(TestRedis):
_BACKEND = 'constance.backends.redisd.CachingRedisBackend' _BACKEND = "constance.backends.redisd.CachingRedisBackend"
def test_mget_empty_keys(self):
result = self.config._backend.mget([])
self.assertEqual(result, {})
class TestRedisAsync(TransactionTestCase):
_BACKEND = "constance.backends.redisd.RedisBackend"
def setUp(self):
self.old_backend = settings.BACKEND
settings.BACKEND = self._BACKEND
self.config = Config()
self.config._backend._rd.clear()
def tearDown(self):
self.config._backend._rd.clear()
settings.BACKEND = self.old_backend
async def test_aget_returns_none_for_missing_key(self):
result = await self.config._backend.aget("INT_VALUE")
self.assertIsNone(result)
async def test_aget_returns_value(self):
self.config._backend.set("INT_VALUE", 42)
result = await self.config._backend.aget("INT_VALUE")
self.assertEqual(result, 42)
async def test_aset_stores_value(self):
await self.config._backend.aset("INT_VALUE", 99)
result = self.config._backend.get("INT_VALUE")
self.assertEqual(result, 99)
async def test_amget_returns_empty_for_no_keys(self):
result = await self.config._backend.amget([])
self.assertEqual(result, {})
async def test_amget_returns_values(self):
self.config._backend.set("INT_VALUE", 10)
self.config._backend.set("BOOL_VALUE", value=True)
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
self.assertEqual(result, {"INT_VALUE": 10, "BOOL_VALUE": True})
async def test_amget_skips_missing_keys(self):
self.config._backend.set("INT_VALUE", 10)
result = await self.config._backend.amget(["INT_VALUE", "MISSING_KEY"])
self.assertEqual(result, {"INT_VALUE": 10})
class TestCachingRedisAsync(TransactionTestCase):
_BACKEND = "constance.backends.redisd.CachingRedisBackend"
def setUp(self):
self.old_backend = settings.BACKEND
settings.BACKEND = self._BACKEND
self.config = Config()
self.config._backend._rd.clear()
self.config._backend._cache.clear()
def tearDown(self):
self.config._backend._rd.clear()
self.config._backend._cache.clear()
settings.BACKEND = self.old_backend
async def test_aget_caches_value(self):
# First set a value via sync
self.config._backend.set("INT_VALUE", 42)
# Clear the in-memory cache
self.config._backend._cache.clear()
# Async get should fetch and cache
result = await self.config._backend.aget("INT_VALUE")
self.assertEqual(result, 42)
# Verify it's cached
self.assertIn("INT_VALUE", self.config._backend._cache)
async def test_aget_returns_cached_value(self):
# Manually set cache
from time import monotonic
timeout = self.config._backend._timeout
self.config._backend._cache["INT_VALUE"] = (monotonic() + timeout, 100)
result = await self.config._backend.aget("INT_VALUE")
self.assertEqual(result, 100)
async def test_aget_refreshes_expired_cache(self):
from time import monotonic
# Set expired cache
self.config._backend._cache["INT_VALUE"] = (monotonic() - 10, 100)
# Set different value in redis using proper codec format
self.config._backend._rd.set(
self.config._backend.add_prefix("INT_VALUE"),
b'{"__type__": "default", "__value__": 200}',
)
result = await self.config._backend.aget("INT_VALUE")
self.assertEqual(result, 200)
async def test_aset_updates_cache(self):
await self.config._backend.aset("INT_VALUE", 55)
# Verify cache is updated
self.assertIn("INT_VALUE", self.config._backend._cache)
self.assertEqual(self.config._backend._cache["INT_VALUE"][1], 55)
async def test_amget_returns_empty_for_no_keys(self):
result = await self.config._backend.amget([])
self.assertEqual(result, {})
async def test_amget_returns_cached_values(self):
from time import monotonic
timeout = self.config._backend._timeout
self.config._backend._cache["INT_VALUE"] = (monotonic() + timeout, 10)
self.config._backend._cache["BOOL_VALUE"] = (monotonic() + timeout, True)
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
self.assertEqual(result, {"INT_VALUE": 10, "BOOL_VALUE": True})
async def test_amget_fetches_missing_keys(self):
from time import monotonic
timeout = self.config._backend._timeout
# One key cached, one in Redis only
self.config._backend._cache["INT_VALUE"] = (monotonic() + timeout, 10)
self.config._backend._rd.set(
self.config._backend.add_prefix("BOOL_VALUE"),
b'{"__type__": "default", "__value__": true}',
)
result = await self.config._backend.amget(["INT_VALUE", "BOOL_VALUE"])
self.assertEqual(result["INT_VALUE"], 10)
self.assertEqual(result["BOOL_VALUE"], True)
async def test_amget_refreshes_expired_keys(self):
from time import monotonic
# Set expired cache
self.config._backend._cache["INT_VALUE"] = (monotonic() - 10, 100)
# Set different value in redis using proper codec format
self.config._backend._rd.set(
self.config._backend.add_prefix("INT_VALUE"),
b'{"__type__": "default", "__value__": 200}',
)
result = await self.config._backend.amget(["INT_VALUE"])
self.assertEqual(result["INT_VALUE"], 200)
class TestRedisBackendInit(TestCase):
"""Tests for RedisBackend.__init__ client initialization paths."""
def setUp(self):
self.old_conn_cls = settings.REDIS_CONNECTION_CLASS
self.old_async_conn_cls = settings.REDIS_ASYNC_CONNECTION_CLASS
self.old_conn = settings.REDIS_CONNECTION
def tearDown(self):
settings.REDIS_CONNECTION_CLASS = self.old_conn_cls
settings.REDIS_ASYNC_CONNECTION_CLASS = self.old_async_conn_cls
settings.REDIS_CONNECTION = self.old_conn
def test_no_redis_package_raises_improperly_configured(self):
settings.REDIS_CONNECTION_CLASS = None
settings.REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection"
with mock.patch.dict("sys.modules", {"redis": None}), self.assertRaises(ImproperlyConfigured):
RedisBackend()
def test_sync_redis_from_url_with_string_connection(self):
settings.REDIS_CONNECTION_CLASS = None
settings.REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection"
settings.REDIS_CONNECTION = "redis://localhost:6379/0"
mock_redis = mock.MagicMock()
with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": mock_redis.asyncio}):
backend = RedisBackend()
mock_redis.from_url.assert_called_once_with("redis://localhost:6379/0")
self.assertEqual(backend._rd, mock_redis.from_url.return_value)
def test_sync_redis_with_dict_connection(self):
settings.REDIS_CONNECTION_CLASS = None
settings.REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection"
settings.REDIS_CONNECTION = {"host": "localhost", "port": 6379}
mock_redis = mock.MagicMock()
with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": mock_redis.asyncio}):
backend = RedisBackend()
mock_redis.Redis.assert_called_once_with(host="localhost", port=6379)
self.assertEqual(backend._rd, mock_redis.Redis.return_value)
def test_async_redis_not_available_sets_ard_none(self):
settings.REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
settings.REDIS_ASYNC_CONNECTION_CLASS = None
mock_redis = mock.MagicMock()
# Simulate redis.asyncio not being available
with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": None}):
backend = RedisBackend()
self.assertIsNone(backend._ard)
def test_async_redis_from_url_with_string_connection(self):
settings.REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
settings.REDIS_ASYNC_CONNECTION_CLASS = None
settings.REDIS_CONNECTION = "redis://localhost:6379/0"
mock_aredis = mock.MagicMock()
mock_redis = mock.MagicMock()
mock_redis.asyncio = mock_aredis
with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": mock_aredis}):
backend = RedisBackend()
mock_aredis.from_url.assert_called_once_with("redis://localhost:6379/0")
self.assertEqual(backend._ard, mock_aredis.from_url.return_value)
def test_async_redis_with_dict_connection(self):
settings.REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
settings.REDIS_ASYNC_CONNECTION_CLASS = None
settings.REDIS_CONNECTION = {"host": "localhost", "port": 6379}
mock_aredis = mock.MagicMock()
mock_redis = mock.MagicMock()
mock_redis.asyncio = mock_aredis
with mock.patch.dict("sys.modules", {"redis": mock_redis, "redis.asyncio": mock_aredis}):
backend = RedisBackend()
mock_aredis.Redis.assert_called_once_with(host="localhost", port=6379)
self.assertEqual(backend._ard, mock_aredis.Redis.return_value)
def test_check_async_support_raises_when_ard_is_none(self):
backend = RedisBackend()
backend._ard = None
with self.assertRaises(ImproperlyConfigured):
backend._check_async_support()

25
tests/cache_mockup.py Normal file
View file

@ -0,0 +1,25 @@
from django.core.cache.backends.base import BaseCache
from django.core.cache.backends.locmem import LocMemCache
class Cache(BaseCache):
def __init__(self, name, params):
self._cache = LocMemCache(name, params)
self.add = self._cache.add
self.delete = self._cache.delete
self.set = self._cache.set
self.get = self._cache.get
self.clear = self._cache.clear
self.set_many = self._cache.set_many
self.get_many = self._cache.get_many
self.delete_many = self._cache.delete_many
# Async methods for DatabaseBackend.aget() support
async def aget(self, key, default=None, version=None):
return self.get(key, default, version)
async def aget_many(self, keys, version=None):
return self.get_many(keys, version)
async def aadd(self, key, value, timeout=None, version=None):
return self.add(key, value, timeout, version)

View file

@ -1,6 +1,28 @@
class Connection(dict): # Shared storage so sync (Connection) and async (AsyncConnection) instances
# operate on the same underlying data, just like a real Redis server would.
_shared_store = {}
class Connection:
def set(self, key, value): def set(self, key, value):
self[key] = value _shared_store[key] = value
def get(self, key, default=None):
return _shared_store.get(key, default)
def mget(self, keys): def mget(self, keys):
return [self.get(key) for key in keys] return [_shared_store.get(key) for key in keys]
def clear(self):
_shared_store.clear()
class AsyncConnection:
async def set(self, key, value):
_shared_store[key] = value
async def get(self, key):
return _shared_store.get(key)
async def mget(self, keys):
return [_shared_store.get(key) for key in keys]

View file

@ -4,107 +4,115 @@ from datetime import time
from datetime import timedelta from datetime import timedelta
from decimal import Decimal from decimal import Decimal
SECRET_KEY = 'cheese' SECRET_KEY = "cheese"
MIDDLEWARE = ( MIDDLEWARE = (
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'django.middleware.common.CommonMiddleware', "django.middleware.common.CommonMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django.contrib.auth.middleware.SessionAuthenticationMiddleware', "django.contrib.auth.middleware.SessionAuthenticationMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.middleware.clickjacking.XFrameOptionsMiddleware",
) )
DATABASE_ENGINE = 'sqlite3' DATABASE_ENGINE = "sqlite3"
DATABASES = { DATABASES = {
'default': { "default": {
'ENGINE': 'django.db.backends.sqlite3', "ENGINE": "django.db.backends.sqlite3",
'NAME': ':memory:', "NAME": ":memory:",
}, },
'secondary': { "secondary": {
'ENGINE': 'django.db.backends.sqlite3', "ENGINE": "django.db.backends.sqlite3",
'NAME': ':memory:', "NAME": ":memory:",
}, },
} }
CACHES = {
"default": {
"BACKEND": "tests.cache_mockup.Cache",
"LOCATION": "locmem",
}
}
INSTALLED_APPS = ( INSTALLED_APPS = (
'django.contrib.admin', "django.contrib.admin",
'django.contrib.staticfiles', "django.contrib.staticfiles",
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.messages', "django.contrib.messages",
'constance', "constance",
'constance.backends.database', "constance.backends.database",
) )
ROOT_URLCONF = 'tests.urls' ROOT_URLCONF = "tests.urls"
CONSTANCE_REDIS_CONNECTION_CLASS = 'tests.redis_mockup.Connection' CONSTANCE_REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
CONSTANCE_REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection"
CONSTANCE_ADDITIONAL_FIELDS = { CONSTANCE_ADDITIONAL_FIELDS = {
'yes_no_null_select': [ "yes_no_null_select": [
'django.forms.fields.ChoiceField', "django.forms.fields.ChoiceField",
{'widget': 'django.forms.Select', 'choices': ((None, '-----'), ('yes', 'Yes'), ('no', 'No'))}, {"widget": "django.forms.Select", "choices": ((None, "-----"), ("yes", "Yes"), ("no", "No"))},
], ],
# note this intentionally uses a tuple so that we can test immutable # note this intentionally uses a tuple so that we can test immutable
'email': ('django.forms.fields.EmailField',), "email": ("django.forms.fields.EmailField",),
'array': ['django.forms.fields.CharField', {'widget': 'django.forms.Textarea'}], "array": ["django.forms.fields.CharField", {"widget": "django.forms.Textarea"}],
'json': ['django.forms.fields.CharField', {'widget': 'django.forms.Textarea'}], "json": ["django.forms.fields.CharField", {"widget": "django.forms.Textarea"}],
} }
USE_TZ = True USE_TZ = True
CONSTANCE_CONFIG = { CONSTANCE_CONFIG = {
'INT_VALUE': (1, 'some int'), "INT_VALUE": (1, "some int"),
'BOOL_VALUE': (True, 'true or false'), "BOOL_VALUE": (True, "true or false"),
'STRING_VALUE': ('Hello world', 'greetings'), "STRING_VALUE": ("Hello world", "greetings"),
'DECIMAL_VALUE': (Decimal('0.1'), 'the first release version'), "DECIMAL_VALUE": (Decimal("0.1"), "the first release version"),
'DATETIME_VALUE': (datetime(2010, 8, 23, 11, 29, 24), 'time of the first commit'), "DATETIME_VALUE": (datetime(2010, 8, 23, 11, 29, 24), "time of the first commit"),
'FLOAT_VALUE': (3.1415926536, 'PI'), "FLOAT_VALUE": (3.1415926536, "PI"),
'DATE_VALUE': (date(2010, 12, 24), 'Merry Chrismas'), "DATE_VALUE": (date(2010, 12, 24), "Merry Chrismas"),
'TIME_VALUE': (time(23, 59, 59), 'And happy New Year'), "TIME_VALUE": (time(23, 59, 59), "And happy New Year"),
'TIMEDELTA_VALUE': (timedelta(days=1, hours=2, minutes=3), 'Interval'), "TIMEDELTA_VALUE": (timedelta(days=1, hours=2, minutes=3), "Interval"),
'CHOICE_VALUE': ('yes', 'select yes or no', 'yes_no_null_select'), "CHOICE_VALUE": ("yes", "select yes or no", "yes_no_null_select"),
'LINEBREAK_VALUE': ('Spam spam', 'eggs\neggs'), "LINEBREAK_VALUE": ("Spam spam", "eggs\neggs"),
'EMAIL_VALUE': ('test@example.com', 'An email', 'email'), "EMAIL_VALUE": ("test@example.com", "An email", "email"),
'LIST_VALUE': ([1, '1', date(2019, 1, 1)], 'A list', 'array'), "LIST_VALUE": ([1, "1", date(2019, 1, 1)], "A list", "array"),
'JSON_VALUE': ( "JSON_VALUE": (
{ {
'key': 'value', "key": "value",
'key2': 2, "key2": 2,
'key3': [1, 2, 3], "key3": [1, 2, 3],
'key4': {'key': 'value'}, "key4": {"key": "value"},
'key5': date(2019, 1, 1), "key5": date(2019, 1, 1),
'key6': None, "key6": None,
}, },
'A JSON object', "A JSON object",
'json', "json",
), ),
} }
DEBUG = True DEBUG = True
STATIC_ROOT = './static/' STATIC_ROOT = "./static/"
STATIC_URL = '/static/' STATIC_URL = "/static/"
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': [], "DIRS": [],
'APP_DIRS': True, "APP_DIRS": True,
'OPTIONS': { "OPTIONS": {
'context_processors': [ "context_processors": [
'django.template.context_processors.debug', "django.template.context_processors.debug",
'django.template.context_processors.i18n', "django.template.context_processors.i18n",
'django.template.context_processors.request', "django.template.context_processors.request",
'django.template.context_processors.static', "django.template.context_processors.static",
'django.contrib.auth.context_processors.auth', "django.contrib.auth.context_processors.auth",
'django.contrib.messages.context_processors.messages', "django.contrib.messages.context_processors.messages",
'constance.context_processors.config', "constance.context_processors.config",
], ],
}, },
}, },

View file

@ -16,60 +16,60 @@ class StorageTestsMixin:
def test_store(self): def test_store(self):
self.assertEqual(self.config.INT_VALUE, 1) self.assertEqual(self.config.INT_VALUE, 1)
self.assertEqual(self.config.BOOL_VALUE, True) self.assertEqual(self.config.BOOL_VALUE, True)
self.assertEqual(self.config.STRING_VALUE, 'Hello world') self.assertEqual(self.config.STRING_VALUE, "Hello world")
self.assertEqual(self.config.DECIMAL_VALUE, Decimal('0.1')) self.assertEqual(self.config.DECIMAL_VALUE, Decimal("0.1"))
self.assertEqual(self.config.DATETIME_VALUE, datetime(2010, 8, 23, 11, 29, 24)) self.assertEqual(self.config.DATETIME_VALUE, datetime(2010, 8, 23, 11, 29, 24))
self.assertEqual(self.config.FLOAT_VALUE, 3.1415926536) self.assertEqual(self.config.FLOAT_VALUE, 3.1415926536)
self.assertEqual(self.config.DATE_VALUE, date(2010, 12, 24)) self.assertEqual(self.config.DATE_VALUE, date(2010, 12, 24))
self.assertEqual(self.config.TIME_VALUE, time(23, 59, 59)) self.assertEqual(self.config.TIME_VALUE, time(23, 59, 59))
self.assertEqual(self.config.TIMEDELTA_VALUE, timedelta(days=1, hours=2, minutes=3)) self.assertEqual(self.config.TIMEDELTA_VALUE, timedelta(days=1, hours=2, minutes=3))
self.assertEqual(self.config.CHOICE_VALUE, 'yes') self.assertEqual(self.config.CHOICE_VALUE, "yes")
self.assertEqual(self.config.EMAIL_VALUE, 'test@example.com') self.assertEqual(self.config.EMAIL_VALUE, "test@example.com")
self.assertEqual(self.config.LIST_VALUE, [1, '1', date(2019, 1, 1)]) self.assertEqual(self.config.LIST_VALUE, [1, "1", date(2019, 1, 1)])
self.assertEqual( self.assertEqual(
self.config.JSON_VALUE, self.config.JSON_VALUE,
{ {
'key': 'value', "key": "value",
'key2': 2, "key2": 2,
'key3': [1, 2, 3], "key3": [1, 2, 3],
'key4': {'key': 'value'}, "key4": {"key": "value"},
'key5': date(2019, 1, 1), "key5": date(2019, 1, 1),
'key6': None, "key6": None,
}, },
) )
# set values # set values
self.config.INT_VALUE = 100 self.config.INT_VALUE = 100
self.config.BOOL_VALUE = False self.config.BOOL_VALUE = False
self.config.STRING_VALUE = 'Beware the weeping angel' self.config.STRING_VALUE = "Beware the weeping angel"
self.config.DECIMAL_VALUE = Decimal('1.2') self.config.DECIMAL_VALUE = Decimal("1.2")
self.config.DATETIME_VALUE = datetime(1977, 10, 2) self.config.DATETIME_VALUE = datetime(1977, 10, 2)
self.config.FLOAT_VALUE = 2.718281845905 self.config.FLOAT_VALUE = 2.718281845905
self.config.DATE_VALUE = date(2001, 12, 20) self.config.DATE_VALUE = date(2001, 12, 20)
self.config.TIME_VALUE = time(1, 59, 0) self.config.TIME_VALUE = time(1, 59, 0)
self.config.TIMEDELTA_VALUE = timedelta(days=2, hours=3, minutes=4) self.config.TIMEDELTA_VALUE = timedelta(days=2, hours=3, minutes=4)
self.config.CHOICE_VALUE = 'no' self.config.CHOICE_VALUE = "no"
self.config.EMAIL_VALUE = 'foo@bar.com' self.config.EMAIL_VALUE = "foo@bar.com"
self.config.LIST_VALUE = [1, date(2020, 2, 2)] self.config.LIST_VALUE = [1, date(2020, 2, 2)]
self.config.JSON_VALUE = {'key': 'OK'} self.config.JSON_VALUE = {"key": "OK"}
# read again # read again
self.assertEqual(self.config.INT_VALUE, 100) self.assertEqual(self.config.INT_VALUE, 100)
self.assertEqual(self.config.BOOL_VALUE, False) self.assertEqual(self.config.BOOL_VALUE, False)
self.assertEqual(self.config.STRING_VALUE, 'Beware the weeping angel') self.assertEqual(self.config.STRING_VALUE, "Beware the weeping angel")
self.assertEqual(self.config.DECIMAL_VALUE, Decimal('1.2')) self.assertEqual(self.config.DECIMAL_VALUE, Decimal("1.2"))
self.assertEqual(self.config.DATETIME_VALUE, datetime(1977, 10, 2)) self.assertEqual(self.config.DATETIME_VALUE, datetime(1977, 10, 2))
self.assertEqual(self.config.FLOAT_VALUE, 2.718281845905) self.assertEqual(self.config.FLOAT_VALUE, 2.718281845905)
self.assertEqual(self.config.DATE_VALUE, date(2001, 12, 20)) self.assertEqual(self.config.DATE_VALUE, date(2001, 12, 20))
self.assertEqual(self.config.TIME_VALUE, time(1, 59, 0)) self.assertEqual(self.config.TIME_VALUE, time(1, 59, 0))
self.assertEqual(self.config.TIMEDELTA_VALUE, timedelta(days=2, hours=3, minutes=4)) self.assertEqual(self.config.TIMEDELTA_VALUE, timedelta(days=2, hours=3, minutes=4))
self.assertEqual(self.config.CHOICE_VALUE, 'no') self.assertEqual(self.config.CHOICE_VALUE, "no")
self.assertEqual(self.config.EMAIL_VALUE, 'foo@bar.com') self.assertEqual(self.config.EMAIL_VALUE, "foo@bar.com")
self.assertEqual(self.config.LIST_VALUE, [1, date(2020, 2, 2)]) self.assertEqual(self.config.LIST_VALUE, [1, date(2020, 2, 2)])
self.assertEqual(self.config.JSON_VALUE, {'key': 'OK'}) self.assertEqual(self.config.JSON_VALUE, {"key": "OK"})
def test_nonexistent(self): def test_nonexistent(self):
self.assertRaises(AttributeError, getattr, self.config, 'NON_EXISTENT') self.assertRaises(AttributeError, getattr, self.config, "NON_EXISTENT")
with self.assertRaises(AttributeError): with self.assertRaises(AttributeError):
self.config.NON_EXISTENT = 1 self.config.NON_EXISTENT = 1
@ -77,15 +77,15 @@ class StorageTestsMixin:
def test_missing_values(self): def test_missing_values(self):
# set some values and leave out others # set some values and leave out others
self.config.BOOL_VALUE = False self.config.BOOL_VALUE = False
self.config.DECIMAL_VALUE = Decimal('1.2') self.config.DECIMAL_VALUE = Decimal("1.2")
self.config.DATETIME_VALUE = datetime(1977, 10, 2) self.config.DATETIME_VALUE = datetime(1977, 10, 2)
self.config.DATE_VALUE = date(2001, 12, 20) self.config.DATE_VALUE = date(2001, 12, 20)
self.config.TIME_VALUE = time(1, 59, 0) self.config.TIME_VALUE = time(1, 59, 0)
self.assertEqual(self.config.INT_VALUE, 1) # this should be the default value self.assertEqual(self.config.INT_VALUE, 1) # this should be the default value
self.assertEqual(self.config.BOOL_VALUE, False) self.assertEqual(self.config.BOOL_VALUE, False)
self.assertEqual(self.config.STRING_VALUE, 'Hello world') # this should be the default value self.assertEqual(self.config.STRING_VALUE, "Hello world") # this should be the default value
self.assertEqual(self.config.DECIMAL_VALUE, Decimal('1.2')) self.assertEqual(self.config.DECIMAL_VALUE, Decimal("1.2"))
self.assertEqual(self.config.DATETIME_VALUE, datetime(1977, 10, 2)) self.assertEqual(self.config.DATETIME_VALUE, datetime(1977, 10, 2))
self.assertEqual(self.config.FLOAT_VALUE, 3.1415926536) # this should be the default value self.assertEqual(self.config.FLOAT_VALUE, 3.1415926536) # this should be the default value
self.assertEqual(self.config.DATE_VALUE, date(2001, 12, 20)) self.assertEqual(self.config.DATE_VALUE, date(2001, 12, 20))
@ -96,13 +96,13 @@ class StorageTestsMixin:
# Check corner cases such as falsy values # Check corner cases such as falsy values
self.config.INT_VALUE = 0 self.config.INT_VALUE = 0
self.config.BOOL_VALUE = False self.config.BOOL_VALUE = False
self.config.STRING_VALUE = '' self.config.STRING_VALUE = ""
values = dict(self.config._backend.mget(settings.CONFIG)) values = self.config._backend.mget(settings.CONFIG)
self.assertEqual(values['INT_VALUE'], 0) self.assertEqual(values["INT_VALUE"], 0)
self.assertEqual(values['BOOL_VALUE'], False) self.assertEqual(values["BOOL_VALUE"], False)
self.assertEqual(values['STRING_VALUE'], '') self.assertEqual(values["STRING_VALUE"], "")
def test_backend_does_not_return_none_values(self): def test_backend_does_not_return_none_values(self):
result = dict(self.config._backend.mget(settings.CONFIG)) result = self.config._backend.mget(settings.CONFIG)
self.assertEqual(result, {}) self.assertEqual(result, {})

View file

@ -23,196 +23,196 @@ class TestAdmin(TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.rf = RequestFactory() self.rf = RequestFactory()
self.superuser = User.objects.create_superuser('admin', 'nimda', 'a@a.cz') self.superuser = User.objects.create_superuser("admin", "nimda", "a@a.cz")
self.normaluser = User.objects.create_user('normal', 'nimda', 'b@b.cz') self.normaluser = User.objects.create_user("normal", "nimda", "b@b.cz")
self.normaluser.is_staff = True self.normaluser.is_staff = True
self.normaluser.save() self.normaluser.save()
self.options = admin.site._registry[self.model] self.options = admin.site._registry[self.model]
def test_changelist(self): def test_changelist(self):
self.client.login(username='admin', password='nimda') self.client.login(username="admin", password="nimda")
request = self.rf.get('/admin/constance/config/') request = self.rf.get("/admin/constance/config/")
request.user = self.superuser request.user = self.superuser
response = self.options.changelist_view(request, {}) response = self.options.changelist_view(request, {})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_custom_auth(self): def test_custom_auth(self):
settings.SUPERUSER_ONLY = False settings.SUPERUSER_ONLY = False
self.client.login(username='normal', password='nimda') self.client.login(username="normal", password="nimda")
request = self.rf.get('/admin/constance/config/') request = self.rf.get("/admin/constance/config/")
request.user = self.normaluser request.user = self.normaluser
self.assertRaises(PermissionDenied, self.options.changelist_view, request, {}) self.assertRaises(PermissionDenied, self.options.changelist_view, request, {})
self.assertFalse(request.user.has_perm('constance.change_config')) self.assertFalse(request.user.has_perm("constance.change_config"))
# reload user to reset permission cache # reload user to reset permission cache
request = self.rf.get('/admin/constance/config/') request = self.rf.get("/admin/constance/config/")
request.user = User.objects.get(pk=self.normaluser.pk) request.user = User.objects.get(pk=self.normaluser.pk)
request.user.user_permissions.add(Permission.objects.get(codename='change_config')) request.user.user_permissions.add(Permission.objects.get(codename="change_config"))
self.assertTrue(request.user.has_perm('constance.change_config')) self.assertTrue(request.user.has_perm("constance.change_config"))
response = self.options.changelist_view(request, {}) response = self.options.changelist_view(request, {})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_linebreaks(self): def test_linebreaks(self):
self.client.login(username='admin', password='nimda') self.client.login(username="admin", password="nimda")
request = self.rf.get('/admin/constance/config/') request = self.rf.get("/admin/constance/config/")
request.user = self.superuser request.user = self.superuser
response = self.options.changelist_view(request, {}) response = self.options.changelist_view(request, {})
self.assertContains(response, 'LINEBREAK_VALUE') self.assertContains(response, "LINEBREAK_VALUE")
self.assertContains(response, linebreaksbr('eggs\neggs')) self.assertContains(response, linebreaksbr("eggs\neggs"))
@mock.patch( @mock.patch(
'constance.settings.CONFIG_FIELDSETS', "constance.settings.CONFIG_FIELDSETS",
{ {
'Numbers': ('INT_VALUE',), "Numbers": ("INT_VALUE",),
'Text': ('STRING_VALUE',), "Text": ("STRING_VALUE",),
}, },
) )
def test_fieldset_headers(self): def test_fieldset_headers(self):
self.client.login(username='admin', password='nimda') self.client.login(username="admin", password="nimda")
request = self.rf.get('/admin/constance/config/') request = self.rf.get("/admin/constance/config/")
request.user = self.superuser request.user = self.superuser
response = self.options.changelist_view(request, {}) response = self.options.changelist_view(request, {})
self.assertContains(response, '<h2>Numbers</h2>') self.assertContains(response, "Numbers</h2>")
self.assertContains(response, '<h2>Text</h2>') self.assertContains(response, "Text</h2>")
@mock.patch( @mock.patch(
'constance.settings.CONFIG_FIELDSETS', "constance.settings.CONFIG_FIELDSETS",
( (
('Numbers', ('INT_VALUE',)), ("Numbers", ("INT_VALUE",)),
('Text', ('STRING_VALUE',)), ("Text", ("STRING_VALUE",)),
), ),
) )
def test_fieldset_tuple(self): def test_fieldset_tuple(self):
self.client.login(username='admin', password='nimda') self.client.login(username="admin", password="nimda")
request = self.rf.get('/admin/constance/config/') request = self.rf.get("/admin/constance/config/")
request.user = self.superuser request.user = self.superuser
response = self.options.changelist_view(request, {}) response = self.options.changelist_view(request, {})
self.assertContains(response, '<h2>Numbers</h2>') self.assertContains(response, "Numbers</h2>")
self.assertContains(response, '<h2>Text</h2>') self.assertContains(response, "Text</h2>")
@mock.patch( @mock.patch(
'constance.settings.CONFIG_FIELDSETS', "constance.settings.CONFIG_FIELDSETS",
{ {
'Numbers': { "Numbers": {
'fields': ( "fields": (
'INT_VALUE', "INT_VALUE",
'DECIMAL_VALUE', "DECIMAL_VALUE",
), ),
'collapse': True, "collapse": True,
}, },
'Text': { "Text": {
'fields': ( "fields": (
'STRING_VALUE', "STRING_VALUE",
'LINEBREAK_VALUE', "LINEBREAK_VALUE",
), ),
'collapse': True, "collapse": True,
}, },
}, },
) )
def test_collapsed_fieldsets(self): def test_collapsed_fieldsets(self):
self.client.login(username='admin', password='nimda') self.client.login(username="admin", password="nimda")
request = self.rf.get('/admin/constance/config/') request = self.rf.get("/admin/constance/config/")
request.user = self.superuser request.user = self.superuser
response = self.options.changelist_view(request, {}) response = self.options.changelist_view(request, {})
self.assertContains(response, 'module collapse') self.assertContains(response, "module collapse")
@mock.patch('constance.settings.CONFIG_FIELDSETS', {'FieldSetOne': ('INT_VALUE',)}) @mock.patch("constance.settings.CONFIG_FIELDSETS", {"FieldSetOne": ("INT_VALUE",)})
@mock.patch( @mock.patch(
'constance.settings.CONFIG', "constance.settings.CONFIG",
{ {
'INT_VALUE': (1, 'some int'), "INT_VALUE": (1, "some int"),
}, },
) )
@mock.patch('constance.settings.IGNORE_ADMIN_VERSION_CHECK', True) @mock.patch("constance.settings.IGNORE_ADMIN_VERSION_CHECK", True)
@mock.patch('constance.forms.ConstanceForm.save', lambda _: None) @mock.patch("constance.forms.ConstanceForm.save", lambda _: None)
@mock.patch('constance.forms.ConstanceForm.is_valid', lambda _: True) @mock.patch("constance.forms.ConstanceForm.is_valid", lambda _: True)
def test_submit(self): def test_submit(self):
""" """
Test that submitting the admin page results in an http redirect when Test that submitting the admin page results in an http redirect when
everything is in order. everything is in order.
""" """
initial_value = {'INT_VALUE': settings.CONFIG['INT_VALUE'][0]} initial_value = {"INT_VALUE": settings.CONFIG["INT_VALUE"][0]}
self.client.login(username='admin', password='nimda') self.client.login(username="admin", password="nimda")
request = self.rf.post( request = self.rf.post(
'/admin/constance/config/', "/admin/constance/config/",
data={ data={
**initial_value, **initial_value,
'version': '123', "version": "123",
}, },
) )
request.user = self.superuser request.user = self.superuser
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
with mock.patch('django.contrib.messages.add_message') as mock_message, mock.patch.object( with mock.patch("django.contrib.messages.add_message") as mock_message, mock.patch.object(
ConstanceForm, '__init__', **initial_value, return_value=None ConstanceForm, "__init__", **initial_value, return_value=None
) as mock_form: ) as mock_form:
response = self.options.changelist_view(request, {}) response = self.options.changelist_view(request, {})
mock_form.assert_called_with(data=request.POST, files=request.FILES, initial=initial_value, request=request) mock_form.assert_called_with(data=request.POST, files=request.FILES, initial=initial_value, request=request)
mock_message.assert_called_with(request, 25, _('Live settings updated successfully.')) mock_message.assert_called_with(request, 25, _("Live settings updated successfully."))
self.assertIsInstance(response, HttpResponseRedirect) self.assertIsInstance(response, HttpResponseRedirect)
@mock.patch('constance.settings.CONFIG_FIELDSETS', {'FieldSetOne': ('MULTILINE',)}) @mock.patch("constance.settings.CONFIG_FIELDSETS", {"FieldSetOne": ("MULTILINE",)})
@mock.patch( @mock.patch(
'constance.settings.CONFIG', "constance.settings.CONFIG",
{ {
'MULTILINE': ('Hello\nWorld', 'multiline value'), "MULTILINE": ("Hello\nWorld", "multiline value"),
}, },
) )
@mock.patch('constance.settings.IGNORE_ADMIN_VERSION_CHECK', True) @mock.patch("constance.settings.IGNORE_ADMIN_VERSION_CHECK", True)
def test_newlines_normalization(self): def test_newlines_normalization(self):
self.client.login(username='admin', password='nimda') self.client.login(username="admin", password="nimda")
request = self.rf.post( request = self.rf.post(
'/admin/constance/config/', "/admin/constance/config/",
data={ data={
'MULTILINE': 'Hello\r\nWorld', "MULTILINE": "Hello\r\nWorld",
'version': '123', "version": "123",
}, },
) )
request.user = self.superuser request.user = self.superuser
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
with mock.patch('django.contrib.messages.add_message'): with mock.patch("django.contrib.messages.add_message"):
response = self.options.changelist_view(request, {}) response = self.options.changelist_view(request, {})
self.assertIsInstance(response, HttpResponseRedirect) self.assertIsInstance(response, HttpResponseRedirect)
self.assertEqual(get_values()['MULTILINE'], 'Hello\nWorld') self.assertEqual(get_values()["MULTILINE"], "Hello\nWorld")
@mock.patch( @mock.patch(
'constance.settings.CONFIG', "constance.settings.CONFIG",
{ {
'DATETIME_VALUE': (datetime(2019, 8, 7, 18, 40, 0), 'some naive datetime'), "DATETIME_VALUE": (datetime(2019, 8, 7, 18, 40, 0), "some naive datetime"),
}, },
) )
@mock.patch('constance.settings.IGNORE_ADMIN_VERSION_CHECK', True) @mock.patch("constance.settings.IGNORE_ADMIN_VERSION_CHECK", True)
@mock.patch('tests.redis_mockup.Connection.set', mock.MagicMock()) @mock.patch("tests.redis_mockup.Connection.set", mock.MagicMock())
def test_submit_aware_datetime(self): def test_submit_aware_datetime(self):
""" """
Test that submitting the admin page results in an http redirect when Test that submitting the admin page results in an http redirect when
everything is in order. everything is in order.
""" """
request = self.rf.post( request = self.rf.post(
'/admin/constance/config/', "/admin/constance/config/",
data={ data={
'DATETIME_VALUE_0': '2019-08-07', "DATETIME_VALUE_0": "2019-08-07",
'DATETIME_VALUE_1': '19:17:01', "DATETIME_VALUE_1": "19:17:01",
'version': '123', "version": "123",
}, },
) )
request.user = self.superuser request.user = self.superuser
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
with mock.patch('django.contrib.messages.add_message'): with mock.patch("django.contrib.messages.add_message"):
response = self.options.changelist_view(request, {}) response = self.options.changelist_view(request, {})
self.assertIsInstance(response, HttpResponseRedirect) self.assertIsInstance(response, HttpResponseRedirect)
@mock.patch( @mock.patch(
'constance.settings.CONFIG_FIELDSETS', "constance.settings.CONFIG_FIELDSETS",
{ {
'Numbers': ('INT_VALUE',), "Numbers": ("INT_VALUE",),
'Text': ('STRING_VALUE',), "Text": ("STRING_VALUE",),
}, },
) )
def test_inconsistent_fieldset_submit(self): def test_inconsistent_fieldset_submit(self):
@ -220,51 +220,107 @@ class TestAdmin(TestCase):
Test that the admin page warns users if the CONFIG_FIELDSETS setting Test that the admin page warns users if the CONFIG_FIELDSETS setting
doesn't account for every field in CONFIG. doesn't account for every field in CONFIG.
""" """
self.client.login(username='admin', password='nimda') self.client.login(username="admin", password="nimda")
request = self.rf.post('/admin/constance/config/', data=None) request = self.rf.post("/admin/constance/config/", data=None)
request.user = self.superuser request.user = self.superuser
request._dont_enforce_csrf_checks = True request._dont_enforce_csrf_checks = True
with mock.patch('django.contrib.messages.add_message'): with mock.patch("django.contrib.messages.add_message"):
response = self.options.changelist_view(request, {}) response = self.options.changelist_view(request, {})
self.assertContains(response, 'is missing field(s)') self.assertContains(response, "is missing field(s)")
@mock.patch( @mock.patch(
'constance.settings.CONFIG_FIELDSETS', "constance.settings.CONFIG_FIELDSETS",
{ {
'Fieldsets': ( "Fieldsets": (
'STRING_VALUE', "STRING_VALUE",
'INT_VALUE', "INT_VALUE",
), ),
}, },
) )
def test_fieldset_ordering_1(self): def test_fieldset_ordering_1(self):
"""Ordering of inner list should be preserved.""" """Ordering of inner list should be preserved."""
self.client.login(username='admin', password='nimda') self.client.login(username="admin", password="nimda")
request = self.rf.get('/admin/constance/config/') request = self.rf.get("/admin/constance/config/")
request.user = self.superuser request.user = self.superuser
response = self.options.changelist_view(request, {}) response = self.options.changelist_view(request, {})
response.render() response.render()
content_str = response.content.decode() content_str = response.content.decode()
self.assertGreater(content_str.find('INT_VALUE'), content_str.find('STRING_VALUE')) self.assertGreater(content_str.find("INT_VALUE"), content_str.find("STRING_VALUE"))
@mock.patch( @mock.patch(
'constance.settings.CONFIG_FIELDSETS', "constance.settings.CONFIG_FIELDSETS",
{ {
'Fieldsets': ( "Fieldsets": (
'INT_VALUE', "INT_VALUE",
'STRING_VALUE', "STRING_VALUE",
), ),
}, },
) )
def test_fieldset_ordering_2(self): def test_fieldset_ordering_2(self):
"""Ordering of inner list should be preserved.""" """Ordering of inner list should be preserved."""
self.client.login(username='admin', password='nimda') self.client.login(username="admin", password="nimda")
request = self.rf.get('/admin/constance/config/') request = self.rf.get("/admin/constance/config/")
request.user = self.superuser request.user = self.superuser
response = self.options.changelist_view(request, {}) response = self.options.changelist_view(request, {})
response.render() response.render()
content_str = response.content.decode() content_str = response.content.decode()
self.assertGreater(content_str.find('STRING_VALUE'), content_str.find('INT_VALUE')) self.assertGreater(content_str.find("STRING_VALUE"), content_str.find("INT_VALUE"))
@mock.patch(
"constance.settings.ADDITIONAL_FIELDS",
{
"language_select": [
"django.forms.fields.TypedMultipleChoiceField",
{
"widget": "django.forms.CheckboxSelectMultiple",
"choices": (("en", "English"), ("de", "German"), ("fr", "French")),
"coerce": str,
},
],
},
)
@mock.patch(
"constance.settings.CONFIG",
{
"LANGUAGES": (["en", "de"], "Supported languages", "language_select"),
},
)
def test_reset_to_default_multi_select(self):
"""
Test that multi-select config values render with data-field-type='multi-select'
and a JSON-encoded data-default attribute.
"""
# Re-parse additional fields so the mock is picked up by the form
from constance.forms import FIELDS
from constance.forms import parse_additional_fields
FIELDS.update(
parse_additional_fields(
{
"language_select": [
"django.forms.fields.TypedMultipleChoiceField",
{
"widget": "django.forms.CheckboxSelectMultiple",
"choices": (("en", "English"), ("de", "German"), ("fr", "French")),
"coerce": str,
},
]
}
)
)
try:
self.client.login(username="admin", password="nimda")
request = self.rf.get("/admin/constance/config/")
request.user = self.superuser
response = self.options.changelist_view(request, {})
response.render()
content = response.content.decode()
self.assertIn('data-field-type="multi-select"', content)
self.assertIn('data-default="[&quot;en&quot;, &quot;de&quot;]"', content)
finally:
# Clean up FIELDS to avoid leaking into other tests
FIELDS.pop("language_select", None)
def test_labels(self): def test_labels(self):
self.assertEqual(type(self.model._meta.label), str) self.assertEqual(type(self.model._meta.label), str)

198
tests/test_async.py Normal file
View file

@ -0,0 +1,198 @@
import warnings
from django.test import TransactionTestCase
from constance import config
from constance import utils
class AsyncTestCase(TransactionTestCase):
async def test_async_get(self):
# Accessing an attribute on config should be awaitable when in async context
val = await config.INT_VALUE
self.assertEqual(val, 1)
async def test_async_set(self):
await config.aset("INT_VALUE", 42)
val = await config.INT_VALUE
self.assertEqual(val, 42)
# Verify sync access also works (and emits warning)
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
sync_val = int(config.INT_VALUE)
self.assertEqual(sync_val, 42)
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_amget(self):
values = await config.amget(["INT_VALUE", "BOOL_VALUE"])
self.assertEqual(values["INT_VALUE"], 1)
self.assertEqual(values["BOOL_VALUE"], True)
async def test_sync_math_in_async_loop(self):
# Accessing math should work but emit warning
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
res = config.INT_VALUE + 10
# Note: res will be 42 + 10 if test_async_set ran before, or 1 + 10 if not.
# TransactionTestCase should reset state, but let's be careful.
# config.INT_VALUE defaults to 1.
self.assertEqual(res, 11 if res < 50 else 52)
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_utils_aget_values(self):
values = await utils.aget_values()
self.assertIn("INT_VALUE", values)
self.assertIn("BOOL_VALUE", values)
self.assertEqual(values["INT_VALUE"], 1)
async def test_utils_aget_values_for_keys(self):
values = await utils.aget_values_for_keys(["INT_VALUE"])
self.assertEqual(len(values), 1)
self.assertEqual(values["INT_VALUE"], 1)
async def test_bool_proxy(self):
# BOOL_VALUE is True by default
self.assertTrue(config.BOOL_VALUE)
async def test_int_proxy(self):
await config.aset("INT_VALUE", 1)
self.assertEqual(int(config.INT_VALUE), 1)
async def test_container_proxy(self):
# LIST_VALUE is [1, "1", date(2019, 1, 1)] by default
self.assertEqual(config.LIST_VALUE[0], 1)
self.assertEqual(len(config.LIST_VALUE), 3)
self.assertIn(1, config.LIST_VALUE)
self.assertEqual(next(iter(config.LIST_VALUE)), 1)
class AsyncValueProxyTestCase(TransactionTestCase):
"""Tests for AsyncValueProxy dunder methods in async context."""
async def test_str_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = str(config.STRING_VALUE)
self.assertEqual(result, "Hello world")
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_repr_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = repr(config.STRING_VALUE)
self.assertEqual(result, "'Hello world'")
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_float_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = float(config.FLOAT_VALUE)
self.assertAlmostEqual(result, 3.1415926536)
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_eq_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = config.INT_VALUE == 1
self.assertTrue(result)
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_ne_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = config.INT_VALUE != 2
self.assertTrue(result)
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_lt_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = config.INT_VALUE < 10
self.assertTrue(result)
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_le_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = config.INT_VALUE <= 1
self.assertTrue(result)
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_gt_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = config.INT_VALUE > 0
self.assertTrue(result)
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_ge_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = config.INT_VALUE >= 1
self.assertTrue(result)
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_hash_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = hash(config.INT_VALUE)
self.assertEqual(result, hash(1))
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_sub_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = config.INT_VALUE - 1
self.assertEqual(result, 0)
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_mul_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = config.INT_VALUE * 5
self.assertEqual(result, 5)
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_truediv_proxy(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
result = config.INT_VALUE / 1
self.assertEqual(result, 1.0)
self.assertTrue(any("Synchronous access" in str(warn.message) for warn in w))
async def test_aset_invalid_key(self):
with self.assertRaises(AttributeError):
await config.aset("INVALID_KEY", 42)
class AsyncUtilsTestCase(TransactionTestCase):
"""Tests for async utility functions."""
async def test_aget_values_for_keys_invalid_type(self):
with self.assertRaises(TypeError):
await utils.aget_values_for_keys("key1")
async def test_aget_values_for_keys_missing_key(self):
with self.assertRaises(AttributeError) as ctx:
await utils.aget_values_for_keys(["INVALID_KEY"])
self.assertIn("INVALID_KEY", str(ctx.exception))
async def test_aget_values_for_keys_empty(self):
result = await utils.aget_values_for_keys([])
self.assertEqual(result, {})
class ConfigBaseTestCase(TransactionTestCase):
"""Tests for Config class edge cases."""
def test_config_dir(self):
# Test __dir__ method
keys = dir(config)
self.assertIn("INT_VALUE", keys)
self.assertIn("BOOL_VALUE", keys)
def test_access_backend_attribute(self):
# Test accessing _backend attribute in sync context
backend = config._backend
self.assertIsNotNone(backend)

View file

@ -8,7 +8,7 @@ from constance.checks import get_inconsistent_fieldnames
class ChecksTestCase(TestCase): class ChecksTestCase(TestCase):
@mock.patch('constance.settings.CONFIG_FIELDSETS', {'Set1': settings.CONFIG.keys()}) @mock.patch("constance.settings.CONFIG_FIELDSETS", {"Set1": settings.CONFIG.keys()})
def test_get_inconsistent_fieldnames_none(self): def test_get_inconsistent_fieldnames_none(self):
""" """
Test that get_inconsistent_fieldnames returns an empty data and no checks fail Test that get_inconsistent_fieldnames returns an empty data and no checks fail
@ -19,8 +19,8 @@ class ChecksTestCase(TestCase):
self.assertFalse(extra_keys) self.assertFalse(extra_keys)
@mock.patch( @mock.patch(
'constance.settings.CONFIG_FIELDSETS', "constance.settings.CONFIG_FIELDSETS",
{'Set1': list(settings.CONFIG.keys())[:-1]}, {"Set1": list(settings.CONFIG.keys())[:-1]},
) )
def test_get_inconsistent_fieldnames_for_missing_keys(self): def test_get_inconsistent_fieldnames_for_missing_keys(self):
""" """
@ -33,8 +33,8 @@ class ChecksTestCase(TestCase):
self.assertEqual(1, len(check_fieldsets())) self.assertEqual(1, len(check_fieldsets()))
@mock.patch( @mock.patch(
'constance.settings.CONFIG_FIELDSETS', "constance.settings.CONFIG_FIELDSETS",
{'Set1': [*settings.CONFIG.keys(), 'FORGOTTEN_KEY']}, {"Set1": [*settings.CONFIG.keys(), "FORGOTTEN_KEY"]},
) )
def test_get_inconsistent_fieldnames_for_extra_keys(self): def test_get_inconsistent_fieldnames_for_extra_keys(self):
""" """
@ -46,7 +46,7 @@ class ChecksTestCase(TestCase):
self.assertTrue(extra_keys) self.assertTrue(extra_keys)
self.assertEqual(1, len(check_fieldsets())) self.assertEqual(1, len(check_fieldsets()))
@mock.patch('constance.settings.CONFIG_FIELDSETS', {}) @mock.patch("constance.settings.CONFIG_FIELDSETS", {})
def test_check_fieldsets(self): def test_check_fieldsets(self):
"""check_fieldsets should not output warning if CONFIG_FIELDSETS is not defined.""" """check_fieldsets should not output warning if CONFIG_FIELDSETS is not defined."""
del settings.CONFIG_FIELDSETS del settings.CONFIG_FIELDSETS

View file

@ -7,6 +7,7 @@ from django.conf import settings
from django.core.management import CommandError from django.core.management import CommandError
from django.core.management import call_command from django.core.management import call_command
from django.test import TransactionTestCase from django.test import TransactionTestCase
from django.test import override_settings
from django.utils import timezone from django.utils import timezone
from django.utils.encoding import smart_str from django.utils.encoding import smart_str
@ -20,10 +21,10 @@ class CliTestCase(TransactionTestCase):
def test_help(self): def test_help(self):
with contextlib.suppress(SystemExit): with contextlib.suppress(SystemExit):
call_command('constance', '--help') call_command("constance", "--help")
def test_list(self): def test_list(self):
call_command('constance', 'list', stdout=self.out) call_command("constance", "list", stdout=self.out)
self.assertEqual( self.assertEqual(
set(self.out.getvalue().splitlines()), set(self.out.getvalue().splitlines()),
@ -51,16 +52,16 @@ class CliTestCase(TransactionTestCase):
) )
def test_get(self): def test_get(self):
call_command('constance', *('get EMAIL_VALUE'.split()), stdout=self.out) call_command("constance", *(["get", "EMAIL_VALUE"]), stdout=self.out)
self.assertEqual(self.out.getvalue().strip(), 'test@example.com') self.assertEqual(self.out.getvalue().strip(), "test@example.com")
def test_set(self): def test_set(self):
call_command('constance', *('set EMAIL_VALUE blah@example.com'.split()), stdout=self.out) call_command("constance", *(["set", "EMAIL_VALUE", "blah@example.com"]), stdout=self.out)
self.assertEqual(config.EMAIL_VALUE, 'blah@example.com') self.assertEqual(config.EMAIL_VALUE, "blah@example.com")
call_command('constance', *('set', 'DATETIME_VALUE', '2011-09-24', '12:30:25'), stdout=self.out) call_command("constance", *("set", "DATETIME_VALUE", "2011-09-24", "12:30:25"), stdout=self.out)
expected = datetime(2011, 9, 24, 12, 30, 25) expected = datetime(2011, 9, 24, 12, 30, 25)
if settings.USE_TZ: if settings.USE_TZ:
@ -70,50 +71,70 @@ class CliTestCase(TransactionTestCase):
def test_get_invalid_name(self): def test_get_invalid_name(self):
self.assertRaisesMessage( self.assertRaisesMessage(
CommandError, CommandError,
'NOT_A_REAL_CONFIG is not defined in settings.CONSTANCE_CONFIG', "NOT_A_REAL_CONFIG is not defined in settings.CONSTANCE_CONFIG",
call_command, call_command,
'constance', "constance",
'get', "get",
'NOT_A_REAL_CONFIG', "NOT_A_REAL_CONFIG",
) )
def test_set_invalid_name(self): def test_set_invalid_name(self):
self.assertRaisesMessage( self.assertRaisesMessage(
CommandError, CommandError,
'NOT_A_REAL_CONFIG is not defined in settings.CONSTANCE_CONFIG', "NOT_A_REAL_CONFIG is not defined in settings.CONSTANCE_CONFIG",
call_command, call_command,
'constance', "constance",
'set', "set",
'NOT_A_REAL_CONFIG', "NOT_A_REAL_CONFIG",
'foo', "foo",
) )
def test_set_invalid_value(self): def test_set_invalid_value(self):
self.assertRaisesMessage( self.assertRaisesMessage(
CommandError, CommandError,
'Enter a valid email address.', "Enter a valid email address.",
call_command, call_command,
'constance', "constance",
'set', "set",
'EMAIL_VALUE', "EMAIL_VALUE",
'not a valid email', "not a valid email",
) )
def test_set_invalid_multi_value(self): def test_set_invalid_multi_value(self):
self.assertRaisesMessage( self.assertRaisesMessage(
CommandError, CommandError,
'Enter a list of values.', "Enter a list of values.",
call_command, call_command,
'constance', "constance",
'set', "set",
'DATETIME_VALUE', "DATETIME_VALUE",
'2011-09-24 12:30:25', "2011-09-24 12:30:25",
) )
def test_delete_stale_records(self): def test_delete_stale_records(self):
self._populate_database_with_default_values()
initial_count = Constance.objects.count() initial_count = Constance.objects.count()
Constance.objects.create(key='STALE_KEY', value=None) Constance.objects.create(key="STALE_KEY", value=None)
call_command('constance', 'remove_stale_keys', stdout=self.out) call_command("constance", "remove_stale_keys", stdout=self.out)
self.assertEqual(Constance.objects.count(), initial_count, msg=self.out) self.assertEqual(Constance.objects.count(), initial_count, msg=self.out)
@override_settings(
CONSTANCE_DATABASE_PREFIX="constance:",
)
def test_delete_stale_records_respects_prefix(self):
self._populate_database_with_default_values()
initial_count = Constance.objects.count()
call_command("constance", "remove_stale_keys", stdout=self.out)
self.assertEqual(Constance.objects.count(), initial_count, msg=self.out)
def _populate_database_with_default_values(self):
"""
Helper function to populate the database with default values defined
in settings since that's not done automatically at startup
"""
for key, (value, *_) in settings.CONSTANCE_CONFIG.items():
Constance.objects.create(key=f"{getattr(settings, 'CONSTANCE_DATABASE_PREFIX', '')}{key}", value=value)

View file

@ -16,16 +16,16 @@ class TestJSONSerialization(TestCase):
self.datetime = datetime(2023, 10, 5, 15, 30, 0) self.datetime = datetime(2023, 10, 5, 15, 30, 0)
self.date = date(2023, 10, 5) self.date = date(2023, 10, 5)
self.time = time(15, 30, 0) self.time = time(15, 30, 0)
self.decimal = Decimal('10.5') self.decimal = Decimal("10.5")
self.uuid = uuid.UUID('12345678123456781234567812345678') self.uuid = uuid.UUID("12345678123456781234567812345678")
self.string = 'test' self.string = "test"
self.integer = 42 self.integer = 42
self.float = 3.14 self.float = 3.14
self.boolean = True self.boolean = True
self.none = None self.none = None
self.timedelta = timedelta(days=1, hours=2, minutes=3) self.timedelta = timedelta(days=1, hours=2, minutes=3)
self.list = [1, 2, self.date] self.list = [1, 2, self.date]
self.dict = {'key': self.date, 'key2': 1} self.dict = {"key": self.date, "key2": 1}
def test_serializes_and_deserializes_default_types(self): def test_serializes_and_deserializes_default_types(self):
self.assertEqual(dumps(self.datetime), '{"__type__": "datetime", "__value__": "2023-10-05T15:30:00"}') self.assertEqual(dumps(self.datetime), '{"__type__": "datetime", "__value__": "2023-10-05T15:30:00"}')
@ -65,18 +65,18 @@ class TestJSONSerialization(TestCase):
self.assertEqual(t, loads(dumps(t))) self.assertEqual(t, loads(dumps(t)))
def test_invalid_deserialization(self): def test_invalid_deserialization(self):
with self.assertRaisesRegex(ValueError, 'Expecting value'): with self.assertRaisesRegex(ValueError, "Expecting value"):
loads('THIS_IS_NOT_RIGHT') loads("THIS_IS_NOT_RIGHT")
with self.assertRaisesRegex(ValueError, 'Invalid object'): with self.assertRaisesRegex(ValueError, "Invalid object"):
loads('{"__type__": "THIS_IS_NOT_RIGHT", "__value__": "test", "THIS_IS_NOT_RIGHT": "THIS_IS_NOT_RIGHT"}') loads('{"__type__": "THIS_IS_NOT_RIGHT", "__value__": "test", "THIS_IS_NOT_RIGHT": "THIS_IS_NOT_RIGHT"}')
with self.assertRaisesRegex(ValueError, 'Unsupported type'): with self.assertRaisesRegex(ValueError, "Unsupported type"):
loads('{"__type__": "THIS_IS_NOT_RIGHT", "__value__": "test"}') loads('{"__type__": "THIS_IS_NOT_RIGHT", "__value__": "test"}')
def test_handles_unknown_type(self): def test_handles_unknown_type(self):
class UnknownType: class UnknownType:
pass pass
with self.assertRaisesRegex(TypeError, 'Object of type UnknownType is not JSON serializable'): with self.assertRaisesRegex(TypeError, "Object of type UnknownType is not JSON serializable"):
dumps(UnknownType()) dumps(UnknownType())
def test_custom_type_serialization(self): def test_custom_type_serialization(self):
@ -84,25 +84,25 @@ class TestJSONSerialization(TestCase):
def __init__(self, value): def __init__(self, value):
self.value = value self.value = value
register_type(CustomType, 'custom', lambda o: o.value, lambda o: CustomType(o)) register_type(CustomType, "custom", lambda o: o.value, lambda o: CustomType(o))
custom_data = CustomType('test') custom_data = CustomType("test")
json_data = dumps(custom_data) json_data = dumps(custom_data)
self.assertEqual(json_data, '{"__type__": "custom", "__value__": "test"}') self.assertEqual(json_data, '{"__type__": "custom", "__value__": "test"}')
deserialized_data = loads(json_data) deserialized_data = loads(json_data)
self.assertTrue(isinstance(deserialized_data, CustomType)) self.assertTrue(isinstance(deserialized_data, CustomType))
self.assertEqual(deserialized_data.value, 'test') self.assertEqual(deserialized_data.value, "test")
def test_register_known_type(self): def test_register_known_type(self):
with self.assertRaisesRegex(ValueError, 'Discriminator must be specified'): with self.assertRaisesRegex(ValueError, "Discriminator must be specified"):
register_type(int, '', lambda o: o.value, lambda o: int(o)) register_type(int, "", lambda o: o.value, lambda o: int(o))
with self.assertRaisesRegex(ValueError, 'Type with discriminator default is already registered'): with self.assertRaisesRegex(ValueError, "Type with discriminator default is already registered"):
register_type(int, 'default', lambda o: o.value, lambda o: int(o)) register_type(int, "default", lambda o: o.value, lambda o: int(o))
register_type(int, 'new_custom_type', lambda o: o.value, lambda o: int(o)) register_type(int, "new_custom_type", lambda o: o.value, lambda o: int(o))
with self.assertRaisesRegex(ValueError, 'Type with discriminator new_custom_type is already registered'): with self.assertRaisesRegex(ValueError, "Type with discriminator new_custom_type is already registered"):
register_type(int, 'new_custom_type', lambda o: o.value, lambda o: int(o)) register_type(int, "new_custom_type", lambda o: o.value, lambda o: int(o))
def test_nested_collections(self): def test_nested_collections(self):
data = {'key': [[[[{'key': self.date}]]]]} data = {"key": [[[[{"key": self.date}]]]]}
self.assertEqual( self.assertEqual(
dumps(data), dumps(data),
( (

View file

@ -8,16 +8,16 @@ class TestForm(TestCase):
def test_form_field_types(self): def test_form_field_types(self):
f = ConstanceForm({}) f = ConstanceForm({})
self.assertIsInstance(f.fields['INT_VALUE'], fields.IntegerField) self.assertIsInstance(f.fields["INT_VALUE"], fields.IntegerField)
self.assertIsInstance(f.fields['BOOL_VALUE'], fields.BooleanField) self.assertIsInstance(f.fields["BOOL_VALUE"], fields.BooleanField)
self.assertIsInstance(f.fields['STRING_VALUE'], fields.CharField) self.assertIsInstance(f.fields["STRING_VALUE"], fields.CharField)
self.assertIsInstance(f.fields['DECIMAL_VALUE'], fields.DecimalField) self.assertIsInstance(f.fields["DECIMAL_VALUE"], fields.DecimalField)
self.assertIsInstance(f.fields['DATETIME_VALUE'], fields.SplitDateTimeField) self.assertIsInstance(f.fields["DATETIME_VALUE"], fields.SplitDateTimeField)
self.assertIsInstance(f.fields['TIMEDELTA_VALUE'], fields.DurationField) self.assertIsInstance(f.fields["TIMEDELTA_VALUE"], fields.DurationField)
self.assertIsInstance(f.fields['FLOAT_VALUE'], fields.FloatField) self.assertIsInstance(f.fields["FLOAT_VALUE"], fields.FloatField)
self.assertIsInstance(f.fields['DATE_VALUE'], fields.DateField) self.assertIsInstance(f.fields["DATE_VALUE"], fields.DateField)
self.assertIsInstance(f.fields['TIME_VALUE'], fields.TimeField) self.assertIsInstance(f.fields["TIME_VALUE"], fields.TimeField)
# from CONSTANCE_ADDITIONAL_FIELDS # from CONSTANCE_ADDITIONAL_FIELDS
self.assertIsInstance(f.fields['CHOICE_VALUE'], fields.ChoiceField) self.assertIsInstance(f.fields["CHOICE_VALUE"], fields.ChoiceField)
self.assertIsInstance(f.fields['EMAIL_VALUE'], fields.EmailField) self.assertIsInstance(f.fields["EMAIL_VALUE"], fields.EmailField)

View file

@ -42,10 +42,10 @@ try:
"""Assert that the class decorator changes config.BOOL_VALUE.""" """Assert that the class decorator changes config.BOOL_VALUE."""
assert not config.BOOL_VALUE assert not config.BOOL_VALUE
@pytest.mark.override_config(BOOL_VALUE='True') @pytest.mark.override_config(BOOL_VALUE="True")
def test_override_config_on_overridden_value(self): def test_override_config_on_overridden_value(self):
"""Ensure that method mark decorator changes already overridden value for class.""" """Ensure that method mark decorator changes already overridden value for class."""
assert config.BOOL_VALUE == 'True' assert config.BOOL_VALUE == "True"
def test_fixture_override_config(override_config): def test_fixture_override_config(override_config):
""" """
@ -66,7 +66,7 @@ except ImportError:
class PytestTests(unittest.TestCase): class PytestTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.skipTest('Skip all pytest tests when using unittest') self.skipTest("Skip all pytest tests when using unittest")
def test_do_not_skip_silently(self): def test_do_not_skip_silently(self):
"""If no at least one test present, unittest silently skips module.""" """If no at least one test present, unittest silently skips module."""

View file

@ -6,32 +6,37 @@ from django.test import TestCase
from constance.management.commands.constance import _set_constance_value from constance.management.commands.constance import _set_constance_value
from constance.utils import get_values from constance.utils import get_values
from constance.utils import get_values_for_keys
class UtilsTestCase(TestCase): class UtilsTestCase(TestCase):
def test_set_value_validation(self): def test_set_value_validation(self):
self.assertRaisesMessage(ValidationError, 'Enter a whole number.', _set_constance_value, 'INT_VALUE', 'foo') self.assertRaisesMessage(ValidationError, "Enter a whole number.", _set_constance_value, "INT_VALUE", "foo")
self.assertRaisesMessage( self.assertRaisesMessage(
ValidationError, 'Enter a valid email address.', _set_constance_value, 'EMAIL_VALUE', 'not a valid email' ValidationError,
"Enter a valid email address.",
_set_constance_value,
"EMAIL_VALUE",
"not a valid email",
) )
self.assertRaisesMessage( self.assertRaisesMessage(
ValidationError, ValidationError,
'Enter a valid date.', "Enter a valid date.",
_set_constance_value, _set_constance_value,
'DATETIME_VALUE', "DATETIME_VALUE",
( (
'2000-00-00', "2000-00-00",
'99:99:99', "99:99:99",
), ),
) )
self.assertRaisesMessage( self.assertRaisesMessage(
ValidationError, ValidationError,
'Enter a valid time.', "Enter a valid time.",
_set_constance_value, _set_constance_value,
'DATETIME_VALUE', "DATETIME_VALUE",
( (
'2016-01-01', "2016-01-01",
'99:99:99', "99:99:99",
), ),
) )
@ -39,26 +44,52 @@ class UtilsTestCase(TestCase):
self.assertEqual( self.assertEqual(
get_values(), get_values(),
{ {
'FLOAT_VALUE': 3.1415926536, "FLOAT_VALUE": 3.1415926536,
'BOOL_VALUE': True, "BOOL_VALUE": True,
'EMAIL_VALUE': 'test@example.com', "EMAIL_VALUE": "test@example.com",
'INT_VALUE': 1, "INT_VALUE": 1,
'CHOICE_VALUE': 'yes', "CHOICE_VALUE": "yes",
'TIME_VALUE': datetime.time(23, 59, 59), "TIME_VALUE": datetime.time(23, 59, 59),
'DATE_VALUE': datetime.date(2010, 12, 24), "DATE_VALUE": datetime.date(2010, 12, 24),
'TIMEDELTA_VALUE': datetime.timedelta(days=1, hours=2, minutes=3), "TIMEDELTA_VALUE": datetime.timedelta(days=1, hours=2, minutes=3),
'LINEBREAK_VALUE': 'Spam spam', "LINEBREAK_VALUE": "Spam spam",
'DECIMAL_VALUE': Decimal('0.1'), "DECIMAL_VALUE": Decimal("0.1"),
'STRING_VALUE': 'Hello world', "STRING_VALUE": "Hello world",
'DATETIME_VALUE': datetime.datetime(2010, 8, 23, 11, 29, 24), "DATETIME_VALUE": datetime.datetime(2010, 8, 23, 11, 29, 24),
'LIST_VALUE': [1, '1', datetime.date(2019, 1, 1)], "LIST_VALUE": [1, "1", datetime.date(2019, 1, 1)],
'JSON_VALUE': { "JSON_VALUE": {
'key': 'value', "key": "value",
'key2': 2, "key2": 2,
'key3': [1, 2, 3], "key3": [1, 2, 3],
'key4': {'key': 'value'}, "key4": {"key": "value"},
'key5': datetime.date(2019, 1, 1), "key5": datetime.date(2019, 1, 1),
'key6': None, "key6": None,
}, },
}, },
) )
def test_get_values_for_keys(self):
self.assertEqual(
get_values_for_keys(["BOOL_VALUE", "CHOICE_VALUE", "LINEBREAK_VALUE"]),
{
"BOOL_VALUE": True,
"CHOICE_VALUE": "yes",
"LINEBREAK_VALUE": "Spam spam",
},
)
def test_get_values_for_keys_empty_keys(self):
result = get_values_for_keys([])
self.assertEqual(result, {})
def test_get_values_for_keys_throw_error_if_no_key(self):
self.assertRaisesMessage(
AttributeError,
'"OLD_VALUE, BOLD_VALUE" keys not found in configuration.',
get_values_for_keys,
["BOOL_VALUE", "OLD_VALUE", "BOLD_VALUE"],
)
def test_get_values_for_keys_invalid_input_type(self):
with self.assertRaises(TypeError):
get_values_for_keys("key1")

View file

@ -2,5 +2,5 @@ from django.contrib import admin
from django.urls import path from django.urls import path
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path("admin/", admin.site.urls),
] ]

View file

@ -3,7 +3,9 @@ isolated_build = true
envlist = envlist =
py{38,39,310,311,312}-dj{42}-{unittest,pytest,checkmigrations} py{38,39,310,311,312}-dj{42}-{unittest,pytest,checkmigrations}
py{310,311,312}-dj{50}-{unittest,pytest,checkmigrations} py{310,311,312}-dj{50}-{unittest,pytest,checkmigrations}
py{310,311,312}-dj{main}-{unittest,pytest,checkmigrations} py{310,311,312,313}-dj{51}-{unittest,pytest,checkmigrations}
py{310,311,312,313,314}-dj{52}-{unittest,pytest,checkmigrations}
py{312,313,314}-dj{main}-{unittest,pytest,checkmigrations}
skip_missing_interpreters = True skip_missing_interpreters = True
[testenv] [testenv]
@ -13,6 +15,7 @@ deps =
dj42: django>=4.2,<4.3 dj42: django>=4.2,<4.3
dj50: django>=5.0,<5.1 dj50: django>=5.0,<5.1
dj51: django>=5.1,<5.2 dj51: django>=5.1,<5.2
dj52: django>=5.2,<5.3
djmain: https://github.com/django/django/archive/main.tar.gz djmain: https://github.com/django/django/archive/main.tar.gz
pytest: pytest pytest: pytest
pytest: pytest-cov pytest: pytest-cov
@ -38,3 +41,5 @@ python =
3.10: py310 3.10: py310
3.11: py311 3.11: py311
3.12: py312 3.12: py312
3.13: py313
3.14: py314