├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── AGENTS.md ├── LICENSE ├── Makefile ├── README.md ├── README_SHORT.md ├── content_settings ├── __init__.py ├── admin.py ├── apps.py ├── cache_triggers.py ├── caching.py ├── conf.py ├── context_managers.py ├── context_processors.py ├── defaults │ ├── collections.py │ ├── context.py │ ├── filters.py │ └── modifiers.py ├── export.py ├── fields.py ├── functools.py ├── management │ └── commands │ │ ├── content_settings_export.py │ │ ├── content_settings_import.py │ │ └── content_settings_migrate.py ├── middlewares.py ├── migrate.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_user_defined.py │ ├── 0003_user_preview.py │ ├── 0004_userdefined_preview.py │ └── __init__.py ├── models.py ├── permissions.py ├── receivers.py ├── settings.py ├── store.py ├── tags.py ├── templates │ └── admin │ │ └── content_settings │ │ └── contentsetting │ │ ├── change_form.html │ │ ├── change_list.html │ │ ├── context_tags.html │ │ ├── import_json.html │ │ ├── pagination.html │ │ └── submit_line.html ├── templatetags │ ├── __init__.py │ ├── content_settings_admin.py │ └── content_settings_extras.py ├── tests │ └── test_all_settings.py ├── types │ ├── __init__.py │ ├── array.py │ ├── basic.py │ ├── datetime.py │ ├── each.py │ ├── lazy.py │ ├── markup.py │ ├── mixins.py │ ├── template.py │ └── validators.py ├── utils.py ├── views.py └── widgets.py ├── cs_test ├── Dockerfile ├── cs_test │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── settings_maria.py │ ├── urls.py │ └── wsgi.py ├── docker-compose.yml ├── manage.py └── songs │ ├── admin.py │ ├── content_settings.py │ ├── migrations │ ├── 0001_initial.py │ ├── 0002_userdefined_preview.py │ ├── 0003_admin.py │ ├── 0004_update_settings.py │ └── __init__.py │ ├── models.py │ ├── templates │ └── songs │ │ └── index.html │ └── urls.py ├── docs ├── access.md ├── api.md ├── caching.md ├── changelog.md ├── commands.md ├── contribute.md ├── cookbook.md ├── defaults.md ├── epilogue.md ├── extends.md ├── faq.md ├── first.md ├── glossary.md ├── img │ ├── dict_suffixes_preview.gif │ ├── preview.gif │ ├── preview_on_site.png │ ├── split_translation.png │ ├── title.png │ └── ui │ │ ├── batch_changes.png │ │ ├── django_history.png │ │ ├── edit_page.png │ │ ├── history_button.png │ │ ├── history_export.png │ │ ├── import_json.png │ │ ├── import_json_error.png │ │ ├── import_json_preview.png │ │ ├── list_view.png │ │ ├── list_view_actions.png │ │ ├── list_view_actions_export.png │ │ ├── list_view_bottom.png │ │ ├── list_view_preview.png │ │ ├── list_view_preview_panel.png │ │ ├── list_view_tag_filter.png │ │ └── main_admin.png ├── index.md ├── permissions.md ├── requirements.txt ├── settings.md ├── source.md ├── template_types.md ├── types.md ├── ui.md └── uservar.md ├── mdsource.py ├── mkdocs.yml ├── noxfile.py ├── poetry.lock ├── pyproject.toml ├── set_version.py └── tests ├── __init__.py ├── books ├── content_settings.py ├── models.py ├── templates │ └── books │ │ ├── index.html │ │ ├── list.html │ │ └── simple.html └── urls.py ├── conftest.py ├── test_admin.py ├── test_admin_import.py ├── test_admin_preview.py ├── test_api.py ├── test_caching.py ├── test_commands.py ├── test_context_defaults.py ├── test_get_help.py ├── test_init.py ├── test_json_view_value.py ├── test_lazy.py ├── test_migrate_db.py ├── test_types_array.py ├── test_types_basic.py ├── test_types_datetime.py ├── test_types_each.py ├── test_types_markup.py ├── test_types_template.py ├── test_unit_defaults.py ├── test_unit_utils.py ├── test_units.py ├── test_user_defined_types.py ├── test_utils.py ├── tools.py └── urls.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.db 3 | *~ 4 | .* 5 | *.py.bak 6 | cov.xml 7 | db.sqlite3 8 | 9 | 10 | /site/ 11 | /htmlcov/ 12 | /coverage/ 13 | /build/ 14 | /dist/ 15 | /*.egg-info/ 16 | MANIFEST 17 | coverage.* 18 | 19 | !.github 20 | !.gitignore 21 | !.pre-commit-config.yaml 22 | !.readthedocs.yaml 23 | local_*.sh -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: stable 4 | hooks: 5 | - id: black 6 | language_version: python3.8 -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for MkDocs projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the version of Python and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | 13 | mkdocs: 14 | configuration: mkdocs.yml 15 | 16 | # Optionally declare the Python requirements required to build your docs 17 | python: 18 | install: 19 | - requirements: docs/requirements.txt -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # AGENTS Instructions 2 | 3 | This repository uses a Makefile for common development tasks. 4 | 5 | * Run `make init` to install dependencies and initialize the environment. 6 | * Run `make test-all` to execute the full test suite. 7 | 8 | Whenever you modify code or documentation, ensure you run these commands to verify your changes. 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Occipital 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | init: 2 | poetry install 3 | 4 | test-all: 5 | make test-min 6 | make test-full 7 | make test 8 | make test-cache 9 | make test-cache-full 10 | make test-cache-min 11 | 12 | test-min: 13 | TESTING_SETTINGS=min poetry run pytest 14 | 15 | test-full: 16 | TESTING_SETTINGS=full poetry run pytest 17 | 18 | test: 19 | poetry run pytest 20 | 21 | test-cache: 22 | TESTING_PRECACHED_PY_VALUES=1 poetry run pytest 23 | 24 | test-cache-full: 25 | TESTING_PRECACHED_PY_VALUES=1 TESTING_SETTINGS=full poetry run pytest 26 | 27 | test-cache-min: 28 | TESTING_PRECACHED_PY_VALUES=1 TESTING_SETTINGS=min poetry run pytest 29 | 30 | test-v: 31 | poetry run pytest -vv -s 32 | 33 | test-cov: 34 | TESTING_SETTINGS=full poetry run pytest --cov=content_settings 35 | 36 | test-cov-xml: 37 | TESTING_SETTINGS=full poetry run pytest --cov=content_settings --cov-report xml:cov.xml 38 | 39 | test-nox: 40 | poetry run nox 41 | 42 | test-nox-oldest: 43 | poetry run nox --session "tests-3.8(pyyaml=True, django='3.2')" 44 | 45 | doc: 46 | poetry run mkdocs serve 47 | 48 | mdsource: 49 | poetry run poetry run python mdsource.py 50 | 51 | publish: 52 | poetry run python set_version.py 53 | poetry publish --build 54 | 55 | cs-test: 56 | make cs-test-migrate 57 | poetry run poetry run python cs_test/manage.py runserver 0.0.0.0:8000 58 | 59 | cs-test-migrate: 60 | poetry run poetry run python cs_test/manage.py migrate 61 | 62 | cs-test-shell: 63 | poetry run poetry run python cs_test/manage.py shell 64 | 65 | cs-test-docker-build: 66 | docker compose -f cs_test/docker-compose.yml build 67 | 68 | cs-test-docker-up: 69 | docker compose -f cs_test/docker-compose.yml up -------------------------------------------------------------------------------- /content_settings/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.29.2" 2 | -------------------------------------------------------------------------------- /content_settings/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class StaticContentConfig(AppConfig): 5 | name = "content_settings" 6 | verbose_name = "Content Settings" 7 | 8 | def ready(self): 9 | import content_settings.receivers 10 | -------------------------------------------------------------------------------- /content_settings/context_managers.py: -------------------------------------------------------------------------------- 1 | """ 2 | context managers for the content settings, but not all `defaults` context manager can be found in `content_settings.defaults.context.defaults` 3 | """ 4 | 5 | from contextlib import ContextDecorator 6 | 7 | 8 | class content_settings_context(ContextDecorator): 9 | """ 10 | context manager that overwrites settings in the context. 11 | 12 | `**kwargs` for the context manager are the settings to overwrite, where key is a setting name and value is a raw value of the setting. 13 | 14 | outside of the content_settings module can be used for testing. 15 | 16 | `_raise_errors: bool = True` - if False, then ignore errors when applying value of the setting. 17 | """ 18 | 19 | def __init__(self, **values) -> None: 20 | self.raise_errors = values.pop("_raise_errors", True) 21 | super().__init__() 22 | self.values_to_update = values 23 | self.prev_values = {} 24 | self.prev_types = {} 25 | 26 | def __enter__(self): 27 | from content_settings.caching import set_new_value, set_new_type 28 | 29 | for name, new_value in self.values_to_update.items(): 30 | if isinstance(new_value, tuple): 31 | new_value, *type_define = new_value 32 | try: 33 | self.prev_types[name] = set_new_type(name, *type_define) 34 | except: 35 | if self.raise_errors: 36 | raise 37 | try: 38 | self.prev_values[name] = set_new_value(name, new_value) 39 | except: 40 | if self.raise_errors: 41 | raise 42 | 43 | def __exit__(self, *exc): 44 | from content_settings.caching import ( 45 | set_new_value, 46 | replace_user_type, 47 | delete_user_value, 48 | delete_value, 49 | ) 50 | 51 | for name, new_value in self.prev_values.items(): 52 | if name in self.prev_types: 53 | if self.prev_types[name] is None: 54 | delete_user_value(name) 55 | continue 56 | replace_user_type(name, self.prev_types[name]) 57 | if new_value is None: 58 | delete_value(name) 59 | else: 60 | set_new_value(name, new_value) 61 | -------------------------------------------------------------------------------- /content_settings/context_processors.py: -------------------------------------------------------------------------------- 1 | """ 2 | the module contains context processors for the django templates. 3 | """ 4 | 5 | from .conf import content_settings as _content_settings 6 | 7 | 8 | def content_settings(request=None): 9 | """ 10 | context processor for the django templates that provides content_settings object into template as CONTENT_SETTINGS. 11 | """ 12 | return {"CONTENT_SETTINGS": _content_settings} 13 | -------------------------------------------------------------------------------- /content_settings/defaults/collections.py: -------------------------------------------------------------------------------- 1 | """ 2 | defaults collections for using in `CONTENT_SETTINGS_DEFAULTS`. 3 | 4 | For example: 5 | 6 | ```python 7 | CONTENT_SETTINGS_DEFAULTS = [ 8 | codemirror_python(), 9 | codemirror_json(), 10 | ] 11 | ``` 12 | 13 | Or: 14 | 15 | ```python 16 | CONTENT_SETTINGS_DEFAULTS = [ 17 | *codemirror_all(), 18 | ] 19 | ``` 20 | """ 21 | 22 | from content_settings.functools import or_ 23 | 24 | from .modifiers import add_admin_head, add_widget_class 25 | from .filters import full_name_exact 26 | 27 | DEFAULT_CODEMIRROR_PATH = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/" 28 | 29 | 30 | def _codemirror_raw_js(class_attr: str, mode: str): 31 | return f""" 32 | Array.from(document.getElementsByClassName("{class_attr}")).forEach((item, i) => {{ 33 | const cm = CodeMirror.fromTextArea(item, {{ 34 | lineNumbers: true, 35 | mode: "{mode}" 36 | }}); 37 | cm.on("change", function(editor) {{ 38 | item.value = editor.getValue(); 39 | }}); 40 | cm.on("focus", function(editor) {{ 41 | item.dispatchEvent(new Event('focus')); 42 | }}); 43 | cm.display.wrapper.after(item); 44 | }}); 45 | """ 46 | 47 | 48 | def codemirror_python( 49 | path: str = DEFAULT_CODEMIRROR_PATH, class_attr: str = "codemirror_python" 50 | ): 51 | """ 52 | Replace Textarea with CodeMirror for python code for SimpleEval and SimpleExec. 53 | """ 54 | return ( 55 | or_( 56 | full_name_exact("content_settings.types.template.SimpleEval"), 57 | full_name_exact("content_settings.types.template.SimpleExec"), 58 | full_name_exact("content_settings.types.template.SimpleExecNoCompile"), 59 | ), 60 | add_admin_head( 61 | css=[f"{path}codemirror.min.css"], 62 | js=[ 63 | f"{path}codemirror.min.js", 64 | f"{path}mode/python/python.min.js", 65 | ], 66 | js_raw=[_codemirror_raw_js(class_attr, "python")], 67 | ), 68 | add_widget_class(class_attr), 69 | ) 70 | 71 | 72 | def codemirror_json( 73 | path: str = DEFAULT_CODEMIRROR_PATH, class_attr: str = "codemirror_json" 74 | ): 75 | """ 76 | Replace Textarea with CodeMirror for json code for SimpleJSON. 77 | """ 78 | return ( 79 | full_name_exact("content_settings.types.markup.SimpleJSON"), 80 | add_admin_head( 81 | css=[f"{path}codemirror.min.css"], 82 | js=[ 83 | f"{path}codemirror.min.js", 84 | f"{path}mode/javascript/javascript.min.js", 85 | ], 86 | js_raw=[_codemirror_raw_js(class_attr, "javascript")], 87 | ), 88 | add_widget_class(class_attr), 89 | ) 90 | 91 | 92 | def codemirror_yaml( 93 | path: str = DEFAULT_CODEMIRROR_PATH, class_attr: str = "codemirror_yaml" 94 | ): 95 | """ 96 | Replace Textarea with CodeMirror for yaml code for SimpleYAML. 97 | """ 98 | return ( 99 | full_name_exact("content_settings.types.markup.SimpleYAML"), 100 | add_admin_head( 101 | css=[f"{path}codemirror.min.css"], 102 | js=[ 103 | f"{path}codemirror.min.js", 104 | f"{path}mode/yaml/yaml.min.js", 105 | ], 106 | js_raw=[_codemirror_raw_js(class_attr, "yaml")], 107 | ), 108 | add_widget_class(class_attr), 109 | ) 110 | 111 | 112 | def codemirror_all( 113 | path: str = DEFAULT_CODEMIRROR_PATH, class_attr_prefix: str = "codemirror_" 114 | ): 115 | """ 116 | Replace Textarea with CodeMirror for python, json and yaml code. 117 | """ 118 | return [ 119 | codemirror_python(path, f"{class_attr_prefix}python"), 120 | codemirror_json(path, f"{class_attr_prefix}json"), 121 | codemirror_yaml(path, f"{class_attr_prefix}yaml"), 122 | ] 123 | -------------------------------------------------------------------------------- /content_settings/defaults/context.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Iterator, Set, Dict, Any 3 | 4 | from content_settings.settings import DEFAULTS as DEFAULTS_SETTINGS 5 | from content_settings.types import BaseSetting 6 | from content_settings.defaults.modifiers import TModifier 7 | 8 | from .filters import any_name 9 | from .modifiers import set_if_missing, add_tags, help_prefix, help_suffix, NotSet 10 | 11 | 12 | DEFAULTS = [*DEFAULTS_SETTINGS] 13 | 14 | 15 | @contextmanager 16 | def defaults(*args, **kwargs): 17 | """ 18 | Context manager for setting defaults. 19 | """ 20 | DEFAULTS.append((any_name, *args, set_if_missing(**kwargs))) 21 | try: 22 | yield 23 | finally: 24 | DEFAULTS.pop() 25 | 26 | 27 | @contextmanager 28 | def default_tags(tags: Set[str]): 29 | """ 30 | defaults context for setting default tags. 31 | """ 32 | with defaults(add_tags(tags)): 33 | yield 34 | 35 | 36 | @contextmanager 37 | def default_help_prefix(prefix: str): 38 | """ 39 | defaults context for setting default help prefix. 40 | """ 41 | with defaults(help_prefix(prefix)): 42 | yield 43 | 44 | 45 | @contextmanager 46 | def default_help_suffix(suffix: str): 47 | """ 48 | defaults context for setting default help suffix. 49 | """ 50 | with defaults(help_suffix(suffix)): 51 | yield 52 | 53 | 54 | def defaults_modifiers(setting: BaseSetting) -> Iterator[TModifier]: 55 | """ 56 | Generator for all modifiers for the given setting. 57 | """ 58 | for modifier in DEFAULTS: 59 | if not modifier[0](setting.__class__): 60 | continue 61 | yield from modifier[1:] 62 | 63 | 64 | def update_defaults(setting: BaseSetting, kwargs: Dict[str, Any]): 65 | """ 66 | Update paramas of the setting type by applying all of the modifiers from the defaults context. 67 | """ 68 | type_kwargs = { 69 | name: getattr(setting, name) 70 | for name in dir(setting) 71 | if setting.can_assign(name) 72 | } 73 | updates = {} 74 | for modifier in defaults_modifiers(setting): 75 | updates.update(modifier(type_kwargs, updates, kwargs)) 76 | 77 | if not updates: 78 | return kwargs 79 | 80 | return { 81 | **kwargs, 82 | **{ 83 | k: v 84 | for k, v in updates.items() 85 | if setting.can_assign(k) and v is not NotSet and type_kwargs[k] != v 86 | }, 87 | } 88 | -------------------------------------------------------------------------------- /content_settings/defaults/filters.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions that can be used as filters for the `DEFAULTS` setting. 3 | 4 | Each function has a single attribute *settings type* and should return a boolean. 5 | """ 6 | 7 | from typing import Callable, Type 8 | 9 | from content_settings.types import BaseSetting 10 | from content_settings.utils import class_names 11 | 12 | TFilter = Callable[[Type[BaseSetting]], bool] 13 | 14 | 15 | def any_name(cls: Type[BaseSetting]) -> bool: 16 | """ 17 | Allow all settings. 18 | """ 19 | return True 20 | 21 | 22 | def name_exact(name: str) -> TFilter: 23 | """ 24 | Allow only settings with the exact type name or parent type name. 25 | 26 | Args: 27 | name (str): The exact name to match. 28 | """ 29 | 30 | def f(cls: Type[BaseSetting]) -> bool: 31 | return any(name == el[1] for el in class_names(cls)) 32 | 33 | return f 34 | 35 | 36 | def full_name_exact(name: str) -> TFilter: 37 | """ 38 | Allow only settings with the exact full type name or parent type name. The name includes module name. 39 | 40 | Args: 41 | name (str): The exact full name to match, including the module. 42 | 43 | """ 44 | 45 | def f(cls: Type[BaseSetting]) -> bool: 46 | return any(name == f"{el[0]}.{el[1]}" for el in class_names(cls)) 47 | 48 | return f 49 | -------------------------------------------------------------------------------- /content_settings/fields.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fields that can be used in `cls_field` attribute of the content settings. 3 | 4 | It includes all of the fields from django.forms.fields + several custom fields. 5 | """ 6 | 7 | from django.forms.fields import * 8 | from django.forms.fields import CharField as BaseCharField 9 | 10 | 11 | class StripCharField(CharField): 12 | """ 13 | CharField that strips the value. (default Django CharField) 14 | """ 15 | 16 | 17 | class NoStripCharField(CharField): 18 | """ 19 | CharField that does not strip the value. 20 | """ 21 | 22 | def __init__(self, *args, **kwargs): 23 | super().__init__(*args, **kwargs) 24 | self.strip = False 25 | -------------------------------------------------------------------------------- /content_settings/functools.py: -------------------------------------------------------------------------------- 1 | """ 2 | in the same way as python has functools, the module also has a few functions 3 | to help with the function manipulation. 4 | """ 5 | 6 | from .utils import call_base_str 7 | 8 | 9 | def and_(*funcs): 10 | """ 11 | Returns a function that performs an 'and' operation on multiple functions. 12 | """ 13 | 14 | def _(*args, call_base=None, **kwargs): 15 | return all( 16 | call_base_str(func, *args, call_base=call_base, **kwargs) for func in funcs 17 | ) 18 | 19 | return _ 20 | 21 | 22 | def or_(*funcs): 23 | """ 24 | Returns a function that performs an 'or' operation on multiple functions. 25 | """ 26 | 27 | def _(*args, call_base=None, **kwargs): 28 | return any( 29 | call_base_str(func, *args, call_base=call_base, **kwargs) for func in funcs 30 | ) 31 | 32 | return _ 33 | 34 | 35 | def not_(func): 36 | """ 37 | Returns a function that performs a 'not' operation on a function. 38 | """ 39 | 40 | def _(*args, call_base=None, **kwargs): 41 | return not call_base_str(func, *args, call_base=call_base, **kwargs) 42 | 43 | return _ 44 | -------------------------------------------------------------------------------- /content_settings/management/commands/content_settings_export.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.core.management.base import BaseCommand 3 | from content_settings.models import ContentSetting 4 | from content_settings.export import export_to_format 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Export selected content settings (by name) or all content settings into JSON format." 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument( 12 | "--names", 13 | nargs="+", 14 | type=str, 15 | help="Names of the content settings to export. If not provided, all content settings will be exported.", 16 | ) 17 | 18 | def handle(self, *args, **options): 19 | names = options["names"] 20 | if names: 21 | content_settings = ContentSetting.objects.filter(name__in=names) 22 | else: 23 | content_settings = ContentSetting.objects.all() 24 | 25 | json_data = export_to_format(content_settings) 26 | 27 | self.stdout.write(json_data) 28 | -------------------------------------------------------------------------------- /content_settings/management/commands/content_settings_import.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | from django.core.management.base import BaseCommand 4 | from content_settings.export import preview_data, import_to 5 | from django.contrib.auth import get_user_model 6 | 7 | User = get_user_model() 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Import content settings from a JSON file." 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument( 15 | "filename", 16 | type=str, 17 | help="Path to the JSON file containing content settings.", 18 | ) 19 | parser.add_argument( 20 | "--import", 21 | action="store_true", 22 | default=False, 23 | help="Apply the import if set, otherwise just preview.", 24 | ) 25 | parser.add_argument( 26 | "--preview-for", 27 | type=str, 28 | default=None, 29 | help="Import as a preview for a specific user.", 30 | ) 31 | parser.add_argument( 32 | "--names", 33 | nargs="+", 34 | type=str, 35 | help="Names of specific content settings to import.", 36 | ) 37 | 38 | parser.add_argument( 39 | "--show-skipped", 40 | action="store_true", 41 | default=False, 42 | help="Show skipped settings.", 43 | ) 44 | parser.add_argument( 45 | "--show-only-errors", 46 | action="store_true", 47 | default=False, 48 | help="Show only errors.", 49 | ) 50 | 51 | def handle(self, *args, **options): 52 | filename = options["filename"] 53 | do_import = options["import"] 54 | preview_for = options["preview_for"] 55 | names = options["names"] 56 | show_skipped = options["show_skipped"] 57 | show_only_errors = options["show_only_errors"] 58 | 59 | with open(filename, "r") as file: 60 | data = json.load(file) 61 | 62 | if preview_for: 63 | preview_for = User.objects.get(username=preview_for) 64 | 65 | errors, applied, skipped = preview_data(data, preview_for) 66 | 67 | if skipped and show_skipped and not show_only_errors: 68 | self.stdout.write("Skipped:") 69 | self.stdout.write(json.dumps(skipped, indent=2)) 70 | 71 | if errors: 72 | self.stderr.write("Errors:") 73 | self.stderr.write(json.dumps(errors, indent=2)) 74 | 75 | if applied and not show_only_errors: 76 | self.stdout.write("Applied:") 77 | self.stdout.write(json.dumps(applied, indent=2)) 78 | 79 | if do_import or preview_for: 80 | if names: 81 | applied = [row for row in applied if row["name"] in names] 82 | 83 | import_to(data, applied, preview_for is not None, preview_for) 84 | 85 | if preview_for: 86 | self.stdout.write( 87 | self.style.SUCCESS( 88 | "Preview saved for user %s" % preview_for.username 89 | ) 90 | ) 91 | else: 92 | self.stdout.write(self.style.SUCCESS("Import completed.")) 93 | -------------------------------------------------------------------------------- /content_settings/management/commands/content_settings_migrate.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from django.core.management.base import BaseCommand 4 | from django.db import transaction 5 | 6 | 7 | from content_settings.conf import set_initial_values_for_db 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Create new records and remove unused settings for content settings" 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument( 15 | "--apply", action="store_true", default=False, help="Apply " 16 | ) 17 | 18 | def handle(self, *args, **options): 19 | with transaction.atomic(): 20 | log = set_initial_values_for_db(apply=options.get("apply")) 21 | if not log: 22 | print("No Static Content Settings Updated") 23 | return 24 | 25 | for l in log: 26 | print(" ".join(l)) 27 | 28 | print("Applied!" if options.get("apply") else "Ignored") 29 | -------------------------------------------------------------------------------- /content_settings/middlewares.py: -------------------------------------------------------------------------------- 1 | """ 2 | Available middlewares for the content settings. 3 | """ 4 | 5 | from django.urls import reverse 6 | 7 | from .context_managers import content_settings_context 8 | from .models import UserPreview 9 | from .settings import PREVIEW_ON_SITE_SHOW 10 | 11 | 12 | def preview_on_site(get_response): 13 | """ 14 | the middleware required for previewing the content settings on the site. 15 | 16 | It checks content_settings.can_preview_on_site permission for the user and if the user has it, then the middleware will preview the content settings for the user. 17 | """ 18 | 19 | def middleware(request): 20 | if not PREVIEW_ON_SITE_SHOW or not request.user.has_perm( 21 | "content_settings.can_preview_on_site" 22 | ): 23 | return get_response(request) 24 | 25 | if request.path.startswith(reverse("admin:index") + "content_settings/"): 26 | return get_response(request) 27 | 28 | preview_settings = UserPreview.get_context_dict(request.user) 29 | if not preview_settings: 30 | return get_response(request) 31 | 32 | with content_settings_context(**preview_settings, _raise_errors=False): 33 | return get_response(request) 34 | 35 | return middleware 36 | -------------------------------------------------------------------------------- /content_settings/migrate.py: -------------------------------------------------------------------------------- 1 | """ 2 | A set of functions that can be used inside migrations. 3 | """ 4 | 5 | from typing import Union, Optional, Type, Dict, Any 6 | import json 7 | 8 | from django.db import migrations, models 9 | from django.contrib.auth.models import User 10 | 11 | 12 | def RunImport(data: Union[str, dict], reverse_data: Optional[Union[str, dict]] = None): 13 | """ 14 | The function that can be used inside your migration file. 15 | 16 | Args: 17 | data (Union[str, dict]): The data to import. Can be either a JSON string or a dictionary. 18 | reverse_data (Optional[Union[str, dict]], optional): The data to use for reversing the migration. 19 | Can be either a JSON string or a dictionary. If None, the migration will not be reversible. Defaults to None. 20 | 21 | Returns: 22 | migrations.RunPython: A RunPython operation that can be used in a migration file. 23 | 24 | Example: 25 | In your migration file: 26 | 27 | from content_settings.migrate import RunImport 28 | 29 | class Migration(migrations.Migration): 30 | dependencies = [ 31 | ('content_settings', '0004_userdefined_preview'), 32 | ] 33 | 34 | operations = [ 35 | RunImport({ 36 | "settings": { 37 | "AFTER_TITLE": { 38 | "value": "Best Settings Framework", 39 | "version": "" 40 | }, 41 | "ARTIST_LINE": { 42 | "value": "", 43 | "version": "" 44 | }, 45 | "DAYS_WITHOUT_FAIL": { 46 | "value": "5", 47 | "version": " 48 | }, 49 | "WEE": { 50 | "value": "12", 51 | "tags": "", 52 | "help": "12", 53 | "version": "", 54 | "user_defined_type": "text" 55 | } 56 | } 57 | }), 58 | ] 59 | 60 | Note: 61 | This function is designed to be used within Django migration files. It handles the import of content settings, 62 | creating or updating them as necessary. If reverse_data is provided, it also sets up the reverse operation 63 | for the migration. 64 | """ 65 | if isinstance(data, str): 66 | data = json.loads(data) 67 | 68 | def run(apps, schema_editor): 69 | 70 | import_settings( 71 | data, 72 | model_cs=apps.get_model("content_settings", "ContentSetting"), 73 | model_cs_history=apps.get_model( 74 | "content_settings", "HistoryContentSetting" 75 | ), 76 | ) 77 | 78 | if reverse_data is None: 79 | return migrations.RunPython(run, lambda apps, schema_editor: None) 80 | 81 | if isinstance(reverse_data, str): 82 | reverse_data = json.loads(reverse_data) 83 | 84 | def reverse_run(apps, schema_editor): 85 | import_settings( 86 | reverse_data, 87 | model_cs=apps.get_model("content_settings", "ContentSetting"), 88 | model_cs_history=apps.get_model( 89 | "content_settings", "HistoryContentSetting" 90 | ), 91 | ) 92 | 93 | return migrations.RunPython(run, reverse_run) 94 | 95 | 96 | def import_settings( 97 | data: Dict[str, Any], 98 | model_cs: Type[models.Model], 99 | model_cs_history: Optional[Type[models.Model]] = None, 100 | user: Optional[User] = None, 101 | ) -> None: 102 | """ 103 | Import content settings from a dictionary. 104 | 105 | Args: 106 | data (Dict[str, Any]): A dictionary containing the settings to import. 107 | model_cs (Type[models.Model]): The ContentSetting model class. 108 | model_cs_history (Optional[Type[models.Model]]): The ContentSettingHistory model class, if history tracking is enabled. 109 | user (Optional[User]): The user performing the import, if applicable. 110 | 111 | Returns: 112 | None 113 | 114 | This function iterates through the settings in the provided data dictionary, 115 | creating or updating ContentSetting objects as necessary. It also handles 116 | history tracking if a history model is provided. 117 | """ 118 | for name, value in data["settings"].items(): 119 | cs = model_cs.objects.filter(name=name).first() 120 | was_changed: Optional[bool] = cs is not None 121 | if value is None: 122 | was_changed = None 123 | 124 | if value is None: 125 | if cs: 126 | cs.delete() 127 | else: 128 | if not cs: 129 | cs = model_cs(name=name) 130 | for key, in_value in value.items(): 131 | setattr(cs, key, in_value) 132 | cs.save() 133 | 134 | if user is not None: 135 | model_cs_history.update_last_record_for_name(name, user) 136 | 137 | elif model_cs_history is not None: 138 | history = model_cs_history( 139 | name=name, was_changed=was_changed, by_user=False 140 | ) 141 | 142 | for key, in_value in value.items(): 143 | setattr(history, key, in_value) 144 | history.save() 145 | -------------------------------------------------------------------------------- /content_settings/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.12 on 2020-06-01 19:35 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="ContentSetting", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("name", models.CharField(max_length=200, unique=True)), 29 | ("value", models.TextField(blank=True)), 30 | ("version", models.CharField(max_length=50, null=True)), 31 | ], 32 | options={"ordering": ("name",)}, 33 | ), 34 | migrations.CreateModel( 35 | name="HistoryContentSetting", 36 | fields=[ 37 | ( 38 | "id", 39 | models.AutoField( 40 | primary_key=True, serialize=False, verbose_name="ID" 41 | ), 42 | ), 43 | ("created_on", models.DateTimeField(default=django.utils.timezone.now)), 44 | ("name", models.CharField(max_length=200)), 45 | ("value", models.TextField(blank=True, null=True)), 46 | ("version", models.CharField(max_length=50, null=True)), 47 | ( 48 | "was_changed", 49 | models.BooleanField( 50 | choices=[ 51 | (True, "Changed"), 52 | (False, "Added"), 53 | (None, "Removed"), 54 | ], 55 | default=True, 56 | null=True, 57 | ), 58 | ), 59 | ( 60 | "by_user", 61 | models.BooleanField( 62 | choices=[ 63 | (True, "by user"), 64 | (False, "by app"), 65 | (None, "unknown"), 66 | ], 67 | default=None, 68 | null=True, 69 | ), 70 | ), 71 | ( 72 | "user", 73 | models.ForeignKey( 74 | null=True, 75 | on_delete=django.db.models.deletion.SET_NULL, 76 | to=settings.AUTH_USER_MODEL, 77 | ), 78 | ), 79 | ], 80 | options={ 81 | "ordering": ("-id",), 82 | }, 83 | ), 84 | migrations.CreateModel( 85 | name="UserTagSetting", 86 | fields=[ 87 | ( 88 | "id", 89 | models.AutoField( 90 | primary_key=True, serialize=False, verbose_name="ID" 91 | ), 92 | ), 93 | ("name", models.CharField(max_length=200)), 94 | ("tag", models.CharField(max_length=200)), 95 | ( 96 | "user", 97 | models.ForeignKey( 98 | on_delete=django.db.models.deletion.CASCADE, 99 | to=settings.AUTH_USER_MODEL, 100 | ), 101 | ), 102 | ], 103 | options={ 104 | "unique_together": {("user", "name", "tag")}, 105 | }, 106 | ), 107 | migrations.AddIndex( 108 | model_name="historycontentsetting", 109 | index=models.Index( 110 | fields=["name", "-id"], name="static_cont_name_d40020_idx" 111 | ), 112 | ), 113 | ] 114 | -------------------------------------------------------------------------------- /content_settings/migrations/0002_user_defined.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-01-16 11:35 2 | 3 | from django.db import migrations, models 4 | import django.core.validators 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("content_settings", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="contentsetting", 15 | name="help", 16 | field=models.TextField(blank=True, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name="contentsetting", 20 | name="tags", 21 | field=models.TextField(blank=True, null=True), 22 | ), 23 | migrations.AddField( 24 | model_name="contentsetting", 25 | name="user_defined_type", 26 | field=models.CharField( 27 | db_index=True, default=None, max_length=50, null=True 28 | ), 29 | ), 30 | migrations.AddField( 31 | model_name="historycontentsetting", 32 | name="help", 33 | field=models.TextField(blank=True, null=True), 34 | ), 35 | migrations.AddField( 36 | model_name="historycontentsetting", 37 | name="tags", 38 | field=models.TextField(blank=True, null=True), 39 | ), 40 | migrations.AddField( 41 | model_name="historycontentsetting", 42 | name="user_defined_type", 43 | field=models.CharField(default=None, max_length=50, null=True), 44 | ), 45 | migrations.AlterField( 46 | model_name="contentsetting", 47 | name="name", 48 | field=models.CharField( 49 | max_length=200, 50 | unique=True, 51 | validators=[ 52 | django.core.validators.RegexValidator( 53 | message="Value must be uppercase, can include numbers and underscores, and cannot start with a number.", 54 | regex="^[A-Z][A-Z0-9_]*$", 55 | ) 56 | ], 57 | ), 58 | ), 59 | ] 60 | -------------------------------------------------------------------------------- /content_settings/migrations/0003_user_preview.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-02-24 18:39 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("content_settings", "0002_user_defined"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterModelOptions( 17 | name="contentsetting", 18 | options={ 19 | "ordering": ("name",), 20 | "permissions": [("can_preview_on_site", "Can preview on site")], 21 | }, 22 | ), 23 | migrations.CreateModel( 24 | name="UserPreviewHistory", 25 | fields=[ 26 | ( 27 | "id", 28 | models.AutoField( 29 | primary_key=True, serialize=False, verbose_name="ID" 30 | ), 31 | ), 32 | ("created_on", models.DateTimeField(default=django.utils.timezone.now)), 33 | ("name", models.CharField(max_length=200)), 34 | ("value", models.TextField(null=True)), 35 | ( 36 | "status", 37 | models.IntegerField( 38 | choices=[ 39 | (0, "Created"), 40 | (10, "Applied"), 41 | (20, "Removed"), 42 | (30, "Ignored"), 43 | ], 44 | default=0, 45 | ), 46 | ), 47 | ( 48 | "user", 49 | models.ForeignKey( 50 | on_delete=django.db.models.deletion.CASCADE, 51 | to=settings.AUTH_USER_MODEL, 52 | ), 53 | ), 54 | ], 55 | ), 56 | migrations.CreateModel( 57 | name="UserPreview", 58 | fields=[ 59 | ( 60 | "id", 61 | models.AutoField( 62 | primary_key=True, serialize=False, verbose_name="ID" 63 | ), 64 | ), 65 | ("name", models.CharField(max_length=200)), 66 | ("from_value", models.TextField()), 67 | ("value", models.TextField()), 68 | ( 69 | "user", 70 | models.ForeignKey( 71 | on_delete=django.db.models.deletion.CASCADE, 72 | to=settings.AUTH_USER_MODEL, 73 | ), 74 | ), 75 | ], 76 | options={ 77 | "unique_together": {("user", "name")}, 78 | }, 79 | ), 80 | ] 81 | -------------------------------------------------------------------------------- /content_settings/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/content_settings/migrations/__init__.py -------------------------------------------------------------------------------- /content_settings/permissions.py: -------------------------------------------------------------------------------- 1 | """ 2 | A list of functions that are used as values for type attributes such as `fetch_permission`, `view_permission`, and `update_permission`. 3 | """ 4 | 5 | 6 | def any(user): 7 | """ 8 | Returns True for any user. 9 | """ 10 | return True 11 | 12 | 13 | def none(user): 14 | """ 15 | Returns False for any user. 16 | """ 17 | return False 18 | 19 | 20 | def authenticated(user): 21 | """ 22 | Returns True if the user is authenticated. 23 | """ 24 | return user.is_authenticated 25 | 26 | 27 | def staff(user): 28 | """ 29 | Returns True if the user is active and a staff member. 30 | """ 31 | return user.is_active and user.is_staff 32 | 33 | 34 | def superuser(user): 35 | """ 36 | Returns True if the user is active and a superuser. 37 | """ 38 | return user.is_active and user.is_superuser 39 | 40 | 41 | def has_perm(perm): 42 | """ 43 | Returns a function that checks if the user has a specific permission. 44 | """ 45 | 46 | def _(user): 47 | return user.is_active and user.has_perm(perm) 48 | 49 | return _ 50 | -------------------------------------------------------------------------------- /content_settings/receivers.py: -------------------------------------------------------------------------------- 1 | """ 2 | the module is used for connecting signals to the content settings. 3 | """ 4 | 5 | from django.db.models.signals import post_save, post_migrate, post_delete, pre_save 6 | from django.core.signals import request_started 7 | from django.db.backends.signals import connection_created 8 | from django.apps import apps 9 | from django.db import transaction 10 | 11 | from .settings import ( 12 | USER_DEFINED_TYPES, 13 | UPDATE_DB_VALUES_BY_MIGRATE, 14 | CHECK_UPDATE_CELERY, 15 | CHECK_UPDATE_HUEY, 16 | PRECACHED_PY_VALUES, 17 | VALIDATE_DEFAULT_VALUE, 18 | ) 19 | 20 | from django.dispatch import receiver 21 | 22 | from .caching import ( 23 | check_update, 24 | recalc_checksums, 25 | validate_default_values, 26 | populate, 27 | ) 28 | from .conf import set_initial_values_for_db, get_type_by_name, get_str_tags 29 | from .models import ContentSetting, HistoryContentSetting 30 | from .utils import call_base_str 31 | 32 | 33 | @receiver(post_delete, sender=ContentSetting) 34 | @receiver(post_save, sender=ContentSetting) 35 | def do_update_stored_checksum(*args, **kwargs): 36 | """ 37 | update the stored checksum of the settings. 38 | """ 39 | connection = transaction.get_connection() 40 | if connection.in_atomic_block: 41 | transaction.on_commit(recalc_checksums) 42 | else: 43 | recalc_checksums() 44 | 45 | 46 | @receiver(post_save, sender=ContentSetting) 47 | def trigger_on_change(sender, instance, created, **kwargs): 48 | """ 49 | Trigger on_change and on_change_commited for the content setting 50 | """ 51 | if created: 52 | return 53 | 54 | cs_type = get_type_by_name(instance.name) 55 | if cs_type is None: 56 | return 57 | 58 | for on_change in cs_type.get_on_change(): 59 | call_base_str(on_change, instance) 60 | 61 | on_changes = cs_type.get_on_change_commited() 62 | if not on_changes: 63 | return 64 | 65 | connection = transaction.get_connection() 66 | if connection.in_atomic_block: 67 | transaction.on_commit(lambda: [call_base_str(f, instance) for f in on_changes]) 68 | else: 69 | for on_change in on_changes: 70 | call_base_str(on_change, instance) 71 | 72 | 73 | @receiver(post_save, sender=ContentSetting) 74 | def create_history_settings(sender, instance, created, **kwargs): 75 | HistoryContentSetting.objects.create( 76 | name=instance.name, 77 | value=instance.value, 78 | version=instance.version, 79 | tags=instance.tags, 80 | help=instance.help, 81 | user_defined_type=instance.user_defined_type, 82 | was_changed=not created, 83 | ) 84 | 85 | 86 | @receiver(pre_save, sender=ContentSetting) 87 | def update_value_tags(sender, instance, **kwargs): 88 | if instance.user_defined_type: 89 | return 90 | 91 | cs_type = get_type_by_name(instance.name) 92 | if cs_type is None: 93 | return 94 | 95 | instance.tags = get_str_tags(instance.name, cs_type, instance.value) 96 | 97 | 98 | if USER_DEFINED_TYPES: 99 | 100 | @receiver(pre_save, sender=ContentSetting) 101 | def check_user_defined_type_version(sender, instance, **kwargs): 102 | from .conf import USER_DEFINED_TYPES_INITIAL 103 | 104 | if instance.user_defined_type not in USER_DEFINED_TYPES_INITIAL: 105 | return 106 | instance.version = USER_DEFINED_TYPES_INITIAL[ 107 | instance.user_defined_type 108 | ].version 109 | if instance.tags: 110 | instance.tags = instance.tags.replace("\r\n", "\n") 111 | 112 | 113 | @receiver(post_delete, sender=ContentSetting) 114 | def create_history_settings_delete(sender, instance, **kwargs): 115 | HistoryContentSetting.objects.create( 116 | name=instance.name, 117 | value=instance.value, 118 | version=instance.version, 119 | tags=instance.tags, 120 | help=instance.help, 121 | was_changed=None, 122 | ) 123 | 124 | 125 | if VALIDATE_DEFAULT_VALUE: 126 | 127 | @receiver(connection_created) 128 | def validate_default_values_for_connection(*args, **kwargs): 129 | validate_default_values() 130 | 131 | 132 | if PRECACHED_PY_VALUES: 133 | 134 | @receiver(connection_created) 135 | def reset_all_values_by_connection(*args, **kwargs): 136 | populate() 137 | 138 | 139 | @receiver(request_started) 140 | def check_update_for_request(*args, **kwargs): 141 | check_update() 142 | 143 | 144 | if UPDATE_DB_VALUES_BY_MIGRATE: 145 | 146 | @receiver(post_migrate, sender=apps.get_app_config("content_settings")) 147 | def update_stored_values(*args, **kwargs): 148 | try: 149 | with transaction.atomic(): 150 | log = set_initial_values_for_db(apply=True) 151 | except Exception as e: 152 | log = [("Error", str(e))] 153 | 154 | if not log: 155 | return 156 | 157 | for l in log: 158 | print(" ".join(l)) 159 | 160 | 161 | # INTEGRATIONS 162 | 163 | if CHECK_UPDATE_CELERY: 164 | try: 165 | from celery.signals import task_prerun 166 | except ImportError: 167 | pass 168 | else: 169 | 170 | @task_prerun.connect 171 | def check_update_for_celery(*args, **kwargs): 172 | check_update() 173 | 174 | 175 | if CHECK_UPDATE_HUEY: 176 | try: 177 | from huey.contrib.djhuey import pre_execute 178 | except ImportError: 179 | pass 180 | else: 181 | 182 | @pre_execute() 183 | def check_update_for_huey(*args, **kwargs): 184 | check_update() 185 | -------------------------------------------------------------------------------- /content_settings/settings.py: -------------------------------------------------------------------------------- 1 | # update docs/settings.md 2 | 3 | from django.conf import settings 4 | 5 | from . import __version__ 6 | 7 | 8 | def get_setting(name, default=None): 9 | return getattr(settings, "CONTENT_SETTINGS_" + name, default) 10 | 11 | 12 | PRECACHED_PY_VALUES = get_setting("PRECACHED_PY_VALUES", False) 13 | 14 | PREVIEW_ON_SITE_HREF = get_setting("PREVIEW_ON_SITE_HREF", "/") 15 | 16 | PREVIEW_ON_SITE_SHOW = get_setting("PREVIEW_ON_SITE_SHOW", False) 17 | 18 | UPDATE_DB_VALUES_BY_MIGRATE = get_setting("UPDATE_DB_VALUES_BY_MIGRATE", True) 19 | 20 | TAGS = get_setting( 21 | "TAGS", 22 | [], 23 | ) 24 | 25 | TAG_CHANGED = get_setting("TAG_CHANGED", "changed") 26 | 27 | CHECKSUM_KEY_PREFIX = ( 28 | get_setting("CHECKSUM_KEY_PREFIX", "CS_CHECKSUM_") + __version__ + "__" 29 | ) 30 | 31 | CHECKSUM_USER_KEY_PREFIX = ( 32 | get_setting("CHECKSUM_KEY_PREFIX", "CS_USER_CHECKSUM_") + __version__ + "__" 33 | ) 34 | 35 | USER_TAGS = get_setting( 36 | "USER_TAGS", 37 | { 38 | "favorites": ("⭐", "⚝"), 39 | "marked": ("💚", "♡"), 40 | }, 41 | ) 42 | 43 | CACHE_TRIGGER = get_setting( 44 | "CACHE_TRIGGER", "content_settings.cache_triggers.VersionChecksum" 45 | ) 46 | if isinstance(CACHE_TRIGGER, str): 47 | CACHE_TRIGGER = { 48 | "backend": CACHE_TRIGGER, 49 | } 50 | elif isinstance(CACHE_TRIGGER, dict) and "backend" not in CACHE_TRIGGER: 51 | CACHE_TRIGGER = { 52 | "backend": "content_settings.cache_triggers.VersionChecksum", 53 | **CACHE_TRIGGER, 54 | } 55 | 56 | VALUES_ONLY_FROM_DB = get_setting("VALUES_ONLY_FROM_DB", False) and not settings.DEBUG 57 | 58 | VALIDATE_DEFAULT_VALUE = get_setting("VALIDATE_DEFAULT_VALUE", settings.DEBUG) 59 | 60 | DEFAULTS = get_setting("DEFAULTS", []) 61 | 62 | ADMIN_CHECKSUM_CHECK_BEFORE_SAVE = get_setting( 63 | "ADMIN_CHECKSUM_CHECK_BEFORE_SAVE", False 64 | ) 65 | 66 | CHAIN_VALIDATE = get_setting("CHAIN_VALIDATE", True) 67 | 68 | UI_DOC_URL = get_setting( 69 | "UI_DOC_URL", "https://django-content-settings.readthedocs.io/en/0.29.1/ui/" 70 | ) 71 | 72 | CHECK_UPDATE_CELERY = get_setting("CHECK_UPDATE_CELERY", True) 73 | 74 | CHECK_UPDATE_HUEY = get_setting("CHECK_UPDATE_HUEY", True) 75 | 76 | USER_DEFINED_TYPES = get_setting("USER_DEFINED_TYPES", []) 77 | 78 | assert isinstance( 79 | USER_DEFINED_TYPES, list 80 | ), "CONTENT_SETTINGS_USER_DEFINED_TYPES must be a list" 81 | assert len(USER_DEFINED_TYPES) == len( 82 | set([val[0] for val in USER_DEFINED_TYPES]) 83 | ), "CONTENT_SETTINGS_USER_DEFINED_TYPES must have unique slugs" 84 | for i, val in enumerate(USER_DEFINED_TYPES): 85 | assert ( 86 | len(val) == 3 87 | ), f"CONTENT_SETTINGS_USER_DEFINED_TYPES[{i}] must be a tuple of 3 elements" 88 | assert all( 89 | [isinstance(v, str) for v in val] 90 | ), f"CONTENT_SETTINGS_USER_DEFINED_TYPES[{i}] must be a tuple of 3 strings" 91 | 92 | slug, imp_line, name = val 93 | assert ( 94 | len(slug) < 50 95 | ), f"CONTENT_SETTINGS_USER_DEFINED_TYPES[{i}][0] must be less than 50 chars" 96 | -------------------------------------------------------------------------------- /content_settings/store.py: -------------------------------------------------------------------------------- 1 | """ 2 | the module is used for collecting information. 3 | 4 | the `APP_NAME_STORE` a dict `setting_name: app_name` is used to store the name of the app that uses the setting. Which later on can be used in `tags.app_name` to generate a tag with the name of the app. 5 | """ 6 | 7 | from typing import Callable, Dict, List 8 | 9 | from .types import BaseSetting 10 | 11 | APP_NAME_STORE: Dict[str, str] = {} # setting_name: app_name 12 | 13 | ADMIN_HEAD_CSS: List[str] = [] 14 | ADMIN_HEAD_JS: List[str] = [] 15 | ADMIN_HEAD_CSS_RAW: List[str] = [] 16 | ADMIN_HEAD_JS_RAW: List[str] = [] 17 | 18 | PREFIXSES: Dict[str, Callable] = {} 19 | 20 | 21 | def add_app_name(cs_name: str, app_name: str) -> None: 22 | """ 23 | add the name of the app that uses the setting. 24 | """ 25 | APP_NAME_STORE[cs_name] = app_name 26 | 27 | 28 | def cs_has_app(cs_name: str) -> bool: 29 | """ 30 | check if the setting has an app name. 31 | """ 32 | return cs_name in APP_NAME_STORE 33 | 34 | 35 | def get_app_name(cs_name: str) -> str: 36 | """ 37 | get the name of the app that uses the setting. 38 | """ 39 | return APP_NAME_STORE[cs_name] 40 | 41 | 42 | def add_admin_head_css(css_url: str) -> None: 43 | """ 44 | add a css url to the admin head. 45 | """ 46 | if css_url not in ADMIN_HEAD_CSS: 47 | ADMIN_HEAD_CSS.append(css_url) 48 | 49 | 50 | def add_admin_head_js(js_url: str) -> None: 51 | """ 52 | add a js url to the admin head. 53 | """ 54 | if js_url not in ADMIN_HEAD_JS: 55 | ADMIN_HEAD_JS.append(js_url) 56 | 57 | 58 | def add_admin_head_css_raw(css: str) -> None: 59 | """ 60 | add a css code to the admin head. 61 | """ 62 | if css not in ADMIN_HEAD_CSS_RAW: 63 | ADMIN_HEAD_CSS_RAW.append(css) 64 | 65 | 66 | def add_admin_head_js_raw(js: str) -> None: 67 | """ 68 | add a js code to the admin head. 69 | """ 70 | if js not in ADMIN_HEAD_JS_RAW: 71 | ADMIN_HEAD_JS_RAW.append(js) 72 | 73 | 74 | def add_admin_head(setting: BaseSetting) -> None: 75 | """ 76 | add a setting to the admin head. 77 | """ 78 | for css in setting.get_admin_head_css(): 79 | add_admin_head_css(css) 80 | for js in setting.get_admin_head_js(): 81 | add_admin_head_js(js) 82 | for css in setting.get_admin_head_css_raw(): 83 | add_admin_head_css_raw(css) 84 | for js in setting.get_admin_head_js_raw(): 85 | add_admin_head_js_raw(js) 86 | 87 | 88 | def get_admin_head() -> str: 89 | """ 90 | get the admin head. 91 | """ 92 | 93 | def gen(): 94 | for link in ADMIN_HEAD_CSS: 95 | yield f"" 96 | for link in ADMIN_HEAD_JS: 97 | yield f"" 98 | for code in ADMIN_HEAD_CSS_RAW: 99 | yield f"" 100 | 101 | return "".join(gen()) 102 | 103 | 104 | def get_admin_raw_js() -> str: 105 | """ 106 | get the admin raw js. 107 | """ 108 | 109 | def gen(): 110 | for code in ADMIN_HEAD_JS_RAW: 111 | yield f"(function($){{{code}}})(django.jQuery);" 112 | 113 | return "".join(gen()) 114 | 115 | 116 | def register_prefix(name: str) -> Callable: 117 | """ 118 | decorator for registration a new prefix 119 | """ 120 | 121 | def _cover(func: Callable) -> Callable: 122 | assert name not in PREFIXSES 123 | PREFIXSES[name] = func 124 | return func 125 | 126 | return _cover 127 | -------------------------------------------------------------------------------- /content_settings/tags.py: -------------------------------------------------------------------------------- 1 | """ 2 | the functions that can be used for `CONTENT_SETTINGS_TAGS` and generate tags for the content settings based on setting name, type and value. 3 | """ 4 | 5 | from .settings import TAG_CHANGED 6 | from .store import cs_has_app, get_app_name 7 | from .types import BaseSetting 8 | from typing import Set 9 | 10 | 11 | def changed(name: str, cs_type: BaseSetting, value: str) -> Set[str]: 12 | """ 13 | returns a tag `changed` if the value of the setting is different from the default value. 14 | 15 | the name of the tag can be changed in `CONTENT_SETTINGS_TAG_CHANGED` django setting. 16 | """ 17 | return set() if value == cs_type.default else set([TAG_CHANGED]) 18 | 19 | 20 | def app_name(name: str, cs_type: BaseSetting, value: str) -> Set[str]: 21 | """ 22 | returns a tag with the name of the app that uses the setting. 23 | """ 24 | return set([get_app_name(name)]) if cs_has_app(name) else set() 25 | -------------------------------------------------------------------------------- /content_settings/templates/admin/content_settings/contentsetting/change_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/change_form.html' %} 2 | 3 | {% block footer %}{{ block.super }} 4 | 25 | 103 | {% endblock %} 104 | 105 | {% block extrahead %}{{block.super}} 106 | {{CONTENT_SETTINGS.admin_head|safe}} 107 | {% endblock %} -------------------------------------------------------------------------------- /content_settings/templates/admin/content_settings/contentsetting/context_tags.html: -------------------------------------------------------------------------------- 1 | {% for data in selected_tags %} 2 | {{data.tag|safe}} x 3 | {% endfor %} 4 | 5 | {% for data in available_tags %} 6 | {{data.tag|safe}}{{data.total}} 7 | {% endfor %} -------------------------------------------------------------------------------- /content_settings/templates/admin/content_settings/contentsetting/import_json.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load i18n admin_urls %} 3 | 4 | 5 | {% block extrastyle %} 6 | {{ block.super }} 7 | 24 | {% endblock %} 25 | 26 | {% block content %} 27 |
28 |
29 | 30 | {% csrf_token %} 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 | 39 | 40 |
41 |
42 | {% if errors %} 43 |

{% trans "Errors" %}

44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | {% for error in errors %} 53 | 54 | 55 | 56 | 57 | {% endfor %} 58 | 59 |
{% trans "Name" %}{% trans "Reason" %}
{{ error.name }}{{ error.reason }}
60 | {% endif %} 61 | {% if applied %} 62 |

{% trans "Applied Changes" %}

63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | {% for item in applied %} 74 | 75 | 76 | 77 | 78 | 79 | 80 | {% endfor %} 81 | 82 |
 {% trans "Name" %}{% trans "Old Value" %}{% trans "New Value" %}
{{ item.name }}
{{ item.old_value|escape }}
{{ item.new_value|escape }}
83 | {% endif %} 84 | {% if skipped %} 85 |

{% trans "Skipped" %}

86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | {% for item in skipped %} 95 | 96 | 97 | 98 | 99 | {% endfor %} 100 | 101 |
{% trans "Name" %}{% trans "Reason" %}
{{ item.name }}{{ item.reason }}
102 | {% endif %} 103 |
104 | 105 | {% if applied %} 106 | {% if preview_on_site_allowed %} 107 |     108 | 109 | {% endif %} 110 | 111 | {% endif %} 112 |
113 |
114 |
115 | {% endblock %} -------------------------------------------------------------------------------- /content_settings/templates/admin/content_settings/contentsetting/pagination.html: -------------------------------------------------------------------------------- 1 | {% load admin_list %} 2 | {% load i18n %} 3 |

4 | {% if pagination_required %} 5 | {% for i in page_range %} 6 | {% paginator_number cl i %} 7 | {% endfor %} 8 | {% endif %} 9 | {{ cl.result_count }} {% if cl.result_count == 1 %}{{ cl.opts.verbose_name }}{% else %}{{ cl.opts.verbose_name_plural }}{% endif %} 10 | {% if show_all_url %}{% translate 'Show all' %}{% endif %} 11 | {% if cl.formset and cl.result_count %} 12 | 13 | 14 | {% endif %} 15 |

16 | -------------------------------------------------------------------------------- /content_settings/templates/admin/content_settings/contentsetting/submit_line.html: -------------------------------------------------------------------------------- 1 | {% load i18n admin_urls %} 2 |
3 | {% block submit-row %} 4 | {% if PREVIEW_ON_SITE_SHOW %}{% endif %} 5 | {% if show_save %}{% endif %} 6 | {% if show_save_as_new %}{% endif %} 7 | {% if show_save_and_add_another %}{% endif %} 8 | {% if show_save_and_continue %}{% endif %} 9 | {% if show_delete_link and original %} 10 | {% url opts|admin_urlname:'delete' original.pk|admin_urlquote as delete_url %} 11 | {% translate "Delete" %} 12 | {% endif %} 13 | {% if show_close %}{% translate 'Close' %}{% endif %} 14 | 15 | {% endblock %} 16 |
17 | -------------------------------------------------------------------------------- /content_settings/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/content_settings/templatetags/__init__.py -------------------------------------------------------------------------------- /content_settings/templatetags/content_settings_admin.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | from content_settings.models import UserTagSetting 4 | 5 | register = template.Library() 6 | 7 | 8 | @register.simple_tag(takes_context=True) 9 | def content_settings_user_tags(context, *args, **kwargs): 10 | user = context["request"].user 11 | return list(UserTagSetting.objects.filter(user=user).values_list("name", "tag")) 12 | -------------------------------------------------------------------------------- /content_settings/templatetags/content_settings_extras.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.html import escape 3 | from django.utils.safestring import SafeString 4 | from django.utils.safestring import mark_safe 5 | 6 | from content_settings.conf import content_settings 7 | 8 | register = template.Library() 9 | 10 | 11 | @register.simple_tag 12 | def content_settings_call(name, *args, **kwargs): 13 | """ 14 | template tag that call callable settings in the template 15 | """ 16 | safe = kwargs.pop("_safe", False) 17 | var = getattr(content_settings, name) 18 | result = var(*args, **kwargs) 19 | return ( 20 | mark_safe(result) if safe or isinstance(result, SafeString) else escape(result) 21 | ) 22 | -------------------------------------------------------------------------------- /content_settings/tests/test_all_settings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | pytestmark = [ 5 | pytest.mark.django_db, 6 | ] 7 | 8 | from content_settings.conf import content_settings, ALL 9 | from content_settings.caching import populate, set_populated 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "name", [name for name in ALL.keys() if not ALL[name].constant] 14 | ) 15 | def test_admin_fields(name): 16 | set_populated(False) 17 | value = getattr(content_settings, name) 18 | setting = ALL[name] 19 | raw_value = setting.default 20 | assert setting.get_admin_preview_value(raw_value, name) is not None 21 | assert setting.get_help() is not None 22 | 23 | 24 | @pytest.mark.parametrize("name", ALL.keys()) 25 | def test_validate_default(name): 26 | set_populated(False) 27 | setting = ALL[name] 28 | raw_value = setting.default 29 | setting.validate_value(raw_value) 30 | -------------------------------------------------------------------------------- /content_settings/types/__init__.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | from typing import Callable, Union 3 | 4 | 5 | class PREVIEW(Enum): 6 | HTML = auto() 7 | TEXT = auto() 8 | PYTHON = auto() 9 | NONE = auto() 10 | 11 | 12 | class required: 13 | pass 14 | 15 | 16 | class optional: 17 | pass 18 | 19 | 20 | def pre(value: str) -> str: 21 | return "
{}
".format(str(value).replace("<", "<")) 22 | 23 | 24 | class BaseSetting: 25 | pass 26 | 27 | 28 | TCallableStr = Union[str, Callable] 29 | -------------------------------------------------------------------------------- /content_settings/types/array.py: -------------------------------------------------------------------------------- 1 | """ 2 | Types that convert a string into a list of values. 3 | """ 4 | 5 | from typing import Optional, List, Callable, Generator, Iterable 6 | 7 | from django.utils.translation import gettext as _ 8 | 9 | from .basic import ( 10 | SimpleText, 11 | SimpleString, 12 | PREVIEW, 13 | ) 14 | from .each import EachMixin, Item 15 | 16 | 17 | def f_empty(value: str) -> Optional[str]: 18 | value = value.strip() 19 | if not value: 20 | return None 21 | return value 22 | 23 | 24 | def f_comment(prefix: str) -> Callable[[str], Optional[str]]: 25 | def _(value: str) -> Optional[str]: 26 | if value.strip().startswith(prefix): 27 | return None 28 | return value 29 | 30 | return _ 31 | 32 | 33 | class SimpleStringsList(SimpleText): 34 | """ 35 | Split a text into a list of strings. 36 | 37 | * comment_starts_with (default: #): if not None, the lines that start with this string are removed 38 | * filter_empty (default: True): if True, empty lines are removed 39 | * split_lines (default: \n): the string that separates the lines 40 | * filters (default: None): a list of additional filters to apply to the lines. 41 | """ 42 | 43 | comment_starts_with: Optional[str] = "#" 44 | filter_empty: bool = True 45 | split_lines: str = "\n" 46 | filters: Optional[Iterable[Callable]] = None 47 | admin_preview_as: PREVIEW = PREVIEW.PYTHON 48 | 49 | def __init__(self, *args, **kwargs) -> None: 50 | from collections.abc import Iterable 51 | 52 | super().__init__(*args, **kwargs) 53 | assert isinstance(self.comment_starts_with, (str, type(None))) 54 | assert isinstance(self.filter_empty, bool) 55 | assert isinstance(self.split_lines, str) 56 | assert isinstance(self.filters, (Iterable, type(None))) 57 | 58 | def get_filters(self): 59 | """ 60 | Get the filters based on the current configuration. 61 | 62 | * If filters is not None, it is returned. 63 | * If filter_empty is True, f_empty is added to the filters. 64 | * If comment_starts_with is not None, f_comment is added to the filters. 65 | """ 66 | if self.filters is not None: 67 | filters = [*self.filters] 68 | else: 69 | filters = [] 70 | 71 | if self.filter_empty: 72 | filters.append(f_empty) 73 | 74 | if self.comment_starts_with: 75 | filters.append(f_comment(self.comment_starts_with)) 76 | 77 | return filters 78 | 79 | def get_help_format(self) -> Generator[str, None, None]: 80 | yield _("List of values with the following format:") 81 | yield "" 109 | 110 | def filter_line(self, line: str) -> str: 111 | for f in self.get_filters(): 112 | line = f(line) 113 | if line is None: 114 | break 115 | return line 116 | 117 | def gen_to_python(self, value: str) -> Generator[str, None, None]: 118 | """ 119 | Converts a string value into a generator of filtered lines. 120 | """ 121 | lines = value.split(self.split_lines) 122 | for line in lines: 123 | line = self.filter_line(line) 124 | 125 | if line is not None: 126 | yield line 127 | 128 | def to_python(self, value: str) -> List[str]: 129 | return list(self.gen_to_python(value)) 130 | 131 | 132 | class TypedStringsList(EachMixin, SimpleStringsList): 133 | line_type = SimpleString() 134 | 135 | def __init__(self, *args, **kwargs): 136 | super().__init__(*args, **kwargs) 137 | self.each = Item(self.line_type) 138 | -------------------------------------------------------------------------------- /content_settings/types/lazy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Classes that uses for lazy loading of objects. 3 | 4 | Check `BaseString.lazy_give` and `conf.lazy_prefix` 5 | """ 6 | 7 | import operator 8 | 9 | 10 | class LazyObject: 11 | def __init__(self, factory): 12 | # Assign using __dict__ to avoid the setattr method. 13 | self.__dict__["_factory"] = factory 14 | 15 | def new_method_proxy(func): 16 | """ 17 | Util function to help us route functions 18 | to the nested object. 19 | """ 20 | 21 | def inner(self, *args): 22 | return func(self._factory(), *args) 23 | 24 | return inner 25 | 26 | def __call__(self, *args, **kwargs): 27 | return self._factory()(*args, **kwargs) 28 | 29 | __getattr__ = new_method_proxy(getattr) 30 | __bytes__ = new_method_proxy(bytes) 31 | __str__ = new_method_proxy(str) 32 | __bool__ = new_method_proxy(bool) 33 | __dir__ = new_method_proxy(dir) 34 | __hash__ = new_method_proxy(hash) 35 | __class__ = property(new_method_proxy(operator.attrgetter("__class__"))) 36 | __eq__ = new_method_proxy(operator.eq) 37 | __lt__ = new_method_proxy(operator.lt) 38 | __le__ = new_method_proxy(operator.le) 39 | __gt__ = new_method_proxy(operator.gt) 40 | __ge__ = new_method_proxy(operator.ge) 41 | __ne__ = new_method_proxy(operator.ne) 42 | __mod__ = new_method_proxy(operator.mod) 43 | __hash__ = new_method_proxy(hash) 44 | __getitem__ = new_method_proxy(operator.getitem) 45 | __iter__ = new_method_proxy(iter) 46 | __len__ = new_method_proxy(len) 47 | __contains__ = new_method_proxy(operator.contains) 48 | __mul__ = new_method_proxy(operator.mul) 49 | __rmul__ = new_method_proxy(operator.mul) 50 | __not__ = new_method_proxy(operator.not_) 51 | __bool__ = new_method_proxy(bool) 52 | __len__ = new_method_proxy(len) 53 | __abs__ = new_method_proxy(operator.abs) 54 | __add__ = new_method_proxy(operator.add) 55 | __radd__ = new_method_proxy(operator.add) 56 | __and__ = new_method_proxy(operator.and_) 57 | __floordiv__ = new_method_proxy(operator.floordiv) 58 | __index__ = new_method_proxy(operator.index) 59 | __inv__ = new_method_proxy(operator.inv) 60 | __invert__ = new_method_proxy(operator.invert) 61 | __mod__ = new_method_proxy(operator.mod) 62 | __matmul__ = new_method_proxy(operator.matmul) 63 | __neg__ = new_method_proxy(operator.neg) 64 | __or__ = new_method_proxy(operator.or_) 65 | __pos__ = new_method_proxy(operator.pos) 66 | __pow__ = new_method_proxy(operator.pow) 67 | __sub__ = new_method_proxy(operator.sub) 68 | __truediv__ = new_method_proxy(operator.truediv) 69 | __xor__ = new_method_proxy(operator.xor) 70 | __concat__ = new_method_proxy(operator.concat) 71 | __contains__ = new_method_proxy(operator.contains) 72 | __getitem__ = new_method_proxy(operator.getitem) 73 | __iadd__ = new_method_proxy(operator.iadd) 74 | __iand__ = new_method_proxy(operator.iand) 75 | __iconcat__ = new_method_proxy(operator.iconcat) 76 | __ifloordiv__ = new_method_proxy(operator.ifloordiv) 77 | __ilshift__ = new_method_proxy(operator.ilshift) 78 | __imod__ = new_method_proxy(operator.imod) 79 | __imul__ = new_method_proxy(operator.imul) 80 | __imatmul__ = new_method_proxy(operator.imatmul) 81 | __ior__ = new_method_proxy(operator.ior) 82 | __ipow__ = new_method_proxy(operator.ipow) 83 | __isub__ = new_method_proxy(operator.isub) 84 | __itruediv__ = new_method_proxy(operator.itruediv) 85 | __ixor__ = new_method_proxy(operator.ixor) 86 | __next__ = new_method_proxy(next) 87 | __reversed__ = new_method_proxy(reversed) 88 | __round__ = new_method_proxy(round) 89 | -------------------------------------------------------------------------------- /content_settings/types/markup.py: -------------------------------------------------------------------------------- 1 | """ 2 | The module contains types of different formats such as JSON, YAML, CSV, and so on. 3 | """ 4 | 5 | from django.core.exceptions import ValidationError 6 | from django.utils.translation import gettext as _ 7 | 8 | from typing import List, Optional, Tuple, Dict, Union 9 | 10 | from content_settings.utils import remove_same_ident 11 | from .basic import SimpleText, PREVIEW, SimpleString 12 | from .each import EachMixin, Keys, Item 13 | from .mixins import EmptyNoneMixin 14 | from . import optional, BaseSetting, required 15 | 16 | 17 | # TODO: should have Empty None as well as JSON 18 | class SimpleYAML(SimpleText): 19 | """ 20 | YAML content settings type. Requires yaml module. 21 | """ 22 | 23 | admin_preview_as: PREVIEW = PREVIEW.PYTHON 24 | yaml_loader = None 25 | 26 | def get_help_format(self): 27 | return _( 28 | "Simple YAML format" 29 | ) 30 | 31 | def __init__(self, default=None, *args, **kwargs): 32 | if isinstance(default, str): 33 | default = remove_same_ident(default) 34 | 35 | super().__init__(default, *args, **kwargs) 36 | 37 | try: 38 | import yaml 39 | except ImportError: 40 | raise AssertionError("Please install pyyaml package") 41 | 42 | def get_yaml_loader(self): 43 | if self.yaml_loader is None: 44 | try: 45 | from yaml import CLoader as Loader 46 | except ImportError: 47 | from yaml import Loader 48 | return Loader 49 | return self.yaml_loader 50 | 51 | def to_python(self, value): 52 | value = super().to_python(value) 53 | if value is None: 54 | return None 55 | 56 | from yaml import load 57 | 58 | try: 59 | return load(value, Loader=self.get_yaml_loader()) 60 | except Exception as e: 61 | raise ValidationError(str(e)) 62 | 63 | 64 | class SimpleJSON(EmptyNoneMixin, SimpleText): 65 | """ 66 | JSON content settings type. 67 | """ 68 | 69 | admin_preview_as: PREVIEW = PREVIEW.PYTHON 70 | decoder_cls = None 71 | 72 | def get_help_format(self): 73 | return _( 74 | "Simple JSON format" 75 | ) 76 | 77 | def get_decoder_cls(self): 78 | return self.decoder_cls 79 | 80 | def to_python(self, value): 81 | value = super().to_python(value) 82 | if value is None: 83 | return None 84 | from json import loads 85 | 86 | try: 87 | return loads(value, cls=self.get_decoder_cls()) 88 | except Exception as e: 89 | raise ValidationError(str(e)) 90 | 91 | 92 | class SimpleRawCSV(SimpleText): 93 | """ 94 | Type that converts simple CSV to list of lists. 95 | """ 96 | 97 | admin_preview_as: PREVIEW = PREVIEW.PYTHON 98 | 99 | csv_dialect = "unix" 100 | 101 | def get_help_format(self): 102 | return _( 103 | "Simple CSV format" 104 | ) 105 | 106 | def get_csv_reader(self, value): 107 | value = super().to_python(value) 108 | if value is None: 109 | return None 110 | 111 | from csv import reader 112 | from io import StringIO 113 | 114 | try: 115 | return reader(StringIO(value), dialect=self.csv_dialect) 116 | except Exception as e: 117 | raise ValidationError(str(e)) 118 | 119 | def to_python(self, value): 120 | reader = self.get_csv_reader(value) 121 | return [list(row) for row in reader if row] 122 | 123 | 124 | class KeysFromList(Keys): 125 | def to_python(self, value): 126 | return super().to_python({k: v for k, v in zip(self.cs_types.keys(), value)}) 127 | 128 | def give_python_to_admin(self, value, name, **kwargs): 129 | return { 130 | k: cs.give_python_to_admin(v, name, **kwargs) 131 | for (k, cs), v in zip(self.cs_types.items(), value) 132 | } 133 | 134 | 135 | class KeysFromListByList(KeysFromList): 136 | def __init__(self, cs_type, keys): 137 | super().__init__(**{k: cs_type for k in keys}) 138 | 139 | 140 | class SimpleCSV(EachMixin, SimpleRawCSV): 141 | """ 142 | Type that converts simple CSV to list of dictionaries. 143 | 144 | Attributes: 145 | 146 | - `csv_fields` (dict, tuple or list): defines the structure of the CSV. The structure definition used by `EachMixin` 147 | - `csv_fields_list_type` (BaseSetting): the type of the list elements in the `csv_fields` if it is not dict. 148 | 149 | Examples: 150 | 151 | ```python 152 | SimpleCSV(csv_fields=["name", "price"]) 153 | SimpleCSV(csv_fields={"name": SimpleString(), "price": SimpleDecimal()}) 154 | SimpleCSV(csv_fields=["name", "price"], csv_fields_list_type=SimpleString()) 155 | ``` 156 | """ 157 | 158 | cls_field: str = "StripCharField" 159 | csv_fields: Optional[Union[List, Tuple, Dict]] = None 160 | csv_fields_list_type: BaseSetting = SimpleString(optional) 161 | 162 | def __init__(self, *args, **kwargs): 163 | super().__init__(*args, **kwargs) 164 | assert self.csv_fields is not None, "csv_fields cannot be None" 165 | assert isinstance( 166 | self.csv_fields, (list, tuple, dict) 167 | ), "csv_fields must be list, tuple or dict" 168 | 169 | self.each = Item( 170 | KeysFromList(**self.csv_fields) 171 | if isinstance(self.csv_fields, dict) 172 | else KeysFromListByList(self.csv_fields_list_type, self.csv_fields) 173 | ) 174 | -------------------------------------------------------------------------------- /content_settings/types/validators.py: -------------------------------------------------------------------------------- 1 | """ 2 | A list of functions that are used as values for validators attribute of a type. 3 | """ 4 | 5 | from django.core.exceptions import ValidationError 6 | from pprint import pformat 7 | 8 | 9 | def not_empty(value: str): 10 | if not value: 11 | raise ValidationError("Value cannot be empty") 12 | 13 | 14 | class PreviewValidator: 15 | """ 16 | return instance of the class from the validator to show the result of validation 17 | """ 18 | 19 | def __init__(self, preview_input: str, preview_output: str): 20 | self.preview_input = preview_input 21 | self.preview_output = preview_output 22 | 23 | 24 | class PreviewValidationError(ValidationError): 25 | """ 26 | use class instead of ValidatorError to show the input arguments that cause the ValidationError 27 | """ 28 | 29 | def __init__(self, preview_input: str, *args, **kwargs) -> None: 30 | self.preview_input = preview_input 31 | super().__init__(*args, **kwargs) 32 | 33 | 34 | class call_validator: 35 | """ 36 | create a valiator that calls the function with the given args and kwargs. 37 | """ 38 | 39 | has_call_representation = True 40 | 41 | def __init__(self, *args, **kwargs): 42 | self.args = args 43 | self.kwargs = kwargs 44 | 45 | def __call__(self, func): 46 | try: 47 | return func(*self.args, **self.kwargs) 48 | except Exception as e: 49 | raise ValidationError(str(e)) 50 | 51 | def __str__(self) -> str: 52 | ret = "" 53 | if self.args: 54 | ret += ", ".join([pformat(arg) for arg in self.args]) 55 | 56 | if self.kwargs: 57 | if ret: 58 | ret += ", " 59 | ret += ", ".join( 60 | [f"{key}={pformat(value)}" for key, value in self.kwargs.items()] 61 | ) 62 | 63 | return ret 64 | 65 | def __repr__(self) -> str: 66 | return f"<{self.__class__.__name__} {str(self)}>" 67 | 68 | 69 | class result_validator(call_validator): 70 | """ 71 | Not only call the function, but also validate the result of the call. 72 | 73 | takes two new arguments: 74 | 75 | * function that validates the result of the call 76 | * error message that will be shown if the function returns False 77 | """ 78 | 79 | def __init__(self, result_validator, error_message, *args, **kwargs): 80 | self.result_validator = result_validator 81 | self.error_message = error_message 82 | super().__init__(*args, **kwargs) 83 | 84 | def __call__(self, func): 85 | ret = super().__call__(func) 86 | if not self.result_validator(ret): 87 | raise ValidationError(self.error_message) 88 | return ret 89 | 90 | 91 | class gen_call_validator(call_validator): 92 | """ 93 | Same as call_validator, but the args and kwargs are generated by a given function. 94 | 95 | Create a valiator that calls the function that generates the args and kwargs, that will be used for the call. 96 | 97 | The function will be regenerated every time when the validator is called. 98 | 99 | The reason of having one is when you are not able to get possible args at the time of the creation of the validator. 100 | """ 101 | 102 | def __init__(self, gen_args_kwargs_func): 103 | self.gen_args_kwargs_func = gen_args_kwargs_func 104 | self._args_kwargs = None 105 | 106 | def gen_args_kwargs(self): 107 | return self.gen_args_kwargs_func() 108 | 109 | @property 110 | def args_kwargs(self): 111 | if self._args_kwargs is None: 112 | self._args_kwargs = self.gen_args_kwargs() 113 | 114 | assert ( 115 | len(self._args_kwargs) == 2 116 | ), "gen_args_kwargs_func must return a tuple of args and kwargs" 117 | assert isinstance( 118 | self._args_kwargs[0], (list, tuple) 119 | ), "args must be a list or tuple" 120 | assert isinstance(self._args_kwargs[1], dict), "kwargs must be a dict" 121 | return self._args_kwargs 122 | 123 | @property 124 | def args(self): 125 | return self.args_kwargs[0] 126 | 127 | @property 128 | def kwargs(self): 129 | return self.args_kwargs[1] 130 | 131 | def __call__(self, func): 132 | try: 133 | return super().__call__(func) 134 | finally: 135 | self._args_kwargs = None 136 | 137 | 138 | class gen_args_call_validator(gen_call_validator): 139 | """ 140 | Same as gen_call_validator, but only generates the list for args. 141 | """ 142 | 143 | def gen_args_kwargs(self): 144 | return self.gen_args_kwargs_func(), {} 145 | 146 | 147 | class gen_single_arg_call_validator(gen_call_validator): 148 | """ 149 | Same as gen_call_validator, but only generates one arg. 150 | """ 151 | 152 | def gen_args_kwargs(self): 153 | return [self.gen_args_kwargs_func()], {} 154 | 155 | 156 | class gen_kwargs_call_validator(gen_call_validator): 157 | """ 158 | Same as gen_call_validator, but only generates the dict for kwargs. 159 | """ 160 | 161 | def gen_args_kwargs(self): 162 | return [], self.gen_args_kwargs_func() 163 | -------------------------------------------------------------------------------- /content_settings/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | A set of available utilites 3 | """ 4 | 5 | from typing import Iterator, Tuple, Type, Any, Callable, Union 6 | 7 | import inspect 8 | from importlib import import_module 9 | 10 | from content_settings.types import BaseSetting, TCallableStr 11 | 12 | 13 | def remove_same_ident(value: str) -> str: 14 | """ 15 | remove same ident from all lines of the string 16 | Ignore a single line string 17 | Ignore lines with only spaces 18 | """ 19 | lines = value.splitlines() 20 | if len(lines) <= 1: 21 | return value 22 | 23 | # Find the minimum indentation level 24 | min_indent = None 25 | for line in lines: 26 | stripped_line = line.lstrip() 27 | if stripped_line: 28 | indent = len(line) - len(stripped_line) 29 | if min_indent is None or indent < min_indent: 30 | min_indent = indent 31 | 32 | if min_indent is None: 33 | return value 34 | 35 | # Remove the minimum indentation level from each line 36 | newline = "\r\n" if "\r\n" in value else "\n" 37 | return newline.join(line[min_indent:] for line in lines) 38 | 39 | 40 | def classes(setting_cls: Type[BaseSetting]) -> Iterator[Type[BaseSetting]]: 41 | """ 42 | Returns an iterator of classes that are subclasses of the given class. 43 | """ 44 | for cls in inspect.getmro(setting_cls): 45 | 46 | if not cls.__name__ or not cls.__module__: 47 | continue 48 | if cls.__module__ == "builtins": 49 | continue 50 | if (cls.__module__, cls.__name__) in ( 51 | ("content_settings.types", "BaseSetting"), 52 | ("content_settings.types.basic", "SimpleString"), 53 | ) and cls != setting_cls: 54 | continue 55 | yield cls 56 | 57 | 58 | def class_names(setting_cls: Type[BaseSetting]) -> Iterator[Tuple[str, str]]: 59 | """ 60 | Returns an iterator of tuple with module and class name that are subclasses of the given class. 61 | """ 62 | for cls in classes(setting_cls): 63 | yield (cls.__module__, cls.__name__) 64 | 65 | 66 | def import_object(path: str) -> Any: 67 | """ 68 | getting an object from the module by the path. `full.path.to.Object` -> `Object` 69 | """ 70 | try: 71 | return import_module(path) 72 | except ImportError: 73 | parts = path.split(".") 74 | module = import_module(".".join(parts[:-1])) 75 | return getattr(module, parts[-1]) 76 | 77 | 78 | def function_has_argument(func: Callable, arg: str) -> bool: 79 | """ 80 | Check if the function has the given argument in its definition. 81 | """ 82 | return arg in inspect.signature(func).parameters 83 | 84 | 85 | def is_bline(func: TCallableStr) -> bool: 86 | """ 87 | Check if it is a string defined as b"string", or is other words bites string. 88 | 89 | The function is part of the future idea https://github.com/occipital/django-content-settings/issues/110 90 | """ 91 | return isinstance(func, bytes) 92 | 93 | 94 | def obj_base_str(obj: Any, call_base: Any = None) -> Callable: 95 | """ 96 | if a given obj is not String - return the obj. If it is string than try to find it using call_base 97 | """ 98 | if is_bline(obj): 99 | obj = obj.decode("utf-8") 100 | 101 | if not isinstance(obj, str): 102 | return obj 103 | 104 | if "." in obj: 105 | return import_object(obj) 106 | 107 | assert ( 108 | call_base 109 | ), "the first argument can not be empty if the first argument a name of the function" 110 | if isinstance(call_base, str): 111 | call_base = import_object(call_base) 112 | 113 | return getattr(call_base, obj) 114 | 115 | 116 | def call_base_str(func: TCallableStr, *args, call_base: Any = None, **kwargs) -> Any: 117 | """ 118 | The goal of the function is to extend interface of callable attributes, so instead of passing a function you can pass a name of the function or full import path to the function. 119 | 120 | It is not only minimise the amout of import lines but also allows to use string attributes in `CONTENT_SETTINGS_DEFAULTS`. 121 | """ 122 | 123 | func = obj_base_str(func, call_base) 124 | 125 | if function_has_argument(func, "call_base"): 126 | kwargs["call_base"] = call_base 127 | 128 | return func(*args, **kwargs) 129 | -------------------------------------------------------------------------------- /content_settings/views.py: -------------------------------------------------------------------------------- 1 | """ 2 | Those are the views can be used in the Integration with the Project. 3 | """ 4 | 5 | from django.http import HttpResponseNotFound, HttpResponseForbidden, HttpResponse 6 | from django.views.generic import View 7 | from django.utils.translation import gettext as _ 8 | from content_settings.conf import content_settings 9 | 10 | from .conf import ALL, split_attr, content_settings 11 | from .caching import get_type_by_name 12 | 13 | 14 | def gen_startswith(startswith: str): 15 | """ 16 | for names attribute of FetchSettingsView, to find settings by name starts with `startswith` 17 | """ 18 | 19 | def _(request): 20 | for name in dir(content_settings): 21 | if name.startswith(startswith): 22 | yield name 23 | 24 | return _ 25 | 26 | 27 | def gen_hastag(tag: str): 28 | """ 29 | for names attribute of FetchSettingsView, to find settings by tag 30 | """ 31 | 32 | def _(request): 33 | for name in dir(content_settings): 34 | if tag in get_type_by_name(name).get_tags(): 35 | yield name 36 | 37 | return _ 38 | 39 | 40 | def gen_all(): 41 | """ 42 | for names attribute of FetchSettingsView, to find all settings 43 | """ 44 | 45 | def _(request): 46 | for name in dir(content_settings): 47 | yield name 48 | 49 | return _ 50 | 51 | 52 | class FetchSettingsView(View): 53 | """ 54 | A View for featching settings from the content settings. 55 | 56 | Use attribute `names` to define the names of the settings to fetch. 57 | 58 | ``` 59 | FetchSettingsView.as_view( 60 | names=[ 61 | "DESCRIPTION", 62 | "OPEN_DATE", 63 | "TITLE", 64 | ] 65 | ) 66 | ``` 67 | 68 | Suffix can be used in names 69 | 70 | ``` 71 | FetchSettingsView.as_view( 72 | names=[ 73 | "TITLE", 74 | "BOOKS__available_names", 75 | ] 76 | ), 77 | ``` 78 | 79 | function for getting names by specific conditions can be used 80 | 81 | ``` 82 | FetchSettingsView.as_view(names=gen_hastag("general")), 83 | ``` 84 | 85 | or combinations of them 86 | 87 | ``` 88 | FetchSettingsView.as_view(names=(gen_startswith("IS_"), "TITLE")), 89 | ``` 90 | 91 | """ 92 | 93 | names = () 94 | show_error_headers = True 95 | 96 | def get_names(self, request): 97 | if callable(self.names): 98 | yield from self.names(request) 99 | else: 100 | for value in self.names: 101 | if callable(value): 102 | yield from value(request) 103 | else: 104 | yield value 105 | 106 | def get(self, request): 107 | ret = [] 108 | errors = [] 109 | for val in self.get_names(request): 110 | if isinstance(val, tuple): 111 | key, val = val 112 | else: 113 | key = val 114 | 115 | prefix, name, suffix = split_attr(val) 116 | assert prefix is None, "prefix is not None" 117 | 118 | cs_type = get_type_by_name(name) 119 | if cs_type is None: 120 | errors.append(f"{key}: not found") 121 | continue 122 | 123 | if not cs_type.can_fetch(request.user): 124 | errors.append(f"{key}: permission denied") 125 | continue 126 | 127 | value = getattr(content_settings, val) 128 | ret.append( 129 | ( 130 | key, 131 | cs_type.json_view_value( 132 | value, suffix=suffix, request=request, name=name 133 | ), 134 | ) 135 | ) 136 | 137 | return HttpResponse( 138 | "{" + (",".join(f'"{name}":{value}' for name, value in ret)) + "}", 139 | content_type="application/json", 140 | headers=( 141 | {"X-Content-Settings-Errors": ";".join(errors)} 142 | if errors and self.show_error_headers 143 | else {} 144 | ), 145 | ) 146 | 147 | 148 | class FetchAllSettingsView(FetchSettingsView): 149 | names = staticmethod(gen_all()) 150 | -------------------------------------------------------------------------------- /content_settings/widgets.py: -------------------------------------------------------------------------------- 1 | """ 2 | Widgets that can be used in `widget` attribute of the content settings. 3 | 4 | It includes all of the widgets from django.forms.widgets + several custom widgets. 5 | """ 6 | 7 | from django.forms.widgets import * 8 | 9 | 10 | class LongInputMix: 11 | """ 12 | Mixin that makes input maximum long 13 | """ 14 | 15 | def __init__(self, attrs=None): 16 | attrs = attrs or {} 17 | style = attrs.pop("style", "") 18 | style = "width: calc(100% - 14px);" + style 19 | attrs["style"] = style 20 | super().__init__(attrs=attrs) 21 | 22 | 23 | class LongTextInput(LongInputMix, TextInput): 24 | """ 25 | Long text input 26 | """ 27 | 28 | pass 29 | 30 | 31 | class LongTextarea(LongInputMix, Textarea): 32 | """ 33 | Long textarea 34 | """ 35 | 36 | pass 37 | 38 | 39 | class LongURLInput(LongInputMix, URLInput): 40 | """ 41 | Long URL Input 42 | """ 43 | 44 | 45 | class LongEmailInput(LongInputMix, EmailInput): 46 | """ 47 | Long Email Input 48 | """ 49 | 50 | pass 51 | -------------------------------------------------------------------------------- /cs_test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12 2 | 3 | WORKDIR /app 4 | 5 | COPY content_settings /app/content_settings 6 | COPY cs_test /app/cs_test 7 | COPY tests /app/tests 8 | 9 | COPY Makefile /app/Makefile 10 | COPY pyproject.toml /app/pyproject.toml 11 | COPY poetry.lock /app/poetry.lock 12 | 13 | 14 | RUN pip install --no-cache-dir poetry 15 | RUN touch README_SHORT.md 16 | RUN make init 17 | RUN poetry run pip install --no-cache-dir mysqlclient 18 | 19 | EXPOSE 8000 20 | 21 | -------------------------------------------------------------------------------- /cs_test/cs_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/cs_test/cs_test/__init__.py -------------------------------------------------------------------------------- /cs_test/cs_test/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for cs_test project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cs_test.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /cs_test/cs_test/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for cs_test project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.9. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | from content_settings.defaults.collections import codemirror_all 15 | 16 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 17 | BASE_DIR = Path(__file__).resolve().parent.parent 18 | 19 | import sys 20 | 21 | sys.path.append(BASE_DIR.parent) 22 | 23 | 24 | # Quick-start development settings - unsuitable for production 25 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 26 | 27 | # SECURITY WARNING: keep the secret key used in production secret! 28 | SECRET_KEY = "django-insecure-f(j28*jpoc_+k4qesjpxorylt!gy3^u&hxsyrna0vy$r!_@$pf" 29 | 30 | # SECURITY WARNING: don't run with debug turned on in production! 31 | DEBUG = True 32 | 33 | ALLOWED_HOSTS = [] 34 | 35 | 36 | # Application definition 37 | 38 | INSTALLED_APPS = [ 39 | "django.contrib.admin", 40 | "django.contrib.auth", 41 | "django.contrib.contenttypes", 42 | "django.contrib.sessions", 43 | "django.contrib.messages", 44 | "django.contrib.staticfiles", 45 | "content_settings", 46 | "songs", 47 | ] 48 | 49 | MIDDLEWARE = [ 50 | "django.middleware.security.SecurityMiddleware", 51 | "django.contrib.sessions.middleware.SessionMiddleware", 52 | "django.middleware.common.CommonMiddleware", 53 | "django.middleware.csrf.CsrfViewMiddleware", 54 | "django.contrib.auth.middleware.AuthenticationMiddleware", 55 | "content_settings.middlewares.preview_on_site", 56 | "django.contrib.messages.middleware.MessageMiddleware", 57 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 58 | ] 59 | 60 | ROOT_URLCONF = "cs_test.urls" 61 | 62 | TEMPLATES = [ 63 | { 64 | "BACKEND": "django.template.backends.django.DjangoTemplates", 65 | "DIRS": [], 66 | "APP_DIRS": True, 67 | "OPTIONS": { 68 | "context_processors": [ 69 | "django.template.context_processors.debug", 70 | "django.template.context_processors.request", 71 | "django.contrib.auth.context_processors.auth", 72 | "django.contrib.messages.context_processors.messages", 73 | "content_settings.context_processors.content_settings", 74 | ], 75 | }, 76 | }, 77 | ] 78 | 79 | WSGI_APPLICATION = "cs_test.wsgi.application" 80 | 81 | 82 | # Database 83 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 84 | 85 | DATABASES = { 86 | "default": { 87 | "ENGINE": "django.db.backends.sqlite3", 88 | "NAME": BASE_DIR / "db.sqlite3", 89 | } 90 | } 91 | 92 | 93 | # Password validation 94 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 95 | 96 | AUTH_PASSWORD_VALIDATORS = [ 97 | { 98 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 99 | }, 100 | { 101 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 102 | }, 103 | { 104 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 105 | }, 106 | { 107 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 108 | }, 109 | ] 110 | 111 | 112 | # Internationalization 113 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 114 | 115 | LANGUAGE_CODE = "en-us" 116 | 117 | TIME_ZONE = "UTC" 118 | 119 | USE_I18N = True 120 | 121 | USE_TZ = True 122 | 123 | 124 | # Static files (CSS, JavaScript, Images) 125 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 126 | 127 | STATIC_URL = "static/" 128 | 129 | # Default primary key field type 130 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 131 | 132 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 133 | 134 | CONTENT_SETTINGS_USER_DEFINED_TYPES = [ 135 | ("text", "content_settings.types.basic.SimpleText", "Private Text"), 136 | ("bool", "content_settings.types.basic.SimpleBool", "Private Bool"), 137 | ] 138 | 139 | CONTENT_SETTINGS_TAGS = [ 140 | "content_settings.tags.changed", 141 | "content_settings.tags.app_name", 142 | ] 143 | 144 | CONTENT_SETTINGS_DEFAULTS = [ 145 | *codemirror_all(), 146 | ] 147 | -------------------------------------------------------------------------------- /cs_test/cs_test/settings_maria.py: -------------------------------------------------------------------------------- 1 | from .settings import * 2 | 3 | DATABASES = { 4 | "default": { 5 | "ENGINE": "django.db.backends.mysql", 6 | "NAME": "django_db", 7 | "USER": "django_user", 8 | "PASSWORD": "djangopassword", 9 | "HOST": "db", 10 | "PORT": "3306", 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /cs_test/cs_test/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for cs_test project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.contrib import admin 19 | from django.urls import path, include 20 | 21 | urlpatterns = [ 22 | path("admin/", admin.site.urls), 23 | path("songs/", include("songs.urls")), 24 | ] 25 | -------------------------------------------------------------------------------- /cs_test/cs_test/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for cs_test project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cs_test.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /cs_test/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | db: 5 | image: mariadb:10.5 6 | volumes: 7 | - mariadb_data:/var/lib/mysql 8 | environment: 9 | MYSQL_ROOT_PASSWORD: rootpassword 10 | MYSQL_DATABASE: django_db 11 | MYSQL_USER: django_user 12 | MYSQL_PASSWORD: djangopassword 13 | ports: 14 | - "3306:3306" 15 | 16 | web: 17 | build: 18 | context: .. 19 | dockerfile: cs_test/Dockerfile 20 | command: make cs-test 21 | volumes: 22 | - .:/app 23 | ports: 24 | - "8000:8000" 25 | depends_on: 26 | - db 27 | environment: 28 | - DJANGO_SETTINGS_MODULE=cs_test.settings_maria 29 | 30 | volumes: 31 | mariadb_data: 32 | -------------------------------------------------------------------------------- /cs_test/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cs_test.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /cs_test/songs/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Artist, Song 3 | 4 | 5 | class SongInline(admin.TabularInline): 6 | model = Song 7 | extra = 1 8 | 9 | 10 | class ArtistAdmin(admin.ModelAdmin): 11 | list_display = ("name",) 12 | inlines = [SongInline] 13 | 14 | 15 | class SongAdmin(admin.ModelAdmin): 16 | list_display = ("title", "artist") 17 | list_filter = ("artist",) 18 | search_fields = ("title", "artist__name") 19 | 20 | 21 | admin.site.register(Artist, ArtistAdmin) 22 | admin.site.register(Song, SongAdmin) 23 | -------------------------------------------------------------------------------- /cs_test/songs/content_settings.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from django.contrib.auth.models import User 4 | 5 | from content_settings.types.basic import ( 6 | SimpleString, 7 | SimpleInt, 8 | SimpleHTML, 9 | SimpleDecimal, 10 | SimpleTextPreview, 11 | ) 12 | from content_settings.types.datetime import DateString 13 | from content_settings.types.mixins import ( 14 | MinMaxValidationMixin, 15 | mix, 16 | AdminPreviewActionsMixin, 17 | CallToPythonMixin, 18 | AdminPreviewSuffixesMixin, 19 | MakeCallMixin, 20 | ) 21 | from content_settings.types.array import ( 22 | SimpleStringsList, 23 | TypedStringsList, 24 | ) 25 | from content_settings.types.markup import SimpleYAML, SimpleJSON 26 | from content_settings.types.each import EachMixin, Keys 27 | from content_settings.types.template import ( 28 | DjangoTemplate, 29 | DjangoTemplateNoArgsHTML, 30 | DjangoModelTemplateHTML, 31 | SimpleEval, 32 | SimpleFunc, 33 | DjangoTemplateHTML, 34 | DjangoTemplateNoArgs, 35 | DjangoTemplateNoArgsHTML, 36 | SimpleExec, 37 | SimpleExecNoArgs, 38 | SimpleExecNoCompile, 39 | DjangoModelEval, 40 | ) 41 | from content_settings.types.validators import call_validator 42 | 43 | from content_settings import permissions 44 | from content_settings.defaults.context import defaults, default_tags 45 | from content_settings.defaults.modifiers import add_tags, help_suffix 46 | 47 | from .models import Artist 48 | 49 | MY_EVAL = SimpleEval("2**4", help="My eval") 50 | MY_JSON = SimpleJSON("""{"a": 1, "b": 2, "c": 3}""", help="My json") 51 | 52 | with defaults(add_tags(["main"]), fetch_permission=permissions.any): 53 | TITLE = SimpleString("My Site", help="Title of the site") 54 | 55 | AFTER_TITLE = DjangoTemplateNoArgsHTML( 56 | "", help="The html goes right after the title", tags=["html"] 57 | ) 58 | 59 | DAYS_WITHOUT_FAIL = mix(MinMaxValidationMixin, SimpleInt)( 60 | "5", min_value=0, max_value=10, help="How many days without fail" 61 | ) 62 | 63 | with defaults(help_suffix("Try not to update too often")): 64 | FAVORITE_SUBJECTS = SimpleStringsList("", help="my favorive songs subjects") 65 | 66 | PRICES = mix(AdminPreviewSuffixesMixin, TypedStringsList)( 67 | "", 68 | line_type=SimpleDecimal(), 69 | suffixes={"positive": lambda value: [v for v in value if v >= 0]}, 70 | ) 71 | 72 | START_DATE = DateString("2024-02-11", constant=True) 73 | 74 | MY_YAML = mix(EachMixin, SimpleYAML)("", each=Keys(price=SimpleDecimal())) 75 | 76 | ARTIST_LINE = DjangoModelTemplateHTML( 77 | "", 78 | template_model_queryset=Artist.objects.all(), 79 | template_object_name="artist", 80 | ) 81 | 82 | HTML_WITH_ACTIONS = mix(AdminPreviewActionsMixin, SimpleHTML)( 83 | "", 84 | admin_preview_actions=[ 85 | ("before", lambda resp, *a, **k: resp.before_html("

Text Before

")), 86 | ("alert", lambda resp, *a, **k: resp.alert("Let you know, you are good")), 87 | ("reset to hi", lambda resp, *a, **k: resp.value("Hello world")), 88 | ("say hi", lambda resp, *a, **k: resp.html("

HI

")), 89 | ], 90 | help="Some html with actions", 91 | ) 92 | 93 | WELCOME_FUNC = SimpleFunc( 94 | "Welcome {name}", 95 | call_func=lambda name, prepared: prepared.format(name=name), 96 | validators=(call_validator("Aex"),), 97 | version="2", 98 | ) 99 | 100 | TOTAL_INT_FUNC = mix(CallToPythonMixin, SimpleInt)( 101 | "10", 102 | call_func=lambda value, prepared: prepared + value, 103 | validators=(call_validator(20),), 104 | ) 105 | 106 | FEE_COOF = SimpleDecimal("10", constant=True) 107 | 108 | with default_tags({"templates"}): 109 | DJANGO_TEMPLATE = DjangoTemplate("Hi") 110 | 111 | SIMPLE_FUNCTON = SimpleFunc( 112 | "HI", 113 | call_func=lambda prepared: prepared * 5, 114 | validators=(call_validator(),), 115 | ) 116 | 117 | DJANGO_TEMPLATE_HTML = DjangoTemplateHTML("HI") 118 | 119 | DJANGO_TEMPLATE_NO_ARGS = DjangoTemplateNoArgs("HI") 120 | 121 | DJANGO_TEMPLATE_NO_ARGS_HTML = DjangoTemplateNoArgsHTML("HI") 122 | 123 | def str_type_validator(value): 124 | ret = value() 125 | if not isinstance(ret, str): 126 | raise ValueError("Value must be a string") 127 | 128 | SIMPLE_EVAL = SimpleEval("'10 * 5'", validators=(str_type_validator,), version="2") 129 | 130 | SIMPLE_EXEC = SimpleExec( 131 | """ 132 | fee = value * Decimal("0.1") 133 | result = value - fee 134 | """, 135 | template_args_default={"value": Decimal("0.0")}, 136 | template_static_data={"Decimal": Decimal}, 137 | template_return=("result", "fee"), 138 | ) 139 | 140 | SIMPLE_EXEC_NO_ARGS = SimpleExecNoArgs( 141 | """ 142 | fee = Decimal("0.1") 143 | result = Decimal("0.2") 144 | """, 145 | template_static_data={"Decimal": Decimal}, 146 | template_return=("result", "fee"), 147 | ) 148 | 149 | SIMPLE_EXEC_NO_COMPILE = SimpleExecNoCompile( 150 | """ 151 | fee = Decimal("0.1") 152 | result = Decimal("0.2") 153 | """, 154 | template_static_data={"Decimal": Decimal}, 155 | template_return="result", 156 | version="2", 157 | ) 158 | 159 | TEXT_MAKE_CALL = mix(MakeCallMixin, SimpleDecimal)("100", version="2") 160 | 161 | USER_MODEL_EVAL = DjangoModelEval( 162 | "100", template_object_name="user", template_model_queryset=User.objects.all() 163 | ) 164 | -------------------------------------------------------------------------------- /cs_test/songs/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-01-25 10:03 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Artist", 15 | fields=[ 16 | ( 17 | "id", 18 | models.BigAutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=100)), 26 | ], 27 | ), 28 | migrations.CreateModel( 29 | name="Song", 30 | fields=[ 31 | ( 32 | "id", 33 | models.BigAutoField( 34 | auto_created=True, 35 | primary_key=True, 36 | serialize=False, 37 | verbose_name="ID", 38 | ), 39 | ), 40 | ("title", models.CharField(max_length=100)), 41 | ( 42 | "artist", 43 | models.ForeignKey( 44 | on_delete=django.db.models.deletion.CASCADE, to="songs.artist" 45 | ), 46 | ), 47 | ], 48 | ), 49 | ] 50 | -------------------------------------------------------------------------------- /cs_test/songs/migrations/0002_userdefined_preview.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.13 on 2024-07-28 19:33 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("songs", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="song", 15 | name="artist", 16 | field=models.ForeignKey( 17 | on_delete=django.db.models.deletion.CASCADE, 18 | related_name="songs", 19 | to="songs.artist", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /cs_test/songs/migrations/0003_admin.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.15 on 2024-08-12 11:03 2 | 3 | from django.db import migrations 4 | 5 | def create_superuser(apps, schema_editor): 6 | User = apps.get_model('auth', 'User') 7 | User.objects.create_superuser( 8 | username='admin', 9 | email='admin@example.com', 10 | password='1' 11 | ) 12 | 13 | class Migration(migrations.Migration): 14 | 15 | dependencies = [ 16 | ('songs', '0002_userdefined_preview'), 17 | ] 18 | 19 | operations = [ 20 | migrations.RunPython(create_superuser), 21 | ] 22 | -------------------------------------------------------------------------------- /cs_test/songs/migrations/0004_update_settings.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-10-16 18:22 2 | 3 | from django.db import migrations 4 | from content_settings.migrate import RunImport 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("songs", "0003_admin"), 10 | ] 11 | 12 | operations = [ 13 | RunImport( 14 | { 15 | "settings": { 16 | "AFTER_TITLE": {"value": "Best Settings Framework", "version": ""}, 17 | "ARTIST_LINE": {"value": "The Line", "version": ""}, 18 | "DAYS_WITHOUT_FAIL": {"value": "8", "version": ""}, 19 | "DJANGO_TEMPLATE": {"value": "Hi Man", "version": ""}, 20 | } 21 | } 22 | ) 23 | ] 24 | -------------------------------------------------------------------------------- /cs_test/songs/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/cs_test/songs/migrations/__init__.py -------------------------------------------------------------------------------- /cs_test/songs/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Artist(models.Model): 5 | name = models.CharField(max_length=100) 6 | 7 | def __str__(self): 8 | return self.name 9 | 10 | 11 | class Song(models.Model): 12 | title = models.CharField(max_length=100) 13 | artist = models.ForeignKey(Artist, on_delete=models.CASCADE, related_name="songs") 14 | 15 | def __str__(self): 16 | return self.title 17 | -------------------------------------------------------------------------------- /cs_test/songs/templates/songs/index.html: -------------------------------------------------------------------------------- 1 | {% load content_settings_extras %} 2 | 3 |

{{CONTENT_SETTINGS.TITLE}}

4 | 5 | {{CONTENT_SETTINGS.AFTER_TITLE}} 6 | 7 |

Started at: {{CONTENT_SETTINGS.START_DATE}}

8 | 9 |

Days without fail: {{CONTENT_SETTINGS.DAYS_WITHOUT_FAIL}}

10 | 11 | Favorites: 12 | 13 | 18 | 19 | Prices: 20 | 21 | 26 | 27 | {{CONTENT_SETTINGS.MY_YAML}} 28 | 29 |

Artists:

30 | 31 | 36 | -------------------------------------------------------------------------------- /cs_test/songs/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.generic import TemplateView 3 | from django.http import HttpResponse 4 | 5 | from content_settings.conf import content_settings 6 | from content_settings.views import FetchSettingsView, gen_hastag 7 | from content_settings.context_managers import content_settings_context 8 | 9 | from .models import Artist 10 | 11 | 12 | def math(request, multi=content_settings.lazy__DAYS_WITHOUT_FAIL): 13 | a = int(request.GET.get("a", 1)) 14 | with content_settings_context(DAYS_WITHOUT_FAIL="17"): 15 | b = int(request.GET.get("b", 1)) * multi 16 | return HttpResponse(f"{a} + {b} = {a + b}") 17 | 18 | 19 | urlpatterns = [ 20 | path( 21 | "", 22 | TemplateView.as_view( 23 | template_name="songs/index.html", 24 | extra_context={"artists": Artist.objects.all()}, 25 | ), 26 | name="index", 27 | ), 28 | path("math/", math, name="math"), 29 | path("fetch/main/", FetchSettingsView.as_view(names=gen_hastag("main"))), 30 | ] 31 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API & Views 2 | 3 | ## Simple Example - FetchAllSettingsView 4 | 5 | Sometimes, you need to organize access to your content settings via API for your front-end applications. While you can access content settings directly in Python code, the module provides a fetching view that simplifies exposing content settings through APIs. 6 | 7 | Add the fetching view to your `urls.py`: 8 | 9 | ```python 10 | from django.urls import path 11 | from content_settings.views import FetchAllSettingsView 12 | 13 | urlpatterns = [ 14 | path( 15 | "fetch/all/", 16 | FetchAllSettingsView.as_view(), 17 | name="fetch_all_info", 18 | ), 19 | ] 20 | ``` 21 | 22 | The API call will return all registered content settings that the user has permission to fetch (based on the `fetch_permission` attribute, explained later in the article). 23 | 24 | ## Group of Settings to Fetch - FetchSettingsView 25 | 26 | If you want to limit the fetched settings to specific names, use the `FetchSettingsView`. 27 | 28 | Example: 29 | 30 | ```python 31 | from django.urls import path 32 | from content_settings.views import FetchSettingsView 33 | 34 | urlpatterns = [ 35 | path( 36 | "fetch/main/", 37 | FetchSettingsView.as_view( 38 | names=[ 39 | "TITLE", 40 | "DESCRIPTION", 41 | ] 42 | ), 43 | name="fetch_main_info", 44 | ), 45 | ] 46 | ``` 47 | 48 | The `names` attribute specifies the settings included in the API. `FetchSettingsView` checks permissions for each setting using the `fetch_permission` attribute. By default, settings are not fetchable, so you need to update the settings' `fetch_permission` attribute. [Learn more about permissions here](permissions.md). 49 | 50 | ```python 51 | from content_settings import permissions # <-- Update 52 | 53 | TITLE = SimpleString( 54 | "My Site", 55 | fetch_permission=permissions.any, # <-- Update 56 | help="The title of the site", 57 | ) 58 | 59 | DESCRIPTION = SimpleString( 60 | "Isn't it cool?", 61 | fetch_permission=permissions.any, # <-- Update 62 | help="The description of the site", 63 | ) 64 | ``` 65 | 66 | Now any user can access the `TITLE` and `DESCRIPTION` settings using the `fetch/main/` API. 67 | 68 | ```bash 69 | $ curl http://127.0.0.1/fetch/main/ 70 | {"TITLE":"My Site","DESCRIPTION":"Isn't it cool?"} 71 | ``` 72 | 73 | ## Other Options for Using the `names` Attribute 74 | 75 | ### Fetch All Settings Matching Specific Conditions 76 | 77 | Instead of specifying setting names directly, you can use a function to fetch all settings that meet certain criteria. 78 | 79 | #### Example: Matching All Settings with a Specific Tag 80 | 81 | ```python 82 | from content_settings.views import FetchSettingsView, gen_hastag 83 | 84 | FetchSettingsView.as_view( 85 | names=gen_hastag("general") 86 | ) 87 | ``` 88 | 89 | #### Example: Matching All Settings with a Specific Prefix 90 | 91 | ```python 92 | from content_settings.views import FetchSettingsView, gen_startswith 93 | 94 | FetchSettingsView.as_view( 95 | names=gen_startswith("GENERAL_") 96 | ) 97 | ``` 98 | 99 | #### Example: Combining Criteria 100 | 101 | Fetch all settings that start with `"GENERAL_"` and also include the setting `TITLE`. 102 | 103 | ```python 104 | from content_settings.views import FetchSettingsView, gen_startswith 105 | 106 | FetchSettingsView.as_view( 107 | names=[ 108 | gen_startswith("GENERAL_"), 109 | "TITLE", 110 | ] 111 | ) 112 | ``` 113 | 114 | ### Using a Suffix 115 | 116 | ```python 117 | FetchSettingsView.as_view( 118 | names=[ 119 | "TITLE", 120 | "BOOKS__available_names", 121 | ], 122 | ) 123 | ``` 124 | 125 | ### Define Specific Keys for the Result JSON 126 | 127 | You can customize the keys in the resulting JSON by using a tuple in the `names` list. The first value in the tuple will be the key. 128 | 129 | ```python 130 | FetchSettingsView.as_view( 131 | names=[ 132 | "TITLE", 133 | ("NAMES", "BOOKS__available_names"), 134 | ], 135 | ) 136 | ``` 137 | 138 | In this example, the key `"NAMES"` will store the value of `content_settings.BOOKS__available_names`. This is useful if you change the setting name in Python but want to retain the old name in the API interface. 139 | 140 | ## FAQ 141 | 142 | ### What Happens if a User Lacks Permission to Fetch a Setting? 143 | 144 | The response will still have a status of `200`. The JSON response will include only the settings the user is allowed to access. An additional header, `X-Content-Settings-Errors`, will provide details about excluded settings. 145 | 146 | ### How Can I Hide Errors from the Response Headers? 147 | 148 | Set the `show_error_headers` attribute to `False`. 149 | 150 | Example: 151 | 152 | ```python 153 | FetchSettingsView.as_view( 154 | names=[ 155 | "TITLE", 156 | "DESCRIPTION", 157 | ], 158 | show_error_headers=False, 159 | ) 160 | ``` 161 | 162 | ### How Can I Create a Custom View That Still Checks Permissions? 163 | 164 | To check permissions, use the `can_fetch` method of the setting type. You can retrieve the setting type by name in two ways: 165 | 166 | #### Using the `type__` Prefix 167 | 168 | ```python 169 | content_settings.type__TITLE.can_fetch(user) 170 | ``` 171 | 172 | #### Using `get_type_by_name` from the Caching Module 173 | 174 | ```python 175 | from content_settings.caching import get_type_by_name 176 | 177 | get_type_by_name("TITLE").can_fetch(user) 178 | ``` 179 | 180 | ### How Can I Customize the JSON Representation for Complex Settings? 181 | 182 | - Overwrite the `SimpleString.json_view_value(self, value: Any, **kwargs)` method. The method should return a string in JSON format. 183 | - Use the `json_encoder` parameter to specify a custom JSON serializer (default: `DjangoJSONEncoder`). 184 | 185 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) 186 | -------------------------------------------------------------------------------- /docs/caching.md: -------------------------------------------------------------------------------- 1 | # Caching 2 | 3 | There are two storage mechanisms for raw data: the database (DB) and thread-local storage. The cache is used to signal when the local cache needs to be updated. 4 | 5 | During the first run, a checksum is calculated for all registered variables. This checksum acts as a cache key that signals when a value has been updated and needs to be refreshed from the DB. 6 | 7 | Values from the DB are retrieved only when at least one setting in the thread is requested. In this case, data is fetched from the DB only when required. 8 | 9 | Once at least one setting is requested, all raw values are fetched and saved in the thread-local storage. This ensures that all settings remain consistent. 10 | 11 | Raw data for a setting is converted to a Python object only when the setting is requested. This avoids unnecessary processing of all raw data. Since raw data can be converted to a Python object at any time, this approach is efficient. 12 | 13 | For every request, the system checks the cache to verify if the checksum value has changed. If it has, the thread-local data is marked as unpopulated. 14 | 15 | When the system repopulates (i.e., a setting is requested again after being marked unpopulated), it updates all raw values. However, Python objects are invalidated only if the corresponding raw value has changed. 16 | 17 | The cache trigger class is responsible for updating Python objects when the corresponding database values are updated. 18 | 19 | The default trigger class is `content_settings.cache_triggers.VersionChecksum`, and its name is stored in [`CONTENT_SETTINGS_CACHE_TRIGGER`](settings.md#content_settings_cache_trigger). 20 | 21 | --- 22 | 23 | ## Raw to Python to Value 24 | 25 | The journey from a database raw value to the setting's final returned value (`settings` attribute) consists of two stages: 26 | 27 | 1. **Creating a Python Object from the Raw Value**: 28 | - Retrieving an updated value involves converting the raw value into a Python object. 29 | 30 | 2. **Using the `give` Function**: 31 | - The `give` function of the type class converts the Python object into the attribute's final value. 32 | 33 | ### Behavior for Different Types: 34 | - **Simple Types**: 35 | - The `give` function directly returns the Python object. 36 | 37 | - **Complex Types**: 38 | - The `give` function can: 39 | - Utilize the context in which the attribute is used. 40 | - Apply suffixes of the attribute to modify the returned value. 41 | 42 | --- 43 | 44 | ## When Is the Checksum Validated? 45 | 46 | The checksum is validated in the following scenarios: 47 | 48 | - **At the Beginning of a Request**: 49 | - Triggered by `signals.check_update_for_request`. 50 | 51 | - **Before a Celery Task (if Celery is available)**: 52 | - Triggered by `signals.check_update_for_celery`. 53 | 54 | - **Before a Huey Task (if Huey is available)**: 55 | - Triggered by `signals.check_update_for_huey`. 56 | 57 | --- 58 | 59 | ## Precached Python Values 60 | 61 | *Experimental Feature* 62 | 63 | The system allows all Python objects to be precached for each thread at the time the thread starts (e.g., when the DB connection is initialized). 64 | 65 | To activate this feature, set: `CONTENT_SETTINGS_PRECACHED_PY_VALUES = True` 66 | 67 | Note: This feature might cause issues if a new thread is started for every request. 68 | 69 | See also [`content_settings.caching`](source.md#caching). 70 | 71 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) -------------------------------------------------------------------------------- /docs/commands.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | This is a list of available Django commands for content settings. 4 | 5 | --- 6 | 7 | ## `content_settings_migrate` 8 | 9 | You can migrate settings updates using this command. It is particularly useful if you have the Django setting `CONTENT_SETTINGS_UPDATE_DB_VALUES_BY_MIGRATE=False`. (See [settings](settings.md#content_settings_update_db_values_by_migrate)). 10 | 11 | ```bash 12 | $ python manage.py content_settings_migrate 13 | ``` 14 | 15 | Without arguments, the command does not apply any changes but displays the changes that would be applied. 16 | 17 | ```bash 18 | $ python manage.py content_settings_migrate --apply 19 | ``` 20 | 21 | Add `--apply` to apply the changes to the database. 22 | 23 | --- 24 | 25 | ## `content_settings_export` 26 | 27 | Export values from the database to STDOUT in JSON format. 28 | 29 | ```bash 30 | $ python manage.py content_settings_export 31 | ``` 32 | 33 | Without arguments, this command exports all settings. 34 | 35 | ```bash 36 | $ python manage.py content_settings_export > backup.json 37 | ``` 38 | 39 | Redirect the output to a file to create a complete backup of your settings. 40 | 41 | ```bash 42 | $ python manage.py content_settings_export --names TITLE DESCRIPTION 43 | ``` 44 | 45 | Export only specific settings. In the example above, only the settings "TITLE" and "DESCRIPTION" are exported. 46 | 47 | *You can also perform exports through the Django Admin Panel. [Read more about it here](ui.md#export).* 48 | 49 | --- 50 | 51 | ## `content_settings_import` 52 | 53 | Import data into the database from a JSON file generated by `content_settings_export` or the [Django Admin UI](ui.md#export). 54 | 55 | ### Example Commands: 56 | 57 | ```bash 58 | $ python manage.py content_settings_import file.json 59 | ``` 60 | 61 | This command does not immediately import data but displays what would be imported, along with any errors. 62 | 63 | ```bash 64 | $ python manage.py content_settings_import file.json --show-only-errors 65 | ``` 66 | 67 | Use this option to display only errors, skipping valid or approved records. 68 | 69 | ```bash 70 | $ python manage.py content_settings_import file.json --show-skipped 71 | ``` 72 | 73 | By default, only changes to the database are displayed. Use `--show-skipped` to display valid records that do not result in changes. 74 | 75 | ```bash 76 | $ python manage.py content_settings_import file.json --import 77 | ``` 78 | 79 | Add `--import` to apply all approved records to the database. 80 | 81 | ```bash 82 | $ python manage.py content_settings_import file.json --preview-for admin 83 | ``` 84 | 85 | Use `--preview-for {username}` to add all approved values to the user's preview group. 86 | 87 | ```bash 88 | $ python manage.py content_settings_import file.json --import --names TITLE DESCRIPTION 89 | ``` 90 | 91 | Limit the imported values by specifying them with the `--names` argument. 92 | 93 | *You can also perform imports through the Django Admin Panel. [Read more about it here](ui.md#import).* 94 | 95 | --- 96 | 97 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) 98 | -------------------------------------------------------------------------------- /docs/contribute.md: -------------------------------------------------------------------------------- 1 | # How to Contribute to the Project 2 | 3 | ## What Can I Do? 4 | 5 | - Review the [List of Open Tickets](https://github.com/occipital/django-content-settings/issues) to find tasks or issues to work on. 6 | - Test the project and write tests to increase test coverage. Use the command `make test-cov` to check coverage. 7 | 8 | --- 9 | 10 | ## How to Set Up the Environment 11 | 12 | Follow these steps to set up your development environment: 13 | 14 | ```bash 15 | pre-commit install 16 | make init 17 | make test 18 | ``` 19 | 20 | --- 21 | 22 | ## How to Set Up the `cs_test` Project 23 | 24 | The `cs_test` project/folder is used for testing and verifying the front-end portion of the project. 25 | 26 | 1. After setting up the environment, run: 27 | ```bash 28 | make cs-test-migrate 29 | ``` 30 | This creates a database for the project. 31 | 32 | 2. To start a local runserver with content settings configured: 33 | ```bash 34 | make cs-test 35 | ``` 36 | 37 | 3. To access the content settings shell: 38 | ```bash 39 | make cs-test-shell 40 | ``` 41 | 42 | --- 43 | 44 | ### Docker Container for Testing Different Backends 45 | 46 | To test and adjust the MySQL backend, a Docker Compose file is included for the current `cs_test` project. 47 | 48 | - Build the Docker container: 49 | ```bash 50 | make cs-test-docker-build 51 | ``` 52 | 53 | - Start the container: 54 | ```bash 55 | make cs-test-docker-up 56 | ``` 57 | 58 | Feel free to modify the Docker setup to suit your testing needs. 59 | 60 | --- 61 | 62 | ## When Updating Documentation 63 | 64 | Creating high-quality documentation is challenging, and improvements are always welcome! If you’re contributing to documentation, please keep the following in mind: 65 | 66 | - Use terms consistently from the [Glossary](glossary.md), as the system introduces many new concepts. 67 | - If you update docstrings (documentation inside Python files), run: 68 | ```bash 69 | make mdsource 70 | ``` 71 | This collects the updated docstrings into [source.md](source.md). 72 | 73 | --- 74 | 75 | ## Tests 76 | 77 | It's essential to create tests for new functionality and improve tests for existing functionality. Submitting a pull request with additional tests is highly encouraged. 78 | 79 | ### Testing Tools 80 | 81 | We use the following modules for testing: 82 | 83 | ``` 84 | pytest = "^7.4.3" 85 | pytest-mock = "^3.12.0" 86 | pytest-django = "^4.7.0" 87 | django-webtest = "^1.9.11" 88 | pytest-cov = "^4.1.0" 89 | nox = "^2023.4.22" 90 | ``` 91 | 92 | ### Testing Commands 93 | 94 | The following `make` commands can help streamline your testing process: 95 | 96 | - `make test`: Runs all tests in the current Poetry environment. 97 | - `make test-full`: Runs tests with extended settings. 98 | - `make test-min`: Runs tests with minimal settings to limit functionality. 99 | - `make test-cov`: Checks the current test coverage. 100 | - `make test-cov-xml`: Generates test coverage in `cov.xml`, which can help identify untested areas. 101 | - `make test-nox`: (Takes longer) Runs tests under all supported Python and Django versions. 102 | - `make test-nox-oldest`: Runs tests under the oldest supported combination of Python and Django versions. 103 | 104 | --- 105 | 106 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) 107 | -------------------------------------------------------------------------------- /docs/cookbook.md: -------------------------------------------------------------------------------- 1 | # Cookbook 2 | 3 | This section covers practical use cases for the `django-content-settings` module that might be useful in your projects. 4 | 5 | --- 6 | 7 | ### Grouping Multiple Settings by the Same Rule 8 | 9 | Suppose you have a group of settings with the same permission and want to append a note to the help text for those settings. While you can configure each setting individually, grouping them simplifies the process. 10 | 11 | ```python 12 | from content_settings.types.basic import SimpleString 13 | from content_settings.permissions import superuser 14 | from content_settings.defaults.context import defaults 15 | from content_settings.defaults.modifiers import help_suffix 16 | 17 | with defaults(help_suffix("Only superuser can change that"), update_permission=superuser): 18 | SITE_TITLE = SimpleString("Book Store", help="title for the site.") 19 | SITE_KEYWORDS = SimpleString("books, store, popular", help="head keywords.") 20 | ``` 21 | 22 | The above code can be replaced with individual configurations as follows: 23 | 24 | ```python 25 | # same imports 26 | 27 | SITE_TITLE = SimpleString( 28 | "Book Store", 29 | update_permission=superuser, 30 | help="title for the site.
Only superuser can change that", 31 | ) 32 | SITE_KEYWORDS = SimpleString( 33 | "books, store, popular", 34 | update_permission=superuser, 35 | help="head keywords.
Only superuser can change that", 36 | ) 37 | ``` 38 | 39 | --- 40 | 41 | ### Setting as a Class Attribute (Lazy Settings) 42 | 43 | Consider the following `content_settings.py`: 44 | 45 | ```python 46 | from content_settings.types.basic import SimpleInt 47 | 48 | POSTS_PER_PAGE = SimpleInt(10, help="How many blog posts will be shown per page") 49 | ``` 50 | 51 | In a `views.py`: 52 | 53 | ```python 54 | from django.views.generic import ListView 55 | from blog.models import Post 56 | from content_settings.conf import content_settings 57 | 58 | 59 | class PostListView(ListView): 60 | model = Post 61 | paginate_by = content_settings.POSTS_PER_PAGE 62 | ``` 63 | 64 | The above will work until you update `POSTS_PER_PAGE` in the Django admin, at which point the change won’t reflect. Instead, use a lazy value: 65 | 66 | ```python 67 | # same imports 68 | 69 | class PostListView(ListView): 70 | model = Post 71 | paginate_by = content_settings.lazy__POSTS_PER_PAGE # <-- update 72 | ``` 73 | 74 | --- 75 | 76 | ### How to Test Setting Changes 77 | 78 | Use `content_settings_context` from `content_settings.context_managers` to test setting changes. 79 | 80 | #### As a Decorator: 81 | 82 | ```python 83 | @content_settings_context(TITLE="New Book Store") 84 | def test_get_simple_text_updated(): 85 | assert content_settings.TITLE == "New Book Store" 86 | ``` 87 | 88 | #### As a Context Manager: 89 | 90 | ```python 91 | def test_get_simple_text_updated_twice(): 92 | client = get_anonymous_client() 93 | with content_settings_context(TITLE="New Book Store"): 94 | assert content_settings.TITLE == "New Book Store" 95 | 96 | with content_settings_context(TITLE="SUPER New Book Store"): 97 | assert content_settings.TITLE == "SUPER New Book Store" 98 | ``` 99 | 100 | --- 101 | 102 | ### Handling Endless Running Commands 103 | 104 | If you have an endless running command and want to keep settings updated, manually check updates inside the loop. Use `check_update` from `content_settings.caching`. 105 | 106 | ```python 107 | from django.core.management.base import BaseCommand 108 | from content_settings.caching import check_update 109 | 110 | class Command(BaseCommand): 111 | def handle(self, *args, **options): 112 | while True: 113 | check_update() 114 | 115 | # your logic 116 | ``` 117 | 118 | --- 119 | 120 | ### Triggering a Procedure When a Variable Changes 121 | 122 | To trigger an action, such as data synchronization, when a setting changes, add a `post_save` signal handler for `models.ContentSetting`. 123 | 124 | #### Case #1: Manually Convert Raw Data 125 | 126 | ```python 127 | from django.db.models.signals import post_save 128 | from django.dispatch import receiver 129 | from content_settings.models import ContentSetting 130 | 131 | @receiver(post_save, sender=ContentSetting) 132 | def process_variable_update(instance, created, **kwargs): 133 | if instance.name != 'VARIABLE': 134 | return 135 | val = content_settings.type__VARIABLE.give_python(instance.value) 136 | 137 | # process value 138 | ``` 139 | 140 | #### Case #2: Use `content_settings_context` 141 | 142 | ```python 143 | # same imports 144 | from content_settings.context_managers import content_settings_context 145 | 146 | @receiver(post_save, sender=ContentSetting) 147 | def process_variable_update(instance, created, **kwargs): 148 | if instance.name != 'VARIABLE': 149 | return 150 | 151 | with content_settings_context(VARIABLE=instance.value): 152 | val = content_settings.VARIABLE 153 | 154 | # process value 155 | ``` 156 | 157 | --- 158 | 159 | ### Upgrading a Variable from SimpleText to Template 160 | 161 | If you previously used a `SimpleText` variable and later need a template, you don’t have to update all references from `VARNAME` to `VARNAME()`. 162 | 163 | Use `GiveCallMixin` or `NoArgs` types such as `DjangoTemplateNoArgs` or `SimpleEvalNoArgs`. For the opposite scenario, use `MakeCallMixin`. 164 | 165 | --- 166 | 167 | ### Using `DjangoModelTemplate` Without Directly Importing a Model 168 | 169 | If you cannot import a model to assign a query to `template_model_queryset`, use `DjangoTemplate` with `gen_args_call_validator`. 170 | 171 | ```python 172 | def getting_first_profile(): 173 | from accounts.models import Profile 174 | 175 | return Profile.objects.first() 176 | 177 | NEW_SETTING = DjangoTemplate( 178 | "{{object.name}}", 179 | validators=[gen_args_call_validator(getting_first_profile)], 180 | template_args_default={'object': require} 181 | ) 182 | ``` 183 | 184 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) 185 | -------------------------------------------------------------------------------- /docs/epilogue.md: -------------------------------------------------------------------------------- 1 | # Epilogue 2 | 3 | I once heard a song in my dreams. When I woke up, I tried to write it down, but it was never as perfect as it was in my dream. This version, however, is the closest I’ve come so far. 4 | 5 | Thank you for taking the time to explore this project. 6 | 7 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) 8 | -------------------------------------------------------------------------------- /docs/extends.md: -------------------------------------------------------------------------------- 1 | # Possible Extensions 2 | 3 | The aim of this article is to showcase the various ways you can extend the basic functionality of `django-content-settings`. 4 | 5 | --- 6 | 7 | ## Create Your Own Classes 8 | 9 | The most basic and common way to extend functionality is by creating your own classes based on the ones in `content_settings.types`. 10 | 11 | - Check how other types are created. 12 | - Review the extension points for [`content_settings.types.basic.SimpleString`](source.md#class-simplestringbasesettingsource). 13 | 14 | --- 15 | 16 | ## Generating Tags 17 | 18 | Using the Django setting [`CONTENT_SETTINGS_TAGS`](settings.md#content_settings_tags), you can leverage built-in tags, such as `content_settings.tags.changed`. 19 | 20 | Additionally, you can create custom functions to generate tags for your settings based on their content. For inspiration, review the [source code](source.md#tags) for existing tags. 21 | 22 | --- 23 | 24 | ## Redefine Default Attributes for All Settings 25 | 26 | Using the Django setting [`CONTENT_SETTINGS_DEFAULTS`](settings.md#content_settings_defaults), you can customize how default attributes are applied to all (or specific) settings. 27 | 28 | - Refer to the [collections module](source.md#defaultscollections), which includes defaults for CodeMirror support. 29 | - Similarly, configure defaults to support other code editors or UI components. 30 | 31 | --- 32 | 33 | ## Custom Access Rules 34 | 35 | Access rules for settings can be defined by assigning specific functions to attributes. For more information, see [permissions](permissions.md). 36 | 37 | To go beyond predefined functions, you can create your own custom access rule functions to implement unique logic. 38 | 39 | --- 40 | 41 | ## Custom Prefix for Settings 42 | 43 | Several built-in prefixes, such as `withtag__` and `lazy__`, are available ([see full list here](access.md#prefix)). However, you can register your own prefixes using the `store.register_prefix` decorator. 44 | 45 | #### Example: 46 | 47 | ```python 48 | from content_settings.store import register_prefix 49 | from content_settings.caching import get_value 50 | 51 | @register_prefix("endswith") 52 | def endswith_prefix(name: str, suffix: str): 53 | return { 54 | k: get_value(k, suffix) for k in dir(content_settings) if k.endswith(name) 55 | } 56 | ``` 57 | 58 | #### Usage: 59 | 60 | ```python 61 | for name, value in content_settings.endswith__BETA: 62 | ... 63 | ``` 64 | 65 | --- 66 | 67 | ## Integration with Task Management Systems 68 | 69 | There are built-in integrations for task managers like Celery and Huey. These integrations are simple, and you can create your own. 70 | 71 | #### Example: Celery Integration 72 | 73 | This example ensures that all settings are updated before a task begins. It can be found in [signals.py](https://github.com/occipital/django-content-settings/blob/master/content_settings/signals.py): 74 | 75 | ```python 76 | try: 77 | from celery.signals import task_prerun 78 | except ImportError: 79 | pass 80 | else: 81 | 82 | @task_prerun.connect 83 | def check_update_for_celery(*args, **kwargs): 84 | check_update() 85 | ``` 86 | 87 | The idea is to verify that all settings are up-to-date before starting a task. 88 | 89 | --- 90 | 91 | ## Middleware for Preview 92 | 93 | To enable the preview functionality for settings, add the middleware `content_settings.middleware.preview_on_site` to your project’s settings. 94 | 95 | The middleware: 96 | - Checks if the user has preview objects. 97 | - Processes the response under the updated settings context. 98 | 99 | You can review the middleware’s [source code here](https://github.com/occipital/django-content-settings/blob/master/content_settings/middlewares.py). 100 | 101 | ### Custom Middleware 102 | 103 | You may create custom middleware for specialized use cases, such as integrating with [django-impersonate](https://pypi.org/project/django-impersonate/). 104 | 105 | --- 106 | 107 | ## Cache Triggers 108 | 109 | Cache triggers are part of the [caching functionality](caching.md). They allow you to configure when to update py objects related to settings. 110 | 111 | - See the source code for [`content_settings.cache_triggers`](source.md#cache_triggers) for more information. 112 | 113 | --- 114 | 115 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) 116 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | Many common use cases are covered in the [Cookbook](cookbook.md) and [Possible Extensions](extends.md), but here I want to address some frequently asked questions from other users. 4 | 5 | --- 6 | 7 | ### Can I create a variable in the Django admin before it’s defined in `content_settings.py`? 8 | 9 | Yes, you can. Check out the [User Defined Variables](uservar.md) section for more information. 10 | 11 | --- 12 | 13 | ### Why are there two functions, `give` and `to_python`, when `give` often just returns its input? 14 | 15 | The `give` function is designed to adapt data specifically for use in the project’s code, whereas `to_python` converts a string value into a Python object. 16 | 17 | The key difference: 18 | - **`to_python`**: Converts a string value to a Python object when the string value changes or at project startup. 19 | - **`give`**: Adapts the Python object for use in the project, and this happens whenever the data is requested (e.g., from an attribute or another source). 20 | 21 | --- 22 | 23 | ### Why is the version still 0? 24 | 25 | The module is still in active development. The design, including the naming conventions for types, may change. Version 1.0 will include a more stable and finalized design. 26 | 27 | --- 28 | 29 | ### Can I see the changes on the site before applying them to all users? 30 | 31 | Yes. The preview functionality allows you to see changes before applying them globally. Learn more about it in the [UI article](ui.md#preview-functionality). 32 | 33 | --- 34 | 35 | ### I need to change multiple variables at once for a desired effect. How can I avoid users seeing an incomplete configuration? 36 | 37 | This can be handled in several ways: 38 | 39 | 1. **Edit Multiple Values in the Change List**: 40 | - Use the "mark" functionality to edit multiple settings on the same page and submit them together. Read more in the [UI article](ui.md#apply-multiple-settings-at-once). 41 | 42 | 2. **Use Preview Settings**: 43 | - Add multiple changes to the preview and apply them all in one click. Read more in the [UI article](ui.md#preview-functionality). 44 | 45 | --- 46 | 47 | ### The guide didn’t help. What should I do? 48 | 49 | This happens, as the documentation is still a work in progress and not all scenarios are covered yet. 50 | 51 | Here’s how you can get additional support: 52 | 53 | - **Have a specific question?** Use [Discussions on GitHub](https://github.com/occipital/django-content-settings/discussions). 54 | - **Found a bug or unexpected behavior?** Report it on [Issues in GitHub](https://github.com/occipital/django-content-settings/issues). 55 | - **Want to improve the documentation?** Contributions are welcome! You can find the Markdown sources in the [docs folder](https://github.com/occipital/django-content-settings/tree/master/docs). 56 | 57 | --- 58 | 59 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) 60 | -------------------------------------------------------------------------------- /docs/glossary.md: -------------------------------------------------------------------------------- 1 | # Glossary 2 | 3 | Some of the terms used in other articles are explained here. The terms from the glossary are shown in *italic* in the other articles. 4 | 5 | In order to better understand these terms, I'll use the following example. 6 | 7 | ```python 8 | FAVORITE_SUBJECTS = SimpleStringsList( 9 | "mysubject", 10 | comment_starts_with="//", 11 | help="my favorite songs subjects" 12 | ) 13 | ``` 14 | 15 | ### admin preview 16 | 17 | *or setting admin preview* 18 | 19 | When the admin changes the *raw value* in Django Admin, the admin panel shows a preview that somehow illustrates the *value*. Sometimes, it is tricky when you need to show a value of *callable type*. 20 | 21 | ### callable type 22 | 23 | When the *setting value* is callable, so in code you need to call the value, for example `content_settings.FAVORITE_SUBJECTS()`. Most of the *callable types* can be found in `content_settings.types.template`. 24 | 25 | ### content tags 26 | 27 | Tags for the setting generated by the value of the setting. 28 | 29 | ### db value 30 | 31 | *or setting db value* 32 | 33 | *Raw value* that is stored in the database. 34 | 35 | ### default value 36 | 37 | *or setting default value* 38 | 39 | `"mysubject"` - *raw value* that will be used for database initialization or for cases when *db value* is not set. 40 | 41 | ### definition 42 | 43 | *or setting definition* 44 | 45 | The example above shows the full setting definition. Includes *name* and *type definition*. 46 | 47 | ### django settings 48 | 49 | As we use settings to refer to content settings, we will use django settings for actual Django settings constants. 50 | 51 | ### instance 52 | 53 | *or setting instance* 54 | 55 | The result of calling the *type definition*. The instance is responsible for converting, parsing, and validating processes. 56 | 57 | ### JSON value 58 | 59 | *or setting JSON value* 60 | 61 | JSON representation of the *value* for API. Generated by `json_view_value` of the *instance*. 62 | 63 | ### lazy value 64 | 65 | *or setting lazy value* 66 | 67 | *Use setting* with *prefix* that returns *value* as a lazy object. Can be useful when you need to save a reference to the *value* in a global environment before the *python object* generation. Example: `content_settings.lazy__FAVORITE_SUBJECTS`. Generated by `lazy_give` of the *instance*, see also `content_settings.types.lazy`. 68 | 69 | ### mixin 70 | 71 | *or setting mixin* 72 | 73 | In the *setting definition*, you can extend *setting type* with a list of mixins using the `mixin` function (the function and most of the available mixins can be found in `content_settings.types.mixins`). Example: `DAYS_WITHOUT_FAIL = mix(MinMaxValidationMixin, SimpleInt)("5", min_value=0, max_value=10, help="How many days without fail")` - *type* `SimpleInt` was extended with the mixin `MinMaxValidationMixin`, which adds new optional attributes `min_value` and `max_value` and validates if the *python object* is within a given range. 74 | 75 | ### name 76 | 77 | *or setting name* 78 | 79 | `FAVORITE_SUBJECTS` - the unique name of your setting. Should always be uppercased. By the same name, you can *use setting* and change it in Django Admin. 80 | 81 | ### prefix 82 | 83 | *or setting prefix* 84 | 85 | A content setting method that can return something other than *setting value*. For example, `content_settings.lazy__FAVORITE_SUBJECTS` - `lazy` is a prefix and the whole *use* returns the *lazy value* of the setting `FAVORITE_SUBJECTS`. The `register_prefix` allows new prefix registration. 86 | 87 | ### python object 88 | 89 | *or setting python object or py object* 90 | 91 | The object generated by converting the raw value when starting the server (or when the raw value is changed). Generated by the method `to_python` of the *setting instance*. 92 | 93 | ### raw value 94 | 95 | *or setting raw value* 96 | 97 | The initial value, always a string. This value is parsed/converted using the *setting instance*. 98 | 99 | ### suffix 100 | 101 | *or setting suffix* 102 | 103 | An extra attribute that extends the `give` method that returns *value*. For example, `content_settings.FAVORITE_SUBJECTS__first` - `first` is a *suffix*. Suffixes are convenient for cases when you need to extract some other data from the *python object*, not only the *value*, or you need to return *value* in a different way for special cases. 104 | 105 | ### type 106 | 107 | *or setting type* 108 | 109 | `SimpleStringsList` - All of the built-in classes can be found in `content_settings.types`. 110 | 111 | ### type arguments 112 | 113 | *or setting type arguments* 114 | 115 | `comment_starts_with="//", help="my favorite songs subjects"`. 116 | 117 | ### type definition 118 | 119 | *or setting type definition* 120 | 121 | `SimpleStringsList("mysubject", comment_starts_with="//", help="my favorite songs subjects")`. 122 | 123 | ### use setting 124 | 125 | *or setting use* 126 | 127 | When you use a setting in the Python code `content_settings.FAVORITE_SUBJECTS` or in a template `{{CONTENT_SETTINGS.FAVORITE_SUBJECTS}}`. All of these code examples return the *setting value*. 128 | 129 | ### user defined types 130 | 131 | Types that are allowed to be used for user-defined settings. [More about it here](uservar.md). 132 | 133 | ### user defined settings 134 | 135 | Settings that are created in Django Admin by the user (not by code). [More about it here](uservar.md). 136 | 137 | ### value 138 | 139 | *or setting value* 140 | 141 | The value that will be returned when you *use setting*. Generated by the `give` method for the *setting instance* for each *use*. For the most basic types, *value* is the same as the *python object*. 142 | 143 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) -------------------------------------------------------------------------------- /docs/img/dict_suffixes_preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/dict_suffixes_preview.gif -------------------------------------------------------------------------------- /docs/img/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/preview.gif -------------------------------------------------------------------------------- /docs/img/preview_on_site.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/preview_on_site.png -------------------------------------------------------------------------------- /docs/img/split_translation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/split_translation.png -------------------------------------------------------------------------------- /docs/img/title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/title.png -------------------------------------------------------------------------------- /docs/img/ui/batch_changes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/batch_changes.png -------------------------------------------------------------------------------- /docs/img/ui/django_history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/django_history.png -------------------------------------------------------------------------------- /docs/img/ui/edit_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/edit_page.png -------------------------------------------------------------------------------- /docs/img/ui/history_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/history_button.png -------------------------------------------------------------------------------- /docs/img/ui/history_export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/history_export.png -------------------------------------------------------------------------------- /docs/img/ui/import_json.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/import_json.png -------------------------------------------------------------------------------- /docs/img/ui/import_json_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/import_json_error.png -------------------------------------------------------------------------------- /docs/img/ui/import_json_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/import_json_preview.png -------------------------------------------------------------------------------- /docs/img/ui/list_view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/list_view.png -------------------------------------------------------------------------------- /docs/img/ui/list_view_actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/list_view_actions.png -------------------------------------------------------------------------------- /docs/img/ui/list_view_actions_export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/list_view_actions_export.png -------------------------------------------------------------------------------- /docs/img/ui/list_view_bottom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/list_view_bottom.png -------------------------------------------------------------------------------- /docs/img/ui/list_view_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/list_view_preview.png -------------------------------------------------------------------------------- /docs/img/ui/list_view_preview_panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/list_view_preview_panel.png -------------------------------------------------------------------------------- /docs/img/ui/list_view_tag_filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/list_view_tag_filter.png -------------------------------------------------------------------------------- /docs/img/ui/main_admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/occipital/django-content-settings/26060296a8f93700a8861b365be96170ad2ce2c5/docs/img/ui/main_admin.png -------------------------------------------------------------------------------- /docs/permissions.md: -------------------------------------------------------------------------------- 1 | # Permissions 2 | 3 | ## Overview 4 | 5 | The `content_settings.permissions` *([source](source.md#permissions))* module in Django provides functions that can be used as arguments for the permission attributes of your settings, such as: 6 | 7 | - `fetch_permission`: Controls API access to variables through `views.FetchSettingsView`. 8 | - `update_permission`: Restricts the ability to change a variable in the admin panel. 9 | - `view_permission`: Determines who can see the variable in the admin panel (it will not be listed for unauthorized users). 10 | 11 | --- 12 | 13 | ## Functions in the Module 14 | 15 | ### `any` 16 | 17 | Allows access for all users. 18 | 19 | ### `none` 20 | 21 | Denies access to all users. 22 | 23 | ### `authenticated` 24 | 25 | Grants access only to authenticated users. 26 | 27 | ### `staff` 28 | 29 | Restricts access to staff users. 30 | 31 | ### `superuser` 32 | 33 | Restricts access to superusers. 34 | 35 | ### `has_perm(perm)` 36 | 37 | Allows access to users with a specific permission. 38 | 39 | **Example**: 40 | 41 | ```python 42 | has_perm('app_label.permission_codename') 43 | ``` 44 | 45 | --- 46 | 47 | ## Functions from `functools` Module 48 | 49 | ### `and_(*funcs)` 50 | 51 | Combines multiple permission functions using a logical AND. 52 | 53 | **Example**: 54 | 55 | ```python 56 | and_(authenticated, has_perm('app_label.permission_codename')) 57 | ``` 58 | 59 | ### `or_(*funcs)` 60 | 61 | Combines multiple permission functions using a logical OR. 62 | 63 | **Example**: 64 | 65 | ```python 66 | or_(staff, has_perm('app_label.permission_codename')) 67 | ``` 68 | 69 | ### `not_(*funcs)` 70 | 71 | Applies a logical NOT to the given permission functions. 72 | 73 | --- 74 | 75 | ## Usage Examples 76 | 77 | ### Example 1: Setting Multiple Permissions 78 | 79 | Restrict a variable so that only staff members or users with a specific permission can update it: 80 | 81 | ```python 82 | from content_settings.types.basic import SimpleString, SimpleDecimal 83 | from content_settings.permissions import staff, has_perm 84 | from content_settings.functools import or_ 85 | 86 | TITLE = SimpleString( 87 | "default value", 88 | update_permission=or_(staff, has_perm("app_label.permission_codename")) 89 | ) 90 | 91 | MAX_PRICE = SimpleDecimal( 92 | "9.99", 93 | fetch_permission=staff, 94 | ) 95 | ``` 96 | 97 | In this example: 98 | - `TITLE` can be updated by either staff members or users with the specified permission. 99 | - `MAX_PRICE` can only be fetched by staff members. 100 | 101 | --- 102 | 103 | ### Example 2: Using Permission Names Instead of Functions 104 | 105 | You can use permission names directly if they are defined in the `content_settings.permissions` module: 106 | 107 | ```python 108 | from content_settings.types.basic import SimpleString, SimpleDecimal 109 | from content_settings.permissions import has_perm 110 | from content_settings.functools import or_ 111 | 112 | TITLE = SimpleString( 113 | "default value", 114 | update_permission=or_("staff", has_perm("app_label.permission_codename")) 115 | ) 116 | 117 | MAX_PRICE = SimpleDecimal( 118 | "9.99", 119 | fetch_permission="staff", 120 | ) 121 | ``` 122 | 123 | Alternatively, use the full import path for custom permissions: 124 | 125 | ```python 126 | from content_settings.types.basic import SimpleDecimal 127 | 128 | MAX_PRICE = SimpleDecimal( 129 | "9.99", 130 | fetch_permission="my_project.permissions.main_users", 131 | ) 132 | ``` 133 | 134 | --- 135 | 136 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) 137 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.5.3 -------------------------------------------------------------------------------- /docs/uservar.md: -------------------------------------------------------------------------------- 1 | # User Defined Variables 2 | 3 | ## Introduction 4 | 5 | The main concept of content settings is to allow you to define a constant in code that can be edited in the Django Admin panel. However, there might be a case when you need to create new content settings not in code but in the admin panel, and this is where user-defined types are used. 6 | 7 | ## How is it useful? 8 | 9 | 1. **Use in Template Variables**: If you have several template settings containing the same text, do not copy and paste the same text in each value - you can simply create your own content settings variable and use it for every template value. 10 | 11 | 2. **Flexibility for Developers**: You can have a view that fetches variables by tag (see [API](api.md#all-settings-that-matches-specific-conditions)), and by creating a new variable, you can add new data to the API response. Later, you can define the setting in code, which replaces the user-defined setting with a simple setting. On top of that, we have the prefix "withtag__", which allows you to get all of the settings with a specific tag. 12 | 13 | ## Setting Up User Defined Types 14 | 15 | To enable the creation of such variables, you need to set up a specific setting that lists all the types available for creation: 16 | 17 | ```python 18 | CONTENT_SETTINGS_USER_DEFINED_TYPES=[ 19 | ("text", "content_settings.types.basic.SimpleText", "Text"), 20 | ("html", "content_settings.types.basic.SimpleHTML", "HTML"), 21 | ] 22 | ``` 23 | 24 | Read more about this setting [here](settings.md#content_settings_user_defined_types). 25 | 26 | Having the Django setting set admin should see the "Add Content Settings" button at the top of the list of all available content settings (see [UI](ui.md#list-of-available-settings)) 27 | 28 | ## Overwriting User-Defined Variables 29 | 30 | It's important to note that if you decide to create a variable in the code that should overwrite a variable previously made in the admin panel, you will encounter a migration error. To avoid this, explicitly state that the code variable will overwrite the admin-created variable by setting `overwrite_user_defined=True`. 31 | 32 | ## Conclusion 33 | 34 | User User-defined types in `django-content-settings` offer significant flexibility and customization for managing variables in Django applications. This feature empowers administrators to create and modify variables directly from the admin panel while still providing the option for developers to override these variables in the code if needed. 35 | 36 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) -------------------------------------------------------------------------------- /mdsource.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import os 3 | from pathlib import Path 4 | 5 | 6 | GITHUB_PREFIX = "https://github.com/occipital/django-content-settings/blob/master/" 7 | SOURCE_FOLDER = "content_settings" 8 | IGNORE_MODULES = ["receivers.py", "apps.py", "admin.py"] 9 | 10 | 11 | def split_path(path): 12 | return list(Path(path).parts) 13 | 14 | 15 | def path_to_linux(path): 16 | return "/".join(split_path(path)) 17 | 18 | 19 | def get_base_classes(bases): 20 | """Extract the names of base classes from the bases list in a class definition.""" 21 | base_class_names = [] 22 | for base in bases: 23 | if isinstance(base, ast.Name): 24 | base_class_names.append(base.id) 25 | elif isinstance(base, ast.Attribute): 26 | base_class_names.append(ast.unparse(base)) 27 | else: 28 | base_class_names.append(ast.unparse(base)) 29 | return ", ".join(base_class_names) 30 | 31 | 32 | def get_function_signature(func): 33 | """Generate the signature for a function or method.""" 34 | args = [] 35 | # Extract arguments and their default values 36 | defaults = [None] * ( 37 | len(func.args.args) - len(func.args.defaults) 38 | ) + func.args.defaults 39 | for arg, default in zip(func.args.args, defaults): 40 | if isinstance(arg.annotation, ast.expr): 41 | # Get the annotation if present 42 | annotation = ast.unparse(arg.annotation) 43 | arg_desc = f"{arg.arg}: {annotation}" 44 | else: 45 | arg_desc = arg.arg 46 | 47 | if default is not None: 48 | default_value = ast.unparse(default) 49 | arg_desc += f" = {default_value}" 50 | args.append(arg_desc) 51 | return f"({', '.join(args)})" 52 | 53 | 54 | def md_from_node(node, prefix, file_path): 55 | for n in node.body: 56 | if isinstance(n, ast.ClassDef): 57 | if class_doc := ast.get_docstring(n): 58 | yield f"\n\n{prefix} class {n.name}({get_base_classes(n.bases)})" 59 | yield f"[source]({GITHUB_PREFIX}{path_to_linux(file_path)}#L{n.lineno})\n\n" 60 | yield class_doc 61 | 62 | yield from md_from_node(n, prefix=prefix + "#", file_path=file_path) 63 | 64 | elif isinstance(n, ast.FunctionDef): 65 | if func_doc := ast.get_docstring(n): 66 | yield f"\n\n{prefix} def {n.name}" 67 | yield get_function_signature(n) 68 | yield f"[source]({GITHUB_PREFIX}{path_to_linux(file_path)}#L{n.lineno})\n\n" 69 | yield func_doc 70 | 71 | 72 | def md_from_file(file_path): 73 | with open(file_path, "r") as file: 74 | node = ast.parse(file.read(), filename=file_path) 75 | 76 | if module_doc := ast.get_docstring(node): 77 | yield module_doc 78 | 79 | yield from md_from_node(node, prefix="###", file_path=file_path) 80 | 81 | 82 | module_list = [] 83 | main_lines = [] 84 | 85 | for dirname, dirs, files in os.walk(SOURCE_FOLDER): 86 | if dirname.endswith("__pycache__"): 87 | continue 88 | 89 | for name in sorted(files): 90 | if not name.endswith(".py"): 91 | continue 92 | if name in IGNORE_MODULES: 93 | continue 94 | 95 | mddoc = "".join(md_from_file(os.path.join(dirname, name))) 96 | if not mddoc: 97 | continue 98 | 99 | # Save generated doc to the docfile 100 | 101 | dir = dirname[len(SOURCE_FOLDER) + 1 :] 102 | module_name = ".".join(Path(dir).parts + (os.path.splitext(name)[0],)) 103 | 104 | main_lines.append(f"\n\n## {module_name}") 105 | module_list.append(f"- [{module_name}](#{module_name.replace('.', '')})") 106 | 107 | main_lines.append("\n\n") 108 | main_lines.append(mddoc) 109 | 110 | with open(os.path.join("docs", "source.md"), "w") as fh: 111 | fh.write("# Module List\n\n") 112 | fh.write("\n".join(module_list)) 113 | fh.write("\n\n") 114 | 115 | fh.write("\n".join(main_lines)) 116 | fh.write( 117 | """ 118 | 119 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg)](https://stand-with-ukraine.pp.ua) 120 | 121 | """ 122 | ) 123 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django Content Settings 2 | nav: 3 | - Home: index.md 4 | - Getting Started: first.md 5 | - Setting Types and Attributes: types.md 6 | - Template Types: template_types.md 7 | - Using Settings: access.md 8 | - Permissions: permissions.md 9 | - Defaults Context: defaults.md 10 | - API & Views: api.md 11 | - Available Django Settings: settings.md 12 | - User Interface for Django Admin: ui.md 13 | - Commands: commands.md 14 | - How Caching is Organized: caching.md 15 | - User Defined Variables: uservar.md 16 | - Possible Extensions: extends.md 17 | - Cookbook: cookbook.md 18 | - Frequently Asked Questions: faq.md 19 | - Changelog: changelog.md 20 | - Glossary: glossary.md 21 | - How to contribute: contribute.md 22 | - Source Doc: source.md 23 | - Epilogue: epilogue.md 24 | theme: readthedocs 25 | repo_url: https://github.com/occipital/django-content-settings/ -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | 4 | @nox.session(python=["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]) 5 | @nox.parametrize("django", ["3.2", "4.2", "5.0", "5.1"]) 6 | @nox.parametrize("pyyaml", [True, False]) 7 | def tests(session, django, pyyaml): 8 | if django in ["5.0", "5.1"] and session.python in ( 9 | "3.8", 10 | "3.9", 11 | ): 12 | return 13 | session.install(f"django=={django}") 14 | if pyyaml: 15 | session.install("PyYAML") 16 | if session.python in ["3.12", "3.13"]: 17 | session.install("setuptools") 18 | session.install("pytest~=7.4.3") 19 | session.install("pytest-mock~=3.12.0") 20 | session.install("pytest-django~=4.7.0") 21 | session.install("django-webtest~=1.9.11") 22 | session.install("-e", ".") 23 | for testing_settings in ["min", "full", "normal"]: 24 | for precache in (True, False): 25 | session.run( 26 | "pytest", 27 | env={ 28 | "TESTING_SETTINGS": testing_settings, 29 | **({"TESTING_PRECACHED_PY_VALUES": "1"} if precache else {}), 30 | }, 31 | ) 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-content-settings" 3 | version = "0.29.2" 4 | description = "DCS - the most advanced admin editable setting" 5 | homepage = "https://django-content-settings.readthedocs.io/" 6 | repository = "https://github.com/occipital/django-content-settings/" 7 | authors = ["oduvan "] 8 | keywords = ["Django", "settings"] 9 | readme = "README_SHORT.md" 10 | classifiers = [ 11 | "Framework :: Django :: 3.2", 12 | "Framework :: Django :: 4", 13 | "Framework :: Django :: 4.0", 14 | "Framework :: Django :: 4.1", 15 | "Framework :: Django :: 4.2", 16 | "Framework :: Django :: 5.0", 17 | "Framework :: Django :: 5.1", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | ] 25 | license = "MIT" 26 | packages = [{include = "content_settings"}] 27 | 28 | [tool.poetry.dependencies] 29 | python = ">=3.8" 30 | django = ">=3.2" 31 | 32 | 33 | [tool.poetry.group.test.dependencies] 34 | pytest = "^7.4.3" 35 | pytest-mock = "^3.12.0" 36 | pytest-django = "^4.7.0" 37 | django-webtest = "^1.9.11" 38 | pytest-cov = "^4.1.0" 39 | nox = "^2023.4.22" 40 | 41 | 42 | [tool.poetry.group.dev.dependencies] 43 | ipdb = "^0.13.13" 44 | 45 | 46 | [tool.poetry.group.docs.dependencies] 47 | mkdocs = "^1.5.3" 48 | 49 | [build-system] 50 | requires = ["poetry-core"] 51 | build-backend = "poetry.core.masonry.api" 52 | -------------------------------------------------------------------------------- /set_version.py: -------------------------------------------------------------------------------- 1 | import tomllib 2 | 3 | with open("pyproject.toml", "rb") as toml_file: 4 | data = tomllib.load(toml_file) 5 | 6 | print(data["tool"]["poetry"]["name"]) 7 | print(data["tool"]["poetry"]["version"]) 8 | 9 | with open("content_settings/__init__.py", "w") as f: 10 | f.write(f'__version__ = "{data["tool"]["poetry"]["version"]}"\n') 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | yaml_installed = False 4 | try: 5 | import yaml 6 | 7 | yaml_installed = True 8 | except ImportError: 9 | pass 10 | 11 | testing_settings = os.environ.get("TESTING_SETTINGS", "normal") 12 | testing_settings_normal = testing_settings == "normal" 13 | testing_settings_full = testing_settings == "full" 14 | testing_settings_min = testing_settings == "min" 15 | testing_precached_py_values = os.environ.get("TESTING_PRECACHED_PY_VALUES", False) 16 | -------------------------------------------------------------------------------- /tests/books/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Book(models.Model): 5 | title = models.CharField(max_length=255) 6 | description = models.TextField() 7 | 8 | class Meta: 9 | permissions = [ 10 | ("can_read_todo", "Can view all books"), 11 | ("can_edit_todo", "Can view all books"), 12 | ] 13 | 14 | def __str__(self): 15 | return self.title 16 | -------------------------------------------------------------------------------- /tests/books/templates/books/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ CONTENT_SETTINGS.TITLE }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% for book in CONTENT_SETTINGS.BOOKS %} 13 | 14 | 15 | 16 | 17 | {% endfor %} 18 |
TitlePrice
{{ book.name }}{{ book.price }}
19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/books/templates/books/list.html: -------------------------------------------------------------------------------- 1 | {% load content_settings_extras %} 2 | 3 | 4 | 5 | {{CONTENT_SETTINGS.TITLE}} 6 | 7 | 8 |
9 |

Book List

10 |
11 | {% for book in object_list %} 12 | {% content_settings_call "BOOK_RICH_DESCRIPTION" book _safe=True%} 13 | {% empty %} 14 |
  • No books found.
  • 15 | {% endfor %} 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/books/templates/books/simple.html: -------------------------------------------------------------------------------- 1 | SIMPLE_HTML_FIELD: {{CONTENT_SETTINGS.SIMPLE_HTML_FIELD}} 2 | 3 | TITLE: {{CONTENT_SETTINGS.TITLE}} -------------------------------------------------------------------------------- /tests/books/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.generic import TemplateView, ListView 3 | 4 | from .models import Book 5 | from content_settings.views import ( 6 | FetchSettingsView, 7 | gen_hastag, 8 | gen_startswith, 9 | FetchAllSettingsView, 10 | gen_all, 11 | ) 12 | 13 | 14 | class BookListView(ListView): 15 | model = Book 16 | template_name = "books/list.html" 17 | 18 | 19 | app_name = "books" 20 | urlpatterns = [ 21 | path("", TemplateView.as_view(template_name="books/index.html"), name="index"), 22 | path("list/", BookListView.as_view(), name="list"), 23 | path( 24 | "simple-html/", 25 | TemplateView.as_view(template_name="books/simple.html"), 26 | name="simple-html", 27 | ), 28 | path( 29 | "fetch/main/", 30 | FetchSettingsView.as_view( 31 | names=[ 32 | "TITLE", 33 | "BOOKS__available_names", 34 | ] 35 | ), 36 | name="fetch_main", 37 | ), 38 | path( 39 | "fetch/main-simple/", 40 | FetchSettingsView.as_view( 41 | names=[ 42 | "TITLE", 43 | ("BOOKS", "BOOKS__available_names"), 44 | ] 45 | ), 46 | name="fetch_main", 47 | ), 48 | path( 49 | "fetch/home-detail/", 50 | FetchSettingsView.as_view( 51 | names=[ 52 | "DESCRIPTION", 53 | "OPEN_DATE", 54 | "TITLE", 55 | ] 56 | ), 57 | name="fetch_home_detail", 58 | ), 59 | path( 60 | "fetch/home/", 61 | FetchSettingsView.as_view( 62 | names=[ 63 | "TITLE", 64 | ] 65 | ), 66 | name="fetch_home_detail", 67 | ), 68 | path( 69 | "fetch/constants/", 70 | FetchSettingsView.as_view( 71 | names=[ 72 | "AUTHOR", 73 | ] 74 | ), 75 | name="fetch_home_detail", 76 | ), 77 | path( 78 | "fetch/is/", 79 | FetchSettingsView.as_view(names=gen_startswith("IS_")), 80 | name="fetch_is", 81 | ), 82 | path( 83 | "fetch/by/", 84 | FetchSettingsView.as_view(names=gen_startswith("BY_")), 85 | name="fetch_by", 86 | ), 87 | path( 88 | "fetch/is-and-title/", 89 | FetchSettingsView.as_view(names=(gen_startswith("IS_"), "TITLE")), 90 | name="fetch_is_and_title", 91 | ), 92 | path( 93 | "fetch/general/", 94 | FetchSettingsView.as_view(names=gen_hastag("general")), 95 | name="fetch_general", 96 | ), 97 | path( 98 | "fetch/all/", 99 | FetchAllSettingsView.as_view(), 100 | name="fetch_all", 101 | ), 102 | path( 103 | "fetch/all-extended/", 104 | FetchSettingsView.as_view( 105 | names=( 106 | gen_all(), 107 | "BOOKS__available_names", 108 | ), 109 | ), 110 | name="fetch_all_extended", 111 | ), 112 | ] 113 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import django 2 | import pytest 3 | 4 | from django.contrib.auth import get_user_model 5 | 6 | from tests import ( 7 | testing_settings_full, 8 | testing_settings_min, 9 | testing_precached_py_values, 10 | ) 11 | 12 | 13 | def pytest_configure(config): 14 | from django.conf import settings 15 | from content_settings.defaults.filters import full_name_exact 16 | from content_settings.defaults.modifiers import help_prefix 17 | 18 | if testing_settings_full: 19 | content_settings_settings = dict( 20 | CONTENT_SETTINGS_USER_DEFINED_TYPES=[ 21 | ( 22 | "line", 23 | "tests.books.content_settings.PublicSimpleString", 24 | "Line (Public)", 25 | ), 26 | ("text", "content_settings.types.basic.SimpleText", "Text"), 27 | ("html", "content_settings.types.basic.SimpleHTML", "HTML"), 28 | ("bool", "content_settings.types.basic.SimpleBool", "Boolean"), 29 | ], 30 | CONTENT_SETTINGS_DEFAULTS=[ 31 | ( 32 | full_name_exact("content_settings.types.basic.SimplePassword"), 33 | help_prefix("Do Not Share"), 34 | ), 35 | ], 36 | CONTENT_SETTINGS_ADMIN_CHECKSUM_CHECK_BEFORE_SAVE=True, 37 | CONTENT_SETTINGS_TAGS=[ 38 | "content_settings.tags.changed", 39 | "content_settings.tags.app_name", 40 | ], 41 | CONTENT_SETTINGS_VALIDATE_DEFAULT_VALUE=False, 42 | CONTENT_SETTINGS_PREVIEW_ON_SITE_SHOW=True, 43 | ) 44 | elif testing_settings_min: 45 | content_settings_settings = dict( 46 | CONTENT_SETTINGS_CHAIN_VALIDATE=False, 47 | CONTENT_SETTINGS_PREVIEW_ON_SITE_SHOW=False, 48 | ) 49 | else: 50 | content_settings_settings = {"CONTENT_SETTINGS_PREVIEW_ON_SITE_SHOW": True} 51 | 52 | settings.configure( 53 | DEBUG_PROPAGATE_EXCEPTIONS=True, 54 | DATABASES={ 55 | "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}, 56 | }, 57 | SITE_ID=1, 58 | USE_TZ=True, 59 | SECRET_KEY="not very secret in tests", 60 | USE_I18N=True, 61 | LANGUAGES=[ 62 | ("en", "English"), 63 | ("es", "Spanish"), 64 | ], 65 | STATIC_URL="/static/", 66 | ROOT_URLCONF="tests.urls", 67 | TEMPLATES=[ 68 | { 69 | "BACKEND": "django.template.backends.django.DjangoTemplates", 70 | "APP_DIRS": True, 71 | "OPTIONS": { 72 | "debug": True, # We want template errors to raise 73 | "context_processors": [ 74 | "django.contrib.auth.context_processors.auth", 75 | "django.contrib.messages.context_processors.messages", 76 | "django.template.context_processors.request", 77 | "content_settings.context_processors.content_settings", 78 | ], 79 | }, 80 | }, 81 | ], 82 | MIDDLEWARE=( 83 | "django.middleware.common.CommonMiddleware", 84 | "django.contrib.sessions.middleware.SessionMiddleware", 85 | "django.middleware.locale.LocaleMiddleware", 86 | "django.contrib.auth.middleware.AuthenticationMiddleware", 87 | "content_settings.middlewares.preview_on_site", 88 | "django.contrib.messages.middleware.MessageMiddleware", 89 | ), 90 | INSTALLED_APPS=( 91 | "django.contrib.admin", 92 | "django.contrib.auth", 93 | "django.contrib.contenttypes", 94 | "django.contrib.sessions", 95 | "django.contrib.sites", 96 | "django.contrib.staticfiles", 97 | "django.contrib.messages", 98 | "content_settings", 99 | "tests.books", 100 | ), 101 | PASSWORD_HASHERS=("django.contrib.auth.hashers.MD5PasswordHasher",), 102 | **content_settings_settings, 103 | CONTENT_SETTINGS_PRECACHED_PY_VALUES=testing_precached_py_values, 104 | ) 105 | 106 | django.setup() 107 | 108 | 109 | @pytest.fixture(autouse=True) 110 | def do_reset_all_values(): 111 | from content_settings.caching import DATA as CACHE_DATA, TRIGGER 112 | 113 | CACHE_DATA.POPULATED = False 114 | CACHE_DATA.ALL_VALUES = None 115 | CACHE_DATA.ALL_RAW_VALUES = None 116 | CACHE_DATA.ALL_USER_DEFINES = None 117 | 118 | TRIGGER.last_checksum_from_cache = None 119 | 120 | from content_settings.cache_triggers import DATA as TRIGGER_DATA 121 | 122 | TRIGGER_DATA.ALL_VALUES_CHECKSUM = "" 123 | 124 | 125 | @pytest.fixture 126 | def testadmin(): 127 | return get_user_model().objects.get_or_create( 128 | username="testadmin", is_staff=True, is_superuser=True 129 | )[0] 130 | 131 | 132 | @pytest.fixture 133 | def webtest_admin(django_app_factory, testadmin): 134 | web = django_app_factory(csrf_checks=False) 135 | web.set_user(testadmin) 136 | return web 137 | 138 | 139 | @pytest.fixture 140 | def testuser(): 141 | return get_user_model().objects.get_or_create(username="testuser")[0] 142 | 143 | 144 | @pytest.fixture 145 | def webtest_user(django_app_factory, testuser): 146 | web = django_app_factory(csrf_checks=False) 147 | web.set_user(testuser) 148 | return web 149 | 150 | 151 | @pytest.fixture 152 | def teststaff(): 153 | from django.contrib.auth.models import Permission 154 | 155 | user = get_user_model().objects.get_or_create( 156 | username="teststaff", is_staff=True, is_superuser=False 157 | )[0] 158 | 159 | for codename in ("change_contentsetting", "view_contentsetting"): 160 | perm = Permission.objects.get(codename=codename) 161 | user.user_permissions.add(perm) 162 | 163 | return user 164 | 165 | 166 | @pytest.fixture 167 | def webtest_staff(django_app_factory, teststaff): 168 | web = django_app_factory(csrf_checks=False) 169 | web.set_user(teststaff) 170 | return web 171 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | from io import StringIO 4 | import os 5 | 6 | from django.core.management import call_command 7 | 8 | from content_settings.models import ContentSetting, UserPreview 9 | 10 | pytestmark = [pytest.mark.django_db(transaction=True)] 11 | 12 | 13 | def std_command(command, *args, **kwargs): 14 | out = StringIO() 15 | err = StringIO() 16 | call_command(command, *args, stdout=out, stderr=err, **kwargs) 17 | return out.getvalue(), err.getvalue() 18 | 19 | 20 | def std_import(data, *args, **kwargs): 21 | import tempfile 22 | 23 | # Create a temporary file 24 | with tempfile.NamedTemporaryFile( 25 | mode="w+", delete=False, suffix=".json" 26 | ) as tmp_file: 27 | # Write the data to the temporary file 28 | json.dump(data, tmp_file) 29 | tmp_file.flush() 30 | 31 | # Call the import command with the temporary file 32 | try: 33 | return std_command("content_settings_import", tmp_file.name, *args, **kwargs) 34 | finally: 35 | os.unlink(tmp_file.name) 36 | 37 | 38 | def test_export_all(): 39 | 40 | # Call the command 41 | out, err = std_command("content_settings_export") 42 | 43 | # Check that there's no stderr output 44 | assert not err, "Expected no stderr output" 45 | 46 | # Check that stdout is JSON serializable 47 | try: 48 | data = json.loads(out) 49 | except json.JSONDecodeError: 50 | pytest.fail("Stdout is not JSON serializable") 51 | else: 52 | assert data["settings"] 53 | 54 | 55 | def test_export_names(): 56 | out, err = std_command( 57 | "content_settings_export", "--names", "TITLE", "BOOKS_ON_HOME_PAGE" 58 | ) 59 | assert not err, "Expected no stderr output" 60 | assert json.loads(out)["settings"] == { 61 | "BOOKS_ON_HOME_PAGE": {"value": "3", "version": ""}, 62 | "TITLE": {"value": "Book Store", "version": ""}, 63 | } 64 | 65 | 66 | def test_import_one_setting(): 67 | out, err = std_import( 68 | { 69 | "settings": { 70 | "TITLE": {"value": "The New Book Store", "version": ""}, 71 | } 72 | }, 73 | ) 74 | assert not err, "Expected no stderr output" 75 | assert "Applied" in out 76 | assert ContentSetting.objects.get(name="TITLE").value == "Book Store" 77 | 78 | 79 | def test_import_one_setting_error(): 80 | out, err = std_import( 81 | { 82 | "settings": { 83 | "TITLE": {"value": "The New Book Store", "version": "2"}, 84 | } 85 | }, 86 | ) 87 | assert err 88 | assert "Applied" not in out 89 | assert ContentSetting.objects.get(name="TITLE").value == "Book Store" 90 | 91 | 92 | def test_import_one_setting_preview(teststaff): 93 | out, err = std_import( 94 | { 95 | "settings": { 96 | "TITLE": {"value": "The New Book Store", "version": ""}, 97 | } 98 | }, 99 | preview_for="teststaff", 100 | ) 101 | assert not err 102 | assert "Applied" in out 103 | assert ContentSetting.objects.get(name="TITLE").value == "Book Store" 104 | assert ( 105 | UserPreview.objects.get(user=teststaff, name="TITLE").value 106 | == "The New Book Store" 107 | ) 108 | 109 | 110 | def test_import_one_setting_confirmed(teststaff): 111 | out, err = std_import( 112 | { 113 | "settings": { 114 | "TITLE": {"value": "The New Book Store", "version": ""}, 115 | } 116 | }, 117 | "--import", 118 | ) 119 | assert not err 120 | assert "Applied" in out 121 | assert ContentSetting.objects.get(name="TITLE").value == "The New Book Store" 122 | 123 | 124 | def test_import_two_settings_one_error(): 125 | out, err = std_import( 126 | { 127 | "settings": { 128 | "TITLE": {"value": "The New Book Store", "version": "2"}, 129 | "BOOKS_ON_HOME_PAGE": {"value": "4", "version": ""}, 130 | } 131 | }, 132 | ) 133 | assert err 134 | assert "Applied" in out 135 | 136 | 137 | def test_import_two_settings_show_only_errors(): 138 | out, err = std_import( 139 | { 140 | "settings": { 141 | "TITLE": {"value": "The New Book Store", "version": "2"}, 142 | "BOOKS_ON_HOME_PAGE": {"value": "4", "version": ""}, 143 | } 144 | }, 145 | "--show-only-errors", 146 | ) 147 | assert err 148 | assert "Applied" not in out 149 | 150 | 151 | def test_import_two_settings_not_show_skipped(): 152 | out, err = std_import( 153 | { 154 | "settings": { 155 | "TITLE": {"value": "The New Book Store", "version": "2"}, 156 | "BOOKS_ON_HOME_PAGE": {"value": "3", "version": ""}, 157 | } 158 | }, 159 | ) 160 | assert err 161 | assert "Applied" not in out 162 | assert "Skipped" not in out 163 | 164 | 165 | def test_import_two_settings_show_skipped(): 166 | out, err = std_import( 167 | { 168 | "settings": { 169 | "TITLE": {"value": "The New Book Store", "version": "2"}, 170 | "BOOKS_ON_HOME_PAGE": {"value": "3", "version": ""}, 171 | } 172 | }, 173 | "--show-skipped", 174 | ) 175 | assert err 176 | assert "Applied" not in out 177 | assert "Skipped" in out 178 | 179 | 180 | def test_import_two_setting_one_confirmed(): 181 | out, err = std_import( 182 | { 183 | "settings": { 184 | "TITLE": {"value": "The New Book Store", "version": ""}, 185 | "BOOKS_ON_HOME_PAGE": {"value": "4", "version": ""}, 186 | } 187 | }, 188 | "--import", 189 | "--names", 190 | "TITLE", 191 | ) 192 | assert not err 193 | assert "Applied" in out 194 | assert ContentSetting.objects.get(name="TITLE").value == "The New Book Store" 195 | assert ContentSetting.objects.get(name="BOOKS_ON_HOME_PAGE").value == "3" 196 | -------------------------------------------------------------------------------- /tests/test_context_defaults.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from content_settings.types.basic import SimpleString 4 | from content_settings.permissions import any, none 5 | 6 | from content_settings.defaults.context import defaults 7 | from content_settings.defaults.modifiers import add_tags 8 | 9 | pytestmark = [pytest.mark.django_db(transaction=True)] 10 | 11 | 12 | def test_context_defaults(): 13 | with defaults(fetch_permission=any): 14 | assert SimpleString(fetch_permission=any).fetch_permission == any 15 | assert SimpleString().fetch_permission == any 16 | assert SimpleString(fetch_permission=none).fetch_permission == none 17 | 18 | 19 | def test_nested(): 20 | with defaults(fetch_permission=any): 21 | assert SimpleString().fetch_permission == any 22 | with defaults(fetch_permission=none): 23 | assert SimpleString().fetch_permission == none 24 | assert SimpleString(fetch_permission=any).fetch_permission == any 25 | assert SimpleString().fetch_permission == any 26 | 27 | 28 | def test_ignore_unkown_kwargs(): 29 | with defaults(unknown_kwarg="value", fetch_permission=any): 30 | assert SimpleString().fetch_permission == any 31 | assert not hasattr(SimpleString(), "unknown_kwarg") 32 | 33 | 34 | def test_add_tags_nested(): 35 | with defaults(add_tags({"main"})): 36 | assert SimpleString().tags == {"main"} 37 | 38 | with defaults(add_tags({"second"})): 39 | assert SimpleString().tags == {"main", "second"} 40 | 41 | assert SimpleString().tags == {"main"} 42 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | import pytest 3 | 4 | from content_settings.types.basic import ValidationError 5 | 6 | from content_settings.conf import content_settings 7 | from content_settings.models import ContentSetting 8 | from content_settings.caching import get_raw_value, set_populated 9 | 10 | pytestmark = [pytest.mark.django_db(transaction=True)] 11 | 12 | 13 | def test_simple_text(): 14 | assert content_settings.TITLE == "Book Store" 15 | 16 | 17 | def test_unknown_setting_name(): 18 | with pytest.raises(AttributeError): 19 | content_settings.UNKNOWN_SETTING 20 | 21 | 22 | def test_setting_simple_text(): 23 | assert content_settings.TITLE == "Book Store" 24 | 25 | 26 | def test_update_simple_text(): 27 | assert content_settings.TITLE == "Book Store" 28 | 29 | setting = ContentSetting.objects.get(name="TITLE") 30 | setting.value = "New Title" 31 | setting.save() 32 | 33 | set_populated(False) 34 | 35 | assert content_settings.TITLE == "New Title" 36 | 37 | 38 | def test_startswith(): 39 | assert content_settings.startswith__IS_ == { 40 | "IS_OPEN": True, 41 | "IS_CLOSED": False, 42 | "IS_OPEN_VALIDATED": True, 43 | } 44 | 45 | 46 | def test_withtag(): 47 | assert content_settings.withtag__GENERAL == { 48 | "TITLE": "Book Store", 49 | "DESCRIPTION": "Book Store is the best book store in the world", 50 | } 51 | 52 | 53 | def test_get_raw_value(): 54 | assert get_raw_value("TITLE") == "Book Store" 55 | assert get_raw_value("IS_OPEN") == "1" 56 | 57 | 58 | def test_assign_value(): 59 | content_settings.TITLE = "New Title" 60 | assert content_settings.TITLE == "New Title" 61 | 62 | 63 | def test_assign_value_to_wrong_version(): 64 | from content_settings.models import ContentSetting 65 | 66 | ContentSetting.objects.filter(name="TITLE").update(version="WRONG") 67 | with pytest.raises(AssertionError): 68 | content_settings.TITLE = "New Title" 69 | 70 | 71 | def test_assign_value_creates_new_setting(): 72 | from content_settings.models import ContentSetting 73 | 74 | ContentSetting.objects.filter(name="TITLE").delete() 75 | 76 | content_settings.TITLE = "New Title" 77 | 78 | assert ContentSetting.objects.get(name="TITLE").value == "New Title" 79 | assert content_settings.TITLE == "New Title" 80 | 81 | 82 | def test_assert_prefix_error(): 83 | with pytest.raises(AssertionError): 84 | content_settings.startswith__TITLE = "1" 85 | 86 | 87 | def test_assign_bool_value(): 88 | content_settings.IS_OPEN = "+" 89 | assert content_settings.IS_OPEN is True 90 | 91 | 92 | def test_assign_non_valid_value(): 93 | with pytest.raises(ValidationError): 94 | content_settings.IS_OPEN = "123" 95 | 96 | 97 | def test_to_raw_processor(): 98 | content_settings.OPEN_DATE = date(2023, 1, 1) 99 | assert content_settings.OPEN_DATE == date(2023, 1, 1) 100 | 101 | assert ContentSetting.objects.get(name="OPEN_DATE").value == "2023-01-01" 102 | 103 | 104 | def test_to_raw_processor_suffixes(): 105 | content_settings.OPEN_DATE__tuple = (2023, 1, 1) 106 | assert content_settings.OPEN_DATE == date(2023, 1, 1) 107 | 108 | assert ContentSetting.objects.get(name="OPEN_DATE").value == "2023-01-01" 109 | -------------------------------------------------------------------------------- /tests/test_json_view_value.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from content_settings.types.basic import ( 3 | SimpleString, 4 | SimpleInt, 5 | SimpleHTML, 6 | SimpleDecimal, 7 | SimpleBool, 8 | ) 9 | from content_settings.types.markup import SimpleJSON, SimpleYAML 10 | 11 | from tests.books.models import Book 12 | from tests.tools import adjust_params 13 | from tests import yaml_installed 14 | 15 | pytestmark = [pytest.mark.django_db(transaction=True)] 16 | 17 | 18 | @pytest.mark.parametrize( 19 | "var,value,initial", 20 | adjust_params( 21 | [ 22 | ( 23 | SimpleString("Hello worlds"), 24 | '"Hello worlds"', 25 | ), 26 | (SimpleHTML("

    Hello worlds

    "), '"

    Hello worlds

    "'), 27 | (SimpleInt("13"), "13"), 28 | (SimpleDecimal("1.23"), '"1.23"'), 29 | (SimpleBool("1"), "true"), 30 | (SimpleBool("0"), "false"), 31 | (SimpleJSON('{"a": 1}'), '{"a": 1}'), 32 | ] 33 | ), 34 | ) 35 | def test_value(var, value, initial): 36 | if initial: 37 | initial() 38 | in_value = var.give_python(var.default) 39 | assert var.json_view_value(in_value) == value 40 | 41 | 42 | @pytest.mark.skipif(not yaml_installed, reason="yaml is installed") 43 | def test_value_yaml(): 44 | var = SimpleYAML( 45 | """ 46 | - a: 1 47 | b: flex 48 | """ 49 | ) 50 | in_value = var.give_python(var.default) 51 | assert var.json_view_value(in_value) == '[{"a": 1, "b": "flex"}]' 52 | -------------------------------------------------------------------------------- /tests/test_lazy.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import operator 3 | import sys 4 | 5 | from content_settings.types.lazy import LazyObject 6 | 7 | 8 | @pytest.fixture 9 | def lazy_int(): 10 | return LazyObject(lambda: 5) 11 | 12 | 13 | @pytest.fixture 14 | def lazy_list(): 15 | return LazyObject(lambda: [1, 2, 3]) 16 | 17 | 18 | @pytest.fixture 19 | def lazy_string(): 20 | return LazyObject(lambda: "hello") 21 | 22 | 23 | @pytest.fixture 24 | def lazy_obj(): 25 | class Dummy: 26 | def mult5(self, val): 27 | return val * 5 28 | 29 | return LazyObject(lambda: Dummy()) 30 | 31 | 32 | @pytest.fixture 33 | def lazy_function(): 34 | return LazyObject(lambda: lambda x: x + 5) 35 | 36 | 37 | def test_getattr(lazy_obj): 38 | assert lazy_obj.mult5(2) == 10 39 | 40 | 41 | def test_bytes(lazy_int): 42 | assert bytes(lazy_int) == bytes(5) 43 | 44 | 45 | def test_str(lazy_int): 46 | assert str(lazy_int) == "5" 47 | 48 | 49 | def test_bool(lazy_int): 50 | assert bool(lazy_int) 51 | 52 | 53 | def test_dir(lazy_int): 54 | assert "real" in dir(lazy_int) 55 | 56 | 57 | def test_hash(lazy_int): 58 | assert hash(lazy_int) == hash(5) 59 | 60 | 61 | def test_class(lazy_int): 62 | assert lazy_int.__class__ == int 63 | 64 | 65 | def test_eq(lazy_int): 66 | assert lazy_int == 5 67 | 68 | 69 | def test_lt(lazy_int): 70 | assert lazy_int < 10 71 | 72 | 73 | def test_le(lazy_int): 74 | assert lazy_int <= 5 75 | 76 | 77 | def test_gt(lazy_int): 78 | assert lazy_int > 4 79 | 80 | 81 | def test_ge(lazy_int): 82 | assert lazy_int >= 5 83 | 84 | 85 | def test_ne(lazy_int): 86 | assert lazy_int != 6 87 | 88 | 89 | def test_mod(lazy_int): 90 | assert lazy_int % 2 == 1 91 | 92 | 93 | def test_getitem(lazy_list): 94 | assert lazy_list[0] == 1 95 | 96 | 97 | def test_iter(lazy_list): 98 | assert list(iter(lazy_list)) == [1, 2, 3] 99 | 100 | 101 | def test_len(lazy_list): 102 | assert len(lazy_list) == 3 103 | 104 | 105 | def test_contains(lazy_list): 106 | assert 2 in lazy_list 107 | 108 | 109 | def test_mul(lazy_int): 110 | assert lazy_int * 2 == 10 111 | 112 | 113 | def test_rmul(lazy_int): 114 | assert 2 * lazy_int == 10 115 | 116 | 117 | def test_abs(lazy_int): 118 | assert abs(lazy_int) == 5 119 | 120 | 121 | def test_add(lazy_int): 122 | assert lazy_int + 3 == 8 123 | 124 | 125 | def test_radd(lazy_int): 126 | assert 3 + lazy_int == 8 127 | 128 | 129 | def test_and(lazy_int): 130 | assert (lazy_int & 1) == (5 & 1) 131 | 132 | 133 | def test_floordiv(lazy_int): 134 | assert lazy_int // 2 == 2 135 | 136 | 137 | def test_index(lazy_int): 138 | assert operator.index(lazy_int) == 5 139 | 140 | 141 | def test_inv(lazy_int): 142 | assert ~lazy_int == ~5 143 | 144 | 145 | def test_invert(lazy_int): 146 | assert operator.invert(lazy_int) == ~5 147 | 148 | 149 | def test_matmul(lazy_list): 150 | with pytest.raises(TypeError): 151 | lazy_list @ lazy_list 152 | 153 | 154 | def test_neg(lazy_int): 155 | assert -lazy_int == -5 156 | 157 | 158 | def test_or(lazy_int): 159 | assert lazy_int | 1 == 5 | 1 160 | 161 | 162 | def test_pos(lazy_int): 163 | assert +lazy_int == +5 164 | 165 | 166 | def test_pow(lazy_int): 167 | assert lazy_int**2 == 25 168 | 169 | 170 | def test_sub(lazy_int): 171 | assert lazy_int - 2 == 3 172 | 173 | 174 | def test_truediv(lazy_int): 175 | assert lazy_int / 2 == 2.5 176 | 177 | 178 | def test_xor(lazy_int): 179 | assert lazy_int ^ 1 == 5 ^ 1 180 | 181 | 182 | def test_concat(lazy_string): 183 | assert lazy_string + " world" == "hello world" 184 | 185 | 186 | def test_next(): 187 | lazy_iter = LazyObject(lambda: iter([1, 2, 3])) 188 | assert next(lazy_iter) == 1 189 | 190 | 191 | def test_reversed(lazy_list): 192 | assert list(reversed(lazy_list)) == [3, 2, 1] 193 | 194 | 195 | def test_round(lazy_int): 196 | assert round(lazy_int) == 5 197 | 198 | 199 | def test_call(lazy_function): 200 | assert lazy_function(5) == 10 201 | 202 | 203 | def test_iadd(lazy_int): 204 | lazy_int += 3 205 | assert lazy_int == 8 206 | 207 | 208 | def test_iand(lazy_int): 209 | lazy_int &= 1 210 | assert lazy_int == 1 211 | 212 | 213 | def test_iconcat(lazy_string): 214 | lazy_string += " world" 215 | assert lazy_string == "hello world" 216 | 217 | 218 | def test_ifloordiv(lazy_int): 219 | lazy_int //= 2 220 | assert lazy_int == 2 221 | 222 | 223 | def test_ilshift(): 224 | lazy_int = LazyObject(lambda: 1) 225 | lazy_int <<= 2 226 | assert lazy_int == 4 227 | 228 | 229 | def test_imod(lazy_int): 230 | lazy_int %= 2 231 | assert lazy_int == 1 232 | 233 | 234 | def test_imul(lazy_int): 235 | lazy_int *= 2 236 | assert lazy_int == 10 237 | 238 | 239 | def test_imatmul(): 240 | with pytest.raises(TypeError): 241 | lazy_list = LazyObject(lambda: [1, 2, 3]) 242 | lazy_list @= lazy_list 243 | 244 | 245 | def test_ior(lazy_int): 246 | lazy_int |= 1 247 | assert lazy_int == 5 | 1 248 | 249 | 250 | def test_ipow(lazy_int): 251 | lazy_int **= 2 252 | assert lazy_int == 25 253 | 254 | 255 | def test_isub(lazy_int): 256 | lazy_int -= 2 257 | assert lazy_int == 3 258 | 259 | 260 | def test_itruediv(lazy_int): 261 | lazy_int /= 2 262 | assert lazy_int == 2.5 263 | 264 | 265 | def test_ixor(lazy_int): 266 | lazy_int ^= 1 267 | assert lazy_int == 4 268 | -------------------------------------------------------------------------------- /tests/test_migrate_db.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from content_settings.conf import set_initial_values_for_db 4 | from content_settings.models import ContentSetting 5 | from tests import testing_settings_full 6 | 7 | pytestmark = [pytest.mark.django_db(transaction=True)] 8 | 9 | 10 | def test_init(): 11 | assert set_initial_values_for_db() == [] 12 | assert not ContentSetting.objects.filter(name="AUTHOR").exists() 13 | 14 | 15 | def test_create_value(): 16 | ContentSetting.objects.filter(name="TITLE").delete() 17 | assert set_initial_values_for_db() == [("TITLE", "create")] 18 | 19 | 20 | def test_created_constant(): 21 | ContentSetting.objects.create(name="AUTHOR", value="John Doe") 22 | assert set_initial_values_for_db() == [("AUTHOR", "delete")] 23 | 24 | 25 | def test_created_unkown(): 26 | ContentSetting.objects.create(name="UNKWONW", value="John Doe") 27 | assert set_initial_values_for_db() == [("UNKWONW", "delete")] 28 | 29 | 30 | def test_update_version_value(): 31 | cs = ContentSetting.objects.get(name="TITLE") 32 | cs.value = "old value" 33 | cs.version += "2" 34 | cs.save() 35 | 36 | assert set_initial_values_for_db(apply=True) == [("TITLE", "update")] 37 | 38 | cs.refresh_from_db() 39 | assert cs.value == "Book Store" 40 | 41 | 42 | def test_update_tags_only(): 43 | ContentSetting.objects.filter(name="TITLE").update(tags="newtag", value="old value") 44 | 45 | set_initial_values_for_db(apply=True) == [("TITLE", "adjust")] 46 | 47 | cs = ContentSetting.objects.get(name="TITLE") 48 | assert cs.value == "old value" 49 | if testing_settings_full: 50 | assert cs.tags == "changed\ngeneral\ntests.books" 51 | else: 52 | assert cs.tags == "general" 53 | 54 | 55 | def test_overwrite_user_defined_allowed_without_version_change(): 56 | ContentSetting.objects.filter(name="TITLE").update( 57 | user_defined_type="line", value="WOW Store" 58 | ) 59 | 60 | assert set_initial_values_for_db(apply=True) == [("TITLE", "adjust")] 61 | 62 | cs = ContentSetting.objects.get(name="TITLE") 63 | assert not cs.user_defined_type 64 | assert cs.value == "WOW Store" 65 | 66 | 67 | def test_overwrite_user_defined_allowed_with_version_change(): 68 | ContentSetting.objects.filter(name="TITLE").update( 69 | user_defined_type="line", 70 | value="WOW Store", 71 | version="OLD", 72 | ) 73 | 74 | assert set_initial_values_for_db(apply=True) == [("TITLE", "update")] 75 | 76 | cs = ContentSetting.objects.get(name="TITLE") 77 | assert not cs.user_defined_type 78 | assert cs.value == "Book Store" 79 | 80 | 81 | def test_overwrite_user_defined_not_allowed(): 82 | ContentSetting.objects.filter(name="DESCRIPTION").update( 83 | user_defined_type="line", value="WOW Store" 84 | ) 85 | 86 | with pytest.raises(AssertionError): 87 | set_initial_values_for_db(apply=True) 88 | -------------------------------------------------------------------------------- /tests/test_types_array.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.core.exceptions import ValidationError 4 | 5 | from content_settings.types.basic import SimpleInt 6 | from content_settings.types.array import ( 7 | SimpleStringsList, 8 | TypedStringsList, 9 | ) 10 | 11 | pytestmark = [pytest.mark.django_db] 12 | 13 | 14 | def test_simple_list(): 15 | var = SimpleStringsList() 16 | 17 | assert ( 18 | var.give_python( 19 | """ 20 | When I die, then bury me 21 | In my beloved Ukraine, 22 | My tomb upon a grave mound high 23 | Amid the spreading plain, 24 | So that the fields, the boundless steppes, 25 | The Dnieper's plunging shore 26 | My eyes could see, my ears could hear 27 | The mighty river roar. 28 | """ 29 | ) 30 | == [ 31 | "When I die, then bury me", 32 | "In my beloved Ukraine,", 33 | "My tomb upon a grave mound high", 34 | "Amid the spreading plain,", 35 | "So that the fields, the boundless steppes,", 36 | "The Dnieper's plunging shore", 37 | "My eyes could see, my ears could hear", 38 | "The mighty river roar.", 39 | ] 40 | ) 41 | 42 | 43 | def test_simple_list_comment(): 44 | var = SimpleStringsList() 45 | 46 | assert ( 47 | var.give_python( 48 | """ 49 | When I die, then bury me 50 | # In my beloved Ukraine, 51 | # My tomb upon a grave mound high 52 | # Amid the spreading plain, 53 | So that the fields, the boundless steppes, 54 | The Dnieper's plunging shore 55 | My eyes could see, my ears could hear 56 | The mighty river roar. 57 | """ 58 | ) 59 | == [ 60 | "When I die, then bury me", 61 | "So that the fields, the boundless steppes,", 62 | "The Dnieper's plunging shore", 63 | "My eyes could see, my ears could hear", 64 | "The mighty river roar.", 65 | ] 66 | ) 67 | 68 | 69 | def test_simple_list_window_new_line(): 70 | var = SimpleStringsList() 71 | 72 | assert var.give_python("windows\r\nlinebreaks") == ["windows", "linebreaks"] 73 | 74 | 75 | def test_simple_list_comment_starts_with(): 76 | var = SimpleStringsList(comment_starts_with="//") 77 | 78 | assert ( 79 | var.give_python( 80 | """ 81 | When I die, then bury me 82 | In my beloved Ukraine, 83 | // My tomb upon a grave mound high 84 | Amid the spreading plain, 85 | So that the fields, the boundless steppes, 86 | The Dnieper's plunging shore 87 | My eyes could see, my ears could hear 88 | The mighty river roar. 89 | """ 90 | ) 91 | == [ 92 | "When I die, then bury me", 93 | "In my beloved Ukraine,", 94 | "Amid the spreading plain,", 95 | "So that the fields, the boundless steppes,", 96 | "The Dnieper's plunging shore", 97 | "My eyes could see, my ears could hear", 98 | "The mighty river roar.", 99 | ] 100 | ) 101 | 102 | 103 | class SimpleIntsList(TypedStringsList): 104 | line_type = SimpleInt() 105 | 106 | 107 | def test_typed_list(): 108 | var = SimpleIntsList() 109 | 110 | assert ( 111 | var.give_python( 112 | """ 113 | 1 114 | 2 115 | 3 116 | 117 | """ 118 | ) 119 | == [1, 2, 3] 120 | ) 121 | 122 | 123 | def test_typed_list_with_comment(): 124 | var = SimpleIntsList() 125 | 126 | assert ( 127 | var.give_python( 128 | """ 129 | 1 130 | #2 131 | 3 132 | 133 | """ 134 | ) 135 | == [1, 3] 136 | ) 137 | 138 | 139 | def test_typed_list_validate(): 140 | var = SimpleIntsList() 141 | 142 | with pytest.raises(ValidationError) as error: 143 | var.validate_value( 144 | """ 145 | 1 146 | 2 147 | 3 148 | a 149 | """ 150 | ) 151 | assert error.value.message == "item #4: Enter a whole number." 152 | 153 | 154 | @pytest.mark.parametrize("cs_type", [SimpleStringsList, SimpleIntsList]) 155 | def test_empty_value(cs_type): 156 | var = cs_type() 157 | var.validate_value("") 158 | 159 | 160 | @pytest.mark.parametrize("cs_type", [SimpleStringsList, SimpleIntsList]) 161 | def test_empty_value_list(cs_type): 162 | var = cs_type() 163 | assert var.give_python("") == [] 164 | -------------------------------------------------------------------------------- /tests/test_types_datetime.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from datetime import datetime, date, time, timedelta 3 | 4 | from django.core.exceptions import ValidationError 5 | 6 | from content_settings.types.datetime import ( 7 | DateTimeString, 8 | DateString, 9 | TimeString, 10 | SimpleTimedelta, 11 | ) 12 | 13 | pytestmark = [pytest.mark.django_db] 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "cs_type", 18 | [ 19 | DateTimeString, 20 | DateString, 21 | TimeString, 22 | SimpleTimedelta, 23 | ], 24 | ) 25 | def test_empty_value(cs_type): 26 | var = cs_type() 27 | var.validate_value("") 28 | 29 | 30 | @pytest.mark.parametrize( 31 | "cs_type", 32 | [ 33 | DateTimeString, 34 | DateString, 35 | TimeString, 36 | SimpleTimedelta, 37 | ], 38 | ) 39 | def test_empty_value_none(cs_type): 40 | var = cs_type() 41 | assert var.give_python("") is None 42 | 43 | 44 | def test_datetime(): 45 | var = DateTimeString() 46 | 47 | assert var.give_python("2020-01-01 00:00:00").replace(tzinfo=None) == datetime( 48 | 2020, 1, 1 49 | ) 50 | 51 | 52 | def test_datetime_empty_is_none(): 53 | var = DateTimeString() 54 | 55 | assert var.give_python("") is None 56 | 57 | 58 | def test_date(): 59 | var = DateString() 60 | 61 | assert var.give_python("2020-01-01") == date(2020, 1, 1) 62 | assert var.give_python("") is None 63 | 64 | 65 | def test_date_input_format(): 66 | var = DateString("", date_formats="%d/%m/%Y") 67 | 68 | assert var.give_python("03/01/2020") == date(2020, 1, 3) 69 | 70 | with pytest.raises(ValidationError): 71 | var.give_python("2020-01-03") 72 | 73 | 74 | def test_time(): 75 | var = TimeString() 76 | 77 | assert var.give_python("00:00:00") == time(0, 0, 0) 78 | assert var.give_python("03:30") == time(3, 30) 79 | 80 | 81 | def test_timedelta(): 82 | var = SimpleTimedelta() 83 | 84 | assert var.give_python("1d") == timedelta(days=1) 85 | assert var.give_python("1d 2h") == timedelta(days=1, hours=2) 86 | -------------------------------------------------------------------------------- /tests/test_unit_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from content_settings.utils import class_names, call_base_str 4 | from content_settings.types.basic import SimpleString, SimpleInt 5 | from content_settings.types.datetime import TimeString 6 | from content_settings import functools 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "cls, expected", 11 | [ 12 | pytest.param( 13 | SimpleString, 14 | [("content_settings.types.basic", "SimpleString")], 15 | id="SimpleString", 16 | ), 17 | pytest.param( 18 | SimpleInt, [("content_settings.types.basic", "SimpleInt")], id="SimpleInt" 19 | ), 20 | pytest.param( 21 | TimeString, 22 | [ 23 | ("content_settings.types.datetime", "TimeString"), 24 | ("content_settings.types.datetime", "DateTimeString"), 25 | ("content_settings.types.datetime", "ProcessInputFormats"), 26 | ("content_settings.types.mixins", "EmptyNoneMixin"), 27 | ], 28 | id="TimeString", 29 | ), 30 | ], 31 | ) 32 | def test_class_names(cls, expected): 33 | assert list(class_names(cls)) == expected 34 | 35 | 36 | def my_odd(x: int) -> bool: 37 | return x % 2 == 1 38 | 39 | 40 | def my_even(x: int) -> bool: 41 | return x % 2 == 0 42 | 43 | 44 | def my_false(x: int) -> bool: 45 | return False 46 | 47 | 48 | def my_true(x: int) -> bool: 49 | return True 50 | 51 | 52 | @pytest.mark.parametrize( 53 | "base, func, value, expected", 54 | [ 55 | pytest.param( 56 | "tests.test_unit_utils", "my_odd", 5, True, id="base_string_func_string" 57 | ), 58 | pytest.param( 59 | None, "tests.test_unit_utils.my_odd", 5, True, id="base_none_func_string" 60 | ), 61 | pytest.param(None, my_odd, 5, True, id="base_none_func_callable"), 62 | pytest.param( 63 | "tests.test_unit_utils", 64 | functools.not_("my_odd"), 65 | 5, 66 | False, 67 | id="base_string_not_func_string", 68 | ), 69 | pytest.param( 70 | "tests.test_unit_utils", 71 | functools.or_("my_even", "my_false"), 72 | 5, 73 | False, 74 | id="base_string_or_func_string", 75 | ), 76 | pytest.param( 77 | "tests.test_unit_utils", 78 | functools.or_("my_even", "my_false", my_true), 79 | 5, 80 | True, 81 | id="base_string_or_func_string_and_ref", 82 | ), 83 | ], 84 | ) 85 | def test_call_base_str_odd(base, func, value, expected): 86 | assert call_base_str(func, value, call_base=base) == expected 87 | -------------------------------------------------------------------------------- /tests/test_units.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from content_settings.conf import split_attr, SplitFormatError 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "line,val", 8 | [ 9 | ("TITLE", (None, "TITLE", None)), 10 | ("type__TITLE", ("type", "TITLE", None)), 11 | ("type__TITLE", ("type", "TITLE", None)), 12 | ("type__TITLE_SUBTITLE", ("type", "TITLE_SUBTITLE", None)), 13 | ("type__TITLE__SUBTITLE", ("type", "TITLE__SUBTITLE", None)), 14 | ("TITLE__SUBTITLE", (None, "TITLE__SUBTITLE", None)), 15 | ("TITLE__SUBTITLE__name", (None, "TITLE__SUBTITLE", "name")), 16 | ("TITLE__SUBTITLE__name__upper", (None, "TITLE__SUBTITLE", "name__upper")), 17 | ( 18 | "type__TITLE__SUBTITLE__name__upper", 19 | ("type", "TITLE__SUBTITLE", "name__upper"), 20 | ), 21 | ], 22 | ) 23 | def test_split_attr(line, val): 24 | assert split_attr(line) == val 25 | 26 | 27 | @pytest.mark.parametrize( 28 | "line,msg", 29 | [ 30 | ("title", "Invalid attribute name: title; name should be uppercase"), 31 | ( 32 | "lazy__title", 33 | "Invalid attribute name: title in lazy__title; name should be uppercase", 34 | ), 35 | ( 36 | "unknown__TITLE", 37 | "Invalid attribute name: unknown in unknown__TITLE; name should be uppercase or it is unknown prefix", 38 | ), 39 | ( 40 | "TITLE__hi__NO", 41 | "Invalid attribute name: hi__NO in TITLE__hi__NO; suffix should be lowercase", 42 | ), 43 | ], 44 | ) 45 | def test_split_attr_error(line, msg): 46 | with pytest.raises(SplitFormatError) as e: 47 | split_attr(line) 48 | assert str(e.value) == msg 49 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from content_settings.utils import remove_same_ident 2 | 3 | 4 | def test_remove_same_ident_ident_is_not_removed(): 5 | assert remove_same_ident("a\n b\n c\n d") == "a\n b\n c\n d" 6 | 7 | 8 | def test_remove_same_ident_ident_is_removed(): 9 | assert ( 10 | remove_same_ident(" b\n c\n d\n e") == "b\n c\n d\n e" 11 | ) 12 | 13 | 14 | def test_remove_same_ident_ident_is_removed_with_windows_newlines(): 15 | assert ( 16 | remove_same_ident(" b\r\n c\r\n d\r\n e") 17 | == "b\r\n c\r\n d\r\n e" 18 | ) 19 | 20 | 21 | def test_remove_same_ident_empty_string(): 22 | assert remove_same_ident("") == "" 23 | 24 | 25 | def test_remove_same_ident_only_spaces(): 26 | assert remove_same_ident(" ") == " " 27 | 28 | 29 | def test_remove_same_ident_only_spaces_with_newlines(): 30 | assert ( 31 | remove_same_ident("\n \n \n \n \n") 32 | == "\n \n \n \n \n" 33 | ) 34 | 35 | 36 | def test_remove_same_ident_with_multiline_str(): 37 | assert ( 38 | remove_same_ident( 39 | """ 40 | a 41 | b 42 | c 43 | d 44 | """ 45 | ) 46 | == """ 47 | a 48 | b 49 | c 50 | d 51 | """ 52 | ) 53 | -------------------------------------------------------------------------------- /tests/tools.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def adjust_params(params): 5 | def _(): 6 | for v in params: 7 | if len(v) == 3: 8 | yield v 9 | else: 10 | yield v + (None,) 11 | 12 | return list(_()) 13 | 14 | 15 | def adjust_id_params(params): 16 | def _(): 17 | for v in params: 18 | id, v = v[0], v[1:] 19 | if len(v) == 3: 20 | yield pytest.param(*v, id=id) 21 | else: 22 | yield pytest.param(*v, None, id=id) 23 | 24 | return list(_()) 25 | 26 | 27 | def extract_messages(resp): 28 | return [m.message for m in resp.context["messages"]] 29 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | path("admin/", admin.site.urls), 6 | path("books/", include("tests.books.urls")), 7 | ] 8 | --------------------------------------------------------------------------------