├── .gitignore ├── AUTHORS ├── LICENSE ├── MANIFEST.in ├── README.rst ├── dbsettings ├── __init__.py ├── forms.py ├── group.py ├── loading.py ├── locale │ └── pl │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── management.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── settings.py ├── templates │ └── dbsettings │ │ ├── app_settings.html │ │ ├── site_settings.html │ │ └── values.html ├── tests │ ├── __init__.py │ ├── test_urls.py │ └── tests.py ├── urls.py ├── utils.py ├── values.py └── views.py ├── runtests.py ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | MANIFEST 4 | build/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Marty Alchin (Initial Author) 2 | 3 | Contibutors 4 | ----------- 5 | 6 | Samuel Cormier-Iijima 7 | Alfredo Aguirre 8 | Darian Moody 9 | Jacek Tomaszewski 10 | Alexander Danilenko 11 | Igor Tokarev 12 | Helber Maciel Guerra 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Samuel Cormier-Iijima 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | * Neither the name of Trapeze Media nor the names of its contributors 13 | may be used to endorse or promote products derived from this software 14 | without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 19 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 20 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 21 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 22 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 23 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 24 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 25 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 26 | POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE AUTHORS 2 | exclude runtests.py 3 | recursive-include dbsettings/templates *.html 4 | recursive-include dbsettings/locale *.mo *.po 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | | 2 | 3 | ================================ 4 | Storing settings in the database 5 | ================================ 6 | 7 | Not all settings belong in ``settings.py``, as it has some particular 8 | limitations: 9 | 10 | * Settings are project-wide. This not only requires apps to clutter up 11 | ``settings.py``, but also increases the chances of naming conflicts. 12 | 13 | * Settings are constant throughout an instance of Django. They cannot be 14 | changed without restarting the application. 15 | 16 | * Settings require a programmer in order to be changed. This is true even 17 | if the setting has no functional impact on anything else. 18 | 19 | Many applications find need to overcome these limitations, and ``dbsettings`` 20 | provides a convenient way to do so. 21 | 22 | The main goal in using this application is to define a set of placeholders that 23 | will be used to represent the settings that are stored in the database. Then, 24 | the settings may be edited at run-time using the provided editor, and all Python 25 | code in your application that uses the setting will receive the updated value. 26 | 27 | Requirements 28 | ============ 29 | 30 | +------------------+------------+--------------+ 31 | | Dbsettings | Python | Django | 32 | +==================+============+==============+ 33 | | ==0.10 | 3.4 - 3.5 | 1.7 - 1.10 | 34 | | +------------+--------------+ 35 | | | 3.2 - 3.3 | 1.7 - 1.8 | 36 | | +------------+--------------+ 37 | | | 2.7 | 1.7 - 1.10 | 38 | +------------------+------------+--------------+ 39 | | ==0.9 | 3.4 - 3.5 | 1.7 - 1.9 | 40 | | +------------+--------------+ 41 | | | 3.2 - 3.3 | 1.7 - 1.8 | 42 | | +------------+--------------+ 43 | | | 2.7 | 1.7 - 1.9 | 44 | +------------------+------------+--------------+ 45 | | ==0.8 | 3.2 | 1.5 - 1.8 | 46 | | +------------+--------------+ 47 | | | 2.7 | 1.4 - 1.8 | 48 | | +------------+--------------+ 49 | | | 2.6 | 1.4 - 1.6 | 50 | +------------------+------------+--------------+ 51 | | ==0.7 | 3.2 | 1.5 - 1.7 | 52 | | +------------+--------------+ 53 | | | 2.7 | 1.3 - 1.7 | 54 | | +------------+--------------+ 55 | | | 2.6 | 1.3 - 1.6 | 56 | +------------------+------------+--------------+ 57 | | ==0.6 | 3.2 | 1.5 | 58 | | +------------+--------------+ 59 | | | 2.6 - 2.7 | 1.3 - 1.5 | 60 | +------------------+------------+--------------+ 61 | | <=0.5 | 2.6 - 2.7 | 1.2\* - 1.4 | 62 | +------------------+------------+--------------+ 63 | 64 | \* Possibly version below 1.2 will work too, but not tested. 65 | 66 | Installation 67 | ============ 68 | 69 | To install the ``dbsettings`` package, simply place it anywhere on your 70 | ``PYTHONPATH``. 71 | 72 | Project settings 73 | ---------------- 74 | 75 | In order to setup database storage, and to let Django know about your use of 76 | ``dbsettings``, simply add it to your ``INSTALLED_APPS`` setting, like so:: 77 | 78 | INSTALLED_APPS = ( 79 | ... 80 | 'dbsettings', 81 | ... 82 | ) 83 | 84 | If your Django project utilizes ``sites`` framework, all setting would be related 85 | to some site. If ``sites`` are not present, settings won't be connected to any site 86 | (and ``sites`` framework is no longer required since 0.8.1). 87 | 88 | You can force to do (not) use ``sites`` via ``DBSETTINGS_USE_SITES = True / False`` 89 | configuration variable (put it in project's ``settings.py``). 90 | 91 | By default, values stored in database are limited to 255 characters per setting. 92 | You can change this limit with ``DBSETTINGS_VALUE_LENGTH`` configuration variable. 93 | If you change this value after migrations were run, you need to manually alter 94 | the ``dbsettings_setting`` table schema. 95 | 96 | URL Configuration 97 | ----------------- 98 | 99 | In order to edit your settings at run-time, you'll need to configure a URL to 100 | access the provided editors. You'll just need to add a single line, defining 101 | the base URL for the editors, as ``dbsettings`` has its own URLconf to handle 102 | the rest. You may choose any location you like:: 103 | 104 | urlpatterns = patterns('', 105 | ... 106 | (r'^settings/', include('dbsettings.urls')), 107 | ... 108 | ) 109 | 110 | A note about caching 111 | -------------------- 112 | 113 | This framework utilizes Django's built-in `cache framework`_, which is used to 114 | minimize how often the database needs to be accessed. During development, 115 | Django's built-in server runs in a single process, so all cache backends will 116 | work just fine. 117 | 118 | Most productions environments, including mod_python, FastCGI or WSGI, run multiple 119 | processes, which some backends don't fully support. When using the ``simple`` 120 | or ``locmem`` backends, updates to your settings won't be reflected immediately 121 | in all workers, causing your application to ignore the new changes. 122 | 123 | No other backends exhibit this behavior, but since ``simple`` is the default, 124 | make sure to specify a proper backend when moving to a production environment. 125 | 126 | .. _`cache framework`: http://docs.djangoproject.com/en/dev/topics/cache/ 127 | 128 | Alternatively you can disable caching of settings by setting 129 | ``DBSETTINGS_USE_CACHE = False`` in ``settings.py``. Beware though: every 130 | access of any setting will result in database hit. 131 | 132 | Usage 133 | ===== 134 | 135 | These database-backed settings can be applied to any model in any app, or even 136 | in the app itself. All the tools necessary to do so are available within the 137 | ``dbsettings`` module. A single import provides everything you'll need:: 138 | 139 | import dbsettings 140 | 141 | Defining a group of settings 142 | ---------------------------- 143 | 144 | Settings are be defined in groups that allow them to be referenced together 145 | under a single attribute. Defining a group uses a declarative syntax similar 146 | to that of models, by declaring a new subclass of the ``Group`` class and 147 | populating it with values. 148 | 149 | :: 150 | 151 | class ImageLimits(dbsettings.Group): 152 | maximum_width = dbsettings.PositiveIntegerValue() 153 | maximum_height = dbsettings.PositiveIntegerValue() 154 | 155 | You may name your groups anything you like, and they may be defined in any 156 | module. This allows them to be imported from common applications if applicable. 157 | 158 | Defining individual settings 159 | ---------------------------- 160 | 161 | Within your groups, you may define any number of individual settings by simply 162 | assigning the value types to appropriate names. The names you assign them to 163 | will be the attribute names you'll use to reference the setting later, so be 164 | sure to choose names accordingly. 165 | 166 | For the editor, the default description of each setting will be retrieved from 167 | the attribute name, similar to how the ``verbose_name`` of model fields is 168 | retrieved. Also like model fields, however, an optional argument may be provided 169 | to define a more fitting description. It's recommended to leave the first letter 170 | lower-case, as it will be capitalized as necessary, automatically. 171 | 172 | :: 173 | 174 | class EmailOptions(dbsettings.Group): 175 | enabled = dbsettings.BooleanValue('whether to send emails or not') 176 | sender = dbsettings.StringValue('address to send emails from') 177 | subject = dbsettings.StringValue(default='SiteMail') 178 | 179 | For more descriptive explanation, the ``help_text`` argument can be used. It 180 | will be shown in the editor. 181 | 182 | The ``default`` argument is very useful - it specify an initial value of the 183 | setting. 184 | 185 | In addition, settings may be supplied with a list of available options, through 186 | the use of of the ``choices`` argument. This works exactly like the ``choices`` 187 | argument for model fields, and that of the newforms ``ChoiceField``. 188 | 189 | The widget used for a value can be overriden using the ``widget`` keyword. For example: 190 | 191 | :: 192 | 193 | payment_instructions = dbsettings.StringValue( 194 | help_text="Printed on every invoice.", 195 | default="Payment to Example XYZ\nBank name here\nAccount: 0123456\nSort: 01-02-03", 196 | widget=forms.Textarea 197 | ) 198 | 199 | A full list of value types is available later in this document, but the process 200 | and arguments are the same for each. 201 | 202 | Assigning settings 203 | ------------------ 204 | 205 | Once your settings are defined and grouped properly, they must be assigned to a 206 | location where they will be referenced later. This is as simple as instantiating 207 | the settings group in the appropriate location. This may be at the module level 208 | or within any standard Django model. 209 | 210 | Group instance may receive one optional argument: verbose name of the group. 211 | This name will be displayed in the editor. 212 | 213 | :: 214 | 215 | email = EmailOptions() 216 | 217 | class Image(models.Model): 218 | image = models.ImageField(upload_to='/upload/path') 219 | caption = models.TextField() 220 | 221 | limits = ImageLimits('Dimension settings') 222 | 223 | Multiple groups may be assigned to the same module or model, and they can even 224 | be combined into a single group by using standard addition syntax:: 225 | 226 | options = EmailOptions() + ImageLimits() 227 | 228 | To separate and tag settings nicely in the editor, use verbose names:: 229 | 230 | options = EmailOptions('Email') + ImageLimits('Dimesions') 231 | 232 | Database setup 233 | -------------- 234 | 235 | A single model is provided for database storage, and this model must be 236 | installed in your database before you can use the included editors or the 237 | permissions that will be automatically created. This is a simple matter of 238 | running ``manage.py syncdb`` or ``manage.py migrate`` now that your settings 239 | are configured. 240 | 241 | This step need only be repeate when settings are added to a new application, 242 | as it will create the appropriate permissions. Once those are in place, new 243 | settings may be added to existing applications with no impact on the database. 244 | 245 | Using your settings 246 | =================== 247 | 248 | Once the above steps are completed, you're ready to make use of database-backed 249 | settings. 250 | 251 | Editing settings 252 | ---------------- 253 | 254 | When first defined, your settings will default to ``None`` (or ``False`` in 255 | the case of ``BooleanValue``), so their values must be set using one of the 256 | supplied editors before they can be considered useful (however, if the setting 257 | had the ``default`` argument passed in the constructor, its value is already 258 | useful - equal to the defined default). 259 | 260 | The editor will be available at the URL configured earlier. 261 | For example, if you used the prefix of ``'settings/'``, the URL ``/settings/`` 262 | will provide an editor of all available settings, while ``/settings/myapp/`` 263 | would contain a list of just the settings for ``myapp``. 264 | 265 | URL patterns are named: ``'site_settings'`` and ``'app_settings'``, respectively. 266 | 267 | The editors are restricted to staff members, and the particular settings that 268 | will be available to users is based on permissions that are set for them. This 269 | means that superusers will automatically be able to edit all settings, while 270 | other staff members will need to have permissions set explicitly. 271 | 272 | Accessing settings in Python 273 | ---------------------------- 274 | 275 | Once settings have been assigned to an appropriate location, they may be 276 | referenced as standard Python attributes. The group becomes an attribute of the 277 | location where it was assigned, and the individual values are attributes of the 278 | group. 279 | 280 | If any settings are referenced without being set to a particular value, they 281 | will default to ``None`` (or ``False`` in the case of ``BooleanValue``, or 282 | whatever was passed as ``default``). In the 283 | following example, assume that ``EmailOptions`` were just added to the project 284 | and the ``ImageLimits`` were added earlier and already set via editor. 285 | 286 | :: 287 | 288 | >>> from myproject.myapp import models 289 | 290 | # EmailOptions are not defined 291 | >>> models.email.enabled 292 | False 293 | >>> models.email.sender 294 | >>> models.email.subject 295 | 'SiteMail' # Since default was defined 296 | 297 | # ImageLimits are defined 298 | >>> models.Image.limits.maximum_width 299 | 1024 300 | >>> models.Image.limits.maximum_height 301 | 768 302 | 303 | These settings are accessible from any Python code, making them especially 304 | useful in model methods and views. Each time the attribute is accessed, it will 305 | retrieve the current value, so your code doesn't need to worry about what 306 | happens behind the scenes. 307 | 308 | :: 309 | 310 | def is_valid(self): 311 | if self.width > Image.limits.maximum_width: 312 | return False 313 | if self.height > Image.limits.maximum_height: 314 | return False 315 | return True 316 | 317 | As mentioned, views can make use of these settings as well. 318 | 319 | :: 320 | 321 | from myproject.myapp.models import email 322 | 323 | def submit(request): 324 | 325 | ... 326 | # Deal with a form submission 327 | ... 328 | 329 | if email.enabled: 330 | from django.core.mail import send_mail 331 | send_mail(email.subject, 'message', email.sender, [request.user.email]) 332 | 333 | Settings can be not only read, but also written. The admin editor is more 334 | user-friendly, but in case code need to change something:: 335 | 336 | from myproject.myapp.models import Image 337 | 338 | def low_disk_space(): 339 | Image.limits.maximum_width = Image.limits.maximum_height = 200 340 | 341 | Every write is immediately commited to the database and proper cache key is deleted. 342 | 343 | A note about model instances 344 | ---------------------------- 345 | 346 | Since settings aren't related to individual model instances, any settings that 347 | are set on models may only be accessed by the model class itself. Attempting to 348 | access settings on an instance will raise an ``AttributeError``. 349 | 350 | Value types 351 | =========== 352 | 353 | There are several various value types available for database-backed settings. 354 | Select the one most appropriate for each individual setting, but all types use 355 | the same set of arguments. 356 | 357 | BooleanValue 358 | ------------ 359 | 360 | Presents a checkbox in the editor, and returns ``True`` or ``False`` in Python. 361 | 362 | DurationValue 363 | ------------- 364 | 365 | Presents a set of inputs suitable for specifying a length of time. This is 366 | represented in Python as a |timedelta|_ object. 367 | 368 | .. |timedelta| replace:: ``timedelta`` 369 | .. _timedelta: https://docs.python.org/2/library/datetime.html#timedelta-objects 370 | 371 | FloatValue 372 | ---------- 373 | 374 | Presents a standard input field, which becomes a ``float`` in Python. 375 | 376 | IntegerValue 377 | ------------ 378 | 379 | Presents a standard input field, which becomes an ``int`` in Python. 380 | 381 | PercentValue 382 | ------------ 383 | 384 | Similar to ``IntegerValue``, but with a limit requiring that the value be 385 | between 0 and 100. In addition, when accessed in Python, the value will be 386 | divided by 100, so that it is immediately suitable for calculations. 387 | 388 | For instance, if a ``myapp.taxes.sales_tax`` was set to 5 in the editor, 389 | the following calculation would be valid:: 390 | 391 | >>> 5.00 * myapp.taxes.sales_tax 392 | 0.25 393 | 394 | PositiveIntegerValue 395 | -------------------- 396 | 397 | Similar to ``IntegerValue``, but limited to positive values and 0. 398 | 399 | StringValue 400 | ----------- 401 | 402 | Presents a standard input, accepting any text string up to 255 403 | (or ``DBSETTINGS_VALUE_LENGTH``) characters. In 404 | Python, the value is accessed as a standard string. 405 | 406 | DateTimeValue 407 | ------------- 408 | 409 | Presents a standard input field, which becomes a ``datetime`` in Python. 410 | 411 | User input will be parsed according to ``DATETIME_INPUT_FORMATS`` setting. 412 | 413 | In code, one can assign to field string or datetime object:: 414 | 415 | # These two statements has the same effect 416 | myapp.Feed.next_feed = '2012-06-01 00:00:00' 417 | myapp.Feed.next_feed = datetime.datetime(2012, 6, 1, 0, 0, 0) 418 | 419 | DateValue 420 | --------- 421 | 422 | Presents a standard input field, which becomes a ``date`` in Python. 423 | 424 | User input will be parsed according to ``DATE_INPUT_FORMATS`` setting. 425 | 426 | See ``DateTimeValue`` for the remark about assigning. 427 | 428 | TimeValue 429 | --------- 430 | 431 | Presents a standard input field, which becomes a ``time`` in Python. 432 | 433 | User input will be parsed according to ``TIME_INPUT_FORMATS`` setting. 434 | 435 | See ``DateTimeValue`` for the remark about assigning. 436 | 437 | ImageValue 438 | ---------- 439 | 440 | (requires PIL or Pillow imaging library to work) 441 | 442 | Allows to upload image and view its preview. 443 | 444 | ImageValue has optional ``upload_to`` keyword, which specify path 445 | (relative to ``MEDIA_ROOT``), where uploaded images will be stored. 446 | If keyword is not present, files will be saved directly under 447 | ``MEDIA_ROOT``. 448 | 449 | PasswordValue 450 | ------------- 451 | 452 | Presents a standard password input. Retain old setting value if not changed. 453 | 454 | 455 | Setting defaults for a distributed application 456 | ============================================== 457 | 458 | Distributed applications often have need for certain default settings that are 459 | useful for the common case, but which may be changed to suit individual 460 | installations. For such cases, a utility is provided to enable applications to 461 | set any applicable defaults. 462 | 463 | Living at ``dbsettings.utils.set_defaults``, this utility is designed to be used 464 | within the app's ``management.py``. This way, when the application is installed 465 | using ``syncdb``/``migrate``, the default settings will also be installed to the database. 466 | 467 | The function requires a single positional argument, which is the ``models`` 468 | module for the application. Any additional arguments must represent the actual 469 | settings that will be installed. Each argument is a 3-tuple, of the following 470 | format: ``(class_name, setting_name, value)``. 471 | 472 | If the value is intended for a module-level setting, simply set ``class_name`` 473 | to an empty string. The value for ``setting_name`` should be the name given to 474 | the setting itself, while the name assigned to the group isn't supplied, as it 475 | isn't used for storing the value. 476 | 477 | For example, the following code in ``management.py`` would set defaults for 478 | some of the settings provided earlier in this document:: 479 | 480 | from django.conf import settings 481 | from dbsettings.utils import set_defaults 482 | from myproject.myapp import models as myapp 483 | 484 | set_defaults(myapp, 485 | ('', 'enabled', True) 486 | ('', 'sender', settings.ADMINS[0][1]) # Email of the first listed admin 487 | ('Image', 'maximum_width', 800) 488 | ('Image', 'maximum_height', 600) 489 | ) 490 | 491 | ---------- 492 | 493 | Changelog 494 | ========= 495 | 496 | **0.10.0** (25/09/2016) 497 | - Added compatibility with Django 1.10 498 | **0.9.3** (02/06/2016) 499 | - Fixed (hopefully for good) problem with ImageValue in Python 3 (thanks rolexCoder) 500 | **0.9.2** (01/05/2016) 501 | - Fixed bug when saving non-required settings 502 | - Fixed problem with ImageValue in Python 3 (thanks rolexCoder) 503 | **0.9.1** (10/01/2016) 504 | - Fixed `Sites` app being optional (thanks rolexCoder) 505 | **0.9.0** (25/12/2015) 506 | - Added compatibility with Django 1.9 (thanks Alonso) 507 | - Dropped compatibility with Django 1.4, 1.5, 1.6 508 | **0.8.2** (17/09/2015) 509 | - Added migrations to distro 510 | - Add configuration option to change max length of setting values from 255 to whatever 511 | - Add configuration option to disable caching (thanks nwaxiomatic) 512 | - Fixed PercentValue rendering (thanks last-partizan) 513 | **0.8.1** (21/06/2015) 514 | - Made ``django.contrib.sites`` framework dependency optional 515 | - Added migration for app 516 | **0.8.0** (16/04/2015) 517 | - Switched to using django.utils.six instead of standalone six. 518 | - Added compatibility with Django 1.8 519 | - Dropped compatibility with Django 1.3 520 | **0.7.4** (24/03/2015) 521 | - Added default values for fields. 522 | - Fixed Python 3.3 compatibility 523 | - Added creation of folders with ImageValue 524 | **0.7.3**, **0.7.2** 525 | pypi problems 526 | **0.7.1** (11/03/2015) 527 | - Fixed pypi distribution. 528 | **0.7** (06/07/2014) 529 | - Added PasswordValue 530 | - Added compatibility with Django 1.6 and 1.7. 531 | **0.6** (16/09/2013) 532 | - Added compatibility with Django 1.5 and python3, dropped support for Django 1.2. 533 | - Fixed permissions: added permission for editing non-model (module-level) settings 534 | - Make PIL/Pillow not required in setup.py 535 | **0.5** (11/10/2012) 536 | - Fixed error occuring when test are run with ``LANGUAGE_CODE`` different than 'en' 537 | - Added verbose_name option for Groups 538 | - Cleaned code 539 | **0.4.1** (02/10/2012) 540 | - Fixed Image import 541 | **0.4** (30/09/2012) 542 | - Named urls 543 | - Added polish translation 544 | **0.3** (04/09/2012) 545 | Included testrunner in distribution 546 | **0.2** (05/07/2012) 547 | - Fixed errors appearing when module-level and model-level settings have 548 | same attribute names 549 | - Corrected the editor templates admin integration 550 | - Updated README 551 | **0.1** (29/06/2012) 552 | Initial PyPI release 553 | -------------------------------------------------------------------------------- /dbsettings/__init__.py: -------------------------------------------------------------------------------- 1 | from dbsettings.values import * # NOQA 2 | from dbsettings.group import Group # NOQA 3 | -------------------------------------------------------------------------------- /dbsettings/forms.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from collections import OrderedDict 4 | from django.apps import apps 5 | from django import forms 6 | from django.utils.text import capfirst 7 | 8 | from dbsettings.loading import get_setting_storage 9 | 10 | 11 | RE_FIELD_NAME = re.compile(r'^(.+)__(.*)__(.+)$') 12 | 13 | 14 | class SettingsEditor(forms.BaseForm): 15 | "Base editor, from which customized forms are created" 16 | 17 | def __iter__(self): 18 | for field in super(SettingsEditor, self).__iter__(): 19 | yield self.specialize(field) 20 | 21 | def __getitem__(self, name): 22 | field = super(SettingsEditor, self).__getitem__(name) 23 | return self.specialize(field) 24 | 25 | def specialize(self, field): 26 | "Wrapper to add module_name and class_name for regrouping" 27 | field.label = capfirst(field.label) 28 | module_name, class_name, _ = RE_FIELD_NAME.match(field.name).groups() 29 | 30 | app_label = self.apps[field.name] 31 | field.module_name = app_label 32 | 33 | if class_name: 34 | model = apps.get_model(app_label, class_name) 35 | if model: 36 | class_name = model._meta.verbose_name 37 | field.class_name = class_name 38 | field.verbose_name = self.verbose_names[field.name] 39 | 40 | return field 41 | 42 | 43 | def customized_editor(user, settings): 44 | "Customize the setting editor based on the current user and setting list" 45 | base_fields = OrderedDict() 46 | verbose_names = {} 47 | apps = {} 48 | for setting in settings: 49 | perm = '%s.can_edit_%s_settings' % ( 50 | setting.app, 51 | setting.class_name.lower() 52 | ) 53 | if user.has_perm(perm): 54 | # Add the field to the customized field list 55 | storage = get_setting_storage(*setting.key) 56 | kwargs = { 57 | 'label': setting.description, 58 | 'help_text': setting.help_text, 59 | # Provide current setting values for initializing the form 60 | 'initial': setting.to_editor(storage.value), 61 | 'required': setting.required, 62 | 'widget': setting.widget, 63 | } 64 | if setting.choices: 65 | field = forms.ChoiceField(choices=setting.choices, **kwargs) 66 | else: 67 | field = setting.field(**kwargs) 68 | key = '%s__%s__%s' % setting.key 69 | apps[key] = setting.app 70 | base_fields[key] = field 71 | verbose_names[key] = setting.verbose_name 72 | attrs = {'base_fields': base_fields, 'verbose_names': verbose_names, 'apps': apps} 73 | return type('SettingsEditor', (SettingsEditor,), attrs) 74 | -------------------------------------------------------------------------------- /dbsettings/group.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from django.utils import six 3 | 4 | from dbsettings.values import Value 5 | from dbsettings.loading import register_setting, unregister_setting 6 | from dbsettings.management import mk_permissions 7 | 8 | __all__ = ['Group'] 9 | 10 | 11 | class GroupBase(type): 12 | def __init__(mcs, name, bases, attrs): 13 | if not bases or bases == (object,): 14 | return 15 | attrs.pop('__module__', None) 16 | attrs.pop('__doc__', None) 17 | attrs.pop('__qualname__', None) 18 | for attribute_name, attr in attrs.items(): 19 | if not isinstance(attr, Value): 20 | raise TypeError('The type of %s (%s) is not a valid Value.' % 21 | (attribute_name, attr.__class__.__name__)) 22 | mcs.add_to_class(attribute_name, attr) 23 | super(GroupBase, mcs).__init__(name, bases, attrs) 24 | 25 | 26 | class GroupDescriptor(object): 27 | def __init__(self, group, attribute_name): 28 | self.group = group 29 | self.attribute_name = attribute_name 30 | 31 | def __get__(self, instance=None, cls=None): 32 | if instance is not None: 33 | raise AttributeError("%r is not accessible from %s instances." % 34 | (self.attribute_name, cls.__name__)) 35 | return self.group 36 | 37 | 38 | @six.add_metaclass(GroupBase) 39 | class Group(object): 40 | 41 | def __new__(cls, verbose_name=None, copy=True, app_label=None): 42 | # If not otherwise provided, set the module to where it was executed 43 | if '__module__' in cls.__dict__: 44 | module_name = cls.__dict__['__module__'] 45 | else: 46 | module_name = sys._getframe(1).f_globals['__name__'] 47 | 48 | attrs = [(k, v) for (k, v) in cls.__dict__.items() if isinstance(v, Value)] 49 | if copy: 50 | attrs = [(k, v.copy()) for (k, v) in attrs] 51 | attrs.sort(key=lambda a: a[1]) 52 | 53 | for _, attr in attrs: 54 | attr.creation_counter = Value.creation_counter 55 | Value.creation_counter += 1 56 | if not hasattr(attr, 'verbose_name'): 57 | attr.verbose_name = verbose_name 58 | if app_label: 59 | attr._app = app_label 60 | register_setting(attr) 61 | 62 | attr_dict = dict(attrs + [('__module__', module_name)]) 63 | 64 | # A new class is created so descriptors work properly 65 | # object.__new__ is necessary here to avoid recursion 66 | group = object.__new__(type('Group', (cls,), attr_dict)) 67 | group._settings = attrs 68 | 69 | return group 70 | 71 | def contribute_to_class(self, cls, name): 72 | # Override module_name and class_name of all registered settings 73 | for attr in self.__class__.__dict__.values(): 74 | if isinstance(attr, Value): 75 | unregister_setting(attr) 76 | attr.module_name = cls.__module__ 77 | attr.class_name = cls.__name__ 78 | attr._app = cls._meta.app_label 79 | register_setting(attr) 80 | 81 | # Create permission for editing settings on the model 82 | permission = ( 83 | 'can_edit_%s_settings' % cls.__name__.lower(), 84 | 'Can edit %s settings' % cls._meta.verbose_name_raw, 85 | ) 86 | if permission not in cls._meta.permissions: 87 | # Add a permission for the setting editor 88 | try: 89 | cls._meta.permissions.append(permission) 90 | except AttributeError: 91 | # Permissions were supplied as a tuple, so preserve that 92 | cls._meta.permissions = tuple(cls._meta.permissions + (permission,)) 93 | # Django migrations runner cache properties 94 | if hasattr(cls._meta, 'original_attrs'): 95 | cls._meta.original_attrs['permissions'] = cls._meta.permissions 96 | 97 | # Finally, place the attribute on the class 98 | setattr(cls, name, GroupDescriptor(self, name)) 99 | 100 | @classmethod 101 | def add_to_class(cls, attribute_name, value): 102 | value.contribute_to_class(cls, attribute_name) 103 | 104 | def __add__(self, other): 105 | if not isinstance(other, Group): 106 | raise NotImplementedError('Groups may only be added to other groups.') 107 | 108 | attrs = dict(self._settings + other._settings) 109 | attrs['__module__'] = sys._getframe(1).f_globals['__name__'] 110 | return type('Group', (Group,), attrs)(copy=False) 111 | 112 | def __iter__(self): 113 | for attribute_name, _ in self._settings: 114 | yield attribute_name, getattr(self, attribute_name) 115 | 116 | def keys(self): 117 | return [k for (k, _) in self] 118 | 119 | def values(self): 120 | return [v for (_, v) in self] 121 | -------------------------------------------------------------------------------- /dbsettings/loading.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from django.core.cache import cache 3 | 4 | 5 | __all__ = ['get_all_settings', 'get_setting', 'get_setting_storage', 6 | 'register_setting', 'unregister_setting', 'set_setting_value'] 7 | 8 | 9 | _settings = OrderedDict() 10 | 11 | 12 | def _get_cache_key(module_name, class_name, attribute_name): 13 | return '.'.join(['dbsettings', module_name, class_name, attribute_name]) 14 | 15 | 16 | def get_all_settings(): 17 | return list(_settings.values()) 18 | 19 | 20 | def get_app_settings(app_label): 21 | return [p for p in _settings.values() if app_label == p.app] 22 | 23 | 24 | def get_setting(module_name, class_name, attribute_name): 25 | return _settings[module_name, class_name, attribute_name] 26 | 27 | 28 | def setting_in_db(module_name, class_name, attribute_name): 29 | from dbsettings.models import Setting 30 | return Setting.objects.filter( 31 | module_name=module_name, 32 | class_name=class_name, 33 | attribute_name=attribute_name, 34 | ).count() == 1 35 | 36 | 37 | def get_setting_storage(module_name, class_name, attribute_name): 38 | from dbsettings.models import Setting 39 | from dbsettings.settings import USE_CACHE 40 | storage = None 41 | if USE_CACHE: 42 | key = _get_cache_key(module_name, class_name, attribute_name) 43 | storage = cache.get(key) 44 | if storage is None: 45 | try: 46 | storage = Setting.objects.get( 47 | module_name=module_name, 48 | class_name=class_name, 49 | attribute_name=attribute_name, 50 | ) 51 | except Setting.DoesNotExist: 52 | setting_object = get_setting(module_name, class_name, attribute_name) 53 | storage = Setting( 54 | module_name=module_name, 55 | class_name=class_name, 56 | attribute_name=attribute_name, 57 | value=setting_object.default, 58 | ) 59 | if USE_CACHE: 60 | cache.set(key, storage) 61 | return storage 62 | 63 | 64 | def register_setting(setting): 65 | if setting.key not in _settings: 66 | _settings[setting.key] = setting 67 | 68 | 69 | def unregister_setting(setting): 70 | if setting.key in _settings and _settings[setting.key] is setting: 71 | del _settings[setting.key] 72 | 73 | 74 | def set_setting_value(module_name, class_name, attribute_name, value): 75 | from dbsettings.settings import USE_CACHE 76 | setting = get_setting(module_name, class_name, attribute_name) 77 | storage = get_setting_storage(module_name, class_name, attribute_name) 78 | storage.value = setting.get_db_prep_save(value) 79 | storage.save() 80 | if USE_CACHE: 81 | key = _get_cache_key(module_name, class_name, attribute_name) 82 | cache.delete(key) 83 | -------------------------------------------------------------------------------- /dbsettings/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sciyoshi/django-dbsettings/549423861af71c075ba07fd99c57033b1d01d1b9/dbsettings/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /dbsettings/locale/pl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-dbsettings 0.4\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2013-10-11 11:48+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: Jacek Tomaszewski \n" 14 | "Language: Polish\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " 19 | "|| n%100>=20) ? 1 : 2)\n" 20 | 21 | #: values.py:220 22 | msgid "" 23 | "Leave empty in order to retain old password. Provide new value to change." 24 | msgstr "Pozostaw pole puste, by nie zmieniać hasła. Wpisz nową wartość, by zmienić." 25 | 26 | #: views.py:20 27 | msgid "Site settings" 28 | msgstr "Wszystkie ustawienia" 29 | 30 | #: views.py:23 31 | #, python-format 32 | msgid "%(app)s settings" 33 | msgstr "Ustawienia %(app)s" 34 | 35 | #: views.py:52 36 | #, python-format 37 | msgid "Updated %(desc)s on %(location)s" 38 | msgstr "Uaktualniono '%(desc)s' w %(location)s" 39 | 40 | #: templates/dbsettings/app_settings.html:11 41 | #: templates/dbsettings/site_settings.html:11 42 | msgid "Home" 43 | msgstr "Początek" 44 | 45 | #: templates/dbsettings/app_settings.html:12 46 | #: templates/dbsettings/site_settings.html:12 47 | msgid "Edit Settings" 48 | msgstr "Zmień ustawienia" 49 | 50 | #: templates/dbsettings/app_settings.html:20 51 | #: templates/dbsettings/site_settings.html:20 52 | msgid "Please correct the error below." 53 | msgid_plural "Please correct the errors below." 54 | msgstr[0] "Popraw poniższy błąd" 55 | msgstr[1] "Popraw poniższe błędy" 56 | msgstr[2] "Popraw poniższe błędy" 57 | 58 | #: templates/dbsettings/app_settings.html:28 59 | #, python-format 60 | msgid "Settings included in the %(name)s class." 61 | msgstr "Ustawienia dotyczące klasy %(name)s." 62 | 63 | #: templates/dbsettings/app_settings.html:29 64 | #: templates/dbsettings/site_settings.html:33 65 | msgid "Application settings" 66 | msgstr "Ustawienia aplikacji" 67 | 68 | #: templates/dbsettings/app_settings.html:37 69 | #: templates/dbsettings/site_settings.html:44 70 | msgid "You don't have permission to edit values." 71 | msgstr "Nie masz uprawnień do edycji ustawień." 72 | 73 | #: templates/dbsettings/site_settings.html:28 74 | #, python-format 75 | msgid "Models available in the %(name)s application." 76 | msgstr "Modele dostępne w aplikacji %(name)s. ABCDEF" 77 | -------------------------------------------------------------------------------- /dbsettings/management.py: -------------------------------------------------------------------------------- 1 | from django import VERSION 2 | from django.db.models.signals import post_migrate 3 | 4 | 5 | def mk_permissions(permissions, appname, verbosity): 6 | """ 7 | Make permission at app level - hack with empty ContentType. 8 | 9 | Adapted code from http://djangosnippets.org/snippets/334/ 10 | """ 11 | from django.contrib.auth.models import Permission 12 | from django.contrib.contenttypes.models import ContentType 13 | # create a content type for the app 14 | defaults = {} if VERSION >= (1, 10) else {'name': appname} 15 | ct, created = ContentType.objects.get_or_create(model='', app_label=appname, 16 | defaults=defaults) 17 | if created and verbosity >= 2: 18 | print("Adding custom content type '%s'" % ct) 19 | # create permissions 20 | for codename, name in permissions: 21 | p, created = Permission.objects.get_or_create(codename=codename, 22 | content_type__pk=ct.id, 23 | defaults={'name': name, 'content_type': ct}) 24 | if created and verbosity >= 2: 25 | print("Adding custom permission '%s'" % p) 26 | 27 | 28 | def handler(sender, **kwargs): 29 | from dbsettings.loading import get_app_settings 30 | app_label = sender.label 31 | are_global_settings = any(not s.class_name for s in get_app_settings(app_label)) 32 | if are_global_settings: 33 | permission = ( 34 | 'can_edit__settings', 35 | 'Can edit %s non-model settings' % app_label, 36 | ) 37 | mk_permissions([permission], app_label, 0) 38 | 39 | 40 | post_migrate.connect(handler) 41 | -------------------------------------------------------------------------------- /dbsettings/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | from dbsettings.settings import USE_SITES, VALUE_LENGTH 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [] 12 | if USE_SITES: 13 | dependencies.append(('sites', '0001_initial')) 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Setting', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('module_name', models.CharField(max_length=255)), 21 | ('class_name', models.CharField(max_length=255, blank=True)), 22 | ('attribute_name', models.CharField(max_length=255)), 23 | ('value', models.CharField(max_length=VALUE_LENGTH, blank=True)), 24 | ] + ([('site', models.ForeignKey(to='sites.Site'))] if USE_SITES else []) 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /dbsettings/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sciyoshi/django-dbsettings/549423861af71c075ba07fd99c57033b1d01d1b9/dbsettings/migrations/__init__.py -------------------------------------------------------------------------------- /dbsettings/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from dbsettings.settings import USE_SITES, VALUE_LENGTH 4 | 5 | if USE_SITES: 6 | from django.contrib.sites.models import Site 7 | 8 | class SiteSettingManager(models.Manager): 9 | def get_queryset(self): 10 | sup = super(SiteSettingManager, self) 11 | qs = sup.get_queryset() if hasattr(sup, 'get_queryset') else sup.get_query_set() 12 | return qs.filter(site=Site.objects.get_current()) 13 | get_query_set = get_queryset 14 | 15 | 16 | class Setting(models.Model): 17 | module_name = models.CharField(max_length=255) 18 | class_name = models.CharField(max_length=255, blank=True) 19 | attribute_name = models.CharField(max_length=255) 20 | value = models.CharField(max_length=VALUE_LENGTH, blank=True) 21 | 22 | if USE_SITES: 23 | site = models.ForeignKey(Site) 24 | objects = SiteSettingManager() 25 | 26 | def save(self, *args, **kwargs): 27 | self.site = Site.objects.get_current() 28 | return super(Setting, self).save(*args, **kwargs) 29 | 30 | def __bool__(self): 31 | return self.pk is not None 32 | -------------------------------------------------------------------------------- /dbsettings/settings.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf import settings 3 | 4 | 5 | sites_installed = apps.is_installed('django.contrib.sites') 6 | USE_SITES = getattr(settings, 'DBSETTINGS_USE_SITES', sites_installed) 7 | USE_CACHE = getattr(settings, 'DBSETTINGS_USE_CACHE', True) 8 | VALUE_LENGTH = getattr(settings, 'DBSETTINGS_VALUE_LENGTH', 255) 9 | -------------------------------------------------------------------------------- /dbsettings/templates/dbsettings/app_settings.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n static %} 3 | 4 | {% block extrastyle %}{{ block.super }}{% endblock %} 5 | 6 | {% block coltype %}colMS{% endblock %} 7 | {% block bodyclass %}dbsettings{% endblock %} 8 | {% block breadcrumbs %}{% if not is_popup %} 9 | 13 | {% endif %}{% endblock %} 14 | 15 | {% block content %} 16 |
17 | {% if form.errors %} 18 |

19 | {% blocktrans count form.errors|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} 20 |

21 | {% endif %} 22 | {% if form.fields %} 23 | {% regroup form by class_name as classes %} 24 |
25 | {% for class in classes %} 26 |
27 | 28 | 29 | {% include "dbsettings/values.html" %} 30 |
{% filter capfirst %}{% if class.grouper %}{{ class.grouper }}{% else %}{% trans "Application settings" %}{% endif %}{% endfilter %}
31 |
32 | {% endfor %} 33 | {% csrf_token %} 34 |
35 | {% else %} 36 |

{% trans "You don't have permission to edit values." %}

37 | {% endif %} 38 |
39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /dbsettings/templates/dbsettings/site_settings.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n static %} 3 | 4 | {% block extrastyle %}{{ block.super }}{% endblock %} 5 | 6 | {% block coltype %}colMS{% endblock %} 7 | {% block bodyclass %}dbsettings{% endblock %} 8 | {% block breadcrumbs %}{% if not is_popup %} 9 | 13 | {% endif %}{% endblock %} 14 | 15 | {% block content %} 16 |
17 | {% if form.errors %} 18 |

19 | {% blocktrans count form.errors|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} 20 |

21 | {% endif %} 22 | {% if form.fields %} 23 | {% regroup form by module_name as modules %} 24 |
25 | {% for module in modules %} 26 |
27 | 28 | 29 | {% regroup module.list by class_name as classes %} 30 | {% for class in classes %} 31 | 32 | 33 | 34 | 35 | {% include "dbsettings/values.html" %} 36 | {% endfor %} 37 |
{% filter capfirst %}{{ module.grouper }}{% endfilter %}
{% filter capfirst %}{% if class.grouper %}{{ class.grouper }}{% else %}{% trans "Application settings" %}{% endif %}{% endfilter %} 
38 |
39 | {% endfor %} 40 | {% csrf_token %} 41 |
42 | {% else %} 43 |

{% trans "You don't have permission to edit values." %}

44 | {% endif %} 45 |
46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /dbsettings/templates/dbsettings/values.html: -------------------------------------------------------------------------------- 1 | {% regroup class.list|dictsort:"verbose_name" by verbose_name as classes %} 2 | {% for class in classes %} 3 | {% if class.grouper %} 4 | 5 | {% filter capfirst %}{{ class.grouper }}{% endfilter %} 6 | 7 | {% endif %} 8 | {% for field in class.list %} 9 | {% if field.errors %} 10 | 11 | {{ field.errors }} 12 | 13 | {% endif %} 14 | 15 | 16 | {{ field.label_tag }} 17 | {% if field.help_text %} 18 |

{{ field.help_text|escape }}

19 | {% endif %} 20 | 21 | {{ field }} 22 | 23 | {% endfor %} 24 | {% endfor %} 25 | -------------------------------------------------------------------------------- /dbsettings/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sciyoshi/django-dbsettings/549423861af71c075ba07fd99c57033b1d01d1b9/dbsettings/tests/__init__.py -------------------------------------------------------------------------------- /dbsettings/tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | from django.contrib import admin 3 | 4 | 5 | urlpatterns = [ 6 | url(r'^admin/', include(admin.site.urls)), 7 | url(r'^settings/', include('dbsettings.urls')), 8 | ] 9 | -------------------------------------------------------------------------------- /dbsettings/tests/tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import django 4 | from django.db import models 5 | from django import test 6 | from django.utils import six 7 | from django.utils.functional import curry 8 | from django.utils.translation import activate, deactivate 9 | 10 | import dbsettings 11 | from dbsettings import loading, views 12 | 13 | 14 | # Set up some settings to test 15 | MODULE_NAME = 'dbsettings.tests.tests' 16 | 17 | 18 | class TestSettings(dbsettings.Group): 19 | boolean = dbsettings.BooleanValue() 20 | integer = dbsettings.IntegerValue() 21 | string = dbsettings.StringValue() 22 | list_semi_colon = dbsettings.MultiSeparatorValue() 23 | list_comma = dbsettings.MultiSeparatorValue(separator=',') 24 | date = dbsettings.DateValue() 25 | time = dbsettings.TimeValue() 26 | datetime = dbsettings.DateTimeValue() 27 | 28 | # This is assigned to module, rather than a model 29 | module_settings = TestSettings(app_label='dbsettings') 30 | 31 | 32 | class Defaults(models.Model): 33 | class settings(dbsettings.Group): 34 | boolean = dbsettings.BooleanValue(default=True) 35 | boolean_false = dbsettings.BooleanValue(default=False) 36 | integer = dbsettings.IntegerValue(default=1) 37 | string = dbsettings.StringValue(default="default") 38 | list_semi_colon = dbsettings.MultiSeparatorValue(default=['one', 'two']) 39 | list_comma = dbsettings.MultiSeparatorValue(separator=',', default=('one', 'two')) 40 | date = dbsettings.DateValue(default=datetime.date(2012, 3, 14)) 41 | time = dbsettings.TimeValue(default=datetime.time(12, 3, 14)) 42 | datetime = dbsettings.DateTimeValue(default=datetime.datetime(2012, 3, 14, 12, 3, 14)) 43 | settings = settings() 44 | 45 | 46 | class TestBaseModel(models.Model): 47 | class Meta: 48 | abstract = True 49 | app_label = 'dbsettings' 50 | 51 | 52 | # These will be populated by the fixture data 53 | class Populated(TestBaseModel): 54 | settings = TestSettings() 55 | 56 | 57 | # These will be empty after startup 58 | class Unpopulated(TestBaseModel): 59 | settings = TestSettings() 60 | 61 | 62 | # These will allow blank values 63 | class Blankable(TestBaseModel): 64 | settings = TestSettings() 65 | 66 | 67 | class Editable(TestBaseModel): 68 | settings = TestSettings('Verbose name') 69 | 70 | 71 | class Combined(TestBaseModel): 72 | class settings(dbsettings.Group): 73 | enabled = dbsettings.BooleanValue() 74 | settings = TestSettings() + settings() 75 | 76 | 77 | # For registration testing 78 | class ClashSettings1(dbsettings.Group): 79 | clash1 = dbsettings.BooleanValue() 80 | 81 | 82 | class ClashSettings2(dbsettings.Group): 83 | clash2 = dbsettings.BooleanValue() 84 | 85 | 86 | class ClashSettings1_2(dbsettings.Group): 87 | clash1 = dbsettings.IntegerValue() 88 | clash2 = dbsettings.IntegerValue() 89 | 90 | module_clash1 = ClashSettings1(app_label='dbsettings') 91 | 92 | 93 | class ModelClash(TestBaseModel): 94 | settings = ClashSettings1_2() 95 | 96 | module_clash2 = ClashSettings2(app_label='dbsettings') 97 | 98 | 99 | class NonRequiredSettings(dbsettings.Group): 100 | integer = dbsettings.IntegerValue(required=False) 101 | string = dbsettings.StringValue(required=False) 102 | fl = dbsettings.FloatValue(required=False) 103 | decimal = dbsettings.DecimalValue(required=False) 104 | percent = dbsettings.PercentValue(required=False) 105 | 106 | 107 | class NonReq(TestBaseModel): 108 | non_req = NonRequiredSettings() 109 | 110 | 111 | @test.override_settings(ROOT_URLCONF='dbsettings.tests.test_urls') 112 | class SettingsTestCase(test.TestCase): 113 | 114 | @classmethod 115 | def setUpClass(cls): 116 | # Since some text assertions are performed, make sure that no translation interrupts. 117 | super(SettingsTestCase, cls).setUpClass() 118 | activate('en') 119 | 120 | @classmethod 121 | def tearDownClass(cls): 122 | deactivate() 123 | super(SettingsTestCase, cls).tearDownClass() 124 | 125 | def setUp(self): 126 | super(SettingsTestCase, self).setUp() 127 | # Standard test fixtures don't update the in-memory cache. 128 | # So we have to do it ourselves this time. 129 | loading.set_setting_value(MODULE_NAME, 'Populated', 'boolean', True) 130 | loading.set_setting_value(MODULE_NAME, 'Populated', 'integer', 42) 131 | loading.set_setting_value(MODULE_NAME, 'Populated', 'string', 'Ni!') 132 | loading.set_setting_value(MODULE_NAME, 'Populated', 'list_semi_colon', 133 | 'a@b.com;c@d.com;e@f.com') 134 | loading.set_setting_value(MODULE_NAME, 'Populated', 'list_comma', 135 | 'a@b.com,c@d.com,e@f.com') 136 | loading.set_setting_value(MODULE_NAME, 'Populated', 'date', '2012-06-28') 137 | loading.set_setting_value(MODULE_NAME, 'Populated', 'time', '16:19:17') 138 | loading.set_setting_value(MODULE_NAME, 'Populated', 'datetime', 139 | '2012-06-28 16:19:17') 140 | loading.set_setting_value(MODULE_NAME, '', 'boolean', False) 141 | loading.set_setting_value(MODULE_NAME, '', 'integer', 14) 142 | loading.set_setting_value(MODULE_NAME, '', 'string', 'Module') 143 | loading.set_setting_value(MODULE_NAME, '', 'list_semi_colon', 144 | 'g@h.com;i@j.com;k@l.com') 145 | loading.set_setting_value(MODULE_NAME, '', 'list_comma', 'g@h.com,i@j.com,k@l.com') 146 | loading.set_setting_value(MODULE_NAME, '', 'date', '2011-05-27') 147 | loading.set_setting_value(MODULE_NAME, '', 'time', '15:18:16') 148 | loading.set_setting_value(MODULE_NAME, '', 'datetime', '2011-05-27 15:18:16') 149 | loading.set_setting_value(MODULE_NAME, 'Combined', 'boolean', False) 150 | loading.set_setting_value(MODULE_NAME, 'Combined', 'integer', 1138) 151 | loading.set_setting_value(MODULE_NAME, 'Combined', 'string', 'THX') 152 | loading.set_setting_value(MODULE_NAME, 'Combined', 'list_semi_colon', 153 | 'm@n.com;o@p.com;q@r.com') 154 | loading.set_setting_value(MODULE_NAME, 'Combined', 'list_comma', 155 | 'm@n.com,o@p.com,q@r.com') 156 | loading.set_setting_value(MODULE_NAME, 'Combined', 'date', '2010-04-26') 157 | loading.set_setting_value(MODULE_NAME, 'Combined', 'time', '14:17:15') 158 | loading.set_setting_value(MODULE_NAME, 'Combined', 'datetime', '2010-04-26 14:17:15') 159 | loading.set_setting_value(MODULE_NAME, 'Combined', 'enabled', True) 160 | 161 | def test_settings(self): 162 | "Make sure settings groups are initialized properly" 163 | 164 | # Settings already in the database are available immediately 165 | self.assertEqual(Populated.settings.boolean, True) 166 | self.assertEqual(Populated.settings.integer, 42) 167 | self.assertEqual(Populated.settings.string, 'Ni!') 168 | self.assertEqual(Populated.settings.list_semi_colon, ['a@b.com', 'c@d.com', 'e@f.com']) 169 | self.assertEqual(Populated.settings.list_comma, ['a@b.com', 'c@d.com', 'e@f.com']) 170 | self.assertEqual(Populated.settings.date, datetime.date(2012, 6, 28)) 171 | self.assertEqual(Populated.settings.time, datetime.time(16, 19, 17)) 172 | self.assertEqual(Populated.settings.datetime, datetime.datetime(2012, 6, 28, 16, 19, 17)) 173 | 174 | # Module settings are kept separate from model settings 175 | self.assertEqual(module_settings.boolean, False) 176 | self.assertEqual(module_settings.integer, 14) 177 | self.assertEqual(module_settings.string, 'Module') 178 | self.assertEqual(module_settings.list_semi_colon, ['g@h.com', 'i@j.com', 'k@l.com']) 179 | self.assertEqual(module_settings.list_comma, ['g@h.com', 'i@j.com', 'k@l.com']) 180 | self.assertEqual(module_settings.date, datetime.date(2011, 5, 27)) 181 | self.assertEqual(module_settings.time, datetime.time(15, 18, 16)) 182 | self.assertEqual(module_settings.datetime, datetime.datetime(2011, 5, 27, 15, 18, 16)) 183 | 184 | # Settings can be added together 185 | self.assertEqual(Combined.settings.boolean, False) 186 | self.assertEqual(Combined.settings.integer, 1138) 187 | self.assertEqual(Combined.settings.string, 'THX') 188 | self.assertEqual(Combined.settings.enabled, True) 189 | self.assertEqual(Combined.settings.list_semi_colon, ['m@n.com', 'o@p.com', 'q@r.com']) 190 | self.assertEqual(Combined.settings.list_comma, ['m@n.com', 'o@p.com', 'q@r.com']) 191 | self.assertEqual(Combined.settings.date, datetime.date(2010, 4, 26)) 192 | self.assertEqual(Combined.settings.time, datetime.time(14, 17, 15)) 193 | self.assertEqual(Combined.settings.datetime, datetime.datetime(2010, 4, 26, 14, 17, 15)) 194 | 195 | # Settings not in the database use empty defaults 196 | self.assertEqual(Unpopulated.settings.boolean, False) 197 | self.assertEqual(Unpopulated.settings.integer, None) 198 | self.assertEqual(Unpopulated.settings.string, '') 199 | self.assertEqual(Unpopulated.settings.list_semi_colon, []) 200 | self.assertEqual(Unpopulated.settings.list_comma, []) 201 | 202 | # ...Unless a default parameter was specified, then they use that 203 | self.assertEqual(Defaults.settings.boolean, True) 204 | self.assertEqual(Defaults.settings.boolean_false, False) 205 | self.assertEqual(Defaults.settings.integer, 1) 206 | self.assertEqual(Defaults.settings.string, 'default') 207 | self.assertEqual(Defaults.settings.list_semi_colon, ['one', 'two']) 208 | self.assertEqual(Defaults.settings.list_comma, ['one', 'two']) 209 | self.assertEqual(Defaults.settings.date, datetime.date(2012, 3, 14)) 210 | self.assertEqual(Defaults.settings.time, datetime.time(12, 3, 14)) 211 | self.assertEqual(Defaults.settings.datetime, datetime.datetime(2012, 3, 14, 12, 3, 14)) 212 | 213 | # Settings should be retrieved in the order of definition 214 | self.assertEqual(Populated.settings.keys(), 215 | ['boolean', 'integer', 'string', 'list_semi_colon', 216 | 'list_comma', 'date', 'time', 'datetime']) 217 | self.assertEqual(Combined.settings.keys(), 218 | ['boolean', 'integer', 'string', 'list_semi_colon', 219 | 'list_comma', 'date', 'time', 'datetime', 'enabled']) 220 | 221 | # Values should be coerced to the proper Python types 222 | self.assertTrue(isinstance(Populated.settings.boolean, bool)) 223 | self.assertTrue(isinstance(Populated.settings.integer, int)) 224 | self.assertTrue(isinstance(Populated.settings.string, six.string_types)) 225 | 226 | # Settings can not be accessed directly from models, only instances 227 | self.assertRaises(AttributeError, lambda: Populated().settings) 228 | self.assertRaises(AttributeError, lambda: Unpopulated().settings) 229 | 230 | # Updates are reflected in the live settings 231 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'boolean', True) 232 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'integer', 13) 233 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'string', 'Friday') 234 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'list_semi_colon', 235 | 'aa@bb.com;cc@dd.com') 236 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'list_comma', 237 | 'aa@bb.com,cc@dd.com') 238 | # for date/time you can specify string (as above) or proper object 239 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'date', 240 | datetime.date(1912, 6, 23)) 241 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'time', 242 | datetime.time(1, 2, 3)) 243 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'datetime', 244 | datetime.datetime(1912, 6, 23, 1, 2, 3)) 245 | 246 | self.assertEqual(Unpopulated.settings.boolean, True) 247 | self.assertEqual(Unpopulated.settings.integer, 13) 248 | self.assertEqual(Unpopulated.settings.string, 'Friday') 249 | self.assertEqual(Unpopulated.settings.list_semi_colon, ['aa@bb.com', 'cc@dd.com']) 250 | self.assertEqual(Unpopulated.settings.list_comma, ['aa@bb.com', 'cc@dd.com']) 251 | self.assertEqual(Unpopulated.settings.date, datetime.date(1912, 6, 23)) 252 | self.assertEqual(Unpopulated.settings.time, datetime.time(1, 2, 3)) 253 | self.assertEqual(Unpopulated.settings.datetime, datetime.datetime(1912, 6, 23, 1, 2, 3)) 254 | 255 | # Updating settings with defaults 256 | loading.set_setting_value(MODULE_NAME, 'Defaults', 'boolean', False) 257 | self.assertEqual(Defaults.settings.boolean, False) 258 | loading.set_setting_value(MODULE_NAME, 'Defaults', 'boolean_false', True) 259 | self.assertEqual(Defaults.settings.boolean_false, True) 260 | 261 | # Updating blankable settings 262 | self.assertEqual(Blankable.settings.string, '') 263 | loading.set_setting_value(MODULE_NAME, 'Blankable', 'string', 'Eli') 264 | self.assertEqual(Blankable.settings.string, 'Eli') 265 | loading.set_setting_value(MODULE_NAME, 'Blankable', 'string', '') 266 | self.assertEqual(Blankable.settings.string, '') 267 | 268 | # And they can be modified in-place 269 | Unpopulated.settings.boolean = False 270 | Unpopulated.settings.integer = 42 271 | Unpopulated.settings.string = 'Caturday' 272 | Unpopulated.settings.date = datetime.date(1939, 9, 1) 273 | Unpopulated.settings.time = '03:47:00' 274 | Unpopulated.settings.datetime = datetime.datetime(1939, 9, 1, 3, 47, 0) 275 | # Test correct stripping while we're at it. 276 | Unpopulated.settings.list_semi_colon = 'ee@ff.com; gg@hh.com' 277 | Unpopulated.settings.list_comma = 'ee@ff.com ,gg@hh.com' 278 | self.assertEqual(Unpopulated.settings.boolean, False) 279 | self.assertEqual(Unpopulated.settings.integer, 42) 280 | self.assertEqual(Unpopulated.settings.string, 'Caturday') 281 | self.assertEqual(Unpopulated.settings.list_semi_colon, ['ee@ff.com', 'gg@hh.com']) 282 | self.assertEqual(Unpopulated.settings.list_comma, ['ee@ff.com', 'gg@hh.com']) 283 | self.assertEqual(Unpopulated.settings.date, datetime.date(1939, 9, 1)) 284 | self.assertEqual(Unpopulated.settings.time, datetime.time(3, 47, 0)) 285 | self.assertEqual(Unpopulated.settings.datetime, datetime.datetime(1939, 9, 1, 3, 47, 0)) 286 | 287 | # Test non-required settings 288 | self.assertEqual(NonReq.non_req.integer, None) 289 | self.assertEqual(NonReq.non_req.fl, None) 290 | self.assertEqual(NonReq.non_req.string, "") 291 | 292 | loading.set_setting_value(MODULE_NAME, 'NonReq', 'integer', '2') 293 | self.assertEqual(NonReq.non_req.integer, 2) 294 | loading.set_setting_value(MODULE_NAME, 'NonReq', 'integer', '') 295 | self.assertEqual(NonReq.non_req.integer, None) 296 | 297 | def test_declaration(self): 298 | "Group declarations can only contain values and a docstring" 299 | # This definition is fine 300 | attrs = { 301 | '__doc__': "This is a docstring", 302 | 'test': dbsettings.IntegerValue(), 303 | } 304 | # So this should succeed 305 | type('GoodGroup', (dbsettings.Group,), attrs) 306 | 307 | # By adding an invalid attribute 308 | attrs['problem'] = 'not a Value' 309 | # This should fail 310 | self.assertRaises(TypeError, curry(type, 'BadGroup', (dbsettings.Group,), attrs)) 311 | 312 | # Make sure affect models get the new permissions 313 | self.assertTrue('can_edit_populated_settings' in dict(Populated._meta.permissions)) 314 | self.assertTrue('can_edit_unpopulated_settings' in dict(Unpopulated._meta.permissions)) 315 | 316 | def assertCorrectSetting(self, value_class, *key): 317 | from dbsettings import loading 318 | setting = loading.get_setting(*key) 319 | self.assertEqual(key, setting.key) # Check if setting is registered with proper key 320 | self.assertTrue(isinstance(setting, value_class)) 321 | 322 | def test_registration(self): 323 | "Module and class settings can be mixed up" 324 | from dbsettings import BooleanValue, IntegerValue 325 | self.assertCorrectSetting(BooleanValue, MODULE_NAME, '', 'clash1') 326 | self.assertCorrectSetting(IntegerValue, MODULE_NAME, 'ModelClash', 'clash1') 327 | self.assertCorrectSetting(IntegerValue, MODULE_NAME, 'ModelClash', 'clash2') 328 | self.assertCorrectSetting(BooleanValue, MODULE_NAME, '', 'clash2') 329 | 330 | def assertLoginFormShown(self, response): 331 | self.assertRedirects(response, '/admin/login/?next=/settings/') 332 | 333 | def test_forms(self): 334 | "Forms should display only the appropriate settings" 335 | from django.contrib.auth.models import User, Permission 336 | from django.core.urlresolvers import reverse 337 | 338 | site_form = reverse(views.site_settings) 339 | 340 | # Set up a users to test the editor forms 341 | user = User.objects.create_user('dbsettings', '', 'dbsettings') 342 | 343 | # Check named url 344 | site_form = reverse('site_settings') 345 | 346 | # First test without any authenticated user 347 | response = self.client.get(site_form) 348 | self.assertLoginFormShown(response) 349 | 350 | # Then test a standard non-staff user 351 | self.client.login(username='dbsettings', password='dbsettings') 352 | response = self.client.get(site_form) 353 | self.assertLoginFormShown(response) 354 | 355 | # Add staff status, but no settings permissions 356 | user.is_staff = True 357 | user.save() 358 | 359 | # Test the site-wide settings editor 360 | response = self.client.get(site_form) 361 | self.assertTemplateUsed(response, 'dbsettings/site_settings.html') 362 | self.assertEqual(response.context[0]['title'], 'Site settings') 363 | # No settings should show up without proper permissions 364 | self.assertEqual(len(response.context[0]['form'].fields), 0) 365 | 366 | # Add permissions so that settings will show up 367 | perm = Permission.objects.get(codename='can_edit_editable_settings') 368 | user.user_permissions.add(perm) 369 | 370 | # Check if verbose_name appears 371 | response = self.client.get(site_form) 372 | self.assertContains(response, 'Verbose name') 373 | 374 | # Erroneous submissions should be caught by newforms 375 | data = { 376 | '%s__Editable__integer' % MODULE_NAME: '3.5', 377 | '%s__Editable__string' % MODULE_NAME: '', 378 | '%s__Editable__list_semi_colon' % MODULE_NAME: '', 379 | '%s__Editable__list_comma' % MODULE_NAME: '', 380 | '%s__Editable__date' % MODULE_NAME: '3-77-99', 381 | '%s__Editable__time' % MODULE_NAME: 'abc', 382 | '%s__Editable__datetime' % MODULE_NAME: '', 383 | } 384 | response = self.client.post(site_form, data) 385 | self.assertFormError(response, 'form', '%s__Editable__integer' % MODULE_NAME, 386 | 'Enter a whole number.') 387 | self.assertFormError(response, 'form', '%s__Editable__string' % MODULE_NAME, 388 | 'This field is required.') 389 | self.assertFormError(response, 'form', '%s__Editable__list_semi_colon' % MODULE_NAME, 390 | 'This field is required.') 391 | self.assertFormError(response, 'form', '%s__Editable__list_comma' % MODULE_NAME, 392 | 'This field is required.') 393 | self.assertFormError(response, 'form', '%s__Editable__date' % MODULE_NAME, 394 | 'Enter a valid date.') 395 | self.assertFormError(response, 'form', '%s__Editable__time' % MODULE_NAME, 396 | 'Enter a valid time.') 397 | self.assertFormError(response, 'form', '%s__Editable__datetime' % MODULE_NAME, 398 | 'This field is required.') 399 | 400 | # Successful submissions should redirect 401 | data = { 402 | '%s__Editable__integer' % MODULE_NAME: '4', 403 | '%s__Editable__string' % MODULE_NAME: 'Success!', 404 | '%s__Editable__list_semi_colon' % MODULE_NAME: 'jj@kk.com;ll@mm.com', 405 | '%s__Editable__list_comma' % MODULE_NAME: 'jj@kk.com,ll@mm.com', 406 | '%s__Editable__date' % MODULE_NAME: '2012-06-28', 407 | '%s__Editable__time' % MODULE_NAME: '16:37:45', 408 | '%s__Editable__datetime' % MODULE_NAME: '2012-06-28 16:37:45', 409 | } 410 | response = self.client.post(site_form, data) 411 | self.assertRedirects(response, site_form) 412 | 413 | # And the data submitted should be immediately available in Python 414 | self.assertEqual(Editable.settings.integer, 4) 415 | self.assertEqual(Editable.settings.string, 'Success!') 416 | self.assertEqual(Editable.settings.list_semi_colon, ['jj@kk.com', 'll@mm.com']) 417 | self.assertEqual(Editable.settings.list_comma, ['jj@kk.com', 'll@mm.com']) 418 | self.assertEqual(Editable.settings.date, datetime.date(2012, 6, 28)) 419 | self.assertEqual(Editable.settings.time, datetime.time(16, 37, 45)) 420 | self.assertEqual(Editable.settings.datetime, datetime.datetime(2012, 6, 28, 16, 37, 45)) 421 | 422 | # test non-req submission 423 | perm = Permission.objects.get(codename='can_edit_nonreq_settings') 424 | user.user_permissions.add(perm) 425 | data = { 426 | '%s__NonReq__integer' % MODULE_NAME: '', 427 | '%s__NonReq__fl' % MODULE_NAME: '', 428 | '%s__NonReq__decimal' % MODULE_NAME: '', 429 | '%s__NonReq__percent' % MODULE_NAME: '', 430 | '%s__NonReq__string' % MODULE_NAME: '', 431 | } 432 | response = self.client.post(site_form, data) 433 | self.assertEqual(NonReq.non_req.integer, None) 434 | self.assertEqual(NonReq.non_req.fl, None) 435 | self.assertEqual(NonReq.non_req.decimal, None) 436 | self.assertEqual(NonReq.non_req.percent, None) 437 | self.assertEqual(NonReq.non_req.string, '') 438 | user.user_permissions.remove(perm) 439 | 440 | # Check if module level settings show properly 441 | self._test_form_fields(site_form, 8, False) 442 | # Add perm for whole app 443 | perm = Permission.objects.get(codename='can_edit__settings') # module-level settings 444 | user.user_permissions.add(perm) 445 | self._test_form_fields(site_form, 18) 446 | # Remove other perms - left only global perm 447 | perm = Permission.objects.get(codename='can_edit_editable_settings') 448 | user.user_permissions.remove(perm) 449 | self._test_form_fields(site_form, 10) 450 | 451 | def _test_form_fields(self, url, fields_num, present=True, variable_name='form'): 452 | global_setting = '%s____clash2' % MODULE_NAME # Some global setting name 453 | response = self.client.get(url) 454 | self.assertEqual(present, global_setting in response.context[0][variable_name].fields) 455 | self.assertEqual(len(response.context[0][variable_name].fields), fields_num) 456 | -------------------------------------------------------------------------------- /dbsettings/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from dbsettings.views import site_settings, app_settings 4 | 5 | 6 | urlpatterns = [ 7 | url(r'^$', site_settings, name='site_settings'), 8 | url(r'^(?P[^/]+)/$', app_settings, name='app_settings'), 9 | ] 10 | -------------------------------------------------------------------------------- /dbsettings/utils.py: -------------------------------------------------------------------------------- 1 | def set_defaults(app, *defaults): 2 | "Installs a set of default values during syncdb processing" 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.db.models import signals 5 | from dbsettings.loading import set_setting_value, setting_in_db 6 | 7 | if not defaults: 8 | raise ImproperlyConfigured("No defaults were supplied to set_defaults.") 9 | app_label = app.__name__.split('.')[-2] if '.' in app.__name__ else app.__name__ 10 | 11 | def install_settings(app, created_models, verbosity=2, **kwargs): 12 | printed = False 13 | 14 | for class_name, attribute_name, value in defaults: 15 | if not setting_in_db(app.__name__, class_name, attribute_name): 16 | if verbosity >= 2 and not printed: 17 | # Print this message only once, and only if applicable 18 | print("Installing default settings for %s" % app_label) 19 | printed = True 20 | try: 21 | set_setting_value(app.__name__, class_name, attribute_name, value) 22 | except: 23 | raise ImproperlyConfigured("%s requires dbsettings." % app_label) 24 | 25 | signals.post_migrate.connect(install_settings, sender=app, weak=False) 26 | -------------------------------------------------------------------------------- /dbsettings/values.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.utils import six 3 | 4 | import datetime 5 | from decimal import Decimal 6 | from hashlib import md5 7 | from os.path import join as pjoin 8 | import time 9 | import os 10 | 11 | from django import forms 12 | from django.conf import settings 13 | from django.core.files.uploadedfile import SimpleUploadedFile 14 | from django.utils import formats 15 | from django.utils.safestring import mark_safe 16 | from django.utils.translation import ugettext_lazy as _ 17 | 18 | from dbsettings.loading import get_setting_storage, set_setting_value 19 | 20 | __all__ = ['Value', 'BooleanValue', 'DecimalValue', 'EmailValue', 21 | 'DurationValue', 'FloatValue', 'IntegerValue', 'PercentValue', 22 | 'PositiveIntegerValue', 'StringValue', 'TextValue', 'PasswordValue', 23 | 'MultiSeparatorValue', 'ImageValue', 24 | 'DateTimeValue', 'DateValue', 'TimeValue'] 25 | 26 | 27 | class Value(object): 28 | 29 | creation_counter = 0 30 | unitialized_value = None 31 | 32 | def __init__(self, description=None, help_text=None, choices=None, required=True, default=None, widget=None): 33 | self.description = description 34 | self.help_text = help_text 35 | self.choices = choices or [] 36 | self.required = required 37 | self.widget = widget 38 | if default is None: 39 | self.default = self.unitialized_value 40 | else: 41 | self.default = default 42 | 43 | self.creation_counter = Value.creation_counter 44 | Value.creation_counter += 1 45 | 46 | def __lt__(self, other): 47 | # This is needed because bisect does not take a comparison function. 48 | return self.creation_counter < other.creation_counter 49 | 50 | def copy(self): 51 | new_value = self.__class__() 52 | new_value.__dict__ = self.__dict__.copy() 53 | return new_value 54 | 55 | @property 56 | def key(self): 57 | return self.module_name, self.class_name, self.attribute_name 58 | 59 | def contribute_to_class(self, cls, attribute_name): 60 | self.module_name = cls.__module__ 61 | self.class_name = '' 62 | self.attribute_name = attribute_name 63 | self.description = self.description or attribute_name.replace('_', ' ') 64 | 65 | setattr(cls, self.attribute_name, self) 66 | 67 | @property 68 | def app(self): 69 | return getattr(self, '_app', self.module_name.split('.')[-2]) 70 | 71 | def __get__(self, instance=None, cls=None): 72 | if instance is None: 73 | raise AttributeError("%r is only accessible from %s instances." % 74 | (self.attribute_name, cls.__name__)) 75 | try: 76 | storage = get_setting_storage(*self.key) 77 | return self.to_python(storage.value) 78 | except: 79 | return None 80 | 81 | def __set__(self, instance, value): 82 | current_value = self.__get__(instance) 83 | python_value = value if value is None else self.to_python(value) 84 | if python_value != current_value: 85 | set_setting_value(*(self.key + (value,))) 86 | 87 | # Subclasses should override the following methods where applicable 88 | 89 | def meaningless(self, value): 90 | return value is None or value == "" 91 | 92 | def to_python(self, value): 93 | "Returns a native Python object suitable for immediate use" 94 | return value 95 | 96 | def get_db_prep_save(self, value): 97 | "Returns a value suitable for storage into a CharField" 98 | return six.text_type(value) 99 | 100 | def to_editor(self, value): 101 | "Returns a value suitable for display in a form widget" 102 | return six.text_type(value) 103 | 104 | ############### 105 | # VALUE TYPES # 106 | ############### 107 | 108 | 109 | class BooleanValue(Value): 110 | unitialized_value = False 111 | 112 | class field(forms.BooleanField): 113 | 114 | def __init__(self, *args, **kwargs): 115 | kwargs['required'] = False 116 | forms.BooleanField.__init__(self, *args, **kwargs) 117 | 118 | def to_python(self, value): 119 | if value in (True, 't', 'True'): 120 | return True 121 | return False 122 | 123 | to_editor = to_python 124 | 125 | 126 | class DecimalValue(Value): 127 | field = forms.DecimalField 128 | 129 | def to_python(self, value): 130 | return Decimal(value) if not self.meaningless(value) else None 131 | 132 | 133 | # DurationValue has a lot of duplication and ugliness because of issue #2443 134 | # Until DurationField is sorted out, this has to do some extra work 135 | class DurationValue(Value): 136 | 137 | class field(forms.CharField): 138 | def clean(self, value): 139 | try: 140 | return datetime.timedelta(seconds=float(value)) 141 | except (ValueError, TypeError): 142 | raise forms.ValidationError('This value must be a real number.') 143 | except OverflowError: 144 | raise forms.ValidationError('The maximum allowed value is %s' % 145 | datetime.timedelta.max) 146 | 147 | def to_python(self, value): 148 | if isinstance(value, datetime.timedelta): 149 | return value 150 | try: 151 | return datetime.timedelta(seconds=float(value)) 152 | except (ValueError, TypeError): 153 | raise forms.ValidationError('This value must be a real number.') 154 | except OverflowError: 155 | raise forms.ValidationError('The maximum allowed value is %s' % datetime.timedelta.max) 156 | 157 | def get_db_prep_save(self, value): 158 | return six.text_type(value.days * 24 * 3600 + value.seconds 159 | + float(value.microseconds) / 1000000) 160 | 161 | 162 | class FloatValue(Value): 163 | field = forms.FloatField 164 | 165 | def to_python(self, value): 166 | return float(value) if not self.meaningless(value) else None 167 | 168 | 169 | class IntegerValue(Value): 170 | field = forms.IntegerField 171 | 172 | def to_python(self, value): 173 | return int(value) if not self.meaningless(value) else None 174 | 175 | 176 | class PercentValue(Value): 177 | 178 | class field(forms.DecimalField): 179 | def __init__(self, *args, **kwargs): 180 | forms.DecimalField.__init__(self, 100, 0, 5, 2, *args, **kwargs) 181 | 182 | class widget(forms.TextInput): 183 | def render(self, *args, **kwargs): 184 | # Place a percent sign after a smaller text field 185 | attrs = kwargs.pop('attrs', {}) 186 | attrs['size'] = attrs['max_length'] = 6 187 | return mark_safe(forms.TextInput.render(self, attrs=attrs, *args, **kwargs) + ' %') 188 | 189 | def to_python(self, value): 190 | return Decimal(value) / 100 if not self.meaningless(value) else None 191 | 192 | 193 | class PositiveIntegerValue(IntegerValue): 194 | 195 | class field(forms.IntegerField): 196 | 197 | def __init__(self, *args, **kwargs): 198 | kwargs['min_value'] = 0 199 | forms.IntegerField.__init__(self, *args, **kwargs) 200 | 201 | 202 | class StringValue(Value): 203 | unitialized_value = '' 204 | field = forms.CharField 205 | 206 | 207 | class TextValue(Value): 208 | unitialized_value = '' 209 | field = forms.CharField 210 | 211 | def to_python(self, value): 212 | return six.text_type(value) 213 | 214 | 215 | class EmailValue(Value): 216 | unitialized_value = '' 217 | field = forms.EmailField 218 | 219 | def to_python(self, value): 220 | return six.text_type(value) 221 | 222 | 223 | class PasswordValue(Value): 224 | class field(forms.CharField): 225 | widget = forms.PasswordInput 226 | 227 | def __init__(self, **kwargs): 228 | if not kwargs.get('help_text'): 229 | kwargs['help_text'] = _( 230 | 'Leave empty in order to retain old password. Provide new value to change.') 231 | forms.CharField.__init__(self, **kwargs) 232 | 233 | def clean(self, value): 234 | # Retain old password if not changed 235 | if value == '': 236 | value = self.initial 237 | return forms.CharField.clean(self, value) 238 | 239 | 240 | class MultiSeparatorValue(TextValue): 241 | """Provides a way to store list-like string settings. 242 | e.g 'mail@test.com;*@blah.com' would be returned as 243 | ['mail@test.com', '*@blah.com']. What the method 244 | uses to split on can be defined by passing in a 245 | separator string (default is semi-colon as above). 246 | """ 247 | 248 | def __init__(self, description=None, help_text=None, separator=';', required=True, 249 | default=None): 250 | self.separator = separator 251 | if default is not None: 252 | # convert from list to string 253 | default = separator.join(default) 254 | super(MultiSeparatorValue, self).__init__(description=description, 255 | help_text=help_text, 256 | required=required, 257 | default=default) 258 | 259 | class field(forms.CharField): 260 | 261 | class widget(forms.Textarea): 262 | pass 263 | 264 | def to_python(self, value): 265 | if value: 266 | value = six.text_type(value) 267 | value = value.split(self.separator) 268 | value = [x.strip() for x in value if x] 269 | else: 270 | value = [] 271 | return value 272 | 273 | 274 | class ImageValue(Value): 275 | def __init__(self, *args, **kwargs): 276 | if 'upload_to' in kwargs: 277 | self._upload_to = kwargs.pop('upload_to', '') 278 | super(ImageValue, self).__init__(*args, **kwargs) 279 | 280 | class field(forms.ImageField): 281 | class widget(forms.FileInput): 282 | "Widget with preview" 283 | 284 | def render(self, name, value, attrs=None): 285 | output = [] 286 | 287 | try: 288 | if not value: 289 | raise IOError('No value') 290 | 291 | from PIL import Image 292 | Image.open(value.file) 293 | file_name = pjoin(settings.MEDIA_URL, value.name).replace("\\", "/") 294 | params = {"file_name": file_name} 295 | output.append('

' % params) 296 | except IOError: 297 | pass 298 | 299 | output.append(forms.FileInput.render(self, name, value, attrs)) 300 | return mark_safe(''.join(output)) 301 | 302 | def to_python(self, value): 303 | "Returns a native Python object suitable for immediate use" 304 | return six.text_type(value) 305 | 306 | def get_db_prep_save(self, value): 307 | "Returns a value suitable for storage into a CharField" 308 | if not value: 309 | return None 310 | 311 | hashed_name = md5(six.text_type(time.time()).encode()).hexdigest() + value.name[-4:] 312 | image_path = pjoin(self._upload_to, hashed_name) 313 | dest_name = pjoin(settings.MEDIA_ROOT, image_path) 314 | directory = pjoin(settings.MEDIA_ROOT, self._upload_to) 315 | 316 | if not os.path.exists(directory): 317 | os.makedirs(directory) 318 | with open(dest_name, 'wb+') as dest_file: 319 | for chunk in value.chunks(): 320 | dest_file.write(chunk) 321 | 322 | return six.text_type(image_path) 323 | 324 | def to_editor(self, value): 325 | "Returns a value suitable for display in a form widget" 326 | if not value: 327 | return None 328 | 329 | file_name = pjoin(settings.MEDIA_ROOT, value) 330 | try: 331 | with open(file_name, 'rb') as f: 332 | uploaded_file = SimpleUploadedFile(value, f.read(), 'image') 333 | 334 | # hack to retrieve path from `name` attribute 335 | uploaded_file.__dict__['_name'] = value 336 | return uploaded_file 337 | except IOError: 338 | return None 339 | 340 | 341 | class DateTimeValue(Value): 342 | field = forms.DateTimeField 343 | formats_source = 'DATETIME_INPUT_FORMATS' 344 | 345 | @property 346 | def _formats(self): 347 | return formats.get_format(self.formats_source) 348 | 349 | def _parse_format(self, value): 350 | for format in self._formats: 351 | try: 352 | return datetime.datetime.strptime(value, format) 353 | except (ValueError, TypeError): 354 | continue 355 | return None 356 | 357 | def get_db_prep_save(self, value): 358 | if isinstance(value, six.string_types): 359 | return value 360 | return value.strftime(self._formats[0]) 361 | 362 | def to_python(self, value): 363 | if isinstance(value, datetime.datetime): 364 | return value 365 | return self._parse_format(value) 366 | 367 | 368 | class DateValue(DateTimeValue): 369 | field = forms.DateField 370 | formats_source = 'DATE_INPUT_FORMATS' 371 | 372 | def to_python(self, value): 373 | if isinstance(value, datetime.datetime): 374 | return value.date() 375 | elif isinstance(value, datetime.date): 376 | return value 377 | res = self._parse_format(value) 378 | if res is not None: 379 | return res.date() 380 | return res 381 | 382 | 383 | class TimeValue(DateTimeValue): 384 | field = forms.TimeField 385 | formats_source = 'TIME_INPUT_FORMATS' 386 | 387 | def to_python(self, value): 388 | if isinstance(value, datetime.datetime): 389 | return value.time() 390 | elif isinstance(value, datetime.time): 391 | return value 392 | res = self._parse_format(value) 393 | if res is not None: 394 | return res.time() 395 | return res 396 | -------------------------------------------------------------------------------- /dbsettings/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from django.utils import six 3 | 4 | from django.http import HttpResponseRedirect 5 | from django.shortcuts import render 6 | from django.contrib.admin.views.decorators import staff_member_required 7 | from django.utils.text import capfirst 8 | from django.utils.translation import ugettext_lazy as _ 9 | from django.contrib import messages 10 | 11 | from dbsettings import loading, forms 12 | 13 | 14 | @staff_member_required 15 | def app_settings(request, app_label, template='dbsettings/app_settings.html'): 16 | # Determine what set of settings this editor is used for 17 | if app_label is None: 18 | settings = loading.get_all_settings() 19 | title = _('Site settings') 20 | else: 21 | settings = loading.get_app_settings(app_label) 22 | title = _('%(app)s settings') % {'app': capfirst(app_label)} 23 | 24 | # Create an editor customized for the current user 25 | editor = forms.customized_editor(request.user, settings) 26 | 27 | if request.method == 'POST': 28 | # Populate the form with user-submitted data 29 | form = editor(request.POST.copy(), request.FILES) 30 | if form.is_valid(): 31 | form.full_clean() 32 | 33 | for name, value in form.cleaned_data.items(): 34 | key = forms.RE_FIELD_NAME.match(name).groups() 35 | setting = loading.get_setting(*key) 36 | try: 37 | storage = loading.get_setting_storage(*key) 38 | current_value = setting.to_python(storage.value) 39 | except: 40 | current_value = None 41 | 42 | if current_value != setting.to_python(value): 43 | args = key + (value,) 44 | loading.set_setting_value(*args) 45 | 46 | # Give user feedback as to which settings were changed 47 | if setting.class_name: 48 | location = setting.class_name 49 | else: 50 | location = setting.module_name 51 | update_msg = (_('Updated %(desc)s on %(location)s') % 52 | {'desc': six.text_type(setting.description), 53 | 'location': location}) 54 | messages.add_message(request, messages.INFO, update_msg) 55 | 56 | return HttpResponseRedirect(request.path) 57 | else: 58 | # Leave the form populated with current setting values 59 | form = editor() 60 | 61 | return render(request, template, { 62 | 'title': title, 63 | 'form': form, 64 | }) 65 | 66 | 67 | # Site-wide setting editor is identical, but without an app_label 68 | def site_settings(request): 69 | return app_settings(request, app_label=None, template='dbsettings/site_settings.html') 70 | # staff_member_required is implied, since it calls app_settings 71 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import django 3 | from django.conf import settings 4 | from django.core.management import call_command 5 | 6 | 7 | INSTALLED_APPS = ( 8 | # Required contrib apps. 9 | 'django.contrib.admin', 10 | 'django.contrib.auth', 11 | 'django.contrib.contenttypes', 12 | 'django.contrib.sites', 13 | 'django.contrib.sessions', 14 | # Our app and it's test app. 15 | 'dbsettings', 16 | ) 17 | 18 | SETTINGS = { 19 | 'INSTALLED_APPS': INSTALLED_APPS, 20 | 'SITE_ID': 1, 21 | 'ROOT_URLCONF': 'dbsettings.tests.test_urls', 22 | 'DATABASES': { 23 | 'default': { 24 | 'ENGINE': 'django.db.backends.sqlite3', 25 | 'NAME': ':memory:', 26 | } 27 | }, 28 | 'MIDDLEWARE_CLASSES': ( 29 | 'django.contrib.sessions.middleware.SessionMiddleware', 30 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 31 | 'django.contrib.messages.middleware.MessageMiddleware', 32 | ), 33 | 'MIGRATION_MODULES': { 34 | # This allow test models to be created even if they are not in migration. 35 | # Still no better solution available: https://code.djangoproject.com/ticket/7835 36 | # This hack is used in Django testrunner itself. 37 | 'dbsettings': 'dbsettings.skip_migrations_for_test', 38 | }, 39 | 'TEMPLATES': [ 40 | { 41 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 42 | 'APP_DIRS': True, 43 | }, 44 | ], 45 | } 46 | 47 | if not settings.configured: 48 | settings.configure(**SETTINGS) 49 | 50 | if django.VERSION >= (1, 7): 51 | django.setup() 52 | 53 | call_command('test', 'dbsettings') 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | # Dynamically calculate the version based on dbsettings.VERSION 4 | version_tuple = (0, 10, 0) 5 | if version_tuple[2] is not None: 6 | if type(version_tuple[2]) == int: 7 | version = "%d.%d.%s" % version_tuple 8 | else: 9 | version = "%d.%d_%s" % version_tuple 10 | else: 11 | version = "%d.%d" % version_tuple[:2] 12 | 13 | setup( 14 | name='django-dbsettings', 15 | version=version, 16 | description='Application settings whose values can be updated while a project is up and running.', 17 | long_description=open('README.rst').read(), 18 | author='Samuel Cormier-Iijima', 19 | author_email='sciyoshi@gmail.com', 20 | maintainer='Jacek Tomaszewski', 21 | maintainer_email='jacek.tomek@gmail.com', 22 | url='http://github.com/zlorf/django-dbsettings', 23 | packages=[ 24 | 'dbsettings', 25 | 'dbsettings.migrations', 26 | ], 27 | include_package_data=True, 28 | license='BSD', 29 | classifiers=[ 30 | 'Development Status :: 4 - Beta', 31 | 'Environment :: Web Environment', 32 | 'Framework :: Django', 33 | 'Intended Audience :: Developers', 34 | 'License :: OSI Approved :: BSD License', 35 | 'Operating System :: OS Independent', 36 | 'Programming Language :: Python', 37 | 'Topic :: Utilities' 38 | ], 39 | zip_safe=False, 40 | ) 41 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | distribute = False 3 | envlist = 4 | py3-dj-1.7.X, 5 | py3-dj-1.8.X, 6 | py3-dj-1.9.X, 7 | py3-dj-1.10.X, 8 | dj-1.10.X, 9 | dj-1.9.X, 10 | dj-1.8.X, 11 | dj-1.7.X, 12 | 13 | [testenv] 14 | downloadcache = {toxworkdir}/_download/ 15 | commands = 16 | django-admin.py --version 17 | {envpython} runtests.py 18 | deps = 19 | 20 | 21 | [testenv:py3-dj-1.10.X] 22 | basepython = python3.5 23 | deps = 24 | {[testenv]deps} 25 | Django>=1.10,<1.11 26 | 27 | [testenv:py3-dj-1.9.X] 28 | basepython = python3.5 29 | deps = 30 | {[testenv]deps} 31 | Django>=1.9,<1.10 32 | 33 | [testenv:py3-dj-1.8.X] 34 | basepython = python3.2 35 | deps = 36 | {[testenv]deps} 37 | Django>=1.8,<1.9 38 | 39 | [testenv:py3-dj-1.7.X] 40 | basepython = python3.2 41 | deps = 42 | {[testenv]deps} 43 | Django>=1.7,<1.8 44 | 45 | [testenv:dj-1.10.X] 46 | deps = 47 | {[testenv]deps} 48 | Django>=1.10,<1.11 49 | 50 | [testenv:dj-1.9.X] 51 | deps = 52 | {[testenv]deps} 53 | Django>=1.9,<1.10 54 | 55 | [testenv:dj-1.8.X] 56 | deps = 57 | {[testenv]deps} 58 | Django>=1.8,<1.9 59 | 60 | [testenv:dj-1.7.X] 61 | deps = 62 | {[testenv]deps} 63 | Django>=1.7,<1.8 64 | --------------------------------------------------------------------------------