├── .coveragerc ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .readthedocs.yaml ├── CONTRIBUTORS.txt ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── conf.py ├── contributing.rst ├── index.rst ├── installation.rst ├── ref │ └── restraint.rst ├── release_notes.rst ├── setup.rst ├── toc.rst └── usage.rst ├── manage.py ├── publish.py ├── requirements ├── docs.txt ├── requirements-testing.txt └── requirements.txt ├── restraint ├── __init__.py ├── apps.py ├── constants.py ├── core.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── update_restraint_db.py ├── managers.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_permset_is_private.py │ ├── 0003_auto_20230301_0109.py │ └── __init__.py ├── models.py ├── signals.py ├── tests │ ├── __init__.py │ ├── commands_tests.py │ ├── configuration.py │ ├── core_tests.py │ └── models_tests.py ├── urls.py └── version.py ├── run_tests.py ├── settings.py ├── setup.cfg └── setup.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | restraint/migrations/* 5 | restraint/version.py 6 | source = restraint 7 | [report] 8 | exclude_lines = 9 | # Have to re-enable the standard pragma 10 | pragma: no cover 11 | 12 | # Don't complain if tests don't hit defensive assertion code: 13 | raise NotImplementedError 14 | fail_under = 100 15 | show_missing = 1 16 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # copied from django-cte 2 | name: restraint tests 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master,develop] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python: ['3.7', '3.8', '3.9'] 16 | # Time to switch to pytest or nose2?? 17 | # nosetests is broken on 3.10 18 | # AttributeError: module 'collections' has no attribute 'Callable' 19 | # https://github.com/nose-devs/nose/issues/1099 20 | django: 21 | - 'Django~=3.2.0' 22 | - 'Django~=4.0.0' 23 | - 'Django~=4.1.0' 24 | - 'Django~=4.2.0' 25 | psycopg: 26 | - 'psycopg2==2.9.6' 27 | - 'psycopg==3.1.10' 28 | experimental: [false] 29 | exclude: 30 | - python: '3.7' 31 | django: 'Django~=4.0.0' 32 | - python: '3.7' 33 | django: 'Django~=4.1.0' 34 | - python: '3.7' 35 | django: 'Django~=4.2.0' 36 | - psycopg: 'psycopg==3.1.10' 37 | django: 'Django~=3.2.0' 38 | - psycopg: 'psycopg==3.1.10' 39 | django: 'Django~=4.0.0' 40 | - psycopg: 'psycopg==3.1.10' 41 | django: 'Django~=4.1.0' 42 | services: 43 | postgres: 44 | image: postgres:latest 45 | env: 46 | POSTGRES_DB: postgres 47 | POSTGRES_PASSWORD: postgres 48 | POSTGRES_USER: postgres 49 | ports: 50 | - 5432:5432 51 | options: >- 52 | --health-cmd pg_isready 53 | --health-interval 10s 54 | --health-timeout 5s 55 | --health-retries 5 56 | steps: 57 | - uses: actions/checkout@v3 58 | - uses: actions/setup-python@v3 59 | with: 60 | python-version: ${{ matrix.python }} 61 | - name: Setup 62 | run: | 63 | python --version 64 | pip install --upgrade pip wheel 65 | pip install -r requirements/requirements.txt 66 | pip install -r requirements/requirements-testing.txt 67 | pip install "${{ matrix.django }}" 68 | pip install "${{ matrix.psycopg }}" 69 | pip freeze 70 | - name: Run tests 71 | env: 72 | DB_SETTINGS: >- 73 | { 74 | "ENGINE":"django.db.backends.postgresql", 75 | "NAME":"restraint", 76 | "USER":"postgres", 77 | "PASSWORD":"postgres", 78 | "HOST":"localhost", 79 | "PORT":"5432" 80 | } 81 | run: | 82 | coverage run manage.py test restraint 83 | coverage report --fail-under=98 84 | continue-on-error: ${{ matrix.experimental }} 85 | - name: Check style 86 | run: flake8 restraint 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled python files 2 | *.pyc 3 | 4 | # Vim files 5 | *.swp 6 | *.swo 7 | 8 | # Coverage files 9 | .coverage 10 | htmlcov/ 11 | 12 | # Setuptools distribution folder. 13 | /dist/ 14 | /build/ 15 | 16 | # Python egg metadata, regenerated from source files by setuptools. 17 | /*.egg-info 18 | /*.egg 19 | 20 | # Virtualenv 21 | env/ 22 | venv/ 23 | 24 | # OSX 25 | .DS_Store 26 | 27 | # Pycharm 28 | .idea/ 29 | 30 | # Documentation artifacts 31 | docs/_build/ 32 | 33 | # IPython Notebook 34 | .ipynb_checkpoints/ 35 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.8" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: requirements/docs.txt 14 | - requirements: requirements/requirements.txt 15 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | Wes Kendall (wes.kendall@ambition.com) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Ambition 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | recursive-include requirements * 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Full documentation is available at http://django-restraint.readthedocs.org 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " epub to make an epub" 33 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 34 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 35 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 36 | @echo " text to make text files" 37 | @echo " man to make manual pages" 38 | @echo " texinfo to make Texinfo files" 39 | @echo " info to make Texinfo files and run them through makeinfo" 40 | @echo " gettext to make PO message catalogs" 41 | @echo " changes to make an overview of all changed/added/deprecated items" 42 | @echo " xml to make Docutils-native XML files" 43 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 44 | @echo " linkcheck to check all external links for integrity" 45 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 46 | 47 | clean: 48 | rm -rf $(BUILDDIR)/* 49 | 50 | html: 51 | $(SPHINXBUILD) -W -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 52 | @echo 53 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 54 | 55 | dirhtml: 56 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 59 | 60 | singlehtml: 61 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 62 | @echo 63 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 64 | 65 | pickle: 66 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 67 | @echo 68 | @echo "Build finished; now you can process the pickle files." 69 | 70 | json: 71 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 72 | @echo 73 | @echo "Build finished; now you can process the JSON files." 74 | 75 | htmlhelp: 76 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 77 | @echo 78 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 79 | ".hhp project file in $(BUILDDIR)/htmlhelp." 80 | 81 | epub: 82 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 83 | @echo 84 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 85 | 86 | latex: 87 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 88 | @echo 89 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 90 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 91 | "(use \`make latexpdf' here to do that automatically)." 92 | 93 | latexpdf: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo "Running LaTeX files through pdflatex..." 96 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 97 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 98 | 99 | latexpdfja: 100 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 101 | @echo "Running LaTeX files through platex and dvipdfmx..." 102 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 103 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 104 | 105 | text: 106 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 107 | @echo 108 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 109 | 110 | man: 111 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 112 | @echo 113 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 114 | 115 | texinfo: 116 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 117 | @echo 118 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 119 | @echo "Run \`make' in that directory to run these through makeinfo" \ 120 | "(use \`make info' here to do that automatically)." 121 | 122 | info: 123 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 124 | @echo "Running Texinfo files through makeinfo..." 125 | make -C $(BUILDDIR)/texinfo info 126 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 127 | 128 | gettext: 129 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 130 | @echo 131 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 132 | 133 | changes: 134 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 135 | @echo 136 | @echo "The overview file is in $(BUILDDIR)/changes." 137 | 138 | linkcheck: 139 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 140 | @echo 141 | @echo "Link check complete; look for any errors in the above output " \ 142 | "or in $(BUILDDIR)/linkcheck/output.txt." 143 | 144 | doctest: 145 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 146 | @echo "Testing of doctests in the sources finished, look at the " \ 147 | "results in $(BUILDDIR)/doctest/output.txt." 148 | 149 | xml: 150 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 151 | @echo 152 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 153 | 154 | pseudoxml: 155 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 156 | @echo 157 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 158 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-restraint documentation build configuration file 4 | import inspect 5 | import os 6 | import re 7 | 8 | # -- Django configuration ------------------------------------------------- 9 | import sys 10 | sys.path.insert(0, os.path.abspath('..')) 11 | from settings import configure_settings 12 | configure_settings() 13 | 14 | 15 | PY2 = sys.version_info[0] == 2 16 | if PY2: 17 | from django.utils.encoding import force_unicode 18 | else: 19 | def force_unicode(str): 20 | return str 21 | 22 | from django.utils.html import strip_tags 23 | 24 | 25 | def get_version(): 26 | """ 27 | Extracts the version number from the version.py file. 28 | """ 29 | VERSION_FILE = '../restraint/version.py' 30 | mo = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', open(VERSION_FILE, 'rt').read(), re.M) 31 | if mo: 32 | return mo.group(1) 33 | else: 34 | raise RuntimeError('Unable to find version string in {0}.'.format(VERSION_FILE)) 35 | 36 | # If extensions (or modules to document with autodoc) are in another directory, 37 | # add these directories to sys.path here. If the directory is relative to the 38 | # documentation root, use os.path.abspath to make it absolute, like shown here. 39 | #sys.path.insert(0, os.path.abspath('.')) 40 | 41 | # -- General configuration ------------------------------------------------ 42 | 43 | extensions = [ 44 | 'sphinx.ext.autodoc', 45 | 'sphinx.ext.intersphinx', 46 | #'sphinx.ext.viewcode', 47 | ] 48 | 49 | # Add any paths that contain templates here, relative to this directory. 50 | templates_path = ['_templates'] 51 | 52 | # The suffix of source filenames. 53 | source_suffix = '.rst' 54 | 55 | # The master toctree document. 56 | master_doc = 'toc' 57 | 58 | # General information about the project. 59 | project = u'restraint' 60 | copyright = u'2015, Ambition Inc.' 61 | 62 | # The short X.Y version. 63 | version = get_version() 64 | # The full version, including alpha/beta/rc tags. 65 | release = version 66 | 67 | exclude_patterns = ['_build'] 68 | 69 | # The name of the Pygments (syntax highlighting) style to use. 70 | pygments_style = 'sphinx' 71 | 72 | intersphinx_mapping = { 73 | 'python': ('http://python.readthedocs.org/en/v2.7.2/', None), 74 | 'django': ('http://django.readthedocs.org/en/latest/', None), 75 | #'celery': ('http://celery.readthedocs.org/en/latest/', None), 76 | } 77 | 78 | # -- Options for HTML output ---------------------------------------------- 79 | 80 | html_theme = 'default' 81 | #html_theme_path = [] 82 | 83 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 84 | if not on_rtd: # only import and set the theme if we're building docs locally 85 | import sphinx_rtd_theme 86 | html_theme = 'sphinx_rtd_theme' 87 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 88 | 89 | # Add any paths that contain custom static files (such as style sheets) here, 90 | # relative to this directory. They are copied after the builtin static files, 91 | # so a file named "default.css" will overwrite the builtin "default.css". 92 | # html_static_path = ['_static'] 93 | 94 | # Custom sidebar templates, maps document names to template names. 95 | #html_sidebars = {} 96 | 97 | # Additional templates that should be rendered to pages, maps page names to 98 | # template names. 99 | #html_additional_pages = {} 100 | 101 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 102 | html_show_sphinx = False 103 | 104 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 105 | html_show_copyright = True 106 | 107 | # Output file base name for HTML help builder. 108 | htmlhelp_basename = 'django-restraintdoc' 109 | 110 | 111 | # -- Options for LaTeX output --------------------------------------------- 112 | 113 | latex_elements = { 114 | # The paper size ('letterpaper' or 'a4paper'). 115 | #'papersize': 'letterpaper', 116 | 117 | # The font size ('10pt', '11pt' or '12pt'). 118 | #'pointsize': '10pt', 119 | 120 | # Additional stuff for the LaTeX preamble. 121 | #'preamble': '', 122 | } 123 | 124 | # Grouping the document tree into LaTeX files. List of tuples 125 | # (source start file, target name, title, 126 | # author, documentclass [howto, manual, or own class]). 127 | latex_documents = [ 128 | ('index', 'django-restraint.tex', u'django-restraint Documentation', 129 | u'Wes Kendall', 'manual'), 130 | ] 131 | 132 | # -- Options for manual page output --------------------------------------- 133 | 134 | # One entry per manual page. List of tuples 135 | # (source start file, name, description, authors, manual section). 136 | man_pages = [ 137 | ('index', 'django-restraint', u'django-restraint Documentation', 138 | [u'Wes Kendall'], 1) 139 | ] 140 | 141 | # -- Options for Texinfo output ------------------------------------------- 142 | 143 | # Grouping the document tree into Texinfo files. List of tuples 144 | # (source start file, target name, title, author, 145 | # dir menu entry, description, category) 146 | texinfo_documents = [ 147 | ('index', 'django-restraint', u'django-restraint Documentation', 148 | u'Wes Kendall', 'django-restraint', 'A short description', 149 | 'Miscellaneous'), 150 | ] 151 | 152 | 153 | def process_django_model_docstring(app, what, name, obj, options, lines): 154 | """ 155 | Does special processing for django model docstrings, making docs for 156 | fields in the model. 157 | """ 158 | # This causes import errors if left outside the function 159 | from django.db import models 160 | 161 | # Only look at objects that inherit from Django's base model class 162 | if inspect.isclass(obj) and issubclass(obj, models.Model): 163 | # Grab the field list from the meta class 164 | fields = obj._meta.fields 165 | 166 | for field in fields: 167 | # Decode and strip any html out of the field's help text 168 | help_text = strip_tags(force_unicode(field.help_text)) 169 | 170 | # Decode and capitalize the verbose name, for use if there isn't 171 | # any help text 172 | verbose_name = force_unicode(field.verbose_name).capitalize() 173 | 174 | if help_text: 175 | # Add the model field to the end of the docstring as a param 176 | # using the help text as the description 177 | lines.append(u':param %s: %s' % (field.attname, help_text)) 178 | else: 179 | # Add the model field to the end of the docstring as a param 180 | # using the verbose name as the description 181 | lines.append(u':param %s: %s' % (field.attname, verbose_name)) 182 | 183 | # Add the field's type to the docstring 184 | lines.append(u':type %s: %s' % (field.attname, type(field).__name__)) 185 | 186 | # Return the extended docstring 187 | return lines 188 | 189 | 190 | def setup(app): 191 | # Register the docstring processor with sphinx 192 | app.connect('autodoc-process-docstring', process_django_model_docstring) -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Contributions and issues are most welcome! All issues and pull requests are 5 | handled through github on the `ambitioninc repository`_. Also, please check for 6 | any existing issues before filing a new one. If you have a great idea but it 7 | involves big changes, please file a ticket before making a pull request! We 8 | want to make sure you don't spend your time coding something that might not fit 9 | the scope of the project. 10 | 11 | .. _ambitioninc repository: https://github.com/ambitioninc/django-restraint/issues 12 | 13 | Running the tests 14 | ----------------- 15 | 16 | To get the source source code and run the unit tests, run:: 17 | 18 | git clone git://github.com/ambitioninc/django-restraint.git 19 | cd django-restraint 20 | virtualenv env 21 | . env/bin/activate 22 | python setup.py install 23 | coverage run setup.py test 24 | coverage report --fail-under=100 25 | 26 | While 100% code coverage does not make a library bug-free, it significantly 27 | reduces the number of easily caught bugs! Please make sure coverage is at 100% 28 | before submitting a pull request! 29 | 30 | Code Quality 31 | ------------ 32 | 33 | For code quality, please run flake8:: 34 | 35 | pip install flake8 36 | flake8 . 37 | 38 | Code Styling 39 | ------------ 40 | Please arrange imports with the following style 41 | 42 | .. code-block:: python 43 | 44 | # Standard library imports 45 | import os 46 | 47 | # Third party package imports 48 | from mock import patch 49 | from django.conf import settings 50 | 51 | # Local package imports 52 | from restraint.version import __version__ 53 | 54 | Please follow `Google's python style`_ guide wherever possible. 55 | 56 | .. _Google's python style: http://google-styleguide.googlecode.com/svn/trunk/pyguide.html 57 | 58 | Building the docs 59 | ----------------- 60 | 61 | When in the project directory:: 62 | 63 | pip install -r requirements/docs.txt 64 | python setup.py build_sphinx 65 | open docs/_build/html/index.html 66 | 67 | Release Checklist 68 | ----------------- 69 | 70 | Before a new release, please go through the following checklist: 71 | 72 | * Bump version in restraint/version.py 73 | * Add a release note in docs/release_notes.rst 74 | * Git tag the version 75 | * Upload to pypi:: 76 | 77 | pip install wheel 78 | python setup.py sdist bdist_wheel upload 79 | 80 | Vulnerability Reporting 81 | ----------------------- 82 | 83 | For any security issues, please do NOT file an issue or pull request on github! 84 | Please contact `security@ambition.com`_ with the GPG key provided on `Ambition's 85 | website`_. 86 | 87 | .. _security@ambition.com: mailto:security@ambition.com 88 | .. _Ambition's website: http://ambition.com/security/ 89 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Django-restraint Documentation 2 | ============================== 3 | Restraint is a dynamic object-level permission system for Django. It allows permissions to be added to sets of individuals or individuals themselves. An overview of Django restraint is below, followed by :doc:`Installation`, :doc:`Setup`, :doc:`Usage`, 4 | and :doc:`API documentation ` 5 | 6 | Overview 7 | -------- 8 | The key purposes of Restraint are: 9 | 10 | 1. To provide a high-performance method of obtaining permissions over various object levels. 11 | 2. To prevent the user from having to sync permission objects while the properties of their accounts change. In other words, all permissions are determined dynamically. 12 | 13 | The above is performed by registering a Restraint configuration, which details all of the permission sets and permissions / permission levels that are defined for those sets. 14 | 15 | The terms mentioned above are defined as follows: 16 | 17 | * Permission - A permission is an action that can be performed. 18 | * Permission level - A permission level is a level of control over that action. Even boolean permissions, i.e. those that are on and off, always have a global control level set on them. 19 | * Permission set - A permission set is a grouping of permission levels. Think of Django users belonging to groups that have permissions assigned to them. The concept is similar. 20 | 21 | How are permission sets of users determined? Dynamically. A function is registered in the Restraint configuration that accepts a user and returns a list of strings that constitute their permission sets. These strings could be determined from fields on the model or even group relationships of users. Note that a user can be any model. 22 | 23 | How do I figure out the permissions of a user? Dynamically. You instantiate a `Restraint` object with the user and the permissions you are interested in obtaining. The `Restraint` object can then easily tell you if a user has certain permissions and limit querysets by the permissions they own. 24 | 25 | Maybe I don't want to assign permissions to sets. How can I assign permissions to individual users? You can use various model manager methods in Restraint to accomplish this. 26 | 27 | How can I get started using this? 28 | 29 | 1. Read the :doc:`Setup Documentation` first. 30 | 2. Read the :doc:`Usage Documentation` next. 31 | 32 | 33 | Requirements 34 | ------------ 35 | Django restraint currently only supports Django 1.6, although it can easily be upgraded to support more versions than the original user of the code. 36 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | To install the latest release, type:: 5 | 6 | pip install django-restraint 7 | 8 | To install the latest code directly from source, type:: 9 | 10 | pip install git+git://github.com/ambitioninc/django-restraint.git -------------------------------------------------------------------------------- /docs/ref/restraint.rst: -------------------------------------------------------------------------------- 1 | .. _ref-restraint: 2 | 3 | Code Documentation 4 | ================== 5 | 6 | restraint 7 | --------- 8 | 9 | .. automodule:: restraint 10 | .. autoclass:: restraint.Restraint 11 | :members: 12 | 13 | .. automethod:: __init__ -------------------------------------------------------------------------------- /docs/release_notes.rst: -------------------------------------------------------------------------------- 1 | Release Notes 2 | ============= 3 | 4 | v2.3.1 5 | ------ 6 | * Read the Docs config file v2 7 | * Github actions for testing psycopg3 8 | 9 | v2.3.0 10 | ------- 11 | * Django 3.2 & 4.0 support 12 | * Django <3.2 dropped 13 | * Switched testing to github actions 14 | 15 | v2.2.0 16 | ------ 17 | * Added `restraint_db_updated` signal that is called when the restraint database is updated 18 | * Added `is_locked` and `is_hidden` fields to `Perm` and `PermSet` 19 | 20 | v2.1.3 21 | ------ 22 | * Update `filter_qset` to account for when a user might have access through a custom permission checker but no levels to filter on 23 | 24 | v2.1.2 25 | ------ 26 | * When checking for permissions return early if the user has access 27 | 28 | v2.1.1 29 | ------ 30 | * Fix for `perm_checker` being a method and not a string 31 | 32 | v2.1.0 33 | ------ 34 | * Added `perm_checker` option to the `RESTRAINT_CONFIG` to allow for a custom permission checker 35 | 36 | v2.0.1 37 | ------ 38 | * Added `assign_default_permissions_from_permission_set` to `PermAccess` manager 39 | 40 | v2.0.0 41 | ------ 42 | * Removed `RESTRAINT_CONFIG` global variable, replaced with `get_restraint_config` method 43 | * Removed `register_restraint_config` method, replaced with `RESTRAINT_CONFIGURATION` django setting 44 | * Added `is_private` flag to PermSet model 45 | 46 | v1.2.0 47 | ------ 48 | * Python 3.7 49 | * Django 2.1 50 | * Django 2.2 51 | 52 | v1.1.0 53 | ------ 54 | * Add tox 55 | 56 | v1.0.0 57 | ------ 58 | * Remove python 2.7 support 59 | * Remove python 3.4 support 60 | * Remove Django 1.9 support 61 | * Remove Django 1.10 support 62 | * Add Django 2.0 support 63 | 64 | v0.7.0 65 | ------ 66 | * Add Python 3.6 support 67 | * Remove Django 1.8 support 68 | * Add Django 1.9 support 69 | * Add Django 1.10 support 70 | 71 | v0.6.0 72 | ------ 73 | * Add python 3.5 support 74 | * Drop django 1.7 support 75 | 76 | v0.5.1 77 | ------ 78 | * Filtering querysets can now optionally restrict a subset of a queryset based on a boolean permission, allowing the subset to be defined by arbitrary filter kwargs 79 | 80 | v0.5.0 81 | ------ 82 | * When adding new permissions to existing permissions sets, update_restraint_db will add these new permissions while preserving any permissions already assigned to the permission set 83 | 84 | v0.4.0 85 | ------ 86 | * Added Django 1.9 support 87 | 88 | v0.3.0 89 | ------ 90 | * Added Django 1.8 support and dropped 1.6 support 91 | 92 | v0.2.1 93 | ------ 94 | * Added Django 1.7 support 95 | 96 | v0.2.0 97 | ------ 98 | * Added individual permission capabilities 99 | 100 | v0.1 101 | ---- 102 | * This is the initial release of django-restraint. 103 | -------------------------------------------------------------------------------- /docs/setup.rst: -------------------------------------------------------------------------------- 1 | Setup 2 | ===== 3 | 4 | The Restraint Configuration 5 | --------------------------- 6 | Restraint is configured all in one place using the :code:`RESTRAINT_CONFIGURATION` django setting. 7 | This should be a string path to a method that will return the fully qualified restraint config. 8 | 9 | An example Restraint configuration is provided below. Details of the configuration are outlined in later sections. 10 | 11 | 12 | .. code-block:: python 13 | 14 | RESTRAINT_CONFIGURATION = 'app.permissions.get_restraint_config' 15 | 16 | def get_restraint_config(): 17 | return { 18 | 'perm_set_getter': perm_set_getter_function, 19 | 'perm_checker': perm_checker_function, 20 | 'perm_sets': { 21 | 'super': { 22 | 'display_name': 'Super', 23 | 'locked': True, 24 | 'hidden': True 25 | }, 26 | 'individual': { 27 | 'display_name': 'Individual', 28 | }, 29 | 'staff': { 30 | 'display_name': 'Staff' 31 | } 32 | }, 33 | 'perms': { 34 | 'can_edit_stuff': { 35 | 'display_name': 'Can Edit Stuff', 36 | 'levels': { 37 | 'all_stuff': { 38 | 'display_name': 'All Stuff', 39 | 'id_filter': None, 40 | }, 41 | 'some_stuff': { 42 | 'display_name': 'Some Stuff', 43 | 'id_filter': lambda a: User.objects.filter(id=a.id).values_list('id', flat=True), 44 | }, 45 | 'only_superusers': { 46 | 'display_name': 'Only Superusers', 47 | 'id_filter': lambda a: User.objects.filter(is_superuser=True).values_list('id', flat=True), 48 | }, 49 | }, 50 | 'locked': True 51 | }, 52 | 'can_view_stuff': { 53 | 'display_name': 'Can View Stuff', 54 | 'levels': constants.BOOLEAN_LEVELS_CONFIG, 55 | } 56 | }, 57 | 'default_access': { 58 | 'super': { 59 | 'can_edit_stuff': ['all_stuff', 'some_stuff'], 60 | 'can_view_stuff': [constants.BOOLEAN_LEVELS_NAME], 61 | }, 62 | 'individual': { 63 | 'can_edit_stuff': ['some_stuff'], 64 | }, 65 | 'staff': { 66 | 'can_edit_stuff': ['some_stuff', 'only_superusers'] 67 | } 68 | } 69 | } 70 | 71 | Defining The Permission Set Getter 72 | ---------------------------------- 73 | The :code:`perm_set_getter` key in the configuration points to a function that takes a user object. This function is responsible for returning a list of all perm sets that are associated with that user object. For example, 74 | 75 | .. code-block:: python 76 | 77 | def perm_set_getter(user): 78 | perm_sets = ['individual'] 79 | if u.is_superuser: 80 | perm_sets.append('super') 81 | if u.is_staff: 82 | perm_sets.append('staff') 83 | return perm_sets 84 | 85 | 86 | In this example, the user object is a Django :code:`User` model, and the perm_set_getter function returns *individual*, *super*, or *staff* permission sets based on the contents of the user object. 87 | 88 | 89 | Defining The Permission Checker 90 | ------------------------------- 91 | This is optional if you do not need any extra checks. 92 | 93 | The :code:`perm_checker` key in the configuration points to a function that allows a custom check of permissions. 94 | 95 | .. code-block:: python 96 | 97 | def custom_permission_checker(user, user_permissions, permission, level): 98 | if user.is_superuser: 99 | return True 100 | return False 101 | 102 | 103 | 104 | Defining Permission Sets 105 | ------------------------ 106 | The :code:`perm_sets` key is responsible for defining all of the permission sets of your application. These must correlate directly with what `perm_set_getter` may return. 107 | 108 | In the configuration from above, the user has defined that the permission sets are *super*, *individual*, and *staff*. Along with this, human-readable display names are also configured in the dictionary. 109 | 110 | 111 | Defining Permissions And Their Levels 112 | ------------------------------------- 113 | The :code:`perms` key is responsible for defining all of the permissions and their associated levels. Each top-level key in the :code:`perms` config defines the permission name, and the dictionary for each permission defines the display name of the permission and the levels that are associated with that permission. 114 | 115 | In the above example, the :code:`can_edit_stuff` permission is defined over three levels. Each of these levels defines a callable :code:`id_filter` function that can take the user and return lists of IDs associated with the querysets that should be restricted. For example, the :code:`some_stuff` level only allows the user to edit stuff that belongs to their account ID while the :code:`only_superusers` level allows one to edit the stuff belonging to super users. The :code:`all_stuff` level has no :code:`id_filter`, so it provides access over the entire queryset. 116 | 117 | Note that if a user has been granted multiple permission levels over the same permission, the results of those levels will be unioned together. 118 | 119 | If a permission is Boolean and has no levels, it must be configured with the :code:`BOOLEAN_LEVELS_CONFIG` object provided in the :code:`constants` module of Restraint. 120 | 121 | 122 | Defining Default Permission Set Access 123 | -------------------------------------- 124 | The Restraint configuration also allows the user to provide the default access levels for all permission sets. This prevents the user from having to write data migrations or initial fixtures to populate their permissions. 125 | 126 | For example, the above configuration allows *super* users to edit all stuff or any stuff and also provides them access to view stuff. The above configuration only allows *individual* users to edit some stuff without being able to view stuff. 127 | 128 | 129 | Syncing Your Configuration To The Database 130 | ------------------------------------------ 131 | The Restraint configuration will need to be synced to the database before it can be used by an application. Similar to Django's :code:`update_permissions`, Restraint provides an :code:`update_restraint_db` management command. When this command is called, all permission sets and permission levels are synced. Any permission sets and levels that were in the configuration before and not in the current one will be deleted. 132 | 133 | The :code:`default_access` configuration in the Restraint configuration will only be synced the first time this management command is executed. This behavior can be overridden by passing the :code:`--flush_default_access` parameter to the management command. 134 | 135 | 136 | How Do I Add Permissions To Individuals? 137 | ---------------------------------------- 138 | Adding permissions to individuals is not supported in the setup methods of Restraint. However, this may be done dynamically with model manager methods that are covered in the :doc:`Usage` documentation. -------------------------------------------------------------------------------- /docs/toc.rst: -------------------------------------------------------------------------------- 1 | Table of Contents 2 | ================= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | index 8 | installation 9 | setup 10 | usage 11 | ref/restraint 12 | contributing 13 | release_notes -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | After Restrain is set up, the only object you need to use is the :code:`Restraint` object. 5 | 6 | Restraint Initialization 7 | ------------------------ 8 | The :code:`Restraint` object is initialized as follows: 9 | 10 | .. code-block:: python 11 | 12 | from restraint import Restaint 13 | 14 | # Load all permissions for the user 15 | r = Restraint(user) 16 | 17 | # Load some permissions for the user 18 | r = Restraint(user, ['can_edit_accounts']) 19 | 20 | The above code example shows how to initialize the object by loading all of the permissions for the user in the restraint object and how to also only load some of the permissions. 21 | 22 | 23 | Checking For Permissions 24 | ------------------------ 25 | When the :code:`Restraint` object is initialized, the :code:`has_perm` method may be used to determine the user has a particular permission. 26 | 27 | .. code-block:: python 28 | 29 | from restraint import Restaint 30 | 31 | # Load all permissions for the user 32 | r = Restraint(user) 33 | 34 | # Check if the user has any level for a permission 35 | r.has_perm('can_edit_accounts') 36 | 37 | # Check if the user has a particular level for a permission 38 | r.has_perm('can_edit_accounts', 'all_accounts') 39 | 40 | The above example shows how to check if a user has any level for a permission or if they have a level for a permission. 41 | 42 | 43 | Checking Object Access 44 | ---------------------- 45 | The user has the ability to define what IDs may be edited based on an :code:`id_filter` function for each level in the Restraint configuration. The :code:`Restraint` object can filter a queryset by the filters owned by the user by calling :code:`filter_qset`. 46 | 47 | .. code-block:: python 48 | 49 | from restraint import Restaint 50 | 51 | # Load all permissions for the user 52 | r = Restraint(user) 53 | 54 | # Filter a queryset based on the user's permission levels 55 | users_i_can_edit = r.filter_qset(User.objects.all(), 'can_edit_accounts') 56 | 57 | 58 | In the above example, all :code:`User` objects were filtered down to the ones that can be edited by the user. 59 | 60 | 61 | Dynamically Syncing Permission Set Access 62 | ----------------------------------------- 63 | Restraint provides a model manager method if a user wants to sync a permission set access configuration to the database. 64 | 65 | .. code-block:: python 66 | 67 | from restraint.models import PermAccess 68 | 69 | PermAccess.objects.update_perm_set_access({ 70 | 'super': { 71 | 'can_edit_stuff': ['all_stuff', 'some_stuff'], 72 | 'can_view_stuff': [constants.BOOLEAN_LEVELS_NAME], 73 | }, 74 | 'individual': { 75 | 'can_edit_stuff': ['some_stuff'], 76 | }, 77 | 'staff': { 78 | 'can_edit_stuff': ['some_stuff', 'only_superusers'] 79 | } 80 | }, True) 81 | 82 | 83 | In the above example, the same format as the :code:`default_access` part of the Restraint configuration is used to sync the configuration of permission sets. 84 | 85 | 86 | Adding And Removing Permissions For Individuals 87 | ----------------------------------------------- 88 | Along with allowing permissions to be specified over permission sets, Restraint also provides the ability to assign permission levels to specific users. 89 | 90 | To add a permission level to a user, do the following: 91 | 92 | .. code-block:: python 93 | 94 | from restraint import constants 95 | from restraint.models import PermAccess 96 | 97 | user = individual_user_model_object 98 | 99 | # Add a defined level 100 | PermAccess.add_individual_access(user, 'my_perm', 'my_perm_level') 101 | 102 | # Add the boolean level 103 | PermAccess.add_individual_access(user, 'my_perm', constants.BOOLEAN_LEVELS_NAME) 104 | 105 | To remove a permission level to a user, do the following: 106 | 107 | .. code-block:: python 108 | 109 | from restraint import constants 110 | from restraint.models import PermAccess 111 | 112 | user = individual_user_model_object 113 | 114 | # Remove a defined level 115 | PermAccess.remove_individual_access(user, 'my_perm', 'my_perm_level') 116 | 117 | # Remove the boolean level 118 | PermAccess.remove_individual_access(user, 'my_perm', constants.BOOLEAN_LEVELS_NAME) 119 | 120 | When individual permissions are added, they will be accessed the same way as permission set levels with the :code:`Restraint` object. -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | from settings import configure_settings 4 | 5 | # Show warnings about django deprecations - uncomment for version upgrade testing 6 | import warnings 7 | from django.utils.deprecation import RemovedInNextVersionWarning 8 | warnings.filterwarnings('always', category=DeprecationWarning) 9 | warnings.filterwarnings('always', category=PendingDeprecationWarning) 10 | warnings.filterwarnings('always', category=RemovedInNextVersionWarning) 11 | 12 | if __name__ == '__main__': 13 | configure_settings() 14 | 15 | from django.core.management import execute_from_command_line 16 | 17 | execute_from_command_line(sys.argv) 18 | -------------------------------------------------------------------------------- /publish.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | subprocess.call(['rm', '-r', 'dist/']) 4 | subprocess.call(['python', '-m', 'pip', 'install', 'build', 'twine']) 5 | subprocess.call(['python', '-m', 'build']) 6 | subprocess.call(['twine', 'check', 'dist/*']) 7 | subprocess.call(['twine', 'upload', 'dist/*']) 8 | subprocess.call(['rm', '-r', 'dist/']) 9 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | Sphinx>=1.2.2 2 | sphinx_rtd_theme 3 | -------------------------------------------------------------------------------- /requirements/requirements-testing.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | coveralls 3 | django-nose 4 | django-dynamic-fixture 5 | flake8 6 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=3.2 2 | django-manager-utils>=3.1.0 3 | -------------------------------------------------------------------------------- /restraint/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .version import __version__ 3 | 4 | 5 | -------------------------------------------------------------------------------- /restraint/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RestraintConfig(AppConfig): 5 | name = 'restraint' 6 | verbose_name = 'Django Restraint' 7 | -------------------------------------------------------------------------------- /restraint/constants.py: -------------------------------------------------------------------------------- 1 | # A constant that specifies a Boolean level and its name 2 | BOOLEAN_LEVELS_NAME = '' 3 | BOOLEAN_LEVELS_CONFIG = { 4 | BOOLEAN_LEVELS_NAME: { 5 | 'display_name': '', 6 | 'id_filter': None, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /restraint/core.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from itertools import chain 3 | 4 | from django.conf import settings 5 | from django.db import transaction 6 | from django.db.models import Q 7 | from django.utils.functional import cached_property 8 | from django.utils.module_loading import import_string 9 | 10 | from restraint import models 11 | from restraint.signals import restraint_db_updated 12 | 13 | 14 | def get_restraint_config(): 15 | return import_string(settings.RESTRAINT_CONFIGURATION)() 16 | 17 | 18 | @transaction.atomic 19 | def update_restraint_db(flush_default_access=False): 20 | """ 21 | Updates the restraint db based on the restraint config. 22 | Can optionally flush the previous default access configuration. 23 | """ 24 | config = get_restraint_config() 25 | models.PermSet.objects.sync_perm_sets(config['perm_sets']) 26 | updated_perms, new_perms = models.Perm.objects.sync_perms(config['perms']) 27 | models.PermLevel.objects.sync_perm_levels(config['perms']) 28 | models.PermAccess.objects.update_perm_set_access(config.get('default_access', {}), new_perms, flush_default_access) 29 | restraint_db_updated.send(sender=None, config=config) 30 | 31 | 32 | def has_permission(user, user_permissions, permission, level): 33 | """ 34 | The default permission checker. 35 | 36 | Returns true if the restraint object has the perm. If a level is not specified, it returns 37 | true if that perm exists for any level. 38 | """ 39 | return ( 40 | permission in user_permissions and level in user_permissions[permission] 41 | if level 42 | else permission in user_permissions and len(user_permissions[permission]) 43 | ) 44 | 45 | 46 | class Restraint(object): 47 | """ 48 | The primary way of accessing permissions. The programmer loads a restraint object with the 49 | permission object and which permissions they want to load. One permissions are loaded for 50 | that account, the user may check if a user has certain permissions and also restrict 51 | querysets based on access levels that a user has. 52 | """ 53 | def __init__(self, user, which_perms=None): 54 | """ 55 | Initializes the Restraint object. 56 | 57 | :type user: Any object 58 | :param user: A user in a project 59 | 60 | :type which_perms: list 61 | :param which_perms: The permissions to be loaded for the user, or all permissions if None. 62 | """ 63 | 64 | # Save a reference to the config 65 | self._config = get_restraint_config() 66 | 67 | # Save a reference to the user 68 | self._user = user 69 | 70 | # Save a reference wo which perms we loaded 71 | self._which_perms = which_perms 72 | 73 | # Set the permission checkers 74 | self._permission_checkers = [has_permission] 75 | if self._config.get('perm_checker'): 76 | self._permission_checkers.append(self._config.get('perm_checker')) 77 | 78 | @cached_property 79 | def perms(self): 80 | """ 81 | Load and cache the permissions associated with the user 82 | """ 83 | perm_set_names = self._config['perm_set_getter'](self._user) 84 | perm_levels = models.PermLevel.objects.filter( 85 | Q(permaccess__perm_set__name__in=perm_set_names) | Q( 86 | permaccess__perm_user_id=self._user.id, 87 | permaccess__perm_user_type__app_label=self._user._meta.app_label, 88 | permaccess__perm_user_type__model=self._user._meta.model_name)).select_related('perm') 89 | if self._which_perms: 90 | perm_levels = perm_levels.filter(perm__name__in=self._which_perms) 91 | 92 | perms = defaultdict(dict) 93 | for level in perm_levels: 94 | perms[level.perm.name].update({ 95 | level.name: self._config['perms'][level.perm.name]['levels'][level.name]['id_filter'] 96 | }) 97 | return perms 98 | 99 | def has_perm(self, perm, level=None): 100 | """ 101 | Call the configured permission checker 102 | """ 103 | # Try and find the first one that passes 104 | # Do this in a loop to avoid additional checks when not necessary 105 | for permission_checker in self._permission_checkers: 106 | if permission_checker( 107 | user=self._user, 108 | user_permissions=self.perms, 109 | permission=perm, 110 | level=level 111 | ): 112 | return True 113 | return False 114 | 115 | def filter_qset(self, qset, perm, restrict_kwargs=None): 116 | """ 117 | Given a permission, filter the queryset by its levels. 118 | 119 | :type qset: A Django QuerySet 120 | :param qset: The queryset to be filtered 121 | 122 | :type perm: string 123 | :param perm: The permission over which to do the filtering 124 | """ 125 | 126 | # Check if any permission filters exist 127 | # If none exist we know we can allow all 128 | permission_filters = self.perms[perm].values() 129 | allow_all = True if not len(permission_filters) or None in permission_filters else False 130 | has_perm = self.has_perm(perm) 131 | 132 | # The user does not have this permission for any level 133 | if not has_perm: 134 | # if this restraint only protects a certain subset of the queryset, return the rest 135 | if restrict_kwargs is not None: 136 | return qset.exclude(**restrict_kwargs) 137 | # else return nothing 138 | else: 139 | return qset.none() 140 | elif has_perm and allow_all: 141 | # If any levels are none, return the full queryset 142 | return qset 143 | else: 144 | # Filter the queryset by the union of all filters 145 | return qset.filter(id__in=set(chain(*[level(self._user) for level in self.perms[perm].values()]))) 146 | -------------------------------------------------------------------------------- /restraint/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-restraint/ec2f5632c146e29d4ba305799fc8973146fff8ea/restraint/management/__init__.py -------------------------------------------------------------------------------- /restraint/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-restraint/ec2f5632c146e29d4ba305799fc8973146fff8ea/restraint/management/commands/__init__.py -------------------------------------------------------------------------------- /restraint/management/commands/update_restraint_db.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from restraint.core import update_restraint_db 4 | 5 | 6 | class Command(BaseCommand): 7 | """ 8 | A management command for updating the restraint permissions based on the 9 | restraint config. 10 | """ 11 | def add_arguments(self, parser): 12 | parser.add_argument( 13 | '--flush_default_access', 14 | action='store_true', 15 | dest='flush_default_access', 16 | default=False, 17 | help='Flush all permission sets before updating' 18 | ) 19 | 20 | def handle(self, *args, **options): 21 | """ 22 | Runs the command to update the restraint db. 23 | """ 24 | update_restraint_db(flush_default_access=options['flush_default_access']) 25 | -------------------------------------------------------------------------------- /restraint/managers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.db import models, transaction 3 | from manager_utils import sync 4 | 5 | 6 | class PermSetManager(models.Manager): 7 | def sync_perm_sets(self, perm_sets): 8 | """ 9 | Syncs all private perm sets the provided dictionary of perm sets to PermSet models. 10 | """ 11 | from restraint.models import PermSet 12 | sync( 13 | queryset=self.get_queryset().filter( 14 | is_private=True 15 | ), 16 | model_objs=[ 17 | PermSet( 18 | name=name, 19 | display_name=config.get('display_name', ''), 20 | is_private=True, 21 | is_locked=config.get('locked', False), 22 | is_hidden=config.get('hidden', False) 23 | ) 24 | for name, config in perm_sets.items() 25 | ], 26 | unique_fields=[ 27 | 'name' 28 | ], 29 | update_fields=[ 30 | 'display_name', 31 | 'is_locked', 32 | 'is_hidden' 33 | ] 34 | ) 35 | 36 | 37 | class PermManager(models.Manager): 38 | def sync_perms(self, perms): 39 | """ 40 | Syncs the perms to Perm models. 41 | """ 42 | from restraint.models import Perm 43 | return sync( 44 | queryset=self.get_queryset(), 45 | model_objs=[ 46 | Perm( 47 | name=name, 48 | display_name=config.get('display_name', ''), 49 | is_locked=config.get('locked', False), 50 | is_hidden=config.get('hidden', False) 51 | ) 52 | for name, config in perms.items() 53 | ], 54 | unique_fields=[ 55 | 'name' 56 | ], 57 | update_fields=[ 58 | 'display_name', 59 | 'is_locked', 60 | 'is_hidden' 61 | ], 62 | return_upserts_distinct=True 63 | ) 64 | 65 | 66 | class PermLevelManager(models.Manager): 67 | def sync_perm_levels(self, perms): 68 | """ 69 | Given a dictionary of perms that map to perm levels, sync the perm levels 70 | to PermLevel objects in the database. 71 | """ 72 | from restraint.models import Perm, PermLevel 73 | perm_objs = { 74 | p.name: p 75 | for p in Perm.objects.all() 76 | } 77 | perm_levels = [] 78 | for perm, perm_config in perms.items(): 79 | assert perm_config['levels'] 80 | for level, level_config in perm_config['levels'].items(): 81 | perm_levels.append(PermLevel( 82 | perm=perm_objs[perm], 83 | name=level, 84 | display_name=level_config.get('display_name', '') 85 | )) 86 | sync( 87 | queryset=self.get_queryset(), 88 | model_objs=perm_levels, 89 | unique_fields=[ 90 | 'name', 91 | 'perm' 92 | ], 93 | update_fields=[ 94 | 'display_name' 95 | ] 96 | ) 97 | 98 | 99 | class PermAccessManager(models.Manager): 100 | def set_default(self, permission_set_name, permission_name, levels=None): 101 | """ 102 | Sets default levels for a permission for a permission set 103 | :param permission_set_name: The name of the permission set 104 | :param permission_name: The name of the permission 105 | :param levels: A list of levels 106 | """ 107 | from restraint.models import PermSet, PermAccess, PermLevel 108 | permission_access = PermAccess.objects.get_or_create( 109 | perm_set=PermSet.objects.get(name=permission_set_name) 110 | )[0] 111 | if levels: 112 | permission_levels = PermLevel.objects.filter( 113 | perm__name=permission_name, 114 | name__in=levels 115 | ) 116 | permission_access.perm_levels.set(permission_levels) 117 | else: 118 | permission_access.perm_levels.clear() 119 | 120 | def update_perm_set_access(self, config, new_perms=None, flush_previous_config=False): 121 | """ 122 | Update the access for private perm sets with a config. The user can optionally flush 123 | the previous config and set it to the new one. 124 | """ 125 | 126 | # Do model imports to avoid circular 127 | from restraint.models import PermSet, PermAccess, PermLevel 128 | 129 | # Ensure that new perms is not none 130 | if new_perms is None: # pragma: no cover 131 | new_perms = [] 132 | 133 | # Loop over each private permission set 134 | for perm_set in PermSet.objects.filter(is_private=True): 135 | perm_access, created = PermAccess.objects.get_or_create(perm_set=perm_set) 136 | perm_access_levels = [] 137 | for perm, perm_levels in config.get(perm_set.name, {}).items(): 138 | # If we are not flushing the previous config, continue if the perm not among the newly created perms 139 | # this is necessary because perm access is mutable; We don't want to destroy modifications made to 140 | # existing permissions 141 | if not created and not flush_previous_config and perm not in [p.name for p in new_perms]: 142 | continue 143 | assert perm_levels 144 | perm_access_levels.extend(PermLevel.objects.filter(perm__name=perm, name__in=perm_levels)) 145 | 146 | if flush_previous_config: 147 | perm_access.perm_levels.clear() 148 | perm_access.perm_levels.add(*perm_access_levels) 149 | 150 | def add_individual_access(self, user, perm_name, level_name): 151 | """ 152 | Given a user, a permission name, and the name of the level, add the level in the permission access for 153 | the individual user. 154 | """ 155 | from restraint.models import PermAccess, PermLevel 156 | pa, created = PermAccess.objects.get_or_create( 157 | perm_user_id=user.id, 158 | perm_user_type=ContentType.objects.get_for_model(user) 159 | ) 160 | pa.perm_levels.add(PermLevel.objects.get( 161 | perm__name=perm_name, 162 | name=level_name 163 | )) 164 | 165 | def remove_individual_access(self, user, perm_name, level_name): 166 | """ 167 | Given a user, a permission name, and the name of the level, remove the level in the permission access for 168 | the individual user. 169 | """ 170 | from restraint.models import PermAccess, PermLevel 171 | pa = PermAccess.objects.get( 172 | perm_user_id=user.id, 173 | perm_user_type=ContentType.objects.get_for_model(user) 174 | ) 175 | pa.perm_levels.remove(PermLevel.objects.get( 176 | perm__name=perm_name, 177 | name=level_name 178 | )) 179 | 180 | @transaction.atomic 181 | def assign_default_permissions_from_permission_set(self, to_permission_set, from_permission_set): 182 | """ 183 | Create default permission access for a permission set from another permission set 184 | """ 185 | 186 | # Local imports to avoid circular dependency 187 | from restraint.models import PermAccess 188 | 189 | # Get the permission access 190 | to_permission_set_access, to_permission_set_access_created = PermAccess.objects.get_or_create( 191 | perm_set=to_permission_set 192 | ) 193 | from_permission_set_access = PermAccess.objects.filter(perm_set=from_permission_set).first() 194 | 195 | # If we did not create the permission access, do nothing 196 | if not to_permission_set_access_created or from_permission_set_access is None: 197 | return False 198 | 199 | # Use the same levels as the from access 200 | to_permission_set_access.perm_levels.set(from_permission_set_access.perm_levels.all()) 201 | -------------------------------------------------------------------------------- /restraint/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models, migrations 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('contenttypes', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Perm', 16 | fields=[ 17 | ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), 18 | ('name', models.CharField(unique=True, max_length=256, blank=True)), 19 | ('display_name', models.TextField(blank=True)), 20 | ], 21 | options={ 22 | }, 23 | bases=(models.Model,), 24 | ), 25 | migrations.CreateModel( 26 | name='PermAccess', 27 | fields=[ 28 | ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), 29 | ('perm_user_id', models.PositiveIntegerField(default=0)), 30 | ], 31 | options={ 32 | }, 33 | bases=(models.Model,), 34 | ), 35 | migrations.CreateModel( 36 | name='PermLevel', 37 | fields=[ 38 | ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), 39 | ('name', models.CharField(max_length=256, blank=True)), 40 | ('display_name', models.TextField(blank=True)), 41 | ('perm', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='restraint.Perm')), 42 | ], 43 | options={ 44 | }, 45 | bases=(models.Model,), 46 | ), 47 | migrations.CreateModel( 48 | name='PermSet', 49 | fields=[ 50 | ('id', models.AutoField(serialize=False, verbose_name='ID', primary_key=True, auto_created=True)), 51 | ('name', models.CharField(unique=True, max_length=256, blank=True)), 52 | ('display_name', models.TextField(blank=True)), 53 | ], 54 | options={ 55 | }, 56 | bases=(models.Model,), 57 | ), 58 | migrations.AlterUniqueTogether( 59 | name='permlevel', 60 | unique_together=set([('perm', 'name')]), 61 | ), 62 | migrations.AddField( 63 | model_name='permaccess', 64 | name='perm_levels', 65 | field=models.ManyToManyField(to='restraint.PermLevel'), 66 | preserve_default=True, 67 | ), 68 | migrations.AddField( 69 | model_name='permaccess', 70 | name='perm_set', 71 | field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, default=None, null=True, to='restraint.PermSet'), 72 | preserve_default=True, 73 | ), 74 | migrations.AddField( 75 | model_name='permaccess', 76 | name='perm_user_type', 77 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, default=None, null=True, to='contenttypes.ContentType'), 78 | preserve_default=True, 79 | ), 80 | migrations.AlterUniqueTogether( 81 | name='permaccess', 82 | unique_together=set([('perm_user_type', 'perm_user_id')]), 83 | ), 84 | ] 85 | -------------------------------------------------------------------------------- /restraint/migrations/0002_permset_is_private.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-02-20 21:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def update_private_permission_sets(apps, schema_editor): 7 | PermSet = apps.get_model('restraint', 'PermSet') 8 | for perm_set in PermSet.objects.all(): 9 | perm_set.is_private = True 10 | perm_set.save(update_fields=['is_private']) 11 | 12 | 13 | class Migration(migrations.Migration): 14 | 15 | dependencies = [ 16 | ('restraint', '0001_initial'), 17 | ] 18 | 19 | operations = [ 20 | migrations.AddField( 21 | model_name='permset', 22 | name='is_private', 23 | field=models.BooleanField(default=False), 24 | ), 25 | migrations.RunPython(update_private_permission_sets) 26 | ] 27 | -------------------------------------------------------------------------------- /restraint/migrations/0003_auto_20230301_0109.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.16 on 2023-03-01 01:09 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('restraint', '0002_permset_is_private'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='perm', 15 | name='is_hidden', 16 | field=models.BooleanField(default=False), 17 | ), 18 | migrations.AddField( 19 | model_name='perm', 20 | name='is_locked', 21 | field=models.BooleanField(default=False), 22 | ), 23 | migrations.AddField( 24 | model_name='permset', 25 | name='is_hidden', 26 | field=models.BooleanField(default=False), 27 | ), 28 | migrations.AddField( 29 | model_name='permset', 30 | name='is_locked', 31 | field=models.BooleanField(default=False), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /restraint/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-restraint/ec2f5632c146e29d4ba305799fc8973146fff8ea/restraint/migrations/__init__.py -------------------------------------------------------------------------------- /restraint/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.db import models 3 | 4 | from restraint.managers import PermSetManager, PermManager, PermLevelManager, PermAccessManager 5 | 6 | 7 | class PermSet(models.Model): 8 | """ 9 | This is essentially a group that has a name. 10 | Each PermSet will have a PermAccess associated with it that will contain the groups full permissions 11 | The permissions are indicated through PermAccess.perm_levels. 12 | 13 | Fields: 14 | - Name 15 | - Display_name 16 | - Perm_access (One To One to PermAccess) For this set, this user type has these levels 17 | 18 | Example: 19 | - Name: manager 20 | - Display Name: Managers 21 | """ 22 | name = models.CharField(max_length=256, unique=True, blank=True) 23 | display_name = models.TextField(blank=True) 24 | is_private = models.BooleanField(default=False) 25 | is_locked = models.BooleanField(default=False) 26 | is_hidden = models.BooleanField(default=False) 27 | 28 | objects = PermSetManager() 29 | 30 | def __str__(self): 31 | return f'{self.display_name}:{self.name}' 32 | 33 | 34 | class Perm(models.Model): 35 | """ 36 | This is the actual permission name specific to what each app will add, for example competition_all. 37 | 38 | Fields: 39 | - Name 40 | - Display_name 41 | 42 | Example: 43 | - Name: can_edit_accounts 44 | - Display Name: Users: Edit 45 | - Levels (Reverse Many To Many to PermLevel) 46 | """ 47 | name = models.CharField(max_length=256, unique=True, blank=True) 48 | display_name = models.TextField(blank=True) 49 | is_locked = models.BooleanField(default=False) 50 | is_hidden = models.BooleanField(default=False) 51 | 52 | objects = PermManager() 53 | 54 | def __str__(self): 55 | return f'{self.display_name}:{self.name}' 56 | 57 | 58 | class PermLevel(models.Model): 59 | """ 60 | Each specific Perm can have different levels of access for the same permission. 61 | A blank level indicates that its either a yes/no decision 62 | Other options might look like all, created_by, etc... 63 | This should be unique and handled by each app independently 64 | 65 | Fields: 66 | - Perm (Foreign Key) 67 | - Name 68 | - Display_name 69 | 70 | Example: 71 | This is an example of a permission that can have multiple levels of permissions. 72 | Let's take the example permission of can_edit_accounts with three different permission levels: 73 | - Created_by: Can only edit accounts created by the user tied to the permission 74 | - Own: Can only edit their own account 75 | - Under: Can edit accounts that they manage 76 | 77 | Created By: 78 | - Permission: can_edit_accounts 79 | - Name: created_by 80 | - Display Name: Can Edit Users Created 81 | 82 | Own: 83 | - Permission: can_edit_accounts 84 | - Name: own 85 | - Display Name: Can Edit Own Account 86 | 87 | Under: 88 | - Permission: can_edit_accounts 89 | - Name: under 90 | - Display Name: Can Edit Subordinate Accounts 91 | """ 92 | perm = models.ForeignKey(Perm, on_delete=models.CASCADE) 93 | name = models.CharField(max_length=256, blank=True) 94 | display_name = models.TextField(blank=True) 95 | 96 | objects = PermLevelManager() 97 | 98 | class Meta: 99 | unique_together = ('perm', 'name') 100 | 101 | def __str__(self): 102 | return f'{self.name}:{self.display_name}[PERM]{self.perm}' 103 | 104 | 105 | class PermAccess(models.Model): 106 | """ 107 | Provides access a list of permission levels for a permission set or for an individual 108 | user. 109 | 110 | This is what is used to determine what groups or individuals have access to. 111 | The access is determined by what levels are assigned to each group or individual. 112 | This can be found in the many to many relationship to levels `PermAccess.perm_levels.all()` 113 | 114 | Fields: 115 | - PermSet (One To One) 116 | - Perm_user_type (ContentType, Generic Foreign Key) 117 | - Perm_user_id (id of the object associated with the content type) 118 | - Perm_levels (Many to Many PermLevel) 119 | 120 | Example: 121 | An example would be for the manager permission set and allowing any manager to have access to two levels, 122 | own and under so they can edit their own account and any subordinate accounts. 123 | - Perm Set: manager 124 | - User Type: None 125 | - User Id: 0 126 | - Perm Levels 127 | - Own 128 | - Under 129 | """ 130 | perm_set = models.OneToOneField(PermSet, null=True, default=None, on_delete=models.CASCADE) 131 | perm_user_type = models.ForeignKey(ContentType, null=True, default=None, on_delete=models.CASCADE) 132 | perm_user_id = models.PositiveIntegerField(default=0) 133 | perm_levels = models.ManyToManyField(PermLevel) 134 | 135 | class Meta: 136 | unique_together = ('perm_user_type', 'perm_user_id') 137 | 138 | objects = PermAccessManager() 139 | 140 | def __str__(self): # pragma: no cover 141 | return f'[PERM_SET]{self.perm_set}[USER_TYPE]{self.perm_user_type}[USER_ID]{self.perm_user_id}' 142 | -------------------------------------------------------------------------------- /restraint/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | # Fires when the restraint database is updated 5 | restraint_db_updated = Signal() 6 | -------------------------------------------------------------------------------- /restraint/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-restraint/ec2f5632c146e29d4ba305799fc8973146fff8ea/restraint/tests/__init__.py -------------------------------------------------------------------------------- /restraint/tests/commands_tests.py: -------------------------------------------------------------------------------- 1 | from django.core.management import call_command 2 | from django.test import SimpleTestCase 3 | from unittest.mock import patch 4 | 5 | 6 | class UpdateRestraintDbTest(SimpleTestCase): 7 | @patch('restraint.management.commands.update_restraint_db.update_restraint_db', spec_set=True) 8 | def test_wo_flush_default_access(self, mock_update_restraint_db): 9 | call_command('update_restraint_db') 10 | mock_update_restraint_db.assert_called_once_with(flush_default_access=False) 11 | 12 | @patch('restraint.management.commands.update_restraint_db.update_restraint_db', spec_set=True) 13 | def test_w_flush_default_access(self, mock_update_restraint_db): 14 | call_command('update_restraint_db', flush_default_access=True) 15 | mock_update_restraint_db.assert_called_once_with(flush_default_access=True) 16 | -------------------------------------------------------------------------------- /restraint/tests/configuration.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | 3 | from restraint import constants 4 | 5 | 6 | def perm_set_getter(u): 7 | perm_sets = ['individual'] 8 | if u.is_superuser: 9 | perm_sets.append('super') 10 | if u.is_staff: 11 | perm_sets.append('staff') 12 | return perm_sets 13 | 14 | 15 | def user_some_stuff_id_filter(user): 16 | return User.objects.filter(id=user.id).values_list('id', flat=True) 17 | 18 | 19 | def user_only_super_users_id_filter(user): 20 | return User.objects.filter(is_superuser=True).values_list('id', flat=True) 21 | 22 | 23 | def get_configuration(): 24 | return { 25 | 'perm_set_getter': perm_set_getter, 26 | 'perm_sets': { 27 | 'super': { 28 | 'display_name': 'Super', 29 | }, 30 | 'individual': { 31 | 'display_name': 'Individual', 32 | }, 33 | 'staff': { 34 | 'display_name': 'Staff' 35 | }, 36 | 'locked_and_hidden': { 37 | 'display_name': 'This is locked and hidden' 38 | } 39 | }, 40 | 'perms': { 41 | 'can_edit_stuff': { 42 | 'display_name': 'Can Edit Stuff', 43 | 'levels': { 44 | 'all_stuff': { 45 | 'display_name': 'All Stuff', 46 | 'id_filter': None, 47 | }, 48 | 'some_stuff': { 49 | 'display_name': 'Some Stuff', 50 | 'id_filter': user_some_stuff_id_filter, 51 | }, 52 | 'only_superusers': { 53 | 'display_name': 'Only Superusers', 54 | 'id_filter': user_only_super_users_id_filter, 55 | }, 56 | }, 57 | }, 58 | 'can_view_stuff': { 59 | 'display_name': 'Can View Stuff', 60 | 'levels': constants.BOOLEAN_LEVELS_CONFIG, 61 | }, 62 | 'can_access_users_named_foo': { 63 | 'display_name': 'Can Foo', 64 | 'levels': constants.BOOLEAN_LEVELS_CONFIG, 65 | 'locked': True, 66 | 'hidden': True 67 | } 68 | }, 69 | 'default_access': { 70 | 'super': { 71 | 'can_edit_stuff': ['all_stuff', 'some_stuff'], 72 | 'can_view_stuff': [constants.BOOLEAN_LEVELS_NAME], 73 | 'can_access_users_named_foo': [constants.BOOLEAN_LEVELS_NAME], 74 | }, 75 | 'individual': { 76 | 'can_edit_stuff': ['some_stuff'], 77 | }, 78 | 'staff': { 79 | 'can_edit_stuff': ['some_stuff', 'only_superusers'] 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /restraint/tests/core_tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.test import SimpleTestCase, TestCase 4 | from django_dynamic_fixture import G 5 | from unittest.mock import patch, Mock 6 | from unittest.mock import PropertyMock 7 | 8 | from restraint import core, constants 9 | from restraint.models import PermSet, Perm, PermLevel, PermAccess 10 | import restraint.tests.configuration as test_configuration 11 | 12 | 13 | class TestGetRestraintConfig(SimpleTestCase): 14 | def test_get_configuration(self): 15 | self.assertEqual( 16 | core.get_restraint_config()['perm_sets'], 17 | test_configuration.get_configuration()['perm_sets'] 18 | ) 19 | 20 | 21 | class TestRestraintLoadPerms(TestCase): 22 | def setUp(self): 23 | core.update_restraint_db() 24 | 25 | def test_individual_user(self): 26 | # Create an individual user 27 | user = G(User, is_superuser=False, is_staff=False) 28 | 29 | # Get the users restraints 30 | restraints = core.Restraint(user) 31 | perms = restraints.perms 32 | self.assertEqual( 33 | perms, 34 | { 35 | 'can_edit_stuff': { 36 | 'some_stuff': test_configuration.user_some_stuff_id_filter 37 | } 38 | } 39 | ) 40 | 41 | def test_super_user(self): 42 | # Make a user that is a superuser and verify they get all proper permissions 43 | user = G(User, is_superuser=True) 44 | restraints = core.Restraint(user) 45 | perms = restraints.perms 46 | self.assertEqual( 47 | perms, 48 | { 49 | 'can_edit_stuff': { 50 | 'all_stuff': None, 51 | 'some_stuff': test_configuration.user_some_stuff_id_filter, 52 | }, 53 | 'can_view_stuff': { 54 | '': None 55 | }, 56 | 'can_access_users_named_foo': { 57 | '': None 58 | } 59 | } 60 | ) 61 | 62 | def test_user_with_additional_perms(self): 63 | # Make an individual user and verify they get all of the perms. 64 | # Also, add an individual permission to the user and verify they get that too 65 | user = G(User, is_superuser=False, is_staff=False) 66 | pa = G(PermAccess, perm_user_id=user.id, perm_user_type=ContentType.objects.get_for_model(user)) 67 | pa.perm_levels.add(PermLevel.objects.get(name='all_stuff')) 68 | 69 | r = core.Restraint(user) 70 | perms = r.perms 71 | self.assertEqual( 72 | perms, 73 | { 74 | 'can_edit_stuff': { 75 | 'some_stuff': test_configuration.user_some_stuff_id_filter, 76 | 'all_stuff': None 77 | } 78 | } 79 | ) 80 | 81 | def test_load_some_perms(self): 82 | user = G(User, is_superuser=True) 83 | restraints = core.Restraint(user, ['can_edit_stuff']) 84 | perms = restraints.perms 85 | self.assertEqual( 86 | perms, 87 | { 88 | 'can_edit_stuff': { 89 | 'all_stuff': None, 90 | 'some_stuff': test_configuration.user_some_stuff_id_filter 91 | } 92 | } 93 | ) 94 | 95 | 96 | class TestRestraintHasPerms(SimpleTestCase): 97 | @patch.object(core.Restraint, 'perms', new_callable=PropertyMock) 98 | def test_has_perm_w_level_true(self, mock_perms): 99 | mock_perms.return_value = { 100 | 'can_view_stuff': { 101 | '': None, 102 | }, 103 | 'can_edit_stuff': { 104 | 'all_stuff': None, 105 | 'some_stuff': None, 106 | } 107 | } 108 | r = core.Restraint(Mock()) 109 | self.assertTrue(r.has_perm('can_edit_stuff', 'all_stuff')) 110 | 111 | @patch.object(core.Restraint, 'perms', new_callable=PropertyMock) 112 | def test_has_perm_w_level_false(self, mock_perms): 113 | mock_perms.return_value = { 114 | 'can_view_stuff': { 115 | '': None, 116 | }, 117 | 'can_edit_stuff': { 118 | 'all_stuff': None, 119 | 'some_stuff': None, 120 | } 121 | } 122 | r = core.Restraint(Mock()) 123 | self.assertFalse(r.has_perm('can_edit_stuff', 'no_stuff')) 124 | 125 | @patch.object(core.Restraint, 'perms', new_callable=PropertyMock) 126 | def test_has_perm_wo_level_true(self, mock_perms): 127 | mock_perms.return_value = { 128 | 'can_view_stuff': { 129 | '': None, 130 | }, 131 | 'can_edit_stuff': { 132 | 'all_stuff': None, 133 | 'some_stuff': None, 134 | } 135 | } 136 | r = core.Restraint(Mock()) 137 | self.assertTrue(r.has_perm('can_edit_stuff')) 138 | 139 | @patch.object(core.Restraint, 'perms', new_callable=PropertyMock) 140 | def test_has_perm_wo_level_false(self, mock_perms): 141 | mock_perms.return_value = { 142 | 'can_view_stuff': { 143 | '': None, 144 | }, 145 | 'can_edit_stuff': { 146 | 'all_stuff': None, 147 | 'some_stuff': None, 148 | } 149 | } 150 | r = core.Restraint(Mock()) 151 | self.assertFalse(r.has_perm('can_mess_with_stuff')) 152 | 153 | 154 | class TestRestraintFilterQSet(TestCase): 155 | def setUp(self): 156 | core.update_restraint_db() 157 | 158 | def test_filter_qset_global_access(self): 159 | # Make a user that is a superuser and verify they get all of the 160 | # super user perms 161 | u = G(User, is_superuser=True) 162 | # Make another user that they will be able to edit 163 | u2 = G(User) 164 | r = core.Restraint(u) 165 | 166 | filtered_qset = r.filter_qset(User.objects.all(), 'can_edit_stuff') 167 | self.assertEqual(set(filtered_qset), set([u, u2])) 168 | 169 | def test_filter_qset_local_access(self): 170 | # Make a user that is not a superuser 171 | u = G(User, is_superuser=False) 172 | # Make another user that they will not be able to edit 173 | G(User) 174 | r = core.Restraint(u) 175 | 176 | filtered_qset = r.filter_qset(User.objects.all(), 'can_edit_stuff') 177 | self.assertEqual(set(filtered_qset), set([u])) 178 | 179 | def test_filter_qset_multiple_local_access(self): 180 | # Make a user that is staff 181 | u = G(User, is_superuser=False, is_staff=True) 182 | # Make another user that they will not be able to edit 183 | G(User) 184 | # Make another super user that they will be able to edit 185 | u2 = G(User, is_superuser=True) 186 | r = core.Restraint(u) 187 | 188 | filtered_qset = r.filter_qset(User.objects.all(), 'can_edit_stuff') 189 | self.assertEqual(set(filtered_qset), set([u, u2])) 190 | 191 | def test_filter_qset_no_perms(self): 192 | # Make a user that is staff 193 | u = G(User, is_superuser=False, is_staff=True) 194 | # Load permissions that will not give them access to edit any accounts 195 | r = core.Restraint(u, ['bad_perm']) 196 | 197 | filtered_qset = r.filter_qset(User.objects.all(), 'can_edit_stuff') 198 | self.assertEqual(set(filtered_qset), set([])) 199 | 200 | def test_filter_qset_restrict_subset(self): 201 | models = [ 202 | G(User, first_name='foo', last_name='foofington'), 203 | G(User, first_name='bar', last_name='barski'), 204 | G(User, first_name='foo', last_name='foogeelala'), 205 | ] 206 | # Make a user that is a superuser and verify they get all of the 207 | # super user perms 208 | u = G(User, is_superuser=True) 209 | r = core.Restraint(u) 210 | 211 | filtered_qset = r.filter_qset( 212 | User.objects.all(), 'can_access_users_named_foo', restrict_kwargs={'first_name': 'foo'}) 213 | self.assertEqual(set(filtered_qset), set(models + [u])) 214 | 215 | def test_filter_qset_restrict_subset_no_perms(self): 216 | models = [ 217 | G(User, first_name='foo', last_name='foofington'), 218 | G(User, first_name='bar', last_name='barski'), 219 | G(User, first_name='foo', last_name='foogeelala'), 220 | ] 221 | # Make a user that is a superuser and verify they get all of the 222 | # super user perms 223 | u = G(User, is_superuser=False) 224 | r = core.Restraint(u) 225 | 226 | filtered_qset = r.filter_qset( 227 | User.objects.all(), 'can_access_users_named_foo', restrict_kwargs={'first_name': 'foo'}) 228 | self.assertEqual(set(filtered_qset), set([models[1]] + [u])) 229 | 230 | 231 | class UpdateRestraintDbTest(TestCase): 232 | def add_custom_permission_set(self): 233 | # Setup a custom permission set 234 | custom_permission_set = PermSet.objects.create( 235 | name='custom', 236 | display_name='Custom', 237 | is_private=False 238 | ) 239 | 240 | # Add some custom default access levels 241 | PermAccess.objects.set_default( 242 | permission_set_name=custom_permission_set.name, 243 | permission_name='can_edit_stuff', 244 | levels=['all_stuff'] 245 | ) 246 | 247 | @patch.object(core, 'get_restraint_config') 248 | def test_full_update_scenario_not_flush_default_access(self, mock_get_restraint_config): 249 | mock_get_restraint_config.return_value = { 250 | 'perm_sets': { 251 | 'global': { 252 | 'display_name': 'Global', 253 | }, 254 | 'restricted': { 255 | 'display_name': 'Restricted', 256 | }, 257 | }, 258 | 'perms': { 259 | 'can_edit_stuff': { 260 | 'display_name': 'Can Edit Stuff', 261 | 'levels': { 262 | 'all_stuff': { 263 | 'display_name': 'All Stuff', 264 | 'id_filter': None, 265 | }, 266 | 'some_stuff': { 267 | 'display_name': 'Some Stuff', 268 | 'id_filter': None, 269 | }, 270 | }, 271 | }, 272 | 'can_view_stuff': { 273 | 'display_name': 'Can View Stuff', 274 | 'levels': constants.BOOLEAN_LEVELS_CONFIG, 275 | }, 276 | }, 277 | 'default_access': { 278 | 'global': { 279 | 'can_edit_stuff': ['all_stuff', 'some_stuff'], 280 | 'can_view_stuff': [constants.BOOLEAN_LEVELS_NAME], 281 | }, 282 | 'restricted': { 283 | 'can_edit_stuff': ['some_stuff'], 284 | } 285 | } 286 | } 287 | core.update_restraint_db() 288 | core.update_restraint_db() 289 | 290 | @patch.object(core, 'get_restraint_config') 291 | def test_full_update_scenario_not_flush_default_access_update_new_perm(self, mock_get_restraint_config): 292 | """ 293 | Verifies that existing permission set is given access to new permission 294 | """ 295 | config = { 296 | 'perm_sets': { 297 | 'global': { 298 | 'display_name': 'Global', 299 | }, 300 | 'restricted': { 301 | 'display_name': 'Restricted', 302 | 'locked': True, 303 | 'hidden': True 304 | }, 305 | }, 306 | 'perms': { 307 | 'can_edit_stuff': { 308 | 'display_name': 'Can Edit Stuff', 309 | 'levels': { 310 | 'all_stuff': { 311 | 'display_name': 'All Stuff', 312 | 'id_filter': None, 313 | }, 314 | 'some_stuff': { 315 | 'display_name': 'Some Stuff', 316 | 'id_filter': None, 317 | }, 318 | }, 319 | }, 320 | 'can_view_stuff': { 321 | 'display_name': 'Can View Stuff', 322 | 'levels': constants.BOOLEAN_LEVELS_CONFIG, 323 | 'locked': True, 324 | 'hidden': True 325 | }, 326 | }, 327 | 'default_access': { 328 | 'global': { 329 | 'can_edit_stuff': ['all_stuff', 'some_stuff'], 330 | 'can_view_stuff': [constants.BOOLEAN_LEVELS_NAME], 331 | }, 332 | 'restricted': { 333 | 'can_edit_stuff': ['some_stuff'], 334 | } 335 | } 336 | } 337 | mock_get_restraint_config.return_value = config 338 | core.update_restraint_db() 339 | self.add_custom_permission_set() 340 | 341 | # add permission 342 | config['perms']['can_do_stuff'] = { 343 | 'display_name': 'Can Do Stuff', 344 | 'levels': { 345 | 'all_stuff': { 346 | 'display_name': 'All Stuff', 347 | 'id_filter': None, 348 | }, 349 | 'this_thing': { 350 | 'display_name': 'This Thing', 351 | 'id_filter': None, 352 | }, 353 | }, 354 | } 355 | config['perms']['can_alter_stuff'] = { 356 | 'display_name': 'Can Alter Stuff', 357 | 'levels': constants.BOOLEAN_LEVELS_CONFIG 358 | } 359 | config['default_access']['global']['can_alter_stuff'] = [constants.BOOLEAN_LEVELS_NAME] 360 | config['default_access']['restricted']['can_do_stuff'] = ['all_stuff', 'this_thing'] 361 | mock_get_restraint_config.return_value = config 362 | 363 | # update again 364 | core.update_restraint_db() 365 | 366 | self.assertEqual( 367 | set(PermSet.objects.filter(is_private=True).values_list('name', flat=True)), 368 | {'global', 'restricted'} 369 | ) 370 | self.assertEqual( 371 | set(PermSet.objects.filter(is_locked=True).values_list('name', flat=True)), 372 | {'restricted'} 373 | ) 374 | self.assertEqual( 375 | set(PermSet.objects.filter(is_hidden=True).values_list('name', flat=True)), 376 | {'restricted'} 377 | ) 378 | self.assertEqual( 379 | set(PermSet.objects.all().values_list('name', flat=True)), 380 | {'global', 'restricted', 'custom'} 381 | ) 382 | 383 | self.assertEqual( 384 | set(Perm.objects.values_list('name', flat=True)), 385 | {'can_view_stuff', 'can_edit_stuff', 'can_do_stuff', 'can_alter_stuff'} 386 | ) 387 | self.assertEqual( 388 | set(Perm.objects.filter(is_locked=True).values_list('name', flat=True)), 389 | {'can_view_stuff'} 390 | ) 391 | self.assertEqual( 392 | set(Perm.objects.filter(is_hidden=True).values_list('name', flat=True)), 393 | {'can_view_stuff'} 394 | ) 395 | 396 | self.assertEqual( 397 | list(PermLevel.objects.order_by( 398 | 'perm__name', 399 | 'name' 400 | ).values_list( 401 | 'perm__name', 402 | 'name' 403 | )), 404 | [ 405 | ('can_alter_stuff', ''), 406 | ('can_do_stuff', 'all_stuff'), 407 | ('can_do_stuff', 'this_thing'), 408 | ('can_edit_stuff', 'all_stuff'), 409 | ('can_edit_stuff', 'some_stuff'), 410 | ('can_view_stuff', '') 411 | ] 412 | ) 413 | 414 | self.assertEqual( 415 | list( 416 | PermAccess.objects.order_by( 417 | 'perm_set__name', 418 | 'perm_levels__perm__name', 419 | 'perm_levels__name' 420 | ).values_list( 421 | 'perm_set__name', 422 | 'perm_levels__perm__name', 423 | 'perm_levels__name' 424 | ) 425 | ), 426 | [ 427 | ('custom', 'can_edit_stuff', 'all_stuff'), 428 | ('global', 'can_alter_stuff', ''), 429 | ('global', 'can_edit_stuff', 'all_stuff'), 430 | ('global', 'can_edit_stuff', 'some_stuff'), 431 | ('global', 'can_view_stuff', ''), 432 | ('restricted', 'can_do_stuff', 'all_stuff'), 433 | ('restricted', 'can_do_stuff', 'this_thing'), 434 | ('restricted', 'can_edit_stuff', 'some_stuff') 435 | ] 436 | ) 437 | 438 | @patch.object(core, 'get_restraint_config') 439 | def test_full_update_scenario_flush_default_access(self, mock_get_restraint_config): 440 | config = { 441 | 'perm_sets': { 442 | 'global': { 443 | 'display_name': 'Global', 444 | }, 445 | 'restricted': { 446 | 'display_name': 'Restricted', 447 | }, 448 | }, 449 | 'perms': { 450 | 'can_edit_stuff': { 451 | 'display_name': 'Can Edit Stuff', 452 | 'levels': { 453 | 'all_stuff': { 454 | 'display_name': 'All Stuff', 455 | 'id_filter': None, 456 | }, 457 | 'some_stuff': { 458 | 'display_name': 'Some Stuff', 459 | 'id_filter': None, 460 | }, 461 | }, 462 | }, 463 | 'can_view_stuff': { 464 | 'display_name': 'Can View Stuff', 465 | 'levels': constants.BOOLEAN_LEVELS_CONFIG, 466 | }, 467 | }, 468 | 'default_access': { 469 | 'global': { 470 | 'can_edit_stuff': ['all_stuff', 'some_stuff'], 471 | 'can_view_stuff': [constants.BOOLEAN_LEVELS_NAME], 472 | }, 473 | 'restricted': { 474 | 'can_edit_stuff': ['some_stuff'], 475 | } 476 | } 477 | } 478 | mock_get_restraint_config.return_value = config 479 | core.update_restraint_db() 480 | self.add_custom_permission_set() 481 | 482 | config = { 483 | 'perm_sets': { 484 | 'global': { 485 | 'display_name': 'Global', 486 | }, 487 | 'restricted': { 488 | 'display_name': 'Restricted', 489 | }, 490 | }, 491 | 'perms': { 492 | 'can_edit_stuff': { 493 | 'display_name': 'Can Edit Stuff', 494 | 'levels': { 495 | 'all_stuff': { 496 | 'display_name': 'All Stuff', 497 | 'id_filter': None, 498 | }, 499 | 'some_stuff': { 500 | 'display_name': 'Some Stuff', 501 | 'id_filter': None, 502 | }, 503 | }, 504 | }, 505 | 'can_view_stuff': { 506 | 'display_name': 'Can View Stuff', 507 | 'levels': constants.BOOLEAN_LEVELS_CONFIG, 508 | }, 509 | }, 510 | 'default_access': { 511 | 'global': { 512 | 'can_edit_stuff': ['all_stuff'], 513 | }, 514 | } 515 | } 516 | mock_get_restraint_config.return_value = config 517 | core.update_restraint_db(flush_default_access=True) 518 | 519 | self.assertEqual( 520 | set(PermSet.objects.filter(is_private=True).values_list('name', flat=True)), 521 | {'global', 'restricted'} 522 | ) 523 | self.assertEqual( 524 | set(PermSet.objects.all().values_list('name', flat=True)), 525 | {'custom', 'global', 'restricted'} 526 | ) 527 | 528 | self.assertEqual( 529 | set(Perm.objects.values_list('name', flat=True)), 530 | {'can_view_stuff', 'can_edit_stuff'} 531 | ) 532 | 533 | self.assertEqual( 534 | list(PermLevel.objects.order_by( 535 | 'perm__name', 536 | 'name' 537 | ).values_list( 538 | 'perm__name', 539 | 'name' 540 | )), 541 | [ 542 | ('can_edit_stuff', 'all_stuff'), 543 | ('can_edit_stuff', 'some_stuff'), 544 | ('can_view_stuff', '') 545 | ] 546 | ) 547 | 548 | self.assertEqual( 549 | list( 550 | PermAccess.objects.order_by( 551 | 'perm_set__name', 552 | 'perm_levels__perm__name', 553 | 'perm_levels__name' 554 | ).values_list( 555 | 'perm_set__name', 556 | 'perm_levels__perm__name', 557 | 'perm_levels__name' 558 | ) 559 | ), 560 | [ 561 | ('custom', 'can_edit_stuff', 'all_stuff'), 562 | ('global', 'can_edit_stuff', 'all_stuff'), 563 | ('restricted', None, None) 564 | ] 565 | ) 566 | -------------------------------------------------------------------------------- /restraint/tests/models_tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.test import SimpleTestCase, TestCase 4 | from django_dynamic_fixture import N, G, F 5 | 6 | from restraint.models import PermSet, Perm, PermLevel, PermAccess 7 | 8 | 9 | class PermSetTest(SimpleTestCase): 10 | def test_str(self): 11 | ps = N(PermSet, display_name='My Perm Set') 12 | self.assertTrue(str(ps), 'My Perm Set') 13 | 14 | 15 | class PermTest(SimpleTestCase): 16 | def test_str(self): 17 | ps = N(Perm, display_name='My Perm') 18 | self.assertTrue(str(ps), 'My Perm') 19 | 20 | 21 | class PermLevelTest(TestCase): 22 | def test_str(self): 23 | ps = N(PermLevel, display_name='My Perm Level') 24 | self.assertTrue(str(ps), 'My Perm Level') 25 | 26 | 27 | class PermAccessTest(TestCase): 28 | def test_set_default(self): 29 | """ 30 | Test setting default access 31 | """ 32 | permission_set = G(PermSet, name='my_set') 33 | permission_level = G(PermLevel, perm=F(name='my_perm'), name='my_level') 34 | PermAccess.objects.set_default( 35 | permission_set_name=permission_set.name, 36 | permission_name='my_perm', 37 | levels=[permission_level.name] 38 | ) 39 | pa = PermAccess.objects.get(perm_user_id=0, perm_user_type=None, perm_set=permission_set) 40 | self.assertEqual(list(pa.perm_levels.all()), [permission_level]) 41 | 42 | # Set defaults to none 43 | PermAccess.objects.set_default( 44 | permission_set_name=permission_set.name, 45 | permission_name='my_perm', 46 | ) 47 | pa = PermAccess.objects.get(perm_user_id=0, perm_user_type=None, perm_set=permission_set) 48 | self.assertEqual(list(pa.perm_levels.all()), []) 49 | 50 | def test_add_individual_access_level_exists(self): 51 | """ 52 | Tests adding an individual permission to a user. 53 | """ 54 | u = G(User) 55 | pl = G(PermLevel, perm=F(name='my_perm'), name='my_level') 56 | 57 | PermAccess.objects.add_individual_access(u, 'my_perm', 'my_level') 58 | PermAccess.objects.add_individual_access(u, 'my_perm', 'my_level') 59 | 60 | pa = PermAccess.objects.get(perm_user_id=u.id, perm_user_type=ContentType.objects.get_for_model(u)) 61 | self.assertEqual(list(pa.perm_levels.all()), [pl]) 62 | 63 | def test_remove_individual_access_level_exists(self): 64 | """ 65 | Tests adding an individual permission to a user. 66 | """ 67 | u = G(User) 68 | G(PermLevel, perm=F(name='my_perm'), name='my_level') 69 | 70 | PermAccess.objects.add_individual_access(u, 'my_perm', 'my_level') 71 | PermAccess.objects.add_individual_access(u, 'my_perm', 'my_level') 72 | PermAccess.objects.remove_individual_access(u, 'my_perm', 'my_level') 73 | 74 | pa = PermAccess.objects.get(perm_user_id=u.id, perm_user_type=ContentType.objects.get_for_model(u)) 75 | self.assertEqual(list(pa.perm_levels.all()), []) 76 | -------------------------------------------------------------------------------- /restraint/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] # pragma: no cover 2 | -------------------------------------------------------------------------------- /restraint/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.3.1' 2 | -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides the ability to run test on a standalone Django app. 3 | """ 4 | import sys 5 | from optparse import OptionParser 6 | 7 | import django 8 | 9 | from settings import configure_settings 10 | 11 | 12 | # Configure the default settings 13 | configure_settings() 14 | if django.VERSION[1] >= 7: 15 | django.setup() 16 | 17 | 18 | # Django nose must be imported here since it depends on the settings being configured 19 | from django_nose import NoseTestSuiteRunner 20 | 21 | 22 | def run(*test_args, **kwargs): 23 | if not test_args: 24 | test_args = ['restraint'] 25 | 26 | kwargs.setdefault('interactive', False) 27 | 28 | test_runner = NoseTestSuiteRunner(**kwargs) 29 | 30 | failures = test_runner.run_tests(test_args) 31 | sys.exit(failures) 32 | 33 | 34 | if __name__ == '__main__': 35 | parser = OptionParser() 36 | parser.add_option('--verbosity', dest='verbosity', action='store', default=1, type=int) 37 | (options, args) = parser.parse_args() 38 | 39 | run(*args, **options.__dict__) 40 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from django.conf import settings 5 | 6 | 7 | def configure_settings(): 8 | """ 9 | Configures settings for manage.py and for run_tests.py. 10 | """ 11 | if not settings.configured: 12 | # Determine the database settings depending on if a test_db var is set in CI mode or not 13 | test_db = os.environ.get('DB', None) 14 | if test_db is None: 15 | db_config = { 16 | 'ENGINE': 'django.db.backends.postgresql', 17 | 'NAME': 'django_restraint', 18 | 'USER': os.environ.get('DBUSER', 'ambition_dev'), 19 | 'PASSWORD': os.environ.get('DBPASS', 'ambition_dev'), 20 | 'HOST': os.environ.get('DBHOST', 'localhost') 21 | } 22 | elif test_db == 'postgres': 23 | db_config = { 24 | 'ENGINE': 'django.db.backends.postgresql', 25 | 'USER': 'postgres', 26 | 'NAME': 'restraint', 27 | } 28 | elif test_db == 'sqlite': 29 | db_config = { 30 | 'ENGINE': 'django.db.backends.sqlite3', 31 | 'NAME': 'restraint', 32 | } 33 | else: 34 | raise RuntimeError('Unsupported test DB {0}'.format(test_db)) 35 | 36 | # Check env for db override (used for github actions) 37 | if os.environ.get('DB_SETTINGS'): 38 | db_config = json.loads(os.environ.get('DB_SETTINGS')) 39 | 40 | settings.configure( 41 | TEST_RUNNER='django_nose.NoseTestSuiteRunner', 42 | NOSE_ARGS=['--nocapture', '--nologcapture', '--verbosity=1'], 43 | MIDDLEWARE_CLASSES=(), 44 | DATABASES={ 45 | 'default': db_config, 46 | }, 47 | INSTALLED_APPS=( 48 | 'django.contrib.auth', 49 | 'django.contrib.contenttypes', 50 | 'restraint', 51 | 'restraint.tests', 52 | ), 53 | ROOT_URLCONF='restraint.urls', 54 | DEBUG=False, 55 | SECRET_KEY='12345', 56 | DEFAULT_AUTO_FIELD='django.db.models.AutoField', 57 | RESTRAINT_CONFIGURATION=( 58 | 'restraint.tests.configuration.get_configuration' 59 | ) 60 | ) 61 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = build,docs,venv,env,*.egg,migrations,south_migrations 4 | max-complexity = 10 5 | ignore = E402 6 | 7 | [build_sphinx] 8 | source-dir = docs/ 9 | build-dir = docs/_build 10 | all_files = 1 11 | 12 | [bdist_wheel] 13 | universal = 1 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # import multiprocessing to avoid this bug (http://bugs.python.org/issue15881#msg170215) 2 | import multiprocessing 3 | assert multiprocessing 4 | import re 5 | from setuptools import setup, find_packages 6 | 7 | 8 | def get_version(): 9 | """ 10 | Extracts the version number from the version.py file. 11 | """ 12 | VERSION_FILE = 'restraint/version.py' 13 | mo = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', open(VERSION_FILE, 'rt').read(), re.M) 14 | if mo: 15 | return mo.group(1) 16 | else: 17 | raise RuntimeError('Unable to find version string in {0}.'.format(VERSION_FILE)) 18 | 19 | 20 | def get_lines(file_path): 21 | return open(file_path, 'r').read().split('\n') 22 | 23 | 24 | install_requires = get_lines('requirements/requirements.txt') 25 | tests_require = get_lines('requirements/requirements-testing.txt') 26 | 27 | setup( 28 | name='django-restraint', 29 | version=get_version(), 30 | description='A dynamic object-level permission system for Django', 31 | long_description=open('README.rst').read(), 32 | url='https://github.com/ambitioninc/django-restraint', 33 | author='Wes Kendall', 34 | author_email='opensource@ambition.com', 35 | keywords='Django, Permission', 36 | packages=find_packages(), 37 | classifiers=[ 38 | 'Programming Language :: Python', 39 | 'Programming Language :: Python :: 3.7', 40 | 'Programming Language :: Python :: 3.8', 41 | 'Programming Language :: Python :: 3.9', 42 | 'Intended Audience :: Developers', 43 | 'License :: OSI Approved :: MIT License', 44 | 'Operating System :: OS Independent', 45 | 'Framework :: Django', 46 | 'Framework :: Django :: 3.2', 47 | 'Framework :: Django :: 4.0', 48 | 'Framework :: Django :: 4.1', 49 | 'Framework :: Django :: 4.2', 50 | ], 51 | license='MIT', 52 | install_requires=install_requires, 53 | tests_require=tests_require, 54 | test_suite='run_tests.run', 55 | include_package_data=True, 56 | zip_safe=False, 57 | ) 58 | --------------------------------------------------------------------------------