Compare commits

..

223 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
Alexandr Artemyev
a68bf903cf
Fix exception in 0002 migration (#579) 2024-09-05 13:30:28 +05:00
Alexandr Artemyev
f1d0dfcb8d
Add setuptools-scm for dynamic version for scm version management (#582) 2024-09-04 15:08:08 -05:00
Alexandr Artemyev
05dfb5d3cf
Release notes are on GitHub (#580) 2024-09-04 15:07:22 -05:00
Alexandr Artemyev
70dba1ca35
Bump 4.1.0 2024-09-04 14:42:41 +05:00
Alexandr Artemyev
bb0dc4676f
Collections support (#581)
---------

Co-authored-by: Sebastian Manger <manger@netcloud.ch>
2024-09-04 14:41:53 +05:00
Alexandr Artemyev
31c9e8d043
Bump 4.0.2 2024-09-03 18:54:01 +05:00
ivan-klass
5e221a855b
More safe suppress of pickle-to-json data migration (#578) 2024-09-03 08:48:37 -05:00
dependabot[bot]
5ce88b7457
chore(ci): bump pypa/gh-action-pypi-publish from 1.9.0 to 1.10.0 in the github-actions group (#577)
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.9.0 to 1.10.0
- [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases)
- [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.9.0...v1.10.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-09-02 11:25:46 +05:00
Felipe Gonzalez
cf838ab28a
fix: Fixed _pickle.UnpicklingError (#576)
The error indicates an issue with unpickling data during the migration process. Specifically, it raises an `_pickle.UnpicklingError` with the message "invalid load key, '{'." The data being loaded may be corrupted or not in the expected format.
2024-08-31 09:31:21 -05:00
Alexandr Artemyev
c690d5ef0f
Fix #570 (#571)
* Fix #570

* [PATCH] chore(ci): check missing migrations

---------

Co-authored-by: Sebastian Manger <manger@netcloud.ch>
2024-08-23 14:13:54 -05:00
Alexandr Artemyev
98effe6917
Ping gh-action-pypi-publish version (#568) 2024-08-23 14:13:20 -05:00
Christian Clauss
e428387620
docs/index.rst: Fix typo newsletter (#567) 2024-08-23 17:07:24 +05:00
Alexandr Artemyev
f5bf8e5adc
Add build 2024-08-21 12:12:49 +05:00
Alexandr Artemyev
46f2f7aa85
Fix release.yml 2024-08-21 12:09:04 +05:00
Alexandr Artemyev
2c827124cd
bump version (#565) 2024-08-20 16:46:25 -05:00
Alexandr Artemyev
3640eb228a
Replace pickle with JSON (#564)
* Replace pickle with JSON

Co-authored-by: Ivan Klass <klass.ivanklass@gmail.com>
2024-08-20 09:35:27 -05:00
Alexandr Artemyev
ce957ac096
Fix 426 (#563) 2024-08-16 16:02:31 -05:00
Alexandr Artemyev
8c6552fdaf
Enable more rules for ruff (#562) 2024-07-05 19:38:26 +05:00
Alexandr Artemyev
8cec9c24b0
Refactoring for constance cli command (#561) 2024-07-03 19:51:11 +05:00
Alexandr Artemyev
57083bbed2
Add ruff format & lint (isort only) (#560) 2024-07-03 19:21:33 +05:00
Alexandr Artemyev
554eedc7c2
migate from setup.py to pyproject.toml & bump tox & declare support for python 3.12 (#557)
* migate from setup.py to pyproject.toml
* bump tox
* declare support for python 3.12
2024-07-03 13:30:29 +05:00
pre-commit-ci[bot]
451386aca0
[pre-commit.ci] pre-commit autoupdate (#558)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.5.0 → v4.6.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.5.0...v4.6.0)
- [github.com/asottile/pyupgrade: v3.15.2 → v3.16.0](https://github.com/asottile/pyupgrade/compare/v3.15.2...v3.16.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-07-01 16:07:42 -05:00
Rotzbua
5f262fce9a
fix: django 5.1 removed collapse.js (#556) 2024-06-15 00:55:01 +05:00
Rotzbua
ba530b9cc0
fix(docs): update links (#555) 2024-06-14 19:59:48 +05:00
Rotzbua
6976d8a5e9
feat(docs): migrate to sphinx 7 (#553)
* feat(docs): migrate to sphinx 7

feat(docs): add instant search
feat(docs): enable reproducible build with requirements.txt
fix(docs): warnings
WARNING: The pre-Sphinx 1.0 'intersphinx_mapping' format is deprecated and will be removed in Sphinx 8. Update to the current format as described in the documentation. Hint: "intersphinx_mapping = {'<name>': ('https://docs.python.org/', None)}".https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#confval-intersphinx_mapping

* feat(docs): add test build to GH CI
2024-06-14 19:57:30 +05:00
dependabot[bot]
433e0f5be9
chore(ci): bump the github-actions group with 3 updates (#552)
Bumps the github-actions group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [actions/setup-python](https://github.com/actions/setup-python) and [actions/cache](https://github.com/actions/cache).


Updates `actions/checkout` from 3 to 4
- [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/v3...v4)

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

Updates `actions/cache` from 3 to 4
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/cache
  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-06-11 17:52:19 +05:00
Rotzbua
4b78082240
fix(docs): add required readthedocs config (#551)
* fix(docs): add required readthedocs config
---------

Co-authored-by: Alexandr Artemyev <mogost@gmail.com>
2024-06-11 17:51:45 +05:00
Rotzbua
6eaa7a6602
chore: update supported versions (#545)
- drop eol django 3.2 4.0 4.1
- drop eol python 3.7
- drop untested PyPy support
- prepare python 3.12 test
- update GH action dependencies
- add dependabot to maintain GH action dependencies
2024-06-11 17:46:29 +05:00
Alexandr Artemyev
fd54f0a15d
Merge pull request #544 from Rotzbua/chore_migrate_fstring
chore: migrate to f-string
2024-06-10 00:40:49 +05:00
Rotzbua
ee681b592a
feat(pre-commit): add pyupgrade 2024-06-09 21:34:50 +02:00
Rotzbua
eb5aef2554
chore: migrate to f-string
fix: use str() instead of f-string

credit: tool pyupgrade
Co-Authored-By: Alexandr Artemyev <mogost@gmail.com>
2024-06-09 21:07:07 +02:00
Alexandr Artemyev
053944200e
Merge pull request #547 from Rotzbua/chore_migrate_es2015
chore: migrate JS to ES2015
2024-06-09 22:37:39 +05:00
Alexandr Artemyev
bf69baec61
Merge pull request #549 from Rotzbua/fix_remove_legacy
fix: remove legacy django <1.9 code
2024-06-09 22:37:15 +05:00
Alexandr Artemyev
ef24fcb95f
Merge pull request #548 from Rotzbua/fix_html_trailing_slash
fix: html5 does not allow self-closing tags
2024-06-09 22:37:03 +05:00
Alexandr Artemyev
38f365d719
Merge pull request #543 from Rotzbua/fix_typo
fix: typos
2024-06-09 22:36:37 +05:00
Rotzbua
7589b11cf4
fix: remove legacy django <1.9 code 2024-06-08 23:28:12 +02:00
Rotzbua
3eede068f4
fix: html5 does not allow self-closing tags
Seems like leftovers from xhtml.
2024-06-08 21:31:38 +02:00
Rotzbua
60d85d7b54
chore: migrate JS to ES2015
Use let/const instead of var.
2024-06-08 21:25:52 +02:00
Rotzbua
1b27d73586
fix: typos 2024-06-08 19:55:28 +02:00
Chris Clark
cb8ae39854
Merge pull request #538 from jazzband/dont-set-in-get
Fix issue #510
2024-02-05 00:01:09 -05:00
Chris Clark
6f0bb23102 one query 2024-02-01 14:24:48 -05:00
Chris Clark
d1c409b1ac better DB handling 2024-02-01 14:15:22 -05:00
Alexandr Artemyev
46fe20dcd7
Merge pull request #537 from arunsathiya/master
ci: Use GITHUB_OUTPUT envvar instead of set-output command
2024-01-12 15:15:02 +06:00
Arun
69fc0f9a8e
ci: Use GITHUB_OUTPUT envvar instead of set-output command 2024-01-11 17:12:17 -08:00
pre-commit-ci[bot]
e001a228f8
[pre-commit.ci] pre-commit autoupdate (#536)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.5.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.5.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-01-02 20:06:48 -06:00
Sergei Iurchenko
d03bea804f
fix migration on mysql (#531)
Co-authored-by: Iurchenko Sergei <empty>
2023-10-16 09:14:40 -05:00
Camilo Nova
bc9707d618 Bump version 3.1.0 2023-08-21 16:20:18 -05:00
James Tiplady
6a5052e9f4
Adding support for using a subdirectory of MEDIA_ROOT for file fields (#475)
* Adding support for using a subdirectory of MEDIA_ROOT for file fields with CONSTANCE_FILE_ROOT setting

* Improving documentation for CONSTANCE_FILE_ROOT

* Updating PR branch to work with latest master
2023-07-29 11:35:38 -05:00
Sergei Iurchenko
554dac0473
remove pypy from tox tests (#524)
Co-authored-by: Iurchenko Sergei
2023-07-20 12:19:33 -05:00
Camilo Nova
8317070890 Bump version 3.0.0 2023-06-27 15:53:30 -05:00
Rémy HUBSCHER
796f0fac5c
Merge pull request #518 from browniebroke/feat/django-4.2
Add official support for Django 4.2
2023-04-18 14:02:12 +02:00
Bruno Alla
d65b916189
Add official support for Django 4.2 2023-04-18 11:04:48 +01:00
Bruno Alla
9cbd512d75
Fix formatting in changes.rst 2023-04-18 11:02:33 +01:00
Søren Howe Gersager
0047a781af
Fix constance management command without admin installed (#506)
* refactor out ConstanceForm and get_values into forms.py and utils.py respectively

* fix tests and documentation

* correct mock import

* fix merge
2023-04-07 08:16:39 -05:00
pre-commit-ci[bot]
92e595e68b
[pre-commit.ci] pre-commit autoupdate (#516)
updates:
- [github.com/pre-commit/pygrep-hooks: v1.9.0 → v1.10.0](https://github.com/pre-commit/pygrep-hooks/compare/v1.9.0...v1.10.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-04-07 08:12:07 -05:00
Sergei Iurchenko
5ab48e1943
505-race-condition-caused-by-when-constance-registers-django-checks (#514)
Co-authored-by: Iurchenko Sergei
2023-03-13 17:45:31 -05:00
Sergei Iurchenko
b486056802
fix_ci (#512)
* fix_ci

Co-authored-by: Iurchenko Sergei
2023-03-11 08:58:13 -06:00
pre-commit-ci[bot]
79fd8af0f4
[pre-commit.ci] pre-commit autoupdate (#507)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.3.0 → v4.4.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.3.0...v4.4.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-03-10 20:41:05 +03:00
Sergei Iurchenko
2cb2ccd063
Refactor app and model 2023-03-10 20:38:31 +03:00
Ani Mehta
8b03012918
Fix "and and" in backends.rst (#509) 2023-01-11 11:46:55 -06:00
Dmitry
b6f8e2c5b8
"failed to update live settings" message (#491)
* "failed to update live settings" message

* Update constance/locale/ru/LC_MESSAGES/django.po

* test fix

* fix .po/.mo

Co-authored-by: Alexandr Artemyev <mogost@gmail.com>
2022-10-13 12:16:31 -05:00
Felippe Medeiros
b7da81451e
Forward the request when saving the form (#499) 2022-10-12 19:07:49 -05:00
Ihor Sychevskyi
432ffc8f1c
update links (#502) 2022-10-11 00:08:54 +03:00
Rémy HUBSCHER
02c5fd5011
Preparing release 2.9.1 (#497) 2022-08-11 13:54:59 +02:00
pre-commit-ci[bot]
c4d0b6f693
[pre-commit.ci] pre-commit autoupdate (#486)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.1.0 → v4.3.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.1.0...v4.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-08-08 08:16:58 -05:00
Yuekui
4d7b904cd2
Fix text format for MultiValueField usage (#494) 2022-08-05 09:36:29 -05:00
Mariusz Felisiak
7b59c64635
Added support for Django 4.1. (#487)
* Added Django 4.0 classifier.

* Added support for Django 4.1.
2022-07-13 10:04:47 -05:00
alexkiro
55aed5d4d4
Add support for using gettext in fieldset headers (#489)
* Support tuples for CONFIG_FIELDSETS

* Add test for tuple CONFIG_FIELDSETS

* Preserve tuple fieldset sorting

* Add i18n example in the docs
2022-07-13 10:01:25 -05:00
Alessandro Piroddi
807f98cc3b
Fix build issues (#477)
Issue #468

Co-authored-by: Alessandro Piroddi <alessandro.piroddi@inpeco.com>
2022-05-11 07:28:16 -05:00
Alexandr Artemyev
908b72bc47
Merge pull request #479 from yuekui/patch-1
Fix typo in docs
2022-05-04 13:05:41 +06:00
Yuekui
b4bc9120d0
Fix typo in docs 2022-04-28 13:23:45 -07:00
Alexandr Artemyev
c47fefaf46
Merge pull request #478 from dxillar/patch-1
Update index.rst
2022-04-25 19:00:55 +06:00
Amit Garu
4899d1ce5c
Update index.rst
Typo fix for field access in template.
2022-04-24 12:08:50 +05:45
Camilo Nova
5de44eafb1 Bump version 2.9.0 2022-03-11 14:36:39 -06:00
Asger Hautop Drewsen
ebb5c8608b
Add default_auto_field to database backend (#449)
This ensures that changing DEFAULT_AUTO_FIELD in settings.py doesn't
create migrations for constance.
2022-03-11 13:25:57 -06:00
Yurchenko Sergey
fc6d41fdb3
serialize_according_to_widget (#472)
Co-authored-by: Сергей Юрченко <s.yurchenko@softpro.com>
2022-02-11 19:49:08 -06:00
horpto
8b34b63fd0
Add caching redis backend (#466)
* Add caching redis backend

* fix mget implementation

* fix lock
2022-01-06 15:45:11 -05:00
pre-commit-ci[bot]
01a8dc54d3
[pre-commit.ci] pre-commit autoupdate (#467)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.0.1 → v4.1.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.0.1...v4.1.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-01-05 10:11:16 -05:00
MansurAliKoroglu
dc83f99df0
Add documentation for constance_dbs config (#462)
Co-authored-by: Mansur Ali Koroglu <mansur@thorgate.eu>
2021-12-15 11:18:06 -05:00
Hugo van Kemenade
87e2f277a7
Add basic pre-commit config (#461) 2021-11-11 07:48:02 -05:00
Hugo van Kemenade
bd022205f1
CI: Replace deprecated pypy3 with pypy-3.8 (#460)
pypy3 is deprecated and is not available in newer images:
https://github.com/actions/setup-python/issues/244#issuecomment-925966022

Instead explicitly specify the version:
https://github.com/actions/setup-python#specifying-a-pypy-version

Committed via https://github.com/asottile/all-repos
2021-11-11 07:47:20 -05:00
Vasyl Dizhak
9554dbacd9
Fixes for Ukrainian locale (#458)
Co-authored-by: Vasyl Dizhak <vasyl.dizhak@moneypark.com>
2021-10-26 10:17:39 -05:00
Jair Henrique
a706e4798c
Add support to python 3.10 (#455) 2021-10-21 13:56:14 -05:00
Jacob Kaplan-Moss
b89f5ddb4e
Add a note about working around the locmem cache restriction (#448)
Per a conversation on twitter with @jezdez about this, here's a stab at some docs. I've tried to strike a balance: I want to tell people about this option, but not directly provide the code to avoid copy-paste without thinking.
2021-10-21 13:51:20 -05:00
Jazzband Bot
32577c17d8
Jazzband: Created local 'CODE_OF_CONDUCT.md' from remote 'CODE_OF_CONDUCT.md' (#457) 2021-10-21 13:49:53 -05:00
dkr13
9c95294ed5
do not detect datetime fields as date type (#456)
Co-authored-by: Daniel Klein-Ridder <daniel@kleinridder.de>
2021-10-21 13:49:26 -05:00
Alexandr Artemyev
3b419e2066
Merge pull request #451 from johnthagen/patch-1
Improve formatting of the pip install command
2021-09-09 14:40:17 +03:00
johnthagen
7306c0f703
Improve formatting of the pip install command 2021-09-09 07:08:35 -04:00
Alexandr Artemyev
b9a2adf19c
Merge pull request #446 from jazzband/django-3-2-test
Run tests on django 3.2
2021-03-17 20:22:30 +03:00
Alexandr Artemyev
45bcfc1a4a
Run tests on django 3.2 2021-03-17 19:46:41 +03:00
Jannis Leidel
a16cca3e8f
Rename Django's dev branch to main. (#445)
* Rename Django's dev branch to main.

More information: https://groups.google.com/g/django-developers/c/tctDuKUGosc/
Refs: https://github.com/django/django/pull/14048

* Don't run tests against Django main on Python < 3.8.
2021-03-09 13:12:55 +01:00
Jannis Leidel
53ac3cd45f
Add concrete_model class attribute to fake admin model. (#441)
* Add concrete_model class attribute to fake admin model.

This is related to #244 and https://github.com/django-admin-tools/django-admin-tools/issues/103.

* Set attribute during init.
2021-01-13 14:45:37 -05:00
Mohamed Ben Makhlouf
b30ccb6257
add arabic translation (#438)
* add arabic translation

* add compiled file
2020-12-02 11:53:38 -05:00
Jannis Leidel
be92e09c9a
Update badges. 2020-11-30 21:03:00 +01:00
Jannis Leidel
502c8da1ba
Remove Travis config. 2020-11-30 21:02:40 +01:00
Rotzbua
d4ab97591b
Fix build badge url (#437)
travis -> github actions
2020-11-30 21:00:13 +01:00
Jannis Leidel
8d57f02023
Migrate to GitHub Actions. (#435)
* Add GitHub Actions test workflow.

* Add version map.

* Fix name.

* Update .github/workflows/test.yml

* Add release workflow.

Co-authored-by: Hugo van Kemenade <hugovk@users.noreply.github.com>
2020-11-27 09:29:12 -05:00
Camilo Nova
f4732b97fb Bump version 2020-11-19 08:26:54 -05:00
Rotzbua
c6087f8580
update python django (#432)
* update python django
add django 3.1 to test
drop eol python 3.5
add python 3.9
2020-11-18 11:16:33 -05:00
Alexandr Artemyev
550eb1364c
Fix RemovedInDjango40Warning about Signal (#430) 2020-11-18 11:15:44 -05:00
Jair Henrique
08a20c1bbe
Use gettext_lazy instead of ugettext_lazy (#421)
Co-authored-by: Camilo Nova <camilo.nova@gmail.com>
2020-11-18 11:14:05 -05:00
Ekin Ertaç
dfce4f2fe5
url() is deprecated in Django 3.1 (#418)
* replace url()'s with re_path()

* convert re_path to path

* import path instead of re_path

* Convert missed re_path to path

Co-authored-by: Alexandr Artemyev <mogost@gmail.com>
2020-11-18 11:12:27 -05:00
M. Hosseyn Najafi
5d2bbb7092
Respect other classes added by admin templates (#417)
Fixes #365.
2020-11-18 11:11:35 -05:00
Mahdi Namaki
abc532ed18
Create and add fa language translations files (#420) 2020-11-18 11:11:04 -05:00
mandalay-rp
ce37f630f1
changes for get_inconsistent_fieldnames(): fields_list can be a dictionary, when a fieldset is defined as collapsible (#433)
Co-authored-by: dalay <ussria@gmail.com>
2020-11-18 11:10:31 -05:00
andrei-gypaetus
4e5573ed95
issue #404 prevent reset to default for file field (#413) 2020-07-04 16:45:48 -05:00
Camilo Nova
96e95fcfe6 Bump version 2020-06-22 17:37:59 -05:00
Alexandr Artemyev
79db604954
Fix #410 (#412) 2020-06-22 17:31:13 -05:00
Dmitry Kalinin
2eb3fcec8c Added Ukrainian locale 2020-06-15 00:04:30 +03:00
Misha Behersky
9eccfe0386
Add memory backend (#394)
* Add simple backend

* Add test case for simple backend

* Add tests for mget backend method

* Fix redis mock mget implementation

* Make sure memory backend is thread safe

* Add docs section for memory backend

* Add test usage examples to docs

* Update docs for memory backend in testing

* Share memory storage between threads
2020-06-10 19:49:42 +02:00
Pavel Torbeev
6b44a52933
Added a sticky footer (#407)
* Added a sticky footer.

* Returned the semantics to django
2020-06-10 14:21:49 +02:00
Alexandr Artemyev
788616d70c
Add anchors in admin (#402)
* Add anchors in admin

* Fix whitespace

* Add some css
2020-06-09 13:27:52 +02:00
Rotzbua
a304c4f865 add django-constance version to issue template 2020-06-08 18:57:03 +02:00
Alexandr Artemyev
9e6dc03914
Merge pull request #397 from Mogost/issue-396
Normalize newlines from form
2020-06-04 12:23:38 +03:00
utapyngo
73fa362e3f Add test_newlines_normalization 2020-05-26 19:08:17 +07:00
utapyngo
39f762c9c6 Add MULTILINE field to test #396 manually 2020-05-26 18:00:13 +07:00
Alexandr Artemyev
96bd3e1047
Fix #396 2020-05-25 19:24:54 +03:00
Erik Seglem
731514c52f
Switch md5 to sha256. (#395) 2020-05-20 08:28:50 -05:00
Sébastien Corbin
1de764a22c
Read-only form with correct perm (#393) 2020-04-29 12:02:04 -05:00
Sébastien Corbin
540ead383c
Fix #386 Translate app name (#392) 2020-04-29 12:00:47 -05:00
Sébastien Corbin
86933b6774
Update example project for Django>2 (#391) 2020-04-29 12:00:20 -05:00
Sébastien Corbin
3854665637 Put back wheel generation in travis
It was removed by 590fa02eb3
2020-04-20 18:27:41 +02:00
Vladas Tamoshaitis
bd8041c55f
Allow override_config for pytest (#338)
* provides: base override class; unittest and pytest overrides

* raise invalid config error earlier

* update AUTHORS

* avoid AttributeError

* fix comment

* add tests

* fix tests, update docstring

* update docs, improve tests

* fix docs

* fix markdown

* refactor pytest override, use hidden fixture, refactor base and unittest classes

* improve docstring and error

* refactor pytest override to use hooks

* set minimum pytest version

* revert empty lines removal

* introduce pytest test runner for package, refactoring

* WIP

* Finalize tox config, refactor docs, add global fixture

* skip py35

* pytest command: remove unnecessary ignore

* address comments

* Update constance/test/pytest.py

* address comments

* add test for checking nested markers

Co-authored-by: Camilo Nova <camilo.nova@gmail.com>
Co-authored-by: Paweł Zarębski <ppjzarebski@gmail.com>
2020-04-04 11:38:22 -05:00
Ilya Chichak
4de4114bbd
Make groups of fieldsets collapsable. Fix #350 (#351)
* made results table responsive for Django 2 admin

* make fieldsets collapsable

* updated version via feature

* fixed codestyle according to requested changes

* made results table responsive for Django 2 admin

Co-authored-by: Camilo Nova <camilo.nova@axiacore.com>
2020-03-16 11:33:19 -05:00
Camilo Nova
5e91a92431
Allow concurrent calls to set() method (#384) 2020-03-05 18:04:55 -05:00
Martey Dodoo
50287a3b2a
Simplify documentation installation section. (#378)
Make "Installation" section of documentation less fraught by only
including instructions to install django-constance with the default
Redis backend. This will reduce the chances of new users failing to
successfully install the database backend because it needs additional
configuration.

Fixes #375.
2020-02-18 09:24:37 -05:00
Martey Dodoo
12ead8fd1f
Improve grammar of documentation index file. (#377) 2020-02-12 14:23:11 -05:00
Yurchenko Sergey
fa6ae65594
delete south migrations (#371) 2020-01-30 11:56:12 -05:00
Camilo Nova
0ed37e4fce Bump version 2020-01-29 16:33:02 -05:00
Yurchenko Sergey
8f20ca3111 command-to-delete-stale-records (#355)
* command-to-delete-stale-records
2020-01-27 14:24:32 -05:00
Elisey Zanko
b62206da57 Resolve #367: Set pickle protocol version for the Redis backend (#369) 2020-01-14 15:46:27 -05:00
Alexandr Artemyev
590fa02eb3 Drop support py<3.5 django<2.2 (#359)
* Drop support py<3.5 django<2.2

* Remove admin_static
2019-12-23 16:20:41 -05:00
Camilo Nova
886f6d5235 Improve documentation. Fixes #304 2019-12-23 14:17:42 -05:00
Camilo Nova
0e94d13b2a Bump version 2019-12-23 14:07:28 -05:00
Özcan Yarımdunya
288ca7c5e4 Add Turkish language support (#353)
* Add Turkish language
2019-12-23 13:55:58 -05:00
Alexandr Artemyev
917c2790d1
Update AUTHORS
Add myself to authors
2019-11-29 17:24:39 +03:00
Alexandr Artemyev
db3f6e6e2f
Merge pull request #342 from Mogost/fix-341
Fix #341
2019-09-16 15:12:41 +03:00
Alexandr Artemyev
e069ddb91a
Fix #341 2019-09-16 11:51:18 +03:00
Dmitriy Tatarkin
299b3b1996 Fixed "can't compare offset-naive and offset-aware datetimes" (#337)
* Fixed "can't compare offset-naive and offset-aware datetimes" when USE_TZ = True

* Fixed case when `USE_TZ = False`
2019-08-12 11:00:07 -05:00
riazanovslv
98800c0671 ConstanceForm save method tweak (#333)
We need to store the actual name of the file, which the storage's save method provides. Otherwise, if we upload a new file with the same name as the already stored file has, the Constance config will keep a link to the old file.
2019-07-19 13:25:33 -05:00
Kirill Goncharov
1e751f1afa Optimizations for database backend (#329)
* Add test for database backend with query count assertions

* Rewrite set() method of database backend to reduce number of queries
2019-06-08 09:14:58 -05:00
Richard Morrison
2948dff3cc Add a Django system check... (#326)
* Add a Django system check that CONFIG_FIELDSETS accounts for all of CONFIG

* Fix test (don't actually modify values when submitting form) and restore python2 compatibility
2019-04-06 09:13:55 -05:00
Ilya Chichak
d495f1b3bf made results table responsive for Django 2 admin (#325) 2019-03-28 15:04:24 -05:00
Camilo Nova
c965db1bd9 Bump version 2019-03-16 12:25:10 -05:00
Camilo Nova
bb03e1e65a Removed Python 3.4 since is not longer supported 2019-03-16 09:53:08 -05:00
Tyler Kennedy
5e422cfd54 Allow null & blank for PickleField. (#315)
The default behaviour of PickleField was changed from null=True to
null=False. This causes issues with fields such as an image_field which
will try storing a NULL when unset.
2019-03-16 09:47:43 -05:00
Marc-Antoine Lemieux
a31d6f195f Use default_storage to save file (#319) 2019-03-13 19:11:25 -05:00
Moetaz
533c3cb594 fixed "Reset to default" button with constants whose name contains a space. closes #321 (#322) 2019-03-13 19:04:33 -05:00
John Carter
d6ed427c33 Drop Django<1.11 and 2.0, fix tests vs Django 2.2b (#320)
Also added py37, pypy3 to tox, travis
2019-03-11 17:20:08 -05:00
horida
286edca505 show not existing fields in field_list (#309) 2018-10-11 12:18:39 -05:00
Camilo Nova
ce565ecd8a Bump version 2018-09-20 07:31:31 -05:00
Erivânio Vasconcelos
a188c65281 Remove duplicated } (#302) 2018-09-19 18:49:03 -05:00
105 changed files with 4665 additions and 2037 deletions

View file

@ -1,6 +1,9 @@
[run]
source = constance
branch = 1
omit =
*/pytest.py
*/tests/*
[report]
omit = *tests*,*migrations*
omit = *tests*,*migrations*,.tox/*,setup.py,*settings.py

View file

@ -1,12 +1,13 @@
### Describe the problem
Tell us about the problem you're having
Tell us about the problem you're having.
### Steps to reproduce
Tell us how to reproduce it
Tell us how to reproduce it.
### System configuration
* Django version:
* Python version:
* Django-Constance version:

18
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,18 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
commit-message:
prefix: "chore(ci): "
groups:
github-actions:
patterns:
- "*"
open-pull-requests-limit: 1

24
.github/workflows/docs.yml vendored Normal file
View file

@ -0,0 +1,24 @@
name: Docs
on: [push, pull_request]
jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: 'docs/requirements.txt'
- name: Install dependencies
run: pip install -r docs/requirements.txt
- name: Build docs
run: |
cd docs
make html

37
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: Release
on:
push:
tags:
- '*'
jobs:
build:
if: github.repository == 'jazzband/django-constance'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install -U build setuptools twine wheel
- name: Build package
run: |
python -m build
twine check dist/*
- name: Upload packages to Jazzband
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@v1.13.0
with:
user: jazzband
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
repository_url: https://jazzband.co/projects/django-constance/upload

54
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,54 @@
name: Test
on: [push, pull_request]
jobs:
ruff-format:
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- uses: actions/checkout@v6
- uses: chartboost/ruff-action@v1
with:
version: 0.5.0
args: 'format --check'
ruff-lint:
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- uses: actions/checkout@v6
- uses: chartboost/ruff-action@v1
with:
version: 0.5.0
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 5
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade tox tox-gh-actions
- name: Tox tests
run: |
tox -v
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: Python ${{ matrix.python-version }}

3
.gitignore vendored
View file

@ -7,4 +7,7 @@ dist/
test.db
.tox
.coverage
coverage.xml
docs/_build
.idea
constance/_version.py

22
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,22 @@
repos:
- repo: https://github.com/pre-commit/pygrep-hooks
rev: v1.10.0
hooks:
- id: python-check-blanket-noqa
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-merge-conflict
- id: check-yaml
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
hooks:
- id: pyupgrade
args: [ --py38-plus ]
exclude: /migrations/
ci:
autoupdate_schedule: quarterly

18
.readthedocs.yaml Normal file
View file

@ -0,0 +1,18 @@
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
version: 2
build:
os: ubuntu-lts-latest
tools:
python: "3.12"
sphinx:
configuration: docs/conf.py
python:
install:
- requirements: docs/requirements.txt
- method: pip
path: .

View file

@ -1,22 +0,0 @@
sudo: false
language: python
cache: pip
python:
- 2.7
- 3.4
- 3.5
- 3.6
- pypy
install: pip install tox-travis
script: tox
deploy:
provider: pypi
user: jazzband
server: https://jazzband.co/projects/django-constance/upload
distributions: sdist bdist_wheel
password:
secure: fvFbH0oZGYDad2rik7v0L+G4ItH0g/2v8hoBSajeyt/nEyoEShTh2xBwo5413NGkDaIYtYpP/MVqBy02uMc8oSNgh/rS1tIjiIKE77/YJNuZHyQXnZ+90JA+yGaJc5dOyd4G3szEp2Zzi18ov2KkCt37/arObu8bEbChWaEoJqI=
on:
tags: true
repo: jazzband/django-constance
python: 3.6

View file

@ -1,8 +0,0 @@
[main]
host = https://www.transifex.com
lang_map = sr@latin:sr_Latn
[django-constance.main]
file_filter = constance/locale/<lang>/LC_MESSAGES/django.po
source_file = constance/locale/en/LC_MESSAGES/django.po
source_lang = en

View file

@ -1,13 +1,18 @@
Ales Zoulek <ales.zoulek@gmail.com>
Alexander frenzel <alex@relatedworks.com>
Alexander Frenzel <alex@relatedworks.com>
Alexandr Artemyev <mogost@gmail.com>
Bouke Haarsma <bouke@webatoom.nl>
Camilo Nova <camilo.nova@gmail.com>
Charlie Hornsby <charlie.hornsby@hotmail.co.uk>
Curtis Maloney <curtis@tinbrain.net>
Dan Poirier <dpoirier@caktusgroup.com>
David Burke <dmbst32@gmail.com>
Dmitriy Tatarkin <mail@dtatarkin.ru>
Elisey Zanko <elisey.zanko@gmail.com>
Florian Apolloner <florian@apolloner.eu>
Igor Támara <igor@axiacore.com>
Ilya Chichak <ilyachch@gmail.com>
Ivan Klass <klass.ivanklass@gmail.com>
Jake Merdich <jmerdich@users.noreply.github.com>
Jannis Leidel <jannis@leidel.info>
Janusz Harkot <janusz.harkot@gmail.com>
@ -27,10 +32,12 @@ Merijn Bertels <merijn.bertels@gmail.com>
Omer Katz <omer.drow@gmail.com>
Petr Knap <dev@petrknap.cz>
Philip Neustrom <philipn@gmail.com>
Philipp Thumfart <philipp@thumfart.eu>
Pierre-Olivier Marec <pomarec@free.fr>
Roman Krejcik <farin@farin.cz>
Silvan Spross <silvan.spross@gmail.com>
Sławek Ehlert <slafs@op.pl>
Vladas Tamoshaitis <amd.vladas@gmail.com>
Vojtech Jasny <voy@voy.cz>
Yin Jifeng <jifeng.yin@gmail.com>
illumin-us-r3v0lution <luminaries@riseup.net>

3
CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,3 @@
# Django Constance Code of Conduct
The django-constance project utilizes the [Django Commons Code of Conduct](https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md).

View file

@ -1,14 +1,22 @@
Constance - Dynamic Django settings
===================================
.. image:: https://secure.travis-ci.org/jazzband/django-constance.svg
:alt: Build Status
:target: http://travis-ci.org/jazzband/django-constance
.. image:: https://jazzband.co/static/img/badge.svg
:alt: Jazzband
:target: https://jazzband.co/
.. image:: https://img.shields.io/readthedocs/django-constance.svg
:target: https://django-constance.readthedocs.io/
:alt: Documentation
.. image:: https://github.com/jazzband/django-constance/workflows/Test/badge.svg
:target: https://github.com/jazzband/django-constance/actions
:alt: GitHub Actions
.. image:: https://codecov.io/gh/jazzband/django-constance/branch/master/graph/badge.svg
:target: https://codecov.io/gh/jazzband/django-constance
:alt: Coverage
A Django app for storing dynamic settings in pluggable backends (Redis and
Django model backend built in) with an integration with the Django admin app.

View file

@ -1,13 +1,10 @@
from django.utils.functional import LazyObject
__version__ = '2.3.0'
default_app_config = 'constance.apps.ConstanceConfig'
class LazyConfig(LazyObject):
def _setup(self):
from .base import Config
self._wrapped = Config()

View file

@ -1,284 +1,157 @@
import json
from collections import OrderedDict
from datetime import datetime, date, time, timedelta
from decimal import Decimal
from datetime import date
from datetime import datetime
from operator import itemgetter
import hashlib
import os
from django import forms, VERSION
from django import forms
from django import get_version
from django.apps import apps
from django.conf import settings as django_settings
from django.conf.urls import url
from django.contrib import admin, messages
from django.contrib.admin import widgets
from django.contrib import admin
from django.contrib import messages
from django.contrib.admin.options import csrf_protect_m
from django.core.exceptions import PermissionDenied, ImproperlyConfigured
from django.forms import fields
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect
from django.template.response import TemplateResponse
from django.utils import six
from django.utils.encoding import smart_bytes
from django.urls import path
from django.utils.formats import localize
from django.utils.module_loading import import_string
from django.utils.translation import ugettext_lazy as _
from . import LazyConfig, settings
from django.utils.translation import gettext_lazy as _
from . import LazyConfig
from . import settings
from .forms import ConstanceForm
from .utils import get_values
config = LazyConfig()
NUMERIC_WIDGET = forms.TextInput(attrs={'size': 10})
INTEGER_LIKE = (fields.IntegerField, {'widget': NUMERIC_WIDGET})
STRING_LIKE = (fields.CharField, {
'widget': forms.Textarea(attrs={'rows': 3}),
'required': False,
})
FIELDS = {
bool: (fields.BooleanField, {'required': False}),
int: INTEGER_LIKE,
Decimal: (fields.DecimalField, {'widget': NUMERIC_WIDGET}),
str: STRING_LIKE,
datetime: (
fields.SplitDateTimeField, {'widget': widgets.AdminSplitDateTime}
),
timedelta: (
fields.DurationField, {'widget': widgets.AdminTextInputWidget}
),
date: (fields.DateField, {'widget': widgets.AdminDateWidget}),
time: (fields.TimeField, {'widget': widgets.AdminTimeWidget}),
float: (fields.FloatField, {'widget': NUMERIC_WIDGET}),
}
def parse_additional_fields(fields):
for key in fields:
field = list(fields[key])
if len(field) == 1:
field.append({})
field[0] = import_string(field[0])
if 'widget' in field[1]:
klass = import_string(field[1]['widget'])
field[1]['widget'] = klass(
**(field[1].get('widget_kwargs', {}) or {})
)
if 'widget_kwargs' in field[1]:
del field[1]['widget_kwargs']
fields[key] = field
return fields
FIELDS.update(parse_additional_fields(settings.ADDITIONAL_FIELDS))
if not six.PY3:
FIELDS.update({
long: INTEGER_LIKE,
unicode: STRING_LIKE,
})
def get_values():
"""
Get dictionary of values from the backend
:return:
"""
# First load a mapping between config name and default value
default_initial = ((name, options[0])
for name, options in settings.CONFIG.items())
# Then update the mapping with actually values from the backend
initial = dict(default_initial, **dict(config._backend.mget(settings.CONFIG)))
return initial
class ConstanceForm(forms.Form):
version = forms.CharField(widget=forms.HiddenInput)
def __init__(self, initial, *args, **kwargs):
super(ConstanceForm, self).__init__(*args, initial=initial, **kwargs)
version_hash = hashlib.md5()
for name, options in settings.CONFIG.items():
default = options[0]
if len(options) == 3:
config_type = options[2]
if config_type not in settings.ADDITIONAL_FIELDS and not isinstance(default, config_type):
raise ImproperlyConfigured(_("Default value type must be "
"equal to declared config "
"parameter type. Please fix "
"the default value of "
"'%(name)s'.")
% {'name': name})
else:
config_type = type(default)
if config_type not in FIELDS:
raise ImproperlyConfigured(_("Constance doesn't support "
"config values of the type "
"%(config_type)s. Please fix "
"the value of '%(name)s'.")
% {'config_type': config_type,
'name': name})
field_class, kwargs = FIELDS[config_type]
self.fields[name] = field_class(label=name, **kwargs)
version_hash.update(smart_bytes(initial.get(name, '')))
self.initial['version'] = version_hash.hexdigest()
def save(self):
for file_field in self.files:
file = self.cleaned_data[file_field]
file_path = os.path.join(django_settings.MEDIA_ROOT, file.name)
with open(file_path, 'wb+') as destination:
for chunk in file.chunks():
destination.write(chunk)
self.cleaned_data[file_field] = file.name
for name in settings.CONFIG:
if getattr(config, name) != self.cleaned_data[name]:
setattr(config, name, self.cleaned_data[name])
def clean_version(self):
value = self.cleaned_data['version']
if settings.IGNORE_ADMIN_VERSION_CHECK:
return value
if value != self.initial['version']:
raise forms.ValidationError(_('The settings have been modified '
'by someone else. Please reload the '
'form and resubmit your changes.'))
return value
def clean(self):
cleaned_data = super(ConstanceForm, self).clean()
if not settings.CONFIG_FIELDSETS:
return cleaned_data
field_name_list = []
for fieldset_title, fields_list in settings.CONFIG_FIELDSETS.items():
for field_name in fields_list:
field_name_list.append(field_name)
if field_name_list and set(set(settings.CONFIG.keys()) - set(field_name_list)):
raise forms.ValidationError(_('CONSTANCE_CONFIG_FIELDSETS is missing '
'field(s) that exists in CONSTANCE_CONFIG.'))
return cleaned_data
class ConstanceAdmin(admin.ModelAdmin):
change_list_template = 'admin/constance/change_list.html'
change_list_template = "admin/constance/change_list.html"
change_list_form = ConstanceForm
def __init__(self, model, admin_site):
model._meta.concrete_model = Config
super().__init__(model, admin_site)
def get_urls(self):
info = self.model._meta.app_label, self.model._meta.module_name
info = f"{self.model._meta.app_label}_{self.model._meta.module_name}"
return [
url(r'^$',
self.admin_site.admin_view(self.changelist_view),
name='%s_%s_changelist' % info),
url(r'^$',
self.admin_site.admin_view(self.changelist_view),
name='%s_%s_add' % info),
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"),
]
def get_config_value(self, name, options, form, initial):
default, help_text = options[0], options[1]
field_type = None
if len(options) == 3:
field_type = options[2]
# First try to load the value from the actual backend
value = initial.get(name)
# Then if the returned value is None, get the default
if value is None:
value = getattr(config, name)
form_field = form[name]
config_value = {
'name': name,
'default': localize(default),
'raw_default': default,
'help_text': _(help_text),
'value': localize(value),
'modified': localize(value) != localize(default),
'form_field': form[name],
'is_date': isinstance(default, date),
'is_datetime': isinstance(default, datetime),
'is_checkbox': isinstance(form[name].field.widget, forms.CheckboxInput),
'is_file': isinstance(form[name].field.widget, forms.FileInput),
"name": name,
"default": localize(default),
"raw_default": default,
"help_text": _(help_text),
"value": localize(value),
"modified": localize(value) != localize(default),
"form_field": form_field,
"is_date": isinstance(default, date),
"is_datetime": isinstance(default, datetime),
"is_checkbox": isinstance(form_field.field.widget, forms.CheckboxInput),
"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:
serialized_default = form[name].field.prepare_value(default)
config_value["default"] = serialized_default
config_value["raw_default"] = serialized_default
config_value["value"] = form[name].field.prepare_value(value)
return config_value
def get_changelist_form(self, request):
"""
Returns a Form class for use in the changelist_view.
"""
"""Returns a Form class for use in the changelist_view."""
# Defaults to self.change_list_form in order to preserve backward
# compatibility
return self.change_list_form
@csrf_protect_m
def changelist_view(self, request, extra_context=None):
if not self.has_change_permission(request, None):
if not self.has_view_or_change_permission(request):
raise PermissionDenied
initial = get_values()
form_cls = self.get_changelist_form(request)
form = form_cls(initial=initial)
if request.method == 'POST':
form = form_cls(
data=request.POST, files=request.FILES, initial=initial
)
form = form_cls(initial=initial, request=request)
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)
if form.is_valid():
form.save()
messages.add_message(
request,
messages.SUCCESS,
_('Live settings updated successfully.'),
)
return HttpResponseRedirect('.')
context = dict(
self.admin_site.each_context(request),
config_values=[],
title=self.model._meta.app_config.verbose_name,
app_label='constance',
opts=self.model._meta,
form=form,
media=self.media + form.media,
icon_type='gif' if VERSION < (1, 9) else 'svg',
)
messages.add_message(request, messages.SUCCESS, _("Live settings updated successfully."))
return HttpResponseRedirect(".")
messages.add_message(request, messages.ERROR, _("Failed to update live settings."))
context = {
**self.admin_site.each_context(request),
**(extra_context or {}),
"config_values": [],
"title": self.model._meta.app_config.verbose_name,
"app_label": "constance",
"opts": self.model._meta,
"form": form,
"media": self.media + form.media,
"icon_type": "svg",
"django_version": get_version(),
}
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:
context['fieldsets'] = []
for fieldset_title, fields_list in settings.CONFIG_FIELDSETS.items():
fields_exist = all(field in settings.CONFIG for field in fields_list)
assert fields_exist, "CONSTANCE_CONFIG_FIELDSETS contains field(s) that does not exist"
if isinstance(settings.CONFIG_FIELDSETS, dict):
fieldset_items = settings.CONFIG_FIELDSETS.items()
else:
fieldset_items = settings.CONFIG_FIELDSETS
context["fieldsets"] = []
for fieldset_title, fieldset_data in fieldset_items:
if isinstance(fieldset_data, dict):
fields_list = fieldset_data["fields"]
collapse = fieldset_data.get("collapse", False)
else:
fields_list = fieldset_data
collapse = False
absent_fields = [field for field in fields_list if field not in settings.CONFIG]
if any(absent_fields):
raise ValueError(
"CONSTANCE_CONFIG_FIELDSETS contains field(s) that does not exist(s): {}".format(
", ".join(absent_fields)
)
)
config_values = []
for name in fields_list:
options = settings.CONFIG.get(name)
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}
context['fieldsets'].append({
'title': fieldset_title,
'config_values': config_values
})
if not isinstance(settings.CONFIG_FIELDSETS, OrderedDict):
context['fieldsets'].sort(key=itemgetter('title'))
if collapse:
fieldset_context["collapse"] = True
context["fieldsets"].append(fieldset_context)
if not isinstance(settings.CONFIG_FIELDSETS, (OrderedDict, tuple)):
context["fieldsets"].sort(key=itemgetter("title"))
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
return TemplateResponse(request, self.change_list_template, context)
@ -291,23 +164,25 @@ class ConstanceAdmin(admin.ModelAdmin):
def has_change_permission(self, request, obj=None):
if settings.SUPERUSER_ONLY:
return request.user.is_superuser
return super(ConstanceAdmin, self).has_change_permission(request, obj)
return super().has_change_permission(request, obj)
class Config(object):
class Meta(object):
app_label = 'constance'
object_name = 'Config'
model_name = module_name = 'config'
verbose_name_plural = _('config')
class Config:
class Meta:
app_label = "constance"
object_name = "Config"
concrete_model = None
model_name = module_name = "config"
verbose_name_plural = _("config")
abstract = False
swapped = False
is_composite_pk = False
def get_ordered_objects(self):
return False
def get_change_permission(self):
return 'change_%s' % self.model_name
return f"change_{self.model_name}"
@property
def app_config(self):
@ -315,11 +190,11 @@ class Config(object):
@property
def label(self):
return '%s.%s' % (self.app_label, self.object_name)
return f"{self.app_label}.{self.object_name}"
@property
def label_lower(self):
return '%s.%s' % (self.app_label, self.model_name)
return f"{self.app_label}.{self.model_name}"
_meta = Meta()

View file

@ -1,35 +1,14 @@
from django.db.models import signals
from django.apps import AppConfig
from django.core import checks
from django.utils.translation import gettext_lazy as _
from constance.checks import check_fieldsets
class ConstanceConfig(AppConfig):
name = 'constance'
verbose_name = 'Constance'
name = "constance"
verbose_name = _("Constance")
default_auto_field = "django.db.models.AutoField"
def ready(self):
super(ConstanceConfig, self).ready()
signals.post_migrate.connect(self.create_perm,
dispatch_uid='constance.create_perm')
def create_perm(self, using=None, *args, **kwargs):
"""
Creates a fake content type and permission
to be able to check for permissions
"""
from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
constance_dbs = getattr(settings, 'CONSTANCE_DBS', None)
if constance_dbs is not None and using not in constance_dbs:
return
if ContentType._meta.installed and Permission._meta.installed:
content_type, created = ContentType.objects.using(using).get_or_create(
app_label='constance',
model='config',
)
permission, created = Permission.objects.using(using).get_or_create(
content_type=content_type,
codename='change_config',
defaults={'name': 'Can change config'})
checks.register(check_fieldsets, "constance")

View file

@ -1,26 +1,50 @@
"""
Defines the base constance backend
"""
"""Defines the base constance backend."""
from abc import ABC
from abc import abstractmethod
class Backend(object):
class Backend(ABC):
@abstractmethod
def get(self, key):
"""
Get the key from the backend store and return the value.
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):
"""
Get the keys from the backend store and return a list of the values.
Return an empty list if not found.
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.
"""
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):
"""
Add the value to the backend store given the key.
"""
raise NotImplementedError
"""Add the value to the backend store given the key."""
...
@abstractmethod
async def aset(self, key, value):
"""Add the value to the backend store given the key."""
...

View file

@ -0,0 +1,176 @@
from django.core.cache import caches
from django.core.cache.backends.locmem import LocMemCache
from django.core.exceptions import ImproperlyConfigured
from django.db import IntegrityError
from django.db import OperationalError
from django.db import ProgrammingError
from django.db import transaction
from django.db.models.signals import post_save
from constance import config
from constance import settings
from constance import signals
from constance.backends import Backend
from constance.codecs import dumps
from constance.codecs import loads
class DatabaseBackend(Backend):
def __init__(self):
from constance.models import Constance
self._model = Constance
self._prefix = settings.DATABASE_PREFIX
self._autofill_timeout = settings.DATABASE_CACHE_AUTOFILL_TIMEOUT
self._autofill_cachekey = "autofilled"
if self._model._meta.app_config is None:
raise ImproperlyConfigured(
"The constance.backends.database app isn't installed "
"correctly. Make sure it's in your INSTALLED_APPS setting."
)
if settings.DATABASE_CACHE_BACKEND:
self._cache = caches[settings.DATABASE_CACHE_BACKEND]
if isinstance(self._cache, LocMemCache):
raise ImproperlyConfigured(
"The CONSTANCE_DATABASE_CACHE_BACKEND setting refers to a "
f"subclass of Django's local-memory backend ({settings.DATABASE_CACHE_BACKEND!r}). Please "
"set it to a backend that supports cross-process caching."
)
else:
self._cache = None
self.autofill()
# Clear simple cache.
post_save.connect(self.clear, sender=self._model)
def add_prefix(self, key):
return f"{self._prefix}{key}"
def autofill(self):
if not self._autofill_timeout or not self._cache:
return
full_cachekey = self.add_prefix(self._autofill_cachekey)
if self._cache.get(full_cachekey):
return
autofill_values = {full_cachekey: 1}
for key, value in self.mget(settings.CONFIG).items():
autofill_values[self.add_prefix(key)] = value
self._cache.set_many(autofill_values, timeout=self._autofill_timeout)
def mget(self, keys):
result = {}
if not keys:
return result
keys = {self.add_prefix(key): key for key in keys}
try:
stored = self._model._default_manager.filter(key__in=keys)
for const in stored:
result[keys[const.key]] = loads(const.value)
except (OperationalError, ProgrammingError):
pass
return result
def get(self, key):
key = self.add_prefix(key)
value = None
if self._cache:
value = self._cache.get(key)
if value is None:
self.autofill()
value = self._cache.get(key)
if value is None:
match = self._model._default_manager.filter(key=key).only("value").first()
if match:
value = loads(match.value)
if self._cache:
self._cache.add(key, 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):
key = self.add_prefix(key)
created = False
queryset = self._model._default_manager.all()
# Set _for_write attribute as get_or_create method does
# https://github.com/django/django/blob/2.2.11/django/db/models/query.py#L536
queryset._for_write = True
try:
constance = queryset.get(key=key)
except (OperationalError, ProgrammingError):
# database is not created, noop
return
except self._model.DoesNotExist:
try:
with transaction.atomic(using=queryset.db):
queryset.create(key=key, value=dumps(value))
created = True
except IntegrityError:
# Allow concurrent writes
constance = queryset.get(key=key)
if not created:
old_value = loads(constance.value)
constance.value = dumps(value)
constance.save(update_fields=["value"])
else:
old_value = None
if self._cache:
self._cache.set(key, 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):
if self._cache and not created:
keys = [self.add_prefix(k) for k in settings.CONFIG]
keys.append(self.add_prefix(self._autofill_cachekey))
self._cache.delete_many(keys)
self.autofill()

View file

@ -1,109 +0,0 @@
from django.core.cache import caches
from django.core.cache.backends.locmem import LocMemCache
from django.core.exceptions import ImproperlyConfigured
from django.db import OperationalError, ProgrammingError
from django.db.models.signals import post_save
from .. import Backend
from ... import settings, signals, config
class DatabaseBackend(Backend):
def __init__(self):
from .models import Constance
self._model = Constance
self._prefix = settings.DATABASE_PREFIX
self._autofill_timeout = settings.DATABASE_CACHE_AUTOFILL_TIMEOUT
self._autofill_cachekey = 'autofilled'
if not self._model._meta.installed:
raise ImproperlyConfigured(
"The constance.backends.database app isn't installed "
"correctly. Make sure it's in your INSTALLED_APPS setting.")
if settings.DATABASE_CACHE_BACKEND:
self._cache = caches[settings.DATABASE_CACHE_BACKEND]
if isinstance(self._cache, LocMemCache):
raise ImproperlyConfigured(
"The CONSTANCE_DATABASE_CACHE_BACKEND setting refers to a "
"subclass of Django's local-memory backend (%r). Please "
"set it to a backend that supports cross-process caching."
% settings.DATABASE_CACHE_BACKEND)
else:
self._cache = None
self.autofill()
# Clear simple cache.
post_save.connect(self.clear, sender=self._model)
def add_prefix(self, key):
return "%s%s" % (self._prefix, key)
def autofill(self):
if not self._autofill_timeout or not self._cache:
return
full_cachekey = self.add_prefix(self._autofill_cachekey)
if self._cache.get(full_cachekey):
return
autofill_values = {}
autofill_values[full_cachekey] = 1
for key, value in self.mget(settings.CONFIG):
autofill_values[self.add_prefix(key)] = value
self._cache.set_many(autofill_values, timeout=self._autofill_timeout)
def mget(self, keys):
if not keys:
return
keys = {self.add_prefix(key): key for key in keys}
try:
stored = self._model._default_manager.filter(key__in=keys)
for const in stored:
yield keys[const.key], const.value
except (OperationalError, ProgrammingError):
pass
def get(self, key):
key = self.add_prefix(key)
if self._cache:
value = self._cache.get(key)
if value is None:
self.autofill()
value = self._cache.get(key)
else:
value = None
if value is None:
try:
value = self._model._default_manager.get(key=key).value
except (OperationalError, ProgrammingError, self._model.DoesNotExist):
pass
else:
if self._cache:
self._cache.add(key, value)
return value
def set(self, key, value):
old_value = self.get(key)
try:
constance, created = self._model._default_manager.get_or_create(
key=self.add_prefix(key), defaults={'value': value}
)
except (OperationalError, ProgrammingError):
# database is not created, noop
return
if not created:
constance.value = value
constance.save()
if self._cache:
self._cache.set(key, value)
signals.config_updated.send(
sender=config, key=key, old_value=old_value, new_value=value
)
def clear(self, sender, instance, created, **kwargs):
if self._cache and not created:
keys = [self.add_prefix(k) for k in settings.CONFIG]
keys.append(self.add_prefix(self._autofill_cachekey))
self._cache.delete_many(keys)
self.autofill()

View file

@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import picklefield.fields
class Migration(migrations.Migration):
dependencies = []
operations = [
migrations.CreateModel(
name='Constance',
fields=[
('id', models.AutoField(verbose_name='ID', primary_key=True,
auto_created=True, serialize=False)),
('key', models.CharField(unique=True, max_length=255)),
('value', picklefield.fields.PickledObjectField(editable=False)),
],
options={
'verbose_name': 'constance',
'verbose_name_plural': 'constances',
'db_table': 'constance_config',
},
bases=(models.Model,),
),
]

View file

@ -1,24 +0,0 @@
from django.db import models
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import ugettext_lazy as _
try:
from picklefield import PickledObjectField
except ImportError:
raise ImproperlyConfigured("Couldn't find the the 3rd party app "
"django-picklefield which is required for "
"the constance database backend.")
class Constance(models.Model):
key = models.CharField(max_length=255, unique=True)
value = PickledObjectField()
class Meta:
verbose_name = _('constance')
verbose_name_plural = _('constances')
db_table = 'constance_config'
def __unicode__(self):
return self.key

View file

@ -1,33 +0,0 @@
# -*- coding: utf-8 -*-
from south.db import db
from south.v2 import SchemaMigration
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'Constance'
db.create_table('constance_config', (
('id', self.gf('django.db.models.fields.AutoField')(
primary_key=True)),
('key', self.gf('django.db.models.fields.TextField')()),
('value', self.gf('picklefield.fields.PickledObjectField')()),
))
db.send_create_signal('database', ['Constance'])
def backwards(self, orm):
# Deleting model 'Constance'
db.delete_table('constance_config')
models = {
'database.constance': {
'Meta': {'object_name': 'Constance',
'db_table': "'constance_config'"},
'id': ('django.db.models.fields.AutoField', [],
{'primary_key': 'True'}),
'key': ('django.db.models.fields.TextField', [], {}),
'value': ('picklefield.fields.PickledObjectField', [], {})
}
}
complete_apps = ['database']

View file

@ -1,36 +0,0 @@
# -*- coding: utf-8 -*-
from south.db import db
from south.v2 import SchemaMigration
class Migration(SchemaMigration):
def forwards(self, orm):
# Changing field 'Constance.key'
db.alter_column('constance_config', 'key',
self.gf('django.db.models.fields.CharField')(
max_length=255))
# Adding unique constraint on 'Constance', fields ['key']
db.create_unique('constance_config', ['key'])
def backwards(self, orm):
# Removing unique constraint on 'Constance', fields ['key']
db.delete_unique('constance_config', ['key'])
# Changing field 'Constance.key'
db.alter_column('constance_config', 'key',
self.gf('django.db.models.fields.TextField')())
models = {
'database.constance': {
'Meta': {'object_name': 'Constance',
'db_table': "'constance_config'"},
'id': ('django.db.models.fields.AutoField', [],
{'primary_key': 'True'}),
'key': ('django.db.models.fields.CharField', [],
{'unique': 'True', 'max_length': '255'}),
'value': ('picklefield.fields.PickledObjectField', [], {})
}
}
complete_apps = ['database']

View file

@ -0,0 +1,46 @@
from threading import Lock
from constance import config
from constance import signals
from . import Backend
class MemoryBackend(Backend):
"""Simple in-memory backend that should be mostly used for testing purposes."""
_storage = {}
_lock = Lock()
def __init__(self):
super().__init__()
def get(self, key):
with self._lock:
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):
if not keys:
return {}
with self._lock:
return {key: self._storage[key] for key in keys if key in self._storage}
async def amget(self, keys):
if not keys:
return {}
with self._lock:
return {key: self._storage[key] for key in keys if key in self._storage}
def set(self, key, value):
with self._lock:
old_value = self._storage.get(key)
self._storage[key] = 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,37 +1,62 @@
import asyncio
from threading import RLock
from time import monotonic
from django.core.exceptions import ImproperlyConfigured
from django.utils import six
from django.utils.six.moves import zip
from . import Backend
from .. import settings, utils, signals, config
try:
from cPickle import loads, dumps
except ImportError:
from pickle import loads, dumps
from constance import config
from constance import settings
from constance import signals
from constance import utils
from constance.backends import Backend
from constance.codecs import dumps
from constance.codecs import loads
class RedisBackend(Backend):
def __init__(self):
super(RedisBackend, self).__init__()
super().__init__()
self._prefix = settings.REDIS_PREFIX
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)()
else:
try:
import redis
except ImportError:
raise ImproperlyConfigured(
"The Redis backend requires redis-py to be installed.")
if isinstance(settings.REDIS_CONNECTION, six.string_types):
self._rd = redis.from_url(settings.REDIS_CONNECTION)
raise ImproperlyConfigured("The Redis backend requires redis-py to be installed.") from None
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):
return "%s%s" % (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):
value = self._rd.get(self.add_prefix(key))
@ -39,17 +64,156 @@ class RedisBackend(Backend):
return loads(value)
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):
if not keys:
return
return {}
prefixed_keys = [self.add_prefix(key) for key in keys]
for key, value in zip(keys, self._rd.mget(prefixed_keys)):
if value:
yield key, loads(value)
return {key: loads(value) for key, value in zip(keys, self._rd.mget(prefixed_keys)) if 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):
old_value = self.get(key)
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):
_sentinel = object()
_lock = RLock()
_async_lock = None # Lazy-initialized asyncio.Lock
def __init__(self):
super().__init__()
self._timeout = settings.REDIS_CACHE_TIMEOUT
self._cache = {}
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):
return value[0] <= monotonic()
def _cache_value(self, key, new_value):
self._cache[key] = (monotonic() + self._timeout, new_value)
def get(self, key):
value = self._cache.get(key, self._sentinel)
if value is self._sentinel or self._has_expired(value):
with self._lock:
new_value = super().get(key)
self._cache_value(key, new_value)
return new_value
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):
with self._lock:
super().set(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):
if not keys:
return {}
result = {}
for key in keys:
value = self.get(key)
if value is not None:
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,32 +1,149 @@
from . import settings, utils
import asyncio
import warnings
from . import settings
from . import utils
class Config(object):
"""
The global config wrapper that handles the backend.
"""
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:
"""The global config wrapper that handles the backend."""
def __init__(self):
super(Config, self).__setattr__('_backend',
utils.import_module_attr(settings.BACKEND)())
super().__setattr__("_backend", utils.import_module_attr(settings.BACKEND)())
def __getattr__(self, key):
try:
if not len(settings.CONFIG[key]) in (2, 3):
raise AttributeError(key)
default = settings.CONFIG[key][0]
except KeyError:
raise AttributeError(key)
def _get_sync_value(self, key, default):
result = self._backend.get(key)
if result is None:
result = default
setattr(self, key, default)
return result
return result
def __getattr__(self, key):
if key == "_backend":
return super().__getattribute__(key)
try:
if len(settings.CONFIG[key]) not in (2, 3):
raise AttributeError(key)
default = settings.CONFIG[key][0]
except KeyError as e:
raise AttributeError(key) from e
try:
asyncio.get_running_loop()
except RuntimeError:
return self._get_sync_value(key, default)
return AsyncValueProxy(key, self, default)
def __setattr__(self, key, value):
if key == "_backend":
super().__setattr__(key, value)
return
if key not in settings.CONFIG:
raise AttributeError(key)
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):
return settings.CONFIG.keys()

64
constance/checks.py Normal file
View file

@ -0,0 +1,64 @@
from __future__ import annotations
from django.core import checks
from django.core.checks import CheckMessage
from django.utils.translation import gettext_lazy as _
def check_fieldsets(*args, **kwargs) -> list[CheckMessage]:
"""
A Django system check to make sure that, if defined,
CONFIG_FIELDSETS is consistent with settings.CONFIG.
"""
from . import settings
errors = []
if hasattr(settings, "CONFIG_FIELDSETS") and settings.CONFIG_FIELDSETS:
missing_keys, extra_keys = get_inconsistent_fieldnames()
if missing_keys:
check = checks.Warning(
_("CONSTANCE_CONFIG_FIELDSETS is missing field(s) that exists in CONSTANCE_CONFIG."),
hint=", ".join(sorted(missing_keys)),
obj="settings.CONSTANCE_CONFIG",
id="constance.E001",
)
errors.append(check)
if extra_keys:
check = checks.Warning(
_("CONSTANCE_CONFIG_FIELDSETS contains extra field(s) that does not exist in CONFIG."),
hint=", ".join(sorted(extra_keys)),
obj="settings.CONSTANCE_CONFIG",
id="constance.E002",
)
errors.append(check)
return errors
def get_inconsistent_fieldnames() -> tuple[set, set]:
"""
Returns a pair of values:
1) set of keys from settings.CONFIG that are not accounted for in settings.CONFIG_FIELDSETS
2) set of keys from settings.CONFIG_FIELDSETS that are not present in settings.CONFIG
If there are no fieldnames in settings.CONFIG_FIELDSETS, returns an empty set.
"""
from . import settings
if isinstance(settings.CONFIG_FIELDSETS, dict):
fieldset_items = settings.CONFIG_FIELDSETS.items()
else:
fieldset_items = settings.CONFIG_FIELDSETS
unique_field_names = set()
for _fieldset_title, fields_list in fieldset_items:
# fields_list can be a dictionary, when a fieldset is defined as collapsible
# https://django-constance.readthedocs.io/en/latest/#fieldsets-collapsing
if isinstance(fields_list, dict) and "fields" in fields_list:
fields_list = fields_list["fields"]
unique_field_names.update(fields_list)
if not unique_field_names:
return unique_field_names, unique_field_names
config_keys = set(settings.CONFIG.keys())
missing_keys = config_keys - unique_field_names
extra_keys = unique_field_names - config_keys
return missing_keys, extra_keys

101
constance/codecs.py Normal file
View file

@ -0,0 +1,101 @@
from __future__ import annotations
import json
import logging
import uuid
from datetime import date
from datetime import datetime
from datetime import time
from datetime import timedelta
from decimal import Decimal
from typing import Any
from typing import Protocol
from typing import TypeVar
logger = logging.getLogger(__name__)
DEFAULT_DISCRIMINATOR = "default"
class JSONEncoder(json.JSONEncoder):
"""Django-constance custom json encoder."""
def default(self, o):
for discriminator, (t, _, encoder) in _codecs.items():
if isinstance(o, t):
return _as(discriminator, encoder(o))
raise TypeError(f"Object of type {o.__class__.__name__} is not JSON serializable")
def _as(discriminator: str, v: Any) -> dict[str, Any]:
return {"__type__": discriminator, "__value__": v}
def dumps(obj, _dumps=json.dumps, cls=JSONEncoder, default_kwargs=None, **kwargs):
"""Serialize object to json string."""
default_kwargs = default_kwargs or {}
is_default_type = isinstance(obj, (list, dict, str, int, bool, float, type(None)))
return _dumps(
_as(DEFAULT_DISCRIMINATOR, obj) if is_default_type else obj, cls=cls, **dict(default_kwargs, **kwargs)
)
def loads(s, _loads=json.loads, *, first_level=True, **kwargs):
"""Deserialize json string to object."""
if first_level:
return _loads(s, object_hook=object_hook, **kwargs)
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()}
if isinstance(s, list):
return list(loads(v, first_level=False) for v in s)
return _loads(s, object_hook=object_hook, **kwargs)
def object_hook(o: dict) -> Any:
"""Hook function to perform custom deserialization."""
if o.keys() == {"__type__", "__value__"}:
if o["__type__"] == DEFAULT_DISCRIMINATOR:
return o["__value__"]
codec = _codecs.get(o["__type__"])
if not codec:
raise ValueError(f"Unsupported type: {o['__type__']}")
return codec[1](o["__value__"])
if "__type__" not in o and "__value__" not in o:
return o
logger.error("Cannot deserialize object: %s", o)
raise ValueError(f"Invalid object: {o}")
T = TypeVar("T")
class Encoder(Protocol[T]):
def __call__(self, value: T, /) -> str: ... # pragma: no cover
class Decoder(Protocol[T]):
def __call__(self, value: str, /) -> T: ... # pragma: no cover
def register_type(t: type[T], discriminator: str, encoder: Encoder[T], decoder: Decoder[T]):
if not discriminator:
raise ValueError("Discriminator must be specified")
if _codecs.get(discriminator) or discriminator == DEFAULT_DISCRIMINATOR:
raise ValueError(f"Type with discriminator {discriminator} is already registered")
_codecs[discriminator] = (t, decoder, encoder)
_codecs: dict[str, tuple[type, Decoder, Encoder]] = {}
def _register_default_types():
# NOTE: datetime should be registered before date, because datetime is also instance of date.
register_type(datetime, "datetime", datetime.isoformat, datetime.fromisoformat)
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(Decimal, "decimal", str, Decimal)
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_default_types()

168
constance/forms.py Normal file
View file

@ -0,0 +1,168 @@
import hashlib
from datetime import date
from datetime import datetime
from datetime import time
from datetime import timedelta
from decimal import Decimal
from os.path import join
from django import conf
from django import forms
from django.contrib import messages
from django.contrib.admin import widgets
from django.core.exceptions import ImproperlyConfigured
from django.core.files.storage import default_storage
from django.forms import fields
from django.utils import timezone
from django.utils.encoding import smart_bytes
from django.utils.module_loading import import_string
from django.utils.text import normalize_newlines
from django.utils.translation import gettext_lazy as _
from . import LazyConfig
from . import settings
from .checks import get_inconsistent_fieldnames
config = LazyConfig()
NUMERIC_WIDGET = forms.TextInput(attrs={"size": 10})
INTEGER_LIKE = (fields.IntegerField, {"widget": NUMERIC_WIDGET})
STRING_LIKE = (
fields.CharField,
{
"widget": forms.Textarea(attrs={"rows": 3}),
"required": False,
},
)
FIELDS = {
bool: (fields.BooleanField, {"required": False}),
int: INTEGER_LIKE,
Decimal: (fields.DecimalField, {"widget": NUMERIC_WIDGET}),
str: STRING_LIKE,
datetime: (fields.SplitDateTimeField, {"widget": widgets.AdminSplitDateTime}),
timedelta: (fields.DurationField, {"widget": widgets.AdminTextInputWidget}),
date: (fields.DateField, {"widget": widgets.AdminDateWidget}),
time: (fields.TimeField, {"widget": widgets.AdminTimeWidget}),
float: (fields.FloatField, {"widget": NUMERIC_WIDGET}),
}
def parse_additional_fields(fields):
for key in fields:
field = list(fields[key])
if len(field) == 1:
field.append({})
field[0] = import_string(field[0])
if "widget" in field[1]:
klass = import_string(field[1]["widget"])
field[1]["widget"] = klass(**(field[1].get("widget_kwargs", {}) or {}))
if "widget_kwargs" in field[1]:
del field[1]["widget_kwargs"]
fields[key] = field
return fields
FIELDS.update(parse_additional_fields(settings.ADDITIONAL_FIELDS))
class ConstanceForm(forms.Form):
version = forms.CharField(widget=forms.HiddenInput)
def __init__(self, initial, request=None, *args, **kwargs):
super().__init__(*args, initial=initial, **kwargs)
version_hash = hashlib.sha256()
only_view = request and not request.user.has_perm("constance.change_config")
if only_view:
messages.warning(
request,
_("You don't have permission to change these values"),
)
for name, options in settings.CONFIG.items():
default = options[0]
if len(options) == 3:
config_type = options[2]
if config_type not in settings.ADDITIONAL_FIELDS and not isinstance(default, config_type):
raise ImproperlyConfigured(
_(
"Default value type must be "
"equal to declared config "
"parameter type. Please fix "
"the default value of "
"'%(name)s'."
)
% {"name": name}
)
else:
config_type = type(default)
if config_type not in FIELDS:
raise ImproperlyConfigured(
_(
"Constance doesn't support "
"config values of the type "
"%(config_type)s. Please fix "
"the value of '%(name)s'."
)
% {"config_type": config_type, "name": name}
)
field_class, kwargs = FIELDS[config_type]
if only_view:
kwargs["disabled"] = True
self.fields[name] = field_class(label=name, **kwargs)
version_hash.update(smart_bytes(initial.get(name, "")))
self.initial["version"] = version_hash.hexdigest()
def save(self):
for file_field in self.files:
file = self.cleaned_data[file_field]
self.cleaned_data[file_field] = default_storage.save(join(settings.FILE_ROOT, file.name), file)
for name in settings.CONFIG:
current = getattr(config, name)
new = self.cleaned_data[name]
if isinstance(new, str):
new = normalize_newlines(new)
if conf.settings.USE_TZ and isinstance(current, datetime) and not timezone.is_aware(current):
current = timezone.make_aware(current)
if current != new:
setattr(config, name, new)
def clean_version(self):
value = self.cleaned_data["version"]
if settings.IGNORE_ADMIN_VERSION_CHECK:
return value
if value != self.initial["version"]:
raise forms.ValidationError(
_("The settings have been modified by someone else. Please reload the form and resubmit your changes.")
)
return value
def clean(self):
cleaned_data = super().clean()
if not settings.CONFIG_FIELDSETS:
return cleaned_data
missing_keys, extra_keys = get_inconsistent_fieldnames()
if missing_keys or extra_keys:
raise forms.ValidationError(
_("CONSTANCE_CONFIG_FIELDSETS is missing field(s) that exists in CONSTANCE_CONFIG.")
)
return cleaned_data

Binary file not shown.

View file

@ -0,0 +1,100 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <med.b.makhlouf@gmail.com>, 2020.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-06-13 19:40+0530\n"
"PO-Revision-Date: 2020-11-30 23:15+0100\n"
"Language: ar\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Last-Translator: \n"
"Language-Team: \n"
"X-Generator: \n"
#: admin.py:113
#, python-format
msgid ""
"Default value type must be equal to declared config parameter type. Please "
"fix the default value of '%(name)s'."
msgstr ""
"يجب أن يكون نوع القيمة الافتراضي مساوياً لنوع معلمة التكوين المعلن. الرجاء "
"إصلاح القيمة الافتراضية لـ '%(name)s'."
#: admin.py:123
#, python-format
msgid ""
"Constance doesn't support config values of the type %(config_type)s. Please "
"fix the value of '%(name)s'."
msgstr ""
"لا يعتمد كونستانس قيم التكوين من النوع %(config_type)s. الرجاء إصلاح قيمة "
"'%(name)s'."
#: admin.py:147
msgid ""
"The settings have been modified by someone else. Please reload the form and "
"resubmit your changes."
msgstr ""
"تم تعديل الإعدادات بواسطة شخص آخر. الرجاء إعادة تحميل النموذج وإعادة إرسال "
"التغييرات."
#: admin.py:160
msgid ""
"CONSTANCE_CONFIG_FIELDSETS does not contain fields that exist in "
"CONSTANCE_CONFIG."
msgstr ""
"لا يحتوي CONSTANCE_CONFIG_FIELDSETS على حقول موجودة في CONSTANCE_CONFIG."
#: admin.py:224
msgid "Live settings updated successfully."
msgstr "تم تحديث الإعدادات المباشرة بنجاح."
#: admin.py:285
msgid "config"
msgstr "التكوين"
#: apps.py:8
msgid "Constance"
msgstr "كونستانس"
#: backends/database/models.py:19
msgid "constance"
msgstr "كونستانس"
#: backends/database/models.py:20
msgid "constances"
msgstr "كونستانس"
#: management/commands/constance.py:30
msgid "Get/Set In-database config settings handled by Constance"
msgstr ""
"الحصول على / تعيين إعدادات التكوين في قاعدة البيانات التي تعالجها كونستانس"
#: templates/admin/constance/change_list.html:75
msgid "Save"
msgstr "حفظ"
#: templates/admin/constance/change_list.html:84
msgid "Home"
msgstr "الصفحة الرئيسية"
#: templates/admin/constance/includes/results_list.html:5
msgid "Name"
msgstr "الإسم"
#: templates/admin/constance/includes/results_list.html:6
msgid "Default"
msgstr "افتراضي"
#: templates/admin/constance/includes/results_list.html:7
msgid "Value"
msgstr "القيمة"
#: templates/admin/constance/includes/results_list.html:8
msgid "Is modified"
msgstr "تم تعديله"

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-06-13 19:40+0530\n"
"POT-Creation-Date: 2022-07-19 21:00+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -47,6 +47,10 @@ msgstr ""
msgid "Live settings updated successfully."
msgstr ""
#: admin.py:267
msgid "Failed to update live settings."
msgstr ""
#: admin.py:285
msgid "config"
msgstr ""

Binary file not shown.

View file

@ -0,0 +1,104 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-06-13 19:40+0530\n"
"PO-Revision-Date: 2020-09-24 17:33+0330\n"
"Language: fa\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Last-Translator: Mehdi Namaki <mavenium@gmail.com>\n"
"Language-Team: \n"
"X-Generator: Poedit 2.3\n"
#: admin.py:113
#, python-format
msgid ""
"Default value type must be equal to declared config parameter type. Please "
"fix the default value of '%(name)s'."
msgstr ""
"نوع مقدار پیش فرض باید برابر با نوع پارامتر پیکربندی اعلام شده باشد. لطفاً "
"مقدار پیش فرض '%(name)s' را اصلاح کنید."
#: admin.py:123
#, python-format
msgid ""
"Constance doesn't support config values of the type %(config_type)s. Please "
"fix the value of '%(name)s'."
msgstr ""
"تنظیمات مقادیر پیکربندی از نوع %(config_type)s را پشتیبانی نمی کند. لطفاً "
"مقدار '%(name)s' را اصلاح کنید."
#: admin.py:147
msgid ""
"The settings have been modified by someone else. Please reload the form and "
"resubmit your changes."
msgstr ""
"تنظیمات توسط شخص دیگری تغییر یافته است. لطفاً فرم را بارگیری کنید و تغییرات "
"خود را دوباره ارسال کنید."
#: admin.py:160
msgid ""
"CONSTANCE_CONFIG_FIELDSETS does not contain fields that exist in "
"CONSTANCE_CONFIG."
msgstr "CONSTANCE_CONFIG_FIELDSETS شامل فیلدهای CONSTANCE_CONFIG نیست."
#: admin.py:224
msgid "Live settings updated successfully."
msgstr "تنظیمات زنده با موفقیت به روز شد."
#: admin.py:285
msgid "config"
msgstr "پیکربندی"
#: apps.py:8
msgid "Constance"
msgstr "تنظیمات"
#: backends/database/models.py:19
msgid "constance"
msgstr "تنظیمات"
#: backends/database/models.py:20
msgid "constances"
msgstr "تنظیمات"
#: management/commands/constance.py:30
msgid "Get/Set In-database config settings handled by Constance"
msgstr ""
"دریافت/تنظیم تنظیمات پیکربندی درون پایگاه داده که توسط تنظیمات بکار برده می "
"شود"
#: templates/admin/constance/change_list.html:75
msgid "Save"
msgstr "ذخیره"
#: templates/admin/constance/change_list.html:84
msgid "Home"
msgstr "خانه"
#: templates/admin/constance/includes/results_list.html:5
msgid "Name"
msgstr "نام"
#: templates/admin/constance/includes/results_list.html:6
msgid "Default"
msgstr "پیش‌فرض"
#: templates/admin/constance/includes/results_list.html:7
msgid "Value"
msgstr "مقدار"
#: templates/admin/constance/includes/results_list.html:8
msgid "Is modified"
msgstr "تغییر یافته"
#: templates/admin/constance/includes/results_list.html:44
msgid "Reset to default"
msgstr "بازنشانی به پیش‌فرض"

View file

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: django-constance\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-06-13 19:40+0530\n"
"POT-Creation-Date: 2022-07-19 20:59+0500\n"
"PO-Revision-Date: 2014-11-27 18:13+0000\n"
"Last-Translator: Jannis Leidel <jannis@leidel.info>\n"
"Language-Team: Russian (http://www.transifex.com/projects/p/django-constance/"
@ -47,7 +47,11 @@ msgstr ""
#: admin.py:224
msgid "Live settings updated successfully."
msgstr "Настройки успешно сохранены"
msgstr "Настройки успешно сохранены."
#: admin.py:267
msgid "Failed to update live settings."
msgstr "Не удалось сохранить настройки."
#: admin.py:285
msgid "config"

Binary file not shown.

View file

@ -0,0 +1,104 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-11-09 19:14+0300\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Ozcan Yarimdunya <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: constance/admin.py:116
#, python-format
msgid ""
"Default value type must be equal to declared config parameter type. Please "
"fix the default value of '%(name)s'."
msgstr ""
"Varsayılan değer tipi, tanımlanan ayarlar parametresi tipi ile aynı olmalıdır. Lütfen "
"'%(name)s' in varsayılan değerini düzeltin."
#: constance/admin.py:126
#, python-format
msgid ""
"Constance doesn't support config values of the type %(config_type)s. Please "
"fix the value of '%(name)s'."
msgstr ""
"Constance %(config_type)s tipinin yapılandırma değerlerini desteklemiyor. Lütfen "
"'%(name)s' in değerini düzeltin."
#: constance/admin.py:160
msgid ""
"The settings have been modified by someone else. Please reload the form and "
"resubmit your changes."
msgstr ""
"Ayarlar başkası tarafından değiştirildi. Lütfen formu tekrar yükleyin ve "
"değişikliklerinizi tekrar kaydedin."
#: constance/admin.py:172 constance/checks.py:19
msgid ""
"CONSTANCE_CONFIG_FIELDSETS is missing field(s) that exists in "
"CONSTANCE_CONFIG."
msgstr ""
"CONSTANCE_CONFIG içinde mevcut olan alan(lar) için "
"CONSTANCE_CONFIG_FIELDSETS eksik."
#: constance/admin.py:240
msgid "Live settings updated successfully."
msgstr "Canlı ayarlar başarıyla güncellendi."
#: constance/admin.py:305
msgid "config"
msgstr "ayar"
#: constance/backends/database/models.py:19
msgid "constance"
msgstr "constance"
#: constance/backends/database/models.py:20
msgid "constances"
msgstr "constances"
#: constance/management/commands/constance.py:32
msgid "Get/Set In-database config settings handled by Constance"
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
msgid "Save"
msgstr "Kaydet"
#: constance/templates/admin/constance/change_list.html:69
msgid "Home"
msgstr "Anasayfa"
#: constance/templates/admin/constance/includes/results_list.html:6
msgid "Name"
msgstr "İsim"
#: constance/templates/admin/constance/includes/results_list.html:7
msgid "Default"
msgstr "Varsayılan"
#: constance/templates/admin/constance/includes/results_list.html:8
msgid "Value"
msgstr "Değer"
#: constance/templates/admin/constance/includes/results_list.html:9
msgid "Is modified"
msgstr "Değiştirildi mi"
#: constance/templates/admin/constance/includes/results_list.html:22
msgid "Current file"
msgstr "Geçerli dosya"
#: constance/templates/admin/constance/includes/results_list.html:39
msgid "Reset to default"
msgstr "Varsayılana dön"

Binary file not shown.

View file

@ -0,0 +1,121 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
# Translators:
msgid ""
msgstr ""
"Project-Id-Version: django-constance\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-07-19 16:00+0000\n"
"PO-Revision-Date: 2014-11-27 18:13+0000\n"
"Last-Translator: Vasyl Dizhak <vasyl@dizhak.com>\n"
"Language-Team: (http://www.transifex.com/projects/p/django-constance/"
"language/uk/)\n"
"Language: uk\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
#: admin.py:109
msgid "You don't have permission to change these values"
msgstr "У вас немає прав для зміни цих значень"
#: admin.py:117
#, python-format
msgid ""
"Default value type must be equal to declared config parameter type. Please "
"fix the default value of '%(name)s'."
msgstr ""
"Тип значення за замовчуванням повинен співпадати із вказаним типом параметра "
"конфігурації. Будь ласка, виправте значення за замовчуванням для '%(name)s'."
#: admin.py:127
#, python-format
msgid ""
"Constance doesn't support config values of the type %(config_type)s. Please "
"fix the value of '%(name)s'."
msgstr ""
"Constance не підтрумує значення наступного типу %(config_type)s. Будь ласка, "
"змініть тип для значення '%(name)s'"
#: admin.py:166
msgid ""
"The settings have been modified by someone else. Please reload the form and "
"resubmit your changes."
msgstr ""
"Налаштування було змінено кимось іншим. Буд ласка, перезавантажте форму та "
"повторно збережіть зміни."
#: admin.py:178 checks.py:19
msgid ""
"CONSTANCE_CONFIG_FIELDSETS is missing field(s) that exists in "
"CONSTANCE_CONFIG."
msgstr ""
"Одне чи кілька полів з CONSTANCE_CONFIG відсутні в "
"CONSTANCE_CONFIG_FIELDSETS."
#: admin.py:250
msgid "Live settings updated successfully."
msgstr "Налаштування успішно збережені."
#: admin.py:267
msgid "Failed to update live settings."
msgstr "Не вдалося зберегти налаштування."
#: admin.py:326
msgid "config"
msgstr "налаштування"
#: apps.py:8
msgid "Constance"
msgstr ""
#: backends/database/models.py:19
msgid "constance"
msgstr "налаштування"
#: backends/database/models.py:20
msgid "constances"
msgstr "налаштування"
#: management/commands/constance.py:30
msgid "Get/Set In-database config settings handled by Constance"
msgstr "Отримати/встановити налашування в базі даних, якими керує Constance"
#: templates/admin/constance/change_list.html:61
msgid "Save"
msgstr "Зберегти"
#: templates/admin/constance/change_list.html:70
msgid "Home"
msgstr "Головна"
#: templates/admin/constance/includes/results_list.html:6
msgid "Name"
msgstr "Назва"
#: templates/admin/constance/includes/results_list.html:7
msgid "Default"
msgstr "За замовчуванням"
#: templates/admin/constance/includes/results_list.html:8
msgid "Value"
msgstr "Поточне значення"
#: templates/admin/constance/includes/results_list.html:9
msgid "Is modified"
msgstr "Було змінено"
#: templates/admin/constance/includes/results_list.html:26
msgid "Current file"
msgstr "Поточний файл"
#: templates/admin/constance/includes/results_list.html:44
msgid "Reset to default"
msgstr "Скинути до значення за замовчуванням"
#~ msgid "Constance config"
#~ msgstr "Настройки"

View file

@ -1,15 +1,13 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.management import BaseCommand, CommandError
from django.utils.translation import ugettext as _
from django import VERSION
from django.core.management import BaseCommand
from django.core.management import CommandError
from django.utils.translation import gettext as _
from ... import config
from ...admin import ConstanceForm, get_values
from constance import config
from constance.forms import ConstanceForm
from constance.models import Constance
from constance.utils import get_values
def _set_constance_value(key, value):
@ -19,7 +17,6 @@ def _set_constance_value(key, value):
:param value:
:return:
"""
form = ConstanceForm(initial=get_values())
field = form.fields[key]
@ -29,49 +26,59 @@ def _set_constance_value(key, value):
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"
SET = "set"
LIST = "list"
REMOVE_STALE_KEYS = "remove_stale_keys"
def add_arguments(self, parser):
subparsers = parser.add_subparsers(dest='command')
# API changed in Django>=2.1. cmd argument was removed.
parser_list = self._subparsers_add_parser(subparsers, 'list', cmd=self, help='list all Constance keys and their values')
subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser(self.LIST, help="list all Constance keys and their values")
parser_get = self._subparsers_add_parser(subparsers, 'get', cmd=self, help='get the value of a Constance key')
parser_get.add_argument('key', help='name of the key to get', metavar='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_set = self._subparsers_add_parser(subparsers, 'set', cmd=self, help='set the value of a Constance key')
parser_set.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.add_argument("key", help="name of the key to set", metavar="KEY")
# use nargs='+' so that we pass a list to MultiValueField (eg SplitDateTimeField)
parser_set.add_argument('value', help='value to set', metavar='VALUE', nargs='+')
def _subparsers_add_parser(self, subparsers, name, **kwargs):
# API in Django >= 2.1 changed and removed cmd parameter from add_parser
if VERSION >= (2, 1) and 'cmd' in kwargs:
kwargs.pop('cmd')
return subparsers.add_parser(name, **kwargs)
parser_set.add_argument("value", help="value to set", metavar="VALUE", nargs="+")
subparsers.add_parser(
self.REMOVE_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):
if command == 'get':
if command == self.GET:
try:
self.stdout.write("{}".format(getattr(config, key)), ending="\n")
self.stdout.write(str(getattr(config, key)), ending="\n")
except AttributeError as e:
raise CommandError(key + " is not defined in settings.CONSTANCE_CONFIG")
elif command == 'set':
raise CommandError(f"{key} is not defined in settings.CONSTANCE_CONFIG") from e
elif command == self.SET:
try:
if len(value) == 1:
# assume that if a single argument was passed, the field doesn't expect a list
value = value[0]
_set_constance_value(key, value)
except KeyError as e:
raise CommandError(key + " is not defined in settings.CONSTANCE_CONFIG")
raise CommandError(f"{key} is not defined in settings.CONSTANCE_CONFIG") from e
except ValidationError as e:
raise CommandError(", ".join(e))
elif command == 'list':
raise CommandError(", ".join(e)) from e
elif command == self.LIST:
for k, v in get_values().items():
self.stdout.write("{}\t{}".format(k, v), ending="\n")
self.stdout.write(f"{k}\t{v}", ending="\n")
elif command == self.REMOVE_STALE_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)
if stale_records:
self.stdout.write("The following record will be deleted:", ending="\n")
else:
self.stdout.write("There are no stale records in the database.", ending="\n")
for stale_record in stale_records:
self.stdout.write(f"{stale_record.key}\t{stale_record.value}", ending="\n")
stale_records.delete()
else:
raise CommandError("Invalid command")

View file

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

View file

@ -0,0 +1,41 @@
from logging import getLogger
from django.core.management.color import no_style
from django.db import migrations
logger = getLogger(__name__)
def _migrate_from_old_table(apps, schema_editor) -> None:
"""
Copies values from old table.
On new installations just ignore error that table does not exist.
"""
connection = schema_editor.connection
quoted_string = ", ".join([connection.ops.quote_name(item) for item in ["id", "key", "value"]])
old_table_name = "constance_config"
with connection.cursor() as cursor:
if old_table_name not in connection.introspection.table_names():
logger.info("Old table does not exist, skipping")
return
cursor.execute(
f"INSERT INTO constance_constance ( {quoted_string} ) SELECT {quoted_string} FROM {old_table_name}", # noqa: S608
[],
)
cursor.execute(f"DROP TABLE {old_table_name}", [])
Constance = apps.get_model("constance", "Constance")
sequence_sql = connection.ops.sequence_reset_sql(no_style(), [Constance])
with connection.cursor() as cursor:
for sql in sequence_sql:
cursor.execute(sql)
class Migration(migrations.Migration):
dependencies = [("constance", "0001_initial")]
atomic = False
operations = [
migrations.RunPython(_migrate_from_old_table, reverse_code=lambda x, y: None),
]

View file

@ -0,0 +1,68 @@
import json
import logging
import pickle
from base64 import b64decode
from importlib import import_module
from django.db import migrations
from constance import settings
from constance.codecs import dumps
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):
package, module = path.rsplit(".", 1)
return getattr(import_module(package), module)
def migrate_pickled_data(apps, schema_editor) -> None: # pragma: no cover
Constance = apps.get_model("constance", "Constance")
for constance in Constance.objects.exclude(value=None):
if not is_already_migrated(constance.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",
):
import redis
_prefix = settings.REDIS_PREFIX
connection_cls = settings.REDIS_CONNECTION_CLASS
if connection_cls is not None:
_rd = import_module_attr(connection_cls)()
else:
if isinstance(settings.REDIS_CONNECTION, str):
_rd = redis.from_url(settings.REDIS_CONNECTION)
else:
_rd = redis.Redis(**settings.REDIS_CONNECTION)
redis_migrated_data = {}
for key in settings.CONFIG:
prefixed_key = f"{_prefix}{key}"
value = _rd.get(prefixed_key)
if value is not None and not is_already_migrated(value):
redis_migrated_data[prefixed_key] = dumps(pickle.loads(value)) # noqa: S301
for prefixed_key, value in redis_migrated_data.items():
_rd.set(prefixed_key, value)
class Migration(migrations.Migration):
dependencies = [("constance", "0002_migrate_from_old_table")]
operations = [
migrations.RunPython(migrate_pickled_data),
]

18
constance/models.py Normal file
View file

@ -0,0 +1,18 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
class Constance(models.Model):
key = models.CharField(max_length=255, unique=True)
value = models.TextField(null=True, blank=True, editable=False)
class Meta:
verbose_name = _("constance")
verbose_name_plural = _("constances")
permissions = [
("change_config", "Can change config"),
("view_config", "Can view config"),
]
def __str__(self):
return self.key

View file

@ -1,45 +1,31 @@
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", {})
DATABASE_CACHE_BACKEND = getattr(
settings,
'CONSTANCE_DATABASE_CACHE_BACKEND',
None
)
FILE_ROOT = getattr(settings, "CONSTANCE_FILE_ROOT", "")
DATABASE_CACHE_AUTOFILL_TIMEOUT = getattr(
settings,
'CONSTANCE_DATABASE_CACHE_AUTOFILL_TIMEOUT',
60 * 60 * 24
)
DATABASE_CACHE_BACKEND = getattr(settings, "CONSTANCE_DATABASE_CACHE_BACKEND", None)
DATABASE_PREFIX = getattr(settings, 'CONSTANCE_DATABASE_PREFIX', '')
DATABASE_CACHE_AUTOFILL_TIMEOUT = getattr(settings, "CONSTANCE_DATABASE_CACHE_AUTOFILL_TIMEOUT", 60 * 60 * 24)
REDIS_PREFIX = getattr(settings, 'CONSTANCE_REDIS_PREFIX', 'constance:')
DATABASE_PREFIX = getattr(settings, "CONSTANCE_DATABASE_PREFIX", "")
REDIS_CONNECTION_CLASS = getattr(
settings,
'CONSTANCE_REDIS_CONNECTION_CLASS',
None
)
REDIS_PREFIX = getattr(settings, "CONSTANCE_REDIS_PREFIX", "constance:")
REDIS_CONNECTION = getattr(settings, 'CONSTANCE_REDIS_CONNECTION', {})
REDIS_CACHE_TIMEOUT = getattr(settings, "CONSTANCE_REDIS_CACHE_TIMEOUT", 60)
SUPERUSER_ONLY = getattr(settings, 'CONSTANCE_SUPERUSER_ONLY', True)
REDIS_CONNECTION_CLASS = getattr(settings, "CONSTANCE_REDIS_CONNECTION_CLASS", None)
IGNORE_ADMIN_VERSION_CHECK = getattr(
settings,
'CONSTANCE_IGNORE_ADMIN_VERSION_CHECK',
False
)
REDIS_ASYNC_CONNECTION_CLASS = getattr(settings, "CONSTANCE_REDIS_ASYNC_CONNECTION_CLASS", None)
REDIS_CONNECTION = getattr(settings, "CONSTANCE_REDIS_CONNECTION", {})
SUPERUSER_ONLY = getattr(settings, "CONSTANCE_SUPERUSER_ONLY", True)
IGNORE_ADMIN_VERSION_CHECK = getattr(settings, "CONSTANCE_IGNORE_ADMIN_VERSION_CHECK", False)

View file

@ -1,5 +1,3 @@
import django.dispatch
config_updated = django.dispatch.Signal(
providing_args=['key', 'old_value', 'new_value']
)
config_updated = django.dispatch.Signal()

View file

@ -13,3 +13,22 @@
.help {
font-weight: normal !important;
}
#results {
overflow-x: auto;
}
.item-anchor {
visibility: hidden;
margin-left: .1em;
}
.item-name {
white-space: nowrap;
}
.item-name:hover .item-anchor {
visibility: visible;
}
.sticky-footer {
position: sticky;
width: 100%;
left: 0;
bottom: 0;
}

View file

@ -6,16 +6,28 @@
$('#content-main').on('click', '.reset-link', function(e) {
e.preventDefault();
var field = $('#' + this.dataset.fieldId);
var fieldType = this.dataset.fieldType;
const field_selector = this.dataset.fieldId.replace(/ /g, "\\ ")
const field = $('#' + field_selector);
const fieldType = this.dataset.fieldType;
if (fieldType === 'checkbox') {
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') {
var defaultDate = new Date(this.dataset.default * 1000);
$('#' + this.dataset.fieldId).val(defaultDate.strftime(get_format('DATE_INPUT_FORMATS')[0]));}
const defaultDate = new Date(this.dataset.default * 1000);
$('#' + this.dataset.fieldId).val(defaultDate.strftime(get_format('DATE_INPUT_FORMATS')[0]));
} else if (fieldType === 'datetime') {
var defaultDate = new Date(this.dataset.default * 1000);
const defaultDate = new Date(this.dataset.default * 1000);
$('#' + this.dataset.fieldId + '_0').val(defaultDate.strftime(get_format('DATE_INPUT_FORMATS')[0]));
$('#' + this.dataset.fieldId + '_1').val(defaultDate.strftime(get_format('TIME_INPUT_FORMATS')[0]));
} else {

View file

@ -1,12 +1,12 @@
{% extends "admin/base_site.html" %}
{% load admin_static admin_list i18n %}
{% load admin_list static i18n %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/changelists.css' %}" />
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/forms.css' %}" />
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/changelists.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/forms.css' %}">
{{ media.css }}
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/constance.css' %}" />
<link rel="stylesheet" type="text/css" href="{% static 'admin/css/constance.css' %}">
{% endblock %}
{% block extrahead %}
@ -15,9 +15,12 @@
{{ block.super }}
{{ media.js }}
<script type="text/javascript" src="{% static 'admin/js/constance.js' %}"></script>
{% if django_version < "5.1" %}
<script type="text/javascript" src="{% static 'admin/js/collapse.js' %}"></script>
{% endif %}
{% endblock %}
{% block bodyclass %}change-list{% endblock %}
{% block bodyclass %}{{ block.super }} change-list{% endblock %}
{% block content %}
<div id="content-main" class="constance">
@ -45,19 +48,22 @@
{% if fieldsets %}
{% for fieldset in fieldsets %}
<fieldset class="module">
<h2>{{ fieldset.title }}</h2>
<fieldset class="module{% if fieldset.collapse %} collapse{% endif %}">
{% 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 %}
{% include "admin/constance/includes/results_list.html" %}
{% endwith %}
{% if django_version >= "5.1" and fieldset.collapse %}</details>{% endif %}
</fieldset>
{% endfor %}
{% else %}
{% include "admin/constance/includes/results_list.html" %}
{% endif %}
<p class="paginator">
<input type="submit" name="_save" class="default" value="{% trans 'Save' %}"/>
<p class="paginator sticky-footer">
<input type="submit" name="_save" class="default" value="{% trans 'Save' %}">
</p>
</form>
</div>

View file

@ -1,49 +1,59 @@
{% load admin_static admin_list static i18n %}
<table>
<thead>
<tr>
<th><div class="text">{% trans "Name" %}</div></th>
<th><div class="text">{% trans "Default" %}</div></th>
<th><div class="text">{% trans "Value" %}</div></th>
<th><div class="text">{% trans "Is modified" %}</div></th>
{% load admin_list static i18n %}
<div id="results">
<table>
<thead>
<tr>
<th><div class="text">{% trans "Name" %}</div></th>
<th><div class="text">{% trans "Default" %}</div></th>
<th><div class="text">{% trans "Value" %}</div></th>
<th><div class="text">{% trans "Is modified" %}</div></th>
</tr>
</thead>
{% for item in config_values %}
<tr class="{% cycle 'row1' 'row2' %}">
<th>
<span class="item-name" id="{{ item.name|slugify }}">
{{ item.name }}
<a class="item-anchor" href="#{{ item.name|slugify }}" title="Link to this setting"></a>
</span>
<div class="help">{{ item.help_text|linebreaksbr }}</div>
</th>
<td>
{{ item.default|linebreaks }}
</td>
<td>
{{ item.form_field.errors }}
{% if item.is_file %}{% trans "Current file" %}: <a href="{% get_media_prefix as MEDIA_URL %}{{ MEDIA_URL }}{{ item.value }}" target="_blank">{{ item.value }}</a>{% endif %}
{{ item.form_field }}
{% if not item.is_file %}
<br>
<a href="#" class="reset-link"
data-field-id="{{ item.form_field.auto_id }}"
data-field-type="{% spaceless %}
{% if item.is_checkbox %}checkbox
{% elif item.is_multi_select %}multi-select
{% elif item.is_datetime %}datetime
{% elif item.is_date %}date
{% endif %}
{% endspaceless %}"
data-default="{% spaceless %}
{% 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_datetime %}{{ item.raw_default|date:"U" }}
{% else %}{{ item.default }}
{% endif %}
{% endspaceless %}">{% trans "Reset to default" %}</a>
{% endif %}
</td>
<td>
{% if item.modified %}
<img src="{% static 'admin/img/icon-yes.'|add:icon_type %}" alt="{{ item.modified }}">
{% else %}
<img src="{% static 'admin/img/icon-no.'|add:icon_type %}" alt="{{ item.modified }}">
{% endif %}
</td>
</tr>
</thead>
{% for item in config_values %}
<tr class="{% cycle 'row1' 'row2' %}">
<th>
{{ item.name }} <div class="help">{{ item.help_text|linebreaksbr }}</div>
</th>
<td>
{{ item.default|linebreaks }}
</td>
<td>
{{ item.form_field.errors }}
{% if item.is_file %}{% trans "Current file" %}: <a href="{% get_media_prefix as MEDIA_URL %}{{ MEDIA_URL }}{{ item.value }}" target="_blank">{{ item.value }}</a>{% endif %}
{{ item.form_field }}
<br>
<a href="#" class="reset-link"
data-field-id="{{ item.form_field.auto_id }}"
data-field-type="{% spaceless %}
{% if item.is_checkbox %}checkbox
{% elif item.is_date %}date
{% elif item.is_datetime %}datetime
{% endif %}
{% endspaceless %}"
data-default="{% spaceless %}
{% if item.is_checkbox %}{% if item.raw_default %} true {% else %} false {% endif %}
{% elif item.is_date %}{{ item.raw_default|date:"U" }}
{% elif item.is_datetime %}{{ item.raw_default|date:"U" }}
{% else %}{{ item.default }}
{% endif %}
{% endspaceless %}">{% trans "Reset to default" %}</a>
</td>
<td>
{% if item.modified %}
<img src="{% static 'admin/img/icon-yes.'|add:icon_type %}" alt="{{ item.modified }}" />
{% else %}
<img src="{% static 'admin/img/icon-no.'|add:icon_type %}" alt="{{ item.modified }}" />
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endfor %}
</table>
</div>

View file

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

65
constance/test/pytest.py Normal file
View file

@ -0,0 +1,65 @@
"""
Pytest constance override config plugin.
Inspired by https://github.com/pytest-dev/pytest-django/.
"""
from contextlib import ContextDecorator
import pytest
from constance import config as constance_config
@pytest.hookimpl(trylast=True)
def pytest_configure(config): # pragma: no cover
"""Register override_config marker."""
config.addinivalue_line("markers", ("override_config(**kwargs): mark test to override django-constance config"))
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item): # pragma: no cover
"""Validate constance override marker params. Run test with overridden config."""
marker = item.get_closest_marker("override_config")
if marker is not None:
if marker.args:
pytest.fail("Constance override can not not accept positional args")
with override_config(**marker.kwargs):
yield
else:
yield
class override_config(ContextDecorator):
"""
Override config while running test function.
Act as context manager and decorator.
"""
def enable(self):
"""Store original config values and set overridden values."""
for key, value in self._to_override.items():
self._original_values[key] = getattr(constance_config, key)
setattr(constance_config, key, value)
def disable(self):
"""Set original values to the config."""
for key, value in self._original_values.items():
setattr(constance_config, key, value)
def __init__(self, **kwargs):
self._to_override = kwargs.copy()
self._original_values = {}
def __enter__(self):
self.enable()
def __exit__(self, exc_type, exc_val, exc_tb):
self.disable()
@pytest.fixture(name="override_config")
def _override_config():
"""Make override_config available as a function fixture."""
return override_config

View file

@ -1,11 +1,12 @@
from functools import wraps
from django import VERSION as DJANGO_VERSION
from django.test import SimpleTestCase
from django.test.utils import override_settings
from .. import config
from constance import config
__all__ = ('override_config',)
__all__ = ("override_config",)
class override_config(override_settings):
@ -14,25 +15,23 @@ class override_config(override_settings):
Based on django.test.utils.override_settings.
"""
def __init__(self, **kwargs):
super(override_config, self).__init__(**kwargs)
super().__init__(**kwargs)
self.original_values = {}
def __call__(self, test_func):
"""
Modify the decorated function to override config values.
"""
"""Modify the decorated function to override config values."""
if isinstance(test_func, type):
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)
else:
@wraps(test_func)
def inner(*args, **kwargs):
with self:
return test_func(*args, **kwargs)
@wraps(test_func)
def inner(*args, **kwargs):
with self:
return test_func(*args, **kwargs)
return inner
def modify_test_case(self, test_case):
@ -46,9 +45,20 @@ class override_config(override_settings):
original_pre_setup = test_case._pre_setup
original_post_teardown = test_case._post_teardown
def _pre_setup(inner_self):
self.enable()
original_pre_setup(inner_self)
if DJANGO_VERSION < (5, 2):
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):
original_post_teardown(inner_self)
@ -60,26 +70,20 @@ class override_config(override_settings):
return test_case
def enable(self):
"""
Store original config values and set overridden values.
"""
"""Store original config values and set overridden values."""
# Store the original values to an instance variable
for config_key in self.options:
self.original_values[config_key] = getattr(config, config_key)
# Update config with the overriden values
# Update config with the overridden values
self.unpack_values(self.options)
def disable(self):
"""
Set original values to the config.
"""
"""Set original values to the config."""
self.unpack_values(self.original_values)
@staticmethod
def unpack_values(options):
"""
Unpack values from the given dict to config.
"""
"""Unpack values from the given dict to config."""
for name, value in options.items():
setattr(config, name, value)

View file

@ -1,6 +1,76 @@
from importlib import import_module
from . import LazyConfig
from . import settings
config = LazyConfig()
def import_module_attr(path):
package, module = path.rsplit('.', 1)
package, module = path.rsplit(".", 1)
return getattr(import_module(package), module)
def get_values():
"""
Get dictionary of values from the backend
:return:
"""
# First load a mapping between config name and default value
default_initial = ((name, options[0]) for name, options in settings.CONFIG.items())
# Then update the mapping with actually values from the backend
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

@ -1,177 +1,20 @@
# Makefile for Sphinx documentation
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
PAPER =
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
# User-friendly check for sphinx-build
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
endif
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
# Put it first so that "make" without argument is like "make help".
help:
@echo "Please use \`make <target>' where <target> is one of"
@echo " html to make standalone HTML files"
@echo " dirhtml to make HTML files named index.html in directories"
@echo " singlehtml to make a single large HTML file"
@echo " pickle to make pickle files"
@echo " json to make JSON files"
@echo " htmlhelp to make HTML files and a HTML help project"
@echo " qthelp to make HTML files and a qthelp project"
@echo " devhelp to make HTML files and a Devhelp project"
@echo " epub to make an epub"
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
@echo " latexpdf to make LaTeX files and run them through pdflatex"
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
@echo " text to make text files"
@echo " man to make manual pages"
@echo " texinfo to make Texinfo files"
@echo " info to make Texinfo files and run them through makeinfo"
@echo " gettext to make PO message catalogs"
@echo " changes to make an overview of all changed/added/deprecated items"
@echo " xml to make Docutils-native XML files"
@echo " pseudoxml to make pseudoxml-XML files for display purposes"
@echo " linkcheck to check all external links for integrity"
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
clean:
rm -rf $(BUILDDIR)/*
.PHONY: help Makefile
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
singlehtml:
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
pickle:
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo
@echo "Build finished; now you can process the pickle files."
json:
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo
@echo "Build finished; now you can process the JSON files."
htmlhelp:
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo
@echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp."
qthelp:
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-constance.qhcp"
@echo "To view the help file:"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-constance.qhc"
devhelp:
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo
@echo "Build finished."
@echo "To view the help file:"
@echo "# mkdir -p $$HOME/.local/share/devhelp/django-constance"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-constance"
@echo "# devhelp"
epub:
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
latex:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \
"(use \`make latexpdf' here to do that automatically)."
latexpdf:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
latexpdfja:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through platex and dvipdfmx..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
text:
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo
@echo "Build finished. The text files are in $(BUILDDIR)/text."
man:
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
texinfo:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \
"(use \`make info' here to do that automatically)."
info:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
gettext:
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
changes:
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo
@echo "The overview file is in $(BUILDDIR)/changes."
linkcheck:
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
@echo
@echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt."
doctest:
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt."
xml:
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
@echo
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
pseudoxml:
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
@echo
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

View file

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View file

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -10,6 +10,9 @@ configuration values. By default it uses the Redis backend. To override
the default please set the :setting:`CONSTANCE_BACKEND` setting to the appropriate
dotted path.
Configuration values are stored in JSON format and automatically serialized/deserialized
on access.
Redis
-----
@ -23,7 +26,15 @@ to add it to your project settings::
CONSTANCE_BACKEND = 'constance.backends.redisd.RedisBackend'
.. _`redis-py`: https://pypi.python.org/pypi/redis
Default redis backend retrieves values every time. There is another redis backend with local cache.
`CachingRedisBackend` stores the value from a redis to memory at first access and checks a value ttl at next.
Configuration installation is simple::
CONSTANCE_BACKEND = 'constance.backends.redisd.CachingRedisBackend'
# optionally set a value ttl
CONSTANCE_REDIS_CACHE_TIMEOUT = 60
.. _`redis-py`: https://pypi.org/project/redis/
Settings
^^^^^^^^
@ -52,7 +63,7 @@ An (optional) dotted import path to a connection to use, e.g.::
CONSTANCE_REDIS_CONNECTION_CLASS = 'myproject.myapp.mockup.Connection'
If you are using `django-redis <http://niwinz.github.io/django-redis/latest/>`_,
If you are using `django-redis <https://github.com/jazzband/django-redis>`_,
feel free to use the ``CONSTANCE_REDIS_CONNECTION_CLASS`` setting to define
a callable that returns a redis connection, e.g.::
@ -66,30 +77,25 @@ database. Defaults to ``'constance:'``. E.g.::
CONSTANCE_REDIS_PREFIX = 'constance:myproject:'
``CONSTANCE_REDIS_CACHE_TIMEOUT``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The (optional) ttl of values in seconds used by `CachingRedisBackend` for storing in a local cache.
Defaults to `60` seconds.
Database
--------
The database backend is optional and stores the configuration values in a
standard Django model. It requires the package `django-picklefield`_ for
storing those values. Please install it like so::
pip install django-constance[database]
Database backend stores configuration values in a standard Django model.
You must set the ``CONSTANCE_BACKEND`` Django setting to::
CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
Then add the database backend app to your :setting:`INSTALLED_APPS` setting to
make sure the data model is correctly created::
INSTALLED_APPS = (
# other apps
'constance.backends.database',
)
Please make sure to apply the database migrations::
python manage.py migrate database
python manage.py migrate
.. note:: If you're upgrading Constance to 1.0 and use Django 1.7 or higher
please make sure to let the migration system know that you've
@ -99,6 +105,7 @@ Please make sure to apply the database migrations::
python manage.py migrate database --fake
Just like the Redis backend you can set an optional prefix that is used during
database interactions (it defaults to an empty string, ``''``). To use
something else do this::
@ -127,9 +134,32 @@ configured cache backend to enable this feature, e.g. "default"::
cache backend included in Django because correct cache
invalidation can't be guaranteed.
If you try this, Constance will throw an error and refuse
to let your application start. You can work around this by
subclassing ``constance.backends.database.DatabaseBackend``
and overriding `__init__` to remove the check. You'll
want to consult the source code for that function to see
exactly how.
We're deliberately being vague about this, because it's
dangerous; the behavior is undefined, and could even cause
your app to crash. Nevertheless, there are some limited
circumstances in which this could be useful, but please
think carefully before going down this path.
.. note:: By default Constance will autofill the cache on startup and after
saving any of the config values. If you want to disable the cache
simply set the :setting:`CONSTANCE_DATABASE_CACHE_AUTOFILL_TIMEOUT`
setting to ``None``.
.. _django-picklefield: http://pypi.python.org/pypi/django-picklefield/
Memory
------
The configuration values are stored in a memory and do not persist between process
restarts. In order to use this backend you must set the ``CONSTANCE_BACKEND``
Django setting to::
CONSTANCE_BACKEND = 'constance.backends.memory.MemoryBackend'
The main purpose of this one is to be used mostly for testing/developing means,
so make sure you intentionally use it on production environments.

View file

@ -1,12 +1,186 @@
Changelog
---------
Starting with version 4.0.0, the changelog is maintained at the GitHub releases `GitHub releases`_
.. _GitHub releases: https://github.com/jazzband/django-constance/releases
v4.0.0 (2024/08/21)
~~~~~~~~~~~~~~~~~~~
* Replace `pickle` with JSON for the database backend
* Fix migration on MySQL
* Fix data loss using `DatabaseBackend` when the DB connection is unstable
* Fix typos in the documentation
* Fix small HTML errors
* Drop support for legacy Django versions
* Migrate JavaScript to ES2015
* Fix documentation build
* Add linters and formatters (using `ruff`)
* Add support for Django 5.1 and 5.2
* Migrate from `setup.py` to `pyproject.toml`
* Bump `tox`
* Declare support for Python 3.12
v3.1.0 (2023/08/21)
~~~~~~~~~~~~~~~~~~~
* Add support for using a subdirectory of `MEDIA_ROOT` for file fields
* Remove pypy from tox tests
v3.0.0 (2023/07/27)
~~~~~~~~~~~~~~~~~~~
* Refactor database backend
Backward incompatible changes:
remove ``'constance.backends.database'`` from ``INSTALLED_APPS``
* Dropped support for python < 3.7 and django < 3.2
* Example app now supports django 4.1
* Add support for django 4.2
* Forward the request when saving the admin changelist form
v2.9.1 (2022/08/11)
~~~~~~~~~~~~~~~~~~~
* Add support for gettext in fieldset headers
* Add support for Django 4.1
* Fix text format for MultiValueField usage
v2.9.0 (2022/03/11)
~~~~~~~~~~~~~~~~~~~
* Added arabic translation
* Add concrete_model class attribute to fake admin model
* Added tests for django 3.2
* Fix do not detect datetime fields as date type
* Added support for python 3.10
* Fixes for Ukrainian locale
* Added documentation for constance_dbs config
* Add caching redis backend
* Serialize according to widget
* Add default_auto_field to database backend
v2.8.0 (2020/11/19)
~~~~~~~~~~~~~~~~~~~
* Prevent reset to default for file field
* Fields_list can be a dictionary, when a fieldset is defined as collapsible
* Create and add fa language translations files
* Respect other classes added by admin templates
* Removed deprecated url()
* Use gettext_lazy instead of ugettext_lazy
* Updated python and django version support
v2.7.0 (2020/06/22)
~~~~~~~~~~~~~~~~~~~
* Deleted south migrations
* Improve grammar of documentation index file
* Simplify documentation installation section
* Fix IntegrityError after 2.5.0 release
(Allow concurrent calls to `DatabaseBackend.set()` method)
* Make groups of fieldsets collapsable
* Allow override_config for pytest
* Put back wheel generation in travis
* Fix wrong "is modified" in admin for multi line strings
* Switch md5 to sha256
* Fix Attempts to change config values fail silently and
appear to succeed when user does not have change permissions
* Make constance app verbose name translatable
* Update example project for Django>2
* Add anchors in admin for constance settings
* Added a sticky footer in django constance admin
* Add memory backend
* Added Ukrainian locale
* Added lazy checks for pytest
v2.6.0 (2020/01/29)
~~~~~~~~~~~~~~~~~~~
* Drop support py<3.5 django<2.2
* Set pickle protocol version for the Redis backend
* Add a command to delete stale records
v2.5.0 (2019/12/23)
~~~~~~~~~~~~~~~~~~~
* Made results table responsive for Django 2 admin
* Add a Django system check that CONFIG_FIELDSETS accounts for all of CONFIG
* Rewrite set() method of database backend to reduce number of queries
* Fixed "can't compare offset-naive and offset-aware datetimes" when USE_TZ = True
* Fixed compatibility issue with Django 3.0 due to django.utils.six
* Add Turkish language
v2.4.0 (2019/03/16)
~~~~~~~~~~~~~~~~~~~
* Show not existing fields in field_list
* Drop Django<1.11 and 2.0, fix tests vs Django 2.2b
* Fixed "Reset to default" button with constants whose name contains a space
* Use default_storage to save file
* Allow null & blank for PickleField
* Removed Python 3.4 since is not longer supported
v2.3.1 (2018/09/20)
~~~~~~~~~~~~~~~~~~~
* Fixes javascript typo.
v2.3.0 (2018/09/13)
~~~~~~~~~~~~~~~~~~~
* Added zh_Hans translation.
* Fixed TestAdmin.test_linebreaks() due to linebreaksbr() behavior change
* Fixed TestAdmin.test_linebreaks() due to linebreaksbr() behavior change
on Django 2.1
* Improved chinese translation

View file

@ -1,268 +1,108 @@
# -*- coding: utf-8 -*-
# Configuration file for the Sphinx documentation builder.
#
# django-constance documentation build configuration file, created by
# sphinx-quickstart on Tue Nov 25 19:38:51 2014.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys
import os
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings')
import os
import sys
from datetime import datetime
def get_version():
# Try to get version from installed package metadata
try:
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version
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")
# 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
# 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('..'))
sys.path.insert(0, os.path.abspath("extensions"))
sys.path.insert(0, os.path.abspath(".."))
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = "django-constance"
project_copyright = datetime.now().year.__str__() + ", Jazzband"
# The full version, including alpha/beta/rc tags
release = get_version()
# The short X.Y version
version = ".".join(release.split(".")[:3])
# -- General configuration ------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
extensions = [
"sphinx.ext.intersphinx",
"sphinx.ext.todo",
"sphinx_search.extension",
"settings",
]
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = ['sphinx.ext.intersphinx', 'settings']
templates_path = ["_templates"]
source_suffix = ".rst"
root_doc = "index"
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
pygments_style = "sphinx"
html_last_updated_fmt = ""
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'django-constance'
copyright = u'2017, Jazzband'
# The short X.Y version.
try:
from constance import __version__
# The short X.Y version.
version = '.'.join(__version__.split('.')[:2])
# The full version, including alpha/beta/rc tags.
release = __version__
except ImportError:
version = release = 'dev'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all
# documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
#keep_warnings = False
# -- Options for HTML output ----------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'sphinx_rtd_theme'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation.
#html_extra_path = []
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'django-constancedoc'
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = "sphinx_rtd_theme"
html_static_path = ["_static"]
htmlhelp_basename = "django-constancedoc"
# -- Options for LaTeX output ---------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-latex-output
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
latex_elements = {}
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
('index', 'django-constance.tex', u'django-constance Documentation',
u'Jazzband', 'manual'),
("index", "django-constance.tex", "django-constance Documentation", "Jazzband", "manual"),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output ---------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-manual-page-output
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'django-constance', u'django-constance Documentation',
[u'Jazzband'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
man_pages = [("index", "django-constance", "django-constance Documentation", ["Jazzband"], 1)]
# -- Options for Texinfo output -------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-texinfo-output
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'django-constance', u'django-constance Documentation',
u'Jazzband', 'django-constance', 'One line description of project.',
'Miscellaneous'),
(
"index",
"django-constance",
"django-constance Documentation",
"Jazzband",
"django-constance",
"One line description of project.",
"Miscellaneous",
),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'
# If true, do not generate a @detailmenu in the "Top" node's menu.
#texinfo_no_detailmenu = False
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {
'http://docs.python.org/': None,
'django': ('http://docs.djangoproject.com/en/dev/',
'http://docs.djangoproject.com/en/dev/_objects/'),
"python": ("https://docs.python.org/3", None),
"django": ("https://docs.djangoproject.com/en/dev/", "https://docs.djangoproject.com/en/dev/_objects/"),
}

View file

@ -5,27 +5,19 @@ Features
--------
* Easily migrate your static settings to dynamic settings.
* Admin interface to edit the dynamic settings.
* Edit the dynamic settings in the Django admin interface.
.. image:: screenshot2.png
.. image:: _static/screenshot2.png
Installation
------------
Quick Installation
------------------
Install from PyPI the backend specific variant of django-constance:
For the (default) Redis backend::
.. code-block::
pip install "django-constance[redis]"
For the database backend::
pip install "django-constance[database]"
Alternatively -- if you're sure that the dependencies are already
installed -- you can also run::
pip install django-constance
For complete installation instructions, including how to install the
database backend, see :ref:`Backends <backends>`.
Configuration
-------------
@ -52,7 +44,11 @@ the :setting:`CONSTANCE_CONFIG` section, like this:
'The Universe, and Everything'),
}
.. note:: If you use admin extensions like `Grapelli <http://grappelliproject.com/>`_, ``'constance'`` should be added in :setting:`INSTALLED_APPS` *before* that extension
.. note:: Add constance *before* your project apps.
.. note:: If you use admin extensions like
`Grapelli <https://grappelliproject.com/>`_, ``'constance'`` should be added
in :setting:`INSTALLED_APPS` *before* those extensions.
Here, ``42`` is the default value for the key ``THE_ANSWER`` if it is
not found in the backend. The other member of the tuple is a help text the
@ -64,7 +60,8 @@ finish the configuration.
``django-constance``'s hashes generated in different instances of the same
application may differ, preventing data from being saved.
Use this option in order to skip hash verification.
Use :setting:`CONSTANCE_IGNORE_ADMIN_VERSION_CHECK` in order to skip hash
verification.
.. code-block:: python
@ -76,8 +73,6 @@ Signals
Each time a value is changed it will trigger a ``config_updated`` signal.
You can use it as:
.. code-block:: python
from constance.signals import config_updated
@ -87,19 +82,14 @@ You can use it as:
print(sender, key, old_value, new_value)
The sender is the ``config`` object, and the ``key`` and ``new_value``
are the ones just changed.
This callback will get the ``config`` object as the first parameter so you
can have an isolated function where you can access the ``config`` object
without dealing with additional imports.
are the changed settings.
Custom fields
-------------
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:
@ -107,12 +97,17 @@ The supported types are:
* ``int``
* ``float``
* ``Decimal``
* ``long`` (on python 2)
* ``str``
* ``unicode`` (on python 2)
* ``datetime``
* ``timedelta``
* ``date``
* ``time``
* ``list``
* ``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:
@ -121,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 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
path of a field class, and the (optional) second item is a dictionary used to configure the field.
@ -129,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 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
@ -144,7 +139,7 @@ Note: Use later evaluated strings instead of direct classes for the field and wi
'MY_SELECT_KEY': ('yes', 'select yes or no', 'yes_no_null_select'),
}
If you want to work with files you can use this configuration:
If you want to work with images or files you can use this configuration:
.. code-block:: python
@ -163,15 +158,36 @@ When used in a template you probably need to use:
{% load static %}
{% get_media_prefix as MEDIA_URL %}
<img src="{{ MEDIA_URL }}{{ constance.LOGO_IMAGE }}">
<img src="{{ MEDIA_URL }}{{ config.LOGO_IMAGE }}">
Images are uploaded to MEDIA_ROOT.
Images and files are uploaded to ``MEDIA_ROOT`` by default. You can specify a subdirectory of ``MEDIA_ROOT`` to use instead by adding the ``CONSTANCE_FILE_ROOT`` setting. E.g.:
.. code-block:: python
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
CONSTANCE_FILE_ROOT = 'constance'
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
------------------------------
In order to Order the fields , you can use OrderedDict collection. Here is an example:
To sort the fields, you can use an OrderedDict:
.. code-block:: python
@ -187,7 +203,7 @@ In order to Order the fields , you can use OrderedDict collection. Here is an ex
Fieldsets
---------
To group settings together you can define fieldsets. Here's an example:
You can define fieldsets to group settings together:
.. code-block:: python
@ -202,9 +218,61 @@ To group settings together you can define fieldsets. Here's an example:
'Theme Options': ('THEME',),
}
.. note:: CONSTANCE_CONFIG_FIELDSETS must contain all fields from CONSTANCE_CONFIG
.. note:: CONSTANCE_CONFIG_FIELDSETS must contain all fields from CONSTANCE_CONFIG.
.. image:: screenshot3.png
.. image:: _static/screenshot3.png
Fieldsets collapsing
--------------------
To make some fieldsets collapsing you can use new format in CONSTANCE_CONFIG_FIELDSETS. Here's an example:
.. code-block:: python
CONSTANCE_CONFIG = {
'SITE_NAME': ('My Title', 'Website title'),
'SITE_DESCRIPTION': ('', 'Website description'),
'THEME': ('light-blue', 'Website theme'),
}
CONSTANCE_CONFIG_FIELDSETS = {
'General Options': {
'fields': ('SITE_NAME', 'SITE_DESCRIPTION'),
'collapse': True
},
'Theme Options': ('THEME',),
}
Field internationalization
--------------------------
Field description and fieldset headers can be integrated into Django's
internationalization using the ``gettext_lazy`` function. Note that the
``CONSTANCE_CONFIG_FIELDSETS`` must be converted to a tuple instead of dict
as it is not possible to have lazy proxy objects as dictionary keys in the
settings file. Example:
.. code-block:: python
from django.utils.translation import gettext_lazy as _
CONSTANCE_CONFIG = {
'SITE_NAME': ('My Title', _('Website title')),
'SITE_DESCRIPTION': ('', _('Website description')),
'THEME': ('light-blue', _('Website theme')),
}
CONSTANCE_CONFIG_FIELDSETS = (
(
_('General Options'),
{
'fields': ('SITE_NAME', 'SITE_DESCRIPTION'),
'collapse': True,
},
),
(_('Theme Options'), ('THEME',)),
)
Usage
-----
@ -224,10 +292,40 @@ object and accessing the variables with attribute lookups::
if config.THE_ANSWER == 42:
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
^^^^^^^^^^^^^^^^
To access the config object from your template you can either
To access the config object from your template you can
pass the object to the template context:
.. code-block:: python
@ -238,16 +336,9 @@ pass the object to the template context:
def myview(request):
return render(request, 'my_template.html', {'config': config})
Or you can use the included config context processor. For Django pre-1.8, this looks like this:
You can also use the included context processor.
.. code-block:: python
TEMPLATE_CONTEXT_PROCESSORS = (
# ...
'constance.context_processors.config',
)
For Django 1.8 and above, insert ``'constance.context_processors.config'`` at
Insert ``'constance.context_processors.config'`` at
the top of your ``TEMPLATES['OPTIONS']['context_processors']`` list. See the
`Django documentation`_ for details.
@ -266,65 +357,99 @@ any other variable, e.g.:
Woohoo! Head over <a href="/sekrit/">here</a> to use the beta.
{% else %}
Sadly we haven't launched yet, click <a href="/newsletter/">here</a>
to signup for our newletter.
to signup for our newsletter.
{% endif %}
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:
list - output all values in a tab-separated format::
.. program:: constance
$ ./manage.py constance list
THE_ANSWER 42
SITE_NAME My Title
.. option:: list
get KEY - output a single values::
list all Constance keys and their values
$ ./manage.py constance get THE_ANSWER
42
.. code-block:: console
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 = {
...
'DATE_ESTABLISHED': (date(1972, 11, 30), "the shop's first opening"),
}
.. option:: set <KEY> <VALUE>
Then setting an invalid date will fail as follow::
set the value of a Constance key
$ ./manage.py constance set DATE_ESTABLISHED '1999-12-00'
CommandError: Enter a valid date.
.. code-block:: console
$ ./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 fields is a `MultiValueField`, (e.g. datetime, which uses `SplitDateTimeField` by default)
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., given this config::
E.g., a datetime using :class:`SplitDateTimeField`:
CONSTANCE_CONFIG = {
'DATETIME_VALUE': (datetime(2010, 8, 23, 11, 29, 24), 'time of the first commit'),
}
.. code-block:: python
Then this works (and the quotes are optional)::
CONSTANCE_CONFIG = {
'DATETIME_VALUE': (datetime(2010, 8, 23, 11, 29, 24), 'time of the first commit'),
}
./manage.py constance set DATETIME_VALUE '2011-09-24' '12:30:25'
Then this works (and the quotes are optional):
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.
./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.
.. option:: remove_stale_keys
delete all Constance keys and their values if they are not in settings.CONSTANCE_CONFIG (stale keys)
.. code-block:: console
$ ./manage.py constance remove_stale_keys
Record is considered stale if it exists in database but absent in config.
Editing
-------
@ -332,13 +457,12 @@ Editing
Fire up your ``admin`` and you should see a new app called ``Constance``
with ``THE_ANSWER`` in the ``Config`` pseudo model.
By default changing the settings via the admin is only allowed for super users.
But in case you want to use the admin's ability to implement custom
authorization checks, feel free to set the :setting:`CONSTANCE_SUPERUSER_ONLY`
setting to ``False`` and give the users or user groups access to the
By default, changing the settings via the admin is only allowed for superusers.
To change this, feel free to set the :setting:`CONSTANCE_SUPERUSER_ONLY`
setting to ``False`` and give users or user groups access to the
``constance.change_config`` permission.
.. figure:: screenshot1.png
.. figure:: _static/screenshot1.png
The virtual application ``Constance`` among your regular applications.
@ -354,10 +478,11 @@ settings the way you like.
.. code-block:: python
from constance.admin import ConstanceAdmin, ConstanceForm, Config
from constance.admin import ConstanceAdmin, Config
from constance.forms import ConstanceForm
class CustomConfigForm(ConstanceForm):
def __init__(self, *args, **kwargs):
super(CustomConfigForm, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
#... do stuff to make your settings form nice ...
class ConfigAdmin(ConstanceAdmin):
@ -382,7 +507,7 @@ request. For example:
if request.user.is_superuser:
return SuperuserForm:
else:
return super(MyConstanceAdmin, self).get_changelist_form(request)
return super().get_changelist_form(request)
Note that the default method returns ``self.change_list_form``.

View file

@ -1,53 +1,16 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=.
set BUILDDIR=_build
set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
set I18NSPHINXOPTS=%SPHINXOPTS% .
if NOT "%PAPER%" == "" (
set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
)
if "%1" == "" goto help
if "%1" == "help" (
:help
echo.Please use `make ^<target^>` where ^<target^> is one of
echo. html to make standalone HTML files
echo. dirhtml to make HTML files named index.html in directories
echo. singlehtml to make a single large HTML file
echo. pickle to make pickle files
echo. json to make JSON files
echo. htmlhelp to make HTML files and a HTML help project
echo. qthelp to make HTML files and a qthelp project
echo. devhelp to make HTML files and a Devhelp project
echo. epub to make an epub
echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
echo. text to make text files
echo. man to make manual pages
echo. texinfo to make Texinfo files
echo. gettext to make PO message catalogs
echo. changes to make an overview over all changed/added/deprecated items
echo. xml to make Docutils-native XML files
echo. pseudoxml to make pseudoxml-XML files for display purposes
echo. linkcheck to check all external links for integrity
echo. doctest to run all doctests embedded in the documentation if enabled
goto end
)
if "%1" == "clean" (
for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
del /q /s %BUILDDIR%\*
goto end
)
%SPHINXBUILD% 2> nul
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
@ -56,187 +19,17 @@ if errorlevel 9009 (
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "html" (
%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/html.
goto end
)
if "%1" == "" goto help
if "%1" == "dirhtml" (
%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
goto end
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
if "%1" == "singlehtml" (
%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
goto end
)
if "%1" == "pickle" (
%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can process the pickle files.
goto end
)
if "%1" == "json" (
%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can process the JSON files.
goto end
)
if "%1" == "htmlhelp" (
%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can run HTML Help Workshop with the ^
.hhp project file in %BUILDDIR%/htmlhelp.
goto end
)
if "%1" == "qthelp" (
%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can run "qcollectiongenerator" with the ^
.qhcp project file in %BUILDDIR%/qthelp, like this:
echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-constance.qhcp
echo.To view the help file:
echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-constance.ghc
goto end
)
if "%1" == "devhelp" (
%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished.
goto end
)
if "%1" == "epub" (
%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The epub file is in %BUILDDIR%/epub.
goto end
)
if "%1" == "latex" (
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
if errorlevel 1 exit /b 1
echo.
echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
goto end
)
if "%1" == "latexpdf" (
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
cd %BUILDDIR%/latex
make all-pdf
cd %BUILDDIR%/..
echo.
echo.Build finished; the PDF files are in %BUILDDIR%/latex.
goto end
)
if "%1" == "latexpdfja" (
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
cd %BUILDDIR%/latex
make all-pdf-ja
cd %BUILDDIR%/..
echo.
echo.Build finished; the PDF files are in %BUILDDIR%/latex.
goto end
)
if "%1" == "text" (
%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The text files are in %BUILDDIR%/text.
goto end
)
if "%1" == "man" (
%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The manual pages are in %BUILDDIR%/man.
goto end
)
if "%1" == "texinfo" (
%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
goto end
)
if "%1" == "gettext" (
%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
goto end
)
if "%1" == "changes" (
%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
if errorlevel 1 exit /b 1
echo.
echo.The overview file is in %BUILDDIR%/changes.
goto end
)
if "%1" == "linkcheck" (
%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
if errorlevel 1 exit /b 1
echo.
echo.Link check complete; look for any errors in the above output ^
or in %BUILDDIR%/linkcheck/output.txt.
goto end
)
if "%1" == "doctest" (
%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
if errorlevel 1 exit /b 1
echo.
echo.Testing of doctests in the sources finished, look at the ^
results in %BUILDDIR%/doctest/output.txt.
goto end
)
if "%1" == "xml" (
%SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The XML files are in %BUILDDIR%/xml.
goto end
)
if "%1" == "pseudoxml" (
%SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
goto end
)
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

3
docs/requirements.txt Normal file
View file

@ -0,0 +1,3 @@
readthedocs-sphinx-search==0.3.2
sphinx==7.3.7
sphinx-rtd-theme==2.0.0

View file

@ -15,7 +15,7 @@ Usage
It can be used as a decorator at the :class:`~django.test.TestCase` level, the
method level and also as a
`context manager <https://www.python.org/dev/peps/pep-0343/>`_.
`context manager <https://peps.python.org/pep-0343/>`_.
.. code-block:: python
@ -38,3 +38,87 @@ method level and also as a
def test_what_is_your_favourite_color(self):
with override_config(YOUR_FAVOURITE_COLOR="Blue?"):
self.assertEqual(config.YOUR_FAVOURITE_COLOR, "Blue?")
Pytest usage
~~~~~~~~~~~~
Django-constance provides pytest plugin that adds marker
:class:`@pytest.mark.override_config()`. It handles config override for
module/class/function, and automatically revert any changes made to the
constance config values when test is completed.
.. py:function:: pytest.mark.override_config(**kwargs)
Specify different config values for the marked tests in kwargs.
Module scope override
.. code-block:: python
pytestmark = pytest.mark.override_config(API_URL="/awesome/url/")
def test_api_url_is_awesome():
...
Class/function scope
.. code-block:: python
from constance import config
@pytest.mark.override_config(API_URL="/awesome/url/")
class SomeClassTest:
def test_is_awesome_url(self):
assert config.API_URL == "/awesome/url/"
@pytest.mark.override_config(API_URL="/another/awesome/url/")
def test_another_awesome_url(self):
assert config.API_URL == "/another/awesome/url/"
If you want to use override as a context manager or decorator, consider using
.. code-block:: python
from constance.test.pytest import override_config
def test_override_context_manager():
with override_config(BOOL_VALUE=False):
...
# or
@override_config(BOOL_VALUE=False)
def test_override_context_manager():
...
Pytest fixture as function or method parameter.
.. note:: No import needed as fixture is available globally.
.. code-block:: python
def test_api_url_is_awesome(override_config):
with override_config(API_URL="/awesome/url/"):
...
Any scope, auto-used fixture alternative can also be implemented like this
.. code-block:: python
@pytest.fixture(scope='module', autouse=True) # e.g. module scope
def api_url(override_config):
with override_config(API_URL="/awesome/url/"):
yield
Memory backend
~~~~~~~~~~~~~~
If you don't want to rely on any external services such as Redis or database when
running your unittests you can select :class:`MemoryBackend` for a test Django settings file
.. code-block:: python
CONSTANCE_BACKEND = 'constance.backends.memory.MemoryBackend'
It will provide simple thread-safe backend which will reset to default values after each
test run.

View file

@ -1,4 +1,5 @@
from django.contrib import admin
from cheeseshop.apps.catalog.models import Brand
admin.site.register(Brand)

View file

@ -1,20 +1,16 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Brand',
name="Brand",
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(max_length=75)),
("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)),
("name", models.CharField(max_length=75)),
],
),
]

View file

@ -1,5 +1,5 @@
from django.db import models
class Brand(models.Model):
name = models.CharField(max_length=75)

View file

@ -1,5 +1,7 @@
from django.contrib import admin
from cheeseshop.apps.storage.models import Shelf, Supply
from cheeseshop.apps.storage.models import Shelf
from cheeseshop.apps.storage.models import Supply
admin.site.register(Shelf)
admin.site.register(Supply)

View file

@ -1,33 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Shelf',
name="Shelf",
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(max_length=75)),
("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)),
("name", models.CharField(max_length=75)),
],
options={
'verbose_name_plural': 'shelves',
"verbose_name_plural": "shelves",
},
),
migrations.CreateModel(
name='Supply',
name="Supply",
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(max_length=75)),
("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)),
("name", models.CharField(max_length=75)),
],
options={
'verbose_name_plural': 'supplies',
"verbose_name_plural": "supplies",
},
),
]

View file

@ -1,14 +1,15 @@
from django.db import models
class Shelf(models.Model):
name = models.CharField(max_length=75)
class Meta:
verbose_name_plural = 'shelves'
verbose_name_plural = "shelves"
class Supply(models.Model):
name = models.CharField(max_length=75)
class Meta:
verbose_name_plural = 'supplies'
verbose_name_plural = "supplies"

View file

@ -0,0 +1,25 @@
import json
from django.forms import fields
from django.forms import widgets
class JsonField(fields.CharField):
widget = widgets.Textarea
def __init__(self, rows: int = 5, **kwargs):
self.rows = rows
super().__init__(**kwargs)
def widget_attrs(self, widget: widgets.Widget):
attrs = super().widget_attrs(widget)
attrs["rows"] = self.rows
return attrs
def to_python(self, value):
if value:
return json.loads(value)
return {}
def prepare_value(self, value):
return json.dumps(value)

View file

@ -1,13 +1,11 @@
"""
Django settings for cheeseshop project.
Generated by 'django-admin startproject' using Django 1.8.14.
For more information on this file, see
https://docs.djangoproject.com/en/1.8/topics/settings/
https://docs.djangoproject.com/en/4.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.8/ref/settings/
https://docs.djangoproject.com/en/4.1/ref/settings/
"""
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@ -18,12 +16,12 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
SITE_ID = 1
# 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!
DEBUG = True
@ -34,102 +32,122 @@ ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.staticfiles',
'cheeseshop.apps.catalog',
'cheeseshop.apps.storage',
'constance',
'constance.backends.database',
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
"cheeseshop.apps.catalog",
"cheeseshop.apps.storage",
"constance",
)
MIDDLEWARE_CLASSES = (
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
MIDDLEWARE = (
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
)
ROOT_URLCONF = 'cheeseshop.urls'
ROOT_URLCONF = "cheeseshop.urls"
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = 'cheeseshop.wsgi.application'
WSGI_APPLICATION = "cheeseshop.wsgi.application"
# Database
# https://docs.djangoproject.com/en/1.8/ref/settings/#databases
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
#'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
'NAME': '/tmp/cheeseshop.db',
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "/tmp/cheeseshop.db",
}
}
CONSTANCE_REDIS_CONNECTION = {
'host': 'localhost',
'port': 6379,
'db': 0,
"host": "localhost",
"port": 6379,
"db": 0,
}
CONSTANCE_ADDITIONAL_FIELDS = {
'yes_no_null_select': [
'django.forms.fields.ChoiceField',
{
'widget': 'django.forms.Select',
'choices': ((None, "-----"), ("yes", "Yes"), ("no", "No"))
}
"yes_no_null_select": [
"django.forms.fields.ChoiceField",
{"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"],
"image_field": ["django.forms.ImageField", {}],
}
CONSTANCE_CONFIG = {
'BANNER': ('The National Cheese Emporium', 'name of the shop'),
'OWNER': ('Mr. Henry Wensleydale', 'owner of the shop'),
'OWNER_EMAIL': ('henry@example.com', 'contact email for owner', 'email'),
'MUSICIANS': (4, 'number of musicians inside the shop'),
'DATE_ESTABLISHED': (date(1972, 11, 30), "the shop's first opening"),
'MY_SELECT_KEY': ('yes', 'select yes or no', 'yes_no_null_select'),
"BANNER": ("The National Cheese Emporium", "name of the shop"),
"OWNER": ("Mr. Henry Wensleydale", "owner of the shop"),
"OWNER_EMAIL": ("henry@example.com", "contact email for owner", "email"),
"MUSICIANS": (4, "number of musicians inside the shop"),
"DATE_ESTABLISHED": (date(1972, 11, 30), "the shop's first opening"),
"MY_SELECT_KEY": ("yes", "select yes or no", "yes_no_null_select"),
"MULTILINE": ("Line one\nLine two", "multiline string"),
"JSON_DATA": (
{"a": 1_000, "b": "test", "max": 30_000_000},
"Some test data for json",
"json_field",
),
"LOGO": (
"",
"Logo image file",
"image_field",
),
}
CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
CONSTANCE_CONFIG_FIELDSETS = {
"Cheese shop general info": [
"BANNER",
"OWNER",
"OWNER_EMAIL",
"MUSICIANS",
"DATE_ESTABLISHED",
"LOGO",
],
"Awkward test settings": ["MY_SELECT_KEY", "MULTILINE", "JSON_DATA"],
}
CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend"
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
'LOCATION': '127.0.0.1:11211',
"default": {
"BACKEND": "django.core.cache.backends.memcached.PyMemcacheCache",
"LOCATION": "127.0.0.1:11211",
}
}
CONSTANCE_DATABASE_CACHE_BACKEND = 'default'
CONSTANCE_DATABASE_CACHE_BACKEND = "default"
# Internationalization
# https://docs.djangoproject.com/en/1.8/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
@ -139,6 +157,14 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.8/howto/static-files/
# https://docs.djangoproject.com/en/4.1/howto/static-files/
STATIC_URL = '/static/'
STATIC_URL = "/static/"
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
CONSTANCE_FILE_ROOT = "constance"
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"

View file

@ -1,11 +1,12 @@
from django.conf.urls import include, url
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.contrib import admin
from django.conf import settings
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import re_path
admin.autodiscover()
urlpatterns = [
url(r'^admin/', include(admin.site.urls)),
re_path("admin/", admin.site.urls),
]
if settings.DEBUG:

View file

@ -1,12 +1,3 @@
"""
WSGI config for cheeseshop project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application

View file

@ -1,2 +1,3 @@
Django
python-memcached
Django>=3.2
Pillow
pymemcache

109
pyproject.toml Normal file
View file

@ -0,0 +1,109 @@
[build-system]
requires = ["setuptools>=42", "wheel", "setuptools_scm>=8"]
build-backend = "setuptools.build_meta"
[project]
name = "django-constance"
dynamic = ["version"]
description = "Django live settings with pluggable backends, including Redis."
readme = "README.rst"
license = { text = "BSD" }
requires-python = ">=3.8"
authors = [
{ name = "Jannis Leidel", email = "jannis@leidel.info" },
]
keywords = ["django", "libraries", "redis", "settings"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Framework :: Django",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Framework :: Django :: 5.2",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Utilities",
]
[project.optional-dependencies]
redis = [
"redis",
]
[project.entry-points.pytest11]
pytest-django-constance = "constance.test.pytest"
[project.urls]
homepage = "https://github.com/jazzband/django-constance/"
documentation = "https://django-constance.readthedocs.io/en/latest/"
repository = "https://github.com/jazzband/django-constance/"
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]
include = ["constance*"]
[tool.setuptools_scm]
version_file = "constance/_version.py"
[tool.ruff]
line-length = 120
indent-width = 4
[tool.ruff.format]
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.ruff.lint]
select = [
"B",
"D",
"E",
"ERA",
"EXE",
"F",
"FBT",
"FURB",
"G",
"FA",
"I",
"ICN",
"INP",
"LOG",
"PGH",
"RET",
"RUF",
"S",
"SIM",
"TID",
"UP",
"W",
]
ignore = ["D1", "D203", "D205", "D415", "D212", "RUF012", "D400", "D401"]
[tool.ruff.lint.per-file-ignores]
"docs/*" = ["INP"]
"example/*" = ["S"]
"tests/*" = ["S"]
[tool.ruff.lint.isort]
force-single-line = true
[tool.ruff.lint.flake8-boolean-trap]
extend-allowed-calls = ["unittest.mock.patch", "django.db.models.Value"]

View file

@ -1,2 +0,0 @@
[bdist_wheel]
universal = 1

View file

@ -1,64 +0,0 @@
import os
import re
import codecs
from setuptools import setup, find_packages
def read(*parts):
filename = os.path.join(os.path.dirname(__file__), *parts)
with codecs.open(filename, encoding='utf-8') as fp:
return fp.read()
def find_version(*file_paths):
version_file = read(*file_paths)
version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]",
version_file, re.M)
if version_match:
return version_match.group(1)
raise RuntimeError("Unable to find version string.")
setup(
name='django-constance',
version=find_version("constance", "__init__.py"),
url="http://github.com/jazzband/django-constance",
description='Django live settings with pluggable backends, including Redis.',
long_description=read('README.rst'),
author='Jannis Leidel',
author_email='jannis@leidel.info',
license='BSD',
keywords='django libraries settings redis'.split(),
platforms='any',
classifiers=[
'Development Status :: 5 - Production/Stable',
'Environment :: Web Environment',
'Framework :: Django',
'Framework :: Django :: 1.8',
'Framework :: Django :: 1.9',
'Framework :: Django :: 1.10',
'Framework :: Django :: 1.11',
'Framework :: Django :: 2.0',
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
'Natural Language :: English',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: Implementation :: CPython',
'Programming Language :: Python :: Implementation :: PyPy',
'Topic :: Utilities',
],
packages=find_packages(exclude=['tests', 'tests.*']),
include_package_data=True,
zip_safe=False,
extras_require={
'database': ['django-picklefield'],
'redis': ['redis'],
}
)

View file

@ -0,0 +1,148 @@
from django.test import TestCase
from django.test import TransactionTestCase
from constance import settings
from constance.base import Config
from tests.storage import StorageTestsMixin
class TestDatabase(StorageTestsMixin, TestCase):
def setUp(self):
self.old_backend = settings.BACKEND
settings.BACKEND = "constance.backends.database.DatabaseBackend"
super().setUp()
def test_database_queries(self):
# Read and set to default value
with self.assertNumQueries(5):
self.assertEqual(self.config.INT_VALUE, 1)
# Read again
with self.assertNumQueries(1):
self.assertEqual(self.config.INT_VALUE, 1)
# Set value
with self.assertNumQueries(2):
self.config.INT_VALUE = 15
def tearDown(self):
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

@ -0,0 +1,58 @@
from django.test import TestCase
from django.test import TransactionTestCase
from constance import settings
from constance.base import Config
from tests.storage import StorageTestsMixin
class TestMemory(StorageTestsMixin, TestCase):
def setUp(self):
self.old_backend = settings.BACKEND
settings.BACKEND = "constance.backends.memory.MemoryBackend"
super().setUp()
self.config._backend._storage = {}
def tearDown(self):
self.config._backend._storage = {}
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

@ -0,0 +1,261 @@
from unittest import mock
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from django.test import TransactionTestCase
from constance import settings
from constance.backends.redisd import RedisBackend
from constance.base import Config
from tests.storage import StorageTestsMixin
class TestRedis(StorageTestsMixin, TestCase):
_BACKEND = "constance.backends.redisd.RedisBackend"
def setUp(self):
self.old_backend = settings.BACKEND
settings.BACKEND = self._BACKEND
super().setUp()
self.config._backend._rd.clear()
def tearDown(self):
self.config._backend._rd.clear()
settings.BACKEND = self.old_backend
def test_mget_empty_keys(self):
result = self.config._backend.mget([])
self.assertEqual(result, {})
class TestCachingRedis(TestRedis):
_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,11 +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):
self[key] = value
_shared_store[key] = value
def get(self, key, default=None):
return _shared_store.get(key, default)
def mget(self, keys):
values = []
for key in keys:
value = self.get(key, None)
if value is not None:
values.append(value)
return values
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

@ -1,106 +1,118 @@
# -*- encoding: utf-8 -*-
from django.utils import six
from datetime import datetime, date, time, timedelta
from datetime import date
from datetime import datetime
from datetime import time
from datetime import timedelta
from decimal import Decimal
SECRET_KEY = "cheese"
SECRET_KEY = 'cheese'
MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
MIDDLEWARE = (
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.auth.middleware.SessionAuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
)
DATABASE_ENGINE = 'sqlite3'
DATABASE_ENGINE = "sqlite3"
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
},
'secondary': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
"secondary": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
},
}
CACHES = {
"default": {
"BACKEND": "tests.cache_mockup.Cache",
"LOCATION": "locmem",
}
}
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.staticfiles',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'constance',
'constance.backends.database',
"django.contrib.admin",
"django.contrib.staticfiles",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"constance",
"constance.backends.database",
)
ROOT_URLCONF = 'tests.urls'
ROOT_URLCONF = "tests.urls"
CONSTANCE_REDIS_CONNECTION_CLASS = 'tests.redis_mockup.Connection'
long_value = 123456
if not six.PY3:
long_value = long(long_value)
CONSTANCE_REDIS_CONNECTION_CLASS = "tests.redis_mockup.Connection"
CONSTANCE_REDIS_ASYNC_CONNECTION_CLASS = "tests.redis_mockup.AsyncConnection"
CONSTANCE_ADDITIONAL_FIELDS = {
'yes_no_null_select': [
'django.forms.fields.ChoiceField',
{
'widget': 'django.forms.Select',
'choices': ((None, "-----"), ("yes", "Yes"), ("no", "No"))
}
"yes_no_null_select": [
"django.forms.fields.ChoiceField",
{"widget": "django.forms.Select", "choices": ((None, "-----"), ("yes", "Yes"), ("no", "No"))},
],
# 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"}],
"json": ["django.forms.fields.CharField", {"widget": "django.forms.Textarea"}],
}
USE_TZ = True
CONSTANCE_CONFIG = {
'INT_VALUE': (1, 'some int'),
'LONG_VALUE': (long_value, 'some looong int'),
'BOOL_VALUE': (True, 'true or false'),
'STRING_VALUE': ('Hello world', 'greetings'),
'UNICODE_VALUE': (u'Rivière-Bonjour რუსთაველი', 'greetings'),
'DECIMAL_VALUE': (Decimal('0.1'), 'the first release version'),
'DATETIME_VALUE': (datetime(2010, 8, 23, 11, 29, 24),
'time of the first commit'),
'FLOAT_VALUE': (3.1415926536, 'PI'),
'DATE_VALUE': (date(2010, 12, 24), 'Merry Chrismas'),
'TIME_VALUE': (time(23, 59, 59), 'And happy New Year'),
'TIMEDELTA_VALUE': (timedelta(days=1, hours=2, minutes=3), 'Interval'),
'CHOICE_VALUE': ('yes', 'select yes or no', 'yes_no_null_select'),
'LINEBREAK_VALUE': ('Spam spam', 'eggs\neggs'),
'EMAIL_VALUE': ('test@example.com', 'An email', 'email'),
"INT_VALUE": (1, "some int"),
"BOOL_VALUE": (True, "true or false"),
"STRING_VALUE": ("Hello world", "greetings"),
"DECIMAL_VALUE": (Decimal("0.1"), "the first release version"),
"DATETIME_VALUE": (datetime(2010, 8, 23, 11, 29, 24), "time of the first commit"),
"FLOAT_VALUE": (3.1415926536, "PI"),
"DATE_VALUE": (date(2010, 12, 24), "Merry Chrismas"),
"TIME_VALUE": (time(23, 59, 59), "And happy New Year"),
"TIMEDELTA_VALUE": (timedelta(days=1, hours=2, minutes=3), "Interval"),
"CHOICE_VALUE": ("yes", "select yes or no", "yes_no_null_select"),
"LINEBREAK_VALUE": ("Spam spam", "eggs\neggs"),
"EMAIL_VALUE": ("test@example.com", "An email", "email"),
"LIST_VALUE": ([1, "1", date(2019, 1, 1)], "A list", "array"),
"JSON_VALUE": (
{
"key": "value",
"key2": 2,
"key3": [1, 2, 3],
"key4": {"key": "value"},
"key5": date(2019, 1, 1),
"key6": None,
},
"A JSON object",
"json",
),
}
DEBUG = True
STATIC_ROOT = './static/'
STATIC_ROOT = "./static/"
STATIC_URL = '/static/'
STATIC_URL = "/static/"
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.i18n',
'django.template.context_processors.request',
'django.template.context_processors.static',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'constance.context_processors.config',
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.i18n",
"django.template.context_processors.request",
"django.template.context_processors.static",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"constance.context_processors.config",
],
},
},

View file

@ -1,96 +1,108 @@
# -*- encoding: utf-8 -*-
from datetime import datetime, date, time, timedelta
from datetime import date
from datetime import datetime
from datetime import time
from datetime import timedelta
from decimal import Decimal
from django.utils import six
from constance import settings
from constance.base import Config
if six.PY3:
def long(value):
return value
class StorageTestsMixin(object):
class StorageTestsMixin:
def setUp(self):
self.config = Config()
super(StorageTestsMixin, self).setUp()
super().setUp()
def test_store(self):
self.assertEqual(self.config.INT_VALUE, 1)
self.assertEqual(self.config.LONG_VALUE, long(123456))
self.assertEqual(self.config.BOOL_VALUE, True)
self.assertEqual(self.config.STRING_VALUE, 'Hello world')
self.assertEqual(self.config.UNICODE_VALUE, u'Rivière-Bonjour რუსთაველი')
self.assertEqual(self.config.DECIMAL_VALUE, Decimal('0.1'))
self.assertEqual(self.config.STRING_VALUE, "Hello world")
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.FLOAT_VALUE, 3.1415926536)
self.assertEqual(self.config.DATE_VALUE, date(2010, 12, 24))
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.CHOICE_VALUE, 'yes')
self.assertEqual(self.config.EMAIL_VALUE, 'test@example.com')
self.assertEqual(self.config.CHOICE_VALUE, "yes")
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.JSON_VALUE,
{
"key": "value",
"key2": 2,
"key3": [1, 2, 3],
"key4": {"key": "value"},
"key5": date(2019, 1, 1),
"key6": None,
},
)
# set values
self.config.INT_VALUE = 100
self.config.LONG_VALUE = long(654321)
self.config.BOOL_VALUE = False
self.config.STRING_VALUE = 'Beware the weeping angel'
self.config.UNICODE_VALUE = u'Québec'
self.config.DECIMAL_VALUE = Decimal('1.2')
self.config.STRING_VALUE = "Beware the weeping angel"
self.config.DECIMAL_VALUE = Decimal("1.2")
self.config.DATETIME_VALUE = datetime(1977, 10, 2)
self.config.FLOAT_VALUE = 2.718281845905
self.config.DATE_VALUE = date(2001, 12, 20)
self.config.TIME_VALUE = time(1, 59, 0)
self.config.TIMEDELTA_VALUE = timedelta(days=2, hours=3, minutes=4)
self.config.CHOICE_VALUE = 'no'
self.config.EMAIL_VALUE = 'foo@bar.com'
self.config.CHOICE_VALUE = "no"
self.config.EMAIL_VALUE = "foo@bar.com"
self.config.LIST_VALUE = [1, date(2020, 2, 2)]
self.config.JSON_VALUE = {"key": "OK"}
# read again
self.assertEqual(self.config.INT_VALUE, 100)
self.assertEqual(self.config.LONG_VALUE, long(654321))
self.assertEqual(self.config.BOOL_VALUE, False)
self.assertEqual(self.config.STRING_VALUE, 'Beware the weeping angel')
self.assertEqual(self.config.UNICODE_VALUE, u'Québec')
self.assertEqual(self.config.DECIMAL_VALUE, Decimal('1.2'))
self.assertEqual(self.config.STRING_VALUE, "Beware the weeping angel")
self.assertEqual(self.config.DECIMAL_VALUE, Decimal("1.2"))
self.assertEqual(self.config.DATETIME_VALUE, datetime(1977, 10, 2))
self.assertEqual(self.config.FLOAT_VALUE, 2.718281845905)
self.assertEqual(self.config.DATE_VALUE, date(2001, 12, 20))
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.CHOICE_VALUE, 'no')
self.assertEqual(self.config.EMAIL_VALUE, 'foo@bar.com')
self.assertEqual(self.config.CHOICE_VALUE, "no")
self.assertEqual(self.config.EMAIL_VALUE, "foo@bar.com")
self.assertEqual(self.config.LIST_VALUE, [1, date(2020, 2, 2)])
self.assertEqual(self.config.JSON_VALUE, {"key": "OK"})
def test_nonexistent(self):
try:
self.config.NON_EXISTENT
except Exception as e:
self.assertEqual(type(e), AttributeError)
self.assertRaises(AttributeError, getattr, self.config, "NON_EXISTENT")
try:
with self.assertRaises(AttributeError):
self.config.NON_EXISTENT = 1
except Exception as e:
self.assertEqual(type(e), AttributeError)
def test_missing_values(self):
# set some values and leave out others
self.config.LONG_VALUE = long(654321)
self.config.BOOL_VALUE = False
self.config.UNICODE_VALUE = u'Québec'
self.config.DECIMAL_VALUE = Decimal('1.2')
self.config.DECIMAL_VALUE = Decimal("1.2")
self.config.DATETIME_VALUE = datetime(1977, 10, 2)
self.config.DATE_VALUE = date(2001, 12, 20)
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.LONG_VALUE, long(654321))
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.UNICODE_VALUE, u'Québec')
self.assertEqual(self.config.DECIMAL_VALUE, Decimal('1.2'))
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.DATETIME_VALUE, datetime(1977, 10, 2))
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.TIME_VALUE, time(1, 59, 0))
self.assertEqual(self.config.TIMEDELTA_VALUE, timedelta(days=1, hours=2, minutes=3))
def test_backend_retrieves_multiple_values(self):
# Check corner cases such as falsy values
self.config.INT_VALUE = 0
self.config.BOOL_VALUE = False
self.config.STRING_VALUE = ""
values = self.config._backend.mget(settings.CONFIG)
self.assertEqual(values["INT_VALUE"], 0)
self.assertEqual(values["BOOL_VALUE"], False)
self.assertEqual(values["STRING_VALUE"], "")
def test_backend_does_not_return_none_values(self):
result = self.config._backend.mget(settings.CONFIG)
self.assertEqual(result, {})

View file

@ -1,110 +1,326 @@
import mock
from datetime import datetime
from unittest import mock
from django.contrib import admin
from django.contrib.auth.models import User, Permission
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect
from django.template.defaultfilters import linebreaksbr
from django.test import TestCase, RequestFactory
from django.utils import six
from django.test import RequestFactory
from django.test import TestCase
from django.utils.translation import gettext_lazy as _
from constance import settings
from constance.admin import Config
from constance.forms import ConstanceForm
from constance.utils import get_values
class TestAdmin(TestCase):
model = Config
def setUp(self):
super(TestAdmin, self).setUp()
super().setUp()
self.rf = RequestFactory()
self.superuser = User.objects.create_superuser('admin', 'nimda', 'a@a.cz')
self.normaluser = User.objects.create_user('normal', 'nimda', 'b@b.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.is_staff = True
self.normaluser.save()
self.options = admin.site._registry[self.model]
def test_changelist(self):
self.client.login(username='admin', password='nimda')
request = self.rf.get('/admin/constance/config/')
self.client.login(username="admin", password="nimda")
request = self.rf.get("/admin/constance/config/")
request.user = self.superuser
response = self.options.changelist_view(request, {})
self.assertEqual(response.status_code, 200)
def test_custom_auth(self):
settings.SUPERUSER_ONLY = False
self.client.login(username='normal', password='nimda')
request = self.rf.get('/admin/constance/config/')
self.client.login(username="normal", password="nimda")
request = self.rf.get("/admin/constance/config/")
request.user = self.normaluser
self.assertRaises(PermissionDenied,
self.options.changelist_view,
request, {})
self.assertFalse(request.user.has_perm('constance.change_config'))
self.assertRaises(PermissionDenied, self.options.changelist_view, request, {})
self.assertFalse(request.user.has_perm("constance.change_config"))
# 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_permissions.add(Permission.objects.get(codename='change_config'))
self.assertTrue(request.user.has_perm('constance.change_config'))
request.user.user_permissions.add(Permission.objects.get(codename="change_config"))
self.assertTrue(request.user.has_perm("constance.change_config"))
response = self.options.changelist_view(request, {})
self.assertEqual(response.status_code, 200)
def test_str(self):
ct = ContentType.objects.get(app_label='constance', model='config')
self.assertEqual(six.text_type(ct), 'config')
def test_linebreaks(self):
self.client.login(username='admin', password='nimda')
request = self.rf.get('/admin/constance/config/')
self.client.login(username="admin", password="nimda")
request = self.rf.get("/admin/constance/config/")
request.user = self.superuser
response = self.options.changelist_view(request, {})
self.assertContains(response, 'LINEBREAK_VALUE')
self.assertContains(response, linebreaksbr('eggs\neggs'))
self.assertContains(response, "LINEBREAK_VALUE")
self.assertContains(response, linebreaksbr("eggs\neggs"))
@mock.patch('constance.settings.CONFIG_FIELDSETS', {
'Numbers': ('LONG_VALUE', 'INT_VALUE',),
'Text': ('STRING_VALUE', 'UNICODE_VALUE'),
})
@mock.patch(
"constance.settings.CONFIG_FIELDSETS",
{
"Numbers": ("INT_VALUE",),
"Text": ("STRING_VALUE",),
},
)
def test_fieldset_headers(self):
self.client.login(username='admin', password='nimda')
request = self.rf.get('/admin/constance/config/')
self.client.login(username="admin", password="nimda")
request = self.rf.get("/admin/constance/config/")
request.user = self.superuser
response = self.options.changelist_view(request, {})
self.assertContains(response, '<h2>Numbers</h2>')
self.assertContains(response, '<h2>Text</h2>')
self.assertContains(response, "Numbers</h2>")
self.assertContains(response, "Text</h2>")
@mock.patch('constance.settings.CONFIG_FIELDSETS', {
'Numbers': ('LONG_VALUE', 'INT_VALUE',),
})
@mock.patch(
"constance.settings.CONFIG_FIELDSETS",
(
("Numbers", ("INT_VALUE",)),
("Text", ("STRING_VALUE",)),
),
)
def test_fieldset_tuple(self):
self.client.login(username="admin", password="nimda")
request = self.rf.get("/admin/constance/config/")
request.user = self.superuser
response = self.options.changelist_view(request, {})
self.assertContains(response, "Numbers</h2>")
self.assertContains(response, "Text</h2>")
@mock.patch(
"constance.settings.CONFIG_FIELDSETS",
{
"Numbers": {
"fields": (
"INT_VALUE",
"DECIMAL_VALUE",
),
"collapse": True,
},
"Text": {
"fields": (
"STRING_VALUE",
"LINEBREAK_VALUE",
),
"collapse": True,
},
},
)
def test_collapsed_fieldsets(self):
self.client.login(username="admin", password="nimda")
request = self.rf.get("/admin/constance/config/")
request.user = self.superuser
response = self.options.changelist_view(request, {})
self.assertContains(response, "module collapse")
@mock.patch("constance.settings.CONFIG_FIELDSETS", {"FieldSetOne": ("INT_VALUE",)})
@mock.patch(
"constance.settings.CONFIG",
{
"INT_VALUE": (1, "some int"),
},
)
@mock.patch("constance.settings.IGNORE_ADMIN_VERSION_CHECK", True)
@mock.patch("constance.forms.ConstanceForm.save", lambda _: None)
@mock.patch("constance.forms.ConstanceForm.is_valid", lambda _: True)
def test_submit(self):
"""
Test that submitting the admin page results in an http redirect when
everything is in order.
"""
initial_value = {"INT_VALUE": settings.CONFIG["INT_VALUE"][0]}
self.client.login(username="admin", password="nimda")
request = self.rf.post(
"/admin/constance/config/",
data={
**initial_value,
"version": "123",
},
)
request.user = self.superuser
request._dont_enforce_csrf_checks = True
with mock.patch("django.contrib.messages.add_message") as mock_message, mock.patch.object(
ConstanceForm, "__init__", **initial_value, return_value=None
) as mock_form:
response = self.options.changelist_view(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."))
self.assertIsInstance(response, HttpResponseRedirect)
@mock.patch("constance.settings.CONFIG_FIELDSETS", {"FieldSetOne": ("MULTILINE",)})
@mock.patch(
"constance.settings.CONFIG",
{
"MULTILINE": ("Hello\nWorld", "multiline value"),
},
)
@mock.patch("constance.settings.IGNORE_ADMIN_VERSION_CHECK", True)
def test_newlines_normalization(self):
self.client.login(username="admin", password="nimda")
request = self.rf.post(
"/admin/constance/config/",
data={
"MULTILINE": "Hello\r\nWorld",
"version": "123",
},
)
request.user = self.superuser
request._dont_enforce_csrf_checks = True
with mock.patch("django.contrib.messages.add_message"):
response = self.options.changelist_view(request, {})
self.assertIsInstance(response, HttpResponseRedirect)
self.assertEqual(get_values()["MULTILINE"], "Hello\nWorld")
@mock.patch(
"constance.settings.CONFIG",
{
"DATETIME_VALUE": (datetime(2019, 8, 7, 18, 40, 0), "some naive datetime"),
},
)
@mock.patch("constance.settings.IGNORE_ADMIN_VERSION_CHECK", True)
@mock.patch("tests.redis_mockup.Connection.set", mock.MagicMock())
def test_submit_aware_datetime(self):
"""
Test that submitting the admin page results in an http redirect when
everything is in order.
"""
request = self.rf.post(
"/admin/constance/config/",
data={
"DATETIME_VALUE_0": "2019-08-07",
"DATETIME_VALUE_1": "19:17:01",
"version": "123",
},
)
request.user = self.superuser
request._dont_enforce_csrf_checks = True
with mock.patch("django.contrib.messages.add_message"):
response = self.options.changelist_view(request, {})
self.assertIsInstance(response, HttpResponseRedirect)
@mock.patch(
"constance.settings.CONFIG_FIELDSETS",
{
"Numbers": ("INT_VALUE",),
"Text": ("STRING_VALUE",),
},
)
def test_inconsistent_fieldset_submit(self):
"""
Test that the admin page warns users if the CONFIG_FIELDSETS setting
doesn't account for every field in CONFIG.
"""
self.client.login(username="admin", password="nimda")
request = self.rf.post("/admin/constance/config/", data=None)
request.user = self.superuser
request._dont_enforce_csrf_checks = True
with mock.patch("django.contrib.messages.add_message"):
response = self.options.changelist_view(request, {})
self.assertContains(response, "is missing field(s)")
@mock.patch(
"constance.settings.CONFIG_FIELDSETS",
{
"Fieldsets": (
"STRING_VALUE",
"INT_VALUE",
),
},
)
def test_fieldset_ordering_1(self):
"""Ordering of inner list should be preserved"""
self.client.login(username='admin', password='nimda')
request = self.rf.get('/admin/constance/config/')
"""Ordering of inner list should be preserved."""
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_str = response.content.decode('utf-8')
self.assertGreater(
content_str.find('INT_VALUE'),
content_str.find('LONG_VALUE')
)
content_str = response.content.decode()
self.assertGreater(content_str.find("INT_VALUE"), content_str.find("STRING_VALUE"))
@mock.patch('constance.settings.CONFIG_FIELDSETS', {
'Numbers': ('INT_VALUE', 'LONG_VALUE', ),
})
@mock.patch(
"constance.settings.CONFIG_FIELDSETS",
{
"Fieldsets": (
"INT_VALUE",
"STRING_VALUE",
),
},
)
def test_fieldset_ordering_2(self):
"""Ordering of inner list should be preserved"""
self.client.login(username='admin', password='nimda')
request = self.rf.get('/admin/constance/config/')
"""Ordering of inner list should be preserved."""
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_str = response.content.decode('utf-8')
self.assertGreater(
content_str.find('LONG_VALUE'),
content_str.find('INT_VALUE')
content_str = response.content.decode()
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):
self.assertEqual(type(self.model._meta.label), str)

View file

@ -1,54 +0,0 @@
from django.apps import apps
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.db.models import signals
from django.test import TestCase, override_settings
class TestApp(TestCase):
def setUp(self):
self.app_config = apps.get_app_config('constance')
def test_post_migrate_signal_creates_content_type_and_permission_in_default_database(self):
self.assert_uses_correct_database('default')
def test_post_migrate_signal_creates_content_type_and_permission_in_secondary_database(self):
self.assert_uses_correct_database('secondary')
def test_uses_default_db_even_without_giving_using_keyword(self):
self.call_post_migrate(None)
self.assert_content_type_and_permission_created('default')
@override_settings(CONSTANCE_DBS=['default'])
def test_only_use_databases_in_constance_dbs(self):
Permission.objects.using('default').delete()
Permission.objects.using('secondary').delete()
self.assert_uses_correct_database('default')
with self.assertRaises(AssertionError):
self.assert_uses_correct_database('secondary')
def assert_uses_correct_database(self, database_name):
self.call_post_migrate(database_name)
self.assert_content_type_and_permission_created(database_name)
def assert_content_type_and_permission_created(self, database_name):
content_type_queryset = ContentType.objects.filter(app_label=self.app_config.name) \
.using(database_name)
self.assertTrue(content_type_queryset.exists())
permission_queryset = Permission.objects.filter(content_type=content_type_queryset.get()) \
.using(database_name).exists()
self.assertTrue(permission_queryset)
def call_post_migrate(self, database_name):
signals.post_migrate.send(
sender=self.app_config,
app_config=self.app_config,
verbosity=None,
interactive=None,
using=database_name
)

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)

53
tests/test_checks.py Normal file
View file

@ -0,0 +1,53 @@
from unittest import mock
from django.test import TestCase
from constance import settings
from constance.checks import check_fieldsets
from constance.checks import get_inconsistent_fieldnames
class ChecksTestCase(TestCase):
@mock.patch("constance.settings.CONFIG_FIELDSETS", {"Set1": settings.CONFIG.keys()})
def test_get_inconsistent_fieldnames_none(self):
"""
Test that get_inconsistent_fieldnames returns an empty data and no checks fail
if CONFIG_FIELDSETS accounts for every key in settings.CONFIG.
"""
missing_keys, extra_keys = get_inconsistent_fieldnames()
self.assertFalse(missing_keys)
self.assertFalse(extra_keys)
@mock.patch(
"constance.settings.CONFIG_FIELDSETS",
{"Set1": list(settings.CONFIG.keys())[:-1]},
)
def test_get_inconsistent_fieldnames_for_missing_keys(self):
"""
Test that get_inconsistent_fieldnames returns data and the check fails
if CONFIG_FIELDSETS does not account for every key in settings.CONFIG.
"""
missing_keys, extra_keys = get_inconsistent_fieldnames()
self.assertTrue(missing_keys)
self.assertFalse(extra_keys)
self.assertEqual(1, len(check_fieldsets()))
@mock.patch(
"constance.settings.CONFIG_FIELDSETS",
{"Set1": [*settings.CONFIG.keys(), "FORGOTTEN_KEY"]},
)
def test_get_inconsistent_fieldnames_for_extra_keys(self):
"""
Test that get_inconsistent_fieldnames returns data and the check fails
if CONFIG_FIELDSETS contains extra key that is absent in settings.CONFIG.
"""
missing_keys, extra_keys = get_inconsistent_fieldnames()
self.assertFalse(missing_keys)
self.assertTrue(extra_keys)
self.assertEqual(1, len(check_fieldsets()))
@mock.patch("constance.settings.CONFIG_FIELDSETS", {})
def test_check_fieldsets(self):
"""check_fieldsets should not output warning if CONFIG_FIELDSETS is not defined."""
del settings.CONFIG_FIELDSETS
self.assertEqual(0, len(check_fieldsets()))

View file

@ -1,14 +1,18 @@
# -*- coding: utf-8 -*-
import contextlib
from datetime import datetime
from io import StringIO
from textwrap import dedent
from django.core.management import call_command, CommandError
from django.conf import settings
from django.core.management import CommandError
from django.core.management import call_command
from django.test import TransactionTestCase
from django.test import override_settings
from django.utils import timezone
from django.utils.encoding import smart_str
from django.utils.six import StringIO
from constance import config
from constance.models import Constance
class CliTestCase(TransactionTestCase):
@ -16,57 +20,121 @@ class CliTestCase(TransactionTestCase):
self.out = StringIO()
def test_help(self):
try:
call_command('constance', '--help')
except SystemExit:
pass
with contextlib.suppress(SystemExit):
call_command("constance", "--help")
def test_list(self):
call_command('constance', 'list', stdout=self.out)
call_command("constance", "list", stdout=self.out)
self.assertEqual(set(self.out.getvalue().splitlines()), set(dedent(smart_str(
u""" BOOL_VALUE True
EMAIL_VALUE test@example.com
INT_VALUE 1
LINEBREAK_VALUE Spam spam
DATE_VALUE 2010-12-24
TIME_VALUE 23:59:59
TIMEDELTA_VALUE 1 day, 2:03:00
LONG_VALUE 123456
STRING_VALUE Hello world
UNICODE_VALUE Rivière-Bonjour რუსთაველი
CHOICE_VALUE yes
DECIMAL_VALUE 0.1
DATETIME_VALUE 2010-08-23 11:29:24
FLOAT_VALUE 3.1415926536
""")).splitlines()))
self.assertEqual(
set(self.out.getvalue().splitlines()),
set(
dedent(
smart_str(
""" BOOL_VALUE\tTrue
EMAIL_VALUE\ttest@example.com
INT_VALUE\t1
LINEBREAK_VALUE\tSpam spam
DATE_VALUE\t2010-12-24
TIME_VALUE\t23:59:59
TIMEDELTA_VALUE\t1 day, 2:03:00
STRING_VALUE\tHello world
CHOICE_VALUE\tyes
DECIMAL_VALUE\t0.1
DATETIME_VALUE\t2010-08-23 11:29:24
FLOAT_VALUE\t3.1415926536
JSON_VALUE\t{'key': 'value', 'key2': 2, 'key3': [1, 2, 3], 'key4': {'key': 'value'}, 'key5': datetime.date(2019, 1, 1), 'key6': None}
LIST_VALUE\t[1, '1', datetime.date(2019, 1, 1)]
""" # noqa: E501
)
).splitlines()
),
)
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")
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")
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)
self.assertEqual(config.DATETIME_VALUE, datetime(2011, 9, 24, 12, 30, 25))
expected = datetime(2011, 9, 24, 12, 30, 25)
if settings.USE_TZ:
expected = timezone.make_aware(expected)
self.assertEqual(config.DATETIME_VALUE, expected)
def test_get_invalid_name(self):
self.assertRaisesMessage(CommandError, "NOT_A_REAL_CONFIG is not defined in settings.CONSTANCE_CONFIG",
call_command, 'constance', 'get', 'NOT_A_REAL_CONFIG')
self.assertRaisesMessage(
CommandError,
"NOT_A_REAL_CONFIG is not defined in settings.CONSTANCE_CONFIG",
call_command,
"constance",
"get",
"NOT_A_REAL_CONFIG",
)
def test_set_invalid_name(self):
self.assertRaisesMessage(CommandError, "NOT_A_REAL_CONFIG is not defined in settings.CONSTANCE_CONFIG",
call_command, 'constance', 'set', 'NOT_A_REAL_CONFIG', 'foo')
self.assertRaisesMessage(
CommandError,
"NOT_A_REAL_CONFIG is not defined in settings.CONSTANCE_CONFIG",
call_command,
"constance",
"set",
"NOT_A_REAL_CONFIG",
"foo",
)
def test_set_invalid_value(self):
self.assertRaisesMessage(CommandError, "Enter a valid email address.",
call_command, 'constance', 'set', 'EMAIL_VALUE', 'not a valid email')
self.assertRaisesMessage(
CommandError,
"Enter a valid email address.",
call_command,
"constance",
"set",
"EMAIL_VALUE",
"not a valid email",
)
def test_set_invalid_multi_value(self):
self.assertRaisesMessage(CommandError, "Enter a list of values.",
call_command, 'constance', 'set', 'DATETIME_VALUE', '2011-09-24 12:30:25')
self.assertRaisesMessage(
CommandError,
"Enter a list of values.",
call_command,
"constance",
"set",
"DATETIME_VALUE",
"2011-09-24 12:30:25",
)
def test_delete_stale_records(self):
self._populate_database_with_default_values()
initial_count = Constance.objects.count()
Constance.objects.create(key="STALE_KEY", value=None)
call_command("constance", "remove_stale_keys", stdout=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)

113
tests/test_codecs.py Normal file
View file

@ -0,0 +1,113 @@
import uuid
from datetime import date
from datetime import datetime
from datetime import time
from datetime import timedelta
from decimal import Decimal
from unittest import TestCase
from constance.codecs import dumps
from constance.codecs import loads
from constance.codecs import register_type
class TestJSONSerialization(TestCase):
def setUp(self):
self.datetime = datetime(2023, 10, 5, 15, 30, 0)
self.date = date(2023, 10, 5)
self.time = time(15, 30, 0)
self.decimal = Decimal("10.5")
self.uuid = uuid.UUID("12345678123456781234567812345678")
self.string = "test"
self.integer = 42
self.float = 3.14
self.boolean = True
self.none = None
self.timedelta = timedelta(days=1, hours=2, minutes=3)
self.list = [1, 2, self.date]
self.dict = {"key": self.date, "key2": 1}
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.date), '{"__type__": "date", "__value__": "2023-10-05"}')
self.assertEqual(dumps(self.time), '{"__type__": "time", "__value__": "15:30:00"}')
self.assertEqual(dumps(self.decimal), '{"__type__": "decimal", "__value__": "10.5"}')
self.assertEqual(dumps(self.uuid), '{"__type__": "uuid", "__value__": "12345678123456781234567812345678"}')
self.assertEqual(dumps(self.string), '{"__type__": "default", "__value__": "test"}')
self.assertEqual(dumps(self.integer), '{"__type__": "default", "__value__": 42}')
self.assertEqual(dumps(self.float), '{"__type__": "default", "__value__": 3.14}')
self.assertEqual(dumps(self.boolean), '{"__type__": "default", "__value__": true}')
self.assertEqual(dumps(self.none), '{"__type__": "default", "__value__": null}')
self.assertEqual(dumps(self.timedelta), '{"__type__": "timedelta", "__value__": 93780.0}')
self.assertEqual(
dumps(self.list),
'{"__type__": "default", "__value__": [1, 2, {"__type__": "date", "__value__": "2023-10-05"}]}',
)
self.assertEqual(
dumps(self.dict),
'{"__type__": "default", "__value__": {"key": {"__type__": "date", "__value__": "2023-10-05"}, "key2": 1}}',
)
for t in (
self.datetime,
self.date,
self.time,
self.decimal,
self.uuid,
self.string,
self.integer,
self.float,
self.boolean,
self.none,
self.timedelta,
self.dict,
self.list,
):
self.assertEqual(t, loads(dumps(t)))
def test_invalid_deserialization(self):
with self.assertRaisesRegex(ValueError, "Expecting value"):
loads("THIS_IS_NOT_RIGHT")
with self.assertRaisesRegex(ValueError, "Invalid object"):
loads('{"__type__": "THIS_IS_NOT_RIGHT", "__value__": "test", "THIS_IS_NOT_RIGHT": "THIS_IS_NOT_RIGHT"}')
with self.assertRaisesRegex(ValueError, "Unsupported type"):
loads('{"__type__": "THIS_IS_NOT_RIGHT", "__value__": "test"}')
def test_handles_unknown_type(self):
class UnknownType:
pass
with self.assertRaisesRegex(TypeError, "Object of type UnknownType is not JSON serializable"):
dumps(UnknownType())
def test_custom_type_serialization(self):
class CustomType:
def __init__(self, value):
self.value = value
register_type(CustomType, "custom", lambda o: o.value, lambda o: CustomType(o))
custom_data = CustomType("test")
json_data = dumps(custom_data)
self.assertEqual(json_data, '{"__type__": "custom", "__value__": "test"}')
deserialized_data = loads(json_data)
self.assertTrue(isinstance(deserialized_data, CustomType))
self.assertEqual(deserialized_data.value, "test")
def test_register_known_type(self):
with self.assertRaisesRegex(ValueError, "Discriminator must be specified"):
register_type(int, "", lambda o: o.value, lambda o: int(o))
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, "new_custom_type", lambda o: o.value, lambda o: int(o))
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))
def test_nested_collections(self):
data = {"key": [[[[{"key": self.date}]]]]}
self.assertEqual(
dumps(data),
(
'{"__type__": "default", '
'"__value__": {"key": [[[[{"key": {"__type__": "date", "__value__": "2023-10-05"}}]]]]}}'
),
)
self.assertEqual(data, loads(dumps(data)))

View file

@ -1,15 +0,0 @@
from django.test import TestCase
from constance import settings
from tests.storage import StorageTestsMixin
class TestDatabase(StorageTestsMixin, TestCase):
def setUp(self):
self.old_backend = settings.BACKEND
settings.BACKEND = 'constance.backends.database.DatabaseBackend'
super(TestDatabase, self).setUp()
def tearDown(self):
settings.BACKEND = self.old_backend

View file

@ -1,26 +1,23 @@
from constance.admin import ConstanceForm
from django.forms import fields
from django.test import TestCase
from constance.forms import ConstanceForm
class TestForm(TestCase):
def test_form_field_types(self):
f = ConstanceForm({})
self.assertIsInstance(f.fields['INT_VALUE'], fields.IntegerField)
self.assertIsInstance(f.fields['LONG_VALUE'], fields.IntegerField)
self.assertIsInstance(f.fields['BOOL_VALUE'], fields.BooleanField)
self.assertIsInstance(f.fields['STRING_VALUE'], fields.CharField)
self.assertIsInstance(f.fields['UNICODE_VALUE'], fields.CharField)
self.assertIsInstance(f.fields['DECIMAL_VALUE'], fields.DecimalField)
self.assertIsInstance(f.fields['DATETIME_VALUE'], fields.SplitDateTimeField)
self.assertIsInstance(f.fields['TIMEDELTA_VALUE'], fields.DurationField)
self.assertIsInstance(f.fields['FLOAT_VALUE'], fields.FloatField)
self.assertIsInstance(f.fields['DATE_VALUE'], fields.DateField)
self.assertIsInstance(f.fields['TIME_VALUE'], fields.TimeField)
self.assertIsInstance(f.fields["INT_VALUE"], fields.IntegerField)
self.assertIsInstance(f.fields["BOOL_VALUE"], fields.BooleanField)
self.assertIsInstance(f.fields["STRING_VALUE"], fields.CharField)
self.assertIsInstance(f.fields["DECIMAL_VALUE"], fields.DecimalField)
self.assertIsInstance(f.fields["DATETIME_VALUE"], fields.SplitDateTimeField)
self.assertIsInstance(f.fields["TIMEDELTA_VALUE"], fields.DurationField)
self.assertIsInstance(f.fields["FLOAT_VALUE"], fields.FloatField)
self.assertIsInstance(f.fields["DATE_VALUE"], fields.DateField)
self.assertIsInstance(f.fields["TIME_VALUE"], fields.TimeField)
# from CONSTANCE_ADDITIONAL_FIELDS
self.assertIsInstance(f.fields['CHOICE_VALUE'], fields.ChoiceField)
self.assertIsInstance(f.fields['EMAIL_VALUE'], fields.EmailField)
self.assertIsInstance(f.fields["CHOICE_VALUE"], fields.ChoiceField)
self.assertIsInstance(f.fields["EMAIL_VALUE"], fields.EmailField)

View file

@ -0,0 +1,73 @@
import unittest
try:
import pytest
from constance import config
from constance.test.pytest import override_config
class TestPytestOverrideConfigFunctionDecorator:
"""
Test that the override_config decorator works correctly for Pytest classes.
Test usage of override_config on test method and as context manager.
"""
def test_default_value_is_true(self):
"""Assert that the default value of config.BOOL_VALUE is True."""
assert config.BOOL_VALUE
@pytest.mark.override_config(BOOL_VALUE=False)
def test_override_config_on_method_changes_config_value(self):
"""Assert that the pytest mark decorator changes config.BOOL_VALUE."""
assert not config.BOOL_VALUE
def test_override_config_as_context_manager_changes_config_value(self):
"""Assert that the context manager changes config.BOOL_VALUE."""
with override_config(BOOL_VALUE=False):
assert not config.BOOL_VALUE
assert config.BOOL_VALUE
@override_config(BOOL_VALUE=False)
def test_method_decorator(self):
"""Ensure `override_config` can be used as test method decorator."""
assert not config.BOOL_VALUE
@pytest.mark.override_config(BOOL_VALUE=False)
class TestPytestOverrideConfigDecorator:
"""Test that the override_config decorator works on classes."""
def test_override_config_on_class_changes_config_value(self):
"""Assert that the class decorator changes config.BOOL_VALUE."""
assert not config.BOOL_VALUE
@pytest.mark.override_config(BOOL_VALUE="True")
def test_override_config_on_overridden_value(self):
"""Ensure that method mark decorator changes already overridden value for class."""
assert config.BOOL_VALUE == "True"
def test_fixture_override_config(override_config):
"""
Ensure `override_config` fixture is available globally
and can be used in test functions.
"""
with override_config(BOOL_VALUE=False):
assert not config.BOOL_VALUE
@override_config(BOOL_VALUE=False)
def test_func_decorator():
"""Ensure `override_config` can be used as test function decorator."""
assert not config.BOOL_VALUE
except ImportError:
pass
class PytestTests(unittest.TestCase):
def setUp(self):
self.skipTest("Skip all pytest tests when using unittest")
def test_do_not_skip_silently(self):
"""If no at least one test present, unittest silently skips module."""
pass

Some files were not shown because too many files have changed in this diff Show more