├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── code_check.py ├── csv_imports ├── posts.csv ├── posts.xls ├── posts_0cJUUjo.csv ├── posts_132Et0f.xls ├── posts_1X3ITkJ.xls ├── posts_1fM4PTA.csv ├── posts_1x38OpJ.xls ├── posts_4qiXDT3.xls ├── posts_533sczl.csv ├── posts_5OUYDfL.csv ├── posts_88BFDl4.xls ├── posts_9UuUJBN.xls ├── posts_9sBPoMY.csv ├── posts_B04z2w3.xls ├── posts_BOAqLih.csv ├── posts_CqjVtXG.csv ├── posts_D05K7YQ.xls ├── posts_EPNKCjf.xls ├── posts_Ez4OoRt.xls ├── posts_FKWCKan.csv ├── posts_FYVnFFo.xls ├── posts_FenAxen.xls ├── posts_FfBw12p.csv ├── posts_GEYWZFc.xls ├── posts_Gjmc21T.csv ├── posts_Go2yInw.xls ├── posts_J4Qlyq9.csv ├── posts_JAM5urP.csv ├── posts_JoWQHlN.xls ├── posts_KQ3zwmC.xls ├── posts_L5uG9LN.csv ├── posts_LCBBoLA.csv ├── posts_LODiLgz.csv ├── posts_LhO4Xm6.xls ├── posts_MUpPRdx.csv ├── posts_RxZbipS.csv ├── posts_T8XfnpF.csv ├── posts_VCzNWX0.csv ├── posts_VPFwLLo.xls ├── posts_VWr2T2k.xls ├── posts_VdvCYic.csv ├── posts_WaHth54.csv ├── posts_XWiYBvM.xls ├── posts_YRR4wTb.csv ├── posts_YUXh4Ap.csv ├── posts_Z6r42VE.csv ├── posts_dP4hWfE.xls ├── posts_dZJZ069.xls ├── posts_gZJrO7Y.xls ├── posts_gaW1Rwj.xls ├── posts_gtOV0mU.xls ├── posts_hbg2GLB.xls ├── posts_iICze6z.csv ├── posts_jzzIC3Z.csv ├── posts_kFCtM1s.xls ├── posts_kq8BpxO.xls ├── posts_liEkSJk.csv ├── posts_nNpBP5t.csv ├── posts_oRorfg9.csv ├── posts_qYq9TeL.csv ├── posts_sDJck1R.csv ├── posts_skaSah6.csv ├── posts_tUkOrcS.xls ├── posts_ttmJaDj.csv ├── posts_u78oZB9.xls ├── posts_uovp461.xls ├── posts_uvp61RD.xls ├── posts_w5viknL.csv ├── posts_wQ0JioO.xls ├── posts_yWQCkTm.csv ├── posts_yhihhzF.xls └── posts_yztcHBP.xls ├── docs ├── Makefile ├── conf.py ├── createview.rst ├── deleteview.rst ├── img │ ├── quickstart1.png │ ├── quickstart2.png │ ├── quickstart3.png │ ├── quickstart4.png │ ├── quickstart5.png │ └── quickstart6.png ├── index.rst ├── listview.rst ├── misc.rst ├── perms.rst ├── quickstart.rst ├── readview.rst ├── templates.rst ├── updateview.rst ├── users.rst └── views.rst ├── manage.py ├── poetry.lock ├── pyproject.toml ├── setup.cfg ├── smartmin ├── __init__.py ├── apps.py ├── backends.py ├── email.py ├── management │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ ├── collect_sql.py │ │ └── migrate_manual.py │ └── tests.py ├── middleware.py ├── mixins.py ├── models.py ├── pdf.py ├── perms.py ├── static │ ├── css │ │ ├── bootstrap-datepicker3.css │ │ ├── bootstrap-theme.css │ │ ├── bootstrap.css │ │ ├── smartmin_styles.css │ │ └── styles.css │ ├── fonts │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ ├── glyphicons-halflings-regular.woff │ │ └── glyphicons-halflings-regular.woff2 │ ├── img │ │ ├── active.png │ │ ├── background.jpg │ │ ├── button.png │ │ ├── check.png │ │ ├── glyphicons-halflings-white.png │ │ ├── glyphicons-halflings.png │ │ ├── in.png │ │ ├── loc.png │ │ ├── nyaruka.png │ │ ├── out.png │ │ ├── smartmin.png │ │ ├── smartmin │ │ │ ├── arrow-down.gif │ │ │ ├── arrow-up.gif │ │ │ ├── changelist-bg.gif │ │ │ ├── changelist-bg_rtl.gif │ │ │ ├── chooser-bg.gif │ │ │ ├── chooser_stacked-bg.gif │ │ │ ├── default-bg-reverse.gif │ │ │ ├── default-bg.gif │ │ │ ├── deleted-overlay.gif │ │ │ ├── icon-no.gif │ │ │ ├── icon-unknown.gif │ │ │ ├── icon-yes.gif │ │ │ ├── icon_addlink.gif │ │ │ ├── icon_alert.gif │ │ │ ├── icon_calendar.gif │ │ │ ├── icon_changelink.gif │ │ │ ├── icon_clock.gif │ │ │ ├── icon_deletelink.gif │ │ │ ├── icon_error.gif │ │ │ ├── icon_searchbox.png │ │ │ ├── icon_success.gif │ │ │ ├── inline-delete-8bit.png │ │ │ ├── inline-delete.png │ │ │ ├── inline-restore-8bit.png │ │ │ ├── inline-restore.png │ │ │ ├── inline-splitter-bg.gif │ │ │ ├── loading.gif │ │ │ ├── nav-bg-grabber.gif │ │ │ ├── nav-bg-reverse.gif │ │ │ ├── nav-bg.gif │ │ │ ├── selector-add.gif │ │ │ ├── selector-addall.gif │ │ │ ├── selector-remove.gif │ │ │ ├── selector-removeall.gif │ │ │ ├── selector-search.gif │ │ │ ├── selector_stacked-add.gif │ │ │ ├── selector_stacked-remove.gif │ │ │ ├── tool-left.gif │ │ │ ├── tool-left_over.gif │ │ │ ├── tool-right.gif │ │ │ ├── tool-right_over.gif │ │ │ ├── tooltag-add.gif │ │ │ ├── tooltag-add_over.gif │ │ │ ├── tooltag-arrowright.gif │ │ │ └── tooltag-arrowright_over.gif │ │ ├── sort.gif │ │ ├── sort.png │ │ ├── sort_asc.png │ │ └── sort_dsc.png │ ├── js │ │ ├── bootstrap-datepicker.js │ │ ├── bootstrap.min.js │ │ ├── jquery.pjax.js │ │ ├── libs │ │ │ ├── jquery-1.12.4.min.js │ │ │ ├── jquery.min.js │ │ │ └── jquery.url.js │ │ └── scripts.js │ └── top.png ├── templates │ ├── base.html │ ├── csv_imports │ │ └── importtask_read.html │ ├── frame.html │ └── smartmin │ │ ├── base.html │ │ ├── create.html │ │ ├── delete_confirm.html │ │ ├── field.html │ │ ├── form.html │ │ ├── list.html │ │ ├── pjax.html │ │ ├── read.html │ │ ├── update.html │ │ └── users │ │ ├── login.html │ │ ├── no_user_email.txt │ │ ├── user_email.txt │ │ ├── user_expired.html │ │ ├── user_failed.html │ │ ├── user_forget.html │ │ ├── user_list.html │ │ ├── user_newpassword.html │ │ ├── user_recover.html │ │ └── user_update.html ├── templatetags │ ├── __init__.py │ └── smartmin.py ├── tests.py ├── users │ ├── __init__.py │ ├── middleware.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_remove_failed_logins.py │ │ ├── 0003_auto_20210219_1548.py │ │ ├── 0004_alter_failedlogin_failed_on_and_more.py │ │ └── __init__.py │ ├── models.py │ ├── urls.py │ └── views.py ├── views.py └── widgets.py └── test_runner ├── __init__.py ├── blog ├── __init__.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_post_uuid.py │ ├── 0003_auto_20170223_0917.py │ ├── 0004_auto_20170609_0743.py │ ├── 0005_auto_20180615_2036.py │ ├── 0006_post_image.py │ ├── 0007_alter_category_created_by_alter_category_modified_by_and_more.py │ └── __init__.py ├── models.py ├── templates │ └── blog │ │ └── user_list.html ├── test_files │ ├── bom_import.csv │ ├── posts.csv │ └── posts.xls ├── tests.py ├── urls.py └── views.py ├── celeryapp.py ├── settings.py └── urls.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: Test 6 | strategy: 7 | matrix: 8 | python-version: ["3.11.x", "3.12.x", "3.13.x"] 9 | django-version: ["5.1.0", "5.2.1"] 10 | runs-on: ubuntu-latest 11 | 12 | services: 13 | postgres: 14 | image: postgres:15-alpine 15 | env: 16 | POSTGRES_DB: smartmin 17 | POSTGRES_USER: smartmin 18 | POSTGRES_PASSWORD: nyaruka 19 | ports: 20 | - 5432:5432 21 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | 27 | - name: Install Python 28 | uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - name: Install Poetry 33 | uses: snok/install-poetry@v1 34 | with: 35 | virtualenvs-in-project: true 36 | 37 | - name: Initialize environment 38 | run: | 39 | poetry install 40 | poetry add django@~${{ matrix.django-version }} 41 | 42 | - name: Run pre-test checks 43 | run: poetry run ./code_check.py --debug 44 | 45 | - name: Run tests 46 | run: | 47 | poetry run coverage run manage.py test smartmin test_runner --verbosity=2 48 | poetry run coverage report -i 49 | poetry run coverage xml 50 | 51 | - name: Upload coverage 52 | if: success() 53 | uses: codecov/codecov-action@v4 54 | with: 55 | token: ${{ secrets.CODECOV_TOKEN }} 56 | fail_ci_if_error: true 57 | 58 | release: 59 | name: Release 60 | needs: [test] 61 | if: startsWith(github.ref, 'refs/tags/') 62 | runs-on: ubuntu-latest 63 | steps: 64 | - name: Checkout code 65 | uses: actions/checkout@v4 66 | 67 | - name: Install Python 68 | uses: actions/setup-python@v5 69 | with: 70 | python-version: "3.10.x" 71 | 72 | - name: Publish release 73 | run: | 74 | python -m pip install -U pip poetry 75 | poetry build 76 | poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }} 77 | poetry publish 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.sw[po] 3 | *.pyc 4 | *.db 5 | *~ 6 | env/* 7 | *# 8 | smartmin.egg-info 9 | dist 10 | docs/_build 11 | .coverage 12 | build 13 | coverage-report 14 | celerybeat-schedule 15 | 16 | *.DS_Store 17 | .tox/ 18 | .vscode/ 19 | deploy 20 | fabric 21 | fabfile.* 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Nyaruka 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the 5 | following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following 8 | disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following 11 | disclaimer in the documentation and/or other materials provided with the distribution. 12 | 13 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 17 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 19 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 21 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 22 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django Smartmin 2 | =============== 3 | 4 | [![Build Status](https://github.com/nyaruka/smartmin/workflows/CI/badge.svg)](https://github.com/nyaruka/smartmin/actions?query=workflow%3ACI) 5 | [![codecov](https://codecov.io/gh/nyaruka/smartmin/branch/main/graph/badge.svg)](https://codecov.io/gh/nyaruka/smartmin) 6 | [![PyPI Release](https://img.shields.io/pypi/v/smartmin.svg)](https://pypi.python.org/pypi/smartmin/) 7 | 8 | Smartmin was born out of the frustration of the Django admin site not being well suited to being exposed to clients. 9 | It aims to allow you to quickly build scaffolding which you can customize by using Django views. 10 | 11 | It is very opinionated in how it works, if you don't agree, Smartmin may not be for you: 12 | 13 | - Permissions are used to gate access to each page, embrace permissions throughout and you'll love this 14 | - CRUDL operations at the object level, that is, Create, Read, Update, Delete and List, permissions and views are based 15 | around this 16 | - URL automapping via the the CRUDL objects, this should keep things very very DRY 17 | 18 | About Versions 19 | ============== 20 | 21 | Smartmin tries to stay in lock step with the latest Django versions. With each new major Django release we will release 22 | a new Smartmin major version and we will reserve major changes (possibly breaking backwards compatibility) for such 23 | releases. 24 | 25 | The latest version is the 5.* series which supports the Django 5.0 and 4.2. 26 | 27 | About 28 | ===== 29 | 30 | The full documentation can be found at: http://readthedocs.org/docs/smartmin/en/latest/ 31 | 32 | The official source code repository is: http://www.github.com/nyaruka/smartmin/ 33 | 34 | Built in Rwanda by [Nyaruka Ltd](http://www.nyaruka.com). 35 | -------------------------------------------------------------------------------- /code_check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import subprocess 5 | 6 | import colorama 7 | 8 | parser = argparse.ArgumentParser(description="Code checks") 9 | parser.add_argument("--debug", action="store_true") 10 | args = parser.parse_args() 11 | 12 | DEBUG = args.debug 13 | 14 | 15 | def cmd(line): 16 | if DEBUG: 17 | print(colorama.Style.DIM + "% " + line + colorama.Style.RESET_ALL) 18 | try: 19 | output = subprocess.check_output(line, shell=True).decode("utf-8") 20 | if DEBUG: 21 | print(colorama.Style.DIM + output + colorama.Style.RESET_ALL) 22 | return output 23 | except subprocess.CalledProcessError as e: 24 | print(colorama.Fore.RED + e.stdout.decode("utf-8") + colorama.Style.RESET_ALL) 25 | exit(1) 26 | 27 | 28 | def status(line): 29 | print(colorama.Fore.GREEN + f">>> {line}..." + colorama.Style.RESET_ALL) 30 | 31 | 32 | if __name__ == "__main__": 33 | colorama.init() 34 | 35 | status("Make any missing migrations") 36 | cmd("python manage.py makemigrations") 37 | 38 | status("Running black") 39 | cmd("black smartmin test_runner") 40 | 41 | status("Running ruff") 42 | cmd("ruff smartmin") 43 | 44 | status("Running isort") 45 | cmd("isort smartmin") 46 | 47 | # if any code changes were made, exit with error 48 | if cmd("git diff smartmin test_runner"): 49 | print("👎 " + colorama.Fore.RED + "Changes to be committed") 50 | exit(1) 51 | else: 52 | print("👍 " + colorama.Fore.GREEN + "Code looks good. Make that PR!") 53 | -------------------------------------------------------------------------------- /csv_imports/posts.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts.xls -------------------------------------------------------------------------------- /csv_imports/posts_0cJUUjo.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_132Et0f.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_132Et0f.xls -------------------------------------------------------------------------------- /csv_imports/posts_1X3ITkJ.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_1X3ITkJ.xls -------------------------------------------------------------------------------- /csv_imports/posts_1fM4PTA.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_1x38OpJ.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_1x38OpJ.xls -------------------------------------------------------------------------------- /csv_imports/posts_4qiXDT3.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_4qiXDT3.xls -------------------------------------------------------------------------------- /csv_imports/posts_533sczl.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_5OUYDfL.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_88BFDl4.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_88BFDl4.xls -------------------------------------------------------------------------------- /csv_imports/posts_9UuUJBN.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_9UuUJBN.xls -------------------------------------------------------------------------------- /csv_imports/posts_9sBPoMY.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_B04z2w3.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_B04z2w3.xls -------------------------------------------------------------------------------- /csv_imports/posts_BOAqLih.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_CqjVtXG.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_D05K7YQ.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_D05K7YQ.xls -------------------------------------------------------------------------------- /csv_imports/posts_EPNKCjf.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_EPNKCjf.xls -------------------------------------------------------------------------------- /csv_imports/posts_Ez4OoRt.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_Ez4OoRt.xls -------------------------------------------------------------------------------- /csv_imports/posts_FKWCKan.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_FYVnFFo.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_FYVnFFo.xls -------------------------------------------------------------------------------- /csv_imports/posts_FenAxen.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_FenAxen.xls -------------------------------------------------------------------------------- /csv_imports/posts_FfBw12p.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_GEYWZFc.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_GEYWZFc.xls -------------------------------------------------------------------------------- /csv_imports/posts_Gjmc21T.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_Go2yInw.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_Go2yInw.xls -------------------------------------------------------------------------------- /csv_imports/posts_J4Qlyq9.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_JAM5urP.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_JoWQHlN.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_JoWQHlN.xls -------------------------------------------------------------------------------- /csv_imports/posts_KQ3zwmC.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_KQ3zwmC.xls -------------------------------------------------------------------------------- /csv_imports/posts_L5uG9LN.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_LCBBoLA.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_LODiLgz.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_LhO4Xm6.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_LhO4Xm6.xls -------------------------------------------------------------------------------- /csv_imports/posts_MUpPRdx.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_RxZbipS.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_T8XfnpF.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_VCzNWX0.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_VPFwLLo.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_VPFwLLo.xls -------------------------------------------------------------------------------- /csv_imports/posts_VWr2T2k.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_VWr2T2k.xls -------------------------------------------------------------------------------- /csv_imports/posts_VdvCYic.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_WaHth54.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_XWiYBvM.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_XWiYBvM.xls -------------------------------------------------------------------------------- /csv_imports/posts_YRR4wTb.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_YUXh4Ap.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_Z6r42VE.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_dP4hWfE.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_dP4hWfE.xls -------------------------------------------------------------------------------- /csv_imports/posts_dZJZ069.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_dZJZ069.xls -------------------------------------------------------------------------------- /csv_imports/posts_gZJrO7Y.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_gZJrO7Y.xls -------------------------------------------------------------------------------- /csv_imports/posts_gaW1Rwj.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_gaW1Rwj.xls -------------------------------------------------------------------------------- /csv_imports/posts_gtOV0mU.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_gtOV0mU.xls -------------------------------------------------------------------------------- /csv_imports/posts_hbg2GLB.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_hbg2GLB.xls -------------------------------------------------------------------------------- /csv_imports/posts_iICze6z.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_jzzIC3Z.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_kFCtM1s.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_kFCtM1s.xls -------------------------------------------------------------------------------- /csv_imports/posts_kq8BpxO.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_kq8BpxO.xls -------------------------------------------------------------------------------- /csv_imports/posts_liEkSJk.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_nNpBP5t.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_oRorfg9.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_qYq9TeL.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_sDJck1R.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_skaSah6.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_tUkOrcS.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_tUkOrcS.xls -------------------------------------------------------------------------------- /csv_imports/posts_ttmJaDj.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_u78oZB9.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_u78oZB9.xls -------------------------------------------------------------------------------- /csv_imports/posts_uovp461.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_uovp461.xls -------------------------------------------------------------------------------- /csv_imports/posts_uvp61RD.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_uvp61RD.xls -------------------------------------------------------------------------------- /csv_imports/posts_w5viknL.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_wQ0JioO.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_wQ0JioO.xls -------------------------------------------------------------------------------- /csv_imports/posts_yWQCkTm.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /csv_imports/posts_yhihhzF.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_yhihhzF.xls -------------------------------------------------------------------------------- /csv_imports/posts_yztcHBP.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/csv_imports/posts_yztcHBP.xls -------------------------------------------------------------------------------- /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 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " pickle to make pickle files" 22 | @echo " json to make JSON files" 23 | @echo " htmlhelp to make HTML files and a HTML help project" 24 | @echo " qthelp to make HTML files and a qthelp project" 25 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 26 | @echo " changes to make an overview of all changed/added/deprecated items" 27 | @echo " linkcheck to check all external links for integrity" 28 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 29 | 30 | clean: 31 | -rm -rf $(BUILDDIR)/* 32 | 33 | html: 34 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 35 | @echo 36 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 37 | 38 | dirhtml: 39 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 40 | @echo 41 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 42 | 43 | pickle: 44 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 45 | @echo 46 | @echo "Build finished; now you can process the pickle files." 47 | 48 | json: 49 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 50 | @echo 51 | @echo "Build finished; now you can process the JSON files." 52 | 53 | htmlhelp: 54 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 55 | @echo 56 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 57 | ".hhp project file in $(BUILDDIR)/htmlhelp." 58 | 59 | qthelp: 60 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 61 | @echo 62 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 63 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 64 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoSmartmin.qhcp" 65 | @echo "To view the help file:" 66 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoSmartmin.qhc" 67 | 68 | latex: 69 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 70 | @echo 71 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 72 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 73 | "run these through (pdf)latex." 74 | 75 | changes: 76 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 77 | @echo 78 | @echo "The overview file is in $(BUILDDIR)/changes." 79 | 80 | linkcheck: 81 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 82 | @echo 83 | @echo "Link check complete; look for any errors in the above output " \ 84 | "or in $(BUILDDIR)/linkcheck/output.txt." 85 | 86 | doctest: 87 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 88 | @echo "Testing of doctests in the sources finished, look at the " \ 89 | "results in $(BUILDDIR)/doctest/output.txt." 90 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Django Smartmin documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Jun 20 16:37:41 2011. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | from __future__ import unicode_literals 14 | 15 | import sys, os 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | #sys.path.append(os.path.abspath('.')) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # Add any Sphinx extension module names here, as strings. They can be extensions 25 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 26 | extensions = [] 27 | 28 | # Add any paths that contain templates here, relative to this directory. 29 | templates_path = ['_templates'] 30 | 31 | # The suffix of source filenames. 32 | source_suffix = '.rst' 33 | 34 | # The encoding of source files. 35 | #source_encoding = 'utf-8' 36 | 37 | # The master toctree document. 38 | master_doc = 'index' 39 | 40 | # General information about the project. 41 | project = 'Django Smartmin' 42 | copyright = '2011, Nyaruka Ltd' 43 | 44 | # The version info for the project you're documenting, acts as replacement for 45 | # |version| and |release|, also used in various other places throughout the 46 | # built documents. 47 | # 48 | # The short X.Y version. 49 | version = '1.8.1' 50 | # The full version, including alpha/beta/rc tags. 51 | release = '1.8.1' 52 | 53 | # The language for content autogenerated by Sphinx. Refer to documentation 54 | # for a list of supported languages. 55 | #language = None 56 | 57 | # There are two options for replacing |today|: either, you set today to some 58 | # non-false value, then it is used: 59 | #today = '' 60 | # Else, today_fmt is used as the format for a strftime call. 61 | #today_fmt = '%B %d, %Y' 62 | 63 | # List of documents that shouldn't be included in the build. 64 | #unused_docs = [] 65 | 66 | # List of directories, relative to source directory, that shouldn't be searched 67 | # for source files. 68 | exclude_trees = ['_build'] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | #default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | #add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | #add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | #show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. Major themes that come with 94 | # Sphinx are currently 'default' and 'sphinxdoc'. 95 | html_theme = 'sphinxdoc' 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | #html_theme_options = {} 101 | 102 | # Add any paths that contain custom themes here, relative to this directory. 103 | #html_theme_path = [] 104 | 105 | # The name for this set of Sphinx documents. If None, it defaults to 106 | # " v documentation". 107 | #html_title = None 108 | 109 | # A shorter title for the navigation bar. Default is the same as html_title. 110 | #html_short_title = None 111 | 112 | # The name of an image file (relative to this directory) to place at the top 113 | # of the sidebar. 114 | #html_logo = None 115 | 116 | # The name of an image file (within the static path) to use as favicon of the 117 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 118 | # pixels large. 119 | #html_favicon = None 120 | 121 | # Add any paths that contain custom static files (such as style sheets) here, 122 | # relative to this directory. They are copied after the builtin static files, 123 | # so a file named "default.css" will overwrite the builtin "default.css". 124 | html_static_path = ['_static'] 125 | 126 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 127 | # using the given strftime format. 128 | #html_last_updated_fmt = '%b %d, %Y' 129 | 130 | # If true, SmartyPants will be used to convert quotes and dashes to 131 | # typographically correct entities. 132 | #html_use_smartypants = True 133 | 134 | # Custom sidebar templates, maps document names to template names. 135 | #html_sidebars = {} 136 | 137 | # Additional templates that should be rendered to pages, maps page names to 138 | # template names. 139 | #html_additional_pages = {} 140 | 141 | # If false, no module index is generated. 142 | #html_use_modindex = True 143 | 144 | # If false, no index is generated. 145 | #html_use_index = True 146 | 147 | # If true, the index is split into individual pages for each letter. 148 | #html_split_index = False 149 | 150 | # If true, links to the reST sources are added to the pages. 151 | #html_show_sourcelink = True 152 | 153 | # If true, an OpenSearch description file will be output, and all pages will 154 | # contain a tag referring to it. The value of this option must be the 155 | # base URL from which the finished HTML is served. 156 | #html_use_opensearch = '' 157 | 158 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 159 | #html_file_suffix = '' 160 | 161 | # Output file base name for HTML help builder. 162 | htmlhelp_basename = 'DjangoSmartmindoc' 163 | 164 | 165 | # -- Options for LaTeX output -------------------------------------------------- 166 | 167 | # The paper size ('letter' or 'a4'). 168 | #latex_paper_size = 'letter' 169 | 170 | # The font size ('10pt', '11pt' or '12pt'). 171 | #latex_font_size = '10pt' 172 | 173 | # Grouping the document tree into LaTeX files. List of tuples 174 | # (source start file, target name, title, author, documentclass [howto/manual]). 175 | latex_documents = [ 176 | ('index', 'DjangoSmartmin.tex', 'Django Smartmin Documentation', 177 | 'Nyaruka Ltd', 'manual'), 178 | ] 179 | 180 | # The name of an image file (relative to this directory) to place at the top of 181 | # the title page. 182 | #latex_logo = None 183 | 184 | # For "manual" documents, if this is true, then toplevel headings are parts, 185 | # not chapters. 186 | #latex_use_parts = False 187 | 188 | # Additional stuff for the LaTeX preamble. 189 | #latex_preamble = '' 190 | 191 | # Documents to append as an appendix to all manuals. 192 | #latex_appendices = [] 193 | 194 | # If false, no module index is generated. 195 | #latex_use_modindex = True 196 | -------------------------------------------------------------------------------- /docs/createview.rst: -------------------------------------------------------------------------------- 1 | SmartCreateView 2 | ================== 3 | 4 | The SmartCreateView provides a simple and quick way to create form pages for creating your objects. The following attributes are available. 5 | 6 | **fields** 7 | 8 | Defines which fields should be displayed in our form, and in what order. 9 | 10 | Note that if you'd like to have this be set at runtime, you can do so by overriding the ``derive_fields`` method 11 | 12 | **permission** 13 | 14 | Let's you set what permission the user must have in order to view this page. 15 | 16 | **grant_permissions** 17 | 18 | A list or tuple which defines what object level permissions should be granted to the logged in user upon creating this object. This can let you use permissions at an entity level, letting smartmin automatically grant the privileges appropriately. 19 | 20 | **template_name** 21 | 22 | The name of the template used to render this view. By default, this is set to ``smartmin/create.html`` but you can override it to whatever you'd like. 23 | 24 | Overriding 25 | ------------ 26 | 27 | You can also extend your SmartCreateView to modify behavior at runtime, the most common methods to override are listed below. 28 | 29 | **pre_save** 30 | 31 | Called after our form has been validated and cleaned and our object created, but before the object has actually been saved. This can be a good place to add derived attributes to your model. 32 | 33 | **post_save** 34 | 35 | Called after our object has been saved. Sometimes used to add permissions. 36 | 37 | **get_success_url** 38 | 39 | Returns what URL the page should go to after the form has been successfully submitted. 40 | 41 | 42 | -------------------------------------------------------------------------------- /docs/deleteview.rst: -------------------------------------------------------------------------------- 1 | SmartDeleteView 2 | ================== 3 | 4 | The SmartDeleteView provides a simple page for asking for confirmation and deleting an object. 5 | 6 | **cancel_url** 7 | 8 | What URL the user should be brought to if they choose to cancel deleting this object. 9 | 10 | **redirect_url** 11 | 12 | What URL the user should be brought to if they go through with deleting the object 13 | 14 | -------------------------------------------------------------------------------- /docs/img/quickstart1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/docs/img/quickstart1.png -------------------------------------------------------------------------------- /docs/img/quickstart2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/docs/img/quickstart2.png -------------------------------------------------------------------------------- /docs/img/quickstart3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/docs/img/quickstart3.png -------------------------------------------------------------------------------- /docs/img/quickstart4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/docs/img/quickstart4.png -------------------------------------------------------------------------------- /docs/img/quickstart5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/docs/img/quickstart5.png -------------------------------------------------------------------------------- /docs/img/quickstart6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/docs/img/quickstart6.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Django Smartmin documentation master file, created by 2 | sphinx-quickstart on Mon Jun 20 16:37:41 2011. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Introduction 7 | =========================================== 8 | 9 | Smartmin was born out of the frustration of the Django admin site not being well suited to being exposed to clients. 10 | Smartmin aims to allow you to quickly build scaffolding which you can customize by using Django class based views. 11 | 12 | It is very opininated in how it works, if you don't agree, Smartmin may not be for you: 13 | 14 | - Permissions are used to gate access to each page, embrace permissions throughout and you'll love this 15 | - CRUDL operations at the object level, that is, Create, Read, Update, Delete and List, permissions and views are based 16 | around this 17 | - URL automapping via the the CRUDL objects, this should keep things very very DRY 18 | 19 | The full documentation can be found at: 20 | http://readthedocs.org/docs/smartmin/en/latest/ 21 | 22 | The official source code repository is: 23 | http://www.github.com/nyaruka/smartmin/ 24 | 25 | Built in Rwanda by Nyaruka Ltd: 26 | http://www.nyaruka.com 27 | 28 | Installation 29 | =========================================== 30 | 31 | The easiest and fastest way of downloading smartmin is from the cheeseshop:: 32 | 33 | % pip install smartmin 34 | 35 | This will take care of installing all the appropriate dependencies as well. 36 | 37 | Configuration 38 | =========================================== 39 | 40 | To get started with smartmin, the following changes to your ``settings.py`` are needed:: 41 | 42 | # create the smartmin CRUDL permissions on all objects 43 | PERMISSIONS = { 44 | '*': ('create', # can create an object 45 | 'read', # can read an object, viewing it's details 46 | 'update', # can update an object 47 | 'delete', # can delete an object, 48 | 'list'), # can view a list of the objects 49 | } 50 | 51 | # assigns the permissions that each group should have, here creating an Administrator group with 52 | # authority to create and change users 53 | GROUP_PERMISSIONS = { 54 | "Administrator": ('auth.user.*',) 55 | } 56 | 57 | # set this if you want to use smartmin's user login 58 | LOGIN_URL = '/users/login' 59 | 60 | You'll also need to add smartmin to your installed apps:: 61 | 62 | INSTALLED_APPS = ( 63 | # .. other apps .. 64 | 65 | 'smartmin', 66 | ) 67 | 68 | Finally, if you want to use the default smartmin views for managing users and logging in, you'll want to add the 69 | smartmin.users app to your ``urls.py``:: 70 | 71 | urlpatterns = [ 72 | # .. other patterns .. 73 | url(r'^users/', include('smartmin.users.urls')), 74 | ] 75 | 76 | You can now sync your database and start the server:: 77 | 78 | % python manage.py migrate 79 | % python manage.py runserver 80 | 81 | And if you want to see a Smartmin view in action, check out smartmin's user management pages for a demo that 82 | functionality by pointing your browser to:: 83 | 84 | http://localhost:8000/users/user 85 | 86 | From here you can create, update and list users on the system, all using standard smartmin views. The total code to 87 | create all this functionality is less than 30 lines of Python. 88 | 89 | Versioning: 90 | =========================================== 91 | 92 | Smartmin will release major versions in step (or rather a bit behind) Django's major releases. Version 1.11 actually 93 | works against Django 1.11, 1.10 and 1.9 - we hope to support the 3 most recent versions in each release. Smartmin 94 | is used in quite a few of our projects, so we don't rock the boat too much, even in major releases. That said, we don't 95 | guarantee that major releases always be backwards compatible. 96 | 97 | At the onset of each new Django version we will upgrade Twitter Bootstrap to the current version. Currently for 1.11, 98 | which targets Django 1.11, that means Twitter Bootstrap 3. Note that some of our screenshots are a bit outdated, our 99 | standard views now use Bootstrap styling, not the more Django admin looking pages shown in our docs. (PRs accepted to 100 | fix this!) 101 | 102 | Contents: 103 | =========================================== 104 | 105 | .. toctree:: 106 | :maxdepth: 2 107 | 108 | quickstart 109 | views 110 | createview 111 | readview 112 | updateview 113 | deleteview 114 | listview 115 | templates 116 | perms 117 | users 118 | misc 119 | 120 | Indices and tables 121 | ================== 122 | 123 | * :ref:`genindex` 124 | * :ref:`modindex` 125 | * :ref:`search` 126 | 127 | -------------------------------------------------------------------------------- /docs/listview.rst: -------------------------------------------------------------------------------- 1 | SmartListView 2 | ================== 3 | 4 | The SmartListView provides the most bang for the buck, and was largely inspired by Django's own admin list API. It has the following options: 5 | 6 | **fields** 7 | 8 | Defines which fields should be displayed in the list, and in what order. 9 | 10 | The order of precedence to get the field value is first the View, by calling ``get_${field_name}``, then the object itself. This means you can easily define custom formatting of a field for a list view by simply declaring a new method:: 11 | 12 | class PostListView(SmartListView): 13 | model = Post 14 | fields = ('title', 'body') 15 | 16 | def get_body(self, obj): 17 | # only display first 50 characters of body 18 | return obj.body[:50] 19 | 20 | Note that if you'd like to have this be set at runtime, you can do so by overriding the ``derive_fields`` method 21 | 22 | **link_fields** 23 | 24 | Defines which fields should be turned into links to the object itself. By default, this is just the first item in the field list, but you can change it as you wish, including having more than one field. By default Smartmin will generate a link to the 'read' view for the object. 25 | 26 | You can modify what the link is by overriding ``lookup_field_link``:: 27 | 28 | class List(SmartListView): 29 | model = Country 30 | link_fields = ('name', 'currency') 31 | 32 | def lookup_field_link(self, context, field, obj): 33 | # Link our name and currency fields, each going to their own place 34 | if field == 'currency': 35 | return reverse('locales.currency_update', args=[obj.currency.id]) 36 | else: 37 | return reverse('locales.country_update', args=[obj.id]) 38 | 39 | Note that if you'd like to have this be set at runtime, you can do so by overriding the ``derive_link_fields`` method 40 | 41 | **search_fields** 42 | 43 | If set, then enables a search box which will search across the passed in fields. This should be a list or tuple. The values are used to build up a Q object, so you can specify standard Django manipulations if you'd like:: 44 | 45 | class List(SmartListView): 46 | model = User 47 | search_fields = ('username__icontains','first_name__icontains', 'last_name__icontains') 48 | 49 | Alternatively, if you want to customize the search even further, you can modify how the query is built by overriding the ``derive_queryset`` method. 50 | 51 | **template_name** 52 | 53 | The name of the template used to render this view. By default, this is set to ``smartmin/list.html`` but you can override it to whatever you'd like. 54 | 55 | **add_button** 56 | 57 | Whether an add button should be automatically added for this list view. Generally used with CRUDL. 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /docs/misc.rst: -------------------------------------------------------------------------------- 1 | Miscellaneous Utilities 2 | ======================== 3 | 4 | We've included a few bonus features that we find useful when developing django apps. 5 | 6 | Collect SQL Command 7 | ------------------- 8 | 9 | This is a management command to extract SQL operations from your Django migrations and organize them into several master 10 | SQL scripts:: 11 | 12 | python manage.py collect_sql 13 | 14 | This will extract any SQL statements passed to RunSQL operations and write them to ``current_indexes.sql``, 15 | ``current_triggers.sql`` and ``current_functions.sql``. 16 | 17 | Migrate Manual Command 18 | ---------------------- 19 | 20 | This is a management command to make it easier to run long-running Django data migrations manually. To make a migration 21 | compatible with this command, include a function called ``apply_manual`` which takes no parameters:: 22 | 23 | python manage.py migrate_manual flows 0123 24 | 25 | This will manually run the migration in the flows app with the prefix 0123. 26 | 27 | Django Compressor 28 | ----------------- 29 | 30 | Smartmin already comes with django-compressor support. The default ``base.html`` template will wrap your CSS and JS in 31 | ``{% compress %}`` tags in order to optimize your page load times. 32 | 33 | If you want to enable this, you'll just need to add ``compressor`` to your ``INSTALLED_APPS`` in ``settings.py``:: 34 | 35 | INSTALLED_APPS = ( 36 | # .. other apps .. 37 | 'compressor', 38 | ) 39 | 40 | And change the commented out ``{# compress #}`` tags in ``base.html`` to be valid, ie: ``{% compress %}``. 41 | 42 | 43 | PDB Template Tag 44 | ---------------- 45 | 46 | We all love ``pdb.set_trace()`` to help us debug problems, but sometimes you want to do the same thing in a template. 47 | The smartmin template tags include just that:: 48 | 49 | {% pdb %} 50 | 51 | Will throw you into a pdb session when it hits that tag. You can examine variables in the session (including the 52 | request) and debug your template live. 53 | -------------------------------------------------------------------------------- /docs/perms.rst: -------------------------------------------------------------------------------- 1 | 2 | Group Creation 3 | ================ 4 | 5 | Smartmin believes in using groups and permissions to manage access to all your site resources. Managing these can be a bare in Django however as the permission and group ids can change out from under you, making fixtures ill suited. 6 | 7 | Smartmin addresses this by letting you define your groups and the permissions for those groups within your ``settings.py``. Every time you run ``python manage.py syncdb``, smartmin will examine your settings and models and make sure all the permissions in sync. 8 | 9 | Defining Permissions 10 | ====================== 11 | 12 | You can define permissions per object or alternatively for all objects. Here we create the default smartmin 'create', 'read', 'update', 'list', 'delete' permissions on all objects:: 13 | 14 | PERMISSIONS = { 15 | '*': ('create', # can create an object 16 | 'read', # can read an object, viewing it's details 17 | 'update', # can update an object 18 | 'delete', # can delete an object, 19 | 'list'), # can view a list of the objects 20 | } 21 | 22 | You can also add specific permissions for particular objects if you'd like by specifying the path to the object and the verb you'd like to use for the permission:: 23 | 24 | PERMISSIONS = { 25 | 'fruits.apple': ('pick',) 26 | } 27 | 28 | Smartmin will name this permission automatically in the form: ``fruits.apple_pick``. Note that this is slightly different than standard Django convention, which usually uses the order of 'verb'->'object', but Smartmin does this on purpose so that URL reverse names and permissions are named identically. 29 | 30 | Assigning Permissions for Groups 31 | ================================== 32 | 33 | It is usually most convenient to assign users to particular groups, and assign permissions per group. Smartmin makes this easy by allowing you to define the groups that exist in your system, and the permissions granted to them via the settings file. Here's an example:: 34 | 35 | GROUP_PERMISSIONS = { 36 | "Administrator": ('auth.user_create', 'auth.user_read', 'auth.user_update', 37 | 'auth.user_delete', 'auth.user_list'), 38 | "Fruit Picker": ('fruits.apple_list', 'fruits.apple_pick'), 39 | } 40 | 41 | Again, these groups and permissions will automatically be created and granted when you run ``python manage.py syncdb`` 42 | 43 | If you want a particular user to have *ALL* permissions on an object, you can do so by using a wildcard format. For example, to have the Administrator group above be able to perform any action on the user object, you could use: ``auth.user.*``:: 44 | 45 | GROUP_PERMISSIONS = { 46 | "Administrator": ('auth.user.*', ), 47 | "Fruit Picker": ('fruits.apple_list', 'fruits.apple_pick'), 48 | } 49 | 50 | 51 | Permissions on Views 52 | ===================== 53 | 54 | Smartmin supports gating any view using permissions. If you are using a CRUDL object, all you need to do is set ``permissions = True``:: 55 | 56 | class FruitCRUDL(SmartCRUDL): 57 | model = Fruit 58 | permissions = True 59 | 60 | But you can also customize permissions on a per view basis by setting the permission on the View itself:: 61 | 62 | class FruitListView(SmartListView): 63 | model = Fruit 64 | permission = 'fruits.apple_list' 65 | 66 | The user will automatically be redirected to a login page if they try to access this view. 67 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | QuickStart 2 | =============== 3 | 4 | Here's a simple example of Smartmin with a very simple model to show you how it works. You can find out more details on each of these features in the appropriate sections but this is a good overview given a simple model. 5 | 6 | For this example, we are going to use the ubiquitous blog example. We'll simply add a way for authenticated users to create a blog post, list them, edit them and remove them. 7 | 8 | To start out with, let's create our app:: 9 | 10 | python manage.py startappp blog 11 | 12 | Don't forget to add the app to your ``INSTALLED_APPS`` in ``settings.py``. 13 | 14 | Now, let's add a (deliberately simple) Post object to our newly created ``models.py``:: 15 | 16 | from django.db import models 17 | 18 | class Post(models.Model): 19 | title = models.CharField(max_length=128, 20 | help_text="The title of this blog post, keep it relevant") 21 | body = models.TextField(help_text="The body of the post, go crazy") 22 | tags = models.CharField(max_length=128, 23 | help_text="Any tags for this post") 24 | 25 | Ok, so far, this all normal Django stuff. Now let's create some views to manage these Post objects. This is where Smartmin comes in. 26 | 27 | Smartmin provides a controller that lets you easily hook up Create, Read, Update, Delete and List views for your object. In Smartmin, we call this CRUDL, and we call the helper class that defines it a SmartCRUDL. So in our ``views.py`` let's create a CRUDL for our Post object:: 28 | 29 | from smartmin.views import * 30 | from .models import * 31 | 32 | class PostCRUDL(SmartCRUDL): 33 | model = Post 34 | 35 | You'll see that right now, all we are doing is defining the model that our CRUDL is working with. Everything else is using defaults. 36 | 37 | Finally, we'll have to create a ``urls.py`` for our app, and hook in this CRUDL:: 38 | 39 | from .views import * 40 | 41 | urlpatterns = PostCRUDL().as_urlpatterns() 42 | 43 | Again, Smartmin keeps this very, very, DRY. The urls (and reverse patterns) are created automatically using standard Django conventions and the name of our model. The last part is you'll need to add this urls.py to your global ``urls.py``:: 44 | 45 | urlpatterns = patterns('', 46 | # .. other url patterns 47 | url(r'^blog/', include('blog.urls')), 48 | ) 49 | 50 | With a quick ``python manage.py syncdb`` that should be it. You should be able to start your server and hit the index for your CRUDL object at ``http://localhost:8000/blog/post/`` and get a view that looks like this: 51 | 52 | .. image:: img/quickstart1.png 53 | 54 | You'll notice that CRUDL has no only wired up the views, but given us a standard list view by default. If we click on the 'add' button, we'll get the default Create view for our object: 55 | 56 | .. image:: img/quickstart2.png 57 | 58 | If we add a few items, we'll see our list displays things appropriately: 59 | 60 | .. image:: img/quickstart3.png 61 | 62 | We can click on any of the items to edit them, and when editing, and from there even remove them if needed: 63 | 64 | .. image:: img/quickstart4.png 65 | 66 | And that's it. You probably want to learn how to customize things, either at the CRUDL layer or on each individual view. 67 | 68 | Adding Search 69 | ============== 70 | 71 | Ok, so now we have some basic views, but let's spruce up our list view by adding search. All we need to do to enable search is define which fields we want to be searchable. To do this we'll have to overload the default ListView on our PostCRUDL object:: 72 | 73 | class PostCRUDL(SmartCRUDL): 74 | model = Post 75 | 76 | class List(SmartListView): 77 | search_fields = ('title__icontains', 'body__icontains') 78 | default_order = 'title' 79 | 80 | So we are doing two things here. First, by defining ``search_fields`` we are telling smartmin to enable searching on this list view, and to search the contents of the title and body when doing searches. While we are at it, we have also set the default ordering for results to be by the title attribute. 81 | 82 | Here's the result: 83 | 84 | .. image:: img/quickstart5.png 85 | 86 | One thing that could still be better would be to only show the first few words of a post so we don't blow up the table. We can override what smartmin uses as the value of a column by just defining a ``get_[fieldname]`` method on our view:: 87 | 88 | class List(SmartListView): 89 | search_fields = ('title__icontains', 'body__icontains') 90 | default_order = 'title' 91 | 92 | def get_body(self, obj): 93 | """ Show only the first 10 words for long post bodies. """ 94 | if len(obj.body) < 100: 95 | return obj.body 96 | else: 97 | return " ".join(obj.body.split(" ")[0:10]) + ".." 98 | 99 | That gives us this: 100 | 101 | .. image:: img/quickstart6.png 102 | 103 | 104 | Permissions 105 | ============== 106 | 107 | Very few sites really want to allow just anybody to edit content, and the sanest way of managing who can do what is by using permissions. Smartmin uses permissions and groups throughout to help you manage this functionality easily. 108 | 109 | So far we've enabled anybody to create Posts, so as a first step let's required that only authenticated users (and admins) who have the proper privileges can access our views. 110 | 111 | Thankfully, that's a one line change, we just need to add the ``permissions=True`` attribute to our CRUDL object:: 112 | 113 | class PostCRUDL(SmartCRUDL): 114 | model = Post 115 | permissions = True 116 | 117 | # .. view definitions .. 118 | 119 | Now when we try to view any of the CRUDL pages for our Post object we are redirected to a login page. 120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /docs/readview.rst: -------------------------------------------------------------------------------- 1 | SmartReadView 2 | ================== 3 | 4 | The SmartReadView provides a simple readonly view of your object. This is essentially just a detail view. 5 | 6 | **fields** 7 | 8 | Defines which fields should be displayed for the object, and in what order:: 9 | 10 | class PostCRUDL(SmartCRUDL): 11 | model = Post 12 | 13 | class Read(SmartReadView): 14 | fields = ('title', 'tags', 'body') 15 | 16 | Note that if you'd like to have this be set at runtime, you can do so by overriding the ``derive_fields`` method. 17 | 18 | **permission** 19 | 20 | Let's you set what permission the user must have in order to view this page. 21 | 22 | **template_name** 23 | 24 | The name of the template used to render this view. By default, this is set to ``smartmin/create.html`` but you can override it to whatever you'd like. 25 | 26 | -------------------------------------------------------------------------------- /docs/templates.rst: -------------------------------------------------------------------------------- 1 | HTML5 Boilerplate 2 | =========================================== 3 | 4 | Smartmin comes ready to go straight out the box, including using a recent version of the most excellent HTML5 boilerplate so you can build a standards compliant and optimized website. 5 | 6 | File Layout 7 | =========================================== 8 | 9 | Again, Smartmin defines a layout for your files, things will just be easier if you agree:: 10 | 11 | /static/ - all static files 12 | 13 | /css/ - any css stylesheets 14 | reset.css - this is the HTML5 boilerplate reset 15 | smartmin_styles.css - styles specific to smartmin functionality 16 | styles.css - this can be any of your custom styles 17 | 18 | /img/ - any static images 19 | 20 | /js/ - your javascript files 21 | /libs/ - external javascript libraries you depend on 22 | 23 | Blocks 24 | =========================================== 25 | 26 | All pages rendered by smartmin inherit from the ``base.html``, which contains the following blocks: 27 | 28 | title 29 | This is the title of the page displayed in the ```` tag 30 | 31 | extrastyle 32 | Any extra stylesheets or CSS you want to include on your page. Surround either in ``<style>`` or ``<link>`` 33 | 34 | login 35 | The login block, will either display a login link or the name of the logged in user with a logout link 36 | 37 | messages 38 | Any messages, or 'flashes', pushed in by the view. 39 | 40 | content 41 | The primary content block of the page, this is the main body. 42 | 43 | footer 44 | Any footer treatment. 45 | 46 | extrascript 47 | Any extra javascript you wanted included, this is put at the bottom of the page 48 | 49 | 50 | Customizing 51 | ============================================= 52 | 53 | You can, and shoud customize the ``base.html`` to your needs. The only thing smartmin depends on is having the content, extrascript and extrastyle blocks available. 54 | 55 | -------------------------------------------------------------------------------- /docs/updateview.rst: -------------------------------------------------------------------------------- 1 | SmartUpdateView 2 | ================== 3 | 4 | The SmartUpdateView provides a simple and quick way to create form pages for updating objects. The following attributes are available. 5 | 6 | **fields** 7 | 8 | Defines which fields should be displayed in our form, and in what order:: 9 | 10 | class PostCRUDL(SmartCRUDL): 11 | model = Post 12 | 13 | class Update(SmartUpdateView): 14 | fields = ('title', 'tags', 'body') 15 | 16 | Note that if you'd like to have this be set at runtime, you can do so by overriding the ``derive_fields`` method. 17 | 18 | **readonly** 19 | 20 | A tuple of field names for fields which should be displayed in the form, but which should be not be editable:: 21 | 22 | class PostCRUDL(SmartCRUDL): 23 | model = Post 24 | 25 | class Update(SmartUpdateView): 26 | readonly = ('tags',) 27 | 28 | **permission** 29 | 30 | Let's you set what permission the user must have in order to view this page. 31 | 32 | **template_name** 33 | 34 | The name of the template used to render this view. By default, this is set to ``smartmin/create.html`` but you can override it to whatever you'd like. 35 | 36 | Overriding 37 | ------------ 38 | 39 | You can also extend your SmartCreateView to modify behavior at runtime, the most common methods to override are listed below. 40 | 41 | **pre_save** 42 | 43 | Called after our form has been validated and cleaned and our object created, but before the object has actually been saved. This can be a good place to add derived attributes to your model. 44 | 45 | **post_save** 46 | 47 | Called after our object has been saved. Sometimes used to add permissions. 48 | 49 | **get_success_url** 50 | 51 | Returns what URL the page should go to after the form has been successfully submitted. 52 | 53 | 54 | -------------------------------------------------------------------------------- /docs/users.rst: -------------------------------------------------------------------------------- 1 | 2 | Users 3 | ================ 4 | 5 | Smartmin provides some views and utilities to facilitate managing users. This includes views to help users when they forget their passwords, functionality to force password expiration, complexity requirements and prevent users from repeating passwords. 6 | 7 | Configuration 8 | ====================== 9 | 10 | First and foremost, you'll want to include `smartmin.users` in your INSTALLED_APPS, and include smartmin.urls in your project urls.py. 11 | 12 | If you intend to use the password expiration feature, you will also need to add the ChangePasswordMiddleware to your `MIDDLEWARE_CLASSES` setting `smartmin.users.middleware.ChangePasswordMiddleware`. (best if last) 13 | 14 | The following variables can be set in your settings.py to change various behavior: 15 | 16 | USER_FAILED_LOGIN_LIMIT = The number of times a user can fail a login with an incorrect password before being locked out. (default value is 5) 17 | 18 | USER_LOCKOUT_TIMEOUT = The number of minutes that a user must wait before trying to log in again after reaching the limit above. If set to -1 or 0, the user is permanently locked out until an administrator resets the password. (default value is 10) 19 | 20 | USER_ALLOW_EMAIL_RECOVERY = Whether users are able to recover their password via a token sent to their email address. (default is True) 21 | 22 | USER_PASSWORD_EXPIRATION = How many days before a user's password expires and they need to choose a new one. If set to 0 or a negative value then there is no expiration. (default is -1) 23 | 24 | USER_PASSWORD_REPEAT_WINDOW = The window whereby past passwords must not repeat. For example, if set to 365, users will not be able to set a password that has been used in the past year. If set to 0 or a negative value, then no enforcement of repetition is made. (default is -1) 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/views.rst: -------------------------------------------------------------------------------- 1 | Smartmin Views 2 | ================== 3 | 4 | Smartmin comes with five views defined. One each for, Create, Read, Update, Delete and List on your custom model objects. Each of these views can be used individually as you would any Django class based view, or they can be used together in a CRUDL object. 5 | 6 | Whenever using a CRUDL object, you are implicitely creating views for each of the CRUDL actions. For example, if you define a CRUDL for a Post object, as so:: 7 | 8 | class PostCRUDL(SmartCRUDL): 9 | model = Post 10 | 11 | You are implicitely creating views for the CRUDL operations. If you'd rather only include some actions, that is easily done by setting the ``actions`` tuple:: 12 | 13 | class PostCRUDL(SmartCRUDL): 14 | actions = ('create', 'update', 'list') 15 | model = Post 16 | 17 | Now, only the views to create, update and list objects will be created and wired. 18 | 19 | You can also choose to override any of the views for a CRUDL, without losing all the URL magic. The SmartCRUDL object will use any inner class of itself that is named the same as the action:: 20 | 21 | class PostCRUDL(SmartCRUDL): 22 | actions = ('create', 'update', 'list') 23 | model = Post 24 | 25 | class List(SmartListView): 26 | fields = ('title', 'body') 27 | 28 | When created, the List class will be used instead of the default Smartmin generated list view. This let's you easily override behavior as you see fit. 29 | 30 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_runner.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "smartmin" 3 | version = "5.2.0" 4 | description = "Scaffolding system for Django object management." 5 | license = { text = "BSD" } 6 | authors = [ 7 | {"name" = "TextIt", "email" = "code@textit.com"} 8 | ] 9 | readme = "README.md" 10 | requires-python = ">=3.11,<4.0" 11 | dependencies = [ 12 | "Django (>= 5.1,< 5.3)", 13 | "celery (>=5.1)", 14 | "redis (>=3.5.3)", 15 | "sqlparse (>=0.4.1,<0.6.0)", 16 | "xlrd (>=1.2.0)", 17 | "xlwt (>=1.3.0)", 18 | ] 19 | 20 | classifiers = [ 21 | "Development Status :: 5 - Production/Stable", 22 | "Environment :: Web Environment", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: BSD License", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python", 27 | "Framework :: Django", 28 | ] 29 | packages = [ 30 | { include = "smartmin" }, 31 | ] 32 | 33 | [project.urls] 34 | repository = "http://github.com/nyaruka/smartmin" 35 | 36 | 37 | [tool.poetry.group.dev.dependencies] 38 | black = "^24.3.0" 39 | coverage = {extras = ["toml"], version = "^7.2.7"} 40 | isort = "^5.12.0" 41 | ruff = "^0.0.278" 42 | psycopg2-binary = "^2.9.1" 43 | funcsigs = "^1.0.2" 44 | Pillow = "^10.3.0" 45 | colorama = "^0.4.6" 46 | 47 | [tool.black] 48 | line-length = 120 49 | 50 | [tool.ruff] 51 | line-length = 120 52 | select = ["E", "F", "W"] 53 | ignore = ["E501", "F405"] 54 | fix = true 55 | 56 | [tool.isort] 57 | multi_line_output = 3 58 | force_grid_wrap = 0 59 | line_length = 120 60 | include_trailing_comma = true 61 | combine_as_imports = true 62 | sections = ["FUTURE", "STDLIB", "THIRDPARTY", "DJANGO", "FIRSTPARTY", "LOCALFOLDER"] 63 | known_django = ["django"] 64 | 65 | [tool.coverage.run] 66 | source = ["smartmin"] 67 | 68 | [build-system] 69 | requires = ["poetry-core>=2.0.0,<3.0.0"] 70 | build-backend = "poetry.core.masonry.api" -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /smartmin/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "5.2.0" 2 | -------------------------------------------------------------------------------- /smartmin/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.db.models.signals import post_migrate 3 | 4 | 5 | class SmartminConfig(AppConfig): 6 | name = "smartmin" 7 | 8 | def ready(self): 9 | from .perms import sync_permissions 10 | 11 | post_migrate.connect(sync_permissions) 12 | -------------------------------------------------------------------------------- /smartmin/backends.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.backends import ModelBackend 3 | 4 | 5 | class CaseInsensitiveBackend(ModelBackend): 6 | """ 7 | Authenticates against settings.AUTH_USER_MODEL. 8 | """ 9 | 10 | def authenticate(self, request, username=None, password=None, **kwargs): 11 | User = get_user_model() 12 | try: 13 | user = User.objects.get(username__iexact=username) 14 | if user.check_password(password): 15 | return user 16 | else: 17 | return None 18 | except User.DoesNotExist: 19 | # Run the default password hasher once to reduce the timing 20 | # difference between an existing and a non-existing user (#20760). 21 | User().set_password(password) 22 | -------------------------------------------------------------------------------- /smartmin/email.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils.module_loading import import_string 3 | 4 | 5 | def link_components(request, user=None): 6 | protocol = "https" if request.is_secure() else "http" 7 | hostname = request.get_host() 8 | 9 | return {"protocol": protocol, "hostname": hostname} 10 | 11 | 12 | def build_email_context(request=None, user=None): 13 | context = {"user": user} 14 | 15 | processors = [] 16 | collect = [] 17 | collect.extend(getattr(settings, "EMAIL_CONTEXT_PROCESSORS", ("smartmin.email.link_components",))) 18 | for path in collect: 19 | func = import_string(path) 20 | processors.append(func) 21 | 22 | for processor in processors: 23 | context.update(processor(request, user)) 24 | 25 | return context 26 | -------------------------------------------------------------------------------- /smartmin/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/management/__init__.py -------------------------------------------------------------------------------- /smartmin/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/management/commands/__init__.py -------------------------------------------------------------------------------- /smartmin/management/commands/migrate_manual.py: -------------------------------------------------------------------------------- 1 | import time 2 | from importlib import import_module 3 | 4 | from django.core.management.base import BaseCommand, CommandError 5 | from django.core.management.sql import emit_post_migrate_signal, emit_pre_migrate_signal 6 | from django.db import DEFAULT_DB_ALIAS, connections 7 | from django.db.migrations.executor import MigrationExecutor 8 | from django.db.migrations.loader import AmbiguityError 9 | 10 | APPLY_FUNCTION = "apply_manual" 11 | 12 | 13 | class Command(BaseCommand): # pragma: no cover 14 | help = "Applies a migration manually which may have been previously applied or faked" 15 | 16 | def add_arguments(self, parser): 17 | parser.add_argument("app_label", help="App label of an application to synchronize the state.") 18 | parser.add_argument("migration_name", help="Database state will be brought to the state after that migration.") 19 | parser.add_argument( 20 | "--record", action="store_true", dest="record", default=False, help="Record migration as applied." 21 | ) 22 | 23 | def handle(self, app_label, migration_name, *args, **options): 24 | self.verbosity = options.get("verbosity") 25 | interactive = options.get("interactive") 26 | record = options.get("record") 27 | 28 | connection = connections[DEFAULT_DB_ALIAS] 29 | connection.prepare_database() 30 | executor = MigrationExecutor(connection, None) 31 | 32 | # before anything else, see if there's conflicting apps and drop out hard if there are any 33 | conflicts = executor.loader.detect_conflicts() 34 | if conflicts: 35 | name_str = "; ".join("%s in %s" % (", ".join(names), app) for app, names in conflicts.items()) 36 | raise CommandError( 37 | "Conflicting migrations detected (%s).\nTo fix them run " 38 | "'python manage.py makemigrations --merge'" % name_str 39 | ) 40 | 41 | if app_label not in executor.loader.migrated_apps: 42 | raise CommandError( 43 | "App '%s' does not have migrations (you cannot selectively sync unmigrated apps)" % app_label 44 | ) 45 | try: 46 | migration = executor.loader.get_migration_by_prefix(app_label, migration_name) 47 | except AmbiguityError: 48 | raise CommandError( 49 | "More than one migration matches '%s' in app '%s'. Please be more specific." 50 | % (migration_name, app_label) 51 | ) 52 | except KeyError: 53 | raise CommandError("Cannot find a migration matching '%s' from app '%s'." % (migration_name, app_label)) 54 | 55 | migration_module = import_module(migration.__module__) 56 | 57 | # check migration can be run offline 58 | apply_function = getattr(migration_module, APPLY_FUNCTION, None) 59 | if not apply_function or not callable(apply_function): 60 | raise CommandError("Migration %s does not contain function named '%s'" % (migration, APPLY_FUNCTION)) 61 | 62 | plan = executor.migration_plan([(app_label, migration.name)]) 63 | if record and not plan: 64 | raise CommandError("Migration %s has already been applied" % migration) 65 | 66 | emit_pre_migrate_signal(self.verbosity, interactive, connection.alias) 67 | 68 | self.stdout.write(self.style.MIGRATE_HEADING("Operations to perform:")) 69 | self.stdout.write(" Manually apply migration %s" % migration) 70 | if record: 71 | self.stdout.write(" Record migration %s as applied" % migration) 72 | self.stdout.write(self.style.MIGRATE_HEADING("Manual migration:")) 73 | 74 | self.apply_migration(migration, apply_function) 75 | 76 | if record: 77 | self.record_migration(migration, executor) 78 | 79 | # send the post_migrate signal, so individual apps can do whatever they need to do at this point. 80 | emit_post_migrate_signal(self.verbosity, interactive, connection.alias) 81 | 82 | def apply_migration(self, migration, apply_function): 83 | compute_time = self.verbosity > 1 84 | 85 | self.stdout.write(" Applying %s... " % migration, ending="") 86 | self.stdout.flush() 87 | 88 | start = time.time() 89 | 90 | apply_function() 91 | 92 | elapsed = " (%.3fs)" % (time.time() - start) if compute_time else "" 93 | 94 | self.stdout.write(self.style.SUCCESS("OK" + elapsed)) 95 | 96 | def record_migration(self, migration, executor): 97 | self.stdout.write(" Recording %s... " % migration, ending="") 98 | self.stdout.flush() 99 | 100 | executor.recorder.record_applied(migration.app_label, migration.name) 101 | 102 | self.stdout.write(self.style.SUCCESS("DONE")) 103 | -------------------------------------------------------------------------------- /smartmin/management/tests.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call, patch 2 | 3 | from django.core.management import call_command 4 | from django.db.migrations import RunPython, RunSQL 5 | from django.test import TestCase 6 | 7 | from .commands.collect_sql import SqlObjectOperation, SqlType 8 | 9 | 10 | class MockMigration(object): 11 | def __init__(self, operations): 12 | self.operations = operations 13 | 14 | 15 | def mock_run_python(apps, schema_editor): 16 | pass 17 | 18 | 19 | class CollectSqlTest(TestCase): 20 | @patch("smartmin.management.commands.collect_sql.Command.load_migrations") 21 | @patch("smartmin.management.commands.collect_sql.Command.write_dump") 22 | def test_command(self, mock_write_dump, mock_load_migrations): 23 | mock_load_migrations.return_value = [ 24 | MockMigration( 25 | operations=[ 26 | RunSQL( 27 | """ 28 | CREATE INDEX test_1 ON foo(bar); 29 | CREATE INDEX test_2 ON foo(bar); create unique index test_3 on foo(bar); 30 | """ 31 | ), 32 | RunPython(mock_run_python), 33 | ] 34 | ), 35 | MockMigration( 36 | operations=[ 37 | RunSQL("DROP INDEX test_2;"), 38 | ] 39 | ), 40 | MockMigration( 41 | operations=[ 42 | RunSQL("CREATE TRIGGER test_1 AFTER TRUNCATE ON flows_flowstep EXECUTE PROCEDURE foo();"), 43 | RunSQL("CREATE INDEX a_test ON foo(bar);"), 44 | RunPython(mock_run_python), 45 | ] 46 | ), 47 | MockMigration( 48 | operations=[ 49 | RunSQL( 50 | "CREATE OR REPLACE FUNCTION a_func(i INTEGER, s TEXT) RETURNS integer AS $$ BEGIN RETURN i + 1; END; $$ LANGUAGE plpgsql;" 51 | ), 52 | # different function because it has different parameters 53 | RunSQL( 54 | "CREATE OR REPLACE FUNCTION a_func(s text, integer) RETURNS text AS $$ BEGIN RETURN UPPER(s); END; $$ LANGUAGE plpgsql;" 55 | ), 56 | # same function because it has same name and parameters 57 | RunSQL( 58 | "CREATE OR REPLACE FUNCTION A_FUNC(N integer, V text) RETURNS text AS $$ BEGIN RETURN UPPER(V); END; $$ LANGUAGE plpgsql;" 59 | ), 60 | RunSQL( 61 | "CREATE OR REPLACE FUNCTION func2(i INTEGER) RETURNS integer AS $$ BEGIN RETURN i + 1; END; $$ LANGUAGE plpgsql;" 62 | ), 63 | RunSQL("DROP FUNCTION func2(i INTEGER);"), 64 | ] 65 | ), 66 | ] 67 | 68 | call_command("collect_sql", output_dir="sql") 69 | 70 | mock_write_dump.assert_has_calls( 71 | [ 72 | call( 73 | "indexes", 74 | [ 75 | SqlObjectOperation( 76 | "CREATE INDEX a_test ON foo(bar);", 77 | sql_type=SqlType.INDEX, 78 | obj_name="a_test", 79 | is_create=True, 80 | ), 81 | SqlObjectOperation( 82 | "CREATE INDEX test_1 ON foo(bar);", 83 | sql_type=SqlType.INDEX, 84 | obj_name="test_1", 85 | is_create=True, 86 | ), 87 | SqlObjectOperation( 88 | "create unique index test_3 on foo(bar);", 89 | sql_type=SqlType.INDEX, 90 | obj_name="test_3", 91 | is_create=True, 92 | ), 93 | ], 94 | "sql", 95 | ), 96 | call( 97 | "functions", 98 | [ 99 | SqlObjectOperation( 100 | "CREATE OR REPLACE FUNCTION A_FUNC(N integer, V text) RETURNS text AS $$ BEGIN RETURN UPPER(V); END; $$ LANGUAGE plpgsql;", 101 | sql_type=SqlType.FUNCTION, 102 | obj_name="A_FUNC(integer,text)", 103 | is_create=True, 104 | ), 105 | SqlObjectOperation( 106 | "CREATE OR REPLACE FUNCTION a_func(s text, integer) RETURNS text AS $$ BEGIN RETURN UPPER(s); END; $$ LANGUAGE plpgsql;", 107 | sql_type=SqlType.FUNCTION, 108 | obj_name="a_func(text,integer)", 109 | is_create=True, 110 | ), 111 | ], 112 | "sql", 113 | ), 114 | call( 115 | "triggers", 116 | [ 117 | SqlObjectOperation( 118 | "CREATE TRIGGER test_1 AFTER TRUNCATE ON flows_flowstep EXECUTE PROCEDURE foo();", 119 | sql_type=SqlType.TRIGGER, 120 | obj_name="test_1", 121 | is_create=True, 122 | ), 123 | ], 124 | "sql", 125 | ), 126 | ] 127 | ) 128 | -------------------------------------------------------------------------------- /smartmin/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.utils import timezone 3 | 4 | 5 | class TimezoneMiddleware: 6 | def __init__(self, get_response=None): 7 | self.get_response = get_response 8 | 9 | def __call__(self, request): 10 | response = self.get_response(request) 11 | 12 | user_tz = getattr(settings, "USER_TIME_ZONE", None) 13 | 14 | if user_tz: 15 | timezone.activate(user_tz) 16 | 17 | return response 18 | -------------------------------------------------------------------------------- /smartmin/mixins.py: -------------------------------------------------------------------------------- 1 | """ 2 | simple mixins that keep you from writing so much code 3 | """ 4 | 5 | from django.db import transaction 6 | from django.utils.decorators import method_decorator 7 | 8 | 9 | class PassRequestToFormMixin(object): 10 | """ 11 | Mixin to include the request in the form kwargs 12 | """ 13 | 14 | def get_form_kwargs(self): 15 | kwargs = super(PassRequestToFormMixin, self).get_form_kwargs() 16 | kwargs["request"] = self.request 17 | return kwargs 18 | 19 | 20 | class NonAtomicMixin(object): 21 | """ 22 | Mixin to configure a view to be handled without a transaction 23 | """ 24 | 25 | @method_decorator(transaction.non_atomic_requests) 26 | def dispatch(self, request, *args, **kwargs): 27 | return super(NonAtomicMixin, self).dispatch(request, *args, **kwargs) 28 | -------------------------------------------------------------------------------- /smartmin/pdf.py: -------------------------------------------------------------------------------- 1 | import os 2 | from io import StringIO 3 | 4 | import ho.pisa as pisa 5 | 6 | from django.conf import settings 7 | from django.http import HttpResponse 8 | from django.utils.html import escape 9 | 10 | 11 | class PDFMixin(object): 12 | """ 13 | Mixin that will change a class based view to render as PDF 14 | 15 | Dependencies: 16 | - reportlab 17 | - html5lib 18 | - pisa 19 | """ 20 | 21 | def render_to_response(self, context, **response_kwargs): 22 | response = super(PDFMixin, self).render_to_response(context, **response_kwargs) 23 | 24 | # do the actual rendering 25 | response.render() 26 | 27 | # and get the content 28 | result = StringIO.StringIO() 29 | 30 | # now render with pisa as PDF 31 | pdf = pisa.pisaDocument( 32 | StringIO.StringIO(response.rendered_content.encode("ISO-8859-1")), result, link_callback=fetch_resource 33 | ) 34 | if not pdf.err: 35 | return HttpResponse(result.getvalue(), mimetype="application/pdf") 36 | return HttpResponse("We had some errors<pre>%s</pre>" % escape(response.content)) 37 | 38 | 39 | def fetch_resource(uri, rel): 40 | path = os.path.join(settings.STATICFILES_DIRS[0], uri.replace(settings.STATIC_URL, "")) 41 | return path 42 | -------------------------------------------------------------------------------- /smartmin/perms.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.conf import settings 4 | from django.contrib.auth.models import Group, Permission 5 | from django.contrib.contenttypes.management import create_contenttypes 6 | from django.contrib.contenttypes.models import ContentType 7 | 8 | perm_desc_regex = re.compile(r"(?P<app>\w+)\.(?P<contenttype>[a-z0-9]+)((_(?P<perm>\w+))|(\.(?P<wild>\*)))") 9 | 10 | 11 | def update_group_permissions(app, group, permissions: list): 12 | """ 13 | Checks the the passed in role (can be user, group or AnonymousUser) has all the passed 14 | in permissions, granting them if necessary. 15 | """ 16 | 17 | new_permissions = [] 18 | 19 | for perm_desc in permissions: 20 | app_label, content_type, perm = _parse_perm_desc(perm_desc) 21 | 22 | # ignore if this permission is for a type not in this app 23 | if app_label != app.label: 24 | continue 25 | 26 | if perm == "*": 27 | codenames = Permission.objects.filter( 28 | content_type__app_label=app_label, codename__startswith=f"{content_type}_" 29 | ).values_list("codename", flat=True) 30 | else: 31 | codenames = [f"{content_type}_{perm}"] 32 | 33 | perms = [] 34 | for codename in codenames: 35 | try: 36 | perms.append(Permission.objects.get(content_type__app_label=app_label, codename=codename)) 37 | except Permission.DoesNotExist: 38 | raise ValueError(f"Cannot grant permission {app_label}.{codename} as it does not exist.") 39 | 40 | new_permissions.append((app_label, codename)) 41 | 42 | group.permissions.add(*perms) 43 | 44 | # remove any that are extra 45 | for perm in group.permissions.filter(content_type__app_label=app.label): 46 | if (perm.content_type.app_label, perm.codename) not in new_permissions: 47 | group.permissions.remove(perm) 48 | 49 | 50 | def sync_permissions(sender, **kwargs): 51 | """ 52 | 1. Ensures all permissions for this app described by the PERMISSIONS setting exist in the database. 53 | 2. Ensures all permissions for this app granted by the GROUP_PERMISSIONS setting are granted. 54 | """ 55 | 56 | # the content types app also listens for post_migrate signals but since order isn't guaranteed, we need to 57 | # manually invoke what it does for this app to be sure that the content types are created 58 | create_contenttypes(sender) 59 | 60 | # for each of our items 61 | for natural_key, permissions in getattr(settings, "PERMISSIONS", {}).items(): 62 | # if wild, we add these permissions to all content types defined by this app 63 | if natural_key == "*": 64 | for content_type in ContentType.objects.filter(app_label=sender.label): 65 | for permission in permissions: 66 | _ensure_permission_exists(content_type, permission) 67 | 68 | # otherwise check if this type belongs to this app and if so add the permissions to that type only 69 | else: 70 | app_label, model = natural_key.split(".") 71 | if app_label == sender.label: 72 | try: 73 | content_type = ContentType.objects.get_by_natural_key(app_label, model) 74 | except ContentType.DoesNotExist: 75 | raise ValueError(f"No such content type: {app_label}.{model}") 76 | 77 | # add each permission 78 | for permission in permissions: 79 | _ensure_permission_exists(content_type, permission) 80 | 81 | # for each of our items 82 | for name, permissions in getattr(settings, "GROUP_PERMISSIONS", {}).items(): 83 | # get or create the group 84 | group, created = Group.objects.get_or_create(name=name) 85 | 86 | update_group_permissions(sender, group, permissions) 87 | 88 | 89 | def _parse_perm_desc(desc: str) -> tuple: 90 | """ 91 | Parses a permission descriptor into its app_label, model and permission parts, e.g. 92 | app.model.* => app, model, * 93 | app.model_perm => app, model, perm 94 | """ 95 | 96 | match = perm_desc_regex.match(desc) 97 | if not match: 98 | raise ValueError(f"Invalid permission descriptor: {desc}") 99 | 100 | return match.group("app"), match.group("contenttype"), match.group("perm") or match.group("wild") 101 | 102 | 103 | def _ensure_permission_exists(content_type, permission: str): 104 | """ 105 | Adds the passed in permission to that content type. Note that the permission passed 106 | in should be a single word, or verb. The proper 'codename' will be generated from that. 107 | """ 108 | 109 | codename = f"{content_type.model}_{permission}" # build our permission slug 110 | 111 | Permission.objects.get_or_create( 112 | content_type=content_type, codename=codename, defaults={"name": f"Can {permission} {content_type.name}"} 113 | ) 114 | -------------------------------------------------------------------------------- /smartmin/static/css/smartmin_styles.css: -------------------------------------------------------------------------------- 1 | /* Smartmin specific CSS Styles */ 2 | 3 | html, body { 4 | background-color: #efefef; 5 | } 6 | 7 | header { 8 | background-color: #fff; 9 | } 10 | 11 | .container { 12 | } 13 | 14 | .table { 15 | margin-bottom: 8px; 16 | } 17 | 18 | .form-horizontal .help-block { 19 | margin-top: 3px; 20 | } 21 | 22 | table .headerSortUp, table .headerSortDown { 23 | background-color: rgba(141, 192, 219, 0.25); 24 | text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75); 25 | 26 | } 27 | 28 | table .headerSortDown { 29 | background-image: url(../img/sort_dsc.png); 30 | background-repeat: no-repeat; 31 | background-position: 98% 50%; 32 | } 33 | 34 | table .headerSortUp { 35 | background-image: url(../img/sort_asc.png); 36 | background-repeat: no-repeat; 37 | background-position: 98% 50%; 38 | } 39 | 40 | table .header:hover { 41 | background-image: url(../img/sort.png); 42 | background-repeat: no-repeat; 43 | background-position: 98% 50%; 44 | cursor: pointer; 45 | } 46 | 47 | div.page-header { 48 | margin-top: 0px; 49 | margin-bottom: 17px; 50 | padding-bottom: 0px; 51 | } 52 | 53 | .smartmin-form-field ul { 54 | margin-left: 0px; 55 | margin-top: 5px; 56 | padding-left: 0px; 57 | } 58 | 59 | .smartmin-form-field ul li { 60 | list-style: none; 61 | margin-left: 0px; 62 | } 63 | 64 | .smartmin-form-field ul li input { 65 | width: 30px 66 | 67 | } 68 | input.form-control, textarea.form-control, select.form-control { 69 | width: 500px; 70 | } 71 | 72 | .smartmin-form-buttons { 73 | padding: 17px 20px 18px 0px; 74 | margin-top: 18px; 75 | margin-bottom: 18px; 76 | background-color: #eeeeee; 77 | border-top: 1px solid #ddd; 78 | } 79 | 80 | .content { 81 | background-color: #fff; 82 | padding: 20px; 83 | padding-top: 10px; 84 | margin: 0 -20px; 85 | -webkit-border-radius: 0 0 6px 6px; 86 | -moz-border-radius: 0 0 6px 6px; 87 | border-radius: 0 0 6px 6px; 88 | -webkit-box-shadow: 0 1px 2px rgba(0,0,0,.15); 89 | -moz-box-shadow: 0 1px 2px rgba(0,0,0,.15); 90 | box-shadow: 0 1px 2px rgba(0,0,0,.15); 91 | } 92 | 93 | .form-errors li { 94 | list-style: none; 95 | } 96 | 97 | .alert-danger, .alert-error { 98 | background-color: white; 99 | border: #9D261D 3px solid; 100 | font-size: 14px; 101 | } 102 | 103 | ul.errorlist { 104 | margin-left: 0px; 105 | margin-bottom: 8px; 106 | } 107 | 108 | #login-form { 109 | padding-left: 0px; 110 | } 111 | 112 | .list-view-search fieldset { 113 | padding-top: 0px; 114 | margin-bottom: 0px; 115 | } 116 | 117 | .field-errors ul { 118 | display: inline; 119 | } 120 | 121 | .field-errors li { 122 | list-style: none; 123 | color: #9D261D; 124 | display: block; 125 | font-size: 12px; 126 | font-weight: bold; 127 | } 128 | 129 | form div.clearfix.form-field.error { 130 | margin-bottom: 12px; 131 | } 132 | 133 | input, textarea, select, .uneditable-input { 134 | width: 500px; 135 | } 136 | 137 | input[type="checkbox"] { 138 | width: initial; 139 | } 140 | 141 | .help-inline, .help-block { 142 | color: #999; 143 | } 144 | 145 | .buttons { 146 | margin-bottom: 5px; 147 | } 148 | 149 | .pagination { 150 | margin: 0px; 151 | } 152 | 153 | .pagination-text { 154 | margin-top: 9px; 155 | } 156 | 157 | tr.inactive td { 158 | color: #ccc; 159 | } 160 | 161 | tr.inactive td a { 162 | color: #ccc; 163 | } 164 | 165 | .image-picker table td { 166 | border: 0px; 167 | padding: 0px; 168 | vertical-align: bottom; 169 | } 170 | 171 | .image-picker table { 172 | border: 0px; 173 | margin-bottom: 0px; 174 | } 175 | 176 | .input ul li { 177 | display: inline-block; 178 | } 179 | 180 | .input ul li label { 181 | text-align: left; 182 | padding-right: 10px; 183 | } 184 | 185 | .input ul { 186 | margin-left: 0px; 187 | margin-top: 0px; 188 | margin-bottom: 0px; 189 | } 190 | 191 | fieldset { 192 | padding-top: 0px; 193 | } 194 | 195 | fieldset.field-group legend { 196 | background: white; 197 | float: left; 198 | margin: -25px 0 15px 140px; 199 | padding: 0 10px; 200 | font-size: 20px; 201 | font-weight: normal; 202 | line-height: 1; 203 | color: #333; 204 | } 205 | 206 | fieldset.field-group { 207 | margin: 20px -20px -10px 0; 208 | padding: 13px 20px 5px 0; 209 | border-top: 1px solid #DDD; 210 | } 211 | 212 | fieldset.field-group div.form-field { 213 | clear: both; 214 | } 215 | 216 | .table td.read-label { 217 | font-weight: bold; 218 | text-align: right; 219 | } 220 | 221 | input[readonly].datepicker { 222 | cursor: pointer; 223 | } 224 | 225 | .navbar-toggle .icon-bar { 226 | background-color: #333; 227 | } 228 | -------------------------------------------------------------------------------- /smartmin/static/css/styles.css: -------------------------------------------------------------------------------- 1 | #footer { 2 | background-image: url("../img/nyaruka.png"); 3 | height: 16px; 4 | width: 100px; 5 | float: right; 6 | } 7 | 8 | footer { 9 | margin-top: 5px; 10 | padding-top: 3px; 11 | padding-bottom: 3px; 12 | } -------------------------------------------------------------------------------- /smartmin/static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /smartmin/static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /smartmin/static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /smartmin/static/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /smartmin/static/img/active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/active.png -------------------------------------------------------------------------------- /smartmin/static/img/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/background.jpg -------------------------------------------------------------------------------- /smartmin/static/img/button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/button.png -------------------------------------------------------------------------------- /smartmin/static/img/check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/check.png -------------------------------------------------------------------------------- /smartmin/static/img/glyphicons-halflings-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/glyphicons-halflings-white.png -------------------------------------------------------------------------------- /smartmin/static/img/glyphicons-halflings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/glyphicons-halflings.png -------------------------------------------------------------------------------- /smartmin/static/img/in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/in.png -------------------------------------------------------------------------------- /smartmin/static/img/loc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/loc.png -------------------------------------------------------------------------------- /smartmin/static/img/nyaruka.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/nyaruka.png -------------------------------------------------------------------------------- /smartmin/static/img/out.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/out.png -------------------------------------------------------------------------------- /smartmin/static/img/smartmin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin.png -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/arrow-down.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/arrow-down.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/arrow-up.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/arrow-up.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/changelist-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/changelist-bg.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/changelist-bg_rtl.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/changelist-bg_rtl.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/chooser-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/chooser-bg.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/chooser_stacked-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/chooser_stacked-bg.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/default-bg-reverse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/default-bg-reverse.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/default-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/default-bg.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/deleted-overlay.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/deleted-overlay.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/icon-no.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/icon-no.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/icon-unknown.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/icon-unknown.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/icon-yes.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/icon-yes.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/icon_addlink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/icon_addlink.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/icon_alert.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/icon_alert.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/icon_calendar.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/icon_calendar.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/icon_changelink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/icon_changelink.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/icon_clock.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/icon_clock.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/icon_deletelink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/icon_deletelink.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/icon_error.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/icon_error.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/icon_searchbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/icon_searchbox.png -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/icon_success.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/icon_success.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/inline-delete-8bit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/inline-delete-8bit.png -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/inline-delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/inline-delete.png -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/inline-restore-8bit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/inline-restore-8bit.png -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/inline-restore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/inline-restore.png -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/inline-splitter-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/inline-splitter-bg.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/loading.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/nav-bg-grabber.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/nav-bg-grabber.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/nav-bg-reverse.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/nav-bg-reverse.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/nav-bg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/nav-bg.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/selector-add.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/selector-add.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/selector-addall.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/selector-addall.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/selector-remove.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/selector-remove.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/selector-removeall.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/selector-removeall.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/selector-search.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/selector-search.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/selector_stacked-add.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/selector_stacked-add.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/selector_stacked-remove.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/selector_stacked-remove.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/tool-left.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/tool-left.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/tool-left_over.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/tool-left_over.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/tool-right.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/tool-right.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/tool-right_over.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/tool-right_over.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/tooltag-add.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/tooltag-add.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/tooltag-add_over.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/tooltag-add_over.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/tooltag-arrowright.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/tooltag-arrowright.gif -------------------------------------------------------------------------------- /smartmin/static/img/smartmin/tooltag-arrowright_over.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/smartmin/tooltag-arrowright_over.gif -------------------------------------------------------------------------------- /smartmin/static/img/sort.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/sort.gif -------------------------------------------------------------------------------- /smartmin/static/img/sort.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/sort.png -------------------------------------------------------------------------------- /smartmin/static/img/sort_asc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/sort_asc.png -------------------------------------------------------------------------------- /smartmin/static/img/sort_dsc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/img/sort_dsc.png -------------------------------------------------------------------------------- /smartmin/static/js/jquery.pjax.js: -------------------------------------------------------------------------------- 1 | // jquery.pjax.js 2 | // copyright chris wanstrath 3 | // https://github.com/defunkt/pjax 4 | 5 | // When called on a link, fetches the href with ajax into the 6 | // container specified as the first parameter or with the data-pjax 7 | // attribute on the link itself. 8 | // 9 | // Tries to make sure the back button and ctrl+click work the way 10 | // you'd expect. 11 | // 12 | // Accepts a jQuery ajax options object that may include these 13 | // pjax specific options: 14 | // 15 | // container - Stick the response body in this String selector 16 | // $(container).html(xhr.responseBody) 17 | // push - Whether to pushState the URL. Defaults to true (of course). 18 | // replace - Want to use replaceState instead? That's cool. 19 | // 20 | // For convenience the first parameter can be either the container or 21 | // the options object. 22 | // 23 | // Returns the jQuery object 24 | jQuery.fn.pjax = function( container, options ) { 25 | var $ = jQuery 26 | 27 | if ( options ) 28 | options.container = container 29 | else 30 | options = $.isPlainObject(container) ? container : {container:container} 31 | 32 | return this.live('click', function(event){ 33 | // Middle click, cmd click, and ctrl click should open 34 | // links in a new tab as normal. 35 | if ( event.which == 2 || event.metaKey ) 36 | return true 37 | 38 | var defaults = { 39 | url: this.href, 40 | container: $(this).attr('data-pjax') 41 | } 42 | 43 | $.pjax($.extend({}, defaults, options)) 44 | 45 | event.preventDefault() 46 | }) 47 | } 48 | 49 | 50 | // Loads a URL with ajax, puts the response body inside a container, 51 | // then pushState()'s the loaded URL. Also ensures the back button 52 | // works the way you'd expect. 53 | // 54 | // Works just like $.ajax in that it accepts a jQuery ajax 55 | // settings object (with keys like url, type, data, etc). 56 | // 57 | // Accepts these extra keys: 58 | // 59 | // container - Where to stick the response body. 60 | // $(container).html(xhr.responseBody) 61 | // push - Whether to pushState the URL. Defaults to true (of course). 62 | // replace - Want to use replaceState instead? That's cool. 63 | // 64 | // Use it just like $.ajax: 65 | // 66 | // var xhr = $.pjax({ url: this.href, container: '#main' }) 67 | // console.log( xhr.readyState ) 68 | // 69 | // Returns whatever $.ajax returns. 70 | jQuery.pjax = function( options ) { 71 | // Helper 72 | var $ = jQuery, $container = $(options.container) 73 | 74 | var defaults = { 75 | timeout: 10000, 76 | push: true, 77 | replace: false, 78 | cache: false, 79 | type: 'GET', 80 | dataType: 'html', 81 | beforeSend: function(xhr){ 82 | xhr.setRequestHeader('X-PJAX', 'true') 83 | }, 84 | error: function(response){ 85 | // we are being told to redirect 86 | if (response.status == 302) { 87 | jQuery.pjax({ 88 | // To support AJAX client-side redirects the redirect 89 | // location is in the body, not the Location header 90 | url: response.responseText, 91 | data: { 'pjax' : true }, 92 | container: options.container, 93 | push: false, 94 | replace: true, 95 | success: function() { 96 | success.apply(this, arguments); 97 | } 98 | }); 99 | 100 | } else { 101 | // Invoke their success handler if they gave us one. 102 | error.apply(this, arguments); 103 | } 104 | }, 105 | success: function( data ) { 106 | // If we got no data or an entire web page, go directly 107 | // to the page and let normal error handling happen. 108 | if ( !$.trim(data) || /<html/i.test(data) ) 109 | return window.location = options.url 110 | 111 | // Make it happen. 112 | $container.html(data) 113 | 114 | if ( !$.pjax.active ) { 115 | $.pjax.active = true 116 | window.history.replaceState({ pjax: true }, 117 | document.title, 118 | location.pathname) 119 | } 120 | 121 | // If there's a <title> tag in the response, use it as 122 | // the page's title. 123 | var title = $.trim( $container.find('title').remove().text() ) 124 | if ( title ) document.title = title 125 | 126 | var state = { pjax: options.container } 127 | 128 | if ( options.data ) 129 | state.url = options.url + '?' + $.param(options.data) 130 | 131 | if ( options.replace ) { 132 | window.history.replaceState(state, document.title, options.url) 133 | } else if ( options.push ) { 134 | window.history.pushState(state, document.title, options.url) 135 | } 136 | 137 | if ( (options.replace || options.push) && window._gaq ) 138 | _gaq.push(['_trackPageview']) 139 | 140 | // Invoke their success handler if they gave us one. 141 | success.apply(this, arguments) 142 | } 143 | } 144 | 145 | // We don't want to let anyone override our success handler. 146 | var success = options.success || $.noop 147 | delete options.success 148 | 149 | // Don't let them override error either 150 | var error = options.error || $.noop 151 | delete options.error 152 | 153 | options = $.extend(true, {}, defaults, options) 154 | var xhr = $.ajax(options) 155 | 156 | $(document).trigger('pjax', xhr, options) 157 | return xhr 158 | } 159 | 160 | // Has the pjaxing begun? We must know. 161 | jQuery.pjax.active = false 162 | 163 | // onpopstate fires at some point after the first page load, by design. 164 | // pjax only cares about the back button, so we ignore the first onpopstate. 165 | // 166 | // Of course, older webkit doesn't fire the onopopstate event on load. 167 | // So we have to special case. The joys. 168 | jQuery.pjax.firstLoad = true 169 | 170 | if ( jQuery.browser.webkit && parseInt(jQuery.browser.version) < 534 ) 171 | jQuery.pjax.firstLoad = false 172 | 173 | 174 | // Bind our popstate handler which takes care of the back and 175 | // forward buttons, but only once we've called pjax(). 176 | // 177 | // You probably shouldn't use pjax on pages with other pushState 178 | // stuff yet. 179 | jQuery(window).bind('popstate', function(event){ 180 | // Do nothing if we're not pjaxing 181 | if ( jQuery.pjax == jQuery.noop ) 182 | return 183 | 184 | if ( jQuery.pjax.firstLoad ) 185 | return jQuery.pjax.firstLoad = false 186 | 187 | var state = event.state 188 | 189 | if ( jQuery.pjax.active || state && state.pjax ) { 190 | var container = $(state.pjax+'') 191 | if ( container.length ) 192 | jQuery.pjax({ 193 | url: state.url || location.href, 194 | container: container, 195 | push: false 196 | }) 197 | else 198 | window.location = location.href 199 | } 200 | }) 201 | 202 | 203 | // Add the state property to jQuery's event object so we can use it in 204 | // $(window).bind('popstate') 205 | jQuery.event.props.push('state') 206 | 207 | 208 | // Fall back to normalcy for older browsers. 209 | if ( !window.history || !window.history.pushState ) { 210 | jQuery.pjax = jQuery.noop 211 | jQuery.fn.pjax = function() { return this } 212 | }; 213 | -------------------------------------------------------------------------------- /smartmin/static/js/libs/jquery.url.js: -------------------------------------------------------------------------------- 1 | // JQuery URL Parser plugin - https://github.com/allmarkedup/jQuery-URL-Parser 2 | // Written by Mark Perkins, mark@allmarkedup.com 3 | // License: http://unlicense.org/ (i.e. do what you want with it!) 4 | 5 | ;(function($, undefined) { 6 | 7 | var tag2attr = { 8 | a : 'href', 9 | img : 'src', 10 | form : 'action', 11 | base : 'href', 12 | script : 'src', 13 | iframe : 'src', 14 | link : 'href' 15 | }, 16 | 17 | key = ["source","protocol","authority","userInfo","user","password","host","port","relative","path","directory","file","query","fragment"], // keys available to query 18 | 19 | aliases = { "anchor" : "fragment" }, // aliases for backwards compatability 20 | 21 | parser = { 22 | strict : /^(?:([^:\/?#]+):)?(?:\/\/((?:(([^:@]*):?([^:@]*))?@)?([^:\/?#]*)(?::(\d*))?))?((((?:[^?#\/]*\/)*)([^?#]*))(?:\?([^#]*))?(?:#(.*))?)/, //less intuitive, more accurate to the specs 23 | loose : /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?((?:(([^:@]*):?([^:@]*))?@)?([^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/ // more intuitive, fails on relative paths and deviates from specs 24 | }, 25 | 26 | querystring_parser = /(?:^|&|;)([^&=;]*)=?([^&;]*)/g, // supports both ampersand and semicolon-delimted query string key/value pairs 27 | 28 | fragment_parser = /(?:^|&|;)([^&=;]*)=?([^&;]*)/g; // supports both ampersand and semicolon-delimted fragment key/value pairs 29 | 30 | function parseUri( url, strictMode ) 31 | { 32 | var str = decodeURI( url ), 33 | res = parser[ strictMode || false ? "strict" : "loose" ].exec( str ), 34 | uri = { attr : {}, param : {}, seg : {} }, 35 | i = 14; 36 | 37 | while ( i-- ) 38 | { 39 | uri.attr[ key[i] ] = res[i] || ""; 40 | } 41 | 42 | // build query and fragment parameters 43 | 44 | uri.param['query'] = {}; 45 | uri.param['fragment'] = {}; 46 | 47 | uri.attr['query'].replace( querystring_parser, function ( $0, $1, $2 ){ 48 | if ($1) 49 | { 50 | uri.param['query'][$1] = $2; 51 | } 52 | }); 53 | 54 | uri.attr['fragment'].replace( fragment_parser, function ( $0, $1, $2 ){ 55 | if ($1) 56 | { 57 | uri.param['fragment'][$1] = $2; 58 | } 59 | }); 60 | 61 | // split path and fragement into segments 62 | 63 | uri.seg['path'] = uri.attr.path.replace(/^\/+|\/+$/g,'').split('/'); 64 | 65 | uri.seg['fragment'] = uri.attr.fragment.replace(/^\/+|\/+$/g,'').split('/'); 66 | 67 | // compile a 'base' domain attribute 68 | 69 | uri.attr['base'] = uri.attr.host ? uri.attr.protocol+"://"+uri.attr.host + (uri.attr.port ? ":"+uri.attr.port : '') : ''; 70 | 71 | return uri; 72 | }; 73 | 74 | function getAttrName( elm ) 75 | { 76 | var tn = elm.tagName; 77 | if ( tn !== undefined ) return tag2attr[tn.toLowerCase()]; 78 | return tn; 79 | } 80 | 81 | $.fn.url = function( strictMode ) 82 | { 83 | var url = ''; 84 | 85 | if ( this.length ) 86 | { 87 | url = $(this).attr( getAttrName(this[0]) ) || ''; 88 | } 89 | 90 | return $.url( url, strictMode ); 91 | }; 92 | 93 | $.url = function( url, strictMode ) 94 | { 95 | if ( arguments.length === 1 && url === true ) 96 | { 97 | strictMode = true; 98 | url = undefined; 99 | } 100 | 101 | strictMode = strictMode || false; 102 | url = url || window.location.toString(); 103 | 104 | return { 105 | 106 | data : parseUri(url, strictMode), 107 | 108 | // get various attributes from the URI 109 | attr : function( attr ) 110 | { 111 | attr = aliases[attr] || attr; 112 | return attr !== undefined ? this.data.attr[attr] : this.data.attr; 113 | }, 114 | 115 | // return query string parameters 116 | param : function( param ) 117 | { 118 | return param !== undefined ? this.data.param.query[param] : this.data.param.query; 119 | }, 120 | 121 | // return fragment parameters 122 | fparam : function( param ) 123 | { 124 | return param !== undefined ? this.data.param.fragment[param] : this.data.param.fragment; 125 | }, 126 | 127 | // return path segments 128 | segment : function( seg ) 129 | { 130 | if ( seg === undefined ) 131 | { 132 | return this.data.seg.path; 133 | } 134 | else 135 | { 136 | seg = seg < 0 ? this.data.seg.path.length + seg : seg - 1; // negative segments count from the end 137 | return this.data.seg.path[seg]; 138 | } 139 | }, 140 | 141 | // return fragment segments 142 | fsegment : function( seg ) 143 | { 144 | if ( seg === undefined ) 145 | { 146 | return this.data.seg.fragment; 147 | } 148 | else 149 | { 150 | seg = seg < 0 ? this.data.seg.fragment.length + seg : seg - 1; // negative segments count from the end 151 | return this.data.seg.fragment[seg]; 152 | } 153 | } 154 | 155 | }; 156 | 157 | }; 158 | 159 | })(jQuery); -------------------------------------------------------------------------------- /smartmin/static/js/scripts.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/js/scripts.js -------------------------------------------------------------------------------- /smartmin/static/top.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/static/top.png -------------------------------------------------------------------------------- /smartmin/templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends "frame.html" %} 2 | 3 | {% block extra-style %} 4 | 5 | {% endblock %} 6 | 7 | -------------------------------------------------------------------------------- /smartmin/templates/csv_imports/importtask_read.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/read.html" %} 2 | 3 | {% block content %} 4 | {% block pjax %} 5 | <div id="pjax"> 6 | <div class="row"> 7 | <div class="col-md-10"> 8 | <table class="table table-striped"> 9 | <tbody> 10 | <tr> 11 | <td class="bold">Status</td> 12 | <td>{{ object.status }} 13 | {% if object.status == 'PENDING' or object.status == 'RUNNING' or object.status == 'STARTED' %} 14 | <img class="pull-right" src="{{ STATIC_URL }}img/smartmin/loading.gif"> 15 | {% endif %} 16 | </td> 17 | </tr> 18 | <tr> 19 | <td class="bold">File</td> 20 | <td>{{ object.csv_file }}</td> 21 | </tr> 22 | </tbody> 23 | </table> 24 | <pre>{{ object.import_log }}</pre> 25 | </div> 26 | </div> 27 | 28 | </div> 29 | {% endblock %} 30 | {% endblock %} 31 | 32 | {% block extra-style %} 33 | <style> 34 | td.bold { 35 | font-weight: bold; 36 | text-align: right; 37 | } 38 | 39 | div.buttons { 40 | padding-bottom: 5px; 41 | } 42 | 43 | div.loading { 44 | padding-top: 10px; 45 | } 46 | </style> 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /smartmin/templates/frame.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <head> 3 | <meta charset="utf-8"> 4 | <title>{% block title %}Smartmin{% endblock %} 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {# load compress #} 21 | {# compress css #} 22 | {% block styles %} 23 | 24 | 25 | 26 | 27 | {% endblock %} 28 | 29 | {% block extra-style %}{% endblock %} 30 | 31 | {# endcompress #} 32 | 33 | 34 | 35 | 36 | {% block navbar %} 37 | 86 | {% endblock navbar %} 87 | 88 |
89 | 90 | {% load smartmin %} 91 | 92 | {% block content-div %} 93 |
94 | {% block messages %} 95 | {% if messages %} 96 | {% for message in messages %} 97 |
98 | × 99 | {{ message }} 100 |
101 | {% endfor %} 102 | {% endif %} 103 | {% endblock messages %} 104 | 105 | {% block pre-content %} 106 | {% endblock %} 107 | 108 | {% block content %} 109 | {% endblock %} 110 | 111 | {% block post-content %} 112 | {% endblock %} 113 |
114 | {% endblock %} 115 | 116 |
117 | {% block footer %} 118 | 119 | {% endblock %} 120 |
121 | 122 |
123 | 124 | 125 | {% block js-jquery %} 126 | 133 | {% endblock %} 134 | 135 | {# compress js #} 136 | 137 | 138 | 139 | 140 | {# media associated with any form we are displaying #} 141 | {% if form %} 142 | {{ form.media }} 143 | {% endif %} 144 | 145 | {% block extra-script %}{% endblock %} 146 | {% block script %}{% endblock %} 147 | {# endcompress #} 148 | 149 | 150 | 156 | 157 | 158 | 159 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/base.html: -------------------------------------------------------------------------------- 1 | {% extends base_template %} 2 | 3 | {% load smartmin %} 4 | 5 | {% block pre-content %} 6 | 7 | {% endblock %} 8 | 9 | {% block extra-script %} 10 | 11 | {# placeholder form for posterizer href's.. href's with a posterize class will be converted to POSTs #} 12 |
13 | {% csrf_token %} 14 |
15 | 16 | 17 | 18 | 104 | {# embed refresh script if refresh is active #} 105 | {% if refresh %} 106 | 138 | {% endif %} 139 | 140 | {% endblock %} 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/create.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/form.html" %} 2 | 3 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/delete_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/base.html" %} 2 | 3 | {% load smartmin i18n %} 4 | 5 | {% block pre-content %}{% endblock %} 6 | 7 | {% block content %} 8 | {% block pjax %} 9 |
10 | 11 |

{% trans "Remove Item?" %}

12 |

13 | {% blocktrans with object=object %} 14 | Are you sure you want to remove {{ object }}? 15 | {% endblocktrans %} 16 |

17 |

{% trans "Once it is removed, it will be gone forever. There is no way to undo this operation." %}

18 | 19 |
20 | 21 | {% block delete_form %}{% endblock %} 22 | 23 |
24 | {% trans "Cancel" %} 25 | {% csrf_token %} 26 | 27 |
28 |
29 | 30 |
31 | {% endblock %} 32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/field.html: -------------------------------------------------------------------------------- 1 | {% load smartmin %} 2 | 3 | {% with form|field:field as form_field %} 4 | {% getblock "before_field_" field %} 5 | 6 | {% if form_field and form_field.is_hidden %} 7 | {{ form_field }} 8 | {% else %} 9 | {% if form_field != None %} 10 |
11 | 12 |
13 | {{ form_field|add_css:"form-control" }} 14 | {% with view|field_help:field as help %} 15 | {% if help %} 16 | {{ help }} 17 | {% endif %} 18 | {% endwith %} 19 | 20 | {% if form_field.errors %} 21 | {{ form_field.errors }} 22 | {% endif %} 23 |
24 |
25 | {% else %} 26 |
27 | 28 |
29 | {% get_value_from_view field %} 30 | {% with view|field_help:field as help %} 31 | {% if help %} 32 | {{ help }} 33 | {% endif %} 34 | {% endwith %} 35 |
36 |
37 | {% endif %} 38 | {% endif %} 39 | 40 | {% getblock "after_field_" field %} 41 | 42 | {% endwith %} 43 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/form.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/base.html" %} 2 | 3 | {% load smartmin i18n %} 4 | 5 | {% block content %} 6 | {% block pjax %} 7 |
8 |
9 | 10 | {% block pre-form %}{% endblock %} 11 | 12 |
13 | {% block pre-form-errors %} 14 | {% endblock pre-form-errors %} 15 | 16 | {% if form.non_field_errors %} 17 |
18 | {{ form.non_field_errors }} 19 |
20 | {% endif %} 21 | 22 | {% block post-form-errors %} 23 | {% endblock post-form-errors %} 24 | 25 | {% block pre-fields %} 26 | {% endblock %} 27 | 28 | {% block form-help %}{% endblock form-help %} 29 | 30 | {% block fields %} 31 |
32 | {% for field in fields %} 33 | {% render_field field %} 34 | {% endfor %} 35 | 36 | {% block extra-fields %} 37 | {% endblock %} 38 |
39 | {% endblock fields %} 40 | 41 | {% block post-fields %} 42 | {% endblock post-fields %} 43 | 44 | {% csrf_token %} 45 | 46 | {% block form-buttons %} 47 |
48 |
49 | 50 | {% trans "Cancel" %} 51 |
52 |
53 | {% endblock %} 54 |
55 | 56 | {% block post-form %}{% endblock post-form %} 57 |
58 | 59 | {% block form-right %} 60 | {% endblock %} 61 |
62 | {% endblock pjax %} 63 | {% endblock content %} 64 | 65 | {% block extra-script %} 66 | {{ block.super }} 67 | 73 | 74 | {% if javascript_submit %} 75 | 90 | {% endif %} 91 | {% endblock %} 92 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/list.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/base.html" %} 2 | 3 | {% load smartmin i18n %} 4 | 5 | {% block content %} 6 | 7 | {% block table-controls %} 8 |
9 |
10 | {% if view.search_fields %} 11 | {% block search-form %} 12 |
13 | 14 | 15 |
16 | {% endblock %} 17 | {% else %} 18 |   19 | {% endif %} 20 |
21 | 22 |
23 | {% block table-buttons %} 24 | {% if view.add_button %} 25 | {% trans "Add" %} 26 | {% endif %} 27 | {% endblock table-buttons %} 28 |
29 |
30 | {% endblock %} 31 | 32 | {% block pjax %} 33 |
34 |
35 |
36 | {% block pre-table %}{% endblock %} 37 | 38 | {% block table %} 39 | 40 | 41 | 42 | {% for field in fields %} 43 | 44 | {% endfor %} 45 | 46 | 47 | 48 | {% for obj in object_list %} 49 | 50 | {% for field in fields %} 51 | 54 | {% endfor %} 55 | 56 | {% empty %} 57 | 58 | {% for field in fields %} 59 | 60 | {% endfor %} 61 | 62 | {% endfor %} 63 | 64 | {% block extra-rows %} 65 | {% endblock extra-rows %} 66 | 67 |
{% get_label field %}
68 | {% endblock table %} 69 | 70 | {% block post-table %}{% endblock post-table %} 71 |
72 |
73 | 74 | {% block paginator %} 75 |
76 |
77 |
78 | {% if not paginator or paginator.num_pages <= 1 %} 79 | {% blocktrans count counter=object_list|length %} 80 | {{ counter }} result 81 | {% plural %} 82 | {{ counter }} results 83 | {% endblocktrans %} 84 | {% else %} 85 | {% blocktrans with start=page_obj.start_index end=page_obj.end_index count=paginator.count %} 86 | Results {{ start }}-{{ end }} of {{ count }} 87 | {% endblocktrans %} 88 | {% endif %} 89 |
90 |
91 |
92 | {% if paginator and paginator.num_pages > 1 %} 93 |
    94 | {% if page_obj.has_previous %} 95 | 96 | {% else %} 97 | 98 | {% endif %} 99 | 100 | {% for page_num in paginator.page_range %} 101 | {% if page_obj.number < 5 %} 102 | {% if page_num < 10 %} 103 | {% if not page_num == page_obj.number %} 104 |
  • {{ page_num }}
  • 105 | {% else %} 106 |
  • {{ page_num }}
  • 107 | {% endif %} 108 | {% endif %} 109 | {% elif page_num < page_obj.number|add:"5" and page_num > page_obj.number|add:"-5" %} 110 | {% if not page_num == page_obj.number %} 111 |
  • {{ page_num }}
  • 112 | {% else %} 113 |
  • {{ page_num }}
  • 114 | {% endif %} 115 | {% elif page_obj.number > paginator.num_pages|add:"-5" %} 116 | {% if page_num > paginator.num_pages|add:"-9" %} 117 | {% if not page_num == page_obj.number %} 118 |
  • {{ page_num }}
  • 119 | {% else %} 120 |
  • {{ page_num }}
  • 121 | {% endif %} 122 | {% endif %} 123 | 124 | {% endif %} 125 | {% endfor %} 126 | 127 | {% if page_obj.has_next %} 128 | 129 | {% else %} 130 | 131 | {% endif %} 132 |
133 | {% endif %} 134 |
135 |
136 | {% endblock %} 137 | 138 |
139 | {% endblock pjax %} 140 | {% endblock content %} 141 | 142 | {% block extra-script %} 143 | {{ block.super }} 144 | 145 | 161 | 162 | {% endblock %} 163 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/pjax.html: -------------------------------------------------------------------------------- 1 | 2 | {% block pjax %} 3 | {% endblock %} 4 | 5 | {% if refresh > 0 %} 6 | 9 | {% endif %} 10 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/read.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/base.html" %} 2 | 3 | {% load smartmin i18n %} 4 | 5 | {% block content %} 6 | {% block pjax %} 7 | 8 |
9 |
10 |
11 | {% block read-buttons %} 12 | {% if view.edit_button %} 13 | {% trans "edit" %} 14 | {% endif %} 15 | {% endblock %} 16 |
17 |
18 | 19 |
20 |
21 | {% block pre-fields %} 22 | {% endblock pre-fields %} 23 | 24 | {% block fields %} 25 | 26 | 27 | {% for field in fields %} 28 | 29 | 30 | 31 | 32 | {% endfor %} 33 | 34 | {% block extra-fields %} 35 | {% endblock extra-fields %} 36 | 37 |
{% get_label field %}{% get_value object field %} 
38 | {% endblock fields %} 39 | 40 | {% block post-fields %} 41 | {% endblock %} 42 |
43 |
44 |
45 | 46 | {% endblock %} 47 | {% endblock %} 48 | 49 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/update.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/form.html" %} 2 | {% load i18n %} 3 | 4 | {% block form-buttons %} 5 |
6 |
7 | 8 | {% trans "Cancel" %} 9 | {% if delete_url %} 10 | {% trans "Remove" %} 11 | {% endif %} 12 |
13 |
14 | {% endblock %} 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/users/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load smartmin i18n %} 4 | 5 | {% block login %}{% endblock %} 6 | 7 | {% block content %} 8 |
9 |
10 | 11 | 12 |
13 | {% for field, errors in form.errors.items %} 14 | {% if field == '__all__' %} 15 |
16 |

Error

17 | {{ errors }} 18 |
19 | {% endif %} 20 | {% endfor %} 21 | 22 |
23 | {% for field in form %} 24 |
25 | 26 |
{{ field|add_css:"form-control" }}
27 | {% if field.error %} 28 | {{ field.error }} 29 | {% endif %} 30 |
31 | {% endfor %} 32 | {% csrf_token %} 33 | 34 | {% if allow_email_recovery %} 35 | {% trans "Forgot Password?" %} 36 | {% endif %} 37 |
38 |
39 |
40 | 41 |
42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/users/no_user_email.txt: -------------------------------------------------------------------------------- 1 | Hi from {{ hostname }}, 2 | 3 | Someone has requested that the password for this email address be reset, however we don't have an account associated with it. 4 | 5 | If you did not request this, don't worry, this email has only been sent to you and your account remains secure. 6 | 7 | Thank you. 8 | 9 | Please do not reply to this email. 10 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/users/user_email.txt: -------------------------------------------------------------------------------- 1 | Hi from {{ hostname }}, 2 | 3 | Someone has requested that the password for this email address be reset. 4 | 5 | Clicking on the following link will allow you to reset the password for the account {{user.username}}: 6 | 7 | {{protocol}}://{{hostname}}{{path}} 8 | 9 | If you did not request this, don't worry, this email has only been sent to you and your account remains secure. 10 | 11 | Thank you. 12 | 13 | Please do not reply to this email. 14 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/users/user_expired.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html'%} 2 | 3 | {% load smartmin i18n %} 4 | 5 | {% block content %} 6 | 7 | {% trans "Token Expired" %} 8 | 9 | {% endblock content %} 10 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/users/user_failed.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load smartmin i18n %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 | 10 |

11 | {% blocktrans %} 12 | Sorry, you cannot log in at this time because we received {{failed_login_limit}} incorrect login attempts. 13 | {% endblocktrans %} 14 |

15 | 16 | {% if lockout_timeout >= 0 %} 17 |

18 | {% blocktrans %} 19 | Please wait {{lockout_timeout}} minutes before to try log in again. 20 | {% endblocktrans %} 21 |

22 | {% endif %} 23 | 24 | {% if allow_email_recovery %} 25 |

{% trans "Alternatively, you can fill out the form below to have your password reset via e-mail." %}

26 | {% else %} 27 |

{% trans "Please contact the website administrator to have your password reset." %}

28 | {% endif %} 29 |
30 |
31 |
32 | 33 | {% if allow_email_recovery %} 34 |
35 |
36 |
37 | {% csrf_token %} 38 |
39 | 40 | 41 | 42 |
43 |
44 |
45 |
46 | {% endif %} 47 | 48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/users/user_forget.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/form.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block pre-form %} 6 |
{% trans "Please enter the email address you used to sign up and we will help you recover your password." %}
7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/users/user_list.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/list.html" %} 2 | 3 | {% block search-form %} 4 |
5 | 6 | 12 | 13 |
14 | {% endblock %} 15 | 16 | {% block extra-style %} 17 | {{ block.super }} 18 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/users/user_newpassword.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/update.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block pre-form %} 6 |
7 | {% blocktrans %} 8 | Your password has expired. Site policy states that you must pick a new password every {{ expire_days }} days, and that passwords must not have been used within the previous {{ window_days }} days. 9 | {% endblocktrans %} 10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /smartmin/templates/smartmin/users/user_recover.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/form.html" %} -------------------------------------------------------------------------------- /smartmin/templates/smartmin/users/user_update.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/update.html" %} 2 | {% load smartmin %} 3 | 4 | {% block post-form %} 5 | {% if perms.auth.user_mimic %} 6 |
7 | {% csrf_token %} 8 |
9 | 10 |
11 | 12 |
13 |
14 |
15 | {% endif %} 16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /smartmin/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/templatetags/__init__.py -------------------------------------------------------------------------------- /smartmin/tests.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlparse 2 | 3 | from django.conf import settings 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.auth.models import Group 6 | from django.test.testcases import TestCase 7 | from django.urls import reverse 8 | from django.utils.encoding import force_str 9 | 10 | 11 | class SmartminTestMixin(object): 12 | def fetch_protected(self, url, user, post_data=None, failOnFormValidation=True): 13 | """ 14 | Fetches the given url. Fails if it can be fetched without first logging in as given user 15 | """ 16 | # make sure we are logged out before testing permissions 17 | self.client.logout() 18 | 19 | # can't load if we aren't logged in 20 | response = self.client.get(url) 21 | self.assertRedirect( 22 | response, reverse("users.user_login"), msg="'%s' loaded without being logged in first" % url 23 | ) 24 | self.login(user) 25 | 26 | # but now we can! 27 | if not post_data: 28 | response = self.client.get(url) 29 | self.assertEqual(200, response.status_code) 30 | else: 31 | response = self.client.post(url, data=post_data) 32 | self.assertNotRedirect(response, reverse("users.user_login"), msg="Unexpected redirect to login") 33 | 34 | if failOnFormValidation: 35 | self.assertNoFormErrors(response, post_data) 36 | self.assertEqual(302, response.status_code) 37 | 38 | return response 39 | 40 | def assertLoginRedirect(self, response, msg=None): 41 | self.assertRedirect(response, settings.LOGIN_URL, msg=msg) 42 | 43 | def assertRedirect(self, response, url, status_code=302, msg=None): 44 | self.assertEqual(response.status_code, status_code, msg=msg) 45 | segments = urlparse(response.get("Location", None)) 46 | self.assertEqual(segments.path, url, msg=msg) 47 | 48 | def assertNotRedirect(self, response, url, msg=None): 49 | if response.status_code == 302: 50 | segments = urlparse(response.get("Location", None)) 51 | self.assertNotEqual(segments.path, url, msg=msg) 52 | 53 | def create_user(self, username, group_names=()): 54 | # Create a user to run our CRUDL tests 55 | user = get_user_model().objects.create_user(username, "%s@nyaruka.com" % username) 56 | user.set_password(username) 57 | user.save() 58 | for group in group_names: 59 | user.groups.add(Group.objects.get(name=group)) 60 | return user 61 | 62 | def login(self, user): 63 | self.assertTrue( 64 | self.client.login(username=user.username, password=user.username), 65 | "Couldn't login as %(user)s / %(user)s" % dict(user=user.username), 66 | ) 67 | 68 | def assertNoFormErrors(self, response, post_data=None): 69 | if response.status_code == 200 and "form" in response.context: 70 | form = response.context["form"] 71 | 72 | if not form.is_valid(): 73 | errors = [] 74 | for k, v in form.errors.items(): 75 | errors.append("%s=%s" % (k, force_str(v))) 76 | self.fail("Create failed with form errors: %s, Posted: %s" % (",".join(errors), post_data)) 77 | 78 | def create_anonymous_user(self): 79 | User = get_user_model() 80 | _anon_exists = User.objects.filter(username=settings.ANONYMOUS_USER_NAME).exists() 81 | if not _anon_exists: 82 | user = User(username=settings.ANONYMOUS_USER_NAME) 83 | user.set_unusable_password() 84 | user.save() 85 | 86 | 87 | class SmartminTest(SmartminTestMixin, TestCase): 88 | pass 89 | 90 | 91 | class _CRUDLTest(SmartminTest): 92 | """ 93 | Base class for standard CRUDL test cases 94 | """ 95 | 96 | crudl = None 97 | user = None 98 | object = None 99 | 100 | def setUp(self): 101 | self.crudl = None 102 | self.user = None 103 | super(_CRUDLTest, self).setUp() 104 | 105 | def run(self, result=None): 106 | # only actually run sub classes of this 107 | if self.__class__ != _CRUDLTest: 108 | super(_CRUDLTest, self).run(result) 109 | 110 | def getCRUDL(self): 111 | if self.crudl: 112 | return self.crudl() 113 | raise Exception("Must define self.crudl") 114 | 115 | def getUser(self): 116 | if self.user: 117 | return self.user 118 | raise Exception("Must define self.user") 119 | 120 | def getCreatePostData(self): 121 | raise Exception("Missing method: %s.getCreatePostData()" % self.__class__.__name__) 122 | 123 | def getUpdatePostData(self): 124 | raise Exception("Missing method: %s.getUpdatePostData()" % self.__class__.__name__) 125 | 126 | def getManager(self): 127 | return self.getCRUDL().model.objects 128 | 129 | def getTestObject(self): 130 | if self.object: 131 | return self.object 132 | 133 | if self.getCRUDL().permissions: 134 | self.login(self.getUser()) 135 | 136 | # create our object 137 | create_page = reverse(self.getCRUDL().url_name_for_action("create")) 138 | post_data = self.getCreatePostData() 139 | self.client.post(create_page, data=post_data) 140 | 141 | # find our created object 142 | self.object = self.getManager().get(**post_data) 143 | return self.object 144 | 145 | def testCreate(self): 146 | if "create" not in self.getCRUDL().actions: 147 | return 148 | self._do_test_view("create", post_data=self.getCreatePostData()) 149 | 150 | def testRead(self): 151 | if "read" not in self.getCRUDL().actions: 152 | return 153 | self._do_test_view("read", self.getTestObject()) 154 | 155 | def testUpdate(self): 156 | if "update" not in self.getCRUDL().actions: 157 | return 158 | self._do_test_view("update", self.getTestObject(), post_data=self.getUpdatePostData()) 159 | 160 | def testDelete(self): 161 | if "delete" not in self.getCRUDL().actions: 162 | return 163 | object = self.getTestObject() 164 | self._do_test_view("delete", object, post_data=dict()) 165 | self.assertEqual(0, len(self.getManager().filter(pk=object.pk))) 166 | 167 | def testList(self): 168 | if "list" not in self.getCRUDL().actions: 169 | return 170 | # have at least one object 171 | self.getTestObject() 172 | self._do_test_view("list") 173 | 174 | def testCsv(self): 175 | if "csv" not in self.getCRUDL().actions: 176 | return 177 | # have at least one object 178 | self.getTestObject() 179 | self._do_test_view("csv") 180 | 181 | def _do_test_view(self, action=None, object=None, post_data=None, query_string=None): 182 | url_name = self.getCRUDL().url_name_for_action(action) 183 | if object: 184 | url = reverse(url_name, args=[object.pk]) 185 | else: 186 | url = reverse(url_name) 187 | 188 | # append our query string if we have one 189 | if query_string: 190 | url = "%s?%s" % (url, query_string) 191 | 192 | # make sure we are logged out before testing permissions 193 | self.client.logout() 194 | 195 | response = self.client.get(url) 196 | 197 | view = self.getCRUDL().view_for_action(action) 198 | if self.getCRUDL().permissions and view.permission is not None: 199 | self.assertRedirect( 200 | response, 201 | reverse("users.user_login"), 202 | msg="Page for '%s' loaded without being logged in first" % action, 203 | ) 204 | self.login(self.getUser()) 205 | response = self.client.get(url) 206 | 207 | fn = "assert%sGet" % action.capitalize() 208 | self.assertPageGet(action, response) 209 | if hasattr(self, fn): 210 | getattr(self, fn)(response) 211 | 212 | if post_data is not None: 213 | response = self.client.post(url, data=post_data) 214 | 215 | self.assertPagePost(action, response) 216 | fn = "assert%sPost" % action.capitalize() 217 | if hasattr(self, fn): 218 | getattr(self, fn)(response) 219 | 220 | return response 221 | 222 | def assertPageGet(self, action, response): 223 | if response.status_code == 302: 224 | self.fail("'%s' resulted in an unexpected redirect to: %s" % (action, response.get("Location"))) 225 | self.assertEqual( 226 | 200, 227 | response.status_code, 228 | ) 229 | 230 | def assertPagePost(self, action, response): 231 | self.assertNoFormErrors(response) 232 | -------------------------------------------------------------------------------- /smartmin/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/users/__init__.py -------------------------------------------------------------------------------- /smartmin/users/middleware.py: -------------------------------------------------------------------------------- 1 | import django.views.static 2 | from django.conf import settings 3 | from django.http import HttpResponseRedirect 4 | from django.urls import reverse 5 | 6 | from .models import PasswordHistory 7 | 8 | 9 | class ChangePasswordMiddleware: 10 | """ 11 | Redirects all users to the password change form if we find that a user's 12 | password is expired. 13 | """ 14 | 15 | def __init__(self, get_response=None): 16 | self.get_response = get_response 17 | 18 | self.password_expire = getattr(settings, "USER_PASSWORD_EXPIRATION", -1) 19 | 20 | def __call__(self, request): 21 | response = self.get_response(request) 22 | return response 23 | 24 | def process_view(self, request, view, *args, **kwargs): 25 | newpassword_path = reverse("users.user_newpassword", args=[0]) 26 | logout_path = reverse("users.user_logout") 27 | 28 | if ( 29 | self.password_expire < 0 30 | or not request.user.is_authenticated 31 | or view == django.views.static.serve 32 | or request.path == newpassword_path 33 | or request.path == logout_path 34 | ): # noqa 35 | return 36 | 37 | if PasswordHistory.is_password_expired(request.user): 38 | return HttpResponseRedirect(reverse("users.user_newpassword", args=["0"])) 39 | -------------------------------------------------------------------------------- /smartmin/users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [ 7 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 8 | ] 9 | 10 | operations = [ 11 | migrations.CreateModel( 12 | name="FailedLogin", 13 | fields=[ 14 | ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), 15 | ("failed_on", models.DateTimeField(auto_now_add=True)), 16 | ("user", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.PROTECT)), 17 | ], 18 | ), 19 | migrations.CreateModel( 20 | name="PasswordHistory", 21 | fields=[ 22 | ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), 23 | ("password", models.CharField(help_text="The hash of the password that was set", max_length=255)), 24 | ("set_on", models.DateTimeField(help_text="When the password was set", auto_now_add=True)), 25 | ( 26 | "user", 27 | models.ForeignKey( 28 | help_text="The user that set a password", on_delete=models.PROTECT, to=settings.AUTH_USER_MODEL 29 | ), 30 | ), 31 | ], 32 | ), 33 | migrations.CreateModel( 34 | name="RecoveryToken", 35 | fields=[ 36 | ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), 37 | ( 38 | "token", 39 | models.CharField(default=None, help_text="token to reset password", unique=True, max_length=32), 40 | ), 41 | ("created_on", models.DateTimeField(auto_now_add=True)), 42 | ("user", models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.PROTECT)), 43 | ], 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /smartmin/users/migrations/0002_remove_failed_logins.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.17 on 2021-02-19 14:03 2 | 3 | from django.db import migrations 4 | 5 | 6 | def delete_failed_logins(apps, schema_editor): # pragma: no cover 7 | FailedLogin = apps.get_model("users", "FailedLogin") 8 | 9 | FailedLogin.objects.all().delete() 10 | 11 | 12 | def noop(apps, schema_editor): # pragma: no cover 13 | pass 14 | 15 | 16 | class Migration(migrations.Migration): 17 | dependencies = [ 18 | ("users", "0001_initial"), 19 | ] 20 | 21 | operations = [migrations.RunPython(delete_failed_logins, noop)] 22 | -------------------------------------------------------------------------------- /smartmin/users/migrations/0003_auto_20210219_1548.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.17 on 2021-02-19 15:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("users", "0002_remove_failed_logins"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="failedlogin", 14 | name="user", 15 | ), 16 | migrations.AddField( 17 | model_name="failedlogin", 18 | name="username", 19 | field=models.CharField(default=None, max_length=256), 20 | preserve_default=False, 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /smartmin/users/migrations/0004_alter_failedlogin_failed_on_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.3 on 2024-06-13 18:36 2 | 3 | import django.db.models.deletion 4 | import django.utils.timezone 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ("users", "0003_auto_20210219_1548"), 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name="failedlogin", 19 | name="failed_on", 20 | field=models.DateTimeField(default=django.utils.timezone.now), 21 | ), 22 | migrations.AlterField( 23 | model_name="passwordhistory", 24 | name="password", 25 | field=models.CharField(max_length=255), 26 | ), 27 | migrations.AlterField( 28 | model_name="passwordhistory", 29 | name="set_on", 30 | field=models.DateTimeField(default=django.utils.timezone.now), 31 | ), 32 | migrations.AlterField( 33 | model_name="passwordhistory", 34 | name="user", 35 | field=models.ForeignKey( 36 | on_delete=django.db.models.deletion.PROTECT, 37 | related_name="password_history", 38 | to=settings.AUTH_USER_MODEL, 39 | ), 40 | ), 41 | migrations.AlterField( 42 | model_name="recoverytoken", 43 | name="created_on", 44 | field=models.DateTimeField(default=django.utils.timezone.now), 45 | ), 46 | migrations.AlterField( 47 | model_name="recoverytoken", 48 | name="token", 49 | field=models.CharField(max_length=32, unique=True), 50 | ), 51 | migrations.AlterField( 52 | model_name="recoverytoken", 53 | name="user", 54 | field=models.ForeignKey( 55 | on_delete=django.db.models.deletion.PROTECT, related_name="recovery_tokens", to=settings.AUTH_USER_MODEL 56 | ), 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /smartmin/users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/smartmin/users/migrations/__init__.py -------------------------------------------------------------------------------- /smartmin/users/models.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import timedelta 3 | 4 | from django.conf import settings 5 | from django.contrib.auth.hashers import check_password 6 | from django.db import models 7 | from django.utils import timezone 8 | 9 | 10 | def is_password_complex(password): 11 | has_caps = re.search("[A-Z]+", password) 12 | has_lower = re.search("[a-z]+", password) 13 | has_digit = re.search("[0-9]+", password) 14 | 15 | if len(password) < 8 or (len(password) < 12 and (not has_caps or not has_lower or not has_digit)): 16 | return False 17 | else: 18 | return True 19 | 20 | 21 | class RecoveryToken(models.Model): 22 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="recovery_tokens") 23 | token = models.CharField(max_length=32, unique=True) 24 | created_on = models.DateTimeField(default=timezone.now) 25 | 26 | 27 | class FailedLogin(models.Model): 28 | username = models.CharField(max_length=256) 29 | failed_on = models.DateTimeField(default=timezone.now) 30 | 31 | 32 | class PasswordHistory(models.Model): 33 | user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="password_history") 34 | password = models.CharField(max_length=255) # the hash 35 | set_on = models.DateTimeField(default=timezone.now) 36 | 37 | @classmethod 38 | def is_password_repeat(cls, user, password): 39 | password_window = getattr(settings, "USER_PASSWORD_REPEAT_WINDOW", -1) 40 | if password_window <= 0: 41 | return False 42 | 43 | # check their current password 44 | if check_password(password, user.password): 45 | return True 46 | 47 | # get all the passwords in the past year 48 | window_ago = timezone.now() - timedelta(days=password_window) 49 | previous_passwords = PasswordHistory.objects.filter(user=user, set_on__gte=window_ago) 50 | for previous in previous_passwords: 51 | if check_password(password, previous.password): 52 | return True 53 | 54 | return False 55 | 56 | @classmethod 57 | def is_password_expired(cls, user): 58 | password_expiration = getattr(settings, "USER_PASSWORD_EXPIRATION", -1) 59 | 60 | if password_expiration <= 0: 61 | return False 62 | 63 | # get the most recent password change 64 | last_password = PasswordHistory.objects.filter(user=user).order_by("-set_on") 65 | 66 | last_set = user.date_joined 67 | if last_password: 68 | last_set = last_password[0].set_on 69 | 70 | # calculate how long ago our password was set 71 | today = timezone.now() 72 | difference = today - last_set 73 | 74 | # return whether that is expired 75 | return difference.days > password_expiration 76 | -------------------------------------------------------------------------------- /smartmin/users/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.views import LogoutView 3 | from django.urls import re_path 4 | 5 | from .views import Login, UserCRUDL 6 | 7 | logout_url = getattr(settings, "LOGOUT_REDIRECT_URL", None) 8 | 9 | urlpatterns = [ 10 | re_path(r"^login/$", Login.as_view(), dict(template_name="smartmin/users/login.html"), name="users.user_login"), 11 | re_path( 12 | r"^logout/$", 13 | LogoutView.as_view(), 14 | dict(redirect_field_name="go", next_page=logout_url), 15 | name="users.user_logout", 16 | ), 17 | ] 18 | 19 | urlpatterns += UserCRUDL().as_urlpatterns() 20 | -------------------------------------------------------------------------------- /smartmin/widgets.py: -------------------------------------------------------------------------------- 1 | from django.forms import widgets 2 | from django.utils.html import escape 3 | from django.utils.safestring import mark_safe 4 | 5 | 6 | class VisibleHiddenWidget(widgets.Widget): 7 | def render(self, name, value, attrs=None, renderer=None): 8 | """ 9 | Returns this Widget rendered as HTML, as a Unicode string. 10 | 11 | The 'value' given is not guaranteed to be valid input, so subclass 12 | implementations should program defensively. 13 | """ 14 | html = "" 15 | html += "%s" % value 16 | html += '' % (escape(name), escape(value)) 17 | return mark_safe(html) 18 | 19 | 20 | class DatePickerWidget(widgets.DateInput): 21 | """ 22 | Date input which uses Javascript date picker widget 23 | """ 24 | 25 | input_format = ("MM d, yyyy", "%B %d, %Y") # Javascript and Python format strings 26 | 27 | def __init__(self, *args, **kwargs): 28 | kwargs["attrs"] = {"data-provide": "datepicker", "data-date-format": self.input_format[0]} 29 | kwargs["format"] = self.input_format[1] 30 | 31 | super(DatePickerWidget, self).__init__(*args, **kwargs) 32 | 33 | class Media: 34 | js = ("js/bootstrap-datepicker.js",) 35 | css = {"all": ("css/bootstrap-datepicker3.css",)} 36 | 37 | 38 | class ImageThumbnailWidget(widgets.ClearableFileInput): 39 | def __init__(self, thumb_width=75, thumb_height=75): 40 | self.width = thumb_width 41 | self.height = thumb_height 42 | super(ImageThumbnailWidget, self).__init__({}) 43 | 44 | def render(self, name, value, attrs=None, renderer=None): 45 | thumb_html = "" 46 | if value and hasattr(value, "url"): 47 | try: 48 | from sorl.thumbnail import get_thumbnail 49 | 50 | value = get_thumbnail(value, f"{self.width}x{self.height}", crop="center", quality=99) 51 | except ImportError: 52 | pass 53 | 54 | thumb_html += '' % (value.url, self.width, self.height) 55 | 56 | thumb_html += '' % name 58 | thumb_html += "
Clear' % name 57 | thumb_html += '
" 59 | 60 | return mark_safe(str('
%s
' % thumb_html)) 61 | -------------------------------------------------------------------------------- /test_runner/__init__.py: -------------------------------------------------------------------------------- 1 | from .celeryapp import app as celery_app # noqa 2 | -------------------------------------------------------------------------------- /test_runner/blog/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/test_runner/blog/__init__.py -------------------------------------------------------------------------------- /test_runner/blog/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [ 7 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 8 | ] 9 | 10 | operations = [ 11 | migrations.CreateModel( 12 | name="Category", 13 | fields=[ 14 | ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), 15 | ( 16 | "is_active", 17 | models.BooleanField( 18 | default=True, help_text="Whether this item is active, use this instead of deleting" 19 | ), 20 | ), 21 | ( 22 | "created_on", 23 | models.DateTimeField(help_text="When this item was originally created", auto_now_add=True), 24 | ), 25 | ("modified_on", models.DateTimeField(help_text="When this item was last modified", auto_now=True)), 26 | ("name", models.SlugField(help_text="The name of this category", unique=True, max_length=64)), 27 | ( 28 | "created_by", 29 | models.ForeignKey( 30 | related_name="blog_category_creations", 31 | to=settings.AUTH_USER_MODEL, 32 | on_delete=models.PROTECT, 33 | help_text="The user which originally created this item", 34 | ), 35 | ), 36 | ( 37 | "modified_by", 38 | models.ForeignKey( 39 | related_name="blog_category_modifications", 40 | to=settings.AUTH_USER_MODEL, 41 | on_delete=models.PROTECT, 42 | help_text="The user which last modified this item", 43 | ), 44 | ), 45 | ], 46 | options={ 47 | "abstract": False, 48 | }, 49 | ), 50 | migrations.CreateModel( 51 | name="Post", 52 | fields=[ 53 | ("id", models.AutoField(verbose_name="ID", serialize=False, auto_created=True, primary_key=True)), 54 | ( 55 | "is_active", 56 | models.BooleanField( 57 | default=True, help_text="Whether this item is active, use this instead of deleting" 58 | ), 59 | ), 60 | ( 61 | "created_on", 62 | models.DateTimeField(help_text="When this item was originally created", auto_now_add=True), 63 | ), 64 | ("modified_on", models.DateTimeField(help_text="When this item was last modified", auto_now=True)), 65 | ("title", models.CharField(help_text="The title of this blog post, keep it relevant", max_length=128)), 66 | ("body", models.TextField(help_text="The body of the post, go crazy")), 67 | ( 68 | "order", 69 | models.IntegerField(help_text="The order for this post, posts with smaller orders come first"), 70 | ), 71 | ("tags", models.CharField(help_text="Any tags for this post", max_length=128)), 72 | ( 73 | "created_by", 74 | models.ForeignKey( 75 | related_name="blog_post_creations", 76 | to=settings.AUTH_USER_MODEL, 77 | on_delete=models.PROTECT, 78 | help_text="The user which originally created this item", 79 | ), 80 | ), 81 | ( 82 | "modified_by", 83 | models.ForeignKey( 84 | related_name="blog_post_modifications", 85 | to=settings.AUTH_USER_MODEL, 86 | on_delete=models.PROTECT, 87 | help_text="The user which last modified this item", 88 | ), 89 | ), 90 | ], 91 | options={ 92 | "abstract": False, 93 | }, 94 | ), 95 | ] 96 | -------------------------------------------------------------------------------- /test_runner/blog/migrations/0002_post_uuid.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="post", 14 | name="uuid", 15 | field=models.CharField(max_length=36, default=uuid.uuid4, editable=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /test_runner/blog/migrations/0003_auto_20170223_0917.py: -------------------------------------------------------------------------------- 1 | import django.utils.timezone 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [ 7 | ("blog", "0002_post_uuid"), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name="category", 13 | name="created_on", 14 | field=models.DateTimeField( 15 | blank=True, 16 | default=django.utils.timezone.now, 17 | editable=False, 18 | help_text="When this item was originally created", 19 | ), 20 | ), 21 | migrations.AlterField( 22 | model_name="category", 23 | name="modified_on", 24 | field=models.DateTimeField( 25 | blank=True, 26 | default=django.utils.timezone.now, 27 | editable=False, 28 | help_text="When this item was last modified", 29 | ), 30 | ), 31 | migrations.AlterField( 32 | model_name="post", 33 | name="created_on", 34 | field=models.DateTimeField( 35 | blank=True, 36 | default=django.utils.timezone.now, 37 | editable=False, 38 | help_text="When this item was originally created", 39 | ), 40 | ), 41 | migrations.AlterField( 42 | model_name="post", 43 | name="modified_on", 44 | field=models.DateTimeField( 45 | blank=True, 46 | default=django.utils.timezone.now, 47 | editable=False, 48 | help_text="When this item was last modified", 49 | ), 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /test_runner/blog/migrations/0004_auto_20170609_0743.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("blog", "0003_auto_20170223_0917"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="post", 15 | name="written_on", 16 | field=models.DateField(default=django.utils.timezone.now, null=True, blank=True), 17 | ), 18 | migrations.AlterField( 19 | model_name="post", 20 | name="uuid", 21 | field=models.UUIDField(default=uuid.uuid4, editable=False), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /test_runner/blog/migrations/0005_auto_20180615_2036.py: -------------------------------------------------------------------------------- 1 | import django.db.models.deletion 2 | from django.conf import settings 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0004_auto_20170609_0743"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="category", 14 | name="created_by", 15 | field=models.ForeignKey( 16 | help_text="The user which originally created this item", 17 | on_delete=django.db.models.deletion.PROTECT, 18 | related_name="blog_category_creations", 19 | to=settings.AUTH_USER_MODEL, 20 | ), 21 | ), 22 | migrations.AlterField( 23 | model_name="category", 24 | name="modified_by", 25 | field=models.ForeignKey( 26 | help_text="The user which last modified this item", 27 | on_delete=django.db.models.deletion.PROTECT, 28 | related_name="blog_category_modifications", 29 | to=settings.AUTH_USER_MODEL, 30 | ), 31 | ), 32 | migrations.AlterField( 33 | model_name="post", 34 | name="created_by", 35 | field=models.ForeignKey( 36 | help_text="The user which originally created this item", 37 | on_delete=django.db.models.deletion.PROTECT, 38 | related_name="blog_post_creations", 39 | to=settings.AUTH_USER_MODEL, 40 | ), 41 | ), 42 | migrations.AlterField( 43 | model_name="post", 44 | name="modified_by", 45 | field=models.ForeignKey( 46 | help_text="The user which last modified this item", 47 | on_delete=django.db.models.deletion.PROTECT, 48 | related_name="blog_post_modifications", 49 | to=settings.AUTH_USER_MODEL, 50 | ), 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /test_runner/blog/migrations/0006_post_image.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.5 on 2019-01-10 14:09 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("blog", "0005_auto_20180615_2036"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="post", 14 | name="image", 15 | field=models.ImageField( 16 | blank=True, help_text="The logo that should be used for this post", null=True, upload_to="images" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /test_runner/blog/migrations/0007_alter_category_created_by_alter_category_modified_by_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.1 on 2022-01-12 15:47 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("blog", "0006_post_image"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="category", 17 | name="created_by", 18 | field=models.ForeignKey( 19 | help_text="The user which originally created this item", 20 | on_delete=django.db.models.deletion.PROTECT, 21 | related_name="%(app_label)s_%(class)s_creations", 22 | to=settings.AUTH_USER_MODEL, 23 | ), 24 | ), 25 | migrations.AlterField( 26 | model_name="category", 27 | name="modified_by", 28 | field=models.ForeignKey( 29 | help_text="The user which last modified this item", 30 | on_delete=django.db.models.deletion.PROTECT, 31 | related_name="%(app_label)s_%(class)s_modifications", 32 | to=settings.AUTH_USER_MODEL, 33 | ), 34 | ), 35 | migrations.AlterField( 36 | model_name="post", 37 | name="created_by", 38 | field=models.ForeignKey( 39 | help_text="The user which originally created this item", 40 | on_delete=django.db.models.deletion.PROTECT, 41 | related_name="%(app_label)s_%(class)s_creations", 42 | to=settings.AUTH_USER_MODEL, 43 | ), 44 | ), 45 | migrations.AlterField( 46 | model_name="post", 47 | name="modified_by", 48 | field=models.ForeignKey( 49 | help_text="The user which last modified this item", 50 | on_delete=django.db.models.deletion.PROTECT, 51 | related_name="%(app_label)s_%(class)s_modifications", 52 | to=settings.AUTH_USER_MODEL, 53 | ), 54 | ), 55 | ] 56 | -------------------------------------------------------------------------------- /test_runner/blog/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/test_runner/blog/migrations/__init__.py -------------------------------------------------------------------------------- /test_runner/blog/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | from django.utils.timezone import now 5 | 6 | from smartmin.models import ActiveManager, SmartModel 7 | 8 | 9 | class Post(SmartModel): 10 | title = models.CharField(max_length=128, help_text="The title of this blog post, keep it relevant") 11 | body = models.TextField(help_text="The body of the post, go crazy") 12 | order = models.IntegerField(help_text="The order for this post, posts with smaller orders come first") 13 | tags = models.CharField(max_length=128, help_text="Any tags for this post") 14 | 15 | uuid = models.UUIDField(default=uuid.uuid4, editable=False) 16 | 17 | written_on = models.DateField(default=now, null=True, blank=True) 18 | 19 | image = models.ImageField( 20 | upload_to="images", null=True, blank=True, help_text="The logo that should be used for this post" 21 | ) 22 | 23 | objects = models.Manager() 24 | active = ActiveManager() 25 | 26 | @classmethod 27 | def pre_create_instance(cls, field_dict): 28 | field_dict["body"] = "Body: %s" % field_dict["body"] 29 | return field_dict 30 | 31 | @classmethod 32 | def prepare_fields(cls, field_dict, import_params=None, user=None): 33 | field_dict["order"] = int(float(field_dict["order"])) 34 | return field_dict 35 | 36 | @classmethod 37 | def validate_import_header(cls, header): 38 | if "title" not in header: 39 | raise Exception('missing "title" header') 40 | 41 | @classmethod 42 | def finalize_import(cls, task, records): 43 | Post.objects.filter(id__in=[p.id for p in records]).update(tags="new") 44 | 45 | def __str__(self): 46 | return self.title 47 | 48 | 49 | class Category(SmartModel): 50 | name = models.SlugField(max_length=64, unique=True, help_text="The name of this category") 51 | -------------------------------------------------------------------------------- /test_runner/blog/templates/blog/user_list.html: -------------------------------------------------------------------------------- 1 | {% extends "smartmin/list.html" %} 2 | 3 | {% block pre-content %} 4 | Custom Pre-Content 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /test_runner/blog/test_files/bom_import.csv: -------------------------------------------------------------------------------- 1 | URN:tel,Name,Field:email-address 2 | 12065551212,Fred,fred@gmail.com -------------------------------------------------------------------------------- /test_runner/blog/test_files/posts.csv: -------------------------------------------------------------------------------- 1 | title,body,order,tags 2 | "My first post","The body of my first post",0,"tag1 tag2" 3 | "My 2nd post","The body of my post",0,"tag1 tag2" 4 | "My 3rd post","The body of my post",0,"tag1 tag2" 5 | "My 4th post","The body of my post",0,"tag1 tag2" -------------------------------------------------------------------------------- /test_runner/blog/test_files/posts.xls: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaruka/smartmin/68a786c961d15b2d0dd6609b5b862bfa05ee3b9a/test_runner/blog/test_files/posts.xls -------------------------------------------------------------------------------- /test_runner/blog/urls.py: -------------------------------------------------------------------------------- 1 | from .views import CategoryCRUDL, PostCRUDL, UserCRUDL 2 | 3 | urlpatterns = PostCRUDL().as_urlpatterns() 4 | urlpatterns += CategoryCRUDL().as_urlpatterns() 5 | urlpatterns += UserCRUDL().as_urlpatterns() 6 | -------------------------------------------------------------------------------- /test_runner/blog/views.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import messages 3 | from django.contrib.auth.models import User 4 | 5 | from smartmin.views import SmartCreateView, SmartCRUDL, SmartListView, SmartReadView, SmartUpdateView 6 | 7 | from .models import Category, Post 8 | 9 | 10 | class ExcludeForm(forms.ModelForm): 11 | class Meta: 12 | model = Post 13 | fields = ("title", "body", "order", "tags") 14 | 15 | 16 | # We overload a normal CategoryForm to not call the super's clean method. By default 17 | # model forms will check for integrity checks. We want to force a DB thrown IntegrityError 18 | # so we don't call the super, instead letting smartmin wrap the error 19 | class CategoryForm(forms.ModelForm): 20 | def clean(self): 21 | return self.cleaned_data 22 | 23 | class Meta: 24 | model = Category 25 | fields = ("name",) 26 | 27 | 28 | # just tests that our reverse and permissions are based on the view.py app, not 29 | # the model app, the template should also be /blog/user_list.html for the List view 30 | class UserCRUDL(SmartCRUDL): 31 | model = User 32 | permissions = False 33 | actions = ("list",) 34 | 35 | 36 | class CategoryCRUDL(SmartCRUDL): 37 | model = Category 38 | 39 | class Create(SmartCreateView): 40 | form_class = CategoryForm 41 | 42 | 43 | class PostCRUDL(SmartCRUDL): 44 | model = Post 45 | actions = ( 46 | "create", 47 | "read", 48 | "update", 49 | "delete", 50 | "list", 51 | "author", 52 | "exclude", 53 | "exclude2", 54 | "readonly", 55 | "readonly2", 56 | "messages", 57 | "by_uuid", 58 | "refresh", 59 | "no_refresh", 60 | "list_no_pagination", 61 | ) 62 | 63 | class Read(SmartReadView): 64 | permission = None 65 | 66 | class List(SmartListView): 67 | fields = ("title", "tags", "created_on", "created_by") 68 | search_fields = ("title__icontains", "body__icontains") 69 | default_order = "title" 70 | 71 | def as_json(self, context): 72 | return [{"title": obj.title, "body": obj.body, "tags": obj.tags} for obj in self.object_list] 73 | 74 | class ListNoPagination(SmartListView): 75 | fields = ("title", "tags", "created_on", "created_by") 76 | search_fields = ("title__icontains", "body__icontains") 77 | default_order = "title" 78 | 79 | paginate_by = None 80 | 81 | def as_json(self, context): 82 | return [{"title": obj.title, "body": obj.body, "tags": obj.tags} for obj in self.object_list] 83 | 84 | class Author(SmartListView): 85 | fields = ("title", "tags", "created_on", "created_by") 86 | default_order = ("created_by__username", "order") 87 | 88 | class Update(SmartUpdateView): 89 | success_message = "Your blog post has been updated." 90 | 91 | class Create(SmartCreateView): 92 | submit_button_name = "Create New Post" 93 | 94 | class Exclude(SmartUpdateView): 95 | exclude = ("tags",) 96 | 97 | class Exclude2(SmartUpdateView): 98 | form_class = ExcludeForm 99 | exclude = ("tags",) 100 | 101 | class Readonly(SmartUpdateView): 102 | readonly = ("tags",) 103 | 104 | class Readonly2(SmartUpdateView): 105 | form_class = ExcludeForm 106 | readonly = ("tags",) 107 | 108 | class Messages(SmartListView): 109 | def pre_process(self, request, *args, **kwargs): 110 | messages.error(request, "Error Messages") 111 | messages.success(request, "Success Messages") 112 | messages.info(request, "Info Messages") 113 | messages.warning(request, "Warning Messages") 114 | messages.debug(request, "Debug Messages") 115 | 116 | class ByUuid(SmartReadView): 117 | slug_url_kwarg = "uuid" 118 | 119 | class Refresh(SmartReadView): 120 | permission = None 121 | 122 | def derive_refresh(self): 123 | return 123 124 | 125 | class NoRefresh(SmartReadView): 126 | permission = None 127 | 128 | def derive_refresh(self): 129 | return 0 130 | -------------------------------------------------------------------------------- /test_runner/celeryapp.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_runner.settings") 6 | 7 | from django.conf import settings # noqa 8 | 9 | app = Celery("test_runner") 10 | app.config_from_object("django.conf:settings", namespace="CELERY") 11 | app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) 12 | -------------------------------------------------------------------------------- /test_runner/settings.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | DEBUG = True 4 | 5 | ADMINS = ( 6 | # ('Your Name', 'your_email@example.com'), 7 | ) 8 | 9 | MANAGERS = ADMINS 10 | 11 | DATABASES = { 12 | "default": { 13 | "ENGINE": "django.db.backends.postgresql", 14 | "NAME": "smartmin", 15 | "USER": "smartmin", 16 | "PASSWORD": "nyaruka", 17 | "HOST": "localhost", 18 | "PORT": "5432", 19 | "ATOMIC_REQUESTS": True, 20 | "CONN_MAX_AGE": 60, 21 | "OPTIONS": {}, 22 | } 23 | } 24 | 25 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 26 | 27 | # Local time zone for this installation. Choices can be found here: 28 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 29 | # although not all choices may be available on all operating systems. 30 | # On Unix systems, a value of None will cause Django to use the same 31 | # timezone as the operating system. 32 | # If running in a Windows environment this must be set to the same as your 33 | # system time zone. 34 | TIME_ZONE = "GMT" 35 | USER_TIME_ZONE = "Africa/Kigali" 36 | USE_TZ = True 37 | 38 | # Language code for this installation. All choices can be found here: 39 | # http://www.i18nguy.com/unicode/language-identifiers.html 40 | LANGUAGE_CODE = "en-us" 41 | 42 | SITE_ID = 1 43 | 44 | # If you set this to False, Django will make some optimizations so as not 45 | # to load the internationalization machinery. 46 | USE_I18N = True 47 | 48 | # If you set this to False, Django will not format dates, numbers and 49 | # calendars according to the current locale 50 | USE_L10N = True 51 | 52 | # Absolute filesystem path to the directory that will hold user-uploaded files. 53 | # Example: "/home/media/media.lawrence.com/media/" 54 | MEDIA_ROOT = "" 55 | 56 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 57 | # trailing slash. 58 | # Examples: "http://media.lawrence.com/media/", "http://example.com/media/" 59 | MEDIA_URL = "" 60 | 61 | # Absolute path to the directory static files should be collected to. 62 | # Don't put anything in this directory yourself; store your static files 63 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 64 | # Example: "/home/media/media.lawrence.com/static/" 65 | STATIC_ROOT = "" 66 | 67 | # URL prefix for static files. 68 | # Example: "http://media.lawrence.com/static/" 69 | STATIC_URL = "/static/" 70 | 71 | # URL prefix for admin static files -- CSS, JavaScript and images. 72 | # Make sure to use a trailing slash. 73 | # Examples: "http://foo.com/static/admin/", "/static/admin/". 74 | ADMIN_MEDIA_PREFIX = "/static/admin/" 75 | 76 | # Additional locations of static files 77 | STATICFILES_DIRS = ( 78 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 79 | # Always use forward slashes, even on Windows. 80 | # Don't forget to use absolute paths, not relative paths. 81 | ) 82 | 83 | # List of finder classes that know how to find static files in 84 | # various locations. 85 | STATICFILES_FINDERS = ( 86 | "django.contrib.staticfiles.finders.FileSystemFinder", 87 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 88 | ) 89 | 90 | # Make this unique, and don't share it with anybody. 91 | SECRET_KEY = "w4*mtn&nquc57h@$-05gva+2ucq0$tnczy#!d=t4%1&pl!p=jo" 92 | 93 | MIDDLEWARE = ( 94 | "django.middleware.common.CommonMiddleware", 95 | "django.contrib.sessions.middleware.SessionMiddleware", 96 | "django.middleware.csrf.CsrfViewMiddleware", 97 | "django.contrib.auth.middleware.AuthenticationMiddleware", 98 | "django.contrib.messages.middleware.MessageMiddleware", 99 | "smartmin.users.middleware.ChangePasswordMiddleware", 100 | "smartmin.middleware.TimezoneMiddleware", 101 | ) 102 | 103 | warnings.filterwarnings( 104 | "error", r"DateTimeField received a naive datetime", RuntimeWarning, r"django\.db\.models\.fields" 105 | ) 106 | 107 | 108 | ROOT_URLCONF = "test_runner.urls" 109 | 110 | TEMPLATES = [ 111 | { 112 | "BACKEND": "django.template.backends.django.DjangoTemplates", 113 | "DIRS": [ 114 | # insert your TEMPLATE_DIRS here 115 | ], 116 | "OPTIONS": { 117 | "context_processors": [ 118 | "django.contrib.auth.context_processors.auth", 119 | "django.template.context_processors.debug", 120 | "django.template.context_processors.i18n", 121 | "django.template.context_processors.media", 122 | "django.template.context_processors.static", 123 | "django.contrib.messages.context_processors.messages", 124 | "django.template.context_processors.request", 125 | ], 126 | "loaders": [ 127 | "django.template.loaders.filesystem.Loader", 128 | "django.template.loaders.app_directories.Loader", 129 | ], 130 | "debug": DEBUG, 131 | }, 132 | }, 133 | ] 134 | 135 | 136 | INSTALLED_APPS = ( 137 | "django.contrib.auth", 138 | "django.contrib.contenttypes", 139 | "django.contrib.sessions", 140 | "django.contrib.sites", 141 | "django.contrib.messages", 142 | "django.contrib.staticfiles", 143 | "smartmin", 144 | "smartmin.users", 145 | "test_runner.blog", 146 | "django.contrib.admin", 147 | ) 148 | 149 | # A sample logging configuration. The only tangible logging 150 | # performed by this configuration is to send an email to 151 | # the site admins on every HTTP 500 error. 152 | # See http://docs.djangoproject.com/en/dev/topics/logging for 153 | # more details on how to customize your logging configuration. 154 | LOGGING = { 155 | "version": 1, 156 | "disable_existing_loggers": False, 157 | "handlers": {"mail_admins": {"level": "ERROR", "class": "django.utils.log.AdminEmailHandler"}}, 158 | "loggers": { 159 | "django.request": { 160 | "handlers": ["mail_admins"], 161 | "level": "ERROR", 162 | "propagate": True, 163 | }, 164 | }, 165 | } 166 | 167 | AUTHENTICATION_BACKENDS = ("smartmin.backends.CaseInsensitiveBackend",) 168 | 169 | # create the smartmin CRUDL permissions on all objects 170 | PERMISSIONS = { 171 | "*": ( 172 | "create", # can create an object 173 | "read", # can read an object, viewing it's details 174 | "update", # can update an object 175 | "delete", # can delete an object, 176 | "list", # can view a list of the objects 177 | ), 178 | "blog.post": ( 179 | "author", 180 | "exclude", 181 | "exclude2", 182 | "readonly", 183 | "readonly2", 184 | "messages", 185 | "list_no_pagination", 186 | ), 187 | "auth.user": ("profile",), 188 | } 189 | 190 | # assigns the permissions that each group should have, here creating an Administrator group with 191 | # authority to create and change users 192 | GROUP_PERMISSIONS = { 193 | "Administrator": ("auth.user.*",), 194 | "Editors": ("blog.post_update", "blog.post_list", "auth.user_profile"), 195 | "Authors": ("blog.post.*", "blog.category.*", "auth.user_profile"), 196 | } 197 | 198 | LOGIN_URL = "/users/login/" 199 | LOGIN_REDIRECT_URL = "/blog/post/" 200 | 201 | # ----------------------------------------------------------------------------------- 202 | # Async tasks with celery 203 | # ----------------------------------------------------------------------------------- 204 | CELERY_RESULT_BACKED = "database" 205 | CELERY_BROKER_URL = "redis://localhost:6379/4" 206 | -------------------------------------------------------------------------------- /test_runner/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include 2 | from django.contrib import admin 3 | from django.urls import re_path 4 | 5 | urlpatterns = [ 6 | re_path(r"^users/", include("smartmin.users.urls")), 7 | re_path(r"^blog/", include("test_runner.blog.urls")), 8 | re_path(r"^admin/", admin.site.urls), 9 | ] 10 | --------------------------------------------------------------------------------