CRUD for your model¶
Accelerate prototyping with basic CRUD (Create, Read, Update, Delete) python views and HTML templates, enhanced with htmx and Tailwind CSS.
CRUD¶
Usage: crud [--blueprints BLUEPRINTS] [-e EXCLUDED_FIELDS] [--only-python] [--only-html] [--entry-point] [-l] [--skip-git-check] MODEL_PATH [-h] [--completion COMPLETION]
Generate CRUD (Create, Read, Update, Delete) views for a model.
Options
[--blueprints BLUEPRINTS] The path to custom html templates that will serve as blueprints.
[-e, --exclude EXCLUDED_FIELDS] Fields to exclude from the views, forms and templates.
[--only-python] Generate only python code.
[--only-html] Generate only html code.
[--entry-point] Use the specified model as the entry point of the app.
[-l, --login-required] Add the login_required decorator to all views.
[--skip-git-check] Do not check if your git repo is clean.
Arguments
MODEL_PATH The path (<app_label>.<model_name>) of the model to generate CRUD views for. Ex: myapp.product
Help
[-h, --help] Show this message and exit.
[--completion COMPLETION] Use `--completion generate` to print shell-specific completion source. Valid options: generate, complete.
Warning
To avoid potential issues, particularly with the admin code generation, it is advised to run the install-crud-utils
command before using the crud
command. If you’ve initialized your project using the start-project
command,
you don’t need to run this as it is executed for you during project setup.
This command generates htmx-powered create, read, update, and delete views for your model. It follows a similar idea
as neapolitan, but with a completely different approach. Instead of inheriting
from a class as you would with neapolitan
, this command generates basic views
, urls
, forms
, admin
(thanks to django-extensions)
and HTML templates
, and updates or overrides the corresponding files in your project. I prefer this approach because, at the end, you’ll have all the new code directly in front of you. It’s easily
accessible and you can update it as you see fit. The idea is to accelerate project prototyping. Write a model and you instantly have views ready for it.
Why function based views?
I think class-based views get complex faster than function-based views. Both have their use cases, but function-based views stay simpler to manage longer in my experience. There is an excellent document on the topic, read django views the right way.
If you want to see an example of the generated code, check out the source code of the demo project.
Configuration¶
There are some options that you may want to set each time you generate CRUD
views for a model. For instance, most of your views might require user
login, or you might have a specific set of HTML templates that you use every time you run the command. Typing the same options repeatedly can be tedious.
For such scenarios, some of the CLI options can be configured via the pyproject.toml
file.
Here is an example illustrating all available configurations:
[tool.falco.crud]
utils-path = "apps_dir/core"
blueprints = "blueprints"
login-required = true
skip-git-check = true
always-migrate = true
Note
All options are optional.
Keys description
utils-path: This will be written by the install-crud-utils
command. Unless you are changing where the utils are installed, you don’t need to worry about this.
blueprints: If you are using custom blueprints for your html
, set the path here. It works exactly the same as the equivalent CLI option.
login-required: Always generate views that are decorated with the login_required
decorator.
skip-git-check: (Not recommended) This option is for those who like to live dangerously. It will always skip the git check.
always-migrate: This option can only be set in the pyproject.toml
file. My current workflow is to create a new app, add fields to a model and then run crud
.
I often forget to makemigrations
and migrate
. This can cause the admin
generation code to fail. With this option set, the crud
command will first try to
run makemigrations
and migrate
. If either of these operations fails, the command will stop and print the error.
Python code¶
All Python code added by this command will be in append mode, meaning it won’t override the content of your existing files.
Instead, it will add code at the end or create the files if they are missing. The files that will be modified
are forms.py
, urls.py
, admin.py
(if you have django-extension installed),
views.py
and your project root urls.py
.
For the sake brevity, I’ll only show an example of what the urls.py
file might look like for a model named Product
in a django app named products
.
from django.urls import path
from . import views
app_name = "products"
urlpatterns = [
path("products/", views.product_list, name="product_list"),
path("products/create/", views.product_create, name="product_create"),
path("products/<int:pk>/", views.product_detail, name="product_detail"),
path("products/<int:pk>/update/", views.product_update, name="product_update"),
path("products/<int:pk>/delete/", views.product_delete, name="product_delete"),
]
As you can see, the convention is quite simple: <model_name_lower>_<operation>
. Note that if you don’t specify the model name and run
falco crud products
, the same code with the described conventions will be generated for all the models in the products
app.
Now, if you’re anything like me, the code above might have made you cringe due to the excessive repetitions of the word product
.
This wouldn’t have been the case if the model was called Category
, for example. For these specific cases, there is an --entry-point
option.
Let’s try it.
falco crud product.products --entry-point
from django.urls import path
from . import views
app_name = "products"
urlpatterns = [
path("", views.index, name="index"),
path("create/", views.create, name="create"),
path("<int:pk>/", views.detail, name="detail"),
path("<int:pk>/update/", views.update, name="update"),
path("<int:pk>/delete/", views.delete, name="delete"),
]
Much cleaner, specifying that option means you consider the Product
model as the entry point of your products
app.
So, instead of the base URL of the app looking like products/products/
, it will just be products/
.
As previously mentioned, the command will also register your app in your project root URLs configuration. This occurs when
you generate crud
views for a model and there is no existing urls.py
file for the app. In such cases, it is assumed
that you haven’t already registered the URLs for your app since the command just created the file.
Here is an example of how the products
app will be registered.
1urlpatterns = [
2path("admin/", admin.site.urls),
3...
4path("products/", include("products.urls", namespace="products"))
5]
HTML templates¶
Unlike the Python code, the generated HTML templates will overwrite any existing ones. If you want to avoid this, you should commit
your changes before running this command or use the --only-python
option to generate only Python code. The files are generated
with minimal styling (using Tailwind CSS) and are reasonably presentable.
Four files are generated:
<model_name_lower>_list.html
<model_name_lower>_create.html
<model_name_lower>_detail.html
<model_name_lower>_update.html
There is no <model_name_lower>_delete.html
file because deletion is handled in the <model_name_lower>_list.html
.
Each generated HTML file extends a base.html
template. Therefore, make sure you have a top-level base.html
file in
your templates directory.
Note
If you use the --entry-point
option, the files will be named index.html
, create.html
, detail.html
, and update.html
.
To determine where to place the generated files, we check the DIRS
key in the TEMPLATES
settings of your Django project.
If it is populated, we take the first value in the list and generate the template files in <templates_dir>/<app_label>
.
If it is not populated, we use the classic Django layout, which is <app_label>/templates/<app_label>
. If you want an overview
of what the templates look like, check out the demo project.
Custom Templates¶
The crud
command supports the ability to specify your own HTML templates using the --blueprints
option.
This option only takes into account HTML files and will completely override the default templates. The HTML templates
use the jinja2 syntax. To see examples of what the templates look like,
check out the base templates here.
Below is an example of the context each template will receive.
from falco.commands.crud.model_crud import HtmlBlueprintContext
from falco.commands.crud.model_crud import get_html_blueprint_context
from falco.commands.crud.model_crud import DjangoModel
from pprint import pprint
dj_model = DjangoModel(
name = "Product",
name_plural = "Products",
verbose_name = "Product",
verbose_name_plural = "Products",
has_file_field = False,
has_editable_date_field = False,
fields = {
"name": {"verbose_name": "Name", "editable": True, "class_name": "CharField", "accessor": "{{product.name}}"},
"price": {"verbose_name": "Price", "editable": True, "class_name": "DecimalField", "accessor": "{{product.price}}"},
}
)
pprint(get_html_blueprint_context(app_label="products", django_model=dj_model), sort_dicts=False, compact=True, width=120)
{'app_label': 'products',
'model_name': 'Product',
'model_name_plural': 'Products',
'model_verbose_name': 'Product',
'model_verbose_name_plural': 'Products',
'model_has_file_fields': False,
'model_fields': {'name': {'verbose_name': 'Name',
'editable': True,
'class_name': 'CharField',
'accessor': '{{product.name}}'},
'price': {'verbose_name': 'Price',
'editable': True,
'class_name': 'DecimalField',
'accessor': '{{product.price}}'}},
'list_view_url': "{% url 'products:product_list' %}",
'create_view_url': "{% url 'products:product_create' %}",
'detail_view_url': "{% url 'products:product_detail' product.pk %}",
'update_view_url': "{% url 'products:product_update' product.pk %}",
'delete_view_url': "{% url 'products:product_delete' product.pk %}"}
Examples¶
Some usage examples.
$ falco crud products.product
$ falco crud products
$ falco crud products.product -e="secret_field1" -e="secret_field2"
$ falco crud products.product --only-html
$ falco crud products.product --only-python
$ falco crud products.product --entry-point
$ falco crud products.product --entry-point --login
$ falco crud products.product --blueprints /path/to/blueprints
install-crud-utils¶
Usage: install-crud-utils [OUTPUT_DIR] [-h] [--completion COMPLETION]
Install utils necessary for CRUD views.
Arguments
[OUTPUT_DIR] The folder in which to install the crud utils.
Help
[-h, --help] Show this message and exit.
[--completion COMPLETION] Use `--completion generate` to print shell-specific completion source. Valid options: generate, complete.
These utilities may be imported by some parts of the code generated by the crud
command. They are not installed simultaneously
with the crud
command because you only need to run this once. Running this for each execution of the crud
command would not
make sense. However, you can run this to update your code if the utilities have changed in falco. Like all crud
related Python code,
the code is written in append mode, meaning it always adds to the end of the file if it already exists, and creates it if not.
Determining the Destination for the File
The command accepts an optional output_dir
argument. If not supplied, the command defaults to using the current directory’s name, assuming a
subdirectory with an identical name exists. It considers this subdirectory as your apps directory and installs the utils in a core
package within it.
For example, if your django project root directory is named my_project
, the command will assume that there’s a my_project
subdirectory within the
current my_project
directory. This is the default layout for projects generated by the start-project command.
The utilities will be installed in my_project/my_project/core
. The command also add the output_dir path to your pyproject.toml
to
be able to reinstall at the same exact path without you having to retype the output for the next times.
The command also records the output_dir
path in your pyproject.toml
, enabling you to reinstall the utilities at the exact same location
in future runs without needing to re-enter the output path.
If you decide to use the output
argument to change the file’s destination path (which defaults to core/utils.py
), you may need to adjust
some imports after executing the crud
command.
Note
If you’re using the default Django project structure, it’s likely that your apps are located in the root directory of your project.
In this case, you can run the command falco install-crud-utils core
to install the utils in a core
package in the current directory.
Here is an example of the output of the install-crud-utils
command.
from functools import wraps
from django.core.paginator import InvalidPage
from django.core.paginator import Paginator
from django.db.models import QuerySet
from django.http import Http404
from django.http import HttpResponse
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
from .types import HttpRequest
def paginate_queryset(request: HttpRequest, queryset: QuerySet, page_size: int = 10):
paginator = Paginator(queryset, page_size)
page_number = request.GET.get("page") or 1
try:
page_number = int(page_number)
except ValueError as e:
if page_number == "last":
page_number = paginator.num_pages
else:
msg = "Page is not 'last', nor can it be converted to an int."
raise Http404(_(msg)) from e
try:
return paginator.page(page_number)
except InvalidPage as exc:
msg = "Invalid page (%s): %s"
raise Http404(_(msg) % (page_number, str(exc))) from exc
def for_htmx(
*,
if_hx_target: str | None = None,
use_template: str | None = None,
use_partial: str | list[str] | None = None,
use_partial_from_params: bool = False,
):
"""
Adapted from https://github.com/spookylukey/django-htmx-patterns/blob/master/code/htmx_patterns/utils.py
If the request is from htmx, then render a partial page, using either:
- the template specified in `use_template` param
- the partial/partials specified in `use_partial` param
- the partial/partials specified in GET/POST parameter "use_partial", if `use_partial_from_params=True` is passed
If the optional `if_hx_target` parameter is supplied, the
hx-target header must match the supplied value as well in order
for this decorator to be applied.
"""
if len([p for p in [use_partial, use_template, use_partial_from_params] if p]) != 1:
raise ValueError("You must pass exactly one of 'use_template', 'use_partial' or 'use_partial_from_params=True'")
def decorator(view):
@wraps(view)
def _view(request: HttpRequest, *args, **kwargs):
resp = view(request, *args, **kwargs)
if not request.htmx:
return resp
apply_decorator = if_hx_target is None or request.headers.get("Hx-Target", None) == if_hx_target
if not apply_decorator:
return resp
partials_to_use = use_partial
if not hasattr(resp, "render"):
if not resp.content and any(
h in resp.headers
for h in (
"Hx-Trigger",
"Hx-Trigger-After-Swap",
"Hx-Trigger-After-Settle",
"Hx-Redirect",
)
):
# This is a special case response, it doesn't need modifying:
return resp
raise ValueError("Cannot modify a response that isn't a TemplateResponse")
if resp.is_rendered:
raise ValueError("Cannot modify a response that has already been rendered")
if use_partial_from_params:
use_partial_from_params_val = _get_param_from_request(request, "use_partial")
if use_partial_from_params_val is not None:
partials_to_use = use_partial_from_params_val
if use_template is not None:
resp.template_name = use_template
elif partials_to_use is not None:
if not isinstance(partials_to_use, list):
partials_to_use = [partials_to_use]
rendered_partials = [
render_to_string(f"{resp.template_name}#{b}", context=resp.context_data, request=request)
for b in partials_to_use
]
# Create new simple HttpResponse as replacement
resp = HttpResponse(
content="".join(rendered_partials),
status=resp.status_code,
headers=resp.headers,
)
return resp
return _view
return decorator
def _get_param_from_request(request, param):
"""
Checks GET then POST params for specified param
"""
if param in request.GET:
return request.GET.getlist(param)
if request.method == "POST" and param in request.POST:
return request.POST.getlist(param)
return None
from demo.users.models import User
from django.http import HttpRequest as HttpRequestBase
from django_htmx.middleware import HtmxDetails
class HttpRequest(HttpRequestBase):
htmx: HtmxDetails
class AuthenticatedHttpRequest(HttpRequest):
user: User