7 Commits

Author SHA1 Message Date
8aa8eda6ce Tweaks 2024-05-23 06:54:48 +02:00
2d56d06649 Dockerize 2024-05-18 19:19:16 +02:00
382e514d03 City as select 2024-05-16 21:40:28 +02:00
fc6b3a7fa0 Validation 2024-05-16 07:55:27 +02:00
2fc2d07a7d Persons constraint 2024-05-16 07:35:01 +02:00
178377cfb6 Finished 2024-05-15 22:56:30 +02:00
af61d45fa4 Basic table inline edit functionality 2024-05-15 22:16:59 +02:00
22 changed files with 7452 additions and 24 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
**/*.pyc

1
.gitignore vendored
View File

@ -3,3 +3,4 @@
/.venv
__pycache__
/project/settings_local.py
/db.sqlite3

51
Dockerfile Normal file
View File

@ -0,0 +1,51 @@
FROM python:3.11-slim-bookworm AS env-builder
WORKDIR /app
COPY pyproject.toml .
COPY poetry.lock .
# create virtual environment
RUN python -m venv /venv
# set python thingies, set environment variables and activate virtual environment
ENV \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/venv/bin:$PATH"
RUN \
pip install poetry && \
# dump python dependencies into requirements file
poetry export --without-hashes --format=requirements.txt > requirements.txt && \
# install python libs
pip install -r requirements.txt --no-cache-dir --prefer-binary --no-deps --no-compile
FROM python:3.11-slim-bookworm
WORKDIR /app
COPY --from=env-builder /venv /venv
# set python thingies and activate virtual environment
ENV \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/venv/bin:$PATH"
COPY manage.py .
COPY pyproject.toml .
COPY poetry.lock .
COPY db.template.sqlite3 db.sqlite3
COPY ./project ./project
# run as user www-data
USER 33
ENTRYPOINT [ "/venv/bin/gunicorn" ]
CMD [ \
"--bind", "0.0.0.0:8000", \
"--workers", "4", \
"project.wsgi" \
]

View File

@ -1,3 +1,7 @@
CONTAINER_NAME=django-htmx
IMAGE_NAME=django-htmx
ifeq ($(VIRTUAL_ENV),)
RUN_IN_ENV=poetry run
else
@ -19,3 +23,20 @@ migrations:
migrate:
@ $(RUN_IN_ENV) python manage.py migrate
docker-build:
- @docker image rm $(IMAGE_NAME) --force
@docker \
build . \
-t $(IMAGE_NAME)
@docker \
build . \
-t $(IMAGE_NAME)
docker-run:
@docker run \
--publish 8000:8000 \
--name $(CONTAINER_NAME) \
$(IMAGE_NAME)

View File

@ -1 +1,36 @@
# Django-htmx demo
## Run demo
### As docker container
```
make docker-build
make docker-run
```
Browse to [localhost:8000](http://localhost:8000).
Later, start and stop docker container using:
```
docker start django-htmx
```
and
```
docker stop django-htmx
```
### As standard local Django app
- requirements: Python 3.10 or 3.11
- [poetry](https://python-poetry.org)
```
poetry install
make run
```
Browse to [localhost:8000](http://localhost:8000).

Binary file not shown.

File diff suppressed because it is too large Load Diff

27
poetry.lock generated
View File

@ -19,13 +19,13 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
[[package]]
name = "django"
version = "5.0.4"
version = "5.0.6"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
optional = false
python-versions = ">=3.10"
files = [
{file = "Django-5.0.4-py3-none-any.whl", hash = "sha256:916423499d75d62da7aa038d19aef23d23498d8df229775eb0a6309ee1013775"},
{file = "Django-5.0.4.tar.gz", hash = "sha256:4bd01a8c830bb77a8a3b0e7d8b25b887e536ad17a81ba2dce5476135c73312bd"},
{file = "Django-5.0.6-py3-none-any.whl", hash = "sha256:8363ac062bb4ef7c3f12d078f6fa5d154031d129a15170a1066412af49d30905"},
{file = "Django-5.0.6.tar.gz", hash = "sha256:ff1b61005004e476e0aeea47c7f79b85864c70124030e95146315396f1e7951f"},
]
[package.dependencies]
@ -74,13 +74,13 @@ tornado = ["tornado (>=0.2)"]
[[package]]
name = "jinja2"
version = "3.1.3"
version = "3.1.4"
description = "A very fast and expressive template engine."
optional = false
python-versions = ">=3.7"
files = [
{file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"},
{file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"},
{file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"},
{file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"},
]
[package.dependencies]
@ -171,19 +171,18 @@ files = [
[[package]]
name = "sqlparse"
version = "0.4.4"
version = "0.5.0"
description = "A non-validating SQL parser."
optional = false
python-versions = ">=3.5"
python-versions = ">=3.8"
files = [
{file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"},
{file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"},
{file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"},
{file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"},
]
[package.extras]
dev = ["build", "flake8"]
dev = ["build", "hatch"]
doc = ["sphinx"]
test = ["pytest", "pytest-cov"]
[[package]]
name = "typing-extensions"
@ -209,5 +208,5 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "6c86d5721314c92afa919983780e664a1573ebb7c25a8b54622393b97990e509"
python-versions = ">= 3.10, < 3.12"
content-hash = "26984438b0e835c052014186db2a76cbe044e19fd5b56ba3fc3d642b2ed530dc"

View File

@ -1,6 +1,21 @@
import os
import shutil
from django.apps import AppConfig
from django.conf import settings
class MainConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "project.main"
def ready(self):
"""copy template database if db not exists"""
db_fname = settings.DATABASES["default"]["NAME"]
template_db_fname = settings.BASE_DIR / "db.template.sqlite3"
if os.path.exists(db_fname):
return
shutil.copyfile(template_db_fname, db_fname)

View File

@ -0,0 +1,60 @@
from django.db import migrations, models
def create_persons_data(apps, schema_editor):
Person = apps.get_model("main", "Person")
bulk = [
Person(
name="Pero",
address="Perina ulica 15",
city="Zagreb",
),
Person(
name="Mirko",
address="Mirkova ulica 17",
city="Zagreb",
),
Person(
name="Šime",
address="Šimina ulica 19",
city="Split",
),
Person(
name="Furio",
address="Furiozna ulica 21",
city="Pula",
),
]
Person.objects.bulk_create(bulk)
class Migration(migrations.Migration):
dependencies = [
("main", "0002_example_data"),
]
operations = [
migrations.CreateModel(
name="Person",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("address", models.CharField(max_length=100)),
("city", models.CharField(max_length=100)),
],
options={
"db_table": "persons",
},
),
migrations.RunPython(create_persons_data),
]

View File

@ -0,0 +1,46 @@
# Generated by Django 5.0.4 on 2024-05-16 05:33
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("main", "0003_person"),
]
operations = [
migrations.AlterField(
model_name="person",
name="address",
field=models.CharField(
max_length=100,
validators=[
django.core.validators.MinLengthValidator(2),
django.core.validators.MaxLengthValidator(100),
],
),
),
migrations.AlterField(
model_name="person",
name="city",
field=models.CharField(
max_length=100,
validators=[
django.core.validators.MinLengthValidator(2),
django.core.validators.MaxLengthValidator(100),
],
),
),
migrations.AlterField(
model_name="person",
name="name",
field=models.CharField(
max_length=100,
validators=[
django.core.validators.MinLengthValidator(2),
django.core.validators.MaxLengthValidator(100),
],
),
),
]

View File

@ -1 +1,2 @@
from .cat_breed import CatBreed
from .person import Person

View File

@ -0,0 +1,26 @@
from django.core.validators import MaxLengthValidator, MinLengthValidator
from django.db import models
class Person(models.Model):
name = models.CharField(
max_length=100,
blank=False,
null=False,
validators=[MinLengthValidator(2), MaxLengthValidator(100)],
)
address = models.CharField(
max_length=100,
blank=False,
null=False,
validators=[MinLengthValidator(2), MaxLengthValidator(100)],
)
city = models.CharField(
max_length=100,
blank=False,
null=False,
validators=[MinLengthValidator(2), MaxLengthValidator(100)],
)
class Meta:
db_table = "persons"

View File

@ -0,0 +1,78 @@
{% macro inline_table_row(person) %}
<tr hx-target="this" hx-swap="outerHTML">
<td>{{ person.name }}</td>
<td>{{ person.address }}</td>
<td>{{ person.city }}</td>
<td>
<button
class="btn btn-outline-primary"
hx-get="{{ url("table-inline-edit-row", pk=person.pk) }}"
>
<i class="bi bi-pencil-square"></i>
</button>
</td>
</tr>
{% endmacro %}
{% macro inline_table_row_edit(person, cities, errors={}) %}
{% macro render_input(field_name, value) %}
{% set has_error = field_name in errors %}
<input
class="form-control {% if has_error %}is-invalid{% endif %}"
name="{{ field_name }}"
value="{{ value }}"
{% if has_error %}title="{{ errors[field_name] }}"{% endif %}
>
{% endmacro %}
{% macro render_select(field_name, value, options) %}
{% set has_error = field_name in errors %}
<select
class="form-select {% if has_error %}is-invalid{% endif %}"
name="{{ field_name }}"
{% if has_error %}title="{{ errors[field_name] }}"{% endif %}
>
{% for option in options %}
{% set selected = value == option %}
<option value="{{ option }}" {% if selected %}selected{% endif %}>
{{ option }}
</option>
{% endfor %}
</select>
{% endmacro %}
<tr
id="person-row-{{ person.pk }}"
hx-target="this"
hx-swap="outerHTML"
>
<td>
{{ render_input(field_name="name", value=person.name) }}
</td>
<td>
{{ render_input(field_name="address", value=person.address) }}
</td>
<td>
{# {{ render_input(field_name="city", value=person.city) }}#}
{{ render_select(field_name="city", value=person.city, options=cities) }}
</td>
<td>
<button
class="btn btn-outline-success"
hx-post="{{ url("table-inline-edit-row", pk=person.pk) }}"
hx-include="#person-row-{{ person.pk }} input, #person-row-{{ person.pk }} select"
>
<i class="bi bi-check-circle-fill"></i>
</button>
<button
class="btn btn-outline-danger"
hx-get="{{ url("table-inline-edit-row", pk=person.pk) }}"
hx-vals='{"action": "cancel"}'
>
<i class="bi bi-x-circle-fill"></i>
</button>
</td>
</tr>
{% endmacro %}

View File

@ -50,6 +50,5 @@
</div>
</div>
{% include "main/filter_list_content.html" %}
{% endblock %}

View File

@ -1,7 +1,24 @@
{% extends "main/base/layout.html" %}
{% from "main/components/js_alert.html" import js_alert_work_in_progress %}
{% from "main/components/js_alert.html" import no_js_alert %}
{% from "main/components/inline_table_row.html" import inline_table_row %}
{% block content %}
{{ js_alert_work_in_progress() }}
{{ no_js_alert() }}
<table class="table align-middle">
<thead>
<tr>
<th style="width: 25%">Name</th>
<th>Address</th>
<th style="width: 25%">City</th>
<th style="width: 15%">&nbsp;</th>
</tr>
</thead>
<tbody>
{% for person in persons %}
{{ inline_table_row(person) }}
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -0,0 +1,7 @@
{% from "main/components/inline_table_row.html" import inline_table_row, inline_table_row_edit %}
{% if is_editing %}
{{ inline_table_row_edit(person, cities=cities, errors=errors) }}
{% else %}
{{ inline_table_row(person) }}
{% endif %}

View File

@ -8,4 +8,4 @@ from .filter_list import FilterListFilterView, FilterListView
from .form_validation import FormValidationView
from .home import HomeView
from .swap import SwapView
from .table_inline_edit import TableInlineEditView
from .table_inline_edit import TableInlineEditView, TableInlineEditRowView

View File

@ -1,4 +1,4 @@
from typing import Any, Optional
from typing import Any, Optional, Iterator
from django.db.models import Count, Q
@ -6,18 +6,19 @@ from project.main.models import CatBreed
from project.main.views.demo_view_base import DemoViewBase
def get_countries() -> list[str]:
def get_countries() -> Iterator[str]:
ann = (
CatBreed.objects.values("country")
.annotate(Count("country"))
.order_by("country")
)
return [a["country"] for a in ann]
for a in ann:
yield a["country"]
def filter_cat_breeds(
breed_filter: Optional[str] = None, country_filter: Optional[str] = None
) -> list[CatBreed]:
) -> Iterator[CatBreed]:
q = Q()
if breed_filter:
@ -25,7 +26,8 @@ def filter_cat_breeds(
if country_filter:
q &= Q(country=country_filter)
return CatBreed.objects.filter(q).order_by("name")
for c in CatBreed.objects.filter(q).order_by("name"):
yield c
class FilterListView(DemoViewBase):

View File

@ -1,7 +1,88 @@
from typing import Any, Optional
from django.core.exceptions import ValidationError
from django.core.handlers.wsgi import WSGIRequest
from django.http import Http404, HttpResponse
from django.shortcuts import render
from project.main.models import Person
from project.main.views.demo_view_base import DemoViewBase
CITIES: list[str] = [
"",
"Zagreb",
"Split",
"Pula",
"Rijeka",
"Kozari bok",
]
def get_person(pk: int) -> Person:
try:
return Person.objects.get(pk=pk)
except Person.DoesNotExist:
raise Http404("Person not found")
class TableInlineEditView(DemoViewBase):
template_name = "main/table_inline_edit.html"
active_section = "table-inline-edit"
title = "Table Inline Edit"
def get_context_data(self, **kwargs) -> dict[str, Any]:
context_data = super().get_context_data(**kwargs)
persons = Person.objects.all()
context_data.update(
{
"persons": persons,
}
)
return context_data
class TableInlineEditRowView(DemoViewBase):
template_name = "main/table_inline_table_row.html"
def get_context_data(self, **kwargs) -> dict[str, Any]:
context_data = super().get_context_data(**kwargs)
person = get_person(pk=kwargs.get("pk"))
action = self.request.GET.get("action", "edit")
context_data.update(
{
"person": person,
"cities": CITIES,
"is_editing": action == "edit",
}
)
return context_data
def post(self, request: WSGIRequest, *args, **kwargs) -> HttpResponse:
errors: Optional[dict[str, str]] = None
person = get_person(pk=kwargs.get("pk"))
person.name = request.POST.get("name")
person.address = request.POST.get("address")
person.city = request.POST.get("city")
try:
person.clean_fields()
except ValidationError as ex:
errors = {key: value[0].message for key, value in ex.error_dict.items()}
else:
person.save()
return render(
context={
"person": person,
"errors": errors,
"cities": CITIES,
"is_editing": errors is not None,
},
template_name=self.template_name,
request=request,
)

View File

@ -13,4 +13,5 @@ urlpatterns = [
path("complex-form/handle/reports", views.ReportsHandleView.as_view(), name="complex-form-handle-reports"),
path("complex-form/handle/warehouse-management", views.WarehouseManagementHandleView.as_view(), name="complex-form-handle-warehouse-management"),
path("table-inline-edit", views.TableInlineEditView.as_view(), name="table-inline-edit"),
path("table-inline-edit/edit/<int:pk>", views.TableInlineEditRowView.as_view(), name="table-inline-edit-row"),
]

View File

@ -6,7 +6,7 @@ authors = ["Eden Kirin <eden@ekirin.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.10"
python = ">= 3.10, < 3.12"
django = "^5.0.4"
django-jinja = "^2.11.0"
gunicorn = "^21.2.0"