├── .github └── workflows │ ├── docs.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── docs ├── Makefile ├── make.bat └── source │ ├── conf.py │ ├── crud-view.rst │ ├── how-tos.rst │ ├── index.rst │ ├── templates.rst │ └── tutorial.rst ├── examples └── bootstrap │ ├── object_confirm_delete.html │ ├── object_detail.html │ ├── object_form.html │ ├── object_list.html │ └── partial │ ├── detail.html │ └── list.html ├── justfile ├── pyproject.toml ├── src └── neapolitan │ ├── __init__.py │ ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── mktemplate.py │ ├── templates │ └── neapolitan │ │ ├── object_confirm_delete.html │ │ ├── object_detail.html │ │ ├── object_form.html │ │ ├── object_list.html │ │ └── partial │ │ ├── detail.html │ │ └── list.html │ ├── templatetags │ ├── __init__.py │ └── neapolitan.py │ └── views.py └── tests ├── __init__.py ├── migrations ├── 0001_initial.py ├── 0002_namedcollection.py └── __init__.py ├── models.py ├── settings.py ├── templates ├── base.html └── tests │ └── .gitignore └── tests.py /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - 'docs/**' 7 | - '.github/workflows/docs.yml' 8 | push: 9 | branches: 10 | - main 11 | paths: 12 | - 'docs/**' 13 | - '.github/workflows/docs.yml' 14 | 15 | concurrency: 16 | group: ${{ github.workflow }}-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | permissions: 20 | contents: read 21 | 22 | jobs: 23 | docs: 24 | runs-on: ubuntu-22.04 25 | name: docs 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v3 29 | 30 | - name: Set up Python 31 | uses: actions/setup-python@v4 32 | with: 33 | python-version: '3.11' 34 | cache: 'pip' 35 | 36 | - run: python -m pip install -e .[docs] 37 | 38 | - name: Build docs 39 | run: | 40 | cd docs 41 | make json 42 | # sphinx-build -b spelling -n -q -W --keep-going -d _build/doctrees -D language=en_US -j auto . _build/spelling 43 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | concurrency: 9 | group: test-${{ github.head_ref }} 10 | cancel-in-progress: true 11 | 12 | env: 13 | PYTHONUNBUFFERED: "1" 14 | FORCE_COLOR: "1" 15 | 16 | jobs: 17 | test: 18 | name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} 19 | runs-on: "ubuntu-latest" 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | python-version: ["3.10", "3.11", "3.12", "3.13"] 24 | django-version: ["4.2", "5.2", "main"] 25 | exclude: 26 | - python-version: "3.13" 27 | django-version: "4.2" 28 | - python-version: "3.10" 29 | django-version: "main" 30 | - python-version: "3.11" 31 | django-version: "main" 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - uses: extractions/setup-just@v1 36 | 37 | - name: Set up Python ${{ matrix.python-version }} 38 | uses: actions/setup-python@v5 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | cache: "pip" 42 | allow-prereleases: true 43 | 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | if [[ "${{ matrix.django-version }}" == "main" ]]; then 48 | python -m pip install https://github.com/django/django/archive/refs/heads/main.zip 49 | else 50 | python -m pip install django==${{ matrix.django-version }} 51 | fi 52 | python -m pip install '.[tests]' 53 | 54 | - name: Run tests 55 | run: | 56 | just test 57 | 58 | tests: 59 | runs-on: ubuntu-latest 60 | needs: test 61 | if: always() 62 | steps: 63 | - name: OK 64 | if: ${{ !(contains(needs.*.result, 'failure')) }} 65 | run: exit 0 66 | - name: Fail 67 | if: ${{ contains(needs.*.result, 'failure') }} 68 | run: exit 1 69 | 70 | coverage: 71 | runs-on: ubuntu-latest 72 | env: 73 | PYTHON_MIN_VERSION: "3.10" 74 | DJANGO_MIN_VERSION: "4.2" 75 | steps: 76 | - uses: actions/checkout@v4 77 | 78 | - uses: extractions/setup-just@v1 79 | 80 | - name: Set up Python ${{ env.PYTHON_MIN_VERSION }} 81 | uses: actions/setup-python@v5 82 | with: 83 | python-version: ${{ env.PYTHON_MIN_VERSION }} 84 | cache: "pip" 85 | allow-prereleases: true 86 | 87 | - name: Install dependencies 88 | run: | 89 | python -m pip install --upgrade pip 90 | python -m pip install django==${{ env.DJANGO_MIN_VERSION }} 91 | python -m pip install '.[tests]' 92 | 93 | - name: Run coverage 94 | run: | 95 | just coverage 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | docs/build 3 | neapolitan.code-workspace 4 | .coverage 5 | dist 6 | .idea 7 | 8 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | CHANGELOG 3 | ========= 4 | 5 | Neapolitan is still in **alpha** stage. It won't change much but I'm still 6 | working out the fine details of the API. 7 | 8 | The provided templates (in particular) will evolve as needed without notice 9 | until we get closer to a resting place. If a change affects you, you can copy to 10 | templates as were to your own project, and go from there. 11 | 12 | Version numbers correspond to git tags. Please use the compare view on GitHub 13 | for full details. Until we're further along, I will just note the highlights 14 | here: 15 | 16 | 24.8 17 | ==== 18 | 19 | * Switched to using equality for role comparison checks. 20 | 21 | This is an internal change, preparatory for future work. 22 | 23 | 24.7 24 | ==== 25 | 26 | * Fixed URL ordering for slug path converters. 27 | 28 | Thanks to Sam Jennings. 29 | 30 | 24.6 31 | ==== 32 | 33 | * Allowed overriding Role provided initkwargs in ``as_view()``. 34 | 35 | Kwargs provided to ``as_view()`` will now take precedence over any Role 36 | provided defaults. 37 | 38 | 24.5 39 | ==== 40 | 41 | * Fixed an error in 24.4 reversing non-routed URLs when only using a subset of roles. 42 | 43 | Thanks to Emmanuelle Delescolle. 44 | 45 | 24.4 46 | ==== 47 | 48 | * ``CRUDView`` subclasses may now pass a set of ``roles`` to ``get_urls()`` in 49 | order to route only a subset of all the available CRUD roles. 50 | 51 | As an example, to route only the list and detail views, the README/Quickstart example 52 | would become:: 53 | 54 | from neapolitan.views import CRUDView, Role 55 | from .models import Bookmark 56 | 57 | class BookmarkView(CRUDView): 58 | model = Bookmark 59 | fields = ["url", "title", "note"] 60 | filterset_fields = [ 61 | "favourite", 62 | ] 63 | 64 | urlpatterns = [ 65 | *BookmarkView.get_urls(roles={Role.LIST, Role.DETAIL}), 66 | ] 67 | 68 | In order to keep this logic within the view here, you would likely override 69 | ``get_urls()`` in this case, rather than calling it from the outside in your 70 | URL configuration. 71 | 72 | * As well as setting the existing ``lookup_field`` (which defaults to ``"pk"``) 73 | and ``lookup_url_kwarg`` (which defaults to ``lookup_field`` if not set) you 74 | may now set ``path_converter`` and ``url_base`` attributes on your 75 | ``CRUDView`` subclass in order to customise URL generation. 76 | 77 | For example, for this model and ``CRUDView``:: 78 | 79 | class NamedCollection(models.Model): 80 | name = models.CharField(max_length=25, unique=True) 81 | code = models.UUIDField(unique=True, default=uuid.uuid4) 82 | 83 | class NamedCollectionView(CRUDView): 84 | model = NamedCollection 85 | fields = ["name", "code"] 86 | 87 | lookup_field = "code" 88 | path_converter = "uuid" 89 | url_base = "named-collections" 90 | 91 | ``CRUDView`` will generate URLs such as ``/named-collections/``, 92 | ``/named-collections//``, and so on. URL patterns will be named 93 | using ``url_base``: "named-collections-list", "named-collections-detail", and 94 | so on. 95 | 96 | Thanks to Kasun Herath for preliminary discussion and exploration here. 97 | 98 | * BREAKING CHANGE. In order to facilitate the above the ``object_list`` 99 | template tag now takes the whole ``view`` from the context, rather than just 100 | the ``view.fields``. 101 | 102 | If you've overridden the ``object_list.html`` template and are still using 103 | the ``object_list`` template tag, you will need to update your usage to be 104 | like this: 105 | 106 | .. code-block:: html+django 107 | 108 | {% object_list object_list view %} 109 | 110 | * Improved app folder discovery in mktemplate command. 111 | 112 | Thanks to Andrew Miller. 113 | 114 | 24.3 115 | ==== 116 | 117 | * Added the used ``filterset`` to list-view context. 118 | 119 | * Added CI testing for supported Python and Django versions. (Python 3.10 120 | onwards; Django 4.2 onwards, including the development branch.) 121 | 122 | Thanks to Josh Thomas. 123 | 124 | * Added CI build for the documentation. 125 | 126 | Thanks to Eduardo Enriquez 127 | 128 | 24.2 129 | ==== 130 | 131 | * Added the ``mktemplate`` management command to create an override template from the 132 | the active default templates for the specified model and CRUD action. 133 | 134 | See ``./manage.py mktemplate --help`` for full details. 135 | 136 | 24.1 137 | ==== 138 | 139 | * Fixed an incorrect type annotation, that led to incorrect IDE warnings. 140 | 141 | 23.11 142 | ===== 143 | 144 | * Adjusted object form template for multipart forms. 145 | 146 | 23.10 147 | ===== 148 | 149 | * Added a ``{{ delete_view_url}}`` context variable for the form action to the 150 | ``object_confirm_delete.html`` template. 151 | * Added basic styling and docs for the ``object_confirm_delete.html`` template. 152 | 153 | 23.9 154 | ==== 155 | 156 | Adds the beginnings of some TailwindCSS styling to the provided templates. See 157 | the `guide here for integrating TailwindCSS with Django 158 | `_. 159 | 160 | * These are merely CSS classes, so you can ignore them, or override the 161 | templates if you're not using Tailwind. 162 | 163 | * The templates docs now have an introductory sections about the templates to 164 | give a bit of guidance there. 165 | 166 | The ``
`` element in the ``object_form.html`` template has a ``.dl-form`` 167 | class applied, to go with the styles used in the ``object_detail.html``. 168 | 169 | * This assumes you're using Django's new div-style form rendering. 170 | 171 | * This needs a Tailwind plugin to be applied, which is still under-development. 172 | Please see see `issue #8 173 | `_ for an example 174 | snippet that you can add to your Tailwind configuration now. 175 | 176 | 23.8 177 | ==== 178 | 179 | * Adjusted object-view action links to include the detail view link. 180 | 181 | 23.7 182 | ==== 183 | 184 | To 23.7: initial exploratory work. 185 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Carlton Gibson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Neapolitan 3 | ========== 4 | 5 | .. image:: https://img.shields.io/pypi/v/neapolitan.svg 6 | :target: https://pypi.org/project/neapolitan/ 7 | :alt: PyPI version 8 | 9 | I have a Django model: 10 | 11 | .. code:: python 12 | 13 | from django.db import models 14 | 15 | class Bookmark(models.Model): 16 | url = models.URLField(unique=True) 17 | title = models.CharField(max_length=255) 18 | note = models.TextField(blank=True) 19 | favourite = models.BooleanField(default=False) 20 | 21 | I want easy CRUD views for it, without it taking all day: 22 | 23 | .. code:: python 24 | 25 | # urls.py 26 | from neapolitan.views import CRUDView 27 | from .models import Bookmark 28 | 29 | class BookmarkView(CRUDView): 30 | model = Bookmark 31 | fields = ["url", "title", "note"] 32 | filterset_fields = [ 33 | "favourite", 34 | ] 35 | 36 | urlpatterns = [ 37 | *BookmarkView.get_urls(), 38 | ] 39 | 40 | Neapolitan's ``CRUDView`` provides the standard list, detail, 41 | create, edit, and delete views for a model, as well as the hooks you need to 42 | be able to customise any part of that. 43 | 44 | Neapolitan provides base templates and re-usable template tags to make getting 45 | your model on the page as easy as possible. 46 | 47 | Where you take your app after that is up to you. But Neapolitan will get you 48 | started. 49 | 50 | Let's go! 🚀 51 | 52 | Next stop `the docs `_ 🚂 53 | 54 | Versioning and Status 55 | --------------------- 56 | 57 | Neapolitan uses a two-part CalVer versioning scheme, such as ``23.7``. The first 58 | number is the year. The second is the release number within that year. 59 | 60 | On an on-going basis, Neapolitan aims to support all current Django 61 | versions and the matching current Python versions. 62 | 63 | Please see: 64 | 65 | * `Status of supported Python versions `_ 66 | * `List of supported Django versions `_ 67 | 68 | Support for Python and Django versions will be dropped when they reach 69 | end-of-life. Support for Python versions will be dropped when they reach 70 | end-of-life, even when still supported by a current version of Django. 71 | 72 | This is alpha software. I'm still working out the details of the API, and I've 73 | only begun the docs. 74 | 75 | **But**: You could just read ``neapolitan.views.CRUDView`` and see what it does. 76 | Up to you. 😜 77 | 78 | Installation 79 | ------------ 80 | 81 | Install with pip: 82 | 83 | .. code:: bash 84 | 85 | pip install neapolitan 86 | 87 | Add ``neapolitan`` to your ``INSTALLED_APPS``: 88 | 89 | .. code:: python 90 | 91 | INSTALLED_APPS = [ 92 | ... 93 | "neapolitan", 94 | ] 95 | 96 | Templates expect a ``base.html`` template to exist and for that template to define a 97 | ``content`` block. (Refs .) 98 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = 'Neapolitan' 10 | copyright = '2023, Carlton Gibson' 11 | author = 'Carlton Gibson' 12 | 13 | # -- General configuration --------------------------------------------------- 14 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 15 | 16 | extensions = [ 17 | "sphinx.ext.autodoc", 18 | ] 19 | 20 | templates_path = ['_templates'] 21 | exclude_patterns = [] 22 | 23 | 24 | 25 | # -- Options for HTML output ------------------------------------------------- 26 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 27 | 28 | html_theme = 'alabaster' 29 | html_static_path = ['_static'] 30 | -------------------------------------------------------------------------------- /docs/source/crud-view.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | CRUDView Reference 3 | ================== 4 | 5 | .. py:currentmodule:: neapolitan.views 6 | 7 | .. autoclass:: CRUDView 8 | 9 | Request Handlers 10 | ================ 11 | 12 | The core of a class-based view are the request handlers — methods that convert 13 | an HTTP request into an HTTP response. The request handlers are the essence of 14 | the **Django view**. 15 | 16 | Neapolitan's ``CRUDView`` provides handlers the standard list, detail, create, 17 | edit, and delete views for a model. 18 | 19 | List and Detail Views 20 | ---------------------- 21 | 22 | .. automethod:: CRUDView.list 23 | 24 | .. literalinclude:: ../../src/neapolitan/views.py 25 | :pyobject: CRUDView.list 26 | 27 | .. automethod:: CRUDView.detail 28 | 29 | .. literalinclude:: ../../src/neapolitan/views.py 30 | :pyobject: CRUDView.detail 31 | 32 | Create and Update Views 33 | ----------------------- 34 | 35 | .. automethod:: CRUDView.show_form 36 | 37 | .. literalinclude:: ../../src/neapolitan/views.py 38 | :pyobject: CRUDView.show_form 39 | 40 | .. automethod:: CRUDView.process_form 41 | 42 | .. literalinclude:: ../../src/neapolitan/views.py 43 | :pyobject: CRUDView.process_form 44 | 45 | Delete View 46 | ----------- 47 | 48 | .. automethod:: CRUDView.confirm_delete 49 | 50 | .. literalinclude:: ../../src/neapolitan/views.py 51 | :pyobject: CRUDView.confirm_delete 52 | 53 | .. automethod:: CRUDView.process_deletion 54 | 55 | .. literalinclude:: ../../src/neapolitan/views.py 56 | :pyobject: CRUDView.process_deletion 57 | 58 | 59 | QuerySet and object lookup 60 | ========================== 61 | 62 | .. automethod:: CRUDView.get_queryset 63 | 64 | .. literalinclude:: ../../src/neapolitan/views.py 65 | :pyobject: CRUDView.get_queryset 66 | 67 | .. automethod:: CRUDView.get_object 68 | 69 | .. literalinclude:: ../../src/neapolitan/views.py 70 | :pyobject: CRUDView.get_object 71 | 72 | 73 | Form handling 74 | ============= 75 | 76 | .. automethod:: CRUDView.get_form_class 77 | 78 | .. literalinclude:: ../../src/neapolitan/views.py 79 | :pyobject: CRUDView.get_form_class 80 | 81 | .. automethod:: CRUDView.get_form 82 | 83 | .. literalinclude:: ../../src/neapolitan/views.py 84 | :pyobject: CRUDView.get_form 85 | 86 | .. automethod:: CRUDView.form_valid 87 | 88 | .. literalinclude:: ../../src/neapolitan/views.py 89 | :pyobject: CRUDView.form_valid 90 | 91 | .. automethod:: CRUDView.form_invalid 92 | 93 | .. literalinclude:: ../../src/neapolitan/views.py 94 | :pyobject: CRUDView.form_invalid 95 | 96 | .. automethod:: CRUDView.get_success_url 97 | 98 | .. literalinclude:: ../../src/neapolitan/views.py 99 | :pyobject: CRUDView.get_success_url 100 | 101 | Pagination and filtering 102 | ======================== 103 | 104 | .. automethod:: CRUDView.get_paginate_by 105 | 106 | .. literalinclude:: ../../src/neapolitan/views.py 107 | :pyobject: CRUDView.get_paginate_by 108 | 109 | .. automethod:: CRUDView.get_paginator 110 | 111 | .. literalinclude:: ../../src/neapolitan/views.py 112 | :pyobject: CRUDView.get_paginator 113 | 114 | .. automethod:: CRUDView.paginate_queryset 115 | 116 | .. literalinclude:: ../../src/neapolitan/views.py 117 | :pyobject: CRUDView.paginate_queryset 118 | 119 | .. automethod:: CRUDView.get_filterset 120 | 121 | .. literalinclude:: ../../src/neapolitan/views.py 122 | :pyobject: CRUDView.get_filterset 123 | 124 | Response rendering 125 | ================== 126 | 127 | .. automethod:: CRUDView.get_context_object_name 128 | 129 | .. literalinclude:: ../../src/neapolitan/views.py 130 | :pyobject: CRUDView.get_context_object_name 131 | 132 | .. automethod:: CRUDView.get_context_data 133 | 134 | .. literalinclude:: ../../src/neapolitan/views.py 135 | :pyobject: CRUDView.get_context_data 136 | 137 | .. automethod:: CRUDView.get_template_names 138 | 139 | .. literalinclude:: ../../src/neapolitan/views.py 140 | :pyobject: CRUDView.get_template_names 141 | 142 | .. automethod:: CRUDView.render_to_response 143 | 144 | .. literalinclude:: ../../src/neapolitan/views.py 145 | :pyobject: CRUDView.render_to_response 146 | 147 | URLs and view callables 148 | ======================= 149 | 150 | .. automethod:: CRUDView.get_urls 151 | 152 | This is the usual entry-point for routing all CRUD URLs in a single pass:: 153 | 154 | urlpatterns = [ 155 | *BookmarkView.get_urls(), 156 | ] 157 | 158 | Optionally, you may provide an appropriate set of roles in order to limit 159 | the handlers exposed:: 160 | 161 | urlpatterns = [ 162 | *BookmarkView.get_urls(roles={Role.LIST, Role.DETAIL}), 163 | ] 164 | 165 | Subclasses may wish to override ``get_urls()`` in order to encapsulate such 166 | logic. 167 | 168 | .. literalinclude:: ../../src/neapolitan/views.py 169 | :pyobject: CRUDView.get_urls 170 | 171 | 172 | .. automethod:: CRUDView.as_view 173 | 174 | This is the lower-level method used to manually route individual URLs. 175 | 176 | It's extends the Django `View.as_view()` method, and should be passed a an 177 | appropriate ``Role`` giving the handlers to be exposed:: 178 | 179 | path( 180 | "bookmarks/", 181 | BookmarkCRUDView.as_view(role=Role.LIST), 182 | name="bookmark-list", 183 | ) 184 | -------------------------------------------------------------------------------- /docs/source/how-tos.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | How Tos 3 | ======= 4 | 5 | 6 | Project-level Overrides 7 | ======================= 8 | 9 | Maybe ``CRUDView`` doesn't work exactly how you want. Maybe you need to 10 | override something whilst experimenting. 11 | 12 | You can add a base-class of your own to add project-level overrides. 13 | 14 | In your ``views.py``:: 15 | 16 | from neapolitan.views import CRUDView as BaseCRUDView 17 | 18 | 19 | class CRUDView(BaseCRUDView): 20 | # Add your overrides here. 21 | 22 | 23 | class MyModelCRUDView(CRUDView): 24 | model = "MyModel" 25 | fields = ["name", "description"] 26 | 27 | By defining your base-class like this, you can revert to Neapolitan's class 28 | simply by commenting it out, or deleting it, and adjusting the import. 29 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Neapolitan 2 | ========== 3 | 4 | Neapolitan is a re-usable library for Django projects, that provides quick CRUD views 5 | for other applications. 6 | 7 | It helps you get your models into your web output as quickly as possible, and includes base 8 | templates and re-usable template tags. 9 | 10 | All kinds of applications need frontend CRUD functionality - but it's not available out of 11 | the box with Django, and Python programmers often suffer while wrestling with the unfamiliar 12 | technologies and tools required to implement it. Neapolitan addresses that particular 13 | headache. 14 | 15 | If you've ever looked longingly at Django's admin functionality and wished you could have 16 | the same kind of CRUD access that it provides for your frontend, then Neapolitan is for you. 17 | 18 | 19 | Contents 20 | -------- 21 | 22 | .. toctree:: 23 | :maxdepth: 1 24 | 25 | tutorial 26 | crud-view 27 | templates 28 | how-tos 29 | 30 | 31 | A quick look 32 | ------------ 33 | 34 | I have a Django model:: 35 | 36 | from django.db import models 37 | 38 | class Bookmark(models.Model): 39 | url = models.URLField(unique=True) 40 | title = models.CharField(max_length=255) 41 | note = models.TextField(blank=True) 42 | 43 | I want easy CRUD views for it, without it taking all day:: 44 | 45 | # urls.py 46 | from neapolitan.views import CRUDView 47 | from .models import Bookmark 48 | 49 | class BookmarkView(CRUDView): 50 | model = Bookmark 51 | fields = ["url", "title", "note"] 52 | 53 | 54 | urlpatterns = [ ... ] + BookmarkView.get_urls() 55 | 56 | Read the :ref:`tutorial` for a step-by-step guide to getting it up and running. Let's go! 🚀 57 | 58 | 59 | Contribute! 60 | ----------- 61 | 62 | Neapolitan is very much under construction 🚧. 63 | 64 | The docs are still fledging. **But** you can read 65 | ``neapolitan.views.CRUDView`` to see what it does. (It's just the one 66 | class!) 67 | 68 | Whilst I'm working on it, if you wanted to make a PR adding a docstring and 69 | an ``.. automethod::``… you'd be welcome to do so! 🎁 70 | 71 | 72 | What about the name? 73 | -------------------- 74 | 75 | It's homage to Tom Christie's 76 | `django-vanilla-views `_ which long-ago showed 77 | the way to sanity in regards to class-based views. 🥰 I needed just a little bit 78 | more — filtering, generic templates, auto-routing of multiple views, and that's 79 | about it really — but what's that little bit more than Vanilla? 80 | `Neapolitan `_! 🍨 81 | 82 | A quick introductory talk 83 | ------------------------- 84 | 85 | My DjangoCon Europe talk from 2023 was about Neapolitan, and how it came to be. It gives a quick introduction, and some of the thinking behind it. 86 | 87 | You might want to watch that. 🍿 88 | 89 | .. raw:: html 90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /docs/source/templates.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Template reference 3 | ================== 4 | 5 | Neapolitan provides generic templates that can be used as a starting point for 6 | your project. 7 | 8 | The default templates use TailwindCSS classes, for styling. See the `guide here 9 | for integrating TailwindCSS with Django 10 | `_. 11 | 12 | The templates ``{% extends "base.html" %}``, which must provide 13 | ``{% block content %}``. Neapolitan may provide a base template in the future, 14 | see `issue #6 `_. 15 | 16 | This is the full listing of the provided templates: 17 | 18 | .. code-block:: shell 19 | 20 | templates 21 | └── neapolitan 22 | ├── object_confirm_delete.html 23 | ├── object_detail.html 24 | ├── object_form.html 25 | ├── object_list.html 26 | └── partial 27 | ├── detail.html 28 | └── list.html 29 | 30 | Templates 31 | ========= 32 | 33 | You can override these templates by creating your own, either individually or as 34 | a whole. 35 | 36 | If you want to override a single template for your model, you can run the ``mktemplate`` 37 | management command: 38 | 39 | .. code-block:: shell 40 | 41 | python manage.py mktemplate myapp.MyModel --list 42 | 43 | You pass your model in the ``app_name.ModelName`` format, and then an option for the 44 | CRUD template you want to override. The specified template will be copied to your app's 45 | ``templates``, using your active neapolitan default templates, and having the correct 46 | name applied. 47 | 48 | For example, the above command will copy the active ``neapoltian/object_list.html`` template to your app's 49 | ``templates/myapp/mymodel_list.html``, where it will be picked up by a ``CRUDView`` for 50 | ``MyModel`` when serving the list view. 51 | 52 | See ``python manage.py mktemplate --help`` for full details. 53 | 54 | 55 | .. admonition:: Under construction 🚧 56 | 57 | The templates are still being developed. If a change in a release affects 58 | you, you can copy the templates from the previous version to continue, but 59 | please also an open an issue to discuss. 60 | 61 | 62 | ``object_form.html`` 63 | -------------------- 64 | 65 | Used for both the create and update views. 66 | 67 | ``neapolitan/object_list.html`` 68 | 69 | Context variables: 70 | 71 | * ``object``: the object being updated, if present. 72 | * ``object_verbose_name``: the verbose name of the object, e.g. ``bookmark``. 73 | * ``form``: the form. 74 | * ``create_view_url``: the URL for the create view. 75 | * ``update_view_url``: the URL for the update view. 76 | 77 | ``object_confirm_delete.html`` 78 | ------------------------------ 79 | 80 | Used for the delete view. 81 | 82 | ``neapolitan/object_confirm_delete.html`` 83 | 84 | Context variables: 85 | 86 | * ``object``: the object being deleted. 87 | * ``object_verbose_name``: the verbose name of the object, e.g. ``bookmark``. 88 | * ``form``: the form. 89 | * ``delete_view_url``: the URL for the delete view. 90 | 91 | 92 | Template tags 93 | ============= 94 | 95 | .. currentmodule:: neapolitan.templatetags.neapolitan 96 | 97 | Neapolitan provides template tags for generic object and object list rendering. 98 | 99 | Assuming you have ``neaopolitan`` in your ``INSTALLED_APPS`` setting, you can 100 | load the tags as usual with: 101 | 102 | .. code-block:: html+django 103 | 104 | {% load neapolitan %} 105 | 106 | 107 | ``object_detail`` 108 | ----------------- 109 | 110 | .. autofunction:: object_detail 111 | 112 | 113 | ``object_list`` 114 | --------------- 115 | 116 | .. autofunction:: object_list 117 | -------------------------------------------------------------------------------- /docs/source/tutorial.rst: -------------------------------------------------------------------------------- 1 | .. _tutorial: 2 | 3 | Tutorial 4 | ========= 5 | 6 | This tutorial will walk you through an example that introduces key operations and 7 | concepts in Neapolitan. It assumes basic familiarity with Django. 8 | 9 | The tutorial will build a dashboard for a set of software projects. 10 | 11 | Prepare a new Django project 12 | ---------------------------- 13 | 14 | Install Django and Neapolitan into your environment (a virtual environment, preferably):: 15 | 16 | pip install django neapolitan 17 | 18 | Start a new Django project:: 19 | 20 | django-admin startproject dashboard 21 | 22 | In the new project directory, create a ``projects`` application:: 23 | 24 | python manage.py startapp projects 25 | 26 | In ``projects/models.py``, define a ``Project``:: 27 | 28 | class Project(models.Model): 29 | name = models.CharField(max_length=200) 30 | owner = models.CharField(max_length=200) 31 | has_tests = models.BooleanField() 32 | has_docs = models.BooleanField() 33 | 34 | NA = "na" 35 | PLANNED = "PL" 36 | STARTED = "ST" 37 | FIRST_RESULTS = "FR" 38 | MATURE_RESULTS = "MR" 39 | DONE = "DO" 40 | DEFERRED = "DE" 41 | BLOCKED = "BL" 42 | INACTIVE = "IN" 43 | 44 | STATUS_CHOICES = [ 45 | (PLANNED, "Planned"), 46 | (STARTED, "Started"), 47 | (FIRST_RESULTS, "First results"), 48 | (MATURE_RESULTS, "Mature results"), 49 | (DONE, "Done"), 50 | (DEFERRED, "Deferred"), 51 | (BLOCKED, "Blocked"), 52 | (INACTIVE, "Inactive"), 53 | ] 54 | 55 | status = models.CharField( 56 | max_length=2, 57 | choices=STATUS_CHOICES, 58 | ) 59 | 60 | last_review = models.DateField(null=True, blank=True) 61 | 62 | def is_at_risk(self): 63 | return self.status in {self.BLOCKED, self.INACTIVE} 64 | 65 | def __str__(self): 66 | return self.name 67 | 68 | 69 | 70 | Then add the project, the ``projects`` module and Neapolitan to the beginning of 71 | ``INSTALLED_APPS`` in ``settings.py``: 72 | 73 | .. code-block:: Python 74 | :emphasize-lines: 2-4 75 | 76 | INSTALLED_APPS = [ 77 | 'projects', 78 | 'neapolitan', 79 | [...] 80 | ] 81 | 82 | Create migrations, and run them:: 83 | 84 | python manage.py makemigrations 85 | python manage.py migrate 86 | 87 | Wire up the admin; in ``projects/admin.py``:: 88 | 89 | from .models import Project 90 | 91 | admin.site.register(Project) 92 | 93 | and create a superuser:: 94 | 95 | python manage.py createsuperuser 96 | 97 | Finally, start the runserver and in the admin, add a few ``Project`` objects to the database. 98 | 99 | 100 | Wire up Neapolitan views 101 | ------------------------ 102 | 103 | Neapolitan expects to extend a base template (its own templates use 104 | ``{% extends "base.html" %}`` so you'll have to provide one at ``dashboard/templates/base.html``:: 105 | 106 | {% block content %}{% endblock %} 107 | 108 | 109 | And at the end of ``dashboard/urls.py``:: 110 | 111 | from neapolitan.views import CRUDView 112 | 113 | import projects 114 | 115 | class ProjectView(CRUDView): 116 | model = projects.models.Project 117 | fields = ["name", "owner", "last_review", "has_tests", "has_docs", "status"] 118 | 119 | urlpatterns += ProjectView.get_urls() 120 | 121 | At this point, you can see Neapolitan in action at ``/project/`` (e.g. 122 | http://127.0.0.1:8000/project/). It won't look very beautiful, but you'll see 123 | a table of objects and their attributes, along with options to change their values 124 | (which will work - you can save changes). 125 | 126 | 127 | Next steps 128 | ---------- 129 | 130 | The default templates use TailwindCSS classes, for styling. You will need to integrate 131 | TailwindCSS into Django. There is more than one way to do this. The method described here, 132 | from `Tailwind's own documentation `_, 133 | is explicitly *not recommended for production*. 134 | 135 | Turn your ``base.html`` into a more complete template, and note the `` 146 | 147 | 148 | {% block content %}{% endblock %} 149 | 150 | 151 | 152 | You notice that the page is now rendered rather more attractively. 153 | 154 | 155 | .. seealso:: 156 | 157 | You can find more detailed information on using Tailwind with Django here: 158 | `Integrate TailwindCSS into Django `_. 159 | -------------------------------------------------------------------------------- /examples/bootstrap/object_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {# object_confirm_delete.html - Delete confirmation template #} 2 | {% extends "base.html" %} 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |

Are you sure you want to delete following {{ object_verbose_name }}?

10 | 11 |

{{ object }}

12 | 13 | 14 | {% csrf_token %} 15 | {{ form }} 16 |
17 | 18 |
19 | 20 |
21 |
22 |
23 |
24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /examples/bootstrap/object_detail.html: -------------------------------------------------------------------------------- 1 | {# object_detail.html - Detail view template #} 2 | {% extends "base.html" %} 3 | {% load neapolitan %} 4 | 5 | {% block content %} 6 |

{{ object }}

7 | {% object_detail object view.fields %} 8 | {% endblock %} 9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/bootstrap/object_form.html: -------------------------------------------------------------------------------- 1 | {# object_form.html - Create/Edit form template #} 2 | {% extends "base.html" %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% block content %} 6 |
7 |
8 |

9 | {% if object %}Edit {{ object_verbose_name }}{% else %}Create {{ object_verbose_name }}{% endif %} 10 |

11 | 12 |
13 |
14 |
15 | {% csrf_token %} 16 | {{ form|crispy }} 17 |
18 | 19 |
20 |
21 |
22 |
23 |
24 |
25 | {% endblock %} 26 | 27 | -------------------------------------------------------------------------------- /examples/bootstrap/object_list.html: -------------------------------------------------------------------------------- 1 | {# object_list.html - Main list template #} 2 | {% extends "base.html" %} 3 | {% load neapolitan %} 4 | 5 | {% block content %} 6 |
7 |
8 |

{{ object_verbose_name_plural|capfirst }}

9 |
10 | {% if create_view_url %} 11 | 17 | {% endif %} 18 |
19 | 20 | {% if object_list %} 21 | {% object_list object_list view %} 22 | {% else %} 23 |
24 |

There are no {{ object_verbose_name_plural }}. Create one now?

25 |
26 | {% endif %} 27 | {% endblock %} 28 | 29 | -------------------------------------------------------------------------------- /examples/bootstrap/partial/detail.html: -------------------------------------------------------------------------------- 1 | {# partial/detail.html - Detail partial template #} 2 |
3 |
4 |
5 | {% for field, val in object %} 6 |
{{ field|capfirst }}:
7 |
{{ val }}
8 | {% endfor %} 9 |
10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /examples/bootstrap/partial/list.html: -------------------------------------------------------------------------------- 1 | {# partial/list.html - List partial template #} 2 |
3 | 4 | 5 | 6 | {% for header in headers %} 7 | 8 | {% endfor %} 9 | 10 | 11 | 12 | 13 | {% for object in object_list %} 14 | 15 | {% for field in object.fields %} 16 | 17 | {% endfor %} 18 | 21 | 22 | {% endfor %} 23 | 24 |
{{ header|capfirst }}Actions
{{ field }} 19 | {{ object.actions }} 20 |
25 |
26 | 27 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | 2 | test +FLAGS='': 3 | django-admin test --settings=tests.settings --pythonpath=. {{FLAGS}} 4 | 5 | coverage: 6 | coverage erase 7 | coverage run -m django test --settings=tests.settings --pythonpath=. 8 | coverage report 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "neapolitan" 7 | authors = [{name = "Carlton Gibson", email = "carlton.gibson@noumenal.es"}] 8 | readme = "README.rst" 9 | license = {file = "LICENSE"} 10 | classifiers = ["License :: OSI Approved :: MIT License"] 11 | dynamic = ["version", "description"] 12 | dependencies = ["Django", "django-filter"] 13 | 14 | [project.urls] 15 | Repository = "https://github.com/carltongibson/neapolitan" 16 | Docs = "https://noumenal.es/neapolitan/" 17 | 18 | [project.optional-dependencies] 19 | docs = ["Sphinx"] 20 | tests = ["coverage[toml]", "django_coverage_plugin"] 21 | 22 | [tool.coverage.run] 23 | plugins = ["django_coverage_plugin"] 24 | -------------------------------------------------------------------------------- /src/neapolitan/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Neapolitan: quick CRUD views for Django. 3 | 4 | I have a Django model:: 5 | 6 | from django.db import models 7 | 8 | class Bookmark(models.Model): 9 | url = models.URLField(unique=True) 10 | title = models.CharField(max_length=255) 11 | note = models.TextField(blank=True) 12 | 13 | I want easy CRUD views for it, without it taking all day:: 14 | 15 | # urls.py 16 | from neapolitan.views import CRUDView 17 | 18 | class BookmarkView(CRUDView): 19 | model = Bookmark 20 | fields = ["url", "title", "note"] 21 | 22 | 23 | urlpatterns = [ ... ] + BookmarkView.get_urls() 24 | 25 | Neapolitan's `CRUDView` provides the standard list, detail, 26 | create, edit, and delete views for a model, as well as the hooks you need to 27 | be able to customise any part of that. 28 | 29 | Neapolitan provides base templates and re-usable template tags to make getting 30 | your model on the page as easy as possible. 31 | 32 | Where you take your app after that is up to you. But Neapolitan will get you 33 | started. 34 | 35 | Let's go! 🚀 36 | """ 37 | 38 | __version__ = "24.8" 39 | -------------------------------------------------------------------------------- /src/neapolitan/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carltongibson/neapolitan/4efd25225b6b4365c10b73e2703bf7646d3f4569/src/neapolitan/management/__init__.py -------------------------------------------------------------------------------- /src/neapolitan/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carltongibson/neapolitan/4efd25225b6b4365c10b73e2703bf7646d3f4569/src/neapolitan/management/commands/__init__.py -------------------------------------------------------------------------------- /src/neapolitan/management/commands/mktemplate.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.core.management.base import BaseCommand, CommandError 6 | from django.template.loader import TemplateDoesNotExist, get_template 7 | from django.template.engine import Engine 8 | from django.apps import apps 9 | 10 | class Command(BaseCommand): 11 | help = "Bootstrap a CRUD template for a model, copying from the active neapolitan default templates." 12 | 13 | def add_arguments(self, parser): 14 | parser.add_argument( 15 | "model", 16 | type=str, 17 | help="The to bootstrap a template for.", 18 | ) 19 | group = parser.add_mutually_exclusive_group(required=True) 20 | group.add_argument( 21 | "-l", 22 | "--list", 23 | action="store_const", 24 | const="list", 25 | dest="role", 26 | help="List role", 27 | ) 28 | group.add_argument( 29 | "-d", 30 | "--detail", 31 | action="store_const", 32 | const="detail", 33 | dest="role", 34 | help="Detail role", 35 | ) 36 | group.add_argument( 37 | "-c", 38 | "--create", 39 | action="store_const", 40 | const="form", 41 | dest="role", 42 | help="Create role", 43 | ) 44 | group.add_argument( 45 | "-u", 46 | "--update", 47 | action="store_const", 48 | const="form", 49 | dest="role", 50 | help="Update role", 51 | ) 52 | group.add_argument( 53 | "-f", 54 | "--form", 55 | action="store_const", 56 | const="form", 57 | dest="role", 58 | help="Form role", 59 | ) 60 | group.add_argument( 61 | "--delete", 62 | action="store_const", 63 | const="delete", 64 | dest="role", 65 | help="Delete role", 66 | ) 67 | 68 | def handle(self, *args, **options): 69 | model = options["model"] 70 | role = options["role"] 71 | 72 | if role == "list": 73 | suffix = "_list.html" 74 | elif role == "detail": 75 | suffix = "_detail.html" 76 | elif role == "form": 77 | suffix = "_form.html" 78 | elif role == "delete": 79 | suffix = "_confirm_delete.html" 80 | 81 | app_name, model_name = model.split(".") 82 | template_name = f"{app_name}/{model_name.lower()}{suffix}" 83 | neapolitan_template_name = f"neapolitan/object{suffix}" 84 | 85 | # Check if the template already exists. 86 | try: 87 | get_template(template_name) 88 | except TemplateDoesNotExist: 89 | # Get the filesystem path of neapolitan's object template. 90 | neapolitan_template = get_template(neapolitan_template_name) 91 | neapolitan_template_path = neapolitan_template.origin.name 92 | 93 | # Find target directory. 94 | # 1. If f"{app_name}/templates" exists, use that. 95 | # 2. Otherwise, use first project level template dir. 96 | app_config = apps.get_app_config(app_name) 97 | target_dir = f"{app_config.path}/templates" 98 | if not Path(target_dir).exists(): 99 | try: 100 | target_dir = Engine.get_default().template_dirs[0] 101 | except (ImproperlyConfigured, IndexError): 102 | raise CommandError( 103 | "No app or project level template dir found." 104 | ) 105 | # Copy the neapolitan template to the target directory with template_name. 106 | shutil.copyfile(neapolitan_template_path, f"{target_dir}/{template_name}") 107 | else: 108 | self.stdout.write( 109 | f"Template {template_name} already exists. Remove it manually if you want to regenerate it." 110 | ) 111 | raise CommandError("Template already exists.") 112 | -------------------------------------------------------------------------------- /src/neapolitan/templates/neapolitan/object_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |

Are you sure you want to delete following {{ object_verbose_name }}?

6 |

{{ object }}

7 | 8 |
9 |
10 | {% csrf_token %} 11 | 12 | {{ form }} 13 | 14 | 17 |
18 | 19 |
20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /src/neapolitan/templates/neapolitan/object_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load neapolitan %} 3 | 4 | {% block content %} 5 | 6 | 7 |

{{ object }}

8 | 9 | 10 | {% object_detail object view.fields %} 11 | 12 | 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /src/neapolitan/templates/neapolitan/object_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |

{% if object %}Edit {{object_verbose_name}}{% else %}Create {{object_verbose_name}}{% endif %}

6 | 7 |
8 |
10 | {% csrf_token %} 11 | {{ form }} 12 | 14 |
15 |
16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /src/neapolitan/templates/neapolitan/object_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load neapolitan %} 3 | 4 | {% block content %} 5 | 6 |
7 |

{{ object_verbose_name_plural|capfirst }}

8 | {% if create_view_url %} 9 | 13 | {% endif %} 14 |
15 | 16 | {% if object_list %} 17 | {% object_list object_list view %} 18 | {% else %} 19 |

There are no {{ object_verbose_name_plural }}. Create one now?

20 | {% endif %} 21 | 22 |
23 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /src/neapolitan/templates/neapolitan/partial/detail.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | {% for field, val in object %} 4 |
5 |
{{ field|capfirst }}:
6 |
{{ val }}
7 |
8 | {% endfor %} 9 |
10 | -------------------------------------------------------------------------------- /src/neapolitan/templates/neapolitan/partial/list.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 | 7 | 8 | {% for header in headers %} 9 | 11 | {% endfor %} 12 | 15 | 16 | 17 | 18 | 19 | {% for object in object_list %} 20 | 21 | {% for field in object.fields %} 22 | 29 | {% endfor %} 30 | 33 | 34 | {% endfor %} 35 | 36 |
{{ header|capfirst }} 13 | Actions 14 |
{{ field }} 31 | {{ object.actions }} 32 |
37 | 38 |
39 |
40 |
41 | -------------------------------------------------------------------------------- /src/neapolitan/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carltongibson/neapolitan/4efd25225b6b4365c10b73e2703bf7646d3f4569/src/neapolitan/templatetags/__init__.py -------------------------------------------------------------------------------- /src/neapolitan/templatetags/neapolitan.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.safestring import mark_safe 3 | 4 | from neapolitan.views import Role 5 | 6 | register = template.Library() 7 | 8 | 9 | def action_links(view, object): 10 | actions = [ 11 | (url, name) 12 | for url, name in [ 13 | (Role.DETAIL.maybe_reverse(view, object), "View"), 14 | (Role.UPDATE.maybe_reverse(view, object), "Edit"), 15 | (Role.DELETE.maybe_reverse(view, object), "Delete"), 16 | ] if url is not None 17 | ] 18 | links = [f"{anchor_text}" for url, anchor_text in actions] 19 | return mark_safe(" | ".join(links)) 20 | 21 | 22 | @register.inclusion_tag("neapolitan/partial/detail.html") 23 | def object_detail(object, fields): 24 | """ 25 | Renders a detail view of an object with the given fields. 26 | 27 | Inclusion tag usage:: 28 | 29 | {% object_detail object fields %} 30 | 31 | Template: ``neapolitan/partial/detail.html`` - Will render a table of the 32 | object's fields. 33 | """ 34 | 35 | def iter(): 36 | for f in fields: 37 | mf = object._meta.get_field(f) 38 | yield (mf.verbose_name, mf.value_to_string(object)) 39 | 40 | return {"object": iter()} 41 | 42 | 43 | @register.inclusion_tag("neapolitan/partial/list.html") 44 | def object_list(objects, view): 45 | """ 46 | Renders a list of objects with the given fields. 47 | 48 | Inclusion tag usage:: 49 | 50 | {% object_list objects view %} 51 | 52 | Template: ``neapolitan/partial/list.html`` — Will render a table of objects 53 | with links to view, edit, and delete views. 54 | """ 55 | 56 | fields = view.fields 57 | headers = [objects[0]._meta.get_field(f).verbose_name for f in fields] 58 | object_list = [ 59 | { 60 | "object": object, 61 | "fields": [ 62 | object._meta.get_field(f).value_to_string(object) for f in fields 63 | ], 64 | "actions": action_links(view, object), 65 | } 66 | for object in objects 67 | ] 68 | return { 69 | "headers": headers, 70 | "object_list": object_list, 71 | } 72 | -------------------------------------------------------------------------------- /src/neapolitan/views.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.core.paginator import InvalidPage, Paginator 5 | from django.forms import models as model_forms 6 | from django.http import Http404, HttpResponseRedirect 7 | from django.shortcuts import get_object_or_404 8 | from django.template.response import TemplateResponse 9 | from django.urls import NoReverseMatch, path, reverse 10 | from django.utils.decorators import classonlymethod 11 | from django.utils.functional import classproperty 12 | from django.utils.translation import gettext as _ 13 | from django.views.generic import View 14 | from django_filters.filterset import filterset_factory 15 | 16 | 17 | # A CRUDView is a view that can perform all the CRUD operations on a model. The 18 | # `role` attribute determines which operations are available for a given 19 | # as_view() call. 20 | class Role(enum.Enum): 21 | LIST = "list" 22 | CREATE = "create" 23 | DETAIL = "detail" 24 | UPDATE = "update" 25 | DELETE = "delete" 26 | 27 | def handlers(self): 28 | match self: 29 | case Role.LIST: 30 | return {"get": "list"} 31 | case Role.DETAIL: 32 | return {"get": "detail"} 33 | case Role.CREATE: 34 | return { 35 | "get": "show_form", 36 | "post": "process_form", 37 | } 38 | case Role.UPDATE: 39 | return { 40 | "get": "show_form", 41 | "post": "process_form", 42 | } 43 | case Role.DELETE: 44 | return { 45 | "get": "confirm_delete", 46 | "post": "process_deletion", 47 | } 48 | 49 | def extra_initkwargs(self): 50 | # Provide template_name_suffix, "_list", "_detail", "_form", etc. for Role. 51 | match self: 52 | case Role.LIST: 53 | return {"template_name_suffix": "_list"} 54 | case Role.DETAIL: 55 | return {"template_name_suffix": "_detail"} 56 | case Role.CREATE | Role.UPDATE: 57 | return {"template_name_suffix": "_form"} 58 | case Role.DELETE: 59 | return {"template_name_suffix": "_confirm_delete"} 60 | 61 | @property 62 | def url_name_component(self): 63 | return self.value 64 | 65 | def url_pattern(self, view_cls): 66 | url_base = view_cls.url_base 67 | url_kwarg = view_cls.lookup_url_kwarg or view_cls.lookup_field 68 | path_converter = view_cls.path_converter 69 | match self: 70 | case Role.LIST: 71 | return f"{url_base}/" 72 | case Role.DETAIL: 73 | return f"{url_base}/<{path_converter}:{url_kwarg}>/" 74 | case Role.CREATE: 75 | return f"{url_base}/new/" 76 | case Role.UPDATE: 77 | return f"{url_base}/<{path_converter}:{url_kwarg}>/edit/" 78 | case Role.DELETE: 79 | return f"{url_base}/<{path_converter}:{url_kwarg}>/delete/" 80 | 81 | def get_url(self, view_cls): 82 | return path( 83 | self.url_pattern(view_cls), 84 | view_cls.as_view(role=self), 85 | name=f"{view_cls.url_base}-{self.url_name_component}" 86 | ) 87 | 88 | def reverse(self, view, object=None): 89 | url_name = f"{view.url_base}-{self.url_name_component}" 90 | url_kwarg = view.lookup_url_kwarg or view.lookup_field 91 | match self: 92 | case Role.LIST | Role.CREATE: 93 | return reverse(url_name) 94 | case _: 95 | return reverse( 96 | url_name, 97 | kwargs={url_kwarg: getattr(object, view.lookup_field)}, 98 | ) 99 | 100 | def maybe_reverse(self, view, object=None): 101 | try: 102 | return self.reverse(view, object) 103 | except NoReverseMatch: 104 | return None 105 | 106 | 107 | class CRUDView(View): 108 | """ 109 | CRUDView is Neapolitan's core. It provides the standard list, detail, 110 | create, edit, and delete views for a model, as well as the hooks you need to 111 | be able to customise any part of that. 112 | """ 113 | 114 | role: Role 115 | model = None 116 | fields = None # TODO: handle this being None. 117 | 118 | # Object lookup parameters. These are used in the URL kwargs, and when 119 | # performing the model instance lookup. 120 | # Note that if unset then `lookup_url_kwarg` defaults to using the same 121 | # value as `lookup_field`. 122 | lookup_field = "pk" 123 | lookup_url_kwarg = None 124 | path_converter = "int" 125 | object = None 126 | 127 | # All the following are optional, and fall back to default values 128 | # based on the 'model' shortcut. 129 | # Each of these has a corresponding `.get_()` method. 130 | queryset = None 131 | form_class = None 132 | template_name = None 133 | context_object_name = None 134 | 135 | # Pagination parameters. 136 | # Set `paginate_by` to an integer value to turn pagination on. 137 | paginate_by = None 138 | page_kwarg = "page" 139 | allow_empty = True 140 | 141 | # Suffix that should be appended to automatically generated template names. 142 | template_name_suffix = None 143 | 144 | def list(self, request, *args, **kwargs): 145 | """GET handler for the list view.""" 146 | 147 | queryset = self.get_queryset() 148 | filterset = self.get_filterset(queryset) 149 | if filterset is not None: 150 | queryset = filterset.qs 151 | 152 | if not self.allow_empty and not queryset.exists(): 153 | raise Http404 154 | 155 | paginate_by = self.get_paginate_by() 156 | if paginate_by is None: 157 | # Unpaginated response 158 | self.object_list = queryset 159 | context = self.get_context_data( 160 | page_obj=None, 161 | is_paginated=False, 162 | paginator=None, 163 | filterset=filterset, 164 | ) 165 | else: 166 | # Paginated response 167 | page = self.paginate_queryset(queryset, paginate_by) 168 | self.object_list = page.object_list 169 | context = self.get_context_data( 170 | page_obj=page, 171 | is_paginated=page.has_other_pages(), 172 | paginator=page.paginator, 173 | filterset=filterset, 174 | ) 175 | 176 | return self.render_to_response(context) 177 | 178 | def detail(self, request, *args, **kwargs): 179 | """GET handler for the detail view.""" 180 | 181 | self.object = self.get_object() 182 | context = self.get_context_data() 183 | return self.render_to_response(context) 184 | 185 | def show_form(self, request, *args, **kwargs): 186 | """GET handler for the create and update form views.""" 187 | 188 | if self.role == Role.UPDATE: 189 | self.object = self.get_object() 190 | form = self.get_form(instance=self.object) 191 | context = self.get_context_data(form=form) 192 | return self.render_to_response(context) 193 | 194 | def process_form(self, request, *args, **kwargs): 195 | """POST handler for the create and update form views.""" 196 | 197 | if self.role == Role.UPDATE: 198 | self.object = self.get_object() 199 | form = self.get_form( 200 | data=request.POST, 201 | files=request.FILES, 202 | instance=self.object, 203 | ) 204 | if form.is_valid(): 205 | return self.form_valid(form) 206 | return self.form_invalid(form) 207 | 208 | def confirm_delete(self, request, *args, **kwargs): 209 | """GET handler for the delete confirmation view.""" 210 | 211 | self.object = self.get_object() 212 | context = self.get_context_data() 213 | return self.render_to_response(context) 214 | 215 | def process_deletion(self, request, *args, **kwargs): 216 | """POST handler for the delete confirmation view.""" 217 | 218 | self.object = self.get_object() 219 | self.object.delete() 220 | return HttpResponseRedirect(self.get_success_url()) 221 | 222 | # Queryset and object lookup 223 | 224 | def get_queryset(self): 225 | """ 226 | Returns the base queryset for the view. 227 | 228 | Either used as a list of objects to display, or as the queryset 229 | from which to perform the individual object lookup. 230 | """ 231 | if self.queryset is not None: 232 | return self.queryset._clone() 233 | 234 | if self.model is not None: 235 | return self.model._default_manager.all() 236 | 237 | msg = ( 238 | "'%s' must either define 'queryset' or 'model', or override " 239 | + "'get_queryset()'" 240 | ) 241 | raise ImproperlyConfigured(msg % self.__class__.__name__) 242 | 243 | def get_object(self): 244 | """ 245 | Returns the object the view is displaying. 246 | """ 247 | queryset = self.get_queryset() 248 | lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field 249 | 250 | try: 251 | lookup = {self.lookup_field: self.kwargs[lookup_url_kwarg]} 252 | except KeyError: 253 | msg = "Lookup field '%s' was not provided in view kwargs to '%s'" 254 | raise ImproperlyConfigured( 255 | msg % (lookup_url_kwarg, self.__class__.__name__) 256 | ) 257 | 258 | return get_object_or_404(queryset, **lookup) 259 | 260 | # Form handling 261 | 262 | def get_form_class(self): 263 | """ 264 | Returns the form class to use in this view. 265 | """ 266 | if self.form_class is not None: 267 | return self.form_class 268 | 269 | if self.model is not None and self.fields is not None: 270 | return model_forms.modelform_factory(self.model, fields=self.fields) 271 | 272 | msg = ( 273 | "'%s' must either define 'form_class' or both 'model' and " 274 | "'fields', or override 'get_form_class()'" 275 | ) 276 | raise ImproperlyConfigured(msg % self.__class__.__name__) 277 | 278 | def get_form(self, data=None, files=None, **kwargs): 279 | """ 280 | Returns a form instance. 281 | """ 282 | cls = self.get_form_class() 283 | return cls(data=data, files=files, **kwargs) 284 | 285 | def form_valid(self, form): 286 | self.object = form.save() 287 | return HttpResponseRedirect(self.get_success_url()) 288 | 289 | def form_invalid(self, form): 290 | context = self.get_context_data(form=form) 291 | return self.render_to_response(context) 292 | 293 | def get_success_url(self): 294 | assert self.model is not None, ( 295 | "'%s' must define 'model' or override 'get_success_url()'" 296 | % self.__class__.__name__ 297 | ) 298 | if self.role == Role.DELETE: 299 | success_url = reverse(f"{self.url_base}-list") 300 | else: 301 | success_url = reverse( 302 | f"{self.url_base}-detail", kwargs={"pk": self.object.pk} 303 | ) 304 | return success_url 305 | 306 | # Pagination and filtering 307 | 308 | def get_paginate_by(self): 309 | """ 310 | Returns the size of pages to use with pagination. 311 | """ 312 | return self.paginate_by 313 | 314 | def get_paginator(self, queryset, page_size): 315 | """ 316 | Returns a paginator instance. 317 | """ 318 | return Paginator(queryset, page_size) 319 | 320 | def paginate_queryset(self, queryset, page_size): 321 | """ 322 | Paginates a queryset, and returns a page object. 323 | """ 324 | paginator = self.get_paginator(queryset, page_size) 325 | page_kwarg = self.kwargs.get(self.page_kwarg) 326 | page_query_param = self.request.GET.get(self.page_kwarg) 327 | page_number = page_kwarg or page_query_param or 1 328 | try: 329 | page_number = int(page_number) 330 | except ValueError: 331 | if page_number == "last": 332 | page_number = paginator.num_pages 333 | else: 334 | msg = "Page is not 'last', nor can it be converted to an int." 335 | raise Http404(_(msg)) 336 | 337 | try: 338 | return paginator.page(page_number) 339 | except InvalidPage as exc: 340 | msg = "Invalid page (%s): %s" 341 | raise Http404(_(msg) % (page_number, str(exc))) 342 | 343 | def get_filterset(self, queryset=None): 344 | filterset_class = getattr(self, "filterset_class", None) 345 | filterset_fields = getattr(self, "filterset_fields", None) 346 | 347 | if filterset_class is None and filterset_fields: 348 | filterset_class = filterset_factory(self.model, fields=filterset_fields) 349 | 350 | if filterset_class is None: 351 | return None 352 | 353 | return filterset_class( 354 | self.request.GET, 355 | queryset=queryset, 356 | request=self.request, 357 | ) 358 | 359 | # Response rendering 360 | 361 | def get_context_object_name(self, is_list=False): 362 | """ 363 | Returns a descriptive name to use in the context in addition to the 364 | default 'object'/'object_list'. 365 | """ 366 | if self.context_object_name is not None: 367 | return self.context_object_name 368 | 369 | elif self.model is not None: 370 | fmt = "%s_list" if is_list else "%s" 371 | return fmt % self.model._meta.object_name.lower() 372 | 373 | return None 374 | 375 | def get_context_data(self, **kwargs): 376 | kwargs["view"] = self 377 | kwargs["object_verbose_name"] = self.model._meta.verbose_name 378 | kwargs["object_verbose_name_plural"] = self.model._meta.verbose_name_plural 379 | kwargs["create_view_url"] = Role.CREATE.maybe_reverse(self) 380 | 381 | if getattr(self, "object", None) is not None: 382 | kwargs["object"] = self.object 383 | context_object_name = self.get_context_object_name() 384 | if context_object_name: 385 | kwargs[context_object_name] = self.object 386 | 387 | if getattr(self, "object_list", None) is not None: 388 | kwargs["object_list"] = self.object_list 389 | context_object_name = self.get_context_object_name(is_list=True) 390 | if context_object_name: 391 | kwargs[context_object_name] = self.object_list 392 | 393 | return kwargs 394 | 395 | def get_template_names(self): 396 | """ 397 | Returns a list of template names to use when rendering the response. 398 | 399 | If `.template_name` is not specified, uses the 400 | "{app_label}/{model_name}{template_name_suffix}.html" model template 401 | pattern, with the fallback to the 402 | "neapolitan/object{template_name_suffix}.html" default templates. 403 | """ 404 | if self.template_name is not None: 405 | return [self.template_name] 406 | 407 | if self.model is not None and self.template_name_suffix is not None: 408 | return [ 409 | f"{self.model._meta.app_label}/" 410 | f"{self.model._meta.object_name.lower()}" 411 | f"{self.template_name_suffix}.html", 412 | f"neapolitan/object{self.template_name_suffix}.html", 413 | ] 414 | msg = ( 415 | "'%s' must either define 'template_name' or 'model' and " 416 | "'template_name_suffix', or override 'get_template_names()'" 417 | ) 418 | raise ImproperlyConfigured(msg % self.__class__.__name__) 419 | 420 | def render_to_response(self, context): 421 | """ 422 | Given a context dictionary, returns an HTTP response. 423 | """ 424 | return TemplateResponse( 425 | request=self.request, template=self.get_template_names(), context=context 426 | ) 427 | 428 | # URLs and view callables 429 | 430 | @classonlymethod 431 | def as_view(cls, role: Role, **initkwargs): 432 | """Main entry point for a request-response process.""" 433 | for key in initkwargs: 434 | if key in cls.http_method_names: 435 | raise TypeError( 436 | "The method name %s is not accepted as a keyword argument " 437 | "to %s()." % (key, cls.__name__) 438 | ) 439 | if key in [ 440 | "list", 441 | "detail", 442 | "show_form", 443 | "process_form", 444 | "confirm_delete", 445 | "process_deletion", 446 | ]: 447 | raise TypeError( 448 | "CRUDView handler name %s is not accepted as a keyword argument " 449 | "to %s()." % (key, cls.__name__) 450 | ) 451 | if not hasattr(cls, key): 452 | raise TypeError( 453 | "%s() received an invalid keyword %r. as_view " 454 | "only accepts arguments that are already " 455 | "attributes of the class." % (cls.__name__, key) 456 | ) 457 | 458 | def view(request, *args, **kwargs): 459 | # Merge Role default and provided initkwargs. 460 | self = cls(**{**role.extra_initkwargs(), **initkwargs}) 461 | self.role = role 462 | self.setup(request, *args, **kwargs) 463 | if not hasattr(self, "request"): 464 | raise AttributeError( 465 | f"{cls.__name__} instance has no 'request' attribute. Did you " 466 | "override setup() and forget to call super()?" 467 | ) 468 | 469 | for method, action in role.handlers().items(): 470 | handler = getattr(self, action) 471 | setattr(self, method, handler) 472 | 473 | return self.dispatch(request, *args, **kwargs) 474 | 475 | view.view_class = cls 476 | view.view_initkwargs = initkwargs 477 | 478 | # __name__ and __qualname__ are intentionally left unchanged as 479 | # view_class should be used to robustly determine the name of the view 480 | # instead. 481 | view.__doc__ = cls.__doc__ 482 | view.__module__ = cls.__module__ 483 | view.__annotations__ = cls.dispatch.__annotations__ 484 | # Copy possible attributes set by decorators, e.g. @csrf_exempt, from 485 | # the dispatch method. 486 | view.__dict__.update(cls.dispatch.__dict__) 487 | 488 | # Mark the callback if the view class is async. 489 | # if cls.view_is_async: 490 | # markcoroutinefunction(view) 491 | 492 | return view 493 | 494 | @classproperty 495 | def url_base(cls): 496 | """ 497 | The base component of generated URLs. 498 | 499 | Defaults to the model's name, but may be overridden by setting 500 | `url_base` directly on the class, overriding this property:: 501 | 502 | class AlternateCRUDView(CRUDView): 503 | model = Bookmark 504 | url_base = "something-else" 505 | """ 506 | return cls.model._meta.model_name 507 | 508 | @classonlymethod 509 | def get_urls(cls, roles=None): 510 | """Classmethod to generate URL patterns for the view.""" 511 | if roles is None: 512 | roles = iter(Role) 513 | return [role.get_url(cls) for role in roles] 514 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carltongibson/neapolitan/4efd25225b6b4365c10b73e2703bf7646d3f4569/tests/__init__.py -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-04-10 04:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Bookmark", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("url", models.URLField(unique=True)), 25 | ("title", models.CharField(max_length=255)), 26 | ("note", models.TextField(blank=True)), 27 | ("favourite", models.BooleanField(default=False)), 28 | ], 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /tests/migrations/0002_namedcollection.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2024-04-25 07:33 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("tests", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="NamedCollection", 15 | fields=[ 16 | ( 17 | "id", 18 | models.BigAutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=25, unique=True)), 26 | ("code", models.UUIDField(default=uuid.uuid4, unique=True)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carltongibson/neapolitan/4efd25225b6b4365c10b73e2703bf7646d3f4569/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | 4 | 5 | class Bookmark(models.Model): 6 | url = models.URLField(unique=True) 7 | title = models.CharField(max_length=255) 8 | note = models.TextField(blank=True) 9 | favourite = models.BooleanField(default=False) 10 | 11 | 12 | class NamedCollection(models.Model): 13 | """For testing UUID lookup""" 14 | name = models.CharField(max_length=25, unique=True) 15 | code = models.UUIDField(unique=True, default=uuid.uuid4) 16 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | "default": { 3 | "ENGINE": "django.db.backends.sqlite3", 4 | "NAME": ":memory:", 5 | }, 6 | } 7 | MIDDLEWARE = [ 8 | "django.middleware.security.SecurityMiddleware", 9 | "django.contrib.sessions.middleware.SessionMiddleware", 10 | "django.middleware.common.CommonMiddleware", 11 | "django.middleware.csrf.CsrfViewMiddleware", 12 | "django.contrib.auth.middleware.AuthenticationMiddleware", 13 | "django.contrib.messages.middleware.MessageMiddleware", 14 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 15 | ] 16 | ROOT_URLCONF = "tests.tests" 17 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 18 | SECRET_KEY = "a-not-very-secret-test-secret-key" 19 | TEMPLATES = [ 20 | { 21 | "BACKEND": "django.template.backends.django.DjangoTemplates", 22 | "DIRS": [], 23 | "APP_DIRS": True, 24 | "OPTIONS": { 25 | "context_processors": [ 26 | "django.template.context_processors.debug", 27 | "django.template.context_processors.request", 28 | "django.contrib.auth.context_processors.auth", 29 | "django.contrib.messages.context_processors.messages", 30 | ], 31 | "debug": True, 32 | }, 33 | }, 34 | ] 35 | 36 | 37 | INSTALLED_APPS = [ 38 | "django.contrib.admin", 39 | "django.contrib.auth", 40 | "django.contrib.contenttypes", 41 | "django.contrib.sessions", 42 | "django.contrib.messages", 43 | "django.contrib.staticfiles", 44 | "neapolitan", 45 | "tests", 46 | ] 47 | -------------------------------------------------------------------------------- /tests/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <!-- TODO: A great title --> 5 | 6 | 7 | {% block content %}{% endblock %} 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/templates/tests/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.management import call_command 4 | from django.http import HttpResponse 5 | from django.test import RequestFactory, TestCase 6 | from django.urls import reverse 7 | from django.utils.html import escape 8 | 9 | from neapolitan.views import CRUDView, Role, classonlymethod 10 | 11 | from .models import Bookmark, NamedCollection 12 | 13 | 14 | class BookmarkView(CRUDView): 15 | model = Bookmark 16 | fields = ["url", "title", "note"] 17 | filterset_fields = [ 18 | "favourite", 19 | ] 20 | 21 | 22 | class NamedCollectionView(CRUDView): 23 | model = NamedCollection 24 | fields = ["name", "code"] 25 | 26 | lookup_field = "code" 27 | path_converter = "uuid" 28 | 29 | url_base = "named_collections" 30 | 31 | 32 | class BookmarkListOnlyView(CRUDView): 33 | model = Bookmark 34 | fields = ["url", "title", "note"] 35 | url_base = "bookmarklist" 36 | 37 | @classonlymethod 38 | def get_urls(cls, roles=None): 39 | return super().get_urls(roles={Role.LIST}) 40 | 41 | 42 | urlpatterns = [ 43 | *BookmarkView.get_urls(), 44 | *NamedCollectionView.get_urls(), 45 | *BookmarkListOnlyView.get_urls(), 46 | ] 47 | 48 | 49 | class BasicTests(TestCase): 50 | @classmethod 51 | def setUpTestData(cls): 52 | cls.homepage = Bookmark.objects.create( 53 | url="https://noumenal.es/", 54 | title="Noumenal • Dr Carlton Gibson", 55 | note="Carlton Gibson's homepage. Blog, Contact and Project links.", 56 | favourite=True, 57 | ) 58 | cls.github = Bookmark.objects.create( 59 | url="https://github.com/carltongibson", 60 | title="Carlton Gibson - GitHub", 61 | note="Carlton Gibson on GitHub", 62 | ) 63 | cls.fosstodon = Bookmark.objects.create( 64 | url="https://fosstodon.org/@carlton", 65 | title="Carlton Gibson - Fosstodon", 66 | note="Carlton Gibson on Fosstodon", 67 | ) 68 | 69 | cls.main_collection = NamedCollection.objects.create(name="main") 70 | 71 | def test_list(self): 72 | response = self.client.get("/bookmark/") 73 | self.assertEqual(response.status_code, 200) 74 | self.assertIn("filterset", response.context) 75 | self.assertContains(response, self.homepage.title) 76 | self.assertContains(response, self.github.title) 77 | self.assertContains(response, self.fosstodon.title) 78 | self.assertContains(response, ">Add a new bookmark") 79 | 80 | def test_list_empty(self): 81 | Bookmark.objects.all().delete() 82 | response = self.client.get("/bookmark/") 83 | self.assertEqual(response.status_code, 200) 84 | self.assertContains(response, "There are no bookmarks. Create one now?") 85 | self.assertContains(response, ">Add a new bookmark") 86 | 87 | def test_detail(self): 88 | response = self.client.get(f"/bookmark/{self.homepage.pk}/") 89 | self.assertEqual(response.status_code, 200) 90 | self.assertContains(response, self.homepage.title) 91 | self.assertContains(response, escape(self.homepage.note)) 92 | 93 | def test_create(self): 94 | create_url = reverse("bookmark-create") 95 | 96 | # Load the form. 97 | response = self.client.get(create_url) 98 | self.assertEqual(response.status_code, 200) 99 | self.assertContains(response, 'action="/bookmark/new/"') 100 | 101 | # Submit the form. 102 | response = self.client.post( 103 | create_url, 104 | { 105 | "url": "https://example.com/", 106 | "title": "Example", 107 | "note": "Example note", 108 | }, 109 | follow=True, 110 | ) 111 | self.assertEqual(response.status_code, 200) 112 | self.assertEqual(response.resolver_match.url_name, "bookmark-detail") 113 | 114 | def test_delete(self): 115 | delete_url = reverse("bookmark-delete", args=[self.homepage.pk]) 116 | 117 | # Load the form. 118 | response = self.client.get(delete_url) 119 | self.assertEqual(response.status_code, 200) 120 | 121 | # Submit the form. 122 | response = self.client.post(delete_url, follow=True) 123 | self.assertEqual(response.status_code, 200) 124 | self.assertEqual(response.resolver_match.url_name, "bookmark-list") 125 | 126 | def test_update(self): 127 | update_url = reverse("bookmark-update", args=[self.homepage.pk]) 128 | 129 | # Load the form. 130 | response = self.client.get(update_url) 131 | self.assertEqual(response.status_code, 200) 132 | 133 | # Submit the form. 134 | response = self.client.post( 135 | update_url, 136 | { 137 | "url": "https://example.com/", 138 | "title": "Example", 139 | "note": "Example note", 140 | }, 141 | follow=True, 142 | ) 143 | self.assertEqual(response.status_code, 200) 144 | self.assertRedirects( 145 | response, reverse("bookmark-detail", args=[self.homepage.pk]) 146 | ) 147 | self.assertContains(response, "Example") 148 | 149 | def test_filter(self): 150 | response = self.client.get("/bookmark/?favourite=true") 151 | self.assertEqual(response.status_code, 200) 152 | self.assertSequenceEqual([self.homepage], response.context["bookmark_list"]) 153 | self.assertContains(response, self.homepage.title) 154 | self.assertNotContains(response, self.github.title) 155 | self.assertNotContains(response, self.fosstodon.title) 156 | 157 | def test_custom_mount_url(self): 158 | """Test view URL base""" 159 | response = self.client.get("/collection/") 160 | self.assertEqual(response.status_code, 404) 161 | 162 | response = self.client.get("/named_collections/") 163 | self.assertEqual(response.status_code, 200) 164 | 165 | def test_custom_lookup_field(self): 166 | """Test custom view.lookup_field""" 167 | response = self.client.get(f"/named_collections/{self.main_collection.code}/") 168 | self.assertEqual(response.status_code, 200) 169 | self.assertContains(response, self.main_collection.name) 170 | 171 | def test_lookup_url_converter(self): 172 | """Test view.lookup_url_converter""" 173 | response = self.client.get(f"/named_collections/{self.main_collection.id}/") 174 | self.assertEqual(response.status_code, 404) 175 | 176 | response = self.client.get(f"/named_collections/{self.main_collection.code}/") 177 | self.assertEqual(response.status_code, 200) 178 | self.assertContains(response, self.main_collection.name) 179 | 180 | def test_overriding_role_initkwargs(self): 181 | """as_view must prioritise initkwargs over Role extra_initkwargs.""" 182 | 183 | class InitKwargsCRUDView(CRUDView): 184 | model = Bookmark 185 | 186 | def detail(self, request, *args, **kwargs): 187 | return HttpResponse(self.template_name_suffix) 188 | 189 | view = InitKwargsCRUDView.as_view( 190 | role=Role.DETAIL, 191 | template_name_suffix='_test_suffix' 192 | ) 193 | request = RequestFactory().get('/') 194 | response = view(request) 195 | self.assertContains(response, '_test_suffix') 196 | 197 | 198 | class RoleTests(TestCase): 199 | def test_overriding_url_base(self): 200 | class AlternateCRUDView(CRUDView): 201 | model = Bookmark 202 | url_base = "something-else" 203 | 204 | self.assertEqual(AlternateCRUDView.url_base, "something-else") 205 | self.assertEqual( 206 | Role.LIST.url_pattern( 207 | AlternateCRUDView, 208 | ), 209 | "something-else/", 210 | ) 211 | 212 | def test_roles_provide_a_url_name_component(self): 213 | # The URL name is constructed in part by the role value. 214 | tests = [ 215 | (Role.LIST, "list"), 216 | (Role.DETAIL, "detail"), 217 | (Role.CREATE, "create"), 218 | (Role.UPDATE, "update"), 219 | (Role.DELETE, "delete"), 220 | ] 221 | for role, name in tests: 222 | with self.subTest(role=role): 223 | self.assertEqual(role.url_name_component, name) 224 | 225 | def test_url_pattern_generation(self): 226 | tests = [ 227 | (Role.LIST, "bookmark/"), 228 | (Role.DETAIL, "bookmark//"), 229 | (Role.CREATE, "bookmark/new/"), 230 | (Role.UPDATE, "bookmark//edit/"), 231 | (Role.DELETE, "bookmark//delete/"), 232 | ] 233 | for role, pattern in tests: 234 | with self.subTest(role=role): 235 | self.assertEqual( 236 | role.url_pattern(BookmarkView), 237 | pattern, 238 | ) 239 | 240 | def test_role_url_reversing(self): 241 | bookmark = Bookmark.objects.create( 242 | url="https://noumenal.es/", 243 | title="Noumenal • Dr Carlton Gibson", 244 | note="Carlton Gibson's homepage. Blog, Contact and Project links.", 245 | favourite=True, 246 | ) 247 | tests = [ 248 | (Role.LIST, "/bookmark/"), 249 | (Role.DETAIL, f"/bookmark/{bookmark.pk}/"), 250 | (Role.CREATE, "/bookmark/new/"), 251 | (Role.UPDATE, f"/bookmark/{bookmark.pk}/edit/"), 252 | (Role.DELETE, f"/bookmark/{bookmark.pk}/delete/"), 253 | ] 254 | for role, url in tests: 255 | with self.subTest(role=role): 256 | self.assertEqual( 257 | role.reverse(BookmarkView, bookmark), 258 | url, 259 | ) 260 | 261 | def test_routing_subset_of_roles(self): 262 | urlpatterns = BookmarkView.get_urls(roles={Role.LIST, Role.DETAIL}) 263 | self.assertEqual(len(urlpatterns), 2) 264 | 265 | def test_rendering_list_only_role(self): 266 | bookmark = Bookmark.objects.create( 267 | url="https://noumenal.es/", 268 | title="Noumenal • Dr Carlton Gibson", 269 | note="Carlton Gibson's homepage. Blog, Contact and Project links.", 270 | favourite=True, 271 | ) 272 | response = self.client.get('/bookmarklist/') 273 | self.assertEqual(response.status_code, 200) 274 | 275 | for lookup in ['View', 'Edit', 'Delete']: 276 | self.assertNotContains(response, f'>{lookup}') 277 | 278 | def test_url_ordering_for_slug_path_converters(self): 279 | # Ensures correct ordering of URL patterns when using str-based path converters 280 | # https://github.com/carltongibson/neapolitan/issues/64 281 | class BookmarkCRUDView(CRUDView): 282 | model = Bookmark 283 | path_converter = "slug" 284 | lookup_url_kwarg = "title" 285 | 286 | # Get the generated URLs 287 | urls = BookmarkCRUDView.get_urls() 288 | 289 | # Extract paths for the URLs to check ordering 290 | url_paths = [url.pattern._route for url in urls] 291 | 292 | # Expected order of URL paths 293 | expected_paths = [ 294 | 'bookmark/', # LIST 295 | 'bookmark/new/', # CREATE should come before any slug-based URLs 296 | 'bookmark//', # DETAIL 297 | 'bookmark//edit/', # UPDATE 298 | 'bookmark//delete/', # DELETE 299 | ] 300 | 301 | # Assert that the generated URL paths match the expected order 302 | self.assertEqual(url_paths, expected_paths) 303 | 304 | def test_role_equality(self): 305 | """ 306 | Role instances should be equal to themselves but not to other Role 307 | instances. 308 | 309 | Follows directly from Enum base class, but is preparatory to custom 310 | roles. 311 | """ 312 | # Basic examples: 313 | self.assertEqual(Role.LIST, Role.LIST) 314 | self.assertNotEqual(Role.LIST, Role.DETAIL) 315 | 316 | # Exhaustive check: 317 | for role in Role: 318 | self.assertEqual(role, role) 319 | for other_role in (r for r in Role if r != role): 320 | self.assertNotEqual(role, other_role) 321 | 322 | 323 | class MktemplateCommandTest(TestCase): 324 | def test_mktemplate_command(self): 325 | # Run the command 326 | call_command("mktemplate", "tests.Bookmark", "--list") 327 | 328 | # Check if the file was created 329 | file_path = "tests/templates/tests/bookmark_list.html" 330 | self.assertTrue(os.path.isfile(file_path)) 331 | 332 | # Remove the created file 333 | os.remove(file_path) 334 | --------------------------------------------------------------------------------