Compare commits

..

No commits in common. "master" and "2.3.0" have entirely different histories.

105 changed files with 2041 additions and 4669 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

3
.gitignore vendored
View file

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

View file

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

View file

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

22
.travis.yml Normal file
View file

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

8
.tx/config Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,176 +0,0 @@
from django.core.cache import caches
from django.core.cache.backends.locmem import LocMemCache
from django.core.exceptions import ImproperlyConfigured
from django.db import IntegrityError
from django.db import OperationalError
from django.db import ProgrammingError
from django.db import transaction
from django.db.models.signals import post_save
from constance import config
from constance import settings
from constance import signals
from constance.backends import Backend
from constance.codecs import dumps
from constance.codecs import loads
class DatabaseBackend(Backend):
def __init__(self):
from constance.models import Constance
self._model = Constance
self._prefix = settings.DATABASE_PREFIX
self._autofill_timeout = settings.DATABASE_CACHE_AUTOFILL_TIMEOUT
self._autofill_cachekey = "autofilled"
if self._model._meta.app_config is None:
raise ImproperlyConfigured(
"The constance.backends.database app isn't installed "
"correctly. Make sure it's in your INSTALLED_APPS setting."
)
if settings.DATABASE_CACHE_BACKEND:
self._cache = caches[settings.DATABASE_CACHE_BACKEND]
if isinstance(self._cache, LocMemCache):
raise ImproperlyConfigured(
"The CONSTANCE_DATABASE_CACHE_BACKEND setting refers to a "
f"subclass of Django's local-memory backend ({settings.DATABASE_CACHE_BACKEND!r}). Please "
"set it to a backend that supports cross-process caching."
)
else:
self._cache = None
self.autofill()
# Clear simple cache.
post_save.connect(self.clear, sender=self._model)
def add_prefix(self, key):
return f"{self._prefix}{key}"
def autofill(self):
if not self._autofill_timeout or not self._cache:
return
full_cachekey = self.add_prefix(self._autofill_cachekey)
if self._cache.get(full_cachekey):
return
autofill_values = {full_cachekey: 1}
for key, value in self.mget(settings.CONFIG).items():
autofill_values[self.add_prefix(key)] = value
self._cache.set_many(autofill_values, timeout=self._autofill_timeout)
def mget(self, keys):
result = {}
if not keys:
return result
keys = {self.add_prefix(key): key for key in keys}
try:
stored = self._model._default_manager.filter(key__in=keys)
for const in stored:
result[keys[const.key]] = loads(const.value)
except (OperationalError, ProgrammingError):
pass
return result
def get(self, key):
key = self.add_prefix(key)
value = None
if self._cache:
value = self._cache.get(key)
if value is None:
self.autofill()
value = self._cache.get(key)
if value is None:
match = self._model._default_manager.filter(key=key).only("value").first()
if match:
value = loads(match.value)
if self._cache:
self._cache.add(key, value)
return value
async def aget(self, key):
from asgiref.sync import sync_to_async
prefixed_key = self.add_prefix(key)
value = None
if self._cache:
value = await self._cache.aget(prefixed_key)
if value is None:
await sync_to_async(self.autofill, thread_sensitive=True)()
value = await self._cache.aget(prefixed_key)
if value is None:
match = await self._model._default_manager.filter(key=prefixed_key).only("value").afirst()
if match:
value = loads(match.value)
if self._cache:
await self._cache.aadd(prefixed_key, value)
return value
async def amget(self, keys):
if not keys:
return {}
prefixed_keys_map = {self.add_prefix(key): key for key in keys}
results = {}
if self._cache:
cache_results = await self._cache.aget_many(prefixed_keys_map.keys())
for prefixed_key, value in cache_results.items():
results[prefixed_keys_map[prefixed_key]] = value
missing_prefixed_keys = [k for k in prefixed_keys_map if prefixed_keys_map[k] not in results]
if missing_prefixed_keys:
try:
async for const in self._model._default_manager.filter(key__in=missing_prefixed_keys):
results[prefixed_keys_map[const.key]] = loads(const.value)
except (OperationalError, ProgrammingError):
pass
return results
def set(self, key, value):
key = self.add_prefix(key)
created = False
queryset = self._model._default_manager.all()
# Set _for_write attribute as get_or_create method does
# https://github.com/django/django/blob/2.2.11/django/db/models/query.py#L536
queryset._for_write = True
try:
constance = queryset.get(key=key)
except (OperationalError, ProgrammingError):
# database is not created, noop
return
except self._model.DoesNotExist:
try:
with transaction.atomic(using=queryset.db):
queryset.create(key=key, value=dumps(value))
created = True
except IntegrityError:
# Allow concurrent writes
constance = queryset.get(key=key)
if not created:
old_value = loads(constance.value)
constance.value = dumps(value)
constance.save(update_fields=["value"])
else:
old_value = None
if self._cache:
self._cache.set(key, value)
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
async def aset(self, key, value):
from asgiref.sync import sync_to_async
# We use sync_to_async because Django's transaction.atomic() and database connections are thread-local.
# This ensures the operation runs in the correct database thread until native async transactions are supported.
return await sync_to_async(self.set, thread_sensitive=True)(key, value)
def clear(self, sender, instance, created, **kwargs):
if self._cache and not created:
keys = [self.add_prefix(k) for k in settings.CONFIG]
keys.append(self.add_prefix(self._autofill_cachekey))
self._cache.delete_many(keys)
self.autofill()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,46 +0,0 @@
from threading import Lock
from constance import config
from constance import signals
from . import Backend
class MemoryBackend(Backend):
"""Simple in-memory backend that should be mostly used for testing purposes."""
_storage = {}
_lock = Lock()
def __init__(self):
super().__init__()
def get(self, key):
with self._lock:
return self._storage.get(key)
async def aget(self, key):
# Memory operations are fast enough that we don't need true async here
return self.get(key)
def mget(self, keys):
if not keys:
return {}
with self._lock:
return {key: self._storage[key] for key in keys if key in self._storage}
async def amget(self, keys):
if not keys:
return {}
with self._lock:
return {key: self._storage[key] for key in keys if key in self._storage}
def set(self, key, value):
with self._lock:
old_value = self._storage.get(key)
self._storage[key] = value
signals.config_updated.send(sender=config, key=key, old_value=old_value, new_value=value)
async def aset(self, key, value):
# Memory operations are fast enough that we don't need true async here
self.set(key, value)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,68 +0,0 @@
import json
import logging
import pickle
from base64 import b64decode
from importlib import import_module
from django.db import migrations
from constance import settings
from constance.codecs import dumps
logger = logging.getLogger(__name__)
def is_already_migrated(value):
try:
data = json.loads(value)
if isinstance(data, dict) and set(data.keys()) == {"__type__", "__value__"}:
return True
except (json.JSONDecodeError, TypeError, UnicodeDecodeError):
return False
return False
def import_module_attr(path):
package, module = path.rsplit(".", 1)
return getattr(import_module(package), module)
def migrate_pickled_data(apps, schema_editor) -> None: # pragma: no cover
Constance = apps.get_model("constance", "Constance")
for constance in Constance.objects.exclude(value=None):
if not is_already_migrated(constance.value):
constance.value = dumps(pickle.loads(b64decode(constance.value.encode()))) # noqa: S301
constance.save(update_fields=["value"])
if settings.BACKEND in (
"constance.backends.redisd.RedisBackend",
"constance.backends.redisd.CachingRedisBackend",
):
import redis
_prefix = settings.REDIS_PREFIX
connection_cls = settings.REDIS_CONNECTION_CLASS
if connection_cls is not None:
_rd = import_module_attr(connection_cls)()
else:
if isinstance(settings.REDIS_CONNECTION, str):
_rd = redis.from_url(settings.REDIS_CONNECTION)
else:
_rd = redis.Redis(**settings.REDIS_CONNECTION)
redis_migrated_data = {}
for key in settings.CONFIG:
prefixed_key = f"{_prefix}{key}"
value = _rd.get(prefixed_key)
if value is not None and not is_already_migrated(value):
redis_migrated_data[prefixed_key] = dumps(pickle.loads(value)) # noqa: S301
for prefixed_key, value in redis_migrated_data.items():
_rd.set(prefixed_key, value)
class Migration(migrations.Migration):
dependencies = [("constance", "0002_migrate_from_old_table")]
operations = [
migrations.RunPython(migrate_pickled_data),
]

View file

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

View file

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

View file

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

View file

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

View file

@ -6,28 +6,16 @@
$('#content-main').on('click', '.reset-link', function(e) {
e.preventDefault();
const field_selector = this.dataset.fieldId.replace(/ /g, "\\ ")
const field = $('#' + field_selector);
const fieldType = this.dataset.fieldType;
var field = $('#' + this.dataset.fieldId);
var fieldType = this.dataset.fieldType;
if (fieldType === 'checkbox') {
field.prop('checked', this.dataset.default === 'true');
} else if (fieldType === 'multi-select') {
const defaults = JSON.parse(this.dataset.default);
const stringDefaults = defaults.map(function(v) { return String(v); });
// CheckboxSelectMultiple: individual checkboxes inside a wrapper
field.find('input[type="checkbox"]').each(function() {
$(this).prop('checked', stringDefaults.indexOf($(this).val()) !== -1);
});
// SelectMultiple: <select multiple> element
field.find('option').each(function() {
$(this).prop('selected', stringDefaults.indexOf($(this).val()) !== -1);
});
} else if (fieldType === 'date') {
const defaultDate = new Date(this.dataset.default * 1000);
$('#' + this.dataset.fieldId).val(defaultDate.strftime(get_format('DATE_INPUT_FORMATS')[0]));
var defaultDate = new Date(this.dataset.default * 1000);
$('#' + this.dataset.fieldId).val(defaultDate.strftime(get_format('DATE_INPUT_FORMATS')[0]));}
} else if (fieldType === 'datetime') {
const defaultDate = new Date(this.dataset.default * 1000);
var defaultDate = new Date(this.dataset.default * 1000);
$('#' + this.dataset.fieldId + '_0').val(defaultDate.strftime(get_format('DATE_INPUT_FORMATS')[0]));
$('#' + this.dataset.fieldId + '_1').val(defaultDate.strftime(get_format('TIME_INPUT_FORMATS')[0]));
} else {

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,11 @@
from functools import wraps
from django import VERSION as DJANGO_VERSION
from django.test import SimpleTestCase
from django.test.utils import override_settings
from constance import config
from .. import config
__all__ = ("override_config",)
__all__ = ('override_config',)
class override_config(override_settings):
@ -15,23 +14,25 @@ class override_config(override_settings):
Based on django.test.utils.override_settings.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
super(override_config, self).__init__(**kwargs)
self.original_values = {}
def __call__(self, test_func):
"""Modify the decorated function to override config values."""
"""
Modify the decorated function to override config values.
"""
if isinstance(test_func, type):
if not issubclass(test_func, SimpleTestCase):
raise Exception("Only subclasses of Django SimpleTestCase can be decorated with override_config")
raise Exception(
"Only subclasses of Django SimpleTestCase can be "
"decorated with override_config")
return self.modify_test_case(test_func)
@wraps(test_func)
def inner(*args, **kwargs):
with self:
return test_func(*args, **kwargs)
else:
@wraps(test_func)
def inner(*args, **kwargs):
with self:
return test_func(*args, **kwargs)
return inner
def modify_test_case(self, test_case):
@ -45,20 +46,9 @@ class override_config(override_settings):
original_pre_setup = test_case._pre_setup
original_post_teardown = test_case._post_teardown
if DJANGO_VERSION < (5, 2):
def _pre_setup(inner_self):
self.enable()
original_pre_setup(inner_self)
else:
@classmethod
def _pre_setup(cls):
# NOTE: Django 5.2 turned this as a classmethod
# https://github.com/django/django/pull/18514/files
self.enable()
original_pre_setup()
def _pre_setup(inner_self):
self.enable()
original_pre_setup(inner_self)
def _post_teardown(inner_self):
original_post_teardown(inner_self)
@ -70,20 +60,26 @@ class override_config(override_settings):
return test_case
def enable(self):
"""Store original config values and set overridden values."""
"""
Store original config values and set overridden values.
"""
# Store the original values to an instance variable
for config_key in self.options:
self.original_values[config_key] = getattr(config, config_key)
# Update config with the overridden values
# Update config with the overriden values
self.unpack_values(self.options)
def disable(self):
"""Set original values to the config."""
"""
Set original values to the config.
"""
self.unpack_values(self.original_values)
@staticmethod
def unpack_values(options):
"""Unpack values from the given dict to config."""
"""
Unpack values from the given dict to config.
"""
for name, value in options.items():
setattr(config, name, value)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,19 +5,27 @@ Features
--------
* Easily migrate your static settings to dynamic settings.
* Edit the dynamic settings in the Django admin interface.
* Admin interface to edit the dynamic settings.
.. image:: _static/screenshot2.png
.. image:: screenshot2.png
Quick Installation
------------------
Installation
------------
.. code-block::
Install from PyPI the backend specific variant of django-constance:
For the (default) Redis backend::
pip install "django-constance[redis]"
For complete installation instructions, including how to install the
database backend, see :ref:`Backends <backends>`.
For the database backend::
pip install "django-constance[database]"
Alternatively -- if you're sure that the dependencies are already
installed -- you can also run::
pip install django-constance
Configuration
-------------
@ -44,11 +52,7 @@ the :setting:`CONSTANCE_CONFIG` section, like this:
'The Universe, and Everything'),
}
.. note:: Add constance *before* your project apps.
.. note:: If you use admin extensions like
`Grapelli <https://grappelliproject.com/>`_, ``'constance'`` should be added
in :setting:`INSTALLED_APPS` *before* those extensions.
.. note:: If you use admin extensions like `Grapelli <http://grappelliproject.com/>`_, ``'constance'`` should be added in :setting:`INSTALLED_APPS` *before* that extension
Here, ``42`` is the default value for the key ``THE_ANSWER`` if it is
not found in the backend. The other member of the tuple is a help text the
@ -60,8 +64,7 @@ finish the configuration.
``django-constance``'s hashes generated in different instances of the same
application may differ, preventing data from being saved.
Use :setting:`CONSTANCE_IGNORE_ADMIN_VERSION_CHECK` in order to skip hash
verification.
Use this option in order to skip hash verification.
.. code-block:: python
@ -73,6 +76,8 @@ Signals
Each time a value is changed it will trigger a ``config_updated`` signal.
You can use it as:
.. code-block:: python
from constance.signals import config_updated
@ -82,14 +87,19 @@ Each time a value is changed it will trigger a ``config_updated`` signal.
print(sender, key, old_value, new_value)
The sender is the ``config`` object, and the ``key`` and ``new_value``
are the changed settings.
are the ones just changed.
This callback will get the ``config`` object as the first parameter so you
can have an isolated function where you can access the ``config`` object
without dealing with additional imports.
Custom fields
-------------
You can set the field type with the third value in the ``CONSTANCE_CONFIG`` tuple.
The value can be one of the supported types or a string matching a key in your :setting:`CONSTANCE_ADDITIONAL_FIELDS`
The value can be one of the supported types or a string matching a key in your :setting:``CONSTANCE_ADDITIONAL_FIELDS``
The supported types are:
@ -97,17 +107,12 @@ The supported types are:
* ``int``
* ``float``
* ``Decimal``
* ``long`` (on python 2)
* ``str``
* ``unicode`` (on python 2)
* ``datetime``
* ``timedelta``
* ``date``
* ``time``
* ``list``
* ``dict``
.. note::
To be able to use ``list`` and ``dict`` you need to set a widget and form field for these types as it is ambiguous what types shall be stored in the collection object.
You can do so with :setting:`CONSTANCE_ADDITIONAL_FIELDS` as explained below.
For example, to force a value to be handled as a string:
@ -116,7 +121,7 @@ For example, to force a value to be handled as a string:
'THE_ANSWER': (42, 'Answer to the Ultimate Question of Life, '
'The Universe, and Everything', str),
Custom field types are supported using the dictionary :setting:`CONSTANCE_ADDITIONAL_FIELDS`.
Custom field types are supported using the dictionary :setting:``CONSTANCE_ADDITIONAL_FIELDS``.
This is a mapping between a field label and a sequence (list or tuple). The first item in the sequence is the string
path of a field class, and the (optional) second item is a dictionary used to configure the field.
@ -124,7 +129,7 @@ path of a field class, and the (optional) second item is a dictionary used to co
The ``widget`` and ``widget_kwargs`` keys in the field config dictionary can be used to configure the widget used in admin,
the other values will be passed as kwargs to the field's ``__init__()``
.. note:: Use later evaluated strings instead of direct classes for the field and widget classes:
Note: Use later evaluated strings instead of direct classes for the field and widget classes:
.. code-block:: python
@ -139,7 +144,7 @@ the other values will be passed as kwargs to the field's ``__init__()``
'MY_SELECT_KEY': ('yes', 'select yes or no', 'yes_no_null_select'),
}
If you want to work with images or files you can use this configuration:
If you want to work with files you can use this configuration:
.. code-block:: python
@ -158,36 +163,15 @@ When used in a template you probably need to use:
{% load static %}
{% get_media_prefix as MEDIA_URL %}
<img src="{{ MEDIA_URL }}{{ config.LOGO_IMAGE }}">
<img src="{{ MEDIA_URL }}{{ constance.LOGO_IMAGE }}">
Images and files are uploaded to ``MEDIA_ROOT`` by default. You can specify a subdirectory of ``MEDIA_ROOT`` to use instead by adding the ``CONSTANCE_FILE_ROOT`` setting. E.g.:
.. code-block:: python
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
CONSTANCE_FILE_ROOT = 'constance'
This will result in files being placed in ``media/constance`` within your ``BASE_DIR``. You can use deeper nesting in this setting (e.g. ``constance/images``) but other relative path components (e.g. ``../``) will be rejected.
In case you want to store a list of ``int`` values in the constance config, a working setup is
.. code-block:: python
CONSTANCE_ADDITIONAL_FIELDS = {
list: ["django.forms.fields.JSONField", {"widget": "django.forms.Textarea"}],
}
CONSTANCE_CONFIG = {
'KEY': ([0, 10, 20], 'A list of integers', list),
}
Make sure to use the ``JSONField`` for this purpose as user input in the admin page may be understood and saved as ``str`` otherwise.
Images are uploaded to MEDIA_ROOT.
Ordered Fields in Django Admin
------------------------------
To sort the fields, you can use an OrderedDict:
In order to Order the fields , you can use OrderedDict collection. Here is an example:
.. code-block:: python
@ -203,7 +187,7 @@ To sort the fields, you can use an OrderedDict:
Fieldsets
---------
You can define fieldsets to group settings together:
To group settings together you can define fieldsets. Here's an example:
.. code-block:: python
@ -218,61 +202,9 @@ You can define fieldsets to group settings together:
'Theme Options': ('THEME',),
}
.. note:: CONSTANCE_CONFIG_FIELDSETS must contain all fields from CONSTANCE_CONFIG.
.. note:: CONSTANCE_CONFIG_FIELDSETS must contain all fields from CONSTANCE_CONFIG
.. image:: _static/screenshot3.png
Fieldsets collapsing
--------------------
To make some fieldsets collapsing you can use new format in CONSTANCE_CONFIG_FIELDSETS. Here's an example:
.. code-block:: python
CONSTANCE_CONFIG = {
'SITE_NAME': ('My Title', 'Website title'),
'SITE_DESCRIPTION': ('', 'Website description'),
'THEME': ('light-blue', 'Website theme'),
}
CONSTANCE_CONFIG_FIELDSETS = {
'General Options': {
'fields': ('SITE_NAME', 'SITE_DESCRIPTION'),
'collapse': True
},
'Theme Options': ('THEME',),
}
Field internationalization
--------------------------
Field description and fieldset headers can be integrated into Django's
internationalization using the ``gettext_lazy`` function. Note that the
``CONSTANCE_CONFIG_FIELDSETS`` must be converted to a tuple instead of dict
as it is not possible to have lazy proxy objects as dictionary keys in the
settings file. Example:
.. code-block:: python
from django.utils.translation import gettext_lazy as _
CONSTANCE_CONFIG = {
'SITE_NAME': ('My Title', _('Website title')),
'SITE_DESCRIPTION': ('', _('Website description')),
'THEME': ('light-blue', _('Website theme')),
}
CONSTANCE_CONFIG_FIELDSETS = (
(
_('General Options'),
{
'fields': ('SITE_NAME', 'SITE_DESCRIPTION'),
'collapse': True,
},
),
(_('Theme Options'), ('THEME',)),
)
.. image:: screenshot3.png
Usage
-----
@ -292,40 +224,10 @@ object and accessing the variables with attribute lookups::
if config.THE_ANSWER == 42:
answer_the_question()
Asynchronous usage
^^^^^^^^^^^^^^^^^^
If you are using Django's asynchronous features (like async views), you can ``await`` the settings directly on the standard ``config`` object::
from constance import config
async def my_async_view(request):
# Accessing settings is awaitable
if await config.THE_ANSWER == 42:
return await answer_the_question_async()
async def update_settings():
# Updating settings asynchronously
await config.aset('THE_ANSWER', 43)
# Bulk retrieval is supported as well
values = await config.amget(['THE_ANSWER', 'SITE_NAME'])
Performance and Safety
~~~~~~~~~~~~~~~~~~~~~~
While synchronous access (e.g., ``config.THE_ANSWER``) still works inside async views for some backends, it is highly discouraged:
* **Blocking:** Synchronous access blocks the event loop, reducing the performance of your entire application.
* **Safety Guards:** For the Database backend, Django's safety guards will raise a ``SynchronousOnlyOperation`` error if you attempt to access a setting synchronously from an async thread.
* **Automatic Detection:** Constance will emit a ``RuntimeWarning`` if it detects synchronous access inside an asynchronous event loop, helping you identify and fix these performance bottlenecks.
For peak performance, especially with the Redis backend, always use the ``await`` syntax which leverages native asynchronous drivers.
Django templates
^^^^^^^^^^^^^^^^
To access the config object from your template you can
To access the config object from your template you can either
pass the object to the template context:
.. code-block:: python
@ -336,9 +238,16 @@ pass the object to the template context:
def myview(request):
return render(request, 'my_template.html', {'config': config})
You can also use the included context processor.
Or you can use the included config context processor. For Django pre-1.8, this looks like this:
Insert ``'constance.context_processors.config'`` at
.. code-block:: python
TEMPLATE_CONTEXT_PROCESSORS = (
# ...
'constance.context_processors.config',
)
For Django 1.8 and above, insert ``'constance.context_processors.config'`` at
the top of your ``TEMPLATES['OPTIONS']['context_processors']`` list. See the
`Django documentation`_ for details.
@ -357,99 +266,65 @@ any other variable, e.g.:
Woohoo! Head over <a href="/sekrit/">here</a> to use the beta.
{% else %}
Sadly we haven't launched yet, click <a href="/newsletter/">here</a>
to signup for our newsletter.
to signup for our newletter.
{% endif %}
Command Line
^^^^^^^^^^^^
Constance settings can be get/set on the command line with the manage command :command:`constance`.
Constance settings can be get/set on the command line with the manage command `constance`
Available options are:
.. program:: constance
list - output all values in a tab-separated format::
.. option:: list
$ ./manage.py constance list
THE_ANSWER 42
SITE_NAME My Title
list all Constance keys and their values
get KEY - output a single values::
.. code-block:: console
$ ./manage.py constance get THE_ANSWER
42
$ ./manage.py constance list
THE_ANSWER 42
SITE_NAME My Title
set KEY VALUE - set a single value::
.. option:: get <KEY>
$ ./manage.py constance set SITE_NAME "Another Title"
get the value of a Constance key
If the value contains spaces it should be wrapped in quotes.
.. code-block:: console
.. note:: Set values are validated as per in admin, an error will be raised if validation fails:
$ ./manage.py constance get THE_ANSWER
42
E.g., given this config as per the example app::
.. option:: set <KEY> <VALUE>
CONSTANCE_CONFIG = {
...
'DATE_ESTABLISHED': (date(1972, 11, 30), "the shop's first opening"),
}
set the value of a Constance key
Then setting an invalid date will fail as follow::
.. code-block:: console
$ ./manage.py constance set SITE_NAME "Another Title"
If the value contains spaces it should be wrapped in quotes.
.. note:: Set values are validated as per in admin, an error will be raised if validation fails:
E.g., given this config as per the example app:
.. code-block:: python
CONSTANCE_CONFIG = {
...
'DATE_ESTABLISHED': (date(1972, 11, 30), "the shop's first opening"),
}
Setting an invalid date will fail as follow:
.. code-block:: console
$ ./manage.py constance set DATE_ESTABLISHED '1999-12-00'
CommandError: Enter a valid date.
$ ./manage.py constance set DATE_ESTABLISHED '1999-12-00'
CommandError: Enter a valid date.
.. note:: If the admin field is a :class:`MultiValueField`, then the separate field values need to be provided as separate arguments.
.. note:: If the admin fields is a `MultiValueField`, (e.g. datetime, which uses `SplitDateTimeField` by default)
then the separate field values need to be provided as separate arguments.
E.g., a datetime using :class:`SplitDateTimeField`:
E.g., given this config::
.. code-block:: python
CONSTANCE_CONFIG = {
'DATETIME_VALUE': (datetime(2010, 8, 23, 11, 29, 24), 'time of the first commit'),
}
CONSTANCE_CONFIG = {
'DATETIME_VALUE': (datetime(2010, 8, 23, 11, 29, 24), 'time of the first commit'),
}
Then this works (and the quotes are optional)::
Then this works (and the quotes are optional):
./manage.py constance set DATETIME_VALUE '2011-09-24' '12:30:25'
.. code-block:: console
This doesn't work::
./manage.py constance set DATETIME_VALUE '2011-09-24' '12:30:25'
This doesn't work:
.. code-block:: console
./manage.py constance set DATETIME_VALUE '2011-09-24 12:30:25'
CommandError: Enter a list of values.
.. option:: remove_stale_keys
delete all Constance keys and their values if they are not in settings.CONSTANCE_CONFIG (stale keys)
.. code-block:: console
$ ./manage.py constance remove_stale_keys
Record is considered stale if it exists in database but absent in config.
./manage.py constance set DATETIME_VALUE '2011-09-24 12:30:25'
CommandError: Enter a list of values.
Editing
-------
@ -457,12 +332,13 @@ Editing
Fire up your ``admin`` and you should see a new app called ``Constance``
with ``THE_ANSWER`` in the ``Config`` pseudo model.
By default, changing the settings via the admin is only allowed for superusers.
To change this, feel free to set the :setting:`CONSTANCE_SUPERUSER_ONLY`
setting to ``False`` and give users or user groups access to the
By default changing the settings via the admin is only allowed for super users.
But in case you want to use the admin's ability to implement custom
authorization checks, feel free to set the :setting:`CONSTANCE_SUPERUSER_ONLY`
setting to ``False`` and give the users or user groups access to the
``constance.change_config`` permission.
.. figure:: _static/screenshot1.png
.. figure:: screenshot1.png
The virtual application ``Constance`` among your regular applications.
@ -478,11 +354,10 @@ settings the way you like.
.. code-block:: python
from constance.admin import ConstanceAdmin, Config
from constance.forms import ConstanceForm
from constance.admin import ConstanceAdmin, ConstanceForm, Config
class CustomConfigForm(ConstanceForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
super(CustomConfigForm, self).__init__(*args, **kwargs)
#... do stuff to make your settings form nice ...
class ConfigAdmin(ConstanceAdmin):
@ -507,7 +382,7 @@ request. For example:
if request.user.is_superuser:
return SuperuserForm:
else:
return super().get_changelist_form(request)
return super(MyConstanceAdmin, self).get_changelist_form(request)
Note that the default method returns ``self.change_list_form``.

View file

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

View file

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

View file

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View file

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

2
setup.cfg Normal file
View file

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

64
setup.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

54
tests/test_app.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

15
tests/test_database.py Normal file
View file

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

View file

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

View file

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

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