├── siteprefs ├── tests │ ├── __init__.py │ ├── testapp │ │ ├── __init__.py │ │ ├── testmodule.py │ │ └── settings.py │ ├── conftest.py │ ├── test_utils.py │ └── test_basic.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── __init__.py ├── exceptions.py ├── locale │ ├── fr │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pl │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ru │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── en │ │ └── LC_MESSAGES │ │ └── django.po ├── signals.py ├── admin.py ├── apps.py ├── settings.py ├── models.py ├── utils.py └── toolbox.py ├── pytest.ini ├── setup.cfg ├── .coveragerc ├── MANIFEST.in ├── .gitignore ├── AUTHORS ├── tox.ini ├── docs ├── source │ ├── settings.rst │ ├── index.rst │ ├── quickstart.rst │ ├── registration.rst │ └── conf.py └── Makefile ├── .github └── workflows │ └── python-package.yml ├── LICENSE ├── README.rst ├── setup.py └── CHANGELOG /siteprefs/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /siteprefs/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /siteprefs/tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | a = 1 -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --pyargs siteprefs 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | release = clean --all sdist bdist_wheel upload 3 | test = pytest 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = siteprefs/* 3 | omit = siteprefs/migrations/*, siteprefs/tests/* -------------------------------------------------------------------------------- /siteprefs/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (1, 2, 3) 2 | 3 | 4 | default_app_config = 'siteprefs.apps.SiteprefsConfig' -------------------------------------------------------------------------------- /siteprefs/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class SitePrefsException(Exception): 3 | """Base exception class for SitePrefs.""" 4 | -------------------------------------------------------------------------------- /siteprefs/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteprefs/HEAD/siteprefs/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /siteprefs/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteprefs/HEAD/siteprefs/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /siteprefs/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteprefs/HEAD/siteprefs/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /siteprefs/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | prefs_save = Signal() 5 | """Issued when dynamic preferences models are saved. 6 | providing_args=['app', 'updated_prefs'] 7 | 8 | """ 9 | -------------------------------------------------------------------------------- /siteprefs/tests/testapp/testmodule.py: -------------------------------------------------------------------------------- 1 | 2 | def read_option_2(): 3 | from .settings import MY_OPTION_2 4 | return MY_OPTION_2 5 | 6 | 7 | def read_not_an_option(): 8 | from .settings import NOT_AN_OPTION 9 | return NOT_AN_OPTION 10 | -------------------------------------------------------------------------------- /siteprefs/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pytest_djangoapp import configure_djangoapp_plugin 2 | 3 | pytest_plugins = configure_djangoapp_plugin({ 4 | 'SITEPREFS_EXPOSE_MODEL_TO_ADMIN': True, 5 | 'SITEPREFS_DISABLE_AUTODISCOVER': True, 6 | }, admin_contrib=True) 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include AUTHORS 3 | include LICENSE 4 | include CHANGELOG 5 | 6 | include docs/Makefile 7 | recursive-include docs *.rst 8 | recursive-include docs *.py 9 | 10 | recursive-include siteprefs/locale * 11 | recursive-include siteprefs/migrations *.py 12 | recursive-include siteprefs/south_migrations *.py 13 | -------------------------------------------------------------------------------- /.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 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | 30 | # Mr Developer 31 | .mr.developer.cfg 32 | .project 33 | .pydevproject 34 | -------------------------------------------------------------------------------- /siteprefs/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .settings import EXPOSE_MODEL_TO_ADMIN 4 | 5 | 6 | if EXPOSE_MODEL_TO_ADMIN: 7 | from .models import Preference 8 | 9 | class PreferenceAdmin(admin.ModelAdmin): 10 | 11 | list_display = ('app', 'name') 12 | search_fields = ['app', 'name'] 13 | list_filter = ['app'] 14 | ordering = ['app', 'name'] 15 | 16 | 17 | admin.site.register(Preference, PreferenceAdmin) 18 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | django-siteprefs Authors 2 | ======================== 3 | 4 | Created by Igor 'idle sign' Starikov. 5 | 6 | 7 | Contributors 8 | ------------ 9 | 10 | Brian Schott 11 | 12 | 13 | 14 | Translators 15 | ----------- 16 | 17 | Russian: Igor Starikov 18 | Polish: Alexey Subbotin 19 | French: Jean-Etienne Castagnede 20 | -------------------------------------------------------------------------------- /siteprefs/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from .settings import DISABLE_AUTODISCOVER 5 | 6 | 7 | class SiteprefsConfig(AppConfig): 8 | """The default siteprefs configuration.""" 9 | 10 | name = 'siteprefs' 11 | verbose_name = _('Site Preferences') 12 | 13 | def ready(self): 14 | 15 | if DISABLE_AUTODISCOVER: 16 | return 17 | 18 | from .toolbox import autodiscover_siteprefs 19 | autodiscover_siteprefs() 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{36}-django{20,21,22,30,31,32} 4 | py{37,38,39,310}-django{20,21,22,30,31,32,40} 5 | 6 | install_command = pip install {opts} {packages} 7 | skip_missing_interpreters = True 8 | 9 | [testenv] 10 | commands = python setup.py test 11 | 12 | deps = 13 | django20: Django>=2.0,<2.1 14 | django21: Django>=2.1,<2.2 15 | django22: Django>=2.2,<2.3 16 | django30: Django>=3.0,<3.1 17 | django31: Django>=3.1,<3.2 18 | django32: Django>=3.2,<3.3 19 | django40: Django>=4.0,<4.1 20 | -------------------------------------------------------------------------------- /siteprefs/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | EXPOSE_MODEL_TO_ADMIN = getattr(settings, 'SITEPREFS_EXPOSE_MODEL_TO_ADMIN', False) 5 | """Toggles internal preferences model showing up in the Admin.""" 6 | 7 | DISABLE_AUTODISCOVER = getattr(settings, 'SITEPREFS_DISABLE_AUTODISCOVER', False) 8 | """Disables preferences autodiscovery on Django apps registry ready.""" 9 | 10 | PREFS_MODULE_NAME = getattr(settings, 'SITEPREFS_MODULE_NAME', 'settings') 11 | """Module name used by siteprefs.toolbox.autodiscover_siteprefs() to find preferences in application packages.""" 12 | -------------------------------------------------------------------------------- /docs/source/settings.rst: -------------------------------------------------------------------------------- 1 | Settings 2 | ======== 3 | 4 | Some aspects of **siteprefs** could be tuned in `settings.py` of your project. 5 | 6 | 7 | SITEPREFS_EXPOSE_MODEL_TO_ADMIN 8 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 9 | 10 | Toggles internal preferences model showing up in the Admin. Default: true. 11 | 12 | 13 | SITEPREFS_DISABLE_AUTODISCOVER 14 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 15 | 16 | Disables preferences autodiscovery on Django apps registry ready. Default: false. 17 | 18 | 19 | SITEPREFS_MODULE_NAME 20 | ~~~~~~~~~~~~~~~~~~~~~ 21 | 22 | Module name used by siteprefs.toolbox.autodiscover_siteprefs() to find preferences in application packages. 23 | -------------------------------------------------------------------------------- /siteprefs/tests/testapp/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | NOT_AN_OPTION = 'not-an-option' 5 | MY_OPTION_1 = getattr(settings, 'MY_APP_MY_OPTION_1', True) 6 | MY_OPTION_2 = getattr(settings, 'MY_APP_MY_OPTION_2', 'Some value') 7 | MY_OPTION_42 = getattr(settings, 'MY_APP_MY_OPTION_42', 42) 8 | 9 | 10 | if 'siteprefs' in settings.INSTALLED_APPS: 11 | 12 | from siteprefs.toolbox import preferences 13 | 14 | with preferences() as prefs: 15 | 16 | prefs( 17 | MY_OPTION_1, 18 | prefs.one(MY_OPTION_2, static=False), 19 | prefs.group('My Group', [prefs.one(MY_OPTION_42)]), 20 | ) 21 | -------------------------------------------------------------------------------- /siteprefs/locale/en/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 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2014-09-20 11:15+0700\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: apps.py:9 21 | msgid "Site Preferences" 22 | msgstr "" 23 | 24 | #: models.py:10 25 | msgid "Application" 26 | msgstr "" 27 | 28 | #: models.py:11 29 | msgid "Name" 30 | msgstr "" 31 | 32 | #: models.py:12 33 | msgid "Value" 34 | msgstr "" 35 | 36 | #: models.py:49 utils.py:125 37 | msgid "Preference" 38 | msgstr "" 39 | 40 | #: models.py:50 utils.py:126 41 | msgid "Preferences" 42 | msgstr "" 43 | -------------------------------------------------------------------------------- /siteprefs/migrations/0001_initial.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 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Preference', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('app', models.CharField(blank=True, max_length=100, verbose_name='Application', db_index=True, null=True)), 18 | ('name', models.CharField(max_length=150, verbose_name='Name')), 19 | ('text', models.TextField(blank=True, verbose_name='Value', null=True)), 20 | ], 21 | options={ 22 | 'verbose_name_plural': 'Preferences', 23 | 'verbose_name': 'Preference', 24 | }, 25 | bases=(models.Model,), 26 | ), 27 | migrations.AlterUniqueTogether( 28 | name='preference', 29 | unique_together=set([('app', 'name')]), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /siteprefs/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from siteprefs.utils import Mimic 2 | 3 | 4 | def test_mimic(): 5 | 6 | class M(Mimic): 7 | 8 | def __init__(self, value): 9 | self._val = value 10 | 11 | @property 12 | def value(self): 13 | return self._val 14 | 15 | bool(M(False)) 16 | 17 | assert M(4)() == 4 18 | assert str(M('www')) == 'www' 19 | assert len(M('some')) == 4 20 | assert int(M(4)) == 4 21 | assert float(M(4.2)) == 4.2 22 | assert not bool(M(False)) 23 | assert not M(False) 24 | assert M(True) 25 | assert (M(4) + 5) == 9 26 | assert (5 + M(4)) == 9 27 | assert (M(4) - 5) == -1 28 | assert (5 - M(4)) == 1 29 | assert (M(4) * 5) == 20 30 | assert (5 * M(4)) == 20 31 | 32 | x = 5 33 | x += M(4) 34 | assert x == 9 35 | 36 | x = M(4) 37 | x -= 1 38 | assert x == 3 39 | 40 | assert ('some%s' % M('any')) == 'someany' 41 | assert (M('any') + 'some') == 'anysome' 42 | assert M(4) < 5 43 | assert M(4) <= 5 44 | assert M(6) > 5 45 | assert M(6) >= 5 46 | assert M(5) == 5 47 | assert M(4) != 5 48 | assert len(M('some')) == 4 49 | assert 'ome' in M('some') 50 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: [3.6, 3.7, 3.8, 3.9, "3.10"] 18 | django-version: [2.0, 2.1, 2.2, 3.0, 3.1, 3.2, 4.0] 19 | 20 | exclude: 21 | 22 | - python-version: 3.7 23 | django-version: 4.0 24 | 25 | - python-version: 3.6 26 | django-version: 4.0 27 | 28 | steps: 29 | - uses: actions/checkout@v2 30 | - name: Set up Python ${{ matrix.python-version }} & Django ${{ matrix.django-version }} 31 | uses: actions/setup-python@v2 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - name: Install deps 35 | run: | 36 | python -m pip install pytest coverage coveralls "Django~=${{ matrix.django-version }}.0" 37 | - name: Run tests 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.github_token }} 40 | run: | 41 | coverage run --source=siteprefs setup.py test 42 | coveralls --service=github 43 | -------------------------------------------------------------------------------- /siteprefs/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 | # 5 | # Translators: 6 | # 6e2b33107bb6a845e535b0a7845a02f0, 2020 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-siteprefs\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2014-09-20 11:15+0700\n" 12 | "PO-Revision-Date: 2020-04-05 09:12+0700\n" 13 | "Last-Translator: Igor 'idle sign' Starikov \n" 14 | "Language-Team: French (http://www.transifex.com/idlesign/django-siteprefs/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 | "Language: fr\n" 19 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 20 | "X-Generator: Poedit 2.0.6\n" 21 | 22 | #: apps.py:9 23 | msgid "Site Preferences" 24 | msgstr "Préférences du Site " 25 | 26 | #: models.py:10 27 | msgid "Application" 28 | msgstr "Application" 29 | 30 | #: models.py:11 31 | msgid "Name" 32 | msgstr "Nom" 33 | 34 | #: models.py:12 35 | msgid "Value" 36 | msgstr "Valeur" 37 | 38 | #: models.py:49 utils.py:125 39 | msgid "Preference" 40 | msgstr "Préférence" 41 | 42 | #: models.py:50 utils.py:126 43 | msgid "Preferences" 44 | msgstr "Préférences" 45 | -------------------------------------------------------------------------------- /siteprefs/locale/ru/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 | # 5 | # Translators: 6 | # Igor Starikov , 2013-2014 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-siteprefs\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2014-09-20 11:15+0700\n" 12 | "PO-Revision-Date: 2014-09-20 11:23+0700\n" 13 | "Last-Translator: Igor 'idle sign' Starikov \n" 14 | "Language-Team: Russian (http://www.transifex.com/projects/p/django-siteprefs/" 15 | "language/ru/)\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Language: ru\n" 20 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 21 | "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" 22 | "X-Generator: Poedit 1.5.4\n" 23 | 24 | #: apps.py:9 25 | msgid "Site Preferences" 26 | msgstr "Настройки сайта" 27 | 28 | #: models.py:10 29 | msgid "Application" 30 | msgstr "Приложение" 31 | 32 | #: models.py:11 33 | msgid "Name" 34 | msgstr "Название" 35 | 36 | #: models.py:12 37 | msgid "Value" 38 | msgstr "Значение" 39 | 40 | #: models.py:49 utils.py:125 41 | msgid "Preference" 42 | msgstr "Настройка" 43 | 44 | #: models.py:50 utils.py:126 45 | msgid "Preferences" 46 | msgstr "Настройки" 47 | -------------------------------------------------------------------------------- /siteprefs/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 | # 5 | # Translators: 6 | # Alexey Subbotin , 2013 7 | # Igor Starikov , 2014 8 | msgid "" 9 | msgstr "" 10 | "Project-Id-Version: django-siteprefs\n" 11 | "Report-Msgid-Bugs-To: \n" 12 | "POT-Creation-Date: 2014-09-20 11:15+0700\n" 13 | "PO-Revision-Date: 2014-09-20 11:23+0700\n" 14 | "Last-Translator: Igor 'idle sign' Starikov \n" 15 | "Language-Team: Polish (http://www.transifex.com/projects/p/django-siteprefs/" 16 | "language/pl/)\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=UTF-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Language: pl\n" 21 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " 22 | "|| n%100>=20) ? 1 : 2);\n" 23 | "X-Generator: Poedit 1.5.4\n" 24 | 25 | #: apps.py:9 26 | msgid "Site Preferences" 27 | msgstr "Ustawienia strony" 28 | 29 | #: models.py:10 30 | msgid "Application" 31 | msgstr "Aplikacja" 32 | 33 | #: models.py:11 34 | msgid "Name" 35 | msgstr "Nazwa" 36 | 37 | #: models.py:12 38 | msgid "Value" 39 | msgstr "Wartość" 40 | 41 | #: models.py:49 utils.py:125 42 | msgid "Preference" 43 | msgstr "Ustawienie" 44 | 45 | #: models.py:50 utils.py:126 46 | msgid "Preferences" 47 | msgstr "Ustawienia" 48 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | django-siteprefs documentation 2 | ============================== 3 | 4 | http://github.com/idlesign/django-siteprefs 5 | 6 | *Reusable app for Django introducing site preferences system* 7 | 8 | django-siteprefs allows Django applications settings to come alive. 9 | 10 | 11 | Requirements 12 | ------------ 13 | 14 | 1. Python 3.6+ 15 | 2. Django 2.0+ 16 | 3. Django Auth contrib enabled 17 | 4. Django Admin contrib enabled (optional) 18 | 19 | 20 | Table of Contents 21 | ----------------- 22 | 23 | .. toctree:: 24 | :maxdepth: 2 25 | 26 | quickstart 27 | registration 28 | settings 29 | 30 | 31 | Get involved into django-siteprefs 32 | ---------------------------------- 33 | 34 | **Submit issues.** If you spotted something weird in application behavior or want to propose a feature 35 | you can do that at https://github.com/idlesign/django-siteprefs/issues 36 | 37 | **Write code.** If you are eager to participate in application development, 38 | fork it at https://github.com/idlesign/django-siteprefs, write your code, whether it should be a bugfix or a feature 39 | implementation, and make a pull request right from the forked project page. 40 | 41 | **Translate.** If want to translate the application into your native language 42 | use Transifex: https://www.transifex.com/projects/p/django-siteprefs/. 43 | 44 | **Spread the word.** If you have some tips and tricks or any other words in mind that you think 45 | might be of interest for the others — publish them. 46 | 47 | 48 | Also 49 | ---- 50 | 51 | Consider more applications on the subject — https://www.djangopackages.com/grids/g/live-setting/ 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2022, Igor 'idle sign' Starikov 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the django-siteprefs nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-siteprefs 2 | ================ 3 | http://github.com/idlesign/django-siteprefs 4 | 5 | .. image:: https://img.shields.io/pypi/v/django-siteprefs.svg 6 | :target: https://pypi.python.org/pypi/django-siteprefs 7 | 8 | .. image:: https://img.shields.io/pypi/l/django-siteprefs.svg 9 | :target: https://pypi.python.org/pypi/django-siteprefs 10 | 11 | .. image:: https://img.shields.io/coveralls/idlesign/django-siteprefs/master.svg 12 | :target: https://coveralls.io/r/idlesign/django-siteprefs 13 | 14 | 15 | What's that 16 | ----------- 17 | 18 | *django-siteprefs allows Django applications settings to come alive* 19 | 20 | Let's suppose you have your pretty settings.py file with you application: 21 | 22 | .. code-block:: python 23 | 24 | from django.conf import settings 25 | 26 | MY_OPTION_1 = getattr(settings, 'MY_APP_MY_OPTION_1', True) 27 | MY_OPTION_2 = getattr(settings, 'MY_APP_MY_OPTION_2', 'Some value') 28 | MY_OPTION_42 = getattr(settings, 'MY_APP_MY_OPTION_42', 42) 29 | 30 | 31 | Now you want these options to be exposed to Django Admin interface. Just add the following: 32 | 33 | .. code-block:: python 34 | 35 | # To be sure our app is still functional without django-siteprefs. 36 | if 'siteprefs' in settings.INSTALLED_APPS: 37 | 38 | from siteprefs.toolbox import preferences 39 | 40 | with preferences() as prefs: 41 | # And that's how we expose our options to Admin. 42 | prefs(MY_OPTION_1, MY_OPTION_2, MY_OPTION_42) 43 | 44 | 45 | After that you can view your settings in Django Admin. 46 | 47 | If you want those settings to be editable through the Admin - ``siteprefs`` allows that too, and even more. 48 | 49 | Read the docs ;) 50 | 51 | 52 | Documentation 53 | ------------- 54 | 55 | http://django-siteprefs.readthedocs.org/ 56 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Getting started 2 | =============== 3 | 4 | * Add the **siteprefs** application to INSTALLED_APPS in your settings file (usually 'settings.py'). 5 | * Use ``> python manage.py migrate`` command to install apps tables int DB. 6 | 7 | 8 | Quick example 9 | ------------- 10 | 11 | Let's suppose we created ``MYAPP`` application and now create ``settings.py`` file for it: 12 | 13 | .. code-block:: python 14 | 15 | from django.conf import settings 16 | 17 | ENABLE_GRAVATARS = getattr(settings, 'MYAPP_ENABLE_GRAVATARS', True) 18 | ENABLE_MAIL_RECOVERY = getattr(settings, 'MYAPP_ENABLE_MAIL_RECOVERY', True) 19 | ENABLE_MAIL_BOMBS = getattr(settings, 'MYAPP_ENABLE_MAIL_BOMBS', False) 20 | SLOGAN = "I'm short and I'm tall // I'm black and I'm white" 21 | PRIVATE_SETTING = 'Hidden' 22 | 23 | 24 | if 'siteprefs' in settings.INSTALLED_APPS: # Respect those users who doesn't have siteprefs installed. 25 | 26 | from siteprefs.toolbox import preferences 27 | 28 | with preferences() as prefs: 29 | 30 | prefs( # Now we register our settings to make them available as siteprefs. 31 | # First we define a group of related settings, and mark them non-static (editable). 32 | prefs.group('Mail settings', (ENABLE_MAIL_RECOVERY, ENABLE_MAIL_BOMBS), static=False), 33 | SLOGAN, # This setting stays static non-editable. 34 | # And finally we register a non-static setting with extended meta for Admin. 35 | prefs.one( 36 | ENABLE_GRAVATARS, 37 | verbose_name='Enable Gravatar service support', static=False, 38 | help_text='This enables Gravatar support.'), 39 | ) 40 | 41 | 42 | From now on you can view (and edit) your preferences with Django Admin interface. 43 | 44 | Access your settings as usual, all changes made to preferences with Admin interface will be respected: 45 | 46 | .. code-block:: python 47 | 48 | from .settings import ENABLE_MAIL_BOMBS 49 | 50 | def bombing(): 51 | if ENABLE_MAIL_BOMBS: 52 | print('booooom') 53 | 54 | 55 | And mind that we've barely made a scratch of **siteprefs**. 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import re 4 | import sys 5 | 6 | from setuptools import setup, find_packages 7 | 8 | PATH_BASE = os.path.dirname(__file__) 9 | 10 | 11 | def read_file(fpath): 12 | """Reads a file within package directories.""" 13 | with io.open(os.path.join(PATH_BASE, fpath)) as f: 14 | return f.read() 15 | 16 | 17 | def get_version(): 18 | """Returns version number, without module import (which can lead to ImportError 19 | if some dependencies are unavailable before install.""" 20 | contents = read_file(os.path.join('siteprefs', '__init__.py')) 21 | version = re.search('VERSION = \(([^)]+)\)', contents) 22 | version = version.group(1).replace(', ', '.').strip() 23 | return version 24 | 25 | 26 | setup( 27 | name='django-siteprefs', 28 | version=get_version(), 29 | url='http://github.com/idlesign/django-siteprefs', 30 | 31 | description='Reusable app for Django introducing site preferences system', 32 | long_description=read_file('README.rst'), 33 | license='BSD 3-Clause License', 34 | 35 | author='Igor `idle sign` Starikov', 36 | author_email='idlesign@yandex.ru', 37 | 38 | packages=find_packages(), 39 | include_package_data=True, 40 | zip_safe=False, 41 | 42 | install_requires=[ 43 | 'django-etc >= 1.2.0', 44 | ], 45 | setup_requires=[] + (['pytest-runner'] if 'test' in sys.argv else []), 46 | 47 | test_suite='tests', 48 | tests_require=[ 49 | 'pytest', 50 | 'pytest-djangoapp>=0.15.1', 51 | ], 52 | 53 | classifiers=[ 54 | 'Development Status :: 5 - Production/Stable', 55 | 'Environment :: Web Environment', 56 | 'Framework :: Django', 57 | 'Intended Audience :: Developers', 58 | 'License :: OSI Approved :: BSD License', 59 | 'Operating System :: OS Independent', 60 | 'Programming Language :: Python', 61 | 'Programming Language :: Python :: 3', 62 | 'Programming Language :: Python :: 3.6', 63 | 'Programming Language :: Python :: 3.7', 64 | 'Programming Language :: Python :: 3.8', 65 | 'Programming Language :: Python :: 3.9', 66 | 'Programming Language :: Python :: 3.10', 67 | ], 68 | ) 69 | -------------------------------------------------------------------------------- /siteprefs/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.utils import IntegrityError 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class Preference(models.Model): 7 | 8 | app = models.CharField(_('Application'), max_length=100, null=True, blank=True, db_index=True) 9 | name = models.CharField(_('Name'), max_length=150) 10 | text = models.TextField(_('Value'), null=True, blank=True) 11 | 12 | class Meta: 13 | verbose_name = _('Preference') 14 | verbose_name_plural = _('Preferences') 15 | unique_together = ('app', 'name') 16 | 17 | def __str__(self): 18 | return f'{self.app}:{self.name}' 19 | 20 | @classmethod 21 | def read_prefs(cls, mem_prefs: dict): 22 | """Initializes preferences entries in DB according to currently discovered prefs. 23 | 24 | :param mem_prefs: 25 | 26 | """ 27 | db_prefs = { 28 | f"{pref['app']}__{pref['name']}": pref for pref in 29 | cls.objects.values().order_by('app', 'name') 30 | } 31 | 32 | new_prefs = [] 33 | 34 | for app, prefs in mem_prefs.items(): 35 | 36 | for pref_name, pref_proxy in prefs.items(): 37 | 38 | if not pref_proxy.static: # Do not add static options to DB. 39 | key = f'{app}__{pref_name}' 40 | 41 | if key in db_prefs: 42 | # Entry already exists in DB. Let's get pref value from there. 43 | pref_proxy.db_value = db_prefs[key]['text'] 44 | 45 | else: 46 | new_prefs.append(cls(app=app, name=pref_name, text=pref_proxy.default)) 47 | 48 | if new_prefs: 49 | try: 50 | cls.objects.bulk_create(new_prefs) 51 | 52 | except IntegrityError: # Don't bother with duplicates. 53 | pass 54 | 55 | @classmethod 56 | def update_prefs(cls, *args, **kwargs): 57 | # TODO That could be more efficient. 58 | 59 | updated_prefs = kwargs['updated_prefs'] 60 | 61 | for db_pref in cls.objects.filter(app=kwargs['app']): 62 | 63 | if db_pref.name in updated_prefs: 64 | db_pref.text = updated_prefs[db_pref.name] 65 | db_pref.save() 66 | -------------------------------------------------------------------------------- /docs/source/registration.rst: -------------------------------------------------------------------------------- 1 | Preferences registration 2 | ======================== 3 | 4 | **siteprefs** has several helpers to ease application settings registration. 5 | 6 | All of them reside in `siteprefs.toolbox` module. Let's go one by one: 7 | 8 | 9 | * `register_prefs(*args, **kwargs )` 10 | 11 | The main way to register your settings. Expects preferences as **args** and their options as **kwargs**: 12 | 13 | .. code-block:: python 14 | 15 | register_prefs( 16 | MY_OPT_1, 17 | MY_OPT_2, 18 | MY_OPT_3, 19 | category='All the settings' # This will group all the settings into one category in Admin. 20 | ) 21 | 22 | 23 | * `pref_group(group_title, prefs, **kwargs)` 24 | 25 | This allows preferences grouping. Expects a group title, a list of preferences and their options as **kwargs**: 26 | 27 | .. code-block:: python 28 | 29 | register_prefs( 30 | MY_OPT_1, MY_OPT_2, 31 | pref_group('My options group 1', (MY_OPT_3, MY_OPT_4), static=False), 32 | pref_group('My options group 2', (MY_OPT_5, pref(MY_OPT_6, verbose_name='My 6'))), 33 | ) 34 | 35 | 36 | * `pref(preference, **kwargs)` 37 | 38 | Used to mark a preference. Expects a preference and its options as **kwargs**: 39 | 40 | .. code-block:: python 41 | 42 | register_prefs( 43 | MY_OPT_1, MY_OPT_2, 44 | pref(MY_OPT_3, verbose_name='My third option', static=False), 45 | pref(MY_OPT_4, verbose_name='Fourth', help_text='My fourth option.'), 46 | ) 47 | 48 | 49 | The functions mentioned above are available through a shortcut ``preferences()`` 50 | context manager as mentioned in the Quickstart. 51 | 52 | 53 | Options accepted by prefs 54 | ------------------------- 55 | 56 | These are the options accepted as **kwargs** by **siteprefs** helpers: 57 | 58 | 59 | * ``static`` 60 | 61 | Flag to mark a preference editable from Admin - static are not editable. True by default. 62 | 63 | * ``readonly`` 64 | 65 | Flag to mark an [editable] preference read only for Admin. False by default. 66 | 67 | * ``field`` 68 | 69 | Field instance (from django.db.models, e.g. ``BooleanField()``) to represent a sitepref in Admin. 70 | 71 | None by default. If None, **siteprefs** will try to determine an appropriate field type for a given 72 | preference value type. 73 | 74 | * ``category`` 75 | 76 | Category name to group a sitepref under. None by default. 77 | 78 | 79 | * ``verbose_name`` 80 | 81 | Preference name to render in Admin. 82 | 83 | None by default. If None, a name will be deduces from preference variable name. 84 | 85 | * ``help_text`` 86 | 87 | Hint text to render for a preference in Admin. Empty by default. 88 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | django-siteprefs changelog 2 | ========================== 3 | 4 | 5 | v1.2.3 [2021-12-18] 6 | ------------------- 7 | * Django 4.0 compatibility improved. 8 | 9 | 10 | v1.2.2 [2021-01-19] 11 | ------------------- 12 | * 'autodiscover_siteprefs()' behaviour on missing DB reworked. 'MANAGE_SAFE_COMMANDS' setting is now ignored. 13 | 14 | 15 | v1.2.1 [2020-10-31] 16 | ------------------- 17 | * Dropped QA for Django < 2.0. 18 | * Fixed deprecation warning. 19 | 20 | 21 | v1.2.0 [2020-04-05] 22 | ------------------- 23 | ! Dropped support for Python 2. 24 | ! Dropped support for Python 3.5. 25 | + Added French translations. 26 | 27 | 28 | v1.1.0 [2019-12-07] 29 | ------------------- 30 | ! Dropped QA for Django 1.7. 31 | ! Dropped QA for Python 2. 32 | + Add Django 3.0 compatibility. 33 | 34 | 35 | v1.0.0 36 | ------ 37 | ! Dropped QA for Python 3.4. 38 | * No functional changes. Celebrating 1.0.0. 39 | 40 | 41 | v0.9.0 42 | ------ 43 | + IMPORTANT. 'register_prefs' now replaces settins module with a proxy object. No need to call '.get_value()' anymore. 44 | + Introduced a shortcut 'preferences()' context manager as the main API entry point. 45 | * Fixed 'autodiscover_siteprefs' fail on manage.py call w/o arguments. 46 | * Mimic class fixes. 47 | 48 | 49 | v0.8.1 50 | ------ 51 | * Better real value type mimic for preference proxy objects. 52 | 53 | 54 | v0.8.0 55 | ------ 56 | + Usability, code and docstrings improvements. 57 | 58 | 59 | v0.7.0 60 | ------ 61 | + Django 2.0 basic compatibility. 62 | * Dropped support for Python<3.4 and Django<1.7. 63 | 64 | 65 | v0.6.3 66 | ------ 67 | * Package distribution fix 68 | 69 | 70 | v0.6.2 71 | ------ 72 | * Dummy. Never released. 73 | 74 | 75 | v0.6.1 76 | ------ 77 | * import_prefs() regression fix. 78 | 79 | 80 | v0.6.0 81 | ------ 82 | * IMPORTANT: added dependency - django-etc. 83 | * IMPORTANT: manual autodiscover_siteprefs() call is not required after Django 1.7. 84 | * Fixed KeyError in import_prefs (see #10) 85 | 86 | 87 | v0.5.3 88 | ------ 89 | * IMPORTANT: added dependency - django-etc (fixed #13). 90 | 91 | 92 | v0.5.2 93 | ------ 94 | * Fixed `manage` commands fail due to `autodiscover_siteprefs()` on Django 1.9. 95 | 96 | 97 | v0.5.1 98 | ------ 99 | * Django 1.9 compatibility improvements. 100 | 101 | 102 | v0.5.0 103 | ------ 104 | + Djagno 1.7 ready. 105 | + Added Polish loco. 106 | 107 | 108 | v0.4.0 109 | ------ 110 | + pref_group() now accepts prefs already marked by pref(). 111 | 112 | 113 | v0.3.3 114 | ------ 115 | * Fixed UnicodeEncodeError in siteprefs/utils.py on Py 3 (closes #8) 116 | 117 | 118 | v0.3.2 119 | ------ 120 | * Fixed some `pref()` arguments disrespected if field object is passed in `field` argument (closes #7) 121 | 122 | 123 | v0.3.1 124 | ------ 125 | * Fixed admin field render when field type is passed into pref() (see #6). 126 | 127 | 128 | v0.3.0 129 | ------ 130 | + Added support for datetime fields 131 | + Added MANAGE_SAFE_COMMANDS setting 132 | * Fixed PrefProxy.__str__() behaviour. 133 | 134 | 135 | v0.2.1 136 | ------ 137 | + Added support for pre Django 1.4 projects layouts. 138 | * Fixed failure to autodiscover settings on Django dev server. 139 | 140 | 141 | v0.2.0 142 | ------ 143 | * Slight API changes. 144 | * Minor fixes. 145 | 146 | 147 | v0.1.0 148 | ------ 149 | + Basic siteprefs functionality. -------------------------------------------------------------------------------- /siteprefs/tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib import admin 3 | from django.db import models 4 | 5 | from siteprefs.models import Preference 6 | from siteprefs.toolbox import autodiscover_siteprefs, get_app_prefs, get_prefs_models 7 | from siteprefs.utils import Frame, PatchedLocal, PrefProxy, get_field_for_proxy, get_pref_model_class, \ 8 | get_pref_model_admin_class, get_frame_locals, import_module, PREFS_MODULE_NAME 9 | 10 | 11 | def test_many(): 12 | __package__ = 'siteprefs.tests.testapp' 13 | 14 | autodiscover_siteprefs() 15 | 16 | prefs = list(Preference.objects.all()) 17 | assert len(prefs) == 1 18 | 19 | pref = prefs[0] 20 | assert pref.name == 'my_option_2' 21 | assert pref.text == 'Some value' 22 | 23 | prefs = get_app_prefs('testapp') 24 | assert len(prefs) == 3 25 | 26 | prefs = get_app_prefs('buggy') 27 | assert len(prefs) == 0 28 | 29 | def update_option_2(new_value): 30 | # Test update prefs triggering. 31 | model_cls = get_prefs_models()['testapp'] 32 | model = model_cls() 33 | model.my_option_2 = new_value 34 | model.save() 35 | 36 | update_option_2('other_value') 37 | 38 | pref = list(Preference.objects.all())[0] 39 | assert pref.text == 'other_value' 40 | assert '%s' % pref == 'testapp:my_option_2' 41 | 42 | from siteprefs.tests.testapp import testmodule 43 | 44 | assert testmodule.read_option_2() == 'other_value' 45 | assert testmodule.read_not_an_option() == 'not-an-option' 46 | 47 | update_option_2(44) 48 | 49 | assert testmodule.read_option_2() == '44' 50 | 51 | 52 | def test_admin(): 53 | from siteprefs.admin import PreferenceAdmin 54 | return PreferenceAdmin # not too loose unused import 55 | 56 | 57 | class TestUtils: 58 | 59 | def test_frame(self): 60 | with Frame() as f: 61 | assert f.f_locals['self'] is self 62 | 63 | def test_patched_local(self): 64 | pl = PatchedLocal('k', 'v') 65 | assert pl.key == 'k' 66 | assert pl.val == 'v' 67 | 68 | def test_pref_poxy(self): 69 | pp = PrefProxy('proxy_name', 'default') 70 | assert pp.name == 'proxy_name' 71 | assert pp.default == 'default' 72 | assert pp.category is None 73 | assert isinstance(pp.field, models.TextField) 74 | assert pp.verbose_name == 'Proxy name' 75 | assert pp.help_text == '' 76 | assert pp.static 77 | assert pp.readonly 78 | assert pp.get_value() == 'default' 79 | 80 | pp = PrefProxy( 81 | 'proxy_name_2', 2, 82 | category='cat', 83 | field=models.IntegerField(), 84 | verbose_name='verbose name', 85 | help_text='help text', 86 | static=False, 87 | readonly=True) 88 | 89 | assert pp.name == 'proxy_name_2' 90 | assert pp.default == 2 91 | assert pp.category == 'cat' 92 | assert isinstance(pp.field, models.IntegerField) 93 | assert pp.verbose_name == 'verbose name' 94 | assert pp.help_text == 'help text' 95 | assert not pp.static 96 | assert pp.readonly 97 | assert pp.get_value() == 2 98 | 99 | pp.db_value = 42 100 | assert pp.get_value() == 42 101 | assert pp() == 42 102 | assert pp() < 43 103 | assert pp() > 41 104 | 105 | pp = PrefProxy('proxy_name_3', 10, static=False) 106 | assert not pp.readonly 107 | 108 | def test_get_field_for_proxy(self): 109 | pp = PrefProxy('proxy_name', 10) 110 | assert isinstance(get_field_for_proxy(pp), models.IntegerField) 111 | 112 | pp = PrefProxy('proxy_name', True) 113 | assert isinstance(get_field_for_proxy(pp), models.BooleanField) 114 | 115 | pp = PrefProxy('proxy_name', 13.4) 116 | assert isinstance(get_field_for_proxy(pp), models.FloatField) 117 | 118 | pp = PrefProxy('proxy_name', 'abc') 119 | assert isinstance(get_field_for_proxy(pp), models.TextField) 120 | 121 | def test_get_pref_model_class(self): 122 | p1 = PrefProxy('pp1', 10) 123 | p2 = PrefProxy('pp2', 20) 124 | p3 = PrefProxy('pp3', 'admin', field=models.CharField(max_length=10)) 125 | p4 = PrefProxy('pp4', 'another', verbose_name='verbosed', static=False, field=models.CharField(max_length=10)) 126 | 127 | my_prefs_func = lambda: 'yes' 128 | 129 | cl = get_pref_model_class('testapp', {'pp1': p1, 'pp2': p2, 'pp3': p3, 'pp4': p4}, my_prefs_func) 130 | 131 | model = cl() 132 | 133 | assert issubclass(cl, models.Model) 134 | assert isinstance(model._meta.fields[3], models.CharField) 135 | assert model._meta.fields[4].verbose_name == 'verbosed' 136 | 137 | def test_get_pref_model_admin_class(self): 138 | 139 | p1 = PrefProxy('pp1', 10) 140 | p2 = PrefProxy('pp2', 20) 141 | 142 | cl = get_pref_model_admin_class({'pp1': p1, 'pp2': p2}) 143 | assert issubclass(cl, admin.ModelAdmin) 144 | 145 | def test_get_frame_locals(self): 146 | a = 'a' 147 | b = 'b' 148 | local = get_frame_locals(1) 149 | assert 'a' in local 150 | assert 'b' in local 151 | 152 | @pytest.mark.xfail(strict=True) 153 | def test_import_module(self): 154 | import_module('dummy', PREFS_MODULE_NAME) 155 | -------------------------------------------------------------------------------- /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 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 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 " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-siteprefs.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-siteprefs.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-siteprefs" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-siteprefs" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-siteprefs documentation build configuration file. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import sys, os 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | sys.path.insert(0, os.path.abspath('../../')) 19 | from siteprefs import VERSION 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.coverage'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'django-siteprefs' 44 | copyright = u'2013-2022, Igor \'idle sign\' Starikov' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = '.'.join(map(str, VERSION)) 52 | # The full version, including alpha/beta/rc tags. 53 | release = '.'.join(map(str, VERSION)) 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | #language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | #today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | #today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = [] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | html_theme = 'default' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_domain_indices = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 153 | #html_show_sphinx = True 154 | 155 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 156 | #html_show_copyright = True 157 | 158 | # If true, an OpenSearch description file will be output, and all pages will 159 | # contain a tag referring to it. The value of this option must be the 160 | # base URL from which the finished HTML is served. 161 | #html_use_opensearch = '' 162 | 163 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 164 | #html_file_suffix = None 165 | 166 | # Output file base name for HTML help builder. 167 | htmlhelp_basename = 'django-siteprefsdoc' 168 | 169 | 170 | # -- Options for LaTeX output -------------------------------------------------- 171 | 172 | latex_elements = { 173 | # The paper size ('letterpaper' or 'a4paper'). 174 | #'papersize': 'letterpaper', 175 | 176 | # The font size ('10pt', '11pt' or '12pt'). 177 | #'pointsize': '10pt', 178 | 179 | # Additional stuff for the LaTeX preamble. 180 | #'preamble': '', 181 | } 182 | 183 | # Grouping the document tree into LaTeX files. List of tuples 184 | # (source start file, target name, title, author, documentclass [howto/manual]). 185 | latex_documents = [ 186 | ('index', 'django-siteprefs.tex', u'django-siteprefs Documentation', 187 | u'Igor \'idle sign\' Starikov', 'manual'), 188 | ] 189 | 190 | # The name of an image file (relative to this directory) to place at the top of 191 | # the title page. 192 | #latex_logo = None 193 | 194 | # For "manual" documents, if this is true, then toplevel headings are parts, 195 | # not chapters. 196 | #latex_use_parts = False 197 | 198 | # If true, show page references after internal links. 199 | #latex_show_pagerefs = False 200 | 201 | # If true, show URL addresses after external links. 202 | #latex_show_urls = False 203 | 204 | # Documents to append as an appendix to all manuals. 205 | #latex_appendices = [] 206 | 207 | # If false, no module index is generated. 208 | #latex_domain_indices = True 209 | 210 | 211 | # -- Options for manual page output -------------------------------------------- 212 | 213 | # One entry per manual page. List of tuples 214 | # (source start file, name, description, authors, manual section). 215 | man_pages = [ 216 | ('index', 'django-siteprefs', u'django-siteprefs Documentation', 217 | [u'Igor \'idle sign\' Starikov'], 1) 218 | ] 219 | 220 | # If true, show URL addresses after external links. 221 | #man_show_urls = False 222 | 223 | 224 | # -- Options for Texinfo output ------------------------------------------------ 225 | 226 | # Grouping the document tree into Texinfo files. List of tuples 227 | # (source start file, target name, title, author, 228 | # dir menu entry, description, category) 229 | texinfo_documents = [ 230 | ('index', 'django-siteprefs', u'django-siteprefs Documentation', 231 | u'Igor \'idle sign\' Starikov', 'django-siteprefs', 'One line description of project.', 232 | 'Miscellaneous'), 233 | ] 234 | 235 | # Documents to append as an appendix to all manuals. 236 | #texinfo_appendices = [] 237 | 238 | # If false, no module index is generated. 239 | #texinfo_domain_indices = True 240 | 241 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 242 | #texinfo_show_urls = 'footnote' 243 | -------------------------------------------------------------------------------- /siteprefs/utils.py: -------------------------------------------------------------------------------- 1 | 2 | import inspect 3 | import os 4 | from collections import OrderedDict 5 | from datetime import datetime 6 | from typing import Any, Callable, Type, Generator, Tuple 7 | from warnings import warn 8 | 9 | from django.contrib import admin 10 | from django.db import models 11 | from django.utils.translation import gettext_lazy as _ 12 | from etc.toolbox import import_app_module, import_project_modules 13 | 14 | from .settings import PREFS_MODULE_NAME 15 | from .signals import prefs_save 16 | 17 | 18 | class Frame: 19 | """Represents a frame object at a definite level of hierarchy. 20 | 21 | To be used as context manager: 22 | 23 | with Frame as f: 24 | ... 25 | 26 | """ 27 | 28 | def __init__(self, stepback: int = 0): 29 | self.depth = stepback 30 | 31 | def __enter__(self): 32 | frame = inspect.currentframe().f_back 33 | 34 | for __ in range(self.depth): 35 | frame = frame.f_back 36 | 37 | self.frame = frame 38 | 39 | return self.frame 40 | 41 | def __exit__(self, exc_type, exc_val, exc_tb): 42 | del self.frame 43 | 44 | 45 | class PatchedLocal: 46 | """Object of this class temporarily replace all module variables 47 | considered preferences. 48 | 49 | """ 50 | def __init__(self, key: str, val: Any): 51 | self.key = key 52 | self.val = val 53 | 54 | 55 | class Mimic: 56 | """Mimics other types by implementation of various special methods. 57 | 58 | This one is deprecated if favor of setting module proxying (proxy_settings_module()). 59 | 60 | """ 61 | 62 | value: Any = None 63 | 64 | def __call__(self, *args, **kwargs): 65 | return self.value 66 | 67 | def __str__(self): 68 | return self.value.__str__() 69 | 70 | def __bool__(self): 71 | return bool(self.value) 72 | 73 | def __int__(self): 74 | return int(self.value) 75 | 76 | def __float__(self): 77 | return float(self.value) 78 | 79 | def __len__(self): 80 | return self.value.__len__() 81 | 82 | def __contains__(self, item): 83 | return self.value.__contains__(item) 84 | 85 | def __sub__(self, other): 86 | return self.value.__sub__(other) 87 | 88 | def __rsub__(self, other): 89 | return self.value.__rsub__(other) 90 | 91 | def __add__(self, other): 92 | return self.value.__add__(other) 93 | 94 | def __radd__(self, other): 95 | return self.value.__radd__(other) 96 | 97 | def __mul__(self, other): 98 | return self.value.__mul__(other) 99 | 100 | def __rmul__(self, other): 101 | return self.value.__rmul__(other) 102 | 103 | def __lt__(self, other): 104 | return self.value.__lt__(other) 105 | 106 | def __le__(self, other): 107 | return self.value.__le__(other) 108 | 109 | def __gt__(self, other): 110 | return self.value.__gt__(other) 111 | 112 | def __ge__(self, other): 113 | return self.value.__ge__(other) 114 | 115 | def __eq__(self, other): 116 | return self.value.__eq__(other) 117 | 118 | def __ne__(self, other): 119 | return self.value.__ne__(other) 120 | 121 | 122 | class PrefProxy(Mimic): 123 | """Objects of this class replace app preferences.""" 124 | 125 | def __init__( 126 | self, 127 | name: str, 128 | default: Any, 129 | category: str = None, 130 | field: models.Field = None, 131 | verbose_name: str = None, 132 | help_text: str = '', 133 | static: bool = True, 134 | readonly: bool = False 135 | ): 136 | """ 137 | 138 | :param name: Preference name. 139 | 140 | :param default: Default (initial) value. 141 | 142 | :param category: Category name the preference belongs to. 143 | 144 | :param field: Django model field to represent this preference. 145 | 146 | :param verbose_name: Field verbose name. 147 | 148 | :param help_text: Field help text. 149 | 150 | :param static: Leave this preference static (do not store in DB). 151 | 152 | :param readonly: Make this field read only. 153 | 154 | """ 155 | self.name = name 156 | self.category = category 157 | self.default = default 158 | self.static = static 159 | self.help_text = help_text 160 | 161 | if static: 162 | readonly = True 163 | 164 | self.readonly = readonly 165 | 166 | if verbose_name is None: 167 | verbose_name = name.replace('_', ' ').capitalize() 168 | 169 | self.verbose_name = verbose_name 170 | 171 | if field is None: 172 | self.field = get_field_for_proxy(self) 173 | 174 | else: 175 | self.field = field 176 | update_field_from_proxy(self.field, self) 177 | 178 | @property 179 | def value(self) -> Any: 180 | 181 | if self.static: 182 | val = self.default 183 | 184 | else: 185 | try: 186 | val = getattr(self, 'db_value') 187 | 188 | except AttributeError: 189 | val = self.default 190 | 191 | return self.field.to_python(val) 192 | 193 | def get_value(self) -> Any: 194 | warn('Please use .value instead .get_value().', DeprecationWarning, stacklevel=2) 195 | return self.value 196 | 197 | def __repr__(self): 198 | return f'{self.name} = {self.value}' 199 | 200 | 201 | def get_field_for_proxy(pref_proxy: PrefProxy) -> models.Field: 202 | """Returns a field object instance for a given PrefProxy object. 203 | 204 | :param pref_proxy: 205 | 206 | """ 207 | field = { 208 | bool: models.BooleanField, 209 | int: models.IntegerField, 210 | float: models.FloatField, 211 | datetime: models.DateTimeField, 212 | 213 | }.get(type(pref_proxy.default), models.TextField)() 214 | 215 | update_field_from_proxy(field, pref_proxy) 216 | 217 | return field 218 | 219 | 220 | def update_field_from_proxy(field_obj: models.Field, pref_proxy: PrefProxy): 221 | """Updates field object with data from a PrefProxy object. 222 | 223 | :param field_obj: 224 | 225 | :param pref_proxy: 226 | 227 | """ 228 | attr_names = ('verbose_name', 'help_text', 'default') 229 | 230 | for attr_name in attr_names: 231 | setattr(field_obj, attr_name, getattr(pref_proxy, attr_name)) 232 | 233 | 234 | def get_pref_model_class(app: str, prefs: dict, get_prefs_func: Callable) -> Type[models.Model]: 235 | """Returns preferences model class dynamically crated for a given app or None on conflict.""" 236 | 237 | module = f'{app}.{PREFS_MODULE_NAME}' 238 | 239 | model_dict = { 240 | '_prefs_app': app, 241 | '_get_prefs': staticmethod(get_prefs_func), 242 | '__module__': module, 243 | 'Meta': type('Meta', (models.options.Options,), { 244 | 'verbose_name': _('Preference'), 245 | 'verbose_name_plural': _('Preferences'), 246 | 'app_label': app, 247 | 'managed': False, 248 | }) 249 | } 250 | 251 | for field_name, val_proxy in prefs.items(): 252 | model_dict[field_name] = val_proxy.field 253 | 254 | model = type('Preferences', (models.Model,), model_dict) 255 | 256 | def fake_save_base(self, *args, **kwargs): 257 | 258 | updated_prefs = { 259 | f.name: getattr(self, f.name) 260 | for f in self._meta.fields 261 | if not isinstance(f, models.fields.AutoField) 262 | } 263 | 264 | app_prefs = self._get_prefs(self._prefs_app) 265 | 266 | for pref in app_prefs.keys(): 267 | if pref in updated_prefs: 268 | app_prefs[pref].db_value = updated_prefs[pref] 269 | 270 | self.pk = self._prefs_app # Make Django 1.7 happy. 271 | prefs_save.send(sender=self, app=self._prefs_app, updated_prefs=updated_prefs) 272 | 273 | return True 274 | 275 | model.save_base = fake_save_base 276 | 277 | return model 278 | 279 | 280 | def get_pref_model_admin_class(prefs: dict) -> Type[admin.ModelAdmin]: 281 | 282 | by_category = OrderedDict() 283 | readonly_fields = [] 284 | 285 | for field_name, val_proxy in prefs.items(): 286 | 287 | if val_proxy.readonly: 288 | readonly_fields.append(field_name) 289 | 290 | if val_proxy.category not in by_category: 291 | by_category[val_proxy.category] = [] 292 | 293 | by_category[val_proxy.category].append(field_name) 294 | 295 | cl_model_admin_dict = { 296 | 'has_add_permission': lambda *args: False, 297 | 'has_delete_permission': lambda *args: False 298 | } 299 | 300 | if readonly_fields: 301 | cl_model_admin_dict['readonly_fields'] = readonly_fields 302 | 303 | fieldsets = [] 304 | 305 | for category, cat_prefs in by_category.items(): 306 | fieldsets.append((category, {'fields': cat_prefs})) 307 | 308 | if fieldsets: 309 | cl_model_admin_dict['fieldsets'] = fieldsets 310 | 311 | model = type('PreferencesAdmin', (admin.ModelAdmin,), cl_model_admin_dict) 312 | 313 | model.changelist_view = lambda self, request, **kwargs: self.change_view(request, '', **kwargs) 314 | 315 | model.get_object = lambda self, *args: ( 316 | self.model( 317 | **{ 318 | field_name: val_proxy.get_value() for field_name, val_proxy in 319 | self.model._get_prefs(self.model._prefs_app).items() 320 | } 321 | ) 322 | ) 323 | 324 | return model 325 | 326 | 327 | def get_frame_locals(stepback: int = 0) -> dict: 328 | """Returns locals dictionary from a given frame. 329 | 330 | :param stepback: 331 | 332 | """ 333 | with Frame(stepback=stepback) as frame: 334 | locals_dict = frame.f_locals 335 | 336 | return locals_dict 337 | 338 | 339 | def traverse_local_prefs(stepback: int = 0) -> Generator[Tuple[str, dict], None, None]: 340 | """Generator to walk through variables considered as preferences 341 | in locals dict of a given frame. 342 | 343 | :param stepback: 344 | 345 | """ 346 | locals_dict = get_frame_locals(stepback+1) 347 | for k in locals_dict: 348 | if not k.startswith('_') and k.upper() == k: 349 | yield k, locals_dict 350 | 351 | 352 | def import_module(package: str, module_name: str): 353 | """Imports a module from a given package. 354 | 355 | :param package: 356 | :param module_name: 357 | 358 | """ 359 | import_app_module(package, module_name) 360 | 361 | 362 | def import_prefs(): 363 | """Imports preferences modules from packages (apps) and project root.""" 364 | 365 | # settings.py locals if autodiscover_siteprefs() is in urls.py 366 | settings_locals = get_frame_locals(3) 367 | 368 | if 'self' not in settings_locals: # If not SiteprefsConfig.ready() 369 | # Try to import project-wide prefs. 370 | 371 | project_package = settings_locals['__package__'] # Expected project layout introduced in Django 1.4 372 | if not project_package: 373 | # Fallback to old layout. 374 | project_package = os.path.split(os.path.dirname(settings_locals['__file__']))[-1] 375 | 376 | import_module(project_package, PREFS_MODULE_NAME) 377 | 378 | import_project_modules(PREFS_MODULE_NAME) 379 | -------------------------------------------------------------------------------- /siteprefs/toolbox.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from collections import OrderedDict 4 | from types import ModuleType 5 | from typing import Dict, Union, List, Tuple, Any, Optional 6 | 7 | from django.contrib import admin 8 | from django.contrib.admin import AdminSite 9 | from django.db import DatabaseError 10 | from django.db.models import Model, Field 11 | 12 | from .exceptions import SitePrefsException 13 | from .models import Preference 14 | from .signals import prefs_save 15 | from .utils import import_prefs, get_frame_locals, traverse_local_prefs, get_pref_model_admin_class, \ 16 | get_pref_model_class, PrefProxy, PatchedLocal, Frame 17 | 18 | __PATCHED_LOCALS_SENTINEL = '__siteprefs_locals_patched' 19 | 20 | __PREFS_REGISTRY = None 21 | __PREFS_DEFAULT_REGISTRY = OrderedDict() 22 | __MODELS_REGISTRY = {} 23 | 24 | LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | def on_pref_update(*args, **kwargs): 28 | """Triggered on dynamic preferences model save. 29 | Issues DB save and reread. 30 | 31 | """ 32 | Preference.update_prefs(*args, **kwargs) 33 | Preference.read_prefs(get_prefs()) 34 | 35 | prefs_save.connect(on_pref_update) 36 | 37 | 38 | def get_prefs() -> dict: 39 | """Returns a dictionary with all preferences discovered by siteprefs.""" 40 | global __PREFS_REGISTRY 41 | 42 | if __PREFS_REGISTRY is None: 43 | __PREFS_REGISTRY = __PREFS_DEFAULT_REGISTRY 44 | 45 | return __PREFS_REGISTRY 46 | 47 | 48 | def get_app_prefs(app: str = None) -> dict: 49 | """Returns a dictionary with preferences for a certain app/module. 50 | 51 | :param app: 52 | 53 | """ 54 | if app is None: 55 | 56 | with Frame(stepback=1) as frame: 57 | app = frame.f_globals['__name__'].split('.')[0] 58 | 59 | prefs = get_prefs() 60 | 61 | if app not in prefs: 62 | return {} 63 | 64 | return prefs[app] 65 | 66 | 67 | def get_prefs_models() -> Dict[str, Model]: 68 | """Returns registered preferences models indexed by application names.""" 69 | return __MODELS_REGISTRY 70 | 71 | 72 | def bind_proxy( 73 | values: Union[List, Tuple], 74 | category: str = None, 75 | field: Field = None, 76 | verbose_name: str = None, 77 | help_text: str = '', 78 | static: bool = True, 79 | readonly: bool = False 80 | ) -> List[PrefProxy]: 81 | """Binds PrefProxy objects to module variables used by apps as preferences. 82 | 83 | :param values: Preference values. 84 | 85 | :param category: Category name the preference belongs to. 86 | 87 | :param field: Django model field to represent this preference. 88 | 89 | :param verbose_name: Field verbose name. 90 | 91 | :param help_text: Field help text. 92 | 93 | :param static: Leave this preference static (do not store in DB). 94 | 95 | :param readonly: Make this field read only. 96 | 97 | """ 98 | addrs = OrderedDict() 99 | 100 | depth = 3 101 | 102 | for local_name, locals_dict in traverse_local_prefs(depth): 103 | addrs[id(locals_dict[local_name])] = local_name 104 | 105 | proxies = [] 106 | locals_dict = get_frame_locals(depth) 107 | 108 | for value in values: # Try to preserve fields order. 109 | 110 | id_val = id(value) 111 | 112 | if id_val in addrs: 113 | local_name = addrs[id_val] 114 | local_val = locals_dict[local_name] 115 | 116 | if isinstance(local_val, PatchedLocal) and not isinstance(local_val, PrefProxy): 117 | 118 | proxy = PrefProxy( 119 | local_name, value.val, 120 | category=category, 121 | field=field, 122 | verbose_name=verbose_name, 123 | help_text=help_text, 124 | static=static, 125 | readonly=readonly, 126 | ) 127 | 128 | app_name = locals_dict['__name__'].split('.')[-2] # x.y.settings -> y 129 | prefs = get_prefs() 130 | 131 | if app_name not in prefs: 132 | prefs[app_name] = OrderedDict() 133 | 134 | prefs[app_name][local_name.lower()] = proxy 135 | 136 | # Replace original pref variable with a proxy. 137 | locals_dict[local_name] = proxy 138 | proxies.append(proxy) 139 | 140 | return proxies 141 | 142 | 143 | def register_admin_models(admin_site: AdminSite): 144 | """Registers dynamically created preferences models for Admin interface. 145 | 146 | :param admin_site: AdminSite object. 147 | 148 | """ 149 | global __MODELS_REGISTRY 150 | 151 | prefs = get_prefs() 152 | 153 | for app_label, prefs_items in prefs.items(): 154 | 155 | model_class = get_pref_model_class(app_label, prefs_items, get_app_prefs) 156 | 157 | if model_class is not None: 158 | __MODELS_REGISTRY[app_label] = model_class 159 | admin_site.register(model_class, get_pref_model_admin_class(prefs_items)) 160 | 161 | 162 | def autodiscover_siteprefs(admin_site: AdminSite = None): 163 | """Automatically discovers and registers all preferences available in all apps. 164 | 165 | :param admin_site: Custom AdminSite object. 166 | 167 | """ 168 | import_prefs() 169 | 170 | try: 171 | Preference.read_prefs(get_prefs()) 172 | 173 | except DatabaseError: 174 | # This may occur if run from manage.py (or its wrapper) when db is not yet initialized. 175 | LOGGER.warning('Unable to read preferences from database. Skip.') 176 | 177 | else: 178 | if admin_site is None: 179 | admin_site = admin.site 180 | 181 | register_admin_models(admin_site) 182 | 183 | 184 | def patch_locals(depth: int = 2): 185 | """Temporarily (see unpatch_locals()) replaces all module variables 186 | considered preferences with PatchedLocal objects, so that every 187 | variable has different hash returned by id(). 188 | 189 | """ 190 | for name, locals_dict in traverse_local_prefs(depth): 191 | locals_dict[name] = PatchedLocal(name, locals_dict[name]) 192 | 193 | get_frame_locals(depth)[__PATCHED_LOCALS_SENTINEL] = True # Sentinel. 194 | 195 | 196 | def unpatch_locals(depth: int = 3): 197 | """Restores the original values of module variables 198 | considered preferences if they are still PatchedLocal 199 | and not PrefProxy. 200 | 201 | """ 202 | for name, locals_dict in traverse_local_prefs(depth): 203 | if isinstance(locals_dict[name], PatchedLocal): 204 | locals_dict[name] = locals_dict[name].val 205 | 206 | del get_frame_locals(depth)[__PATCHED_LOCALS_SENTINEL] 207 | 208 | 209 | class ModuleProxy: 210 | """Proxy to handle module attributes access.""" 211 | 212 | def __init__(self): 213 | self._module: Optional[ModuleType] = None 214 | self._prefs = [] 215 | 216 | def bind(self, module: ModuleType, prefs: List[str]): 217 | """ 218 | :param module: 219 | :param prefs: Preference names. Just to speed up __getattr__. 220 | 221 | """ 222 | self._module = module 223 | self._prefs = set(prefs) 224 | 225 | def __getattr__(self, name: str) -> Any: 226 | 227 | value = getattr(self._module, name) 228 | 229 | if name in self._prefs: 230 | # It is a PrefProxy 231 | value = value.value 232 | 233 | return value 234 | 235 | 236 | def proxy_settings_module(depth: int = 3): 237 | """Replaces a settings module with a Module proxy to intercept 238 | an access to settings. 239 | 240 | :param depth: Frame count to go backward. 241 | 242 | """ 243 | proxies = [] 244 | 245 | modules = sys.modules 246 | module_name = get_frame_locals(depth)['__name__'] 247 | 248 | module_real = modules[module_name] 249 | 250 | for name, locals_dict in traverse_local_prefs(depth): 251 | 252 | value = locals_dict[name] 253 | 254 | if isinstance(value, PrefProxy): 255 | proxies.append(name) 256 | 257 | new_module = type(module_name, (ModuleType, ModuleProxy), {})(module_name) # ModuleProxy 258 | new_module.bind(module_real, proxies) 259 | 260 | modules[module_name] = new_module 261 | 262 | 263 | def register_prefs(*args: PrefProxy, **kwargs): 264 | """Registers preferences that should be handled by siteprefs. 265 | 266 | Expects preferences as *args. 267 | 268 | Use keyword arguments to batch apply params supported by 269 | ``PrefProxy`` to all preferences not constructed by ``pref`` and ``pref_group``. 270 | 271 | Batch kwargs: 272 | 273 | :param str help_text: Field help text. 274 | 275 | :param bool static: Leave this preference static (do not store in DB). 276 | 277 | :param bool readonly: Make this field read only. 278 | 279 | :param bool swap_settings_module: Whether to automatically replace settings module 280 | with a special ``ProxyModule`` object to access dynamic values of settings 281 | transparently (so not to bother with calling ``.value`` of ``PrefProxy`` object). 282 | 283 | """ 284 | swap_settings_module = bool(kwargs.get('swap_settings_module', True)) 285 | 286 | if __PATCHED_LOCALS_SENTINEL not in get_frame_locals(2): 287 | raise SitePrefsException('Please call `patch_locals()` right before the `register_prefs()`.') 288 | 289 | bind_proxy(args, **kwargs) 290 | 291 | unpatch_locals() 292 | 293 | swap_settings_module and proxy_settings_module() 294 | 295 | 296 | def pref_group( 297 | title: str, 298 | prefs: Union[List, Tuple], 299 | help_text: str = '', 300 | static: bool = True, 301 | readonly: bool = False 302 | ): 303 | """Marks preferences group. 304 | 305 | :param title: Group title 306 | 307 | :param prefs: Preferences to group. 308 | 309 | :param help_text: Field help text. 310 | 311 | :param static: Leave this preference static (do not store in DB). 312 | 313 | :param readonly: Make this field read only. 314 | 315 | """ 316 | bind_proxy(prefs, title, help_text=help_text, static=static, readonly=readonly) 317 | 318 | for proxy in prefs: # For preferences already marked by pref(). 319 | if isinstance(proxy, PrefProxy): 320 | proxy.category = title 321 | 322 | 323 | def pref( 324 | preference: Any, 325 | field: Field = None, 326 | verbose_name: str = None, 327 | help_text: str = '', 328 | static: bool = True, 329 | readonly: bool = False 330 | ) -> Optional[PrefProxy]: 331 | """Marks a preference. 332 | 333 | :param preference: Preference variable. 334 | 335 | :param field: Django model field to represent this preference. 336 | 337 | :param verbose_name: Field verbose name. 338 | 339 | :param help_text: Field help text. 340 | 341 | :param static: Leave this preference static (do not store in DB). 342 | 343 | :param readonly: Make this field read only. 344 | 345 | """ 346 | try: 347 | bound = bind_proxy( 348 | (preference,), 349 | field=field, 350 | verbose_name=verbose_name, 351 | help_text=help_text, 352 | static=static, 353 | readonly=readonly, 354 | ) 355 | return bound[0] 356 | 357 | except IndexError: 358 | return 359 | 360 | 361 | class preferences: 362 | """Context manager - main entry point for siteprefs. 363 | 364 | .. code-block:: python 365 | 366 | from siteprefs.toolbox import preferences 367 | 368 | with preferences() as prefs: 369 | 370 | prefs( 371 | MY_OPTION_1, 372 | prefs.one(MY_OPTION_2, static=False), 373 | prefs.group('My Group', [prefs.one(MY_OPTION_42)]), 374 | ) 375 | 376 | """ 377 | 378 | one = staticmethod(pref) 379 | group = staticmethod(pref_group) 380 | 381 | __call__ = register_prefs 382 | 383 | def __enter__(self): 384 | patch_locals(3) 385 | return self 386 | 387 | def __exit__(self, exc_type, exc_val, exc_tb): 388 | pass 389 | --------------------------------------------------------------------------------