8 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
7c20d4d23e BS icons 2024-05-15 21:56:28 +02:00
24 changed files with 7457 additions and 36 deletions

1
.dockerignore Normal file
View File

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

1
.gitignore vendored
View File

@ -3,3 +3,4 @@
/.venv /.venv
__pycache__ __pycache__
/project/settings_local.py /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),) 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

@ -1,6 +1,21 @@
import os
import shutil
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
class MainConfig(AppConfig): class MainConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "project.main" 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 .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

@ -6,6 +6,7 @@
content="width=100%, user-scalable=yes, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> content="width=100%, user-scalable=yes, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js" integrity="sha384-BBtl+eGJRgqQAUMxJ7pMwbEyER4l1g+O15P+16Ep7Q9Q+zqX6gSbd85u4mG4QzX+" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.min.js" integrity="sha384-BBtl+eGJRgqQAUMxJ7pMwbEyER4l1g+O15P+16Ep7Q9Q+zqX6gSbd85u4mG4QzX+" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<script src="https://unpkg.com/htmx.org@1.9.11" integrity="sha384-0gxUXCCR8yv9FM2b+U3FDbsKthCI66oH5IA9fHppQq9DDMHuMauqq1ZHBpJxQ0J0" crossorigin="anonymous"></script> <script src="https://unpkg.com/htmx.org@1.9.11" integrity="sha384-0gxUXCCR8yv9FM2b+U3FDbsKthCI66oH5IA9fHppQq9DDMHuMauqq1ZHBpJxQ0J0" crossorigin="anonymous"></script>
<title>{{ title or "Django-html demo" }}</title> <title>{{ title or "Django-html demo" }}</title>
</head> </head>

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

@ -1,23 +1,15 @@
{% macro no_js_alert() %} {% macro no_js_alert() %}
<div class="alert alert-success d-flex align-items-center mb-5" role="alert"> <div class="alert alert-success d-flex align-items-center mb-5" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-success me-3" viewBox="0 0 16 16"> <i class="bi bi-check-circle-fill me-3"></i>
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg>
<div>
No JavaScript is used creating this page. No JavaScript is used creating this page.
</div> </div>
</div>
{% endmacro %} {% endmacro %}
{% macro js_alert(content) %} {% macro js_alert(content) %}
<div class="alert alert-warning d-flex align-items-center mb-5" role="alert"> <div class="alert alert-warning d-flex align-items-center mb-5" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="text-warning me-3" viewBox="0 0 16 16"> <i class="bi bi-exclamation-triangle-fill me-3"></i>
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
<div>
{{ content }} {{ content }}
</div> </div>
</div>
{% endmacro %} {% endmacro %}
{% macro js_alert_work_in_progress() %} {% macro js_alert_work_in_progress() %}

View File

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

View File

@ -1,7 +1,24 @@
{% extends "main/base/layout.html" %} {% 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 %} {% 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 %} {% 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 .form_validation import FormValidationView
from .home import HomeView from .home import HomeView
from .swap import SwapView 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 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,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 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): class TableInlineEditView(DemoViewBase):
template_name = "main/table_inline_edit.html" template_name = "main/table_inline_edit.html"
active_section = "table-inline-edit" active_section = "table-inline-edit"
title = "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/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("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", 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" 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"