├── .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 ├── signals.py ├── templates │ └── dbsettings │ │ ├── app_settings.html │ │ ├── image_widget.html │ │ ├── site_settings.html │ │ └── values.html ├── urls.py ├── utils.py ├── values.py └── views.py ├── runtests.py ├── setup.py ├── tests ├── __init__.py ├── models.py ├── test_settings.py ├── test_urls.py └── tests.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 | Mlier 14 | Jonathan Weth (@hansegucker) 15 | Dominik George (@Natureshadow) 16 | -------------------------------------------------------------------------------- /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 | | ==1.3 | 3.8 - 3.10 | 2.1 - 4.0 | 34 | | +------------+--------------+ 35 | | | 3.6 - 3.7 | 2.1 - 3.2 | 36 | | +------------+--------------+ 37 | | | 3.5 | 2.1 - 2.2 | 38 | +------------------+------------+--------------+ 39 | | ==1.2 | 3.5 | 2.1 - 2.2 | 40 | | +------------+--------------+ 41 | | | 3.6 - 3.8 | 2.1 - 3.2 | 42 | +------------------+------------+--------------+ 43 | | 1.0 - 1.1.0 | 3.5 | 2.0 - 2.2 | 44 | | +------------+--------------+ 45 | | | 3.6 - 3.8 | 2.0 - 3.0 | 46 | +------------------+------------+--------------+ 47 | | ==0.11 | 3.5 - 3.7 | 1.10 - 2.2 | 48 | | +------------+--------------+ 49 | | | 2.7 | 1.10 - 1.11 | 50 | +------------------+------------+--------------+ 51 | | ==0.10 | 3.4 - 3.5 | 1.7 - 1.10 | 52 | | +------------+--------------+ 53 | | | 3.2 - 3.3 | 1.7 - 1.8 | 54 | | +------------+--------------+ 55 | | | 2.7 | 1.7 - 1.10 | 56 | +------------------+------------+--------------+ 57 | | ==0.9 | 3.4 - 3.5 | 1.7 - 1.9 | 58 | | +------------+--------------+ 59 | | | 3.2 - 3.3 | 1.7 - 1.8 | 60 | | +------------+--------------+ 61 | | | 2.7 | 1.7 - 1.9 | 62 | +------------------+------------+--------------+ 63 | | ==0.8 | 3.2 | 1.5 - 1.8 | 64 | | +------------+--------------+ 65 | | | 2.7 | 1.4 - 1.8 | 66 | | +------------+--------------+ 67 | | | 2.6 | 1.4 - 1.6 | 68 | +------------------+------------+--------------+ 69 | | ==0.7 | 3.2 | 1.5 - 1.7 | 70 | | +------------+--------------+ 71 | | | 2.7 | 1.3 - 1.7 | 72 | | +------------+--------------+ 73 | | | 2.6 | 1.3 - 1.6 | 74 | +------------------+------------+--------------+ 75 | | ==0.6 | 3.2 | 1.5 | 76 | | +------------+--------------+ 77 | | | 2.6 - 2.7 | 1.3 - 1.5 | 78 | +------------------+------------+--------------+ 79 | | <=0.5 | 2.6 - 2.7 | 1.2\* - 1.4 | 80 | +------------------+------------+--------------+ 81 | 82 | \* Possibly version below 1.2 will work too, but not tested. 83 | 84 | Installation 85 | ============ 86 | 87 | To install the ``dbsettings`` package, simply place it anywhere on your 88 | ``PYTHONPATH``. 89 | 90 | Project settings 91 | ---------------- 92 | 93 | In order to setup database storage, and to let Django know about your use of 94 | ``dbsettings``, simply add it to your ``INSTALLED_APPS`` setting, like so:: 95 | 96 | INSTALLED_APPS = ( 97 | ... 98 | 'dbsettings', 99 | ... 100 | ) 101 | 102 | If your Django project utilizes ``sites`` framework, all setting would be related 103 | to some site. If ``sites`` are not present, settings won't be connected to any site 104 | (and ``sites`` framework is no longer required since 0.8.1). 105 | 106 | You can force to do (not) use ``sites`` via ``DBSETTINGS_USE_SITES = True / False`` 107 | configuration variable (put it in project's ``settings.py``). 108 | 109 | By default, values stored in database are limited to 255 characters per setting. 110 | You can change this limit with ``DBSETTINGS_VALUE_LENGTH`` configuration variable. 111 | If you change this value after migrations were run, you need to manually alter 112 | the ``dbsettings_setting`` table schema. 113 | 114 | URL Configuration 115 | ----------------- 116 | 117 | In order to edit your settings at run-time, you'll need to configure a URL to 118 | access the provided editors. You'll just need to add a single line, defining 119 | the base URL for the editors, as ``dbsettings`` has its own URLconf to handle 120 | the rest. You may choose any location you like:: 121 | 122 | urlpatterns = patterns('', 123 | ... 124 | (r'^settings/', include('dbsettings.urls')), 125 | ... 126 | ) 127 | 128 | or (django 2):: 129 | 130 | from django.urls import path, include 131 | 132 | urlpatterns = [ 133 | ... 134 | path('settings/', include('dbsettings.urls')), 135 | ... 136 | ] 137 | 138 | A note about caching 139 | -------------------- 140 | 141 | This framework utilizes Django's built-in `cache framework`_, which is used to 142 | minimize how often the database needs to be accessed. During development, 143 | Django's built-in server runs in a single process, so all cache backends will 144 | work just fine. 145 | 146 | Most productions environments, including mod_python, FastCGI or WSGI, run multiple 147 | processes, which some backends don't fully support. When using the ``simple`` 148 | or ``locmem`` backends, updates to your settings won't be reflected immediately 149 | in all workers, causing your application to ignore the new changes. 150 | 151 | No other backends exhibit this behavior, but since ``simple`` is the default, 152 | make sure to specify a proper backend when moving to a production environment. 153 | 154 | .. _`cache framework`: http://docs.djangoproject.com/en/dev/topics/cache/ 155 | 156 | Alternatively you can disable caching of settings by setting 157 | ``DBSETTINGS_USE_CACHE = False`` in ``settings.py``. Beware though: every 158 | access of any setting will result in database hit. 159 | 160 | By default, entries are cached for a time that is default for the cache. You can 161 | override this by providing ``DBSETTINGS_CACHE_EXPIRATION``. Values interpreted 162 | as seconds, 0 means no caching and `None` means indefinite caching. See `more`_. 163 | 164 | .. _more: https://docs.djangoproject.com/en/2.2/ref/settings/#timeout 165 | 166 | Usage 167 | ===== 168 | 169 | These database-backed settings can be applied to any model in any app, or even 170 | in the app itself. All the tools necessary to do so are available within the 171 | ``dbsettings`` module. A single import provides everything you'll need:: 172 | 173 | import dbsettings 174 | 175 | Defining a group of settings 176 | ---------------------------- 177 | 178 | Settings are be defined in groups that allow them to be referenced together 179 | under a single attribute. Defining a group uses a declarative syntax similar 180 | to that of models, by declaring a new subclass of the ``Group`` class and 181 | populating it with values. 182 | 183 | :: 184 | 185 | class ImageLimits(dbsettings.Group): 186 | maximum_width = dbsettings.PositiveIntegerValue() 187 | maximum_height = dbsettings.PositiveIntegerValue() 188 | 189 | You may name your groups anything you like, and they may be defined in any 190 | module. This allows them to be imported from common applications if applicable. 191 | 192 | Defining individual settings 193 | ---------------------------- 194 | 195 | Within your groups, you may define any number of individual settings by simply 196 | assigning the value types to appropriate names. The names you assign them to 197 | will be the attribute names you'll use to reference the setting later, so be 198 | sure to choose names accordingly. 199 | 200 | For the editor, the default description of each setting will be retrieved from 201 | the attribute name, similar to how the ``verbose_name`` of model fields is 202 | retrieved. Also like model fields, however, an optional argument may be provided 203 | to define a more fitting description. It's recommended to leave the first letter 204 | lower-case, as it will be capitalized as necessary, automatically. 205 | 206 | :: 207 | 208 | class EmailOptions(dbsettings.Group): 209 | enabled = dbsettings.BooleanValue('whether to send emails or not') 210 | sender = dbsettings.StringValue('address to send emails from') 211 | subject = dbsettings.StringValue(default='SiteMail') 212 | 213 | For more descriptive explanation, the ``help_text`` argument can be used. It 214 | will be shown in the editor. 215 | 216 | The ``default`` argument is very useful - it specify an initial value of the 217 | setting. 218 | 219 | In addition, settings may be supplied with a list of available options, through 220 | the use of of the ``choices`` argument. This works exactly like the ``choices`` 221 | argument for model fields, and that of the newforms ``ChoiceField``. 222 | When using ``choices``, a ``get_FOO_display`` method is added to settings, for example: 223 | 224 | :: 225 | 226 | country = dbsettings.StringValue(choices=(("USA", "United States"), ("BR", "Brazil"))) 227 | 228 | >>> settings.country 229 | "USA" 230 | >>> settings.get_country_display() 231 | "United States" 232 | 233 | The widget used for a value can be overriden using the ``widget`` keyword. For example: 234 | 235 | :: 236 | 237 | payment_instructions = dbsettings.StringValue( 238 | help_text="Printed on every invoice.", 239 | default="Payment to Example XYZ\nBank name here\nAccount: 0123456\nSort: 01-02-03", 240 | widget=forms.Textarea 241 | ) 242 | 243 | A full list of value types is available later in this document, but the process 244 | and arguments are the same for each. 245 | 246 | Assigning settings 247 | ------------------ 248 | 249 | Once your settings are defined and grouped properly, they must be assigned to a 250 | location where they will be referenced later. This is as simple as instantiating 251 | the settings group in the appropriate location. This may be at the module level 252 | or within any standard Django model. 253 | 254 | Group instance may receive one optional argument: verbose name of the group. 255 | This name will be displayed in the editor. 256 | 257 | :: 258 | 259 | email = EmailOptions() 260 | 261 | class Image(models.Model): 262 | image = models.ImageField(upload_to='/upload/path') 263 | caption = models.TextField() 264 | 265 | limits = ImageLimits('Dimension settings') 266 | 267 | Multiple groups may be assigned to the same module or model, and they can even 268 | be combined into a single group by using standard addition syntax:: 269 | 270 | options = EmailOptions() + ImageLimits() 271 | 272 | To separate and tag settings nicely in the editor, use verbose names:: 273 | 274 | options = EmailOptions('Email') + ImageLimits('Dimesions') 275 | 276 | Database setup 277 | -------------- 278 | 279 | A single model is provided for database storage, and this model must be 280 | installed in your database before you can use the included editors or the 281 | permissions that will be automatically created. This is a simple matter of 282 | running ``manage.py syncdb`` or ``manage.py migrate`` now that your settings 283 | are configured. 284 | 285 | This step need only be repeate when settings are added to a new application, 286 | as it will create the appropriate permissions. Once those are in place, new 287 | settings may be added to existing applications with no impact on the database. 288 | 289 | Using your settings 290 | =================== 291 | 292 | Once the above steps are completed, you're ready to make use of database-backed 293 | settings. 294 | 295 | Editing settings 296 | ---------------- 297 | 298 | When first defined, your settings will default to ``None`` (or ``False`` in 299 | the case of ``BooleanValue``), so their values must be set using one of the 300 | supplied editors before they can be considered useful (however, if the setting 301 | had the ``default`` argument passed in the constructor, its value is already 302 | useful - equal to the defined default). 303 | 304 | The editor will be available at the URL configured earlier. 305 | For example, if you used the prefix of ``'settings/'``, the URL ``/settings/`` 306 | will provide an editor of all available settings, while ``/settings/myapp/`` 307 | would contain a list of just the settings for ``myapp``. 308 | 309 | URL patterns are named: ``'site_settings'`` and ``'app_settings'``, respectively. 310 | 311 | The editors are restricted to staff members, and the particular settings that 312 | will be available to users is based on permissions that are set for them. This 313 | means that superusers will automatically be able to edit all settings, while 314 | other staff members will need to have permissions set explicitly. 315 | 316 | Accessing settings in Python 317 | ---------------------------- 318 | 319 | Once settings have been assigned to an appropriate location, they may be 320 | referenced as standard Python attributes. The group becomes an attribute of the 321 | location where it was assigned, and the individual values are attributes of the 322 | group. 323 | 324 | If any settings are referenced without being set to a particular value, they 325 | will default to ``None`` (or ``False`` in the case of ``BooleanValue``, or 326 | whatever was passed as ``default``). In the 327 | following example, assume that ``EmailOptions`` were just added to the project 328 | and the ``ImageLimits`` were added earlier and already set via editor. 329 | 330 | :: 331 | 332 | >>> from myproject.myapp import models 333 | 334 | # EmailOptions are not defined 335 | >>> models.email.enabled 336 | False 337 | >>> models.email.sender 338 | >>> models.email.subject 339 | 'SiteMail' # Since default was defined 340 | 341 | # ImageLimits are defined 342 | >>> models.Image.limits.maximum_width 343 | 1024 344 | >>> models.Image.limits.maximum_height 345 | 768 346 | 347 | These settings are accessible from any Python code, making them especially 348 | useful in model methods and views. Each time the attribute is accessed, it will 349 | retrieve the current value, so your code doesn't need to worry about what 350 | happens behind the scenes. 351 | 352 | :: 353 | 354 | def is_valid(self): 355 | if self.width > Image.limits.maximum_width: 356 | return False 357 | if self.height > Image.limits.maximum_height: 358 | return False 359 | return True 360 | 361 | As mentioned, views can make use of these settings as well. 362 | 363 | :: 364 | 365 | from myproject.myapp.models import email 366 | 367 | def submit(request): 368 | 369 | ... 370 | # Deal with a form submission 371 | ... 372 | 373 | if email.enabled: 374 | from django.core.mail import send_mail 375 | send_mail(email.subject, 'message', email.sender, [request.user.email]) 376 | 377 | Settings can be not only read, but also written. The admin editor is more 378 | user-friendly, but in case code need to change something:: 379 | 380 | from myproject.myapp.models import Image 381 | 382 | def low_disk_space(): 383 | Image.limits.maximum_width = Image.limits.maximum_height = 200 384 | 385 | Every write is immediately commited to the database and proper cache key is deleted. 386 | 387 | A note about model instances 388 | ---------------------------- 389 | 390 | Since settings aren't related to individual model instances, any settings that 391 | are set on models may only be accessed by the model class itself. Attempting to 392 | access settings on an instance will raise an ``AttributeError``. 393 | 394 | Triggering actions on settings changes 395 | -------------------------------------- 396 | 397 | A signal is sent whenever a setting changes. You can receive it by doing 398 | something like this in your appconfig's ``ready()`` method:: 399 | 400 | from dbsettings.loading import get_setting 401 | from dbsettings.signals import setting_changed 402 | 403 | setting_changed.connect(my_function, sender=get_setting('myapp', 'MyClass', 'myattr')) 404 | 405 | `my_function` will be called with a `sender` and `value` parameters, the latter containing a 406 | new value assigned to the setting. 407 | 408 | Value types 409 | =========== 410 | 411 | There are several various value types available for database-backed settings. 412 | Select the one most appropriate for each individual setting, but all types use 413 | the same set of arguments. 414 | 415 | BooleanValue 416 | ------------ 417 | 418 | Presents a checkbox in the editor, and returns ``True`` or ``False`` in Python. 419 | 420 | DurationValue 421 | ------------- 422 | 423 | Presents a set of inputs suitable for specifying a length of time. This is 424 | represented in Python as a |timedelta|_ object. 425 | 426 | .. |timedelta| replace:: ``timedelta`` 427 | .. _timedelta: https://docs.python.org/2/library/datetime.html#timedelta-objects 428 | 429 | FloatValue 430 | ---------- 431 | 432 | Presents a standard input field, which becomes a ``float`` in Python. 433 | 434 | IntegerValue 435 | ------------ 436 | 437 | Presents a standard input field, which becomes an ``int`` in Python. 438 | 439 | PercentValue 440 | ------------ 441 | 442 | Similar to ``IntegerValue``, but with a limit requiring that the value be 443 | between 0 and 100. In addition, when accessed in Python, the value will be 444 | divided by 100, so that it is immediately suitable for calculations. 445 | 446 | For instance, if a ``myapp.taxes.sales_tax`` was set to 5 in the editor, 447 | the following calculation would be valid:: 448 | 449 | >>> 5.00 * myapp.taxes.sales_tax 450 | 0.25 451 | 452 | PositiveIntegerValue 453 | -------------------- 454 | 455 | Similar to ``IntegerValue``, but limited to positive values and 0. 456 | 457 | StringValue 458 | ----------- 459 | 460 | Presents a standard input, accepting any text string up to 255 461 | (or ``DBSETTINGS_VALUE_LENGTH``) characters. In 462 | Python, the value is accessed as a standard string. 463 | 464 | DateTimeValue 465 | ------------- 466 | 467 | Presents a standard input field, which becomes a ``datetime`` in Python. 468 | 469 | User input will be parsed according to ``DATETIME_INPUT_FORMATS`` setting. 470 | 471 | In code, one can assign to field string or datetime object:: 472 | 473 | # These two statements has the same effect 474 | myapp.Feed.next_feed = '2012-06-01 00:00:00' 475 | myapp.Feed.next_feed = datetime.datetime(2012, 6, 1, 0, 0, 0) 476 | 477 | DateValue 478 | --------- 479 | 480 | Presents a standard input field, which becomes a ``date`` in Python. 481 | 482 | User input will be parsed according to ``DATE_INPUT_FORMATS`` setting. 483 | 484 | See ``DateTimeValue`` for the remark about assigning. 485 | 486 | TimeValue 487 | --------- 488 | 489 | Presents a standard input field, which becomes a ``time`` in Python. 490 | 491 | User input will be parsed according to ``TIME_INPUT_FORMATS`` setting. 492 | 493 | See ``DateTimeValue`` for the remark about assigning. 494 | 495 | ImageValue 496 | ---------- 497 | 498 | (requires PIL or Pillow imaging library to work) 499 | 500 | Allows to upload image and view its preview. 501 | 502 | ImageValue has optional keyword arguments: 503 | 504 | - ``upload_to`` specifies path (relative to ``MEDIA_ROOT``), where uploaded 505 | images will be stored. If argument is not present, files will be saved 506 | directly under ``MEDIA_ROOT``. 507 | - ``delete_old`` (default to True) controls whether to delete the old file when 508 | the value has changed 509 | 510 | In Python, the value is accessed as a standard string (file name, relative to 511 | ``MEDIA_ROOT``). 512 | 513 | PasswordValue 514 | ------------- 515 | 516 | Presents a standard password input. Retain old setting value if not changed. 517 | 518 | 519 | Setting defaults for a distributed application 520 | ============================================== 521 | 522 | Distributed applications often have need for certain default settings that are 523 | useful for the common case, but which may be changed to suit individual 524 | installations. For such cases, a utility is provided to enable applications to 525 | set any applicable defaults. 526 | 527 | Living at ``dbsettings.utils.set_defaults``, this utility is designed to be used 528 | within the app's ``management.py``. This way, when the application is installed 529 | using ``syncdb``/``migrate``, the default settings will also be installed to the database. 530 | 531 | The function requires a single positional argument, which is the ``models`` 532 | module for the application. Any additional arguments must represent the actual 533 | settings that will be installed. Each argument is a 3-tuple, of the following 534 | format: ``(class_name, setting_name, value)``. 535 | 536 | If the value is intended for a module-level setting, simply set ``class_name`` 537 | to an empty string. The value for ``setting_name`` should be the name given to 538 | the setting itself, while the name assigned to the group isn't supplied, as it 539 | isn't used for storing the value. 540 | 541 | For example, the following code in ``management.py`` would set defaults for 542 | some of the settings provided earlier in this document:: 543 | 544 | from django.conf import settings 545 | from dbsettings.utils import set_defaults 546 | from myproject.myapp import models as myapp 547 | 548 | set_defaults(myapp, 549 | ('', 'enabled', True) 550 | ('', 'sender', settings.ADMINS[0][1]) # Email of the first listed admin 551 | ('Image', 'maximum_width', 800) 552 | ('Image', 'maximum_height', 600) 553 | ) 554 | 555 | ---------- 556 | 557 | Changelog 558 | ========= 559 | 560 | **1.3.0** (19/02/2022) 561 | - Added compatibility with Django 4.0 (thanks nerdoc) 562 | - Fixed misleading message (about permissions) in the editor if no settings were defined 563 | - Fixed bug with app-level settings not present in the editor if ``verbose_name`` was not set 564 | **1.2.0** (16/12/2021) 565 | - Fixed exception handling in cache operation and added `DBSETTINGS_CACHE_EXPIRATION` (thanks AminHP) 566 | - Added ``get_FOO_display`` method for fields which have ``choices`` (thanks paulogiacomelli) 567 | - Use Django storage framework for ImageValue instead of manual filesystem handling (thanks j0nm1) 568 | - Dropped compatibility with Django 2.0 569 | **1.1.0** (21/03/2020) 570 | - Fixed image widget in Django 2.1 571 | - Added ``delete_old`` parameter to ImageValue 572 | - Make ``upload_to`` parameter to ImageValue no longer required. 573 | - Fix PasswordValue to not render widget as required. 574 | **1.0.1** (26/12/2019) 575 | - Introduced a signal `setting_changed` 576 | - Added compatibility with Django 3.0 577 | - Dropped compatibility with Django 1.10, 1.11 578 | - Dropped compatibility with Python 2 579 | **0.11.0** (31/07/2019) 580 | - Added compatibility with Django 1.11, 2.0, 2.1, 2.2 581 | - Dropped compatibility with Django 1.7, 1.8, 1.9 582 | **0.10.0** (25/09/2016) 583 | - Added compatibility with Django 1.10 584 | **0.9.3** (02/06/2016) 585 | - Fixed (hopefully for good) problem with ImageValue in Python 3 (thanks rolexCoder) 586 | **0.9.2** (01/05/2016) 587 | - Fixed bug when saving non-required settings 588 | - Fixed problem with ImageValue in Python 3 (thanks rolexCoder) 589 | **0.9.1** (10/01/2016) 590 | - Fixed `Sites` app being optional (thanks rolexCoder) 591 | **0.9.0** (25/12/2015) 592 | - Added compatibility with Django 1.9 (thanks Alonso) 593 | - Dropped compatibility with Django 1.4, 1.5, 1.6 594 | **0.8.2** (17/09/2015) 595 | - Added migrations to distro 596 | - Add configuration option to change max length of setting values from 255 to whatever 597 | - Add configuration option to disable caching (thanks nwaxiomatic) 598 | - Fixed PercentValue rendering (thanks last-partizan) 599 | **0.8.1** (21/06/2015) 600 | - Made ``django.contrib.sites`` framework dependency optional 601 | - Added migration for app 602 | **0.8.0** (16/04/2015) 603 | - Switched to using django.utils.six instead of standalone six. 604 | - Added compatibility with Django 1.8 605 | - Dropped compatibility with Django 1.3 606 | **0.7.4** (24/03/2015) 607 | - Added default values for fields. 608 | - Fixed Python 3.3 compatibility 609 | - Added creation of folders with ImageValue 610 | **0.7.3**, **0.7.2** 611 | pypi problems 612 | **0.7.1** (11/03/2015) 613 | - Fixed pypi distribution. 614 | **0.7** (06/07/2014) 615 | - Added PasswordValue 616 | - Added compatibility with Django 1.6 and 1.7. 617 | **0.6** (16/09/2013) 618 | - Added compatibility with Django 1.5 and python3, dropped support for Django 1.2. 619 | - Fixed permissions: added permission for editing non-model (module-level) settings 620 | - Make PIL/Pillow not required in setup.py 621 | **0.5** (11/10/2012) 622 | - Fixed error occuring when test are run with ``LANGUAGE_CODE`` different than 'en' 623 | - Added verbose_name option for Groups 624 | - Cleaned code 625 | **0.4.1** (02/10/2012) 626 | - Fixed Image import 627 | **0.4** (30/09/2012) 628 | - Named urls 629 | - Added polish translation 630 | **0.3** (04/09/2012) 631 | Included testrunner in distribution 632 | **0.2** (05/07/2012) 633 | - Fixed errors appearing when module-level and model-level settings have 634 | same attribute names 635 | - Corrected the editor templates admin integration 636 | - Updated README 637 | **0.1** (29/06/2012) 638 | Initial PyPI release 639 | -------------------------------------------------------------------------------- /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 | 3 | from dbsettings.values import Value 4 | from dbsettings.loading import register_setting, unregister_setting 5 | from dbsettings.management import mk_permissions 6 | 7 | from django.utils.encoding import force_str 8 | from django.utils.hashable import make_hashable 9 | 10 | __all__ = ['Group'] 11 | 12 | 13 | class GroupBase(type): 14 | def __init__(mcs, name, bases, attrs): 15 | if not bases or bases == (object,): 16 | return 17 | attrs.pop('__module__', None) 18 | attrs.pop('__doc__', None) 19 | attrs.pop('__qualname__', None) 20 | for attribute_name, attr in attrs.items(): 21 | if not isinstance(attr, Value): 22 | raise TypeError('The type of %s (%s) is not a valid Value.' % 23 | (attribute_name, attr.__class__.__name__)) 24 | mcs.add_to_class(attribute_name, attr) 25 | super(GroupBase, mcs).__init__(name, bases, attrs) 26 | 27 | 28 | class GroupDescriptor(object): 29 | def __init__(self, group, attribute_name): 30 | self.group = group 31 | self.attribute_name = attribute_name 32 | 33 | def __get__(self, instance=None, cls=None): 34 | if instance is not None: 35 | raise AttributeError("%r is not accessible from %s instances." % 36 | (self.attribute_name, cls.__name__)) 37 | return self.group 38 | 39 | 40 | class Group(object, metaclass=GroupBase): 41 | 42 | def __new__(cls, verbose_name='', copy=True, app_label=None): 43 | # If not otherwise provided, set the module to where it was executed 44 | if '__module__' in cls.__dict__: 45 | module_name = cls.__dict__['__module__'] 46 | else: 47 | module_name = sys._getframe(1).f_globals['__name__'] 48 | 49 | attrs = [(k, v) for (k, v) in cls.__dict__.items() if isinstance(v, Value)] 50 | if copy: 51 | attrs = [(k, v.copy()) for (k, v) in attrs] 52 | attrs.sort(key=lambda a: a[1]) 53 | 54 | for _, attr in attrs: 55 | attr.creation_counter = Value.creation_counter 56 | Value.creation_counter += 1 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 | 122 | def _get_FIELD_display(self, field): 123 | value = getattr(self, field.attribute_name) 124 | choices_dict = dict(make_hashable(field.choices)) 125 | # force_str() to coerce lazy strings. 126 | return force_str(choices_dict.get(make_hashable(value), value), strings_only=True) 127 | -------------------------------------------------------------------------------- /dbsettings/loading.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from django.core.cache import cache 3 | 4 | from .signals import setting_changed 5 | 6 | __all__ = ['get_all_settings', 'get_setting', 'get_setting_storage', 7 | 'register_setting', 'unregister_setting', 'set_setting_value'] 8 | 9 | 10 | _settings = OrderedDict() 11 | 12 | 13 | def _get_cache_key(module_name, class_name, attribute_name): 14 | return '.'.join(['dbsettings', module_name, class_name, attribute_name]) 15 | 16 | 17 | def get_all_settings(): 18 | return list(_settings.values()) 19 | 20 | 21 | def get_app_settings(app_label): 22 | return [p for p in _settings.values() if app_label == p.app] 23 | 24 | 25 | def get_setting(module_name, class_name, attribute_name): 26 | return _settings[module_name, class_name, attribute_name] 27 | 28 | 29 | def setting_in_db(module_name, class_name, attribute_name): 30 | from dbsettings.models import Setting 31 | return Setting.objects.filter( 32 | module_name=module_name, 33 | class_name=class_name, 34 | attribute_name=attribute_name, 35 | ).count() == 1 36 | 37 | 38 | def get_setting_storage(module_name, class_name, attribute_name): 39 | from dbsettings.models import Setting 40 | from dbsettings.settings import USE_CACHE, CACHE_EXPIRATION 41 | storage = None 42 | if USE_CACHE: 43 | key = _get_cache_key(module_name, class_name, attribute_name) 44 | try: 45 | storage = cache.get(key) 46 | except: 47 | pass 48 | if storage is None: 49 | try: 50 | storage = Setting.objects.get( 51 | module_name=module_name, 52 | class_name=class_name, 53 | attribute_name=attribute_name, 54 | ) 55 | except Setting.DoesNotExist: 56 | setting_object = get_setting(module_name, class_name, attribute_name) 57 | storage = Setting( 58 | module_name=module_name, 59 | class_name=class_name, 60 | attribute_name=attribute_name, 61 | value=setting_object.default, 62 | ) 63 | if USE_CACHE: 64 | try: 65 | args = [] 66 | if CACHE_EXPIRATION != -1: 67 | args.append(CACHE_EXPIRATION) 68 | cache.set(key, storage, *args) 69 | except: 70 | pass 71 | return storage 72 | 73 | 74 | def register_setting(setting): 75 | if setting.key not in _settings: 76 | _settings[setting.key] = setting 77 | 78 | 79 | def unregister_setting(setting): 80 | if setting.key in _settings and _settings[setting.key] is setting: 81 | del _settings[setting.key] 82 | 83 | 84 | def set_setting_value(module_name, class_name, attribute_name, value): 85 | from dbsettings.settings import USE_CACHE 86 | setting = get_setting(module_name, class_name, attribute_name) 87 | storage = get_setting_storage(module_name, class_name, attribute_name) 88 | storage.value = setting.get_db_prep_save(value, oldvalue=storage.value) 89 | storage.save() 90 | setting_changed.send(sender=setting, value=setting.to_python(value)) 91 | if USE_CACHE: 92 | key = _get_cache_key(module_name, class_name, attribute_name) 93 | cache.delete(key) 94 | -------------------------------------------------------------------------------- /dbsettings/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zlorf/django-dbsettings/7c736a58ec25e326591be0bf9bcedeecd5c415fe/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: 2022-02-05 21:39+0100\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:241 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:18 27 | msgid "Site settings" 28 | msgstr "Wszystkie ustawienia" 29 | 30 | #: views.py:21 31 | #, python-format 32 | msgid "%(app)s settings" 33 | msgstr "Ustawienia %(app)s" 34 | 35 | #: views.py:50 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:10 41 | #: templates/dbsettings/site_settings.html:10 42 | msgid "Home" 43 | msgstr "Początek" 44 | 45 | #: templates/dbsettings/app_settings.html:11 46 | #: templates/dbsettings/site_settings.html:11 47 | msgid "Edit Settings" 48 | msgstr "Zmień ustawienia" 49 | 50 | #: templates/dbsettings/app_settings.html:19 51 | #: templates/dbsettings/site_settings.html:19 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:23 59 | #: templates/dbsettings/site_settings.html:23 60 | msgid "No settings were defined." 61 | msgstr "Nie zdefiniowano żadnych ustawień." 62 | 63 | #: templates/dbsettings/app_settings.html:29 64 | #, python-format 65 | msgid "Settings included in the %(name)s class." 66 | msgstr "Ustawienia dotyczące klasy %(name)s." 67 | 68 | #: templates/dbsettings/app_settings.html:30 69 | msgid "Application settings" 70 | msgstr "Ustawienia aplikacji" 71 | 72 | #: templates/dbsettings/app_settings.html:38 73 | #: templates/dbsettings/site_settings.html:47 74 | msgid "You don't have permission to edit values." 75 | msgstr "Nie masz uprawnień do edycji ustawień." 76 | 77 | #: templates/dbsettings/site_settings.html:29 78 | #, python-format 79 | msgid "Models available in the %(name)s application." 80 | msgstr "Modele dostępne w aplikacji %(name)s. ABCDEF" 81 | 82 | #: templates/dbsettings/site_settings.html:44 83 | msgid "Save" 84 | msgstr "Zapisz" 85 | -------------------------------------------------------------------------------- /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', on_delete=models.CASCADE))] if USE_SITES else []) 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /dbsettings/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zlorf/django-dbsettings/7c736a58ec25e326591be0bf9bcedeecd5c415fe/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, on_delete=models.CASCADE) 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 | CACHE_EXPIRATION = getattr(settings, 'DBSETTINGS_CACHE_EXPIRATION', -1) 9 | VALUE_LENGTH = getattr(settings, 'DBSETTINGS_VALUE_LENGTH', 255) 10 | -------------------------------------------------------------------------------- /dbsettings/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | __all__ = ['setting_changed'] 4 | 5 | setting_changed = Signal() 6 | -------------------------------------------------------------------------------- /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 no_settings %} 23 |

{% trans "No settings were defined." %}

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

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

39 | {% endif %} 40 |
41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /dbsettings/templates/dbsettings/image_widget.html: -------------------------------------------------------------------------------- 1 | {% if image_url %}

{% endif %} 2 | {% include "django/forms/widgets/file.html" %} 3 | -------------------------------------------------------------------------------- /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 no_settings %} 23 |

{% trans "No settings were defined." %}

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

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

48 | {% endif %} 49 |
50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /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/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from dbsettings.views import site_settings, app_settings 4 | 5 | 6 | urlpatterns = [ 7 | path('', site_settings, name='site_settings'), 8 | path('/', 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 | 3 | import datetime 4 | from decimal import Decimal 5 | from hashlib import md5 6 | from os.path import join as pjoin 7 | from functools import partialmethod 8 | import time 9 | import os 10 | 11 | from django import forms 12 | from django.core.files.storage import default_storage 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 gettext_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 | if self.choices is not None: 68 | # Allows for adding get_FIELD_display() to fields with choices. 69 | display_attribute = 'get_%s_display' % attribute_name 70 | if display_attribute not in cls.__dict__: 71 | setattr( 72 | cls, 73 | display_attribute, 74 | partialmethod(cls._get_FIELD_display, field=self), 75 | ) 76 | 77 | @property 78 | def app(self): 79 | return getattr(self, '_app', self.module_name.split('.')[-2]) 80 | 81 | def __get__(self, instance=None, cls=None): 82 | if instance is None: 83 | raise AttributeError("%r is only accessible from %s instances." % 84 | (self.attribute_name, cls.__name__)) 85 | try: 86 | storage = get_setting_storage(*self.key) 87 | return self.to_python(storage.value) 88 | except: 89 | return None 90 | 91 | def __set__(self, instance, value): 92 | current_value = self.__get__(instance) 93 | if self.to_python(value) != current_value: 94 | set_setting_value(*(self.key + (value,))) 95 | 96 | # Subclasses should override the following methods where applicable 97 | 98 | def meaningless(self, value): 99 | return value is None or value == "" 100 | 101 | def to_python(self, value): 102 | "Returns a native Python object suitable for immediate use" 103 | return value 104 | 105 | def get_db_prep_save(self, value, oldvalue=None): 106 | "Returns a value suitable for storage into a CharField" 107 | return str(value) 108 | 109 | def to_editor(self, value): 110 | "Returns a value suitable for display in a form widget" 111 | return str(value) 112 | 113 | ############### 114 | # VALUE TYPES # 115 | ############### 116 | 117 | 118 | class BooleanValue(Value): 119 | unitialized_value = False 120 | 121 | class field(forms.BooleanField): 122 | 123 | def __init__(self, *args, **kwargs): 124 | kwargs['required'] = False 125 | forms.BooleanField.__init__(self, *args, **kwargs) 126 | 127 | def to_python(self, value): 128 | if value in (True, 't', 'True'): 129 | return True 130 | return False 131 | 132 | to_editor = to_python 133 | 134 | 135 | class DecimalValue(Value): 136 | field = forms.DecimalField 137 | 138 | def to_python(self, value): 139 | return Decimal(value) if not self.meaningless(value) else None 140 | 141 | 142 | # DurationValue has a lot of duplication and ugliness because of issue #2443 143 | # Until DurationField is sorted out, this has to do some extra work 144 | class DurationValue(Value): 145 | 146 | class field(forms.CharField): 147 | def clean(self, value): 148 | try: 149 | return datetime.timedelta(seconds=float(value)) 150 | except (ValueError, TypeError): 151 | raise forms.ValidationError('This value must be a real number.') 152 | except OverflowError: 153 | raise forms.ValidationError('The maximum allowed value is %s' % 154 | datetime.timedelta.max) 155 | 156 | def to_python(self, value): 157 | if isinstance(value, datetime.timedelta): 158 | return value 159 | try: 160 | return datetime.timedelta(seconds=float(value)) 161 | except (ValueError, TypeError): 162 | raise forms.ValidationError('This value must be a real number.') 163 | except OverflowError: 164 | raise forms.ValidationError('The maximum allowed value is %s' % datetime.timedelta.max) 165 | 166 | def get_db_prep_save(self, value, **kwargs): 167 | return str(value.days * 24 * 3600 + value.seconds 168 | + float(value.microseconds) / 1000000) 169 | 170 | 171 | class FloatValue(Value): 172 | field = forms.FloatField 173 | 174 | def to_python(self, value): 175 | return float(value) if not self.meaningless(value) else None 176 | 177 | 178 | class IntegerValue(Value): 179 | field = forms.IntegerField 180 | 181 | def to_python(self, value): 182 | return int(value) if not self.meaningless(value) else None 183 | 184 | 185 | class PercentValue(Value): 186 | 187 | class field(forms.DecimalField): 188 | def __init__(self, *args, **kwargs): 189 | forms.DecimalField.__init__(self, max_value=100, min_value=0, max_digits=5, decimal_places=2, *args, **kwargs) 190 | 191 | class widget(forms.TextInput): 192 | def render(self, *args, **kwargs): 193 | # Place a percent sign after a smaller text field 194 | attrs = kwargs.pop('attrs', {}) 195 | attrs['size'] = attrs['max_length'] = 6 196 | return mark_safe(forms.TextInput.render(self, attrs=attrs, *args, **kwargs) + ' %') 197 | 198 | def to_python(self, value): 199 | return Decimal(value) / 100 if not self.meaningless(value) else None 200 | 201 | 202 | class PositiveIntegerValue(IntegerValue): 203 | 204 | class field(forms.IntegerField): 205 | 206 | def __init__(self, *args, **kwargs): 207 | kwargs['min_value'] = 0 208 | forms.IntegerField.__init__(self, *args, **kwargs) 209 | 210 | 211 | class StringValue(Value): 212 | unitialized_value = '' 213 | field = forms.CharField 214 | 215 | 216 | class TextValue(Value): 217 | unitialized_value = '' 218 | field = forms.CharField 219 | 220 | def to_python(self, value): 221 | return str(value) 222 | 223 | 224 | class EmailValue(Value): 225 | unitialized_value = '' 226 | field = forms.EmailField 227 | 228 | def to_python(self, value): 229 | return str(value) 230 | 231 | 232 | class PasswordValue(Value): 233 | class field(forms.CharField): 234 | widget = forms.PasswordInput 235 | 236 | def __init__(self, **kwargs): 237 | if kwargs['initial']: 238 | kwargs['required'] = False 239 | if not kwargs.get('help_text'): 240 | kwargs['help_text'] = _( 241 | 'Leave empty in order to retain old password. Provide new value to change.') 242 | forms.CharField.__init__(self, **kwargs) 243 | 244 | def clean(self, value): 245 | # Retain old password if not changed 246 | if value == '': 247 | value = self.initial 248 | return forms.CharField.clean(self, value) 249 | 250 | 251 | class MultiSeparatorValue(TextValue): 252 | """Provides a way to store list-like string settings. 253 | e.g 'mail@test.com;*@blah.com' would be returned as 254 | ['mail@test.com', '*@blah.com']. What the method 255 | uses to split on can be defined by passing in a 256 | separator string (default is semi-colon as above). 257 | """ 258 | 259 | def __init__(self, description=None, help_text=None, separator=';', required=True, 260 | default=None): 261 | self.separator = separator 262 | if default is not None: 263 | # convert from list to string 264 | default = separator.join(default) 265 | super(MultiSeparatorValue, self).__init__(description=description, 266 | help_text=help_text, 267 | required=required, 268 | default=default) 269 | 270 | class field(forms.CharField): 271 | 272 | class widget(forms.Textarea): 273 | pass 274 | 275 | def to_python(self, value): 276 | if value: 277 | value = str(value) 278 | value = value.split(self.separator) 279 | value = [x.strip() for x in value] 280 | else: 281 | value = [] 282 | return value 283 | 284 | 285 | class ImageValue(Value): 286 | def __init__(self, *args, **kwargs): 287 | self._upload_to = kwargs.pop('upload_to', '') 288 | self._delete_old = kwargs.pop('delete_old', True) 289 | super(ImageValue, self).__init__(*args, **kwargs) 290 | 291 | class field(forms.ImageField): 292 | class widget(forms.FileInput): 293 | "Widget with preview" 294 | template_name = 'dbsettings/image_widget.html' 295 | 296 | def render(self, name, value, attrs=None, renderer=None): 297 | """Render the widget as an HTML string.""" 298 | context = self.get_context(name, value, attrs) 299 | 300 | if value: 301 | context['image_url'] = default_storage.url(value.name) 302 | else: 303 | context['image_url'] = None 304 | 305 | return self._render(self.template_name, context, renderer) 306 | 307 | def to_python(self, value): 308 | "Returns a native Python object suitable for immediate use" 309 | return str(value) 310 | 311 | def get_db_prep_save(self, value, oldvalue=None): 312 | "Returns a value suitable for storage into a CharField" 313 | if not value: 314 | return None 315 | 316 | hashed_name = md5(str(time.time()).encode()).hexdigest() + value.name[-4:] 317 | image_path = pjoin(self._upload_to, hashed_name) 318 | 319 | image_path = default_storage.save(image_path, value.file) 320 | 321 | # Delete old file 322 | if oldvalue and self._delete_old: 323 | if default_storage.exists(oldvalue): 324 | default_storage.delete(oldvalue) 325 | 326 | return str(image_path) 327 | 328 | def to_editor(self, value): 329 | "Returns a value suitable for display in a form widget" 330 | if not value: 331 | return None 332 | 333 | try: 334 | with default_storage.open(value, 'rb') as f: 335 | uploaded_file = SimpleUploadedFile(value, f.read(), 'image') 336 | 337 | # hack to retrieve path from `name` attribute 338 | uploaded_file.__dict__['_name'] = value 339 | return uploaded_file 340 | except IOError: 341 | return None 342 | 343 | 344 | class DateTimeValue(Value): 345 | field = forms.DateTimeField 346 | formats_source = 'DATETIME_INPUT_FORMATS' 347 | 348 | @property 349 | def _formats(self): 350 | return formats.get_format(self.formats_source) 351 | 352 | def _parse_format(self, value): 353 | for format in self._formats: 354 | try: 355 | return datetime.datetime.strptime(value, format) 356 | except (ValueError, TypeError): 357 | continue 358 | return None 359 | 360 | def get_db_prep_save(self, value, **kwargs): 361 | if isinstance(value, str): 362 | return value 363 | return value.strftime(self._formats[0]) 364 | 365 | def to_python(self, value): 366 | if isinstance(value, datetime.datetime): 367 | return value 368 | return self._parse_format(value) 369 | 370 | 371 | class DateValue(DateTimeValue): 372 | field = forms.DateField 373 | formats_source = 'DATE_INPUT_FORMATS' 374 | 375 | def to_python(self, value): 376 | if isinstance(value, datetime.datetime): 377 | return value.date() 378 | elif isinstance(value, datetime.date): 379 | return value 380 | res = self._parse_format(value) 381 | if res is not None: 382 | return res.date() 383 | return res 384 | 385 | 386 | class TimeValue(DateTimeValue): 387 | field = forms.TimeField 388 | formats_source = 'TIME_INPUT_FORMATS' 389 | 390 | def to_python(self, value): 391 | if isinstance(value, datetime.datetime): 392 | return value.time() 393 | elif isinstance(value, datetime.time): 394 | return value 395 | res = self._parse_format(value) 396 | if res is not None: 397 | return res.time() 398 | return res 399 | -------------------------------------------------------------------------------- /dbsettings/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.http import HttpResponseRedirect 4 | from django.shortcuts import render 5 | from django.contrib.admin.views.decorators import staff_member_required 6 | from django.utils.text import capfirst 7 | from django.utils.translation import gettext_lazy as _ 8 | from django.contrib import messages 9 | 10 | from dbsettings import loading, forms 11 | 12 | 13 | @staff_member_required 14 | def app_settings(request, app_label, template='dbsettings/app_settings.html'): 15 | # Determine what set of settings this editor is used for 16 | if app_label is None: 17 | settings = loading.get_all_settings() 18 | title = _('Site settings') 19 | else: 20 | settings = loading.get_app_settings(app_label) 21 | title = _('%(app)s settings') % {'app': capfirst(app_label)} 22 | 23 | # Create an editor customized for the current user 24 | editor = forms.customized_editor(request.user, settings) 25 | 26 | if request.method == 'POST': 27 | # Populate the form with user-submitted data 28 | form = editor(request.POST.copy(), request.FILES) 29 | if form.is_valid(): 30 | form.full_clean() 31 | 32 | for name, value in form.cleaned_data.items(): 33 | key = forms.RE_FIELD_NAME.match(name).groups() 34 | setting = loading.get_setting(*key) 35 | try: 36 | storage = loading.get_setting_storage(*key) 37 | current_value = setting.to_python(storage.value) 38 | except: 39 | current_value = None 40 | 41 | if current_value != setting.to_python(value): 42 | args = key + (value,) 43 | loading.set_setting_value(*args) 44 | 45 | # Give user feedback as to which settings were changed 46 | if setting.class_name: 47 | location = setting.class_name 48 | else: 49 | location = setting.module_name 50 | update_msg = (_('Updated %(desc)s on %(location)s') % 51 | {'desc': str(setting.description), 52 | 'location': location}) 53 | messages.add_message(request, messages.INFO, update_msg) 54 | 55 | return HttpResponseRedirect(request.path) 56 | else: 57 | # Leave the form populated with current setting values 58 | form = editor() 59 | 60 | return render(request, template, { 61 | 'title': title, 62 | 'no_settings': len(settings) == 0, 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 os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | if __name__ == "__main__": 10 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 11 | django.setup() 12 | TestRunner = get_runner(settings) 13 | test_runner = TestRunner() # debug : verbosity=2, keepdb=True 14 | failures = test_runner.run_tests(["tests"]) 15 | sys.exit(bool(failures)) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | # Dynamically calculate the version based on dbsettings.VERSION 4 | version_tuple = (1, 3, 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 :: 3', 37 | 'Topic :: Utilities' 38 | ], 39 | zip_safe=False, 40 | ) 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zlorf/django-dbsettings/7c736a58ec25e326591be0bf9bcedeecd5c415fe/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | import dbsettings 4 | import datetime 5 | 6 | 7 | class TestSettings(dbsettings.Group): 8 | boolean = dbsettings.BooleanValue() 9 | integer = dbsettings.IntegerValue() 10 | string = dbsettings.StringValue() 11 | list_semi_colon = dbsettings.MultiSeparatorValue() 12 | list_comma = dbsettings.MultiSeparatorValue(separator=',') 13 | date = dbsettings.DateValue() 14 | time = dbsettings.TimeValue() 15 | datetime = dbsettings.DateTimeValue() 16 | string_choices = dbsettings.StringValue(choices=(("String1", "First String Choice"), ("String2", "Second String Choice"))) 17 | 18 | 19 | class Defaults(models.Model): 20 | class settings(dbsettings.Group): 21 | boolean = dbsettings.BooleanValue(default=True) 22 | boolean_false = dbsettings.BooleanValue(default=False) 23 | integer = dbsettings.IntegerValue(default=1) 24 | string = dbsettings.StringValue(default="default") 25 | list_semi_colon = dbsettings.MultiSeparatorValue(default=['one', 'two']) 26 | list_comma = dbsettings.MultiSeparatorValue(separator=',', default=('one', 'two')) 27 | date = dbsettings.DateValue(default=datetime.date(2012, 3, 14)) 28 | time = dbsettings.TimeValue(default=datetime.time(12, 3, 14)) 29 | datetime = dbsettings.DateTimeValue(default=datetime.datetime(2012, 3, 14, 12, 3, 14)) 30 | string_choices = dbsettings.StringValue(choices=(("String1", "First String Choice"), ("String2", "Second String Choice")), default="String2") 31 | settings = settings() 32 | 33 | 34 | class TestBaseModel(models.Model): 35 | class Meta: 36 | abstract = True 37 | app_label = 'tests' 38 | 39 | 40 | # These will be populated by the fixture data 41 | class Populated(TestBaseModel): 42 | settings = TestSettings() 43 | 44 | 45 | # These will be empty after startup 46 | class Unpopulated(TestBaseModel): 47 | settings = TestSettings() 48 | 49 | 50 | # These will allow blank values 51 | class Blankable(TestBaseModel): 52 | settings = TestSettings() 53 | 54 | 55 | class Editable(TestBaseModel): 56 | settings = TestSettings('Verbose name') 57 | 58 | 59 | class Combined(TestBaseModel): 60 | class settings(dbsettings.Group): 61 | enabled = dbsettings.BooleanValue() 62 | settings = TestSettings() + settings() 63 | 64 | 65 | # For registration testing 66 | class ClashSettings1(dbsettings.Group): 67 | clash1 = dbsettings.BooleanValue() 68 | 69 | 70 | class ClashSettings2(dbsettings.Group): 71 | clash2 = dbsettings.BooleanValue() 72 | 73 | 74 | class ClashSettings1_2(dbsettings.Group): 75 | clash1 = dbsettings.IntegerValue() 76 | clash2 = dbsettings.IntegerValue() 77 | 78 | 79 | class ModelClash(TestBaseModel): 80 | settings = ClashSettings1_2() 81 | 82 | 83 | class NonRequiredSettings(dbsettings.Group): 84 | integer = dbsettings.IntegerValue(required=False) 85 | string = dbsettings.StringValue(required=False) 86 | fl = dbsettings.FloatValue(required=False) 87 | decimal = dbsettings.DecimalValue(required=False) 88 | percent = dbsettings.PercentValue(required=False) 89 | 90 | 91 | class NonReq(TestBaseModel): 92 | non_req = NonRequiredSettings() 93 | -------------------------------------------------------------------------------- /tests/test_settings.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 | SECRET_KEY = 'fake-key' 8 | 9 | DEBUG = True 10 | 11 | INSTALLED_APPS = ( 12 | # Required contrib apps. 13 | 'django.contrib.admin', 14 | 'django.contrib.auth', 15 | 'django.contrib.contenttypes', 16 | 'django.contrib.sites', 17 | 'django.contrib.sessions', 18 | 'django.contrib.messages', 19 | # Our app and it's test app. 20 | 'dbsettings', 21 | 'tests', 22 | ) 23 | 24 | 25 | SITE_ID= 1 26 | 27 | ROOT_URLCONF = 'tests.test_urls' 28 | 29 | # Required for Django 4.0 30 | STATIC_URL = '/static/' 31 | 32 | DATABASES = { 33 | 'default': { 34 | 'ENGINE': 'django.db.backends.sqlite3', 35 | 'NAME': ':memory:', 36 | #'TEST': { 37 | # 'NAME': 'auto_tests', 38 | #} 39 | } 40 | } 41 | 42 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 43 | 44 | MIDDLEWARE = ( 45 | 'django.contrib.sessions.middleware.SessionMiddleware', 46 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 47 | 'django.contrib.messages.middleware.MessageMiddleware', 48 | ) 49 | 50 | TEMPLATES = [ 51 | { 52 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 53 | 'APP_DIRS': True, 54 | 'OPTIONS': { 55 | 'context_processors': [ 56 | 'django.template.context_processors.debug', 57 | 'django.template.context_processors.request', 58 | 'django.contrib.auth.context_processors.auth', 59 | 'django.contrib.messages.context_processors.messages', 60 | ], 61 | }, 62 | }, 63 | ] 64 | -------------------------------------------------------------------------------- /tests/test_urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from django.contrib import admin 3 | 4 | 5 | urlpatterns = [ 6 | path('admin/', admin.site.urls), 7 | path('settings/', include('dbsettings.urls')), 8 | ] 9 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from unittest.mock import MagicMock 3 | 4 | import django 5 | from django.db import models 6 | from django import test 7 | from django.utils.translation import activate, deactivate 8 | 9 | import dbsettings 10 | from dbsettings import loading, signals, views 11 | 12 | from .models import TestSettings, Defaults, TestBaseModel, Populated, Unpopulated, Blankable, \ 13 | Editable, Combined, ClashSettings1, ClashSettings2, ClashSettings1_2, ModelClash, NonReq, \ 14 | NonRequiredSettings 15 | 16 | # Set up some settings to test 17 | MODULE_NAME = 'tests.models' 18 | 19 | module_settings = TestSettings(app_label='dbsettings') 20 | 21 | module_clash1 = ClashSettings1(app_label='dbsettings') 22 | 23 | module_clash2 = ClashSettings2(app_label='dbsettings') 24 | 25 | 26 | @test.override_settings(ROOT_URLCONF='tests.test_urls') 27 | class SettingsTestCase(test.TestCase): 28 | 29 | @classmethod 30 | def setUpClass(cls): 31 | # Since some text assertions are performed, make sure that no translation interrupts. 32 | super(SettingsTestCase, cls).setUpClass() 33 | activate('en') 34 | 35 | @classmethod 36 | def tearDownClass(cls): 37 | deactivate() 38 | super(SettingsTestCase, cls).tearDownClass() 39 | 40 | def setUp(self): 41 | super(SettingsTestCase, self).setUp() 42 | # Standard test fixtures don't update the in-memory cache. 43 | # So we have to do it ourselves this time. 44 | 45 | loading.set_setting_value(MODULE_NAME, 'Populated', 'boolean', True) 46 | loading.set_setting_value(MODULE_NAME, 'Populated', 'integer', 42) 47 | loading.set_setting_value(MODULE_NAME, 'Populated', 'string', 'Ni!') 48 | loading.set_setting_value(MODULE_NAME, 'Populated', 'list_semi_colon', 49 | 'a@b.com;c@d.com;e@f.com') 50 | loading.set_setting_value(MODULE_NAME, 'Populated', 'list_comma', 51 | 'a@b.com,c@d.com,e@f.com') 52 | loading.set_setting_value(MODULE_NAME, 'Populated', 'date', '2012-06-28') 53 | loading.set_setting_value(MODULE_NAME, 'Populated', 'time', '16:19:17') 54 | loading.set_setting_value(MODULE_NAME, 'Populated', 'datetime', '2012-06-28 16:19:17') 55 | loading.set_setting_value(MODULE_NAME, 'Populated', 'string_choices', 'String1') 56 | loading.set_setting_value(MODULE_NAME, '', 'boolean', False) 57 | loading.set_setting_value(MODULE_NAME, '', 'integer', 14) 58 | loading.set_setting_value(MODULE_NAME, '', 'string', 'Module') 59 | loading.set_setting_value(MODULE_NAME, '', 'list_semi_colon', 60 | 'g@h.com;i@j.com;k@l.com') 61 | loading.set_setting_value(MODULE_NAME, '', 'list_comma', 'g@h.com,i@j.com,k@l.com') 62 | loading.set_setting_value(MODULE_NAME, '', 'date', '2011-05-27') 63 | loading.set_setting_value(MODULE_NAME, '', 'time', '15:18:16') 64 | loading.set_setting_value(MODULE_NAME, '', 'datetime', '2011-05-27 15:18:16') 65 | loading.set_setting_value(MODULE_NAME, 'Combined', 'boolean', False) 66 | loading.set_setting_value(MODULE_NAME, 'Combined', 'integer', 1138) 67 | loading.set_setting_value(MODULE_NAME, 'Combined', 'string', 'THX') 68 | loading.set_setting_value(MODULE_NAME, 'Combined', 'list_semi_colon', 69 | 'm@n.com;o@p.com;q@r.com') 70 | loading.set_setting_value(MODULE_NAME, 'Combined', 'list_comma', 71 | 'm@n.com,o@p.com,q@r.com') 72 | loading.set_setting_value(MODULE_NAME, 'Combined', 'date', '2010-04-26') 73 | loading.set_setting_value(MODULE_NAME, 'Combined', 'time', '14:17:15') 74 | loading.set_setting_value(MODULE_NAME, 'Combined', 'datetime', '2010-04-26 14:17:15') 75 | loading.set_setting_value(MODULE_NAME, 'Combined', 'string_choices', 'String1') 76 | loading.set_setting_value(MODULE_NAME, 'Combined', 'enabled', True) 77 | 78 | def test_settings(self): 79 | "Make sure settings groups are initialized properly" 80 | 81 | # Settings already in the database are available immediately 82 | self.assertEqual(Populated.settings.boolean, True) 83 | self.assertEqual(Populated.settings.integer, 42) 84 | self.assertEqual(Populated.settings.string, 'Ni!') 85 | self.assertEqual(Populated.settings.list_semi_colon, ['a@b.com', 'c@d.com', 'e@f.com']) 86 | self.assertEqual(Populated.settings.list_comma, ['a@b.com', 'c@d.com', 'e@f.com']) 87 | self.assertEqual(Populated.settings.date, datetime.date(2012, 6, 28)) 88 | self.assertEqual(Populated.settings.time, datetime.time(16, 19, 17)) 89 | self.assertEqual(Populated.settings.datetime, datetime.datetime(2012, 6, 28, 16, 19, 17)) 90 | self.assertEqual(Populated.settings.string_choices, "String1") 91 | self.assertEqual(Populated.settings.get_string_choices_display(), "First String Choice") 92 | 93 | # Module settings are kept separate from model settings 94 | self.assertEqual(module_settings.boolean, False) 95 | self.assertEqual(module_settings.integer, 14) 96 | self.assertEqual(module_settings.string, 'Module') 97 | self.assertEqual(module_settings.list_semi_colon, ['g@h.com', 'i@j.com', 'k@l.com']) 98 | self.assertEqual(module_settings.list_comma, ['g@h.com', 'i@j.com', 'k@l.com']) 99 | self.assertEqual(module_settings.date, datetime.date(2011, 5, 27)) 100 | self.assertEqual(module_settings.time, datetime.time(15, 18, 16)) 101 | self.assertEqual(module_settings.datetime, datetime.datetime(2011, 5, 27, 15, 18, 16)) 102 | 103 | # Settings can be added together 104 | self.assertEqual(Combined.settings.boolean, False) 105 | self.assertEqual(Combined.settings.integer, 1138) 106 | self.assertEqual(Combined.settings.string, 'THX') 107 | self.assertEqual(Combined.settings.enabled, True) 108 | self.assertEqual(Combined.settings.list_semi_colon, ['m@n.com', 'o@p.com', 'q@r.com']) 109 | self.assertEqual(Combined.settings.list_comma, ['m@n.com', 'o@p.com', 'q@r.com']) 110 | self.assertEqual(Combined.settings.date, datetime.date(2010, 4, 26)) 111 | self.assertEqual(Combined.settings.time, datetime.time(14, 17, 15)) 112 | self.assertEqual(Combined.settings.datetime, datetime.datetime(2010, 4, 26, 14, 17, 15)) 113 | self.assertEqual(Combined.settings.string_choices, "String1") 114 | self.assertEqual(Combined.settings.get_string_choices_display(), "First String Choice") 115 | 116 | 117 | # Settings not in the database use empty defaults 118 | self.assertEqual(Unpopulated.settings.boolean, False) 119 | self.assertEqual(Unpopulated.settings.integer, None) 120 | self.assertEqual(Unpopulated.settings.string, '') 121 | self.assertEqual(Unpopulated.settings.list_semi_colon, []) 122 | self.assertEqual(Unpopulated.settings.list_comma, []) 123 | 124 | # ...Unless a default parameter was specified, then they use that 125 | self.assertEqual(Defaults.settings.boolean, True) 126 | self.assertEqual(Defaults.settings.boolean_false, False) 127 | self.assertEqual(Defaults.settings.integer, 1) 128 | self.assertEqual(Defaults.settings.string, 'default') 129 | self.assertEqual(Defaults.settings.list_semi_colon, ['one', 'two']) 130 | self.assertEqual(Defaults.settings.list_comma, ['one', 'two']) 131 | self.assertEqual(Defaults.settings.date, datetime.date(2012, 3, 14)) 132 | self.assertEqual(Defaults.settings.time, datetime.time(12, 3, 14)) 133 | self.assertEqual(Defaults.settings.datetime, datetime.datetime(2012, 3, 14, 12, 3, 14)) 134 | self.assertEqual(Defaults.settings.string_choices, "String2") 135 | self.assertEqual(Defaults.settings.get_string_choices_display(), "Second String Choice") 136 | 137 | # Settings should be retrieved in the order of definition 138 | self.assertEqual(Populated.settings.keys(), 139 | ['boolean', 'integer', 'string', 'list_semi_colon', 140 | 'list_comma', 'date', 'time', 'datetime', 'string_choices']) 141 | self.assertEqual(Combined.settings.keys(), 142 | ['boolean', 'integer', 'string', 'list_semi_colon', 143 | 'list_comma', 'date', 'time', 'datetime', 'string_choices', 'enabled']) 144 | 145 | # Values should be coerced to the proper Python types 146 | self.assertTrue(isinstance(Populated.settings.boolean, bool)) 147 | self.assertTrue(isinstance(Populated.settings.integer, int)) 148 | self.assertTrue(isinstance(Populated.settings.string, str)) 149 | 150 | # Settings can not be accessed directly from models, only instances 151 | self.assertRaises(AttributeError, lambda: Populated().settings) 152 | self.assertRaises(AttributeError, lambda: Unpopulated().settings) 153 | 154 | # Updates are reflected in the live settings 155 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'boolean', True) 156 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'integer', 13) 157 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'string', 'Friday') 158 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'list_semi_colon', 159 | 'aa@bb.com;cc@dd.com') 160 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'list_comma', 161 | 'aa@bb.com,cc@dd.com') 162 | # for date/time you can specify string (as above) or proper object 163 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'date', 164 | datetime.date(1912, 6, 23)) 165 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'time', 166 | datetime.time(1, 2, 3)) 167 | loading.set_setting_value(MODULE_NAME, 'Unpopulated', 'datetime', 168 | datetime.datetime(1912, 6, 23, 1, 2, 3)) 169 | 170 | self.assertEqual(Unpopulated.settings.boolean, True) 171 | self.assertEqual(Unpopulated.settings.integer, 13) 172 | self.assertEqual(Unpopulated.settings.string, 'Friday') 173 | self.assertEqual(Unpopulated.settings.list_semi_colon, ['aa@bb.com', 'cc@dd.com']) 174 | self.assertEqual(Unpopulated.settings.list_comma, ['aa@bb.com', 'cc@dd.com']) 175 | self.assertEqual(Unpopulated.settings.date, datetime.date(1912, 6, 23)) 176 | self.assertEqual(Unpopulated.settings.time, datetime.time(1, 2, 3)) 177 | self.assertEqual(Unpopulated.settings.datetime, datetime.datetime(1912, 6, 23, 1, 2, 3)) 178 | 179 | # Updating settings with defaults 180 | loading.set_setting_value(MODULE_NAME, 'Defaults', 'boolean', False) 181 | self.assertEqual(Defaults.settings.boolean, False) 182 | loading.set_setting_value(MODULE_NAME, 'Defaults', 'boolean_false', True) 183 | self.assertEqual(Defaults.settings.boolean_false, True) 184 | 185 | # Updating blankable settings 186 | self.assertEqual(Blankable.settings.string, '') 187 | loading.set_setting_value(MODULE_NAME, 'Blankable', 'string', 'Eli') 188 | self.assertEqual(Blankable.settings.string, 'Eli') 189 | loading.set_setting_value(MODULE_NAME, 'Blankable', 'string', '') 190 | self.assertEqual(Blankable.settings.string, '') 191 | 192 | # And they can be modified in-place 193 | Unpopulated.settings.boolean = False 194 | Unpopulated.settings.integer = 42 195 | Unpopulated.settings.string = 'Caturday' 196 | Unpopulated.settings.date = datetime.date(1939, 9, 1) 197 | Unpopulated.settings.time = '03:47:00' 198 | Unpopulated.settings.datetime = datetime.datetime(1939, 9, 1, 3, 47, 0) 199 | # Test correct stripping while we're at it. 200 | Unpopulated.settings.list_semi_colon = 'ee@ff.com; gg@hh.com' 201 | Unpopulated.settings.list_comma = 'ee@ff.com ,gg@hh.com' 202 | self.assertEqual(Unpopulated.settings.boolean, False) 203 | self.assertEqual(Unpopulated.settings.integer, 42) 204 | self.assertEqual(Unpopulated.settings.string, 'Caturday') 205 | self.assertEqual(Unpopulated.settings.list_semi_colon, ['ee@ff.com', 'gg@hh.com']) 206 | self.assertEqual(Unpopulated.settings.list_comma, ['ee@ff.com', 'gg@hh.com']) 207 | self.assertEqual(Unpopulated.settings.date, datetime.date(1939, 9, 1)) 208 | self.assertEqual(Unpopulated.settings.time, datetime.time(3, 47, 0)) 209 | self.assertEqual(Unpopulated.settings.datetime, datetime.datetime(1939, 9, 1, 3, 47, 0)) 210 | 211 | # Test non-required settings 212 | self.assertEqual(NonReq.non_req.integer, None) 213 | self.assertEqual(NonReq.non_req.fl, None) 214 | self.assertEqual(NonReq.non_req.string, "") 215 | 216 | loading.set_setting_value(MODULE_NAME, 'NonReq', 'integer', '2') 217 | self.assertEqual(NonReq.non_req.integer, 2) 218 | loading.set_setting_value(MODULE_NAME, 'NonReq', 'integer', '') 219 | self.assertEqual(NonReq.non_req.integer, None) 220 | 221 | def test_declaration(self): 222 | "Group declarations can only contain values and a docstring" 223 | # This definition is fine 224 | attrs = { 225 | '__doc__': "This is a docstring", 226 | 'test': dbsettings.IntegerValue(), 227 | } 228 | # So this should succeed 229 | type('GoodGroup', (dbsettings.Group,), attrs) 230 | 231 | # By adding an invalid attribute 232 | attrs['problem'] = 'not a Value' 233 | # This should fail 234 | self.assertRaises(TypeError, lambda: type('BadGroup', (dbsettings.Group,), attrs)) 235 | 236 | # Make sure affect models get the new permissions 237 | self.assertTrue('can_edit_populated_settings' in dict(Populated._meta.permissions)) 238 | self.assertTrue('can_edit_unpopulated_settings' in dict(Unpopulated._meta.permissions)) 239 | 240 | def assertCorrectSetting(self, value_class, *key): 241 | from dbsettings import loading 242 | setting = loading.get_setting(*key) 243 | self.assertEqual(key, setting.key) # Check if setting is registered with proper key 244 | self.assertTrue(isinstance(setting, value_class)) 245 | 246 | def test_registration(self): 247 | "Module and class settings can be mixed up" 248 | from dbsettings import BooleanValue, IntegerValue 249 | self.assertCorrectSetting(BooleanValue, MODULE_NAME, '', 'clash1') 250 | self.assertCorrectSetting(IntegerValue, MODULE_NAME, 'ModelClash', 'clash1') 251 | self.assertCorrectSetting(IntegerValue, MODULE_NAME, 'ModelClash', 'clash2') 252 | self.assertCorrectSetting(BooleanValue, MODULE_NAME, '', 'clash2') 253 | 254 | def assertLoginFormShown(self, response): 255 | self.assertRedirects(response, '/admin/login/?next=/settings/') 256 | 257 | def test_forms(self): 258 | "Forms should display only the appropriate settings" 259 | from django.contrib.auth.models import User, Permission 260 | from django.urls import reverse 261 | 262 | # Set up a users to test the editor forms 263 | user = User.objects.create_user('dbsettings', '', 'dbsettings') 264 | 265 | # Check named url 266 | site_form = reverse('site_settings') 267 | 268 | # First test without any authenticated user 269 | response = self.client.get(site_form) 270 | self.assertLoginFormShown(response) 271 | 272 | # Then test a standard non-staff user 273 | self.client.login(username='dbsettings', password='dbsettings') 274 | response = self.client.get(site_form) 275 | self.assertLoginFormShown(response) 276 | 277 | # Add staff status, but no settings permissions 278 | user.is_staff = True 279 | user.save() 280 | 281 | # Test the site-wide settings editor 282 | response = self.client.get(site_form) 283 | self.assertTemplateUsed(response, 'dbsettings/site_settings.html') 284 | self.assertEqual(response.context[0]['title'], 'Site settings') 285 | # No settings should show up without proper permissions 286 | self.assertEqual(len(response.context[0]['form'].fields), 0) 287 | 288 | # Add permissions so that settings will show up 289 | perm = Permission.objects.get(codename='can_edit_editable_settings') 290 | user.user_permissions.add(perm) 291 | 292 | # Check if verbose_name appears 293 | response = self.client.get(site_form) 294 | self.assertContains(response, 'Verbose name') 295 | 296 | # Erroneous submissions should be caught by newforms 297 | data = { 298 | '%s__Editable__integer' % MODULE_NAME: '3.5', 299 | '%s__Editable__string' % MODULE_NAME: '', 300 | '%s__Editable__list_semi_colon' % MODULE_NAME: '', 301 | '%s__Editable__list_comma' % MODULE_NAME: '', 302 | '%s__Editable__date' % MODULE_NAME: '3-77-99', 303 | '%s__Editable__time' % MODULE_NAME: 'abc', 304 | '%s__Editable__datetime' % MODULE_NAME: '', 305 | } 306 | response = self.client.post(site_form, data) 307 | self.assertFormError(response, 'form', '%s__Editable__integer' % MODULE_NAME, 308 | 'Enter a whole number.') 309 | self.assertFormError(response, 'form', '%s__Editable__string' % MODULE_NAME, 310 | 'This field is required.') 311 | self.assertFormError(response, 'form', '%s__Editable__list_semi_colon' % MODULE_NAME, 312 | 'This field is required.') 313 | self.assertFormError(response, 'form', '%s__Editable__list_comma' % MODULE_NAME, 314 | 'This field is required.') 315 | self.assertFormError(response, 'form', '%s__Editable__date' % MODULE_NAME, 316 | 'Enter a valid date.') 317 | self.assertFormError(response, 'form', '%s__Editable__time' % MODULE_NAME, 318 | 'Enter a valid time.') 319 | self.assertFormError(response, 'form', '%s__Editable__datetime' % MODULE_NAME, 320 | 'This field is required.') 321 | 322 | # Successful submissions should redirect 323 | data = { 324 | '%s__Editable__integer' % MODULE_NAME: '4', 325 | '%s__Editable__string' % MODULE_NAME: 'Success!', 326 | '%s__Editable__list_semi_colon' % MODULE_NAME: 'jj@kk.com;ll@mm.com', 327 | '%s__Editable__list_comma' % MODULE_NAME: 'jj@kk.com,ll@mm.com', 328 | '%s__Editable__date' % MODULE_NAME: '2012-06-28', 329 | '%s__Editable__time' % MODULE_NAME: '16:37:45', 330 | '%s__Editable__datetime' % MODULE_NAME: '2012-06-28 16:37:45', 331 | '%s__Editable__string_choices' % MODULE_NAME: 'String1', 332 | } 333 | response = self.client.post(site_form, data) 334 | self.assertRedirects(response, site_form) 335 | 336 | # And the data submitted should be immediately available in Python 337 | self.assertEqual(Editable.settings.integer, 4) 338 | self.assertEqual(Editable.settings.string, 'Success!') 339 | self.assertEqual(Editable.settings.list_semi_colon, ['jj@kk.com', 'll@mm.com']) 340 | self.assertEqual(Editable.settings.list_comma, ['jj@kk.com', 'll@mm.com']) 341 | self.assertEqual(Editable.settings.date, datetime.date(2012, 6, 28)) 342 | self.assertEqual(Editable.settings.time, datetime.time(16, 37, 45)) 343 | self.assertEqual(Editable.settings.datetime, datetime.datetime(2012, 6, 28, 16, 37, 45)) 344 | 345 | # test non-req submission 346 | perm = Permission.objects.get(codename='can_edit_nonreq_settings') 347 | user.user_permissions.add(perm) 348 | data = { 349 | '%s__NonReq__integer' % MODULE_NAME: '', 350 | '%s__NonReq__fl' % MODULE_NAME: '', 351 | '%s__NonReq__decimal' % MODULE_NAME: '', 352 | '%s__NonReq__percent' % MODULE_NAME: '', 353 | '%s__NonReq__string' % MODULE_NAME: '', 354 | } 355 | response = self.client.post(site_form, data) 356 | self.assertEqual(NonReq.non_req.integer, None) 357 | self.assertEqual(NonReq.non_req.fl, None) 358 | self.assertEqual(NonReq.non_req.decimal, None) 359 | self.assertEqual(NonReq.non_req.percent, None) 360 | self.assertEqual(NonReq.non_req.string, '') 361 | user.user_permissions.remove(perm) 362 | 363 | # Check if module level settings show properly 364 | self._test_form_fields(site_form, 9, False) 365 | # Add perm for whole app 366 | perm = Permission.objects.get(codename='can_edit__settings') # module-level settings 367 | user.user_permissions.add(perm) 368 | self._test_form_fields(site_form, 20) 369 | # Remove other perms - left only global perm 370 | perm = Permission.objects.get(codename='can_edit_editable_settings') 371 | user.user_permissions.remove(perm) 372 | self._test_form_fields(site_form, 11) 373 | 374 | def _test_form_fields(self, url, fields_num, present=True, variable_name='form'): 375 | global_setting = '%s____clash2' % MODULE_NAME # Some global setting name 376 | response = self.client.get(url) 377 | self.assertEqual(present, global_setting in response.context[0][variable_name].fields) 378 | self.assertEqual(len(response.context[0][variable_name].fields), fields_num) 379 | 380 | def test_signals(self): 381 | value = 'signal fired' 382 | 383 | handler = MagicMock() 384 | setting = loading.get_setting(MODULE_NAME, 'Unpopulated', 'string') 385 | signals.setting_changed.connect(handler, sender=setting) 386 | Unpopulated.settings.string = value 387 | handler.assert_called_once_with(signal=signals.setting_changed, sender=setting, value=value) 388 | 389 | handler = MagicMock() 390 | setting = loading.get_setting(MODULE_NAME, 'Populated', 'string') 391 | signals.setting_changed.connect(handler, sender=setting) 392 | Populated.settings.string = value 393 | handler.assert_called_once_with(signal=signals.setting_changed, sender=setting, value=value) 394 | 395 | handler = MagicMock() 396 | setting = loading.get_setting(MODULE_NAME, 'Combined', 'string') 397 | signals.setting_changed.connect(handler, sender=setting) 398 | Combined.settings.string = value 399 | handler.assert_called_once_with(signal=signals.setting_changed, sender=setting, value=value) 400 | 401 | handler = MagicMock() 402 | setting = loading.get_setting(MODULE_NAME, 'Unpopulated', 'integer') 403 | signals.setting_changed.connect(handler, sender=setting) 404 | Unpopulated.settings.integer = 43 405 | handler.assert_called_once_with(signal=signals.setting_changed, sender=setting, value=43) 406 | 407 | # If value has not changed, then the signal is not sent. 408 | new_handler = MagicMock() 409 | signals.setting_changed.connect(new_handler, sender=setting) 410 | Unpopulated.settings.integer = 43 411 | new_handler.assert_not_called() 412 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | distribute = False 3 | envlist = 4 | py{35}-django{21,22}, 5 | py{36,37}-django{21,22,30,31,32}, 6 | py{38,39,310}-django{21,22,30,31,32,40}, 7 | 8 | [testenv] 9 | downloadcache = {toxworkdir}/_download/ 10 | commands = 11 | python -V 12 | django-admin --version 13 | {envpython} runtests.py 14 | deps = 15 | django20: Django>=2.0,<2.1 16 | django21: Django>=2.1,<2.2 17 | django22: Django>=2.2,<2.3 18 | django30: Django>=3.0,<3.1 19 | django31: Django>=3.1,<3.2 20 | django32: Django>=3.2,<3.3 21 | django40: Django>=4.0,<4.1 22 | --------------------------------------------------------------------------------