├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .readthedocs.yaml ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── Makefile ├── authors.rst ├── bind_preferences_to_models.rst ├── conf.py ├── contributing.rst ├── history.rst ├── index.rst ├── installation.rst ├── lifecycle.rst ├── make.bat ├── preference_types.rst ├── quickstart.rst ├── react_to_updates.rst ├── readme.rst ├── rest_api.rst └── upgrade.rst ├── dynamic_preferences ├── __init__.py ├── admin.py ├── api │ ├── __init__.py │ ├── serializers.py │ └── viewsets.py ├── apps.py ├── exceptions.py ├── forms.py ├── locale │ ├── ar │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── id │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── pl │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── checkpreferences.py ├── managers.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20150712_0332.py │ ├── 0003_auto_20151223_1407.py │ ├── 0004_move_user_model.py │ ├── 0005_auto_20181120_0848.py │ ├── 0006_auto_20191001_2236.py │ └── __init__.py ├── models.py ├── preferences.py ├── processors.py ├── registries.py ├── serializers.py ├── settings.py ├── signals.py ├── templates │ └── dynamic_preferences │ │ ├── base.html │ │ ├── form.html │ │ ├── sections.html │ │ └── testcontext.html ├── types.py ├── urls.py ├── users │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20200821_0837.py │ │ └── __init__.py │ ├── models.py │ ├── registries.py │ ├── serializers.py │ ├── urls.py │ ├── views.py │ └── viewsets.py ├── utils.py └── views.py ├── example ├── example │ ├── __init__.py │ ├── apps.py │ ├── dynamic_preferences_registry.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── settings.py │ ├── templates │ │ └── example.html │ ├── urls.py │ ├── views.py │ └── wsgi.py ├── manage.py └── requirements.txt ├── pytest.ini ├── requirements-docs.txt ├── requirements-test.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── settings.py ├── test_app │ ├── __init__.py │ ├── dynamic_preferences_registry.py │ └── models.py ├── test_checkpreferences_command.py ├── test_global_preferences.py ├── test_manager.py ├── test_preferences.py ├── test_rest_framework.py ├── test_serializers.py ├── test_tutorial.py ├── test_types.py ├── test_user_preferences.py ├── types.py └── urls.py └── tox.ini /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests with tox 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: 17 | - '3.9' 18 | - '3.10' 19 | - '3.11' 20 | - '3.12' 21 | - '3.13' 22 | steps: 23 | 24 | - uses: actions/checkout@v3 25 | 26 | - uses: actions/setup-python@v3 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - uses: actions/cache@v3 31 | with: 32 | path: | 33 | ~/.cache/pip 34 | .tox 35 | key: ${{ env.pythonLocation }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements-test.txt') }} 36 | 37 | - name: Upgrade packaging tools 38 | run: python -m pip install --upgrade pip setuptools virtualenv wheel 39 | 40 | - name: Install dependencies 41 | run: python -m pip install --upgrade tox tox-py 42 | 43 | - name: Run tox targets for ${{ matrix.python-version }} 44 | run: tox --py current -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | virtualenv 21 | venv 22 | 23 | # Installer logs 24 | pip-log.txt 25 | 26 | # Unit test / coverage reports 27 | .coverage 28 | .tox 29 | nosetests.xml 30 | 31 | # Mr Developer 32 | .mr.developer.cfg 33 | .project 34 | .pydevproject 35 | 36 | # Complexity 37 | output/*.html 38 | output/*/index.html 39 | 40 | # Sphinx 41 | docs/_build 42 | 43 | # example 44 | 45 | example/*.sqlite* 46 | .idea/ 47 | .python-version 48 | .cache 49 | .pytest_cache 50 | 51 | # coverage 52 | htmlcov/ 53 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | build: 9 | os: ubuntu-20.04 10 | tools: 11 | python: "3.9" 12 | 13 | sphinx: 14 | configuration: docs/conf.py 15 | 16 | python: 17 | install: 18 | - requirements: requirements-docs.txt -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Agate Blue 9 | 10 | Contributors 11 | ------------ 12 | 13 | * Ryan Anguiano, via `prefs-n-perms package `_ 14 | * [@willseward](https://github.com/willseward) 15 | * [@haroon-sheikh](https://github.com/haroon-sheikh) 16 | * [@yurtaev](https://github.com/yurtaev) 17 | * [@pomerama](https://github.com/pomerama) 18 | * [@philipbelesky](https://github.com/philipbelesky) 19 | * [@what-digital](https://github.com/what-digital) 20 | * [@czlee](https://github.com/czlee) 21 | * [@ricard33](https://github.com/ricard33) 22 | * [@JetUni](https://github.com/JetUni) 23 | * [@pip182](https://github.com/pip182) 24 | * [@JanMalte](https://github.com/JanMalte) 25 | * [@macolo](https://github.com/macolo) 26 | * [@fabrixxm](https://github.com/fabrixxm) 27 | * [@swalladge](https://github.com/swalladge) 28 | * [@rvignesh89](https://github.com/rvignesh89) 29 | * [@okolimar](https://github.com/okolimar) 30 | * [@hansegucker](https://github.com/hansegucker) -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | **Important**: We are using git-flow workflow here, so please submit your pull requests against develop branch (and not master). 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every 8 | little bit helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/agateblue/django-dynamic-preferences/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | * Include whole stacktraces and error reports when necessary, directly in your issue body. Do not use external services such as pastebin. 26 | 27 | Contributing 28 | ------------ 29 | 30 | Fix Bugs 31 | ~~~~~~~~ 32 | 33 | Look through the GitHub issues for bugs. Anything tagged with "bug" 34 | is open to whoever wants to implement it. 35 | 36 | Implement Features 37 | ~~~~~~~~~~~~~~~~~~ 38 | 39 | Look through the GitHub issues for features. Anything tagged with "feature" 40 | is open to whoever wants to implement it. 41 | 42 | Write Documentation 43 | ~~~~~~~~~~~~~~~~~~~ 44 | 45 | django-dynamic-preferences could always use more documentation, whether as part of the 46 | official django-dynamic-preferences docs, in docstrings, or even on the web in blog posts, 47 | articles, and such. 48 | 49 | Submit Feedback 50 | ~~~~~~~~~~~~~~~ 51 | 52 | The best way to send feedback is to file an issue at https://github.com/agateblue/django-dynamic-preferences/issues. 53 | 54 | If you are proposing a feature: 55 | 56 | * Explain in detail how it would work. 57 | * Keep the scope as narrow as possible, to make it easier to implement. 58 | * Remember that this is a volunteer-driven project, and that contributions 59 | are welcome :) 60 | 61 | Get Started! 62 | ------------ 63 | 64 | Ready to contribute? Here's how to set up `django-dynamic-preferences` for local development. 65 | 66 | 1. Fork the `django-dynamic-preferences` repo on GitHub. 67 | 2. Clone your fork locally:: 68 | 69 | $ git clone git@github.com:your_name_here/django-dynamic-preferences.git 70 | 71 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 72 | 73 | $ mkvirtualenv django-dynamic-preferences 74 | $ cd django-dynamic-preferences/ 75 | $ python setup.py develop 76 | 77 | 4. Create a branch for local development:: 78 | 79 | $ git checkout -b name-of-your-bugfix-or-feature 80 | 81 | Now you can make your changes locally. 82 | 83 | 5. When you're done making changes, check that your changes pass flake8 and the 84 | tests, including testing other Python versions with tox:: 85 | 86 | $ flake8 dynamic_preferences tests 87 | $ python setup.py test 88 | $ tox 89 | 90 | To get flake8 and tox, just pip install them into your virtualenv. 91 | 92 | 6. Commit your changes and push your branch to GitHub:: 93 | 94 | $ git add . 95 | $ git commit -m "Your detailed description of your changes." 96 | $ git push origin name-of-your-bugfix-or-feature 97 | 98 | 7. Submit a pull request through the GitHub website. 99 | 100 | Pull Request Guidelines 101 | ----------------------- 102 | 103 | Before you submit a pull request, check that it meets these guidelines: 104 | 105 | 1. The pull request should include tests. 106 | 2. If the pull request adds functionality, the docs should be updated. Put 107 | your new functionality into a function with a docstring, and add the 108 | feature to the list in README.rst. 109 | 3. The pull request should work for Python 3.8, 3.9, 3.10 and 3.11. Check 110 | https://travis-ci.org/agateblue/django-dynamic-preferences/pull_requests 111 | and make sure that the tests pass for all supported Python versions. 112 | 4. The pull request must target the `develop` branch, since the project relies on `git-flow branching model`_ 113 | 114 | .. _git-flow branching model: http://nvie.com/posts/a-successful-git-branching-model/ 115 | 116 | 117 | Tips 118 | ---- 119 | 120 | To run a subset of tests:: 121 | 122 | $ python -m unittest tests.test_dynamic_preferences 123 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Agate Blue 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | * Neither the name of django-dynamic-preferences nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | recursive-include dynamic_preferences *.html *.png *.gif *js *.css *jpg *jpeg *svg *py 7 | recursive-include dynamic_preferences/locale django.mo django.po 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs 2 | 3 | help: 4 | @echo "clean-build - remove build artifacts" 5 | @echo "clean-pyc - remove Python file artifacts" 6 | @echo "lint - check style with flake8" 7 | @echo "test - run tests quickly with the default Python" 8 | @echo "testall - run tests on every Python version with tox" 9 | @echo "coverage - check code coverage quickly with the default Python" 10 | @echo "docs - generate Sphinx HTML documentation, including API docs" 11 | @echo "release - package and upload a release" 12 | @echo "sdist - package" 13 | 14 | clean: clean-build clean-pyc 15 | 16 | clean-build: 17 | rm -fr build/ 18 | rm -fr dist/ 19 | rm -fr *.egg-info 20 | 21 | clean-pyc: 22 | find . -name '*.pyc' -exec rm -f {} + 23 | find . -name '*.pyo' -exec rm -f {} + 24 | find . -name '*~' -exec rm -f {} + 25 | 26 | lint: 27 | flake8 dynamic_preferences tests 28 | 29 | test: 30 | python runtests.py tests 31 | 32 | test-all: 33 | tox 34 | 35 | coverage: 36 | coverage run --source dynamic_preferences runtests.py tests 37 | coverage report -m 38 | coverage html 39 | open htmlcov/index.html 40 | 41 | docs: 42 | rm -f docs/django-dynamic-preferences.rst 43 | rm -f docs/modules.rst 44 | sphinx-apidoc -o docs/ django-dynamic-preferences 45 | $(MAKE) -C docs clean 46 | $(MAKE) -C docs html 47 | open docs/_build/html/index.html 48 | 49 | release: clean 50 | python setup.py sdist upload 51 | python setup.py bdist_wheel upload 52 | 53 | sdist: clean 54 | python setup.py sdist 55 | ls -l dist 56 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | django-dynamic-preferences 3 | ============================= 4 | 5 | .. image:: https://badge.fury.io/py/django-dynamic-preferences.png 6 | :target: https://badge.fury.io/py/django-dynamic-preferences 7 | 8 | .. image:: https://readthedocs.org/projects/django-dynamic-preferences/badge/?version=latest 9 | :target: http://django-dynamic-preferences.readthedocs.org/en/latest/ 10 | 11 | .. image:: https://github.com/agateblue/django-dynamic-preferences/actions/workflows/tests.yml/badge.svg 12 | :target: https://github.com/agateblue/django-dynamic-preferences/actions/workflows/tests.yml 13 | 14 | .. image:: https://opencollective.com/django-dynamic-preferences/backers/badge.svg 15 | :alt: Backers on Open Collective 16 | :target: #backers 17 | 18 | .. image:: https://opencollective.com/django-dynamic-preferences/sponsors/badge.svg 19 | :alt: Sponsors on Open Collective 20 | :target: #sponsors 21 | 22 | Dynamic-preferences is a Django app, BSD-licensed, designed to help you manage your project settings. While most of the time, 23 | a ``settings.py`` file is sufficient, there are some situations where you need something more flexible such as: 24 | 25 | * per-user settings (or, generally speaking, per instance settings) 26 | * settings change without server restart 27 | 28 | For per-instance settings, you could actually store them in some kind of profile model. However, it means that every time you want to add a new setting, you need to add a new column to the profile DB table. Not very efficient. 29 | 30 | Dynamic-preferences allow you to register settings (a.k.a. preferences) in a declarative way. Preferences values are serialized before storage in database, and automatically deserialized when you need them. 31 | 32 | With dynamic-preferences, you can update settings on the fly, through django's admin or custom forms, without restarting your application. 33 | 34 | The project is tested and work under Python 3.7, 3.8, 3.9, 3.10 and 3.11 and with django 3.2 and 4.2. 35 | 36 | Features 37 | -------- 38 | 39 | * Simple to setup 40 | * Admin integration 41 | * Forms integration 42 | * Bundled with global and per-user preferences 43 | * Can be extended to other models if need (e.g. per-site preferences) 44 | * Integrates with django caching mechanisms to improve performance 45 | 46 | Documentation 47 | ------------- 48 | 49 | The full documentation is at https://django-dynamic-preferences.readthedocs.org. 50 | 51 | Changelog 52 | --------- 53 | 54 | See https://django-dynamic-preferences.readthedocs.io/en/latest/history.html 55 | 56 | Contributing 57 | ------------ 58 | 59 | See https://django-dynamic-preferences.readthedocs.org/en/latest/contributing.html 60 | 61 | Credits 62 | 63 | +++++++ 64 | 65 | Contributors 66 | 67 | ------------ 68 | 69 | This project exists thanks to all the people who contribute! 70 | 71 | .. image:: https://opencollective.com/django-dynamic-preferences/contributors.svg?width=890&button=false 72 | 73 | Backers 74 | 75 | ------- 76 | 77 | Thank you to all our backers! `Become a backer`__. 78 | 79 | .. image:: https://opencollective.com/django-dynamic-preferences/backers.svg?width=890 80 | :target: https://opencollective.com/django-dynamic-preferences#backers 81 | 82 | __ Backer_ 83 | .. _Backer: https://opencollective.com/django-dynamic-preferences#backer 84 | 85 | Sponsors 86 | 87 | -------- 88 | 89 | Support us by becoming a sponsor. Your logo will show up here with a link to your website. `Become a sponsor`__. 90 | 91 | .. image:: https://opencollective.com/django-dynamic-preferences/sponsor/0/avatar.svg 92 | :target: https://opencollective.com/django-dynamic-preferences/sponsor/0/website 93 | 94 | __ Sponsor_ 95 | .. _Sponsor: https://opencollective.com/django-dynamic-preferences#sponsor 96 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(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/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/complexity.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/complexity.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/complexity" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/complexity" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/bind_preferences_to_models.rst: -------------------------------------------------------------------------------- 1 | Bind preferences to arbitrary models 2 | ==================================== 3 | 4 | By default, dynamic-preferences come with two kinds of preferences: 5 | 6 | - Global preferences, which are not tied to any particular model instance 7 | - User preferences, which apply to a specific user 8 | 9 | While this can be enough, your project may require additional preferences. For example, you may want to bind preferences to a specific ``Site`` instance. Don't panic, dynamic-preferences got you covered. 10 | 11 | In order to achieve this, you'll need to follow this process: 12 | 13 | 1. Create a preference model with a ForeignKey to Site 14 | 2. Create a registry to store available preferences for sites 15 | 16 | The following guide assumes you want to bind preferences to the ``django.contrib.sites.Site`` model. 17 | 18 | 19 | Create a preference model 20 | ------------------------- 21 | 22 | You'll need to subclass ``PerInstancePreferenceModel`` model, 23 | and add a ``ForeignKey`` field pointing to the target model: 24 | 25 | .. code-block:: python 26 | 27 | # yourapp/models.py 28 | from django.contrib.sites.models import Site 29 | from dynamic_preferences.models import PerInstancePreferenceModel 30 | 31 | class SitePreferenceModel(PerInstancePreferenceModel): 32 | 33 | # note: you *have* to use the `instance` field 34 | instance = models.ForeignKey(Site) 35 | 36 | class Meta: 37 | # Specifying the app_label here is mandatory for backward 38 | # compatibility reasons, see #96 39 | app_label = 'yourapp' 40 | 41 | Now, you can create a migration for your newly created model with ``python manage.py makemigrations``, apply it with ``python manage.py migrate``. 42 | 43 | Create a registry to collect your model preferences 44 | --------------------------------------------------- 45 | 46 | Now, you have to create a registry to collect preferences belonging to the ``Site`` model: 47 | 48 | .. code-block:: python 49 | 50 | # yourapp/registries.py 51 | from dynamic_preferences.registries import PerInstancePreferenceRegistry 52 | 53 | class SitePreferenceRegistry(PerInstancePreferenceRegistry): 54 | pass 55 | 56 | site_preferences_registry = SitePreferenceRegistry() 57 | 58 | Then, you simply have to connect your ``SitePreferenceModel`` to your registry. You should do that in 59 | an ``apps.py`` file, as follows: 60 | 61 | .. code-block:: python 62 | 63 | # yourapp/apps.py 64 | from django.apps import AppConfig 65 | from django.conf import settings 66 | 67 | from dynamic_preferences.registries import preference_models 68 | from .registries import site_preferences_registry 69 | 70 | class YourAppConfig(AppConfig): 71 | name = 'your_app' 72 | 73 | def ready(self): 74 | SitePreferenceModel = self.get_model('SitePreferenceModel') 75 | 76 | preference_models.register(SitePreferenceModel, site_preferences_registry) 77 | 78 | Here, we use django's built-in ``AppConfig``, which is a convenient place to put this kind of logic. 79 | 80 | To ensure this config is actually used by django, you'll also have to add it to your ``settings.py``: 81 | 82 | .. code-block:: python 83 | 84 | INSTALLED_APPS = [ 85 | # your other apps here 86 | # … 87 | 'yourapp.apps.YourAppConfig', 88 | 'dynamic_preferences', 89 | ] 90 | 91 | .. warning:: 92 | 93 | Ensure your app is listed **before** ``dynamic_preferences`` in ``settings.INSTALLED_APPS``, 94 | otherwise, preferences will be collected before your registry is actually registered, and it will end up empty. 95 | 96 | Start creating preferences 97 | -------------------------- 98 | 99 | After this setup, you're good to go, and can start registering your preferences for the ``Site`` model in the same way 100 | you would do with the ``User`` model. You'll simply need to use your registry instead of the ``user_preferences_registry``: 101 | 102 | .. code-block:: python 103 | 104 | # yourapp/dynamic_preferences_registry.py 105 | from dynamic_preferences.types import BooleanPreference, StringPreference 106 | from dynamic_preferences.preferences import Section 107 | from yourapp.registries import site_preferences_registry 108 | 109 | access = Section('access') 110 | 111 | @site_preferences_registry.register 112 | class IsPublic(BooleanPreference): 113 | section = access 114 | name = 'is_public' 115 | default = False 116 | 117 | Preferences will be available on your ``Site`` instances using the ``preferences`` attribute, as described in :doc:`quickstart `: 118 | 119 | .. code-block:: python 120 | 121 | # somewhere in a view 122 | from django.contrib.sites.models import Site 123 | 124 | my_site = Site.objects.first() 125 | if my_site.preferences['access__is_public']: 126 | print('This site is public') 127 | 128 | Provide preferences in a Form 129 | ----------------------------- 130 | 131 | Optionally, you can provide forms with your custom preferences for the ``Site`` model. Start by creating forms: 132 | 133 | .. code-block:: python 134 | 135 | # yourapp/forms.py 136 | from dynamic_preferences.forms import preference_form_builder, PreferenceForm, SinglePerInstancePreferenceForm 137 | 138 | class SiteSinglePreferenceForm(SinglePerInstancePreferenceForm): 139 | 140 | class Meta: 141 | model = SitePreferenceModel 142 | fields = SinglePerInstancePreferenceForm.Meta.fields 143 | 144 | 145 | def site_preference_form_builder(instance, preferences=[], **kwargs): 146 | """ 147 | A shortcut :py:func:`preference_form_builder(SitePreferenceForm, preferences, **kwargs)` 148 | :param site: a :py:class:`Site` instance 149 | """ 150 | return preference_form_builder( 151 | SitePreferenceForm, 152 | preferences, 153 | instance=instance, 154 | **kwargs) 155 | 156 | 157 | class SitePreferenceForm(PreferenceForm): 158 | registry = site_preferences_registry 159 | 160 | The view for your preferences should extend from PreferenceFormView. For simplicity, this example just retrieves the first Site instance in the database. You will likely want to change this functionality based on the actual model being used and how it is associated to the current request. This example lists all Site Preferences, but you can also limit the preferences to a section as described in :doc:`quickstart `: 161 | 162 | .. code-block:: python 163 | 164 | # yourapp/views.py 165 | from django.contrib.sites.models import Site 166 | from django.urls import reverse_lazy 167 | from dynamic_preferences.views import PreferenceFormView 168 | 169 | 170 | class SitePreferencesBuilder(PreferenceFormView): 171 | instance = Site.objects.first() 172 | 173 | template_name = 'yourapp/form.html' 174 | form_class = site_preference_form_builder(instance=instance) 175 | success_url = reverse_lazy("yourapp:site_preferences") 176 | 177 | Include the new view in your app's urls.py: 178 | 179 | .. code-block:: python 180 | 181 | # yourapp/urls.py 182 | from django.urls import path 183 | from yourapp.views import SitePreferencesBuilder 184 | 185 | app_name = "yoursite" 186 | 187 | urlpatterns = [ 188 | path('site-preferences', SitePreferencesBuilder.as_view(), name='site_preferences'), 189 | ] 190 | 191 | And create the template for the form: 192 | 193 | .. code-block:: html+django 194 | 195 | # yourapp/templates/form.html 196 | {% extends "base.html" %} 197 | 198 | {% block content %} 199 | 200 |
201 | 202 | {% csrf_token %} 203 | 204 | {{ form.as_table }} 205 |
206 | 207 | 208 |
209 | 210 | {% endblock content %} 211 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # complexity documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | import django 16 | from django.conf import settings 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | # sys.path.insert(0, os.path.abspath('.')) 22 | settings.configure(DEBUG=True) 23 | django.setup() 24 | 25 | cwd = os.getcwd() 26 | parent = os.path.dirname(cwd) 27 | sys.path.append(parent) 28 | 29 | import dynamic_preferences 30 | 31 | # -- General configuration ----------------------------------------------------- 32 | 33 | # If your documentation needs a minimal Sphinx version, state it here. 34 | # needs_sphinx = '1.0' 35 | 36 | # Add any Sphinx extension module names here, as strings. They can be extensions 37 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 38 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ["_templates"] 42 | 43 | # The suffix of source filenames. 44 | source_suffix = ".rst" 45 | 46 | # The encoding of source files. 47 | # source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = "index" 51 | 52 | # General information about the project. 53 | project = "django-dynamic-preferences" 54 | copyright = "2014, Agate Blue" 55 | 56 | # The version info for the project you're documenting, acts as replacement for 57 | # |version| and |release|, also used in various other places throughout the 58 | # built documents. 59 | # 60 | # The short X.Y version. 61 | version = dynamic_preferences.__version__ 62 | # The full version, including alpha/beta/rc tags. 63 | release = dynamic_preferences.__version__ 64 | 65 | # The language for content autogenerated by Sphinx. Refer to documentation 66 | # for a list of supported languages. 67 | # language = None 68 | 69 | # There are two options for replacing |today|: either, you set today to some 70 | # non-false value, then it is used: 71 | # today = '' 72 | # Else, today_fmt is used as the format for a strftime call. 73 | # today_fmt = '%B %d, %Y' 74 | 75 | # List of patterns, relative to source directory, that match files and 76 | # directories to ignore when looking for source files. 77 | exclude_patterns = ["_build"] 78 | 79 | # The reST default role (used for this markup: `text`) to use for all documents. 80 | # default_role = None 81 | 82 | # If true, '()' will be appended to :func: etc. cross-reference text. 83 | # add_function_parentheses = True 84 | 85 | # If true, the current module name will be prepended to all description 86 | # unit titles (such as .. function::). 87 | # add_module_names = True 88 | 89 | # If true, sectionauthor and moduleauthor directives will be shown in the 90 | # output. They are ignored by default. 91 | # show_authors = False 92 | 93 | # The name of the Pygments (syntax highlighting) style to use. 94 | pygments_style = "sphinx" 95 | 96 | # A list of ignored prefixes for module index sorting. 97 | # modindex_common_prefix = [] 98 | 99 | # If true, keep warnings as "system message" paragraphs in the built documents. 100 | # keep_warnings = False 101 | 102 | 103 | # -- Options for HTML output --------------------------------------------------- 104 | 105 | # The theme to use for HTML and HTML Help pages. See the documentation for 106 | # a list of builtin themes. 107 | html_theme = "default" 108 | 109 | # Theme options are theme-specific and customize the look and feel of a theme 110 | # further. For a list of options available for each theme, see the 111 | # documentation. 112 | # html_theme_options = {} 113 | 114 | # Add any paths that contain custom themes here, relative to this directory. 115 | # html_theme_path = [] 116 | 117 | # The name for this set of Sphinx documents. If None, it defaults to 118 | # " v documentation". 119 | # html_title = None 120 | 121 | # A shorter title for the navigation bar. Default is the same as html_title. 122 | # html_short_title = None 123 | 124 | # The name of an image file (relative to this directory) to place at the top 125 | # of the sidebar. 126 | # html_logo = None 127 | 128 | # The name of an image file (within the static path) to use as favicon of the 129 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 130 | # pixels large. 131 | # html_favicon = None 132 | 133 | # Add any paths that contain custom static files (such as style sheets) here, 134 | # relative to this directory. They are copied after the builtin static files, 135 | # so a file named "default.css" will overwrite the builtin "default.css". 136 | html_static_path = ["_static"] 137 | 138 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 139 | # using the given strftime format. 140 | # html_last_updated_fmt = '%b %d, %Y' 141 | 142 | # If true, SmartyPants will be used to convert quotes and dashes to 143 | # typographically correct entities. 144 | # html_use_smartypants = True 145 | 146 | # Custom sidebar templates, maps document names to template names. 147 | # html_sidebars = {} 148 | 149 | # Additional templates that should be rendered to pages, maps page names to 150 | # template names. 151 | # html_additional_pages = {} 152 | 153 | # If false, no module index is generated. 154 | # html_domain_indices = True 155 | 156 | # If false, no index is generated. 157 | # html_use_index = True 158 | 159 | # If true, the index is split into individual pages for each letter. 160 | # html_split_index = False 161 | 162 | # If true, links to the reST sources are added to the pages. 163 | # html_show_sourcelink = True 164 | 165 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 166 | # html_show_sphinx = True 167 | 168 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 169 | # html_show_copyright = True 170 | 171 | # If true, an OpenSearch description file will be output, and all pages will 172 | # contain a tag referring to it. The value of this option must be the 173 | # base URL from which the finished HTML is served. 174 | # html_use_opensearch = '' 175 | 176 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 177 | # html_file_suffix = None 178 | 179 | # Output file base name for HTML help builder. 180 | htmlhelp_basename = "django-dynamic-preferencesdoc" 181 | 182 | 183 | # -- Options for LaTeX output -------------------------------------------------- 184 | 185 | latex_elements = { 186 | # The paper size ('letterpaper' or 'a4paper'). 187 | #'papersize': 'letterpaper', 188 | # The font size ('10pt', '11pt' or '12pt'). 189 | #'pointsize': '10pt', 190 | # Additional stuff for the LaTeX preamble. 191 | #'preamble': '', 192 | } 193 | 194 | # Grouping the document tree into LaTeX files. List of tuples 195 | # (source start file, target name, title, author, documentclass [howto/manual]). 196 | latex_documents = [ 197 | ( 198 | "index", 199 | "django-dynamic-preferences.tex", 200 | "django-dynamic-preferences Documentation", 201 | "Agate Blue", 202 | "manual", 203 | ), 204 | ] 205 | 206 | # The name of an image file (relative to this directory) to place at the top of 207 | # the title page. 208 | # latex_logo = None 209 | 210 | # For "manual" documents, if this is true, then toplevel headings are parts, 211 | # not chapters. 212 | # latex_use_parts = False 213 | 214 | # If true, show page references after internal links. 215 | # latex_show_pagerefs = False 216 | 217 | # If true, show URL addresses after external links. 218 | # latex_show_urls = False 219 | 220 | # Documents to append as an appendix to all manuals. 221 | # latex_appendices = [] 222 | 223 | # If false, no module index is generated. 224 | # latex_domain_indices = True 225 | 226 | 227 | # -- Options for manual page output -------------------------------------------- 228 | 229 | # One entry per manual page. List of tuples 230 | # (source start file, name, description, authors, manual section). 231 | man_pages = [ 232 | ( 233 | "index", 234 | "django-dynamic-preferences", 235 | "django-dynamic-preferences Documentation", 236 | ["Agate Blue"], 237 | 1, 238 | ) 239 | ] 240 | 241 | # If true, show URL addresses after external links. 242 | # man_show_urls = False 243 | 244 | 245 | # -- Options for Texinfo output ------------------------------------------------ 246 | 247 | # Grouping the document tree into Texinfo files. List of tuples 248 | # (source start file, target name, title, author, 249 | # dir menu entry, description, category) 250 | texinfo_documents = [ 251 | ( 252 | "index", 253 | "django-dynamic-preferences", 254 | "django-dynamic-preferences Documentation", 255 | "Agate Blue", 256 | "django-dynamic-preferences", 257 | "One line description of project.", 258 | "Miscellaneous", 259 | ), 260 | ] 261 | 262 | # Documents to append as an appendix to all manuals. 263 | # texinfo_appendices = [] 264 | 265 | # If false, no module index is generated. 266 | # texinfo_domain_indices = True 267 | 268 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 269 | # texinfo_show_urls = 'footnote' 270 | 271 | # If true, do not generate a @detailmenu in the "Top" node's menu. 272 | # texinfo_no_detailmenu = False 273 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-dynamic-preferences documentation master file, created by 2 | sphinx-quickstart on Sat Jun 28 13:23:43 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Django-dynamic-preferences documentation 7 | ======================================== 8 | 9 | Dynamic-preferences is a Django app, BSD-licensed, designed to help you manage your project settings. While most of the time, 10 | a `settings.py` file is sufficient, there are some situations where you need something more flexible such as: 11 | 12 | * per-user settings (or, generally speaking, per instance settings) 13 | * settings change without server restart 14 | 15 | For per-instance settings, you could actually store them in some kind of profile model. However, it means that every time you want to add a new setting, you need to add a new column to the profile DB table. Not very efficient. 16 | 17 | Dynamic-preferences allow you to register settings (a.k.a. preferences) in a declarative way. Preferences values are serialized before storage in database, and automatically deserialized when you need them. 18 | 19 | With dynamic-preferences, you can update settings on the fly, through django's admin or custom forms, without restarting your application. 20 | 21 | The project is tested and work under Python 3.8, 3.9, 3.10 and 3.11 and with django 3.2 and 4.2. 22 | 23 | Features 24 | -------- 25 | 26 | * Simple to setup 27 | * Admin integration 28 | * Forms integration 29 | * Bundled with global and per-user preferences 30 | * Can be extended to other models if need (e.g. per-site preferences) 31 | * Integrates with django caching mechanisms to improve performance 32 | * Django REST Framework integration 33 | 34 | If you're still interested, head over :doc:`installation`. 35 | 36 | .. warning:: 37 | There is a critical bug in version 1.2 that can result in dataloss. Please upgrade to 1.3 as 38 | soon as possible and do not use 1.2 in production. See `#81 `_ for more details. 39 | 40 | Contents: 41 | 42 | .. toctree:: 43 | :maxdepth: 2 44 | 45 | installation 46 | quickstart 47 | bind_preferences_to_models 48 | react_to_updates 49 | preference_types 50 | rest_api 51 | lifecycle 52 | upgrade 53 | contributing 54 | authors 55 | history 56 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | Dynamic-preferences is available on `PyPI `_ and can be installed with:: 6 | 7 | pip install django-dynamic-preferences 8 | 9 | Setup 10 | ***** 11 | 12 | Add this to your :py:const:`settings.INSTALLED_APPS`:: 13 | 14 | INSTALLED_APPS = ( 15 | # ... 16 | 'django.contrib.auth', 17 | 'dynamic_preferences', 18 | # comment the following line if you don't want to use user preferences 19 | 'dynamic_preferences.users.apps.UserPreferencesConfig', 20 | ) 21 | 22 | Then, create missing tables in your database:: 23 | 24 | python manage.py migrate dynamic_preferences 25 | 26 | Add this to :py:const:`settings.TEMPLATES` if you want to access preferences from templates:: 27 | 28 | TEMPLATES = [ 29 | { 30 | # ... 31 | 'OPTIONS': { 32 | 'context_processors': [ 33 | # ... 34 | 'django.template.context_processors.request', 35 | 'dynamic_preferences.processors.global_preferences', 36 | ], 37 | }, 38 | }, 39 | ] 40 | 41 | Settings 42 | ******** 43 | 44 | Also, take some time to look at provided settings if you want to customize the package behaviour: 45 | 46 | .. code-block:: python 47 | 48 | # available settings with their default values 49 | DYNAMIC_PREFERENCES = { 50 | 51 | # a python attribute that will be added to model instances with preferences 52 | # override this if the default collide with one of your models attributes/fields 53 | 'MANAGER_ATTRIBUTE': 'preferences', 54 | 55 | # The python module in which registered preferences will be searched within each app 56 | 'REGISTRY_MODULE': 'dynamic_preferences_registry', 57 | 58 | # Allow quick editing of preferences directly in admin list view 59 | # WARNING: enabling this feature can cause data corruption if multiple users 60 | # use the same list view at the same time, see https://code.djangoproject.com/ticket/11313 61 | 'ADMIN_ENABLE_CHANGELIST_FORM': False, 62 | 63 | # Customize how you can access preferences from managers. The default is to 64 | # separate sections and keys with two underscores. This is probably not a settings you'll 65 | # want to change, but it's here just in case 66 | 'SECTION_KEY_SEPARATOR': '__', 67 | 68 | # Use this to disable auto registration of the GlobalPreferenceModel. 69 | # This can be useful to register your own model in the global_preferences_registry. 70 | 'ENABLE_GLOBAL_MODEL_AUTO_REGISTRATION': True, 71 | 72 | # Use this to disable caching of preference. This can be useful to debug things 73 | 'ENABLE_CACHE': True, 74 | 75 | # Use this to select which chache should be used to cache preferences. Defaults to default. 76 | 'CACHE_NAME': 'default', 77 | 78 | # Use this to disable checking preferences names. This can be useful to debug things 79 | 'VALIDATE_NAMES': True, 80 | } 81 | -------------------------------------------------------------------------------- /docs/lifecycle.rst: -------------------------------------------------------------------------------- 1 | Preferences lifecycle 2 | ====================== 3 | 4 | 5 | Update 6 | ****** 7 | 8 | To do, help welcome :) 9 | 10 | Deletion 11 | ******** 12 | 13 | If you remove preferences from your registry, corresponding data rows won't be deleted automatically. 14 | 15 | In order to keep a clean database and delete obsolete rows, you can use the `checkpreferences` management command. This command will check all preferences in database, ensure they match a registered preference class and delete rows that do not match any registered preference. 16 | 17 | .. warning:: 18 | 19 | Run this command carefully, since it can lead to data loss. 20 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\complexity.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\complexity.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/preference_types.rst: -------------------------------------------------------------------------------- 1 | Preference types 2 | ================ 3 | 4 | 5 | .. automodule:: dynamic_preferences.types 6 | :members: 7 | -------------------------------------------------------------------------------- /docs/react_to_updates.rst: -------------------------------------------------------------------------------- 1 | React to updates of preferences 2 | =============================== 3 | 4 | Sometimes, it may be necessary to do something once a preference was 5 | updated, e.g. invalidate some caches, re-generate a stylesheet, or 6 | whatever. 7 | 8 | Therefore, a signal is emitted after any preference was updated. 9 | 10 | 11 | Writing a signal receiver 12 | ------------------------- 13 | 14 | First, you need to write a signal receiver that runs code once the signal is 15 | emitted. A signal receiver is a simple function that takes the arguments 16 | of the signal, which are: 17 | 18 | * ``sender`` - the ``PreferenceManager`` of the changed preference 19 | * ``section`` - the section in which a preference was changed 20 | * ``name`` - the name of the changed preference 21 | * ``old_value`` - the value of the preference before changing 22 | * ``new_value`` - the value assigned to the preference after the change 23 | * ``instance`` - the preference Model instance 24 | An example that just prints a message that the preference was changed is 25 | below. 26 | 27 | .. code-block:: python 28 | 29 | # yourapp/util.py 30 | 31 | def notify_on_preference_update(sender, section, name, old_value, new_value, instance, **kwargs): 32 | print("Preference {} in section {} changed from {} to {}".format( 33 | name, section, old, new)) 34 | 35 | 36 | Registering the receiver 37 | ------------------------ 38 | 39 | You must register the signal receiver at some point, e.g. in the ``ready`` 40 | method of your app. 41 | 42 | .. code-block:: python 43 | 44 | # yourapp/apps.py 45 | from django.apps import AppConfig 46 | 47 | from dynamic_preferences.signals import preference_updated 48 | 49 | from .util import notify_on_preference_update 50 | 51 | 52 | class YourAppConfig(AppConfig): 53 | def ready(self): 54 | preference_updated.connect(notify_on_preference_update) 55 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/rest_api.rst: -------------------------------------------------------------------------------- 1 | REST API 2 | ======== 3 | 4 | Dynamic preferences provides an optional integration with Django REST Framework: 5 | 6 | - Serializers you can use for global and user preferences (or to extend for your own preferences) 7 | - Viewsets you can use for global and user preferences (or to extend for your own preferences) 8 | 9 | Getting started 10 | --------------- 11 | 12 | The easiest way to offer API endpoints to manage preferences in your project is to use 13 | bundled viewsets in your ``urls.py``: 14 | 15 | .. code-block:: python 16 | 17 | from django.urls import include, re_path 18 | from rest_framework import routers 19 | 20 | from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet 21 | from dynamic_preferences.users.viewsets import UserPreferencesViewSet 22 | 23 | 24 | router = routers.SimpleRouter() 25 | router.register(r'global', GlobalPreferencesViewSet, 'global') 26 | 27 | # Uncomment this one if you use user preferences 28 | # router.register(r'user', UserPreferencesViewSet, 'user') 29 | 30 | api_patterns = [ 31 | re_path(r'^preferences/', include(router.urls, namespace='preferences')) 32 | 33 | ] 34 | urlpatterns = [ 35 | # your other urls here 36 | re_path(r'^api/', include(api_patterns, namespace='api')) 37 | ] 38 | 39 | 40 | Endpoints 41 | --------- 42 | 43 | For each preference viewset, the following endpoints are available: 44 | 45 | Example reverse and urls are given according to the previous snippet. You'll 46 | have to adapt them to your own URL namespaces and structure. 47 | 48 | List 49 | ^^^^^^^ 50 | 51 | - Methods: GET 52 | - Returns a list of preferences 53 | - Reverse example: ``reverse('api:preferences:global-list')`` 54 | - URL examples: 55 | 56 | - List all preferences 57 | 58 | ``/api/preferences/global/`` 59 | 60 | - List all preferences under ``blog`` section 61 | 62 | ``/api/preferences/global/?section=blog`` 63 | 64 | Detail 65 | ^^^^^^^ 66 | 67 | - Methods: GET, PATCH 68 | - Get or update a single preference 69 | - Reverse example: ``reverse('api:preferences:global-detail', kwargs={'pk': 'section__name'})`` 70 | - URL example: ``/api/preferences/global/section__name`` 71 | 72 | If you call this endpoint via PATCH HTTP method, you can update the preference value. 73 | The following payload is expected:: 74 | 75 | 76 | { 77 | value: 'new_value' 78 | } 79 | 80 | .. note:: 81 | 82 | When updating the preference value, the underlying serializers will call 83 | the preference field validators, and the preference ``validate`` method, 84 | if any is available. 85 | 86 | Bulk 87 | ^^^^^ 88 | 89 | - Methods: POST 90 | - Update multiple preferences at once 91 | - Reverse example: ``reverse('api:preferences:global-bulk')`` 92 | - URL example: ``/api/preferences/global/bulk`` 93 | 94 | If you call this endpoint via POST HTTP method, you can update the the value 95 | of multiple preferences at once. Example payload:: 96 | 97 | { 98 | section1__name1: 'new_value', 99 | section2__name2: false, 100 | } 101 | 102 | This will update the preferences whose identifiers match ``section1__name1`` 103 | and ``section2__name2`` with the corresponding values. 104 | 105 | .. note:: 106 | 107 | Validation will be called for each preferences, ans save will only occur 108 | if no error happens. 109 | 110 | A note about permissions 111 | ^^^^^^^^^^^^^^^^^^^^^^^^ 112 | 113 | - ``GlobalPreferencesViewSet`` will check the user has the ``dynamic_preferences.change_globalpreferencemodel`` permission 114 | - ``UserPreferencesViewSet`` will check the user is logged in and only allow him to edit his own preferences. 115 | -------------------------------------------------------------------------------- /docs/upgrade.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Upgrade 3 | ======= 4 | 5 | 1.0 6 | **** 7 | 8 | In order to fix #33 and to make the whole package lighter and more modular for the 1.0 release, 9 | user preferences where moved to a dedicated app. 10 | 11 | If you were using user preferences before and want to use them after the package, upgrade will require a few changes 12 | to your existing code, as described below. 13 | 14 | If you only use the package for the global preferences, no change should be required on your side, apart from running the migrations. 15 | 16 | Add the app to your INSTALLED_APPS 17 | ---------------------------------- 18 | 19 | In ``settings.py``: 20 | 21 | .. code-block:: python 22 | 23 | INSTALLED_APPS = [ 24 | # ... 25 | 'dynamic_preferences', 26 | 'dynamic_preferences.users.apps.UserPreferencesConfig', # <---- add this line 27 | ] 28 | 29 | Replace old imports 30 | ------------------- 31 | 32 | Some functions and classes were moved to the dedicated ``dynamic_preferences.users`` app. 33 | 34 | The following imports will crash: 35 | 36 | .. code-block:: python 37 | 38 | from dynamic_preferences.registry import user_preferences_registry 39 | from dynamic_preferences.forms import ( 40 | UserSinglePreferenceForm, 41 | user_preference_form_builder, 42 | UserPreferenceForm, 43 | ) 44 | from dynamic_preferences.views import UserPreferenceFormView 45 | from dynamic_preferences.models import UserPreferenceModel 46 | 47 | You should use the following imports instead: 48 | 49 | .. code-block:: python 50 | 51 | from dynamic_preferences.users.registry import user_preferences_registry 52 | from dynamic_preferences.users.forms import ( 53 | UserSinglePreferenceForm, 54 | user_preference_form_builder, 55 | UserPreferenceForm, 56 | ) 57 | from dynamic_preferences.users.views import UserPreferenceFormView 58 | from dynamic_preferences.users.models import UserPreferenceModel 59 | 60 | .. note:: 61 | 62 | It is mandatory to update the path for ``user_preferences_registry``. Other paths are part of the public API but their use is optional and varies depending of how you usage of the package. 63 | 64 | Run the migrations 65 | ------------------------- 66 | 67 | User preferences were stored on the ``UserPreferenceModel`` model class. 68 | 69 | The migrations only rename the old table to match the fact that the model was moved in another app. Otherwise, nothing should be deleted or altered at all, and you can inspect the two related migrations to see what we're doing: 70 | 71 | - dynamic_preferences.0004_move_user_model 72 | - dynamic_preferences.users.0001_initial 73 | 74 | Anyway, please perform a backup before any database migration. 75 | 76 | Once you're ready, just run:: 77 | 78 | python manage.py migrate dynamic_preferences_users 79 | 80 | .. note:: 81 | 82 | If your own code was using ForeignKey fields pointing to ``UserPreferenceModel``, it is likely your code will break with this migration, because your foreign keys will point to the old database table. 83 | 84 | Such foreign keys were not officially supported or recommended though, and should not be needed in the uses cases dynamic_preferences was designed for. However, if you're in this situation, please file an issue on the issue tracker to see what we can do. 85 | 86 | Remove useless setting 87 | ------------------------ 88 | 89 | In previous versions, to partially address #33, a ``ENABLE_USER_PREFERENCES`` setting was added to enable / disable the admin endpoints for user preferences. Since you can now opt into user preferences via ``INSTALLED_APPS``, this setting is now obsolete and can be safely removed from your settings file. 90 | 91 | 92 | 0.8 93 | *** 94 | 95 | .. warning:: 96 | 97 | there is a backward incompatible change in this release. 98 | 99 | To address #45 and #46, an import statement was removed from __init__.py. 100 | Because of that, every file containing the following: 101 | 102 | .. code-block:: python 103 | 104 | from dynamic_preferences import user_preferences_registry, global_preferences_registry 105 | 106 | Will raise an `ImportError`. 107 | 108 | To fix this, you need to replace by this: 109 | 110 | .. code-block:: python 111 | 112 | # .registries was added 113 | from dynamic_preferences.registries import user_preferences_registry, global_preferences_registry 114 | 115 | 0.6 116 | *** 117 | 118 | Sections are now plain python objects (see #19). When you use sections in your code, 119 | instead of the old notation: 120 | 121 | .. code-block:: python 122 | 123 | from dynamic_preferences.types import BooleanPreference 124 | 125 | class MyPref(BooleanPreference): 126 | section = 'misc' 127 | name = 'my_pref' 128 | default = False 129 | 130 | You should do: 131 | 132 | .. code-block:: python 133 | 134 | from dynamic_preferences.types import BooleanPreference, Section 135 | 136 | misc = Section('misc') 137 | 138 | class MyPref(BooleanPreference): 139 | section = misc 140 | name = 'my_pref' 141 | default = False 142 | 143 | Note that the old notation is only deprecated and will continue to work for some time. 144 | 145 | 0.5 146 | *** 147 | 148 | The 0.5 release implies a migration from ``TextField`` to ``CharField`` for ``name`` and ``section`` fields. 149 | 150 | This migration is handled by the package for global and per-user preferences. However, if you created your 151 | own preference model, you'll have to generate the migration yourself. 152 | 153 | You can do it via ``python manage.py makemigrations `` 154 | 155 | After that, just run a ``python manage.py syncdb`` and you'll be done. 156 | -------------------------------------------------------------------------------- /dynamic_preferences/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.17.0" 2 | -------------------------------------------------------------------------------- /dynamic_preferences/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django import forms 3 | 4 | from .settings import preferences_settings 5 | from .registries import global_preferences_registry 6 | from .models import GlobalPreferenceModel 7 | from .forms import GlobalSinglePreferenceForm, SinglePerInstancePreferenceForm 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | 11 | class SectionFilter(admin.AllValuesFieldListFilter): 12 | def __init__(self, field, request, params, model, model_admin, field_path): 13 | super(SectionFilter, self).__init__( 14 | field, request, params, model, model_admin, field_path 15 | ) 16 | parent_model, reverse_path = admin.utils.reverse_field_path(model, field_path) 17 | if model == parent_model: 18 | queryset = model_admin.get_queryset(request) 19 | else: 20 | queryset = parent_model._default_manager.all() 21 | self.registries = [] 22 | registry_name_set = set() 23 | for preferenceModel in queryset.distinct(): 24 | l = len(registry_name_set) 25 | registry_name_set.add(preferenceModel.registry.__class__.__name__) 26 | if len(registry_name_set) != l: 27 | self.registries.append(preferenceModel.registry) 28 | 29 | def choices(self, changelist): 30 | choices = super(SectionFilter, self).choices(changelist) 31 | for choice in choices: 32 | display = choice["display"] 33 | try: 34 | for registry in self.registries: 35 | display = registry.section_objects[display].verbose_name 36 | choice["display"] = display 37 | except (KeyError): 38 | pass 39 | yield choice 40 | 41 | 42 | class DynamicPreferenceAdmin(admin.ModelAdmin): 43 | list_display = ( 44 | "verbose_name", 45 | "name", 46 | "section_name", 47 | "help_text", 48 | "raw_value", 49 | "default_value", 50 | ) 51 | fields = ("raw_value", "default_value", "name", "section_name") 52 | readonly_fields = ("name", "section_name", "default_value") 53 | if preferences_settings.ADMIN_ENABLE_CHANGELIST_FORM: 54 | list_editable = ("raw_value",) 55 | search_fields = ["name", "section", "raw_value"] 56 | list_filter = (("section", SectionFilter),) 57 | 58 | if preferences_settings.ADMIN_ENABLE_CHANGELIST_FORM: 59 | 60 | def get_changelist_form(self, request, **kwargs): 61 | return self.changelist_form 62 | 63 | def default_value(self, obj): 64 | return obj.preference.default 65 | 66 | default_value.short_description = _("Default Value") 67 | 68 | def section_name(self, obj): 69 | try: 70 | return obj.registry.section_objects[obj.section].verbose_name 71 | except KeyError: 72 | pass 73 | return obj.section 74 | 75 | section_name.short_description = _("Section Name") 76 | 77 | def save_model(self, request, obj, form, change): 78 | pref = form.instance 79 | manager = pref.registry.manager(instance=getattr(obj, "instance", None)) 80 | manager.update_db_pref(pref.section, pref.name, form.cleaned_data["raw_value"]) 81 | 82 | 83 | class GlobalPreferenceAdmin(DynamicPreferenceAdmin): 84 | form = GlobalSinglePreferenceForm 85 | changelist_form = GlobalSinglePreferenceForm 86 | 87 | def get_queryset(self, *args, **kwargs): 88 | # Instanciate default prefs 89 | manager = global_preferences_registry.manager() 90 | manager.all() 91 | return super(GlobalPreferenceAdmin, self).get_queryset(*args, **kwargs) 92 | 93 | 94 | admin.site.register(GlobalPreferenceModel, GlobalPreferenceAdmin) 95 | 96 | 97 | class PerInstancePreferenceAdmin(DynamicPreferenceAdmin): 98 | list_display = ("instance",) + DynamicPreferenceAdmin.list_display 99 | fields = ("instance",) + DynamicPreferenceAdmin.fields 100 | raw_id_fields = ("instance",) 101 | form = SinglePerInstancePreferenceForm 102 | changelist_form = SinglePerInstancePreferenceForm 103 | list_select_related = True 104 | -------------------------------------------------------------------------------- /dynamic_preferences/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/dynamic_preferences/api/__init__.py -------------------------------------------------------------------------------- /dynamic_preferences/api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from dynamic_preferences.signals import preference_updated 4 | 5 | 6 | class PreferenceValueField(serializers.Field): 7 | def get_attribute(self, o): 8 | return o 9 | 10 | def to_representation(self, o): 11 | return o.preference.api_repr(o.value) 12 | 13 | def to_internal_value(self, data): 14 | return data 15 | 16 | 17 | class PreferenceSerializer(serializers.Serializer): 18 | 19 | section = serializers.CharField(read_only=True) 20 | name = serializers.CharField(read_only=True) 21 | identifier = serializers.SerializerMethodField() 22 | default = serializers.SerializerMethodField() 23 | value = PreferenceValueField() 24 | verbose_name = serializers.SerializerMethodField() 25 | help_text = serializers.SerializerMethodField() 26 | additional_data = serializers.SerializerMethodField() 27 | field = serializers.SerializerMethodField() 28 | 29 | class Meta: 30 | fields = [ 31 | "default", 32 | "value", 33 | "verbose_name", 34 | "help_text", 35 | ] 36 | 37 | def get_default(self, o): 38 | return o.preference.api_repr(o.preference.get("default")) 39 | 40 | def get_verbose_name(self, o): 41 | return o.preference.get("verbose_name") 42 | 43 | def get_identifier(self, o): 44 | return o.preference.identifier() 45 | 46 | def get_help_text(self, o): 47 | return o.preference.get("help_text") 48 | 49 | def get_additional_data(self, o): 50 | return o.preference.get_api_additional_data() 51 | 52 | def get_field(self, o): 53 | return o.preference.get_api_field_data() 54 | 55 | def validate_value(self, value): 56 | """ 57 | We call validation from the underlying form field 58 | """ 59 | field = self.instance.preference.setup_field() 60 | value = field.to_python(value) 61 | field.validate(value) 62 | field.run_validators(value) 63 | return value 64 | 65 | def update(self, instance, validated_data): 66 | old_value = instance.value 67 | instance.value = validated_data["value"] 68 | instance.save() 69 | preference_updated.send( 70 | sender=self.__class__, 71 | section=instance.section, 72 | name=instance.name, 73 | old_value=old_value, 74 | new_value=validated_data["value"], 75 | instance=instance 76 | ) 77 | return instance 78 | 79 | 80 | class GlobalPreferenceSerializer(PreferenceSerializer): 81 | pass 82 | -------------------------------------------------------------------------------- /dynamic_preferences/api/viewsets.py: -------------------------------------------------------------------------------- 1 | from django.db import transaction 2 | from django.db.models import Q 3 | 4 | from rest_framework import mixins 5 | from rest_framework import viewsets 6 | from rest_framework import permissions 7 | from rest_framework.response import Response 8 | from rest_framework.decorators import action 9 | from rest_framework.generics import get_object_or_404 10 | 11 | from dynamic_preferences import models 12 | from dynamic_preferences import exceptions 13 | from dynamic_preferences.settings import preferences_settings 14 | 15 | from . import serializers 16 | 17 | 18 | class PreferenceViewSet( 19 | mixins.UpdateModelMixin, 20 | mixins.ListModelMixin, 21 | mixins.RetrieveModelMixin, 22 | viewsets.GenericViewSet, 23 | ): 24 | """ 25 | - list preferences 26 | - detail given preference 27 | - batch update preferences 28 | - update a single preference 29 | """ 30 | 31 | def get_queryset(self): 32 | """ 33 | We just ensure preferences are actually populated before fetching 34 | from db 35 | """ 36 | self.init_preferences() 37 | queryset = super(PreferenceViewSet, self).get_queryset() 38 | 39 | section = self.request.query_params.get("section") 40 | if section: 41 | queryset = queryset.filter(section=section) 42 | 43 | return queryset 44 | 45 | def get_manager(self): 46 | return self.queryset.model.registry.manager() 47 | 48 | def init_preferences(self): 49 | manager = self.get_manager() 50 | manager.all() 51 | 52 | def get_object(self): 53 | """ 54 | Returns the object the view is displaying. 55 | You may want to override this if you need to provide non-standard 56 | queryset lookups. Eg if objects are referenced using multiple 57 | keyword arguments in the url conf. 58 | """ 59 | queryset = self.filter_queryset(self.get_queryset()) 60 | lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field 61 | identifier = self.kwargs[lookup_url_kwarg] 62 | section, name = self.get_section_and_name(identifier) 63 | filter_kwargs = {"section": section, "name": name} 64 | obj = get_object_or_404(queryset, **filter_kwargs) 65 | 66 | # May raise a permission denied 67 | self.check_object_permissions(self.request, obj) 68 | 69 | return obj 70 | 71 | def get_section_and_name(self, identifier): 72 | try: 73 | section, name = identifier.split(preferences_settings.SECTION_KEY_SEPARATOR) 74 | except ValueError: 75 | # no section given 76 | section, name = None, identifier 77 | 78 | return section, name 79 | 80 | @action(detail=False, methods=["post"]) 81 | @transaction.atomic 82 | def bulk(self, request, *args, **kwargs): 83 | """ 84 | Update multiple preferences at once 85 | 86 | this is a long method because we ensure everything is valid 87 | before actually persisting the changes 88 | """ 89 | manager = self.get_manager() 90 | errors = {} 91 | preferences = [] 92 | payload = request.data 93 | 94 | # first, we check updated preferences actually exists in the registry 95 | try: 96 | for identifier, value in payload.items(): 97 | try: 98 | preferences.append(self.queryset.model.registry.get(identifier)) 99 | except exceptions.NotFoundInRegistry: 100 | errors[identifier] = "invalid preference" 101 | except (TypeError, AttributeError): 102 | return Response("invalid payload", status=400) 103 | 104 | if errors: 105 | return Response(errors, status=400) 106 | 107 | # now, we generate an optimized Q objects to retrieve all matching 108 | # preferences at once from database 109 | queries = [Q(section=p.section.name, name=p.name) for p in preferences] 110 | 111 | try: 112 | query = queries[0] 113 | except IndexError: 114 | return Response("empty payload", status=400) 115 | for q in queries[1:]: 116 | query |= q 117 | preferences_qs = self.get_queryset().filter(query) 118 | 119 | # next, we generate a serializer for each database preference 120 | serializer_objects = [] 121 | for p in preferences_qs: 122 | s = self.get_serializer_class()( 123 | p, data={"value": payload[p.preference.identifier()]} 124 | ) 125 | serializer_objects.append(s) 126 | 127 | validation_errors = {} 128 | 129 | # we check if any serializer is invalid 130 | for s in serializer_objects: 131 | if s.is_valid(): 132 | continue 133 | validation_errors[s.instance.preference.identifier()] = s.errors 134 | 135 | if validation_errors: 136 | return Response(validation_errors, status=400) 137 | 138 | for s in serializer_objects: 139 | s.save() 140 | 141 | return Response( 142 | [s.data for s in serializer_objects], 143 | status=200, 144 | ) 145 | 146 | 147 | class GlobalPreferencePermission(permissions.DjangoModelPermissions): 148 | perms_map = { 149 | "GET": ["%(app_label)s.change_%(model_name)s"], 150 | "OPTIONS": ["%(app_label)s.change_%(model_name)s"], 151 | "HEAD": ["%(app_label)s.change_%(model_name)s"], 152 | "POST": ["%(app_label)s.change_%(model_name)s"], 153 | "PUT": ["%(app_label)s.change_%(model_name)s"], 154 | "PATCH": ["%(app_label)s.change_%(model_name)s"], 155 | "DELETE": ["%(app_label)s.change_%(model_name)s"], 156 | } 157 | 158 | 159 | class GlobalPreferencesViewSet(PreferenceViewSet): 160 | queryset = models.GlobalPreferenceModel.objects.all() 161 | serializer_class = serializers.GlobalPreferenceSerializer 162 | permission_classes = [GlobalPreferencePermission] 163 | 164 | 165 | class PerInstancePreferenceViewSet(PreferenceViewSet): 166 | def get_manager(self): 167 | return self.queryset.model.registry.manager( 168 | instance=self.get_related_instance() 169 | ) 170 | 171 | def get_queryset(self): 172 | return ( 173 | super(PerInstancePreferenceViewSet, self) 174 | .get_queryset() 175 | .filter(instance=self.get_related_instance()) 176 | ) 177 | 178 | def get_related_instance(self): 179 | """ 180 | Override this to the instance bound to the preferences 181 | """ 182 | raise NotImplementedError 183 | -------------------------------------------------------------------------------- /dynamic_preferences/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | from django.conf import settings 3 | from django.utils.translation import gettext_lazy as _ 4 | from .registries import preference_models, global_preferences_registry 5 | from .settings import preferences_settings 6 | 7 | 8 | class DynamicPreferencesConfig(AppConfig): 9 | name = "dynamic_preferences" 10 | verbose_name = _("Dynamic Preferences") 11 | default_auto_field = "django.db.models.AutoField" 12 | 13 | def ready(self): 14 | if preferences_settings.ENABLE_GLOBAL_MODEL_AUTO_REGISTRATION: 15 | GlobalPreferenceModel = self.get_model("GlobalPreferenceModel") 16 | 17 | preference_models.register( 18 | GlobalPreferenceModel, global_preferences_registry 19 | ) 20 | 21 | # This will load all dynamic_preferences_registry.py files under 22 | # installed apps 23 | app_names = [app.name for app in apps.app_configs.values()] 24 | global_preferences_registry.autodiscover(app_names) 25 | -------------------------------------------------------------------------------- /dynamic_preferences/exceptions.py: -------------------------------------------------------------------------------- 1 | class DynamicPreferencesException(Exception): 2 | detail_default = "An exception occurred with django-dynamic-preferences" 3 | 4 | def __init__(self, detail=None): 5 | if detail is not None: 6 | self.detail = str(detail) 7 | else: 8 | self.detail = str(self.detail_default) 9 | 10 | def __str__(self): 11 | return self.detail 12 | 13 | 14 | class MissingDefault(DynamicPreferencesException): 15 | detail_default = "You must provide a default value for all preferences" 16 | 17 | 18 | class NotFoundInRegistry(DynamicPreferencesException, KeyError): 19 | detail_default = "Preference with this name/section not found in registry" 20 | 21 | 22 | class DoesNotExist(DynamicPreferencesException): 23 | detail_default = "Cannot retrieve preference value, ensure the preference is correctly registered and database is synced" 24 | 25 | 26 | class CachedValueNotFound(DynamicPreferencesException): 27 | detail_default = "Cached value not found" 28 | 29 | 30 | class MissingModel(DynamicPreferencesException): 31 | detail_default = 'You must define a model choice through "model" \ 32 | or "queryset" attribute' 33 | -------------------------------------------------------------------------------- /dynamic_preferences/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import ValidationError 3 | from collections import OrderedDict 4 | 5 | from .registries import global_preferences_registry 6 | from .models import GlobalPreferenceModel 7 | from .exceptions import NotFoundInRegistry 8 | 9 | 10 | class AbstractSinglePreferenceForm(forms.ModelForm): 11 | class Meta: 12 | fields = ("section", "name", "raw_value") 13 | 14 | def __init__(self, *args, **kwargs): 15 | 16 | self.instance = kwargs.get("instance") 17 | initial = {} 18 | if self.instance: 19 | initial["raw_value"] = self.instance.value 20 | kwargs["initial"] = initial 21 | super(AbstractSinglePreferenceForm, self).__init__(*args, **kwargs) 22 | 23 | if self.instance.name: 24 | self.fields["raw_value"] = self.instance.preference.setup_field() 25 | 26 | def clean(self): 27 | cleaned_data = super(AbstractSinglePreferenceForm, self).clean() 28 | try: 29 | self.instance.name, self.instance.section = ( 30 | cleaned_data["name"], 31 | cleaned_data["section"], 32 | ) 33 | except KeyError: # changelist form 34 | pass 35 | try: 36 | self.instance.preference 37 | except NotFoundInRegistry: 38 | raise ValidationError(NotFoundInRegistry.detail_default) 39 | return self.cleaned_data 40 | 41 | def save(self, *args, **kwargs): 42 | self.instance.value = self.cleaned_data["raw_value"] 43 | return super(AbstractSinglePreferenceForm, self).save(*args, **kwargs) 44 | 45 | 46 | class SinglePerInstancePreferenceForm(AbstractSinglePreferenceForm): 47 | class Meta: 48 | fields = ("instance",) + AbstractSinglePreferenceForm.Meta.fields 49 | 50 | def clean(self): 51 | cleaned_data = super(AbstractSinglePreferenceForm, self).clean() 52 | try: 53 | self.instance.name, self.instance.section = ( 54 | cleaned_data["name"], 55 | cleaned_data["section"], 56 | ) 57 | except KeyError: # changelist form 58 | pass 59 | i = cleaned_data.get("instance") 60 | if i: 61 | self.instance.instance = i 62 | try: 63 | self.instance.preference 64 | except NotFoundInRegistry: 65 | raise ValidationError(NotFoundInRegistry.detail_default) 66 | return self.cleaned_data 67 | 68 | 69 | class GlobalSinglePreferenceForm(AbstractSinglePreferenceForm): 70 | class Meta: 71 | model = GlobalPreferenceModel 72 | fields = AbstractSinglePreferenceForm.Meta.fields 73 | 74 | 75 | def preference_form_builder(form_base_class, preferences=[], **kwargs): 76 | """ 77 | Return a form class for updating preferences 78 | :param form_base_class: a Form class used as the base. Must have a ``registry` attribute 79 | :param preferences: a list of :py:class: 80 | :param section: a section where the form builder will load preferences 81 | """ 82 | registry = form_base_class.registry 83 | preferences_obj = [] 84 | if len(preferences) > 0: 85 | # Preferences have been selected explicitly 86 | for pref in preferences: 87 | if isinstance(pref, str): 88 | preferences_obj.append(registry.get(name=pref)) 89 | elif type(pref) == tuple: 90 | preferences_obj.append(registry.get(name=pref[0], section=pref[1])) 91 | else: 92 | raise NotImplementedError( 93 | "The data you provide can't be converted to a Preference object" 94 | ) 95 | elif kwargs.get("section", None): 96 | # Try to use section param 97 | preferences_obj = registry.preferences(section=kwargs.get("section", None)) 98 | 99 | else: 100 | # display all preferences in the form 101 | preferences_obj = registry.preferences() 102 | 103 | fields = OrderedDict() 104 | instances = [] 105 | if "model" in kwargs: 106 | # backward compat, see #212 107 | manager_kwargs = kwargs.get("model") 108 | else: 109 | manager_kwargs = {"instance": kwargs.get("instance", None)} 110 | manager = registry.manager(**manager_kwargs) 111 | 112 | for preference in preferences_obj: 113 | f = preference.field 114 | instance = manager.get_db_pref( 115 | section=preference.section.name, name=preference.name 116 | ) 117 | f.initial = instance.value 118 | fields[preference.identifier()] = f 119 | instances.append(instance) 120 | 121 | form_class = type("Custom" + form_base_class.__name__, (form_base_class,), {}) 122 | form_class.base_fields = fields 123 | form_class.preferences = preferences_obj 124 | form_class.instances = instances 125 | form_class.manager = manager 126 | return form_class 127 | 128 | 129 | def global_preference_form_builder(preferences=[], **kwargs): 130 | """ 131 | A shortcut :py:func:`preference_form_builder(GlobalPreferenceForm, preferences, **kwargs)` 132 | """ 133 | return preference_form_builder(GlobalPreferenceForm, preferences, **kwargs) 134 | 135 | 136 | class PreferenceForm(forms.Form): 137 | 138 | registry = None 139 | 140 | def update_preferences(self, **kwargs): 141 | for instance in self.instances: 142 | self.manager.update_db_pref( 143 | instance.preference.section.name, 144 | instance.preference.name, 145 | self.cleaned_data[instance.preference.identifier()], 146 | ) 147 | 148 | 149 | class GlobalPreferenceForm(PreferenceForm): 150 | 151 | registry = global_preferences_registry 152 | -------------------------------------------------------------------------------- /dynamic_preferences/locale/ar/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/dynamic_preferences/locale/ar/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /dynamic_preferences/locale/ar/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2018-11-08 10:37+0100\n" 11 | "PO-Revision-Date: 2018-11-09 17:15+0100\n" 12 | "Last-Translator: \n" 13 | "Language-Team: \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " 18 | "&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" 19 | "Language: ar\n" 20 | "X-Generator: Poedit 2.1.1\n" 21 | 22 | #: .\admin.py:56 23 | msgid "Default Value" 24 | msgstr "القيمة الافتراضية" 25 | 26 | #: .\admin.py:65 27 | msgid "Section Name" 28 | msgstr "إسم القسم" 29 | 30 | #: .\apps.py:9 31 | msgid "Dynamic Preferences" 32 | msgstr "التفضيلات الديناميكية" 33 | 34 | #: .\models.py:25 35 | msgid "Name" 36 | msgstr "الاسم" 37 | 38 | #: .\models.py:28 39 | msgid "Raw Value" 40 | msgstr "القيمة الأولية" 41 | 42 | #: .\models.py:42 43 | msgid "Verbose Name" 44 | msgstr "اسم مطول" 45 | 46 | #: .\models.py:47 47 | msgid "Help Text" 48 | msgstr "نص المساعدة" 49 | 50 | #: .\models.py:84 51 | msgid "Global preference" 52 | msgstr "التفضيل العام" 53 | 54 | #: .\models.py:85 55 | msgid "Global preferences" 56 | msgstr "التفضيل العام" 57 | 58 | #: .\templates\dynamic_preferences\form.html:11 59 | msgid "Submit" 60 | msgstr "إرسال" 61 | -------------------------------------------------------------------------------- /dynamic_preferences/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/dynamic_preferences/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /dynamic_preferences/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2019-04-15 13:48+0200\n" 11 | "PO-Revision-Date: 2018-11-09 17:14+0100\n" 12 | "Last-Translator: \n" 13 | "Language-Team: \n" 14 | "Language: fr\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 19 | "X-Generator: Poedit 2.1.1\n" 20 | 21 | #: .\admin.py:56 22 | msgid "Default Value" 23 | msgstr "Standardwert" 24 | 25 | #: .\admin.py:65 .\models.py:22 26 | msgid "Section Name" 27 | msgstr "Abschnitt" 28 | 29 | #: .\apps.py:9 30 | msgid "Dynamic Preferences" 31 | msgstr "Dynamische Einstellungen" 32 | 33 | #: .\models.py:25 34 | msgid "Name" 35 | msgstr "Name" 36 | 37 | #: .\models.py:28 38 | msgid "Raw Value" 39 | msgstr "Wert" 40 | 41 | #: .\models.py:42 42 | msgid "Verbose Name" 43 | msgstr "Bezeichnung" 44 | 45 | #: .\models.py:47 46 | msgid "Help Text" 47 | msgstr "Hilfetext" 48 | 49 | #: .\models.py:84 50 | msgid "Global preference" 51 | msgstr "Globale Einstellung" 52 | 53 | #: .\models.py:85 54 | msgid "Global preferences" 55 | msgstr "Globale Einstellungen" 56 | 57 | #: .\templates\dynamic_preferences\form.html:11 58 | msgid "Submit" 59 | msgstr "Absenden" 60 | 61 | #: .\users\apps.py:11 62 | msgid "Preferences - Users" 63 | msgstr "Einstellungen - Benutzer" 64 | 65 | #: .\users\models.py:15 66 | msgid "user preference" 67 | msgstr "Benutzer Einstellung" 68 | 69 | #: .\users\models.py:16 70 | msgid "user preferences" 71 | msgstr "Benutzer Einstellungen" 72 | -------------------------------------------------------------------------------- /dynamic_preferences/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/dynamic_preferences/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /dynamic_preferences/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2018-11-08 10:37+0100\n" 11 | "PO-Revision-Date: 2018-11-09 17:14+0100\n" 12 | "Last-Translator: \n" 13 | "Language-Team: \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 18 | "Language: fr\n" 19 | "X-Generator: Poedit 2.1.1\n" 20 | 21 | #: .\admin.py:56 22 | msgid "Default Value" 23 | msgstr "Valeur par défaut" 24 | 25 | #: .\admin.py:65 26 | msgid "Section Name" 27 | msgstr "Nom de la Section" 28 | 29 | #: .\apps.py:9 30 | msgid "Dynamic Preferences" 31 | msgstr "Préférences dynamiques" 32 | 33 | #: .\models.py:25 34 | msgid "Name" 35 | msgstr "Nom" 36 | 37 | #: .\models.py:28 38 | msgid "Raw Value" 39 | msgstr "Valeur RAW" 40 | 41 | #: .\models.py:42 42 | msgid "Verbose Name" 43 | msgstr "Nom détaillé" 44 | 45 | #: .\models.py:47 46 | msgid "Help Text" 47 | msgstr "Texte d'Aide" 48 | 49 | #: .\models.py:84 50 | msgid "Global preference" 51 | msgstr "Préférence globale" 52 | 53 | #: .\models.py:85 54 | msgid "Global preferences" 55 | msgstr "Préférences globales" 56 | 57 | #: .\templates\dynamic_preferences\form.html:11 58 | msgid "Submit" 59 | msgstr "Valider" 60 | -------------------------------------------------------------------------------- /dynamic_preferences/locale/id/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/dynamic_preferences/locale/id/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /dynamic_preferences/locale/id/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # Kira , 2023. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2023-07-19 20:44+0800\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: Kira \n" 14 | "Language-Team: \n" 15 | "Language: id\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=1; plural=0;\n" 20 | 21 | #: .\dynamic_preferences\admin.py:66 22 | msgid "Default Value" 23 | msgstr "Nilai Default" 24 | 25 | #: .\dynamic_preferences\admin.py:75 .\dynamic_preferences\models.py:30 26 | msgid "Section Name" 27 | msgstr "Nama Bagian" 28 | 29 | #: .\dynamic_preferences\apps.py:10 30 | msgid "Dynamic Preferences" 31 | msgstr "Preferensi Dinamis" 32 | 33 | #: .\dynamic_preferences\models.py:34 34 | msgid "Name" 35 | msgstr "Nama" 36 | 37 | #: .\dynamic_preferences\models.py:37 38 | msgid "Raw Value" 39 | msgstr "Nilai Mentah" 40 | 41 | #: .\dynamic_preferences\models.py:51 42 | msgid "Verbose Name" 43 | msgstr "Nama Verbose" 44 | 45 | #: .\dynamic_preferences\models.py:57 46 | msgid "Help Text" 47 | msgstr "Teks Bantuan" 48 | 49 | #: .\dynamic_preferences\models.py:94 50 | msgid "Global preference" 51 | msgstr "Preferensi global" 52 | 53 | #: .\dynamic_preferences\models.py:95 54 | msgid "Global preferences" 55 | msgstr "Preferensi global" 56 | 57 | #: .\dynamic_preferences\templates\dynamic_preferences\form.html:11 58 | msgid "Submit" 59 | msgstr "Kirim" 60 | 61 | #: .\dynamic_preferences\users\apps.py:11 62 | msgid "Preferences - Users" 63 | msgstr "Preferensi - Pengguna" 64 | 65 | #: .\dynamic_preferences\users\models.py:14 66 | msgid "user preference" 67 | msgstr "preferensi pengguna" 68 | 69 | #: .\dynamic_preferences\users\models.py:15 70 | msgid "user preferences" 71 | msgstr "preferensi pengguna" 72 | -------------------------------------------------------------------------------- /dynamic_preferences/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/dynamic_preferences/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /dynamic_preferences/locale/pl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2019-04-15 13:48+0200\n" 11 | "PO-Revision-Date: 2020-09-23 23:59+0200\n" 12 | "Last-Translator: \n" 13 | "Language-Team: \n" 14 | "Language: pl\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 19 | "X-Generator: Poedit 2.4.1\n" 20 | 21 | #: .\admin.py:56 22 | msgid "Default Value" 23 | msgstr "Wartość domyślna" 24 | 25 | #: .\admin.py:65 .\models.py:22 26 | msgid "Section Name" 27 | msgstr "Nazwa sekcji" 28 | 29 | #: .\apps.py:9 30 | msgid "Dynamic Preferences" 31 | msgstr "Dynamiczne Preferencje" 32 | 33 | #: .\models.py:25 34 | msgid "Name" 35 | msgstr "Nazwa" 36 | 37 | #: .\models.py:28 38 | msgid "Raw Value" 39 | msgstr "Surowa wartość" 40 | 41 | #: .\models.py:42 42 | msgid "Verbose Name" 43 | msgstr "Nazwa Szczegółowa" 44 | 45 | #: .\models.py:47 46 | msgid "Help Text" 47 | msgstr "Tekst pomocy" 48 | 49 | #: .\models.py:84 50 | msgid "Global preference" 51 | msgstr "Globalna preferencja" 52 | 53 | #: .\models.py:85 54 | msgid "Global preferences" 55 | msgstr "Globalne Preferencje" 56 | 57 | #: .\templates\dynamic_preferences\form.html:11 58 | msgid "Submit" 59 | msgstr "Wyślij" 60 | 61 | #: .\users\apps.py:11 62 | msgid "Preferences - Users" 63 | msgstr "Preferencje - Użytkownicy" 64 | 65 | #: .\users\models.py:15 66 | msgid "user preference" 67 | msgstr "preferencja użytkownika" 68 | 69 | #: .\users\models.py:16 70 | msgid "user preferences" 71 | msgstr "preferencje użytkownika" 72 | -------------------------------------------------------------------------------- /dynamic_preferences/management/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "agateblue" 2 | -------------------------------------------------------------------------------- /dynamic_preferences/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "agateblue" 2 | -------------------------------------------------------------------------------- /dynamic_preferences/management/commands/checkpreferences.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from dynamic_preferences.exceptions import NotFoundInRegistry 3 | from dynamic_preferences.models import GlobalPreferenceModel 4 | from dynamic_preferences.registries import ( 5 | global_preferences_registry, 6 | preference_models, 7 | ) 8 | from dynamic_preferences.settings import preferences_settings 9 | 10 | 11 | def delete_preferences(queryset): 12 | """ 13 | Delete preferences objects if they are not present in registry. 14 | Return a list of deleted objects 15 | """ 16 | deleted = [] 17 | 18 | # Iterate through preferences. If an error is raised when accessing 19 | # preference object, just delete it 20 | for p in queryset: 21 | try: 22 | p.registry.get(section=p.section, name=p.name, fallback=False) 23 | except NotFoundInRegistry: 24 | p.delete() 25 | deleted.append(p) 26 | 27 | return deleted 28 | 29 | 30 | class Command(BaseCommand): 31 | help = ( 32 | "Find and delete preferences from database if they don't exist in " 33 | "registries. Create preferences that are not present in database" 34 | "(except when invoked with --skip_create)." 35 | ) 36 | 37 | def add_arguments(self, parser): 38 | parser.add_argument( 39 | "--skip_create", 40 | action="store_true", 41 | help="Forces to skip the creation step for missing preferences", 42 | ) 43 | 44 | def handle(self, *args, **options): 45 | skip_create = options["skip_create"] 46 | 47 | # Create needed preferences 48 | # Global 49 | if not skip_create: 50 | self.stdout.write("Creating missing global preferences...") 51 | manager = global_preferences_registry.manager() 52 | manager.all() 53 | 54 | deleted = delete_preferences(GlobalPreferenceModel.objects.all()) 55 | message = "Deleted {deleted} global preferences".format(deleted=len(deleted)) 56 | self.stdout.write(message) 57 | 58 | for preference_model, registry in preference_models.items(): 59 | deleted = delete_preferences(preference_model.objects.all()) 60 | message = "Deleted {deleted} {model} preferences".format( 61 | deleted=len(deleted), 62 | model=preference_model.__name__, 63 | ) 64 | self.stdout.write(message) 65 | if not hasattr(preference_model, "get_instance_model"): 66 | continue 67 | 68 | if skip_create: 69 | continue 70 | 71 | message = "Creating missing preferences for {model} model...".format( 72 | model=preference_model.get_instance_model().__name__, 73 | ) 74 | self.stdout.write(message) 75 | for instance in preference_model.get_instance_model().objects.all(): 76 | getattr(instance, preferences_settings.MANAGER_ATTRIBUTE).all() 77 | -------------------------------------------------------------------------------- /dynamic_preferences/managers.py: -------------------------------------------------------------------------------- 1 | try: 2 | from collections.abc import Mapping 3 | except ImportError: 4 | from collections import Mapping 5 | 6 | from .settings import preferences_settings 7 | from .exceptions import CachedValueNotFound, DoesNotExist 8 | from .signals import preference_updated 9 | 10 | 11 | class PreferencesManager(Mapping): 12 | 13 | """Handle retrieving / caching of preferences""" 14 | 15 | def __init__(self, model, registry, **kwargs): 16 | self.model = model 17 | self.registry = registry 18 | self.instance = kwargs.get("instance") 19 | 20 | @property 21 | def queryset(self): 22 | qs = self.model.objects.all() 23 | if self.instance: 24 | qs = qs.filter(instance=self.instance) 25 | return qs 26 | 27 | @property 28 | def cache(self): 29 | from django.core.cache import caches 30 | 31 | return caches[preferences_settings.CACHE_NAME] 32 | 33 | def __getitem__(self, key): 34 | return self.get(key) 35 | 36 | def __setitem__(self, key, value): 37 | section, name = self.parse_lookup(key) 38 | preference = self.registry.get(section=section, name=name, fallback=False) 39 | preference.validate(value) 40 | self.update_db_pref(section=section, name=name, value=value) 41 | 42 | def __repr__(self): 43 | return repr(self.all()) 44 | 45 | def __iter__(self): 46 | return self.all().__iter__() 47 | 48 | def __len__(self): 49 | return len(self.all()) 50 | 51 | def by_name(self): 52 | """Return a dictionary with preferences identifiers and values, but without the section name in the identifier""" 53 | return { 54 | key.split(preferences_settings.SECTION_KEY_SEPARATOR)[-1]: value 55 | for key, value in self.all().items() 56 | } 57 | 58 | def get_by_name(self, name): 59 | return self.get(self.registry.get_by_name(name).identifier()) 60 | 61 | def get_cache_key(self, section, name): 62 | """Return the cache key corresponding to a given preference""" 63 | if not self.instance: 64 | return "dynamic_preferences_{0}_{1}_{2}".format( 65 | self.model.__name__, section, name 66 | ) 67 | return "dynamic_preferences_{0}_{1}_{2}_{3}".format( 68 | self.model.__name__, self.instance.pk, section, name, self.instance.pk 69 | ) 70 | 71 | def from_cache(self, section, name): 72 | """Return a preference raw_value from cache""" 73 | cached_value = self.cache.get( 74 | self.get_cache_key(section, name), CachedValueNotFound 75 | ) 76 | 77 | if cached_value is CachedValueNotFound: 78 | raise CachedValueNotFound 79 | 80 | if cached_value == preferences_settings.CACHE_NONE_VALUE: 81 | cached_value = None 82 | return self.registry.get(section=section, name=name).serializer.deserialize( 83 | cached_value 84 | ) 85 | 86 | def many_from_cache(self, preferences): 87 | """ 88 | Return cached value for given preferences 89 | missing preferences will be skipped 90 | """ 91 | keys = {p: self.get_cache_key(p.section.name, p.name) for p in preferences} 92 | cached = self.cache.get_many(list(keys.values())) 93 | 94 | for k, v in cached.items(): 95 | # we replace dummy cached values by None here, if needed 96 | if v == preferences_settings.CACHE_NONE_VALUE: 97 | cached[k] = None 98 | 99 | # we have to remap returned value since the underlying cached keys 100 | # are not usable for an end user 101 | return { 102 | p.identifier(): p.serializer.deserialize(cached[k]) 103 | for p, k in keys.items() 104 | if k in cached 105 | } 106 | 107 | def to_cache(self, *prefs): 108 | """ 109 | Update/create the cache value for the given preference model instances 110 | """ 111 | update_dict = {} 112 | for pref in prefs: 113 | key = self.get_cache_key(pref.section, pref.name) 114 | value = pref.raw_value 115 | if value is None or value == "": 116 | # some cache backends refuse to cache None or empty values 117 | # resulting in more DB queries, so we cache an arbitrary value 118 | # to ensure the cache is hot (even with empty values) 119 | value = preferences_settings.CACHE_NONE_VALUE 120 | update_dict[key] = value 121 | 122 | self.cache.set_many(update_dict) 123 | 124 | def pref_obj(self, section, name): 125 | return self.registry.get(section=section, name=name) 126 | 127 | def parse_lookup(self, lookup): 128 | try: 129 | section, name = lookup.split(preferences_settings.SECTION_KEY_SEPARATOR) 130 | except ValueError: 131 | name = lookup 132 | section = None 133 | return section, name 134 | 135 | def get(self, key, no_cache=False): 136 | """Return the value of a single preference using a dotted path key 137 | :arg no_cache: if true, the cache is bypassed 138 | """ 139 | section, name = self.parse_lookup(key) 140 | preference = self.registry.get(section=section, name=name, fallback=False) 141 | if no_cache or not preferences_settings.ENABLE_CACHE: 142 | return self.get_db_pref(section=section, name=name).value 143 | 144 | try: 145 | return self.from_cache(section, name) 146 | except CachedValueNotFound: 147 | pass 148 | 149 | db_pref = self.get_db_pref(section=section, name=name) 150 | self.to_cache(db_pref) 151 | return db_pref.value 152 | 153 | def get_db_pref(self, section, name): 154 | try: 155 | pref = self.queryset.get(section=section, name=name) 156 | except self.model.DoesNotExist: 157 | pref_obj = self.pref_obj(section=section, name=name) 158 | pref = self.create_db_pref( 159 | section=section, name=name, value=pref_obj.get("default") 160 | ) 161 | 162 | return pref 163 | 164 | def update_db_pref(self, section, name, value): 165 | try: 166 | db_pref = self.queryset.get(section=section, name=name) 167 | old_value = db_pref.value 168 | db_pref.value = value 169 | db_pref.save() 170 | preference_updated.send( 171 | sender=self.__class__, 172 | section=section, 173 | name=name, 174 | old_value=old_value, 175 | new_value=value, 176 | instance=db_pref 177 | ) 178 | except self.model.DoesNotExist: 179 | return self.create_db_pref(section, name, value) 180 | 181 | return db_pref 182 | 183 | def create_db_pref(self, section, name, value): 184 | kwargs = { 185 | "section": section, 186 | "name": name, 187 | } 188 | if self.instance: 189 | kwargs["instance"] = self.instance 190 | 191 | # this is a just a shortcut to get the raw, serialized value 192 | # so we can pass it to get_or_create 193 | m = self.model(**kwargs) 194 | m.value = value 195 | raw_value = m.raw_value 196 | 197 | db_pref, created = self.model.objects.get_or_create(**kwargs) 198 | if created and db_pref.raw_value != raw_value: 199 | db_pref.raw_value = raw_value 200 | db_pref.save() 201 | 202 | return db_pref 203 | 204 | def all(self): 205 | """Return a dictionary containing all preferences by section 206 | Loaded from cache or from db in case of cold cache 207 | """ 208 | if not preferences_settings.ENABLE_CACHE: 209 | return self.load_from_db() 210 | 211 | preferences = self.registry.preferences() 212 | 213 | # first we hit the cache once for all existing preferences 214 | a = self.many_from_cache(preferences) 215 | if len(a) == len(preferences): 216 | return a # avoid database hit if not necessary 217 | 218 | # then we fill those that miss, but exist in the database 219 | # (just hit the database for all of them, filtering is complicated, and 220 | # in most cases you'd need to grab the majority of them anyway) 221 | a.update(self.load_from_db(cache=True)) 222 | return a 223 | 224 | def load_from_db(self, cache=False): 225 | """Return a dictionary of preferences by section directly from DB""" 226 | a = {} 227 | db_prefs = {p.preference.identifier(): p for p in self.queryset} 228 | cache_prefs = [] 229 | 230 | for preference in self.registry.preferences(): 231 | try: 232 | db_pref = db_prefs[preference.identifier()] 233 | except KeyError: 234 | db_pref = self.create_db_pref( 235 | section=preference.section.name, 236 | name=preference.name, 237 | value=preference.get("default"), 238 | ) 239 | else: 240 | # cache if create_db_pref() hasn't already done so 241 | if cache: 242 | cache_prefs.append(db_pref) 243 | 244 | a[preference.identifier()] = db_pref.value 245 | 246 | if cache_prefs: 247 | self.to_cache(*cache_prefs) 248 | 249 | return a 250 | -------------------------------------------------------------------------------- /dynamic_preferences/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="GlobalPreferenceModel", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | auto_created=True, 25 | ), 26 | ), 27 | ( 28 | "section", 29 | models.CharField( 30 | blank=True, 31 | default=None, 32 | null=True, 33 | max_length=150, 34 | db_index=True, 35 | ), 36 | ), 37 | ("name", models.CharField(max_length=150, db_index=True)), 38 | ("raw_value", models.TextField(blank=True, null=True)), 39 | ], 40 | options={ 41 | "verbose_name_plural": "global preferences", 42 | "verbose_name": "global preference", 43 | }, 44 | bases=(models.Model,), 45 | ), 46 | migrations.AlterUniqueTogether( 47 | name="globalpreferencemodel", 48 | unique_together=set([("section", "name")]), 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /dynamic_preferences/migrations/0002_auto_20150712_0332.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("dynamic_preferences", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="globalpreferencemodel", 17 | name="name", 18 | field=models.CharField(max_length=150, db_index=True), 19 | ), 20 | migrations.AlterField( 21 | model_name="globalpreferencemodel", 22 | name="section", 23 | field=models.CharField( 24 | max_length=150, blank=True, db_index=True, default=None, null=True 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /dynamic_preferences/migrations/0003_auto_20151223_1407.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("dynamic_preferences", "0002_auto_20150712_0332"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="globalpreferencemodel", 16 | name="name", 17 | field=models.CharField(max_length=150, db_index=True), 18 | preserve_default=True, 19 | ), 20 | migrations.AlterField( 21 | model_name="globalpreferencemodel", 22 | name="section", 23 | field=models.CharField( 24 | max_length=150, 25 | null=True, 26 | default=None, 27 | db_index=True, 28 | blank=True, 29 | verbose_name="Section Name", 30 | ), 31 | preserve_default=True, 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /dynamic_preferences/migrations/0004_move_user_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | from django.conf import settings 6 | 7 | 8 | class Migration(migrations.Migration): 9 | """ 10 | Migration to move the user preferences to a dedicated app, see #33 11 | Borrowed from http://stackoverflow.com/a/26472482/2844093 12 | """ 13 | 14 | dependencies = [ 15 | ("dynamic_preferences", "0003_auto_20151223_1407"), 16 | ] 17 | 18 | # cf https://github.com/agateblue/django-dynamic-preferences/pull/142 19 | operations = [] 20 | -------------------------------------------------------------------------------- /dynamic_preferences/migrations/0005_auto_20181120_0848.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("dynamic_preferences", "0004_move_user_model"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name="globalpreferencemodel", 16 | options={ 17 | "verbose_name": "Global preference", 18 | "verbose_name_plural": "Global preferences", 19 | }, 20 | ), 21 | migrations.AlterField( 22 | model_name="globalpreferencemodel", 23 | name="name", 24 | field=models.CharField(db_index=True, max_length=150, verbose_name="Name"), 25 | ), 26 | migrations.AlterField( 27 | model_name="globalpreferencemodel", 28 | name="raw_value", 29 | field=models.TextField(blank=True, null=True, verbose_name="Raw Value"), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /dynamic_preferences/migrations/0006_auto_20191001_2236.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.7 on 2019-10-01 14:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("dynamic_preferences", "0005_auto_20181120_0848"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="globalpreferencemodel", 15 | name="section", 16 | field=models.CharField( 17 | blank=True, 18 | db_index=True, 19 | default=None, 20 | max_length=150, 21 | null=True, 22 | verbose_name="Section Name", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /dynamic_preferences/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/dynamic_preferences/migrations/__init__.py -------------------------------------------------------------------------------- /dynamic_preferences/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Preference models, queryset and managers that handle the logic for persisting preferences. 3 | """ 4 | 5 | from django.db import models 6 | from django.db.models.query import QuerySet 7 | from django.conf import settings 8 | from django.utils.functional import cached_property 9 | from django.utils.translation import gettext_lazy as _ 10 | from dynamic_preferences.registries import ( 11 | preference_models, 12 | global_preferences_registry, 13 | ) 14 | from .utils import update 15 | 16 | 17 | class BasePreferenceModel(models.Model): 18 | 19 | """ 20 | A base model with common logic for all preferences models. 21 | """ 22 | 23 | #: The section under which the preference is declared 24 | section = models.CharField( 25 | max_length=150, 26 | db_index=True, 27 | blank=True, 28 | null=True, 29 | default=None, 30 | verbose_name=_("Section Name"), 31 | ) 32 | 33 | #: a name for the preference 34 | name = models.CharField(_("Name"), max_length=150, db_index=True) 35 | 36 | #: a value, serialized to a string. This field should not be accessed directly, use :py:attr:`BasePreferenceModel.value` instead 37 | raw_value = models.TextField(_("Raw Value"), null=True, blank=True) 38 | 39 | class Meta: 40 | abstract = True 41 | app_label = "dynamic_preferences" 42 | 43 | @cached_property 44 | def preference(self): 45 | return self.registry.get(section=self.section, name=self.name, fallback=True) 46 | 47 | @property 48 | def verbose_name(self): 49 | return self.preference.get("verbose_name", self.preference.identifier) 50 | 51 | verbose_name.fget.short_description = _("Verbose Name") 52 | 53 | @property 54 | def help_text(self): 55 | return self.preference.get("help_text", "") 56 | 57 | help_text.fget.short_description = _("Help Text") 58 | 59 | def set_value(self, value): 60 | """ 61 | Save serialized self.value to self.raw_value 62 | """ 63 | self.raw_value = self.preference.serializer.serialize(value) 64 | 65 | def get_value(self): 66 | """ 67 | Return deserialized self.raw_value 68 | """ 69 | return self.preference.serializer.deserialize(self.raw_value) 70 | 71 | value = property(get_value, set_value) 72 | 73 | def save(self, **kwargs): 74 | 75 | if self.pk is None and not self.raw_value: 76 | self.value = self.preference.get("default") 77 | super(BasePreferenceModel, self).save(**kwargs) 78 | 79 | def __str__(self): 80 | return self.__repr__() 81 | 82 | def __repr__(self): 83 | return "{0} - {1}/{2}".format(self.__class__.__name__, self.section, self.name) 84 | 85 | 86 | class GlobalPreferenceModel(BasePreferenceModel): 87 | 88 | registry = global_preferences_registry 89 | 90 | class Meta: 91 | unique_together = ("section", "name") 92 | app_label = "dynamic_preferences" 93 | 94 | verbose_name = _("Global preference") 95 | verbose_name_plural = _("Global preferences") 96 | 97 | 98 | class PerInstancePreferenceModel(BasePreferenceModel): 99 | 100 | """For preferences that are tied to a specific model instance""" 101 | 102 | #: the instance which is concerned by the preference 103 | #: use a ForeignKey pointing to the model of your choice 104 | instance = None 105 | 106 | class Meta(BasePreferenceModel.Meta): 107 | unique_together = ("instance", "section", "name") 108 | abstract = True 109 | 110 | @classmethod 111 | def get_instance_model(cls): 112 | return cls._meta.get_field("instance").remote_field.model 113 | 114 | 115 | global_preferences_registry.preference_model = GlobalPreferenceModel 116 | 117 | # Create default preferences for new instances 118 | 119 | from django.db.models.signals import post_save 120 | 121 | 122 | def invalidate_cache(sender, created, instance, **kwargs): 123 | if not isinstance(instance, BasePreferenceModel): 124 | return 125 | registry = preference_models.get_by_preference(instance) 126 | linked_instance = getattr(instance, "instance", None) 127 | kwargs = {} 128 | if linked_instance: 129 | kwargs["instance"] = linked_instance 130 | 131 | manager = registry.manager(**kwargs) 132 | manager.to_cache(instance) 133 | 134 | 135 | post_save.connect(invalidate_cache) 136 | -------------------------------------------------------------------------------- /dynamic_preferences/preferences.py: -------------------------------------------------------------------------------- 1 | """ 2 | Preferences are regular Python objects that can be declared within any django app. 3 | Once declared and registered, they can be edited by admins (for :py:class:`SitePreference` and :py:class:`GlobalPreference`) 4 | and regular Users (for :py:class:`UserPreference`) 5 | 6 | UserPreference, SitePreference and GlobalPreference are mapped to corresponding PreferenceModel, 7 | which store the actual values. 8 | 9 | """ 10 | from __future__ import unicode_literals 11 | import re 12 | import warnings 13 | 14 | from .settings import preferences_settings 15 | from .exceptions import MissingDefault 16 | from .serializers import UNSET 17 | 18 | 19 | class InvalidNameError(ValueError): 20 | pass 21 | 22 | 23 | def check_name(name, obj): 24 | error = None 25 | if not re.match(r"^\w+$", name): 26 | error = "Non-alphanumeric / underscore characters are forbidden in section and preferences names" 27 | if preferences_settings.SECTION_KEY_SEPARATOR in name: 28 | error = 'Sequence "{0}" is forbidden in section and preferences name, since it is used to access values via managers'.format( 29 | preferences_settings.SECTION_KEY_SEPARATOR 30 | ) 31 | 32 | if error: 33 | full_message = 'Invalid name "{0}" while instanciating {1} object: {2}'.format( 34 | name, obj, error 35 | ) 36 | raise InvalidNameError(full_message) 37 | 38 | 39 | class Section(object): 40 | def __init__(self, name, verbose_name=None): 41 | self.name = name 42 | self.verbose_name = verbose_name or name 43 | if preferences_settings.VALIDATE_NAMES and name: 44 | check_name(self.name, self) 45 | 46 | def __str__(self): 47 | if not self.verbose_name: 48 | return "" 49 | return str(self.verbose_name) 50 | 51 | 52 | EMPTY_SECTION = Section(None) 53 | 54 | 55 | class AbstractPreference(object): 56 | """ 57 | A base class that handle common logic for preferences 58 | """ 59 | 60 | #: The section under which the preference will be registered 61 | section = EMPTY_SECTION 62 | 63 | #: The preference name 64 | name = "" 65 | 66 | #: A default value for the preference 67 | default = UNSET 68 | 69 | def __init__(self, registry=None): 70 | if preferences_settings.VALIDATE_NAMES: 71 | check_name(self.name, self) 72 | if self.section and not hasattr(self.section, "name"): 73 | self.section = Section(name=self.section) 74 | warnings.warn( 75 | "Implicit section instanciation is deprecated and " 76 | "will be removed in future versions of django-dynamic-preferences", 77 | DeprecationWarning, 78 | stacklevel=2, 79 | ) 80 | 81 | self.registry = registry 82 | if self.default == UNSET and not getattr(self, "get_default", None): 83 | raise MissingDefault 84 | 85 | def get(self, attr, default=None): 86 | getter = "get_{0}".format(attr) 87 | if hasattr(self, getter): 88 | return getattr(self, getter)() 89 | return getattr(self, attr, default) 90 | 91 | @property 92 | def model(self): 93 | return self.registry.preference_model 94 | 95 | def identifier(self): 96 | """ 97 | Return the name and the section of the Preference joined with a separator, with the form `sectionname` 98 | """ 99 | 100 | if not self.section or not self.section.name: 101 | return self.name 102 | return preferences_settings.SECTION_KEY_SEPARATOR.join( 103 | [self.section.name, self.name] 104 | ) 105 | -------------------------------------------------------------------------------- /dynamic_preferences/processors.py: -------------------------------------------------------------------------------- 1 | from .registries import global_preferences_registry as gpr 2 | 3 | 4 | def global_preferences(request): 5 | """ 6 | Pass the values of global preferences to template context. 7 | You can then access value with `global_preferences.
.` 8 | """ 9 | manager = gpr.manager() 10 | return {"global_preferences": manager.all()} 11 | -------------------------------------------------------------------------------- /dynamic_preferences/registries.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import FieldDoesNotExist 2 | from django.apps import apps 3 | 4 | # import the logging library 5 | import warnings 6 | import logging 7 | import collections 8 | import persisting_theory 9 | 10 | # Get an instance of a logger 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | #: The package where autodiscover will try to find preferences to register 15 | 16 | from .managers import PreferencesManager 17 | from .settings import preferences_settings 18 | from .exceptions import NotFoundInRegistry 19 | from .types import StringPreference 20 | from .preferences import EMPTY_SECTION, Section 21 | 22 | 23 | class MissingPreference(StringPreference): 24 | """ 25 | Used as a fallback when the preference object is not found in registries 26 | This can happen for example when you delete a preference in the code, 27 | but don't remove the corresponding entries in database 28 | """ 29 | 30 | pass 31 | 32 | 33 | class PreferenceModelsRegistry(persisting_theory.Registry): 34 | """Store relationships beetween preferences model and preferences registry""" 35 | 36 | look_into = preferences_settings.REGISTRY_MODULE 37 | 38 | def register(self, preference_model, preference_registry): 39 | self[preference_model] = preference_registry 40 | preference_registry.preference_model = preference_model 41 | if not hasattr(preference_model, "registry"): 42 | setattr(preference_model, "registry", preference_registry) 43 | self.attach_manager(preference_model, preference_registry) 44 | 45 | def attach_manager(self, model, registry): 46 | if not hasattr(model, "instance"): 47 | return 48 | 49 | def instance_getter(self): 50 | return registry.manager(instance=self) 51 | 52 | getter = property(instance_getter) 53 | instance_class = model._meta.get_field("instance").remote_field.model 54 | setattr(instance_class, preferences_settings.MANAGER_ATTRIBUTE, getter) 55 | 56 | def get_by_preference(self, preference): 57 | return self[ 58 | preference._meta.proxy_for_model 59 | if preference._meta.proxy 60 | else preference.__class__ 61 | ] 62 | 63 | def get_by_instance(self, instance): 64 | """Return a preference registry using a model instance""" 65 | # we iterate through registered preference models in order to get the instance class 66 | # and check if instance is an instance of this class 67 | for model, registry in self.items(): 68 | try: 69 | instance_class = model._meta.get_field("instance").remote_field.model 70 | if isinstance(instance, instance_class): 71 | return registry 72 | 73 | except FieldDoesNotExist: # global preferences 74 | pass 75 | return None 76 | 77 | 78 | preference_models = PreferenceModelsRegistry() 79 | 80 | 81 | class PreferenceRegistry(persisting_theory.Registry): 82 | 83 | """ 84 | Registries are special dictionaries that are used by dynamic-preferences to register and access your preferences. 85 | dynamic-preferences has one registry per Preference type: 86 | 87 | - :py:const:`user_preferences` 88 | - :py:const:`site_preferences` 89 | - :py:const:`global_preferences` 90 | 91 | In order to register preferences automatically, you must call :py:func:`autodiscover` in your URLconf. 92 | 93 | """ 94 | 95 | look_into = preferences_settings.REGISTRY_MODULE 96 | 97 | #: a name to identify the registry 98 | name = "preferences_registry" 99 | preference_model = None 100 | 101 | #: used to reverse urls for sections in form views/templates 102 | section_url_namespace = None 103 | 104 | def __init__(self, *args, **kwargs): 105 | super(PreferenceRegistry, self).__init__(*args, **kwargs) 106 | self.section_objects = collections.OrderedDict() 107 | 108 | def register(self, preference_class): 109 | """ 110 | Store the given preference class in the registry. 111 | 112 | :param preference_class: a :py:class:`prefs.Preference` subclass 113 | """ 114 | preference = preference_class(registry=self) 115 | self.section_objects[preference.section.name] = preference.section 116 | 117 | try: 118 | self[preference.section.name][preference.name] = preference 119 | 120 | except KeyError: 121 | self[preference.section.name] = collections.OrderedDict() 122 | self[preference.section.name][preference.name] = preference 123 | 124 | return preference_class 125 | 126 | def _fallback(self, section_name, pref_name): 127 | """ 128 | Create a fallback preference object, 129 | This is used when you have model instances that do not match 130 | any registered preferences, see #41 131 | """ 132 | message = ( 133 | "Creating a fallback preference with " 134 | + 'section "{}" and name "{}".' 135 | + "This means you have preferences in your database that " 136 | + "don't match any registered preference. " 137 | + "If you want to delete these entries, please refer to the " 138 | + "documentation: https://django-dynamic-preferences.readthedocs.io/en/latest/lifecycle.html" 139 | ) # NOQA 140 | warnings.warn(message.format(section_name, pref_name)) 141 | 142 | class Fallback(MissingPreference): 143 | section = Section(name=section_name) if section_name else None 144 | name = pref_name 145 | default = "" 146 | help_text = "Obsolete: missing in registry" 147 | 148 | return Fallback() 149 | 150 | def get(self, name, section=None, fallback=False): 151 | """ 152 | Returns a previously registered preference 153 | 154 | :param section: The section name under which the preference is registered 155 | :type section: str. 156 | :param name: The name of the preference. You can use dotted notation 'section.name' if you want to avoid providing section param 157 | :type name: str. 158 | :param fallback: Should we return a dummy preference object instead of raising an error if no preference is found? 159 | :type name: bool. 160 | :return: a :py:class:`prefs.BasePreference` instance 161 | """ 162 | # try dotted notation 163 | try: 164 | _section, name = name.split(preferences_settings.SECTION_KEY_SEPARATOR) 165 | return self[_section][name] 166 | 167 | except ValueError: 168 | pass 169 | 170 | # use standard params 171 | try: 172 | return self[section][name] 173 | 174 | except KeyError: 175 | if fallback: 176 | return self._fallback(section_name=section, pref_name=name) 177 | raise NotFoundInRegistry( 178 | "No such preference in {0} with section={1} and name={2}".format( 179 | self.__class__.__name__, section, name 180 | ) 181 | ) 182 | 183 | def get_by_name(self, name): 184 | """Get a preference by name only (no section)""" 185 | for section in self.values(): 186 | for preference in section.values(): 187 | if preference.name == name: 188 | return preference 189 | raise NotFoundInRegistry( 190 | "No such preference in {0} with name={1}".format( 191 | self.__class__.__name__, name 192 | ) 193 | ) 194 | 195 | def manager(self, **kwargs): 196 | """Return a preference manager that can be used to retrieve preference values""" 197 | return PreferencesManager(registry=self, model=self.preference_model, **kwargs) 198 | 199 | def sections(self): 200 | """ 201 | :return: a list of apps with registered preferences 202 | :rtype: list 203 | """ 204 | 205 | return self.keys() 206 | 207 | def preferences(self, section=None): 208 | """ 209 | Return a list of all registered preferences 210 | or a list of preferences registered for a given section 211 | 212 | :param section: The section name under which the preference is registered 213 | :type section: str. 214 | :return: a list of :py:class:`prefs.BasePreference` instances 215 | """ 216 | 217 | if section is None: 218 | return [self[section][name] for section in self for name in self[section]] 219 | else: 220 | return [self[section][name] for name in self[section]] 221 | 222 | 223 | class PerInstancePreferenceRegistry(PreferenceRegistry): 224 | pass 225 | 226 | 227 | class GlobalPreferenceRegistry(PreferenceRegistry): 228 | section_url_namespace = "dynamic_preferences:global.section" 229 | 230 | def populate(self, **kwargs): 231 | return self.models(**kwargs) 232 | 233 | 234 | global_preferences_registry = GlobalPreferenceRegistry() 235 | -------------------------------------------------------------------------------- /dynamic_preferences/settings.py: -------------------------------------------------------------------------------- 1 | # Taken from django-rest-framework 2 | # https://github.com/tomchristie/django-rest-framework 3 | # Copyright (c) 2011-2015, Tom Christie All rights reserved. 4 | 5 | from django.conf import settings 6 | 7 | SETTINGS_ATTR = "DYNAMIC_PREFERENCES" 8 | USER_SETTINGS = None 9 | 10 | 11 | DEFAULTS = { 12 | # 'REGISTRY_MODULE': 'prefs', 13 | # 'BASE_PREFIX': 'base', 14 | # 'SECTIONS_PREFIX': 'sections', 15 | # 'PREFERENCES_PREFIX': 'preferences', 16 | # 'PERMISSIONS_PREFIX': 'permissions', 17 | "MANAGER_ATTRIBUTE": "preferences", 18 | "SECTION_KEY_SEPARATOR": "__", 19 | "REGISTRY_MODULE": "dynamic_preferences_registry", 20 | "ADMIN_ENABLE_CHANGELIST_FORM": False, 21 | "ENABLE_GLOBAL_MODEL_AUTO_REGISTRATION": True, 22 | "ENABLE_USER_PREFERENCES": True, 23 | "ENABLE_CACHE": True, 24 | "CACHE_NAME": "default", 25 | "VALIDATE_NAMES": True, 26 | "FILE_PREFERENCE_UPLOAD_DIR": "dynamic_preferences", 27 | # this will be used to cache empty values, since some cache backends 28 | # does not support it on get_many 29 | "CACHE_NONE_VALUE": "__dynamic_preferences_empty_value", 30 | } 31 | 32 | 33 | class PreferenceSettings(object): 34 | """ 35 | A settings object, that allows API settings to be accessed as properties. 36 | For example: 37 | 38 | from rest_framework.settings import api_settings 39 | print(api_settings.DEFAULT_RENDERER_CLASSES) 40 | 41 | Any setting with string import paths will be automatically resolved 42 | and return the class, rather than the string literal. 43 | """ 44 | 45 | def __init__(self, defaults=None): 46 | self.defaults = defaults or DEFAULTS 47 | 48 | @property 49 | def user_settings(self): 50 | return getattr(settings, SETTINGS_ATTR, {}) 51 | 52 | def __getattr__(self, attr): 53 | if attr not in self.defaults.keys(): 54 | raise AttributeError("Invalid preference setting: '%s'" % attr) 55 | 56 | try: 57 | # Check if present in user settings 58 | val = self.user_settings[attr] 59 | except KeyError: 60 | # Fall back to defaults 61 | val = self.defaults[attr] 62 | 63 | # Cache the result 64 | # We sometimes need to bypass that, like in tests 65 | if getattr(settings, "CACHE_DYNAMIC_PREFERENCES_SETTINGS", True): 66 | setattr(self, attr, val) 67 | return val 68 | 69 | 70 | preferences_settings = PreferenceSettings(DEFAULTS) 71 | -------------------------------------------------------------------------------- /dynamic_preferences/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | # Arguments provided to listeners: "section", "name", "old_value" and "new_value" 4 | preference_updated = Signal() 5 | -------------------------------------------------------------------------------- /dynamic_preferences/templates/dynamic_preferences/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 |
12 | {% block content %}{% endblock %} 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /dynamic_preferences/templates/dynamic_preferences/form.html: -------------------------------------------------------------------------------- 1 | {% extends "dynamic_preferences/base.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 | 5 | {# we continue to pass the sections key in case someone subclassed the template and use these #} 6 | {% include "dynamic_preferences/sections.html" with registry=registry sections=registry.sections %} 7 | 8 |
9 | {% csrf_token %} 10 | {{ form.as_p }} 11 | 12 |
13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /dynamic_preferences/templates/dynamic_preferences/sections.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /dynamic_preferences/templates/dynamic_preferences/testcontext.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/dynamic_preferences/templates/dynamic_preferences/testcontext.html -------------------------------------------------------------------------------- /dynamic_preferences/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.urls import include, re_path 3 | except ImportError: 4 | from django.conf.urls import include, url as re_path 5 | 6 | from django.contrib.admin.views.decorators import staff_member_required 7 | from . import views 8 | from .registries import global_preferences_registry 9 | from .forms import GlobalPreferenceForm 10 | 11 | app_name = "dynamic_preferences" 12 | 13 | urlpatterns = [ 14 | re_path( 15 | r"^global/$", 16 | staff_member_required( 17 | views.PreferenceFormView.as_view( 18 | registry=global_preferences_registry, form_class=GlobalPreferenceForm 19 | ) 20 | ), 21 | name="global", 22 | ), 23 | re_path( 24 | r"^global/(?P
[\w\ ]+)$", 25 | staff_member_required( 26 | views.PreferenceFormView.as_view( 27 | registry=global_preferences_registry, form_class=GlobalPreferenceForm 28 | ) 29 | ), 30 | name="global.section", 31 | ), 32 | re_path(r"^user/", include("dynamic_preferences.users.urls")), 33 | ] 34 | -------------------------------------------------------------------------------- /dynamic_preferences/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/dynamic_preferences/users/__init__.py -------------------------------------------------------------------------------- /dynamic_preferences/users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin as django_admin 2 | from django import forms 3 | 4 | from ..settings import preferences_settings 5 | from .. import admin 6 | from .models import UserPreferenceModel 7 | from .forms import UserSinglePreferenceForm 8 | 9 | 10 | class UserPreferenceAdmin(admin.PerInstancePreferenceAdmin): 11 | search_fields = ["instance__username"] + admin.DynamicPreferenceAdmin.search_fields 12 | form = UserSinglePreferenceForm 13 | changelist_form = UserSinglePreferenceForm 14 | 15 | def get_queryset(self, request, *args, **kwargs): 16 | # Instanciate default prefs 17 | getattr(request.user, preferences_settings.MANAGER_ATTRIBUTE).all() 18 | return super(UserPreferenceAdmin, self).get_queryset(request, *args, **kwargs) 19 | 20 | 21 | django_admin.site.register(UserPreferenceModel, UserPreferenceAdmin) 22 | -------------------------------------------------------------------------------- /dynamic_preferences/users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | from django.conf import settings 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from ..registries import preference_models 6 | from .registries import user_preferences_registry 7 | 8 | 9 | class UserPreferencesConfig(AppConfig): 10 | name = "dynamic_preferences.users" 11 | verbose_name = _("Preferences - Users") 12 | label = "dynamic_preferences_users" 13 | default_auto_field = "django.db.models.AutoField" 14 | 15 | def ready(self): 16 | UserPreferenceModel = self.get_model("UserPreferenceModel") 17 | 18 | preference_models.register(UserPreferenceModel, user_preferences_registry) 19 | -------------------------------------------------------------------------------- /dynamic_preferences/users/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.core.exceptions import ValidationError 3 | from collections import OrderedDict 4 | 5 | from .registries import user_preferences_registry 6 | from ..forms import ( 7 | SinglePerInstancePreferenceForm, 8 | preference_form_builder, 9 | PreferenceForm, 10 | ) 11 | from ..exceptions import NotFoundInRegistry 12 | from .models import UserPreferenceModel 13 | 14 | 15 | class UserSinglePreferenceForm(SinglePerInstancePreferenceForm): 16 | class Meta: 17 | model = UserPreferenceModel 18 | fields = SinglePerInstancePreferenceForm.Meta.fields 19 | 20 | 21 | def user_preference_form_builder(instance, preferences=[], **kwargs): 22 | """ 23 | A shortcut :py:func:`preference_form_builder(UserPreferenceForm, preferences, **kwargs)` 24 | :param user: a :py:class:`django.contrib.auth.models.User` instance 25 | """ 26 | return preference_form_builder( 27 | UserPreferenceForm, preferences, instance=instance, **kwargs 28 | ) 29 | 30 | 31 | class UserPreferenceForm(PreferenceForm): 32 | registry = user_preferences_registry 33 | -------------------------------------------------------------------------------- /dynamic_preferences/users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.6 on 2018-06-15 16:20 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="UserPreferenceModel", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ( 30 | "section", 31 | models.CharField( 32 | blank=True, 33 | db_index=True, 34 | default=None, 35 | max_length=150, 36 | null=True, 37 | ), 38 | ), 39 | ("name", models.CharField(db_index=True, max_length=150)), 40 | ("raw_value", models.TextField(blank=True, null=True)), 41 | ( 42 | "instance", 43 | models.ForeignKey( 44 | on_delete=django.db.models.deletion.CASCADE, 45 | to=settings.AUTH_USER_MODEL, 46 | ), 47 | ), 48 | ], 49 | options={ 50 | "verbose_name": "user preference", 51 | "verbose_name_plural": "user preferences", 52 | "abstract": False, 53 | }, 54 | ), 55 | migrations.AlterUniqueTogether( 56 | name="userpreferencemodel", 57 | unique_together={("instance", "section", "name")}, 58 | ), 59 | ] 60 | -------------------------------------------------------------------------------- /dynamic_preferences/users/migrations/0002_auto_20200821_0837.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1 on 2020-08-21 08:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("dynamic_preferences_users", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="userpreferencemodel", 15 | name="name", 16 | field=models.CharField(db_index=True, max_length=150, verbose_name="Name"), 17 | ), 18 | migrations.AlterField( 19 | model_name="userpreferencemodel", 20 | name="raw_value", 21 | field=models.TextField(blank=True, null=True, verbose_name="Raw Value"), 22 | ), 23 | migrations.AlterField( 24 | model_name="userpreferencemodel", 25 | name="section", 26 | field=models.CharField( 27 | blank=True, 28 | db_index=True, 29 | default=None, 30 | max_length=150, 31 | null=True, 32 | verbose_name="Section Name", 33 | ), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /dynamic_preferences/users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/dynamic_preferences/users/migrations/__init__.py -------------------------------------------------------------------------------- /dynamic_preferences/users/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.conf import settings 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from dynamic_preferences.models import PerInstancePreferenceModel 6 | 7 | 8 | class UserPreferenceModel(PerInstancePreferenceModel): 9 | 10 | instance = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 11 | 12 | class Meta(PerInstancePreferenceModel.Meta): 13 | app_label = "dynamic_preferences_users" 14 | verbose_name = _("user preference") 15 | verbose_name_plural = _("user preferences") 16 | -------------------------------------------------------------------------------- /dynamic_preferences/users/registries.py: -------------------------------------------------------------------------------- 1 | from ..registries import PerInstancePreferenceRegistry 2 | 3 | 4 | class UserPreferenceRegistry(PerInstancePreferenceRegistry): 5 | section_url_namespace = "dynamic_preferences:user.section" 6 | 7 | 8 | user_preferences_registry = UserPreferenceRegistry() 9 | -------------------------------------------------------------------------------- /dynamic_preferences/users/serializers.py: -------------------------------------------------------------------------------- 1 | from dynamic_preferences.api.serializers import PreferenceSerializer 2 | 3 | 4 | class UserPreferenceSerializer(PreferenceSerializer): 5 | pass 6 | -------------------------------------------------------------------------------- /dynamic_preferences/users/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.urls import include, re_path 3 | except ImportError: 4 | from django.conf.urls import include, url as re_path 5 | 6 | from django.contrib.auth.decorators import login_required 7 | from . import views 8 | 9 | urlpatterns = [ 10 | re_path(r"^$", login_required(views.UserPreferenceFormView.as_view()), name="user"), 11 | re_path( 12 | r"^(?P
[\w\ ]+)$", 13 | login_required(views.UserPreferenceFormView.as_view()), 14 | name="user.section", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /dynamic_preferences/users/views.py: -------------------------------------------------------------------------------- 1 | from ..views import PreferenceFormView 2 | from .forms import user_preference_form_builder 3 | from .registries import user_preferences_registry 4 | 5 | 6 | class UserPreferenceFormView(PreferenceFormView): 7 | """ 8 | Will pass `request.user` to form_builder 9 | """ 10 | 11 | registry = user_preferences_registry 12 | 13 | def get_form_class(self, *args, **kwargs): 14 | section = self.kwargs.get("section", None) 15 | form_class = user_preference_form_builder( 16 | instance=self.request.user, section=section 17 | ) 18 | return form_class 19 | -------------------------------------------------------------------------------- /dynamic_preferences/users/viewsets.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | 3 | from dynamic_preferences.api import viewsets 4 | 5 | from . import serializers 6 | from . import models 7 | 8 | 9 | class UserPreferencesViewSet(viewsets.PerInstancePreferenceViewSet): 10 | queryset = models.UserPreferenceModel.objects.all() 11 | serializer_class = serializers.UserPreferenceSerializer 12 | permission_classes = [permissions.IsAuthenticated] 13 | 14 | def get_related_instance(self): 15 | return self.request.user 16 | -------------------------------------------------------------------------------- /dynamic_preferences/utils.py: -------------------------------------------------------------------------------- 1 | try: 2 | from collections.abc import Mapping 3 | except ImportError: 4 | from collections import Mapping 5 | 6 | 7 | def update(d, u): 8 | """ 9 | Custom recursive update of dictionary 10 | from http://stackoverflow.com/questions/3232943/update-value-of-a-nested-dictionary-of-varying-depth 11 | """ 12 | for k, v in u.iteritems(): 13 | if isinstance(v, Mapping): 14 | r = update(d.get(k, {}), v) 15 | d[k] = r 16 | else: 17 | d[k] = u[k] 18 | return d 19 | -------------------------------------------------------------------------------- /dynamic_preferences/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import TemplateView, FormView 2 | from django.http import Http404 3 | from .forms import preference_form_builder 4 | 5 | 6 | """Todo : remove these views and use only context processors""" 7 | 8 | 9 | class RegularTemplateView(TemplateView): 10 | """Used for testing context""" 11 | 12 | template_name = "dynamic_preferences/testcontext.html" 13 | 14 | 15 | class PreferenceFormView(FormView): 16 | """ 17 | Display a form for updating preferences of the given 18 | section provided via URL arg. 19 | If no section is provided, will display a form for all 20 | fields of a given registry. 21 | """ 22 | 23 | #: the registry for preference lookups 24 | registry = None 25 | 26 | #: will be used by :py:func:`forms.preference_form_builder` 27 | # to create the form 28 | form_class = None 29 | template_name = "dynamic_preferences/form.html" 30 | 31 | def dispatch(self, request, *args, **kwargs): 32 | self.section_name = kwargs.get("section", None) 33 | if self.section_name: 34 | try: 35 | self.section = self.registry.section_objects[self.section_name] 36 | except KeyError: 37 | raise Http404 38 | else: 39 | self.section = None 40 | return super(PreferenceFormView, self).dispatch(request, *args, **kwargs) 41 | 42 | def get_form_class(self, *args, **kwargs): 43 | form_class = preference_form_builder(self.form_class, section=self.section_name) 44 | return form_class 45 | 46 | def get_context_data(self, *args, **kwargs): 47 | context = super(PreferenceFormView, self).get_context_data(*args, **kwargs) 48 | context["registry"] = self.registry 49 | context["section"] = self.section 50 | 51 | return context 52 | 53 | def get_success_url(self): 54 | return self.request.path 55 | 56 | def form_valid(self, form): 57 | 58 | form.update_preferences() 59 | return super(PreferenceFormView, self).form_valid(form) 60 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from dynamic_preferences.signals import preference_updated 3 | 4 | 5 | def notify_on_preference_update(sender, section, name, old_value, new_value, **kwargs): 6 | print( 7 | "Preference {} in section {} changed from {} to {}".format( 8 | name, section, old_value, new_value 9 | ) 10 | ) 11 | 12 | 13 | class ExampleConfig(AppConfig): 14 | default_auto_field = "django.db.models.BigAutoField" 15 | name = "example" 16 | 17 | def ready(self): 18 | print("Registering signals") 19 | preference_updated.connect(notify_on_preference_update) 20 | -------------------------------------------------------------------------------- /example/example/dynamic_preferences_registry.py: -------------------------------------------------------------------------------- 1 | from dynamic_preferences.types import * 2 | from dynamic_preferences.registries import global_preferences_registry 3 | from dynamic_preferences.users.registries import user_preferences_registry 4 | 5 | from .models import MyModel 6 | 7 | _section = Section("section") 8 | 9 | 10 | @global_preferences_registry.register 11 | class RegistrationAllowed(BooleanPreference): 12 | """ 13 | Are new registrations allowed ? 14 | """ 15 | 16 | verbose_name = "Allow new users to register" 17 | section = "auth" 18 | name = "registration_allowed" 19 | default = False 20 | 21 | 22 | @global_preferences_registry.register 23 | class MaxUsers(IntPreference): 24 | """ 25 | Are new registrations allowed ? 26 | """ 27 | 28 | section = "auth" 29 | name = "max_users" 30 | default = 100 31 | help_text = "Please fill in the form" 32 | 33 | 34 | @global_preferences_registry.register 35 | class Header(LongStringPreference): 36 | 37 | section = "general" 38 | name = "presentation" 39 | default = "You need a presentation" 40 | 41 | 42 | @user_preferences_registry.register 43 | class ItemsPerPage(IntPreference): 44 | 45 | section = "display" 46 | name = "items_per_page" 47 | default = 25 48 | 49 | 50 | @user_preferences_registry.register 51 | class FavoriteVegetable(ChoicePreference): 52 | 53 | choices = ( 54 | ("C", "Carrot"), 55 | ("T", "Tomato. I know, it's not a vegetable"), 56 | ("P", "Potato"), 57 | ) 58 | section = "auth" 59 | name = "favorite_vegetable" 60 | default = "C" 61 | 62 | 63 | @global_preferences_registry.register 64 | class AdminUsers(MultipleChoicePreference): 65 | name = "admin_users" 66 | section = "auth" 67 | default = None 68 | choices = (("0", "Serge"), ("1", "Alina"), ("2", "Anand")) 69 | 70 | 71 | @user_preferences_registry.register 72 | class FavouriteColour(StringPreference): 73 | """ 74 | What's your favourite colour ? 75 | """ 76 | 77 | section = "misc" 78 | name = "favourite_colour" 79 | default = "Green" 80 | 81 | 82 | @user_preferences_registry.register 83 | class IsZombie(BooleanPreference): 84 | """ 85 | Are you a zombie ? 86 | """ 87 | 88 | section = "misc" 89 | name = "is_zombie" 90 | default = True 91 | 92 | 93 | @user_preferences_registry.register 94 | class IsFanOfTokioHotel(BooleanPreference): 95 | 96 | section = "music" 97 | name = "is_fan_of_tokio_hotel" 98 | default = False 99 | 100 | 101 | @user_preferences_registry.register 102 | class MyModelPreference(ModelChoicePreference): 103 | 104 | section = _section 105 | name = "MyModel_preference" 106 | default = None 107 | queryset = MyModel.objects.all() 108 | required = False 109 | -------------------------------------------------------------------------------- /example/example/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0 on 2018-06-14 16:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="MyModel", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("title", models.CharField(max_length=50)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /example/example/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/example/example/migrations/__init__.py -------------------------------------------------------------------------------- /example/example/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from dynamic_preferences.registries import global_preferences_registry 3 | 4 | 5 | class MyModel(models.Model): 6 | # We can't use the global preferences until after this 7 | title = models.CharField(max_length=50) 8 | 9 | def do_something(self): 10 | # We instantiate a manager for our global preferences 11 | global_preferences = global_preferences_registry.manager() 12 | 13 | # We can then use our global preferences however we like 14 | global_preferences["general__presentation"] 15 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.6/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.6/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | import os 13 | import sys 14 | 15 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 16 | sys.path.append(os.path.dirname(BASE_DIR)) 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = "s32hz0xqy*ow_%ra)yxo&yqyib_3bxbc*o+-m-xywpb$&a%vkk" 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = True 26 | 27 | TEMPLATE_DEBUG = True 28 | 29 | TEMPLATES = [ 30 | { 31 | "BACKEND": "django.template.backends.django.DjangoTemplates", 32 | "APP_DIRS": True, 33 | "OPTIONS": { 34 | "context_processors": [ 35 | "django.template.context_processors.debug", 36 | "django.template.context_processors.request", 37 | "django.contrib.auth.context_processors.auth", 38 | "django.contrib.messages.context_processors.messages", 39 | ], 40 | }, 41 | }, 42 | ] 43 | 44 | ALLOWED_HOSTS = [] 45 | 46 | 47 | # Application definition 48 | 49 | INSTALLED_APPS = ( 50 | "django.contrib.admin", 51 | "django.contrib.auth", 52 | "django.contrib.contenttypes", 53 | "django.contrib.sessions", 54 | "django.contrib.sites", 55 | "django.contrib.messages", 56 | "django.contrib.staticfiles", 57 | "dynamic_preferences", 58 | "dynamic_preferences.users.apps.UserPreferencesConfig", 59 | "debug_toolbar", 60 | "example", 61 | ) 62 | 63 | MIDDLEWARE = ( 64 | "debug_toolbar.middleware.DebugToolbarMiddleware", 65 | "django.contrib.sessions.middleware.SessionMiddleware", 66 | "django.middleware.common.CommonMiddleware", 67 | "django.middleware.csrf.CsrfViewMiddleware", 68 | "django.contrib.auth.middleware.AuthenticationMiddleware", 69 | "django.contrib.messages.middleware.MessageMiddleware", 70 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 71 | ) 72 | 73 | INTERNAL_IPS = [ 74 | "127.0.0.1", 75 | ] 76 | 77 | ROOT_URLCONF = "example.urls" 78 | 79 | WSGI_APPLICATION = "example.wsgi.application" 80 | 81 | 82 | # Database 83 | # https://docs.djangoproject.com/en/1.6/ref/settings/#databases 84 | 85 | DATABASES = { 86 | "default": { 87 | "ENGINE": "django.db.backends.sqlite3", 88 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 89 | } 90 | } 91 | 92 | # Internationalization 93 | # https://docs.djangoproject.com/en/1.6/topics/i18n/ 94 | 95 | LANGUAGE_CODE = "en-us" 96 | 97 | TIME_ZONE = "UTC" 98 | 99 | USE_I18N = True 100 | 101 | USE_L10N = True 102 | 103 | USE_TZ = True 104 | 105 | SITE_ID = 1 106 | 107 | # Static files (CSS, JavaScript, Images) 108 | # https://docs.djangoproject.com/en/1.6/howto/static-files/ 109 | 110 | STATIC_URL = "/static/" 111 | -------------------------------------------------------------------------------- /example/example/templates/example.html: -------------------------------------------------------------------------------- 1 | {{ global_preferences.general__presentation }} 2 | 3 | {% if request.user.preferences.misc__is_zombie %} 4 | You will receive an email each time a human is zombified 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.urls import include, re_path 3 | except ImportError: 4 | from django.conf.urls import include, url as re_path 5 | 6 | from django.contrib import admin 7 | 8 | admin.autodiscover() 9 | 10 | 11 | urlpatterns = [ 12 | # Examples: 13 | # re_path(r'^$', 'example.views.home', name='home'), 14 | # re_path(r'^blog/', include('blog.urls')), 15 | re_path(r"^admin/", admin.site.urls), 16 | re_path(r"^preferences/", include("dynamic_preferences.urls")), 17 | re_path("__debug__/", include("debug_toolbar.urls")), 18 | ] 19 | -------------------------------------------------------------------------------- /example/example/views.py: -------------------------------------------------------------------------------- 1 | from dynamic_preferences.registries import global_preferences_registry 2 | from django.shortcuts import render 3 | 4 | # We instantiate a manager for our global preferences 5 | global_preferences = global_preferences_registry.manager() 6 | 7 | # Now, we can use it to retrieve our preferences 8 | # the lookup for a preference has the following form:
__ 9 | 10 | 11 | def myview(request): 12 | presentation = global_preferences["general__presentation"] 13 | 14 | return render( 15 | request, 16 | "example.html", 17 | { 18 | "presentation": presentation, 19 | }, 20 | ) 21 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.6/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 13 | 14 | from django.core.wsgi import get_wsgi_application 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | django 2 | django-dynamic-preferences 3 | django-debug-toolbar 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE=tests.settings 3 | 4 | # -- recommended but optional: 5 | python_files = tests.py test_*.py *_tests.py 6 | norecursedirs = .tox virtualenv 7 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | # this is needed for autodoc 4 | django 5 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | coverage 3 | coveralls 4 | mock>=1.0.1 5 | flake8>=2.1.0 6 | tox>=1.7.0 7 | # Additional test requirements go here 8 | pytest 9 | pytest-cov 10 | pytest-django 11 | pytest-sugar 12 | django-nose 13 | ipdb 14 | setuptools -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | wheel==0.38.1 2 | persisting_theory==1.0 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | max-line-length = 90 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | 7 | import dynamic_preferences 8 | 9 | try: 10 | from setuptools import setup 11 | except ImportError: 12 | from distutils.core import setup 13 | 14 | version = dynamic_preferences.__version__ 15 | 16 | if sys.argv[-1] == "publish": 17 | os.system("python setup.py sdist upload") 18 | print("You probably want to also tag the version now:") 19 | print(" git tag -a %s -m 'version %s'" % (version, version)) 20 | print(" git push --tags") 21 | sys.exit() 22 | 23 | readme = open("README.rst").read() 24 | 25 | setup( 26 | name="django-dynamic-preferences", 27 | version=version, 28 | description="""Dynamic global and instance settings for your django project""", 29 | long_description=readme, 30 | author="Agate Blue", 31 | author_email="me+github@agate.blue", 32 | url="https://github.com/agateblue/django-dynamic-preferences", 33 | packages=["dynamic_preferences"], 34 | include_package_data=True, 35 | install_requires=[ 36 | "django>=4.2", 37 | "persisting_theory==1.0", 38 | ], 39 | license="BSD", 40 | zip_safe=False, 41 | keywords="django-dynamic-preferences", 42 | classifiers=[ 43 | "Development Status :: 5 - Production/Stable", 44 | "Framework :: Django", 45 | "Intended Audience :: Developers", 46 | "License :: OSI Approved :: BSD License", 47 | "Natural Language :: English", 48 | "Programming Language :: Python :: 3", 49 | ], 50 | ) 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # import tests 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.core import cache as django_cache 4 | from django.contrib.auth.models import User 5 | 6 | 7 | @pytest.fixture(autouse=True) 8 | def cache(): 9 | django_cache.cache.clear() 10 | yield django_cache.cache 11 | 12 | 13 | @pytest.fixture 14 | def assert_redirect(): 15 | def inner(response, expected): 16 | assert response.status_code == 302 17 | assert response["Location"] == expected 18 | 19 | return inner 20 | 21 | 22 | @pytest.fixture 23 | def fake_user(db): 24 | return User.objects.create_user( 25 | username="test", password="test", email="test@test.com" 26 | ) 27 | 28 | 29 | @pytest.fixture 30 | def fake_admin(db): 31 | return User.objects.create_user( 32 | username="admin", 33 | email="admin@admin.com", 34 | password="test", 35 | is_superuser=True, 36 | is_staff=True, 37 | ) 38 | 39 | 40 | @pytest.fixture 41 | def admin_client(client, fake_admin): 42 | assert client.login(username="admin", password="test") is True 43 | return client 44 | 45 | 46 | @pytest.fixture 47 | def user_client(client, fake_user): 48 | assert client.login(username="test", password="test") is True 49 | return client 50 | 51 | 52 | @pytest.fixture 53 | def henri(db): 54 | return User.objects.create_user( 55 | username="henri", password="test", email="henri@henri.com" 56 | ) 57 | 58 | 59 | @pytest.fixture 60 | def henri_client(client, henri): 61 | assert client.login(username="henri", password="test") is True 62 | return client 63 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = True 5 | USE_TZ = True 6 | DATABASES = { 7 | "default": { 8 | "ENGINE": "django.db.backends.sqlite3", 9 | } 10 | } 11 | ROOT_URLCONF = "tests.urls" 12 | INSTALLED_APPS = [ 13 | "django.contrib.auth", 14 | "django.contrib.sites", 15 | "django.contrib.contenttypes", 16 | "django.contrib.sessions", 17 | "django.contrib.admin", 18 | "rest_framework", 19 | "dynamic_preferences", 20 | "dynamic_preferences.users.apps.UserPreferencesConfig", 21 | "tests.test_app", 22 | ] 23 | SITE_ID = 1 24 | SECRET_KEY = "FDLDSKSDJHF" 25 | STATIC_URL = "/static/" 26 | MEDIA_URL = "/media/" 27 | MEDIA_ROOT = tempfile.mkdtemp() 28 | MIDDLEWARE = ( 29 | "django.contrib.sessions.middleware.SessionMiddleware", 30 | "django.middleware.common.CommonMiddleware", 31 | "django.middleware.csrf.CsrfViewMiddleware", 32 | "django.contrib.auth.middleware.AuthenticationMiddleware", 33 | "django.contrib.messages.middleware.MessageMiddleware", 34 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 35 | ) 36 | 37 | 38 | def check_django_version(minimal_version): 39 | import django 40 | from distutils.version import LooseVersion 41 | 42 | django_version = django.get_version() 43 | return LooseVersion(minimal_version) <= LooseVersion(django_version) 44 | 45 | 46 | if check_django_version("1.8"): 47 | TEMPLATES = [ 48 | { 49 | "BACKEND": "django.template.backends.django.DjangoTemplates", 50 | "DIRS": [], 51 | "APP_DIRS": True, 52 | "OPTIONS": { 53 | "debug": True, 54 | "context_processors": [ 55 | "django.template.context_processors.request", 56 | "dynamic_preferences.processors.global_preferences", 57 | ], 58 | }, 59 | }, 60 | ] 61 | else: 62 | from django.conf.global_settings import TEMPLATE_CONTEXT_PROCESSORS 63 | 64 | TEMPLATE_CONTEXT_PROCESSORS = list(TEMPLATE_CONTEXT_PROCESSORS) + [ 65 | "django.core.context_processors.request", 66 | "dynamic_preferences.processors.global_preferences", 67 | ] 68 | 69 | CACHES = { 70 | "default": { 71 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 72 | "LOCATION": "unique-snowflake", 73 | } 74 | } 75 | CACHE_DYNAMIC_PREFERENCES_SETTINGS = False 76 | -------------------------------------------------------------------------------- /tests/test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agateblue/django-dynamic-preferences/27c37d650667db2439d755d68b2ec60d7ce0e4a0/tests/test_app/__init__.py -------------------------------------------------------------------------------- /tests/test_app/dynamic_preferences_registry.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from decimal import Decimal 4 | from dynamic_preferences import types 5 | from dynamic_preferences.registries import global_preferences_registry 6 | from dynamic_preferences.users.registries import user_preferences_registry 7 | from dynamic_preferences.preferences import Section 8 | from django.forms import ValidationError 9 | from .models import BlogEntry 10 | 11 | 12 | # Tutorial preferences 13 | @global_preferences_registry.register 14 | class RegistrationAllowed(types.BooleanPreference): 15 | """ 16 | Are new registrations allowed ? 17 | """ 18 | 19 | section = "user" 20 | name = "registration_allowed" 21 | default = False 22 | 23 | 24 | @global_preferences_registry.register 25 | class MaxUsers(types.IntPreference): 26 | """ 27 | Are new registrations allowed ? 28 | """ 29 | 30 | section = "user" 31 | name = "max_users" 32 | default = 100 33 | verbose_name = "Maximum user count" 34 | help_text = "Be careful with this setting" 35 | 36 | def validate(self, value): 37 | # value can't be equal to 1001, no no no! 38 | if value == 1001: 39 | raise ValidationError("Wrong value!") 40 | return value 41 | 42 | 43 | class NoDefault(types.IntPreference): 44 | section = "user" 45 | name = "no_default" 46 | 47 | 48 | class NoModel(types.ModelChoicePreference): 49 | section = "blog" 50 | name = "no_model" 51 | default = None 52 | 53 | 54 | @global_preferences_registry.register 55 | class ItemsPerPage(types.IntPreference): 56 | section = "user" 57 | name = "items_per_page" 58 | default = 25 59 | 60 | 61 | @global_preferences_registry.register 62 | class FeaturedBlogEntry(types.ModelChoicePreference): 63 | section = "blog" 64 | name = "featured_entry" 65 | queryset = BlogEntry.objects.all() 66 | 67 | def get_default(self): 68 | return self.queryset.first() 69 | 70 | 71 | @global_preferences_registry.register 72 | class BlogLogo(types.FilePreference): 73 | section = "blog" 74 | name = "logo" 75 | 76 | 77 | @global_preferences_registry.register 78 | class BlogLogo2(types.FilePreference): 79 | section = "blog" 80 | name = "logo2" 81 | 82 | 83 | @global_preferences_registry.register 84 | class BlogCost(types.DecimalPreference): 85 | section = "type" 86 | name = "cost" 87 | default = Decimal(0) 88 | 89 | 90 | @user_preferences_registry.register 91 | class FavoriteVegetable(types.ChoicePreference): 92 | choices = ( 93 | ("C", "Carrot"), 94 | ("T", "Tomato. I know, it's not a vegetable"), 95 | ("P", "Potato"), 96 | ) 97 | section = "user" 98 | name = "favorite_vegetable" 99 | default = "C" 100 | 101 | 102 | @user_preferences_registry.register 103 | class FavoriteVegetables(types.MultipleChoicePreference): 104 | choices = ( 105 | ("C", "Carrot"), 106 | ("T", "Tomato. I know, it's not a vegetable"), 107 | ("P", "Potato"), 108 | ) 109 | section = "user" 110 | name = "favorite_vegetables" 111 | default = ["C", "P"] 112 | 113 | 114 | @user_preferences_registry.register 115 | class FavouriteColour(types.StringPreference): 116 | """ 117 | What's your favourite colour ? 118 | """ 119 | 120 | section = "misc" 121 | name = "favourite_colour" 122 | default = "Green" 123 | 124 | 125 | @user_preferences_registry.register 126 | class IsZombie(types.BooleanPreference): 127 | """ 128 | Are you a zombie ? 129 | """ 130 | 131 | section = "misc" 132 | name = "is_zombie" 133 | default = True 134 | 135 | 136 | class BaseTestPref(object): 137 | section = "test" 138 | 139 | 140 | # No section pref 141 | @global_preferences_registry.register 142 | class NoSection(types.BooleanPreference): 143 | name = "no_section" 144 | default = False 145 | 146 | 147 | # User preferences 148 | @user_preferences_registry.register 149 | class TestUserPref1(BaseTestPref, types.StringPreference): 150 | name = "TestUserPref1" 151 | default = "default value" 152 | 153 | 154 | @user_preferences_registry.register 155 | class TestUserPref2(BaseTestPref, types.StringPreference): 156 | name = "TestUserPref2" 157 | default = "default value" 158 | 159 | 160 | @user_preferences_registry.register 161 | class UserBooleanPref(BaseTestPref, types.BooleanPreference): 162 | name = "SiteBooleanPref" 163 | default = False 164 | 165 | 166 | @user_preferences_registry.register 167 | class UserStringPref(BaseTestPref, types.StringPreference): 168 | name = "SUserStringPref" 169 | default = "Hello world!" 170 | 171 | 172 | # Global 173 | @global_preferences_registry.register 174 | class TestGlobal1(BaseTestPref, types.StringPreference): 175 | name = "TestGlobal1" 176 | default = "default value" 177 | 178 | 179 | @global_preferences_registry.register 180 | class TestGlobal2(BaseTestPref, types.BooleanPreference): 181 | name = "TestGlobal2" 182 | default = False 183 | 184 | 185 | @global_preferences_registry.register 186 | class TestGlobal3(BaseTestPref, types.BooleanPreference): 187 | name = "TestGlobal3" 188 | default = False 189 | 190 | 191 | @global_preferences_registry.register 192 | class ExamDuration(types.DurationPreference): 193 | section = "exam" 194 | name = "duration" 195 | default = datetime.timedelta(hours=3) 196 | 197 | 198 | @global_preferences_registry.register 199 | class RegistrationDate(types.DatePreference): 200 | section = "company" 201 | name = "RegistrationDate" 202 | default = datetime.date(1998, 9, 4) 203 | 204 | 205 | @global_preferences_registry.register 206 | class BirthDateTime(types.DateTimePreference): 207 | section = Section("child", verbose_name="Child Section Verbose Name") 208 | name = "BirthDateTime" 209 | default = datetime.datetime(1992, 5, 4, 3, 4, 10, 150, tzinfo=datetime.timezone.utc) 210 | 211 | 212 | @global_preferences_registry.register 213 | class OpenningTime(types.TimePreference): 214 | section = "company" 215 | name = "OpenningTime" 216 | default = datetime.time(hour=8, minute=0) 217 | -------------------------------------------------------------------------------- /tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | 4 | 5 | class BlogEntry(models.Model): 6 | title = models.CharField(max_length=255) 7 | content = models.TextField() 8 | 9 | 10 | class BlogEntryWithNonIntPk(models.Model): 11 | id = models.UUIDField(primary_key=True, default=uuid.uuid4) 12 | title = models.CharField(max_length=255) 13 | content = models.TextField() 14 | -------------------------------------------------------------------------------- /tests/test_checkpreferences_command.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | 3 | from django.core.management import call_command 4 | 5 | 6 | def call(*args, **kwargs): 7 | out = StringIO() 8 | call_command( 9 | "checkpreferences", 10 | *args, 11 | stdout=out, 12 | stderr=StringIO(), 13 | **kwargs, 14 | ) 15 | return out.getvalue().strip() 16 | 17 | 18 | def test_dry_run(db): 19 | out = call(verbosity=0) 20 | expected_output = "\n".join( 21 | [ 22 | "Creating missing global preferences...", 23 | "Deleted 0 global preferences", 24 | "Deleted 0 GlobalPreferenceModel preferences", 25 | "Deleted 0 UserPreferenceModel preferences", 26 | "Creating missing preferences for User model...", 27 | ] 28 | ) 29 | assert out == expected_output 30 | 31 | 32 | def test_skip_create(db): 33 | out = call("--skip_create", verbosity=0) 34 | expected_output = "\n".join( 35 | [ 36 | "Deleted 0 global preferences", 37 | "Deleted 0 GlobalPreferenceModel preferences", 38 | "Deleted 0 UserPreferenceModel preferences", 39 | ] 40 | ) 41 | assert out == expected_output 42 | -------------------------------------------------------------------------------- /tests/test_global_preferences.py: -------------------------------------------------------------------------------- 1 | from datetime import timezone 2 | from decimal import Decimal 3 | 4 | from datetime import date, timedelta, datetime, time 5 | from django.apps import apps 6 | from django.urls import reverse 7 | from django.core.management import call_command 8 | from django.core.files.uploadedfile import SimpleUploadedFile 9 | from django.utils.timezone import make_aware 10 | 11 | from dynamic_preferences.registries import global_preferences_registry as registry 12 | from dynamic_preferences.models import GlobalPreferenceModel 13 | from dynamic_preferences.forms import global_preference_form_builder 14 | 15 | from .test_app.models import BlogEntry 16 | 17 | 18 | def test_preference_model_manager_to_dict(db): 19 | manager = registry.manager() 20 | call_command("checkpreferences", verbosity=1) 21 | expected = { 22 | "test__TestGlobal1": "default value", 23 | "test__TestGlobal2": False, 24 | "test__TestGlobal3": False, 25 | "type__cost": Decimal(0), 26 | "exam__duration": timedelta(hours=3), 27 | "no_section": False, 28 | "user__max_users": 100, 29 | "user__items_per_page": 25, 30 | "blog__featured_entry": None, 31 | "blog__logo": None, 32 | "blog__logo2": None, 33 | "company__RegistrationDate": date(1998, 9, 4), 34 | "child__BirthDateTime": datetime( 35 | 1992, 5, 4, 3, 4, 10, 150, tzinfo=timezone.utc 36 | ), 37 | "company__OpenningTime": time(hour=8, minute=0), 38 | "user__registration_allowed": False, 39 | } 40 | 41 | assert manager.all() == expected 42 | 43 | 44 | def test_registry_default_preference_model(settings): 45 | app_config = apps.app_configs["dynamic_preferences"] 46 | registry.preference_model = None 47 | 48 | settings.DYNAMIC_PREFERENCES = {"ENABLE_GLOBAL_MODEL_AUTO_REGISTRATION": False} 49 | 50 | app_config.ready() 51 | 52 | assert registry.preference_model is None 53 | 54 | settings.DYNAMIC_PREFERENCES = {"ENABLE_GLOBAL_MODEL_AUTO_REGISTRATION": True} 55 | 56 | app_config.ready() 57 | 58 | assert registry.preference_model is GlobalPreferenceModel 59 | 60 | 61 | def test_can_build_global_preference_form(db): 62 | # We want to display a form with two global preferences 63 | # RegistrationAllowed and MaxUsers 64 | form = global_preference_form_builder( 65 | preferences=["user__registration_allowed", "user__max_users"] 66 | )() 67 | 68 | assert len(form.fields) == 2 69 | assert form.fields["user__registration_allowed"].initial is False 70 | 71 | 72 | def test_can_build_preference_form_from_sections(db): 73 | form = global_preference_form_builder(section="test")() 74 | 75 | assert len(form.fields) == 3 76 | 77 | 78 | def test_can_build_global_preference_form_from_sections(db): 79 | form = global_preference_form_builder(section="test")() 80 | 81 | assert len(form.fields) == 3 82 | 83 | 84 | def test_global_preference_view_requires_staff_member( 85 | fake_admin, assert_redirect, client 86 | ): 87 | url = reverse("dynamic_preferences:global") 88 | response = client.get(url) 89 | 90 | assert_redirect(response, "/admin/login/?next=/global/") 91 | 92 | client.login(username="henri", password="test") 93 | response = client.get(url) 94 | assert_redirect(response, "/admin/login/?next=/global/") 95 | 96 | client.login(username="admin", password="test") 97 | response = client.get(url) 98 | 99 | assert fake_admin.is_authenticated is True 100 | assert response.status_code == 200 101 | 102 | 103 | def test_global_preference_view_display_form(admin_client): 104 | 105 | url = reverse("dynamic_preferences:global") 106 | response = admin_client.get(url) 107 | assert len(response.context["form"].fields) == 15 108 | assert response.context["registry"] == registry 109 | 110 | 111 | def test_global_preference_view_section_verbose_names(admin_client): 112 | url = reverse("admin:dynamic_preferences_globalpreferencemodel_changelist") 113 | response = admin_client.get(url) 114 | for key, section in registry.section_objects.items(): 115 | if section.name != section.verbose_name: 116 | # Assert verbose_name in table 117 | assert str(response._container).count(section.verbose_name + "") >= 1 118 | # Assert verbose_name in filter link 119 | assert str(response._container).count(section.verbose_name + "") >= 1 120 | 121 | 122 | def test_formview_includes_section_in_context(admin_client): 123 | url = reverse("dynamic_preferences:global.section", kwargs={"section": "user"}) 124 | response = admin_client.get(url) 125 | assert response.context["section"] == registry.section_objects["user"] 126 | 127 | 128 | def test_formview_with_bad_section_returns_404(admin_client): 129 | url = reverse("dynamic_preferences:global.section", kwargs={"section": "nope"}) 130 | response = admin_client.get(url) 131 | assert response.status_code == 404 132 | 133 | 134 | def test_global_preference_filters_by_section(admin_client): 135 | url = reverse("dynamic_preferences:global.section", kwargs={"section": "user"}) 136 | response = admin_client.get(url) 137 | assert len(response.context["form"].fields) == 3 138 | 139 | 140 | def test_preference_are_updated_on_form_submission(admin_client): 141 | blog_entry = BlogEntry.objects.create(title="test", content="test") 142 | url = reverse("dynamic_preferences:global") 143 | data = { 144 | "user__max_users": 67, 145 | "user__registration_allowed": True, 146 | "user__items_per_page": 12, 147 | "test__TestGlobal1": "new value", 148 | "test__TestGlobal2": True, 149 | "test__TestGlobal3": True, 150 | "no_section": True, 151 | "blog__featured_entry": blog_entry.pk, 152 | "company__RegistrationDate": date(1976, 4, 1), 153 | "child__BirthDateTime": datetime.now(), 154 | "type__cost": 1, 155 | "exam__duration": timedelta(hours=5), 156 | "company__OpenningTime": time(hour=8, minute=0), 157 | } 158 | admin_client.post(url, data) 159 | for key, expected_value in data.items(): 160 | try: 161 | section, name = key.split("__") 162 | except ValueError: 163 | section, name = (None, key) 164 | 165 | p = GlobalPreferenceModel.objects.get(name=name, section=section) 166 | if name == "featured_entry": 167 | expected_value = blog_entry 168 | if name == "BirthDateTime": 169 | expected_value = make_aware(expected_value) 170 | 171 | assert p.value == expected_value 172 | 173 | 174 | def test_preference_are_updated_on_form_submission_by_section(admin_client): 175 | url = reverse("dynamic_preferences:global.section", kwargs={"section": "user"}) 176 | response = admin_client.post( 177 | url, 178 | { 179 | "user__max_users": 95, 180 | "user__registration_allowed": True, 181 | "user__items_per_page": 12, 182 | }, 183 | follow=True, 184 | ) 185 | assert response.status_code == 200 186 | assert ( 187 | GlobalPreferenceModel.objects.get(section="user", name="max_users").value == 95 188 | ) 189 | assert ( 190 | GlobalPreferenceModel.objects.get( 191 | section="user", name="registration_allowed" 192 | ).value 193 | is True 194 | ) 195 | assert ( 196 | GlobalPreferenceModel.objects.get(section="user", name="items_per_page").value 197 | == 12 198 | ) 199 | 200 | 201 | def test_template_gets_global_preferences_via_template_processor(db, client): 202 | global_preferences = registry.manager() 203 | url = reverse("dynamic_preferences.test.templateview") 204 | response = client.get(url) 205 | assert response.context["global_preferences"] == global_preferences.all() 206 | 207 | 208 | def test_file_preference(admin_client): 209 | blog_entry = BlogEntry.objects.create(title="Hello", content="World") 210 | content = b"hello" 211 | logo = SimpleUploadedFile("logo.png", content, content_type="image/png") 212 | url = reverse("dynamic_preferences:global.section", kwargs={"section": "blog"}) 213 | response = admin_client.post( 214 | url, {"blog__featured_entry": blog_entry.pk, "blog__logo": logo}, follow=True 215 | ) 216 | assert response.status_code == 200 217 | assert ( 218 | GlobalPreferenceModel.objects.get(section="blog", name="featured_entry").value 219 | == blog_entry 220 | ) 221 | assert ( 222 | GlobalPreferenceModel.objects.get(section="blog", name="logo").value.read() 223 | == content 224 | ) 225 | -------------------------------------------------------------------------------- /tests/test_manager.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | from dynamic_preferences.registries import global_preferences_registry as registry 4 | from dynamic_preferences.models import GlobalPreferenceModel 5 | 6 | 7 | def test_can_get_preferences_objects_from_manager(db): 8 | manager = registry.manager() 9 | cached_prefs = dict(manager.all()) 10 | qs = manager.queryset 11 | 12 | assert len(qs) == len(cached_prefs) 13 | 14 | assert list(qs) == list(GlobalPreferenceModel.objects.all()) 15 | 16 | 17 | def test_can_get_db_pref_from_manager(db): 18 | manager = registry.manager() 19 | manager.queryset.delete() 20 | pref = manager.get_db_pref(section="test", name="TestGlobal1") 21 | 22 | assert pref.section == "test" 23 | assert pref.name == "TestGlobal1" 24 | assert pref.raw_value == registry.get("test__TestGlobal1").default 25 | 26 | 27 | def test_do_not_restore_default_when_calling_all(db, cache): 28 | manager = registry.manager() 29 | 30 | new_value = "test_new_value" 31 | manager["test__TestGlobal1"] = new_value 32 | assert manager["test__TestGlobal1"] == new_value 33 | cache.clear() 34 | manager.all() 35 | cache.clear() 36 | assert manager["test__TestGlobal1"] == new_value 37 | assert manager.all()["test__TestGlobal1"] == new_value 38 | 39 | 40 | def test_invalidates_cache_when_saving_database_preference(db, cache): 41 | manager = registry.manager() 42 | cache.clear() 43 | new_value = "test_new_value" 44 | key = manager.get_cache_key("test", "TestGlobal1") 45 | manager["test__TestGlobal1"] = new_value 46 | 47 | pref = manager.get_db_pref(section="test", name="TestGlobal1") 48 | assert pref.raw_value == new_value 49 | assert manager.cache.get(key) == new_value 50 | 51 | pref.raw_value = "reset" 52 | pref.save() 53 | 54 | assert manager.cache.get(key) == "reset" 55 | 56 | 57 | def test_invalidates_cache_when_saving_from_admin(admin_client): 58 | 59 | manager = registry.manager() 60 | pref = manager.get_db_pref(section="test", name="TestGlobal1") 61 | url = reverse( 62 | "admin:dynamic_preferences_globalpreferencemodel_change", args=(pref.id,) 63 | ) 64 | key = manager.get_cache_key("test", "TestGlobal1") 65 | 66 | response = admin_client.post(url, {"raw_value": "reset1"}) 67 | 68 | assert manager.cache.get(key) == "reset1" 69 | assert manager.all()["test__TestGlobal1"] == "reset1" 70 | 71 | response = admin_client.post(url, {"raw_value": "reset2"}, follow=True) 72 | 73 | assert response.status_code == 200 74 | 75 | assert manager.cache.get(key) == "reset2" 76 | assert manager.all()["test__TestGlobal1"] == "reset2" 77 | -------------------------------------------------------------------------------- /tests/test_preferences.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from dynamic_preferences.registries import ( 4 | MissingPreference, 5 | global_preferences_registry, 6 | ) 7 | from dynamic_preferences import preferences, exceptions 8 | from dynamic_preferences.types import IntegerPreference, StringPreference 9 | from dynamic_preferences.signals import preference_updated 10 | 11 | from .test_app import dynamic_preferences_registry as prefs 12 | from .test_app.models import BlogEntry 13 | 14 | try: 15 | from unittest.mock import MagicMock 16 | except ImportError: 17 | from mock import MagicMock 18 | 19 | 20 | def test_can_retrieve_preference_using_dotted_notation(db): 21 | registration_allowed = global_preferences_registry.get( 22 | name="registration_allowed", section="user" 23 | ) 24 | dotted_result = global_preferences_registry.get("user__registration_allowed") 25 | assert registration_allowed == dotted_result 26 | 27 | 28 | def test_can_register_and_retrieve_preference_with_section_none(db): 29 | no_section_pref = global_preferences_registry.get(name="no_section") 30 | assert no_section_pref.section == preferences.EMPTY_SECTION 31 | 32 | 33 | def test_cannot_instanciate_preference_or_section_with_invalid_name(): 34 | 35 | invalid_names = ["with space", "with__separator", "with-hyphen"] 36 | 37 | for n in invalid_names: 38 | with pytest.raises(ValueError): 39 | preferences.Section(n) 40 | with pytest.raises(ValueError): 41 | 42 | class P(IntegerPreference): 43 | name = n 44 | 45 | P() 46 | 47 | 48 | def test_preference_order_match_register_call(): 49 | expected = [ 50 | "registration_allowed", 51 | "max_users", 52 | "items_per_page", 53 | "featured_entry", 54 | ] 55 | assert [p.name for p in global_preferences_registry.preferences()][:4] == expected 56 | 57 | 58 | def test_preferences_manager_get(db): 59 | global_preferences = global_preferences_registry.manager() 60 | assert global_preferences["no_section"] is False 61 | 62 | 63 | def test_preferences_manager_set(db): 64 | global_preferences = global_preferences_registry.manager() 65 | global_preferences["no_section"] = True 66 | assert global_preferences["no_section"] is True 67 | 68 | 69 | def test_can_cache_single_preference(db, django_assert_num_queries): 70 | 71 | manager = global_preferences_registry.manager() 72 | manager["no_section"] 73 | with django_assert_num_queries(0): 74 | manager["no_section"] 75 | manager["no_section"] 76 | manager["no_section"] 77 | 78 | 79 | def test_can_bypass_cache_in_get(db, settings, django_assert_num_queries): 80 | settings.DYNAMIC_PREFERENCES = {"ENABLE_CACHE": False} 81 | manager = global_preferences_registry.manager() 82 | manager["no_section"] 83 | with django_assert_num_queries(3): 84 | manager["no_section"] 85 | manager["no_section"] 86 | manager["no_section"] 87 | 88 | 89 | def test_can_bypass_cache_in_get_all(db, settings): 90 | settings.DYNAMIC_PREFERENCES = {"ENABLE_CACHE": False} 91 | settings.DEBUG = True 92 | from django.db import connection 93 | 94 | manager = global_preferences_registry.manager() 95 | 96 | queries_before = len(connection.queries) 97 | manager.all() 98 | manager_queries = len(connection.queries) - queries_before 99 | 100 | manager.all() 101 | assert len(connection.queries) > manager_queries 102 | 103 | 104 | def test_can_cache_all_preferences(db, django_assert_num_queries): 105 | BlogEntry.objects.create(title="test", content="test") 106 | manager = global_preferences_registry.manager() 107 | manager.all() 108 | with django_assert_num_queries(3): 109 | # one request each time we retrieve the blog entry 110 | manager.all() 111 | manager.all() 112 | manager.all() 113 | 114 | 115 | def test_preferences_manager_by_name(db): 116 | manager = global_preferences_registry.manager() 117 | assert manager.by_name()["max_users"] == manager["user__max_users"] 118 | assert len(manager.all()) == len(manager.by_name()) 119 | 120 | 121 | def test_cache_invalidate_on_save(db, django_assert_num_queries): 122 | manager = global_preferences_registry.manager() 123 | model_instance = manager.create_db_pref( 124 | section=None, name="no_section", value=False 125 | ) 126 | 127 | with django_assert_num_queries(0): 128 | assert not manager["no_section"] 129 | manager["no_section"] 130 | 131 | model_instance.value = True 132 | model_instance.save() 133 | 134 | with django_assert_num_queries(0): 135 | assert manager["no_section"] 136 | manager["no_section"] 137 | 138 | 139 | def test_can_get_single_pref_with_cache_disabled(settings, db): 140 | settings.DYNAMIC_PREFERENCES = {"ENABLE_CACHE": False} 141 | manager = global_preferences_registry.manager() 142 | v = manager["no_section"] 143 | assert isinstance(v, bool) is True 144 | 145 | 146 | def test_can_get_single_pref_bypassing_cache(db): 147 | manager = global_preferences_registry.manager() 148 | v = manager.get("no_section", no_cache=True) 149 | assert isinstance(v, bool) is True 150 | 151 | 152 | def test_do_not_crash_if_preference_is_missing_in_registry(db): 153 | """see #41""" 154 | manager = global_preferences_registry.manager() 155 | instance = manager.create_db_pref(section=None, name="bad_pref", value="something") 156 | 157 | assert isinstance(instance.preference, MissingPreference) is True 158 | 159 | assert instance.preference.section is None 160 | assert instance.preference.name == "bad_pref" 161 | assert instance.value == "something" 162 | 163 | 164 | def test_can_get_to_string_notation(db): 165 | pref = global_preferences_registry.get("user__registration_allowed") 166 | 167 | assert pref.identifier() == "user__registration_allowed" 168 | 169 | 170 | def test_preference_requires_default_value(): 171 | with pytest.raises(exceptions.MissingDefault): 172 | prefs.NoDefault() 173 | 174 | 175 | def test_modelchoicepreference_requires_model_value(): 176 | with pytest.raises(exceptions.MissingModel): 177 | prefs.NoModel() 178 | 179 | 180 | def test_get_field_uses_field_kwargs(): 181 | class P(StringPreference): 182 | name = "test" 183 | default = "" 184 | field_kwargs = {"required": False} 185 | 186 | p = P() 187 | 188 | kwargs = p.get_field_kwargs() 189 | assert kwargs["required"] is False 190 | 191 | 192 | def test_preferences_manager_signal(db): 193 | global_preferences = global_preferences_registry.manager() 194 | global_preferences["no_section"] = False 195 | pref = global_preferences.get_db_pref(name="no_section", section=None) 196 | 197 | receiver = MagicMock() 198 | preference_updated.connect(receiver) 199 | global_preferences["no_section"] = True 200 | assert receiver.call_count == 1 201 | call_args = receiver.call_args[1] 202 | assert { 203 | "sender": global_preferences.__class__, 204 | "section": None, 205 | "name": "no_section", 206 | "old_value": False, 207 | "new_value": True, 208 | "instance": pref 209 | }.items() <= call_args.items() 210 | -------------------------------------------------------------------------------- /tests/test_tutorial.py: -------------------------------------------------------------------------------- 1 | from dynamic_preferences.registries import global_preferences_registry 2 | from dynamic_preferences.models import GlobalPreferenceModel 3 | from dynamic_preferences.users.models import UserPreferenceModel 4 | 5 | 6 | def test_quickstart(henri): 7 | global_preferences = global_preferences_registry.manager() 8 | 9 | assert global_preferences["user__registration_allowed"] is False 10 | 11 | global_preferences["user__registration_allowed"] = True 12 | 13 | assert global_preferences["user__registration_allowed"] is True 14 | assert ( 15 | GlobalPreferenceModel.objects.get( 16 | section="user", name="registration_allowed" 17 | ).value 18 | is True 19 | ) 20 | 21 | assert henri.preferences["misc__favourite_colour"] == "Green" 22 | 23 | henri.preferences["misc__favourite_colour"] = "Blue" 24 | 25 | assert henri.preferences["misc__favourite_colour"] == "Blue" 26 | 27 | assert ( 28 | UserPreferenceModel.objects.get( 29 | section="misc", name="favourite_colour", instance=henri 30 | ).value 31 | == "Blue" 32 | ) 33 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | import os 2 | import decimal 3 | import pickle 4 | 5 | import pytest 6 | 7 | from datetime import date, timedelta, datetime, time 8 | from django import forms 9 | from django.db.models import signals 10 | from django.core.files.uploadedfile import SimpleUploadedFile 11 | from django.conf import settings 12 | 13 | from dynamic_preferences.models import GlobalPreferenceModel 14 | from dynamic_preferences.settings import preferences_settings 15 | from dynamic_preferences.registries import global_preferences_registry 16 | from dynamic_preferences import types 17 | 18 | from .test_app.models import BlogEntry 19 | 20 | 21 | @pytest.fixture 22 | def no_validate_names(settings): 23 | settings.DYNAMIC_PREFERENCES = {"VALIDATE_NAMES": False} 24 | 25 | 26 | @pytest.fixture 27 | def blog_entry(db): 28 | return BlogEntry.objects.create(title="Hello", content="World") 29 | 30 | 31 | def test_default_accepts_callable(no_validate_names): 32 | class P(types.IntPreference): 33 | def get_default(self): 34 | return 4 35 | 36 | assert P().get("default") == 4 37 | 38 | 39 | def test_getter(no_validate_names): 40 | class PNoGetter(types.IntPreference): 41 | default = 1 42 | help_text = "Hello" 43 | 44 | class PGetter(types.IntPreference): 45 | def get_default(self): 46 | return 1 47 | 48 | def get_help_text(self): 49 | return "Hello" 50 | 51 | p_no_getter = PNoGetter() 52 | p_getter = PGetter() 53 | for attribute, expected in [("default", 1), ("help_text", "Hello")]: 54 | assert p_no_getter.get(attribute) == expected 55 | assert p_getter.get(attribute) == expected 56 | 57 | 58 | def test_field(no_validate_names): 59 | class P(types.IntPreference): 60 | default = 1 61 | verbose_name = "P" 62 | 63 | p = P() 64 | 65 | assert p.field.initial == 1 66 | assert p.field.label == "P" 67 | assert p.field.__class__ == forms.IntegerField 68 | 69 | 70 | def test_boolean_field_class_instantiation(no_validate_names): 71 | class P(types.BooleanPreference): 72 | default = False 73 | 74 | preference = P() 75 | assert preference.field.initial is False 76 | 77 | 78 | def test_char_field_class_instantiation(no_validate_names): 79 | class P(types.StringPreference): 80 | default = "hello world!" 81 | 82 | preference = P() 83 | 84 | assert preference.field.initial == "hello world!" 85 | 86 | 87 | def test_longstring_preference_widget(no_validate_names): 88 | class P(types.LongStringPreference): 89 | default = "hello world!" 90 | 91 | preference = P() 92 | 93 | assert isinstance(preference.field.widget, forms.Textarea) is True 94 | 95 | 96 | def test_decimal_preference(no_validate_names): 97 | class P(types.DecimalPreference): 98 | default = decimal.Decimal("2.5") 99 | 100 | preference = P() 101 | 102 | assert preference.field.initial == decimal.Decimal("2.5") 103 | 104 | 105 | def test_float_preference(no_validate_names): 106 | class P(types.FloatPreference): 107 | default = 0.35 108 | 109 | preference = P() 110 | 111 | assert preference.field.initial == 0.35 112 | assert preference.field.initial != 0.3 113 | assert preference.field.initial != 0.3001 114 | 115 | 116 | def test_duration_preference(no_validate_names): 117 | class P(types.DurationPreference): 118 | default = timedelta(0) 119 | 120 | preference = P() 121 | 122 | assert preference.field.initial == timedelta(0) 123 | 124 | 125 | def test_date_preference(no_validate_names): 126 | class P(types.DatePreference): 127 | default = date.today() 128 | 129 | preference = P() 130 | 131 | assert preference.field.initial == date.today() 132 | 133 | 134 | def test_datetime_preference(no_validate_names): 135 | initial_date_time = datetime(2017, 10, 4, 23, 7, 20, 682380) 136 | 137 | class P(types.DateTimePreference): 138 | default = initial_date_time 139 | 140 | preference = P() 141 | 142 | assert preference.field.initial == initial_date_time 143 | 144 | 145 | def test_time_preference(no_validate_names): 146 | class P(types.TimePreference): 147 | default = time(0) 148 | 149 | preference = P() 150 | 151 | assert preference.field.initial == time(0) 152 | 153 | 154 | def test_file_preference_defaults_to_none(no_validate_names): 155 | class P(types.FilePreference): 156 | pass 157 | 158 | preference = P() 159 | 160 | assert preference.field.initial is None 161 | 162 | 163 | def test_can_get_upload_path(no_validate_names): 164 | class P(types.FilePreference): 165 | pass 166 | 167 | p = P() 168 | 169 | assert p.get_upload_path() == ( 170 | preferences_settings.FILE_PREFERENCE_UPLOAD_DIR + "/" + p.identifier() 171 | ) 172 | 173 | 174 | def test_file_preference_store_file_path(db): 175 | f = SimpleUploadedFile( 176 | "test_file_1ce410e5-6814-4910-afd7-be1486d3644f.txt", 177 | "hello world".encode("utf-8"), 178 | ) 179 | p = global_preferences_registry.get(section="blog", name="logo") 180 | manager = global_preferences_registry.manager() 181 | manager["blog__logo"] = f 182 | assert manager["blog__logo"].read() == b"hello world" 183 | assert manager["blog__logo"].url == os.path.join( 184 | settings.MEDIA_URL, p.get_upload_path(), f.name 185 | ) 186 | 187 | assert manager["blog__logo"].path == os.path.join( 188 | settings.MEDIA_ROOT, p.get_upload_path(), f.name 189 | ) 190 | 191 | 192 | def test_file_preference_conflicting_file_names(db): 193 | """ 194 | f2 should have a different file name to f, since Django storage needs 195 | to differentiate between the two 196 | """ 197 | f = SimpleUploadedFile( 198 | "test_file_c95d02ef-0e5d-4d36-98c0-1b54505860d0.txt", 199 | "hello world".encode("utf-8"), 200 | ) 201 | f2 = SimpleUploadedFile( 202 | "test_file_c95d02ef-0e5d-4d36-98c0-1b54505860d0.txt", 203 | "hello world 2".encode("utf-8"), 204 | ) 205 | manager = global_preferences_registry.manager() 206 | 207 | manager["blog__logo"] = f 208 | manager["blog__logo2"] = f2 209 | 210 | assert manager["blog__logo2"].read() == b"hello world 2" 211 | assert manager["blog__logo"].read() == b"hello world" 212 | 213 | assert manager["blog__logo"].url != manager["blog__logo2"].url 214 | assert manager["blog__logo"].path != manager["blog__logo2"].path 215 | 216 | 217 | def test_can_delete_file_preference(db): 218 | f = SimpleUploadedFile( 219 | "test_file_bf2e72ef-092f-4a71-9cda-f2442d6166d0.txt", 220 | "hello world".encode("utf-8"), 221 | ) 222 | p = global_preferences_registry.get(section="blog", name="logo") 223 | manager = global_preferences_registry.manager() 224 | manager["blog__logo"] = f 225 | path = os.path.join(settings.MEDIA_ROOT, p.get_upload_path(), f.name) 226 | assert os.path.exists(path) is True 227 | manager["blog__logo"].delete() 228 | assert os.path.exists(path) is False 229 | 230 | 231 | def test_file_preference_api_repr_returns_path(db): 232 | f = SimpleUploadedFile( 233 | "test_file_24485a80-8db9-4191-ae49-da7fe2013794.txt", 234 | "hello world".encode("utf-8"), 235 | ) 236 | p = global_preferences_registry.get(section="blog", name="logo") 237 | manager = global_preferences_registry.manager() 238 | manager["blog__logo"] = f 239 | 240 | f = manager["blog__logo"] 241 | assert p.api_repr(f) == f.url 242 | 243 | 244 | def test_file_preference_if_pickleable(db): 245 | manager = global_preferences_registry.manager() 246 | f = SimpleUploadedFile( 247 | "test_file_24485a80-8db9-4191-ae49-da7fe2013794.txt", 248 | "hello world".encode("utf-8"), 249 | ) 250 | try: 251 | manager["blog__logo"] = f 252 | pickle.dumps(manager["blog__logo"]) 253 | except Exception: 254 | pytest.fail("FilePreference not pickleable") 255 | 256 | 257 | def test_choice_preference(fake_user): 258 | fake_user.preferences["user__favorite_vegetable"] = "C" 259 | assert fake_user.preferences["user__favorite_vegetable"] == "C" 260 | fake_user.preferences["user__favorite_vegetable"] = "P" 261 | assert fake_user.preferences["user__favorite_vegetable"] == "P" 262 | 263 | with pytest.raises(forms.ValidationError): 264 | fake_user.preferences["user__favorite_vegetable"] = "Nope" 265 | 266 | 267 | def test_multiple_choice_preference(fake_user): 268 | fake_user.preferences["user__favorite_vegetables"] = ["C", "T"] 269 | assert fake_user.preferences["user__favorite_vegetables"] == ["C", "T"] 270 | fake_user.preferences["user__favorite_vegetables"] = ["P"] 271 | assert fake_user.preferences["user__favorite_vegetables"] == ["P"] 272 | 273 | with pytest.raises(forms.ValidationError): 274 | fake_user.preferences["user__favorite_vegetables"] = ["Nope", "C"] 275 | 276 | 277 | def test_model_choice_preference(blog_entry): 278 | global_preferences = global_preferences_registry.manager() 279 | global_preferences["blog__featured_entry"] = blog_entry 280 | 281 | in_db = GlobalPreferenceModel.objects.get(section="blog", name="featured_entry") 282 | assert in_db.value == blog_entry 283 | assert in_db.raw_value == str(blog_entry.pk) 284 | 285 | 286 | def test_deleting_model_also_delete_preference(blog_entry): 287 | global_preferences = global_preferences_registry.manager() 288 | global_preferences["blog__featured_entry"] = blog_entry 289 | 290 | assert len(signals.pre_delete.receivers) > 0 291 | 292 | blog_entry.delete() 293 | 294 | with pytest.raises(GlobalPreferenceModel.DoesNotExist): 295 | GlobalPreferenceModel.objects.get(section="blog", name="featured_entry") 296 | -------------------------------------------------------------------------------- /tests/test_user_preferences.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | 4 | from django.urls import reverse 5 | from django.contrib.auth.models import User 6 | from django.db import IntegrityError 7 | 8 | from dynamic_preferences.users.registries import user_preferences_registry as registry 9 | from dynamic_preferences.users.models import UserPreferenceModel 10 | from dynamic_preferences.users import serializers 11 | from dynamic_preferences.managers import PreferencesManager 12 | from dynamic_preferences.users.forms import user_preference_form_builder 13 | 14 | 15 | def test_adding_user_create_default_preferences(db): 16 | 17 | u = User.objects.create(username="post_create") 18 | 19 | assert len(u.preferences) == len(registry.preferences()) 20 | 21 | 22 | def test_manager_is_attached_to_each_referenced_instance(fake_user): 23 | assert isinstance(fake_user.preferences, PreferencesManager) is True 24 | 25 | 26 | def test_preference_is_saved_to_database(fake_user): 27 | 28 | fake_user.preferences["test__TestUserPref1"] = "new test value" 29 | 30 | assert UserPreferenceModel.objects.filter( 31 | section="test", name="TestUserPref1", instance=fake_user 32 | ).exists() 33 | 34 | assert fake_user.preferences["test__TestUserPref1"] == "new test value" 35 | 36 | 37 | def test_per_instance_preference_stay_unique_in_db(fake_user): 38 | 39 | fake_user.preferences["test__TestUserPref1"] = "new value" 40 | 41 | duplicate = UserPreferenceModel( 42 | section="test", name="TestUserPref1", instance=fake_user 43 | ) 44 | 45 | with pytest.raises(IntegrityError): 46 | duplicate.save() 47 | 48 | 49 | def test_preference_value_set_to_default(fake_user): 50 | 51 | pref = registry.get("TestUserPref1", "test") 52 | 53 | value = fake_user.preferences["test__TestUserPref1"] 54 | assert pref.default == value 55 | assert UserPreferenceModel.objects.filter( 56 | section="test", name="TestUserPref1", instance=fake_user 57 | ).exists() 58 | 59 | 60 | def test_user_preference_model_manager_to_dict(fake_user): 61 | expected = { 62 | "misc__favourite_colour": "Green", 63 | "misc__is_zombie": True, 64 | "user__favorite_vegetable": "C", 65 | "user__favorite_vegetables": ["C", "P"], 66 | "test__SUserStringPref": "Hello world!", 67 | "test__SiteBooleanPref": False, 68 | "test__TestUserPref1": "default value", 69 | "test__TestUserPref2": "default value", 70 | } 71 | assert fake_user.preferences.all() == expected 72 | 73 | 74 | def test_can_build_user_preference_form_from_sections(fake_admin): 75 | form = user_preference_form_builder(instance=fake_admin, section="test")() 76 | 77 | assert len(form.fields) == 4 78 | 79 | 80 | def test_user_preference_form_is_bound_with_current_user(henri_client, henri): 81 | assert ( 82 | UserPreferenceModel.objects.get_or_create( 83 | instance=henri, section="misc", name="favourite_colour" 84 | )[0].value 85 | == "Green" 86 | ) 87 | assert ( 88 | UserPreferenceModel.objects.get_or_create( 89 | instance=henri, section="misc", name="is_zombie" 90 | )[0].value 91 | is True 92 | ) 93 | 94 | url = reverse("dynamic_preferences:user.section", kwargs={"section": "misc"}) 95 | response = henri_client.post( 96 | url, {"misc__favourite_colour": "Purple", "misc__is_zombie": False}, follow=True 97 | ) 98 | assert response.status_code == 200 99 | assert henri.preferences["misc__favourite_colour"] == "Purple" 100 | assert henri.preferences["misc__is_zombie"] is False 101 | 102 | 103 | def test_preference_list_requires_authentication(client): 104 | url = reverse("api:user-list") 105 | 106 | # anonymous 107 | response = client.get(url) 108 | assert response.status_code == 403 109 | 110 | 111 | def test_can_list_preferences(user_client, fake_user): 112 | manager = registry.manager(instance=fake_user) 113 | url = reverse("api:user-list") 114 | 115 | response = user_client.get(url) 116 | assert response.status_code == 200 117 | 118 | payload = json.loads(response.content.decode("utf-8")) 119 | 120 | assert len(payload) == len(registry.preferences()) 121 | 122 | for e in payload: 123 | pref = manager.get_db_pref(section=e["section"], name=e["name"]) 124 | serializers.UserPreferenceSerializer(pref) 125 | assert pref.preference.identifier() == e["identifier"] 126 | 127 | 128 | def test_can_list_preference_of_requesting_user(fake_user, user_client): 129 | second_user = User( 130 | username="user2", email="user2@user.com", is_superuser=True, is_staff=True 131 | ) 132 | second_user.set_password("test") 133 | second_user.save() 134 | 135 | manager = registry.manager(instance=fake_user) 136 | url = reverse("api:user-list") 137 | response = user_client.get(url) 138 | assert response.status_code == 200 139 | 140 | payload = json.loads(response.content.decode("utf-8")) 141 | 142 | assert len(payload) == len(registry.preferences()) 143 | 144 | url = reverse("api:user-list") 145 | user_client.login(username="user2", password="test") 146 | response = user_client.get(url) 147 | assert response.status_code == 200 148 | 149 | payload = json.loads(response.content.decode("utf-8")) 150 | 151 | # This should be 7 because each user gets 7 preferences by default. 152 | assert len(payload) == 8 153 | 154 | for e in payload: 155 | pref = manager.get_db_pref(section=e["section"], name=e["name"]) 156 | serializers.UserPreferenceSerializer(pref) 157 | assert pref.preference.identifier() == e["identifier"] 158 | 159 | 160 | def test_can_detail_preference(fake_user, user_client): 161 | manager = registry.manager(instance=fake_user) 162 | pref = manager.get_db_pref(section="user", name="favorite_vegetable") 163 | url = reverse("api:user-detail", kwargs={"pk": pref.preference.identifier()}) 164 | response = user_client.get(url) 165 | assert response.status_code == 200 166 | 167 | payload = json.loads(response.content.decode("utf-8")) 168 | assert pref.preference.identifier() == payload["identifier"] 169 | assert pref.value == payload["value"] 170 | 171 | 172 | def test_can_update_preference(fake_user, user_client): 173 | manager = registry.manager(instance=fake_user) 174 | pref = manager.get_db_pref(section="user", name="favorite_vegetable") 175 | url = reverse("api:user-detail", kwargs={"pk": pref.preference.identifier()}) 176 | response = user_client.patch( 177 | url, json.dumps({"value": "P"}), content_type="application/json" 178 | ) 179 | assert response.status_code == 200 180 | 181 | pref = manager.get_db_pref(section="user", name="favorite_vegetable") 182 | 183 | assert pref.value == "P" 184 | 185 | 186 | def test_can_update_multiple_preferences(fake_user, user_client): 187 | manager = registry.manager(instance=fake_user) 188 | manager.get_db_pref(section="user", name="favorite_vegetable") 189 | url = reverse("api:user-bulk") 190 | payload = { 191 | "user__favorite_vegetable": "C", 192 | "misc__favourite_colour": "Blue", 193 | } 194 | response = user_client.post( 195 | url, json.dumps(payload), content_type="application/json" 196 | ) 197 | assert response.status_code == 200 198 | 199 | pref1 = manager.get_db_pref(section="user", name="favorite_vegetable") 200 | pref2 = manager.get_db_pref(section="misc", name="favourite_colour") 201 | 202 | assert pref1.value == "C" 203 | assert pref2.value == "Blue" 204 | 205 | 206 | def test_update_preference_returns_validation_error(fake_user, user_client): 207 | manager = registry.manager(instance=fake_user) 208 | pref = manager.get_db_pref(section="user", name="favorite_vegetable") 209 | url = reverse("api:user-detail", kwargs={"pk": pref.preference.identifier()}) 210 | response = user_client.patch( 211 | url, json.dumps({"value": "Z"}), content_type="application/json" 212 | ) 213 | assert response.status_code == 400 214 | 215 | payload = json.loads(response.content.decode("utf-8")) 216 | 217 | assert "valid choice" in payload["value"][0] 218 | 219 | 220 | def test_update_multiple_preferences_with_validation_errors_rollback( 221 | user_client, fake_user 222 | ): 223 | manager = registry.manager(instance=fake_user) 224 | pref = manager.get_db_pref(section="user", name="favorite_vegetable") 225 | url = reverse("api:user-bulk") 226 | payload = { 227 | "user__favorite_vegetable": "Z", 228 | "misc__favourite_colour": "Blue", 229 | } 230 | response = user_client.post( 231 | url, json.dumps(payload), content_type="application/json" 232 | ) 233 | assert response.status_code == 400 234 | 235 | errors = json.loads(response.content.decode("utf-8")) 236 | assert "valid choice" in errors[pref.preference.identifier()]["value"][0] 237 | 238 | pref1 = manager.get_db_pref(section="user", name="favorite_vegetable") 239 | pref2 = manager.get_db_pref(section="misc", name="favourite_colour") 240 | 241 | assert pref1.value == pref1.preference.default 242 | assert pref2.value == pref2.preference.default 243 | -------------------------------------------------------------------------------- /tests/types.py: -------------------------------------------------------------------------------- 1 | from dynamic_preferences import types 2 | 3 | # For testing field instantiation 4 | 5 | 6 | class TestBooleanPreference(types.BooleanPreference): 7 | pass 8 | 9 | 10 | class TestStringPreference(types.StringPreference): 11 | 12 | field_attributes = {"initial": "hello world!"} 13 | 14 | 15 | # 16 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | try: 2 | from django.urls import include, re_path 3 | except ImportError: 4 | from django.conf.urls import include, url as re_path 5 | 6 | from django.contrib import admin 7 | from rest_framework import routers 8 | 9 | from dynamic_preferences import views 10 | from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet 11 | from dynamic_preferences.users.viewsets import UserPreferencesViewSet 12 | 13 | admin.autodiscover() 14 | 15 | router = routers.SimpleRouter() 16 | router.register(r"global", GlobalPreferencesViewSet, "global") 17 | router.register(r"user", UserPreferencesViewSet, "user") 18 | # router.register(r'user', AccountViewSet) 19 | 20 | 21 | urlpatterns = [ 22 | re_path(r"^", include("dynamic_preferences.urls")), 23 | re_path(r"^admin/", admin.site.urls), 24 | re_path( 25 | r"^test/template$", 26 | views.RegularTemplateView.as_view(), 27 | name="dynamic_preferences.test.templateview", 28 | ), 29 | re_path(r"^api", include((router.urls, "api"), namespace="api")), 30 | ] 31 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = 8 | {py39,py310,py311,py312}-django-42 9 | {py310,py311,py312,py313}-django-51 10 | {py313}-django-main 11 | 12 | 13 | [testenv] 14 | 15 | setenv = 16 | PYTHONPATH = {toxinidir} 17 | commands = pytest --cov=dynamic_preferences {posargs} 18 | deps = 19 | django-{42,51,main}: djangorestframework>=3.13,<4 20 | django-42: Django>=4.2,<5.0 21 | django-51: Django>=5.1,<6.0 22 | django-main: https://github.com/django/django/archive/main.tar.gz 23 | -r{toxinidir}/requirements-test.txt 24 | 25 | 26 | basepython = 27 | py313: python3.13 28 | py312: python3.12 29 | py311: python3.11 30 | py310: python3.10 31 | py39: python3.9 32 | 33 | [testenv:py313-django-main] 34 | ignore_outcome = true --------------------------------------------------------------------------------