5 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
15 changed files with 7289 additions and 74 deletions

1
.dockerignore Normal file
View File

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

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

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,10 +1,26 @@
from django.core.validators import MaxLengthValidator, MinLengthValidator
from django.db import models from django.db import models
class Person(models.Model): class Person(models.Model):
name = models.CharField(max_length=100) name = models.CharField(
address = models.CharField(max_length=100) max_length=100,
city = 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: class Meta:
db_table = "persons" db_table = "persons"

View File

@ -1,46 +1,78 @@
{% macro inline_table_row(person, is_editing) %} {% macro inline_table_row(person) %}
{% if is_editing %} <tr hx-target="this" hx-swap="outerHTML">
<tr id="person-row-{{ person.pk }}" hx-target="this" hx-swap="outerHTML"> <td>{{ person.name }}</td>
<td> <td>{{ person.address }}</td>
<input class="form-control" name="name" value="{{ person.name }}"> <td>{{ person.city }}</td>
</td> <td>
<td> <button
<input class="form-control" name="address" value="{{ person.address }}"> class="btn btn-outline-primary"
</td> hx-get="{{ url("table-inline-edit-row", pk=person.pk) }}"
<td>
<input class="form-control" name="city" value="{{ person.city }}">
</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"
>
<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>
{% else %}
<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> <i class="bi bi-pencil-square"></i>
</button> </button>
</td> </td>
</tr> </tr>
{% endif %} {% 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 %} {% endmacro %}

View File

@ -17,7 +17,7 @@
</thead> </thead>
<tbody> <tbody>
{% for person in persons %} {% for person in persons %}
{{ inline_table_row(person, is_editing=False) }} {{ inline_table_row(person) }}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

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

View File

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

View File

@ -1,12 +1,22 @@
from typing import Any from typing import Any, Optional
from django.core.exceptions import ValidationError
from django.core.handlers.wsgi import WSGIRequest from django.core.handlers.wsgi import WSGIRequest
from django.http import Http404, HttpRequest, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from project.main.models import Person from project.main.models import Person
from project.main.views.demo_view_base import DemoViewBase from project.main.views.demo_view_base import DemoViewBase
CITIES: list[str] = [
"",
"Zagreb",
"Split",
"Pula",
"Rijeka",
"Kozari bok",
]
def get_person(pk: int) -> Person: def get_person(pk: int) -> Person:
try: try:
@ -45,22 +55,33 @@ class TableInlineEditRowView(DemoViewBase):
context_data.update( context_data.update(
{ {
"person": person, "person": person,
"cities": CITIES,
"is_editing": action == "edit", "is_editing": action == "edit",
} }
) )
return context_data return context_data
def post(self, request: WSGIRequest, *args, **kwargs) -> HttpResponse: def post(self, request: WSGIRequest, *args, **kwargs) -> HttpResponse:
errors: Optional[dict[str, str]] = None
person = get_person(pk=kwargs.get("pk")) person = get_person(pk=kwargs.get("pk"))
person.name = request.POST.get("name") person.name = request.POST.get("name")
person.address = request.POST.get("address") person.address = request.POST.get("address")
person.city = request.POST.get("city") person.city = request.POST.get("city")
person.save()
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( return render(
context={ context={
"person": person, "person": person,
"is_editing": False, "errors": errors,
"cities": CITIES,
"is_editing": errors is not None,
}, },
template_name=self.template_name, template_name=self.template_name,
request=request, request=request,

View File

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