Add simpletodo htmx example app

This commit is contained in:
Daniel Egger 2022-02-05 14:57:54 +01:00
parent 15bea892e4
commit db291b50c0
24 changed files with 481 additions and 151 deletions

View File

@ -82,6 +82,7 @@ THIRD_PARTY_APPS = [
"rest_framework.authtoken", "rest_framework.authtoken",
"corsheaders", "corsheaders",
"drf_spectacular", "drf_spectacular",
"django_htmx",
] ]
LOCAL_APPS = [ LOCAL_APPS = [
@ -145,6 +146,7 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.common.BrokenLinkEmailsMiddleware", "django.middleware.common.BrokenLinkEmailsMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"vbv_lernwelt.core.middleware.auth.AuthenticationRequiredMiddleware", "vbv_lernwelt.core.middleware.auth.AuthenticationRequiredMiddleware",
"vbv_lernwelt.core.middleware.security.SecurityRequestResponseLoggingMiddleware", "vbv_lernwelt.core.middleware.security.SecurityRequestResponseLoggingMiddleware",
] ]

View File

@ -11,26 +11,15 @@ from rest_framework.authtoken.views import obtain_auth_token
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
# fmt: off
urlpatterns = [ urlpatterns = [
path( path("", django_view_authentication_exempt(TemplateView.as_view(template_name="pages/home.html")), name="home"),
"", path("about/", TemplateView.as_view(template_name="pages/about.html"), name="about"),
django_view_authentication_exempt(
TemplateView.as_view(template_name="pages/home.html")
),
name="home",
),
path(
"about/", TemplateView.as_view(template_name="pages/about.html"), name="about"
),
# Django Admin, use {% url 'admin:index' %} # Django Admin, use {% url 'admin:index' %}
path(settings.ADMIN_URL, admin.site.urls), path(settings.ADMIN_URL, admin.site.urls),
# Your stuff: custom urls includes go here # Your stuff: custom urls includes go here
path( path("login/", django_view_authentication_exempt(auth_views.LoginView.as_view(template_name="core/login.html"))),
"login/", path("todo/", include("vbv_lernwelt.simpletodo.urls")),
django_view_authentication_exempt(
auth_views.LoginView.as_view(template_name="core/login.html")
),
),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.DEBUG: if settings.DEBUG:
# Static file serving when using Gunicorn + Uvicorn for local web socket development # Static file serving when using Gunicorn + Uvicorn for local web socket development
@ -43,12 +32,9 @@ urlpatterns += [
# DRF auth token # DRF auth token
path("auth-token/", obtain_auth_token), path("auth-token/", obtain_auth_token),
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
path( path("api/docs/", SpectacularSwaggerView.as_view(url_name="api-schema"), name="api-docs",),
"api/docs/",
SpectacularSwaggerView.as_view(url_name="api-schema"),
name="api-docs",
),
] ]
# fmt: on
if settings.DEBUG: if settings.DEBUG:
# This allows the error pages to be debugged during development, just visit # This allows the error pages to be debugged during development, just visit

View File

@ -1,3 +1,3 @@
export VBV_DATABASE_URL='postgres://vbv_lernwelt@localhost:5432/vbv_lernwelt' export VBV_DATABASE_URL='postgres://vbv_lernwelt@localhost:5432/vbv_lernwelt'
#export VBV_DJANGO_LOGGING_CONF=VBV_DJANGO_LOGGING_CONF_CONSOLE_COLOR export VBV_DJANGO_LOGGING_CONF=VBV_DJANGO_LOGGING_CONF_CONSOLE_COLOR
export VBV_DJANGO_DEBUG=True export VBV_DJANGO_DEBUG=True

View File

@ -69,6 +69,7 @@ django==3.2.12
# django-cors-headers # django-cors-headers
# django-debug-toolbar # django-debug-toolbar
# django-extensions # django-extensions
# django-htmx
# django-model-utils # django-model-utils
# django-redis # django-redis
# django-stubs # django-stubs
@ -83,6 +84,8 @@ django-debug-toolbar==3.2.4
# via -r requirements-dev.in # via -r requirements-dev.in
django-extensions==3.1.5 django-extensions==3.1.5
# via -r requirements-dev.in # via -r requirements-dev.in
django-htmx==1.8.0
# via -r requirements.in
django-model-utils==4.2.0 django-model-utils==4.2.0
# via -r requirements.in # via -r requirements.in
django-redis==5.2.0 django-redis==5.2.0

View File

@ -19,6 +19,7 @@ djangorestframework # https://github.com/encode/django-rest-framework
django-cors-headers # https://github.com/adamchainz/django-cors-headers django-cors-headers # https://github.com/adamchainz/django-cors-headers
# DRF-spectacular for api documentation # DRF-spectacular for api documentation
drf-spectacular drf-spectacular
django-htmx
psycopg2-binary psycopg2-binary
gunicorn gunicorn

View File

@ -30,12 +30,15 @@ django==3.2.12
# via # via
# -r requirements.in # -r requirements.in
# django-cors-headers # django-cors-headers
# django-htmx
# django-model-utils # django-model-utils
# django-redis # django-redis
# djangorestframework # djangorestframework
# drf-spectacular # drf-spectacular
django-cors-headers==3.11.0 django-cors-headers==3.11.0
# via -r requirements.in # via -r requirements.in
django-htmx==1.8.0
# via -r requirements.in
django-model-utils==4.2.0 django-model-utils==4.2.0
# via -r requirements.in # via -r requirements.in
django-redis==5.2.0 django-redis==5.2.0

View File

@ -3,7 +3,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
class SimpleUserFactory(factory.django.DjangoModelFactory): class UserFactory(factory.django.DjangoModelFactory):
class Meta: class Meta:
model = get_user_model() model = get_user_model()
django_get_or_create = ("username",) django_get_or_create = ("username",)

View File

@ -1,3 +1,39 @@
# Register your models here.
from django.contrib import admin from django.contrib import admin
# Register your models here. from .models import SimpleTask, SimpleList
@admin.register(SimpleList)
class SimpleListAdmin(admin.ModelAdmin):
list_display = [
"title",
"user",
"created",
]
list_filter = [
"user",
]
search_fields = [
"title",
]
@admin.register(SimpleTask)
class SimpleTaskAdmin(admin.ModelAdmin):
date_hierarchy = "deadline"
list_display = [
"title",
"deadline",
"created",
"list",
"done",
]
list_filter = [
"list",
"done",
]
search_fields = [
"title",
"text",
]

View File

@ -2,5 +2,5 @@ from django.apps import AppConfig
class SimpletodoConfig(AppConfig): class SimpletodoConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = "django.db.models.BigAutoField"
name = 'vbv_lernwelt.simpletodo' name = "vbv_lernwelt.simpletodo"

View File

@ -18,32 +18,88 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='SimpleList', name="SimpleList",
fields=[ fields=[
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), (
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), "created",
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), model_utils.fields.AutoCreatedField(
('title', models.CharField(max_length=255)), default=django.utils.timezone.now,
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), editable=False,
verbose_name="created",
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("title", models.CharField(max_length=255)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='SimpleTask', name="SimpleTask",
fields=[ fields=[
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), (
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), "created",
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), model_utils.fields.AutoCreatedField(
('title', models.CharField(max_length=255)), default=django.utils.timezone.now,
('text', models.TextField(blank=True, default='')), editable=False,
('done', models.BooleanField(default=False)), verbose_name="created",
('deadline', models.DateTimeField(blank=True, null=True)), ),
('list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='simpletodo.simplelist')), ),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now,
editable=False,
verbose_name="modified",
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("title", models.CharField(max_length=255)),
("text", models.TextField(blank=True, default="")),
("done", models.BooleanField(default=False)),
("deadline", models.DateTimeField(blank=True, null=True)),
(
"list",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="simpletodo.simplelist",
),
),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
), ),
] ]

View File

@ -10,6 +10,9 @@ class SimpleList(TimeStampedModel):
title = models.CharField(max_length=255) title = models.CharField(max_length=255)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
def __str__(self):
return f"{self.title} ({self.user})"
class SimpleTask(TimeStampedModel): class SimpleTask(TimeStampedModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

View File

@ -0,0 +1,23 @@
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer
from vbv_lernwelt.simpletodo.models import SimpleTask, SimpleList
class SimpleTaskSerializer(ModelSerializer):
list_title = serializers.CharField(max_length=100)
class Meta:
model = SimpleTask
fields = ['id', 'title', 'text', 'done', 'deadline', 'list_title', ]
def create(self, validated_data):
user = validated_data.pop('user', None)
if user is None:
raise serializers.ValidationError('User is required')
list_title = validated_data.pop('list_title')
simple_list, _ = SimpleList.objects.get_or_create(title=list_title, user=user)
validated_data['list'] = simple_list
return super().create(validated_data)

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<div class="container mx-auto">
<h1>Hello Todos</h1>
{% for list in simple_lists %}
{% include "simpletodo/partials/simple_list.html" with list=list%}
{% endfor %}
</div>
{% endblock %}

View File

@ -0,0 +1,21 @@
<form class="flex mt-4" action="/todo/api/tasks/" method="POST">
{% csrf_token %}
<input class="shadow appearance-none border rounded w-full py-2 px-3 mr-4 text-gray-darker"
type="text"
name="title"
maxlength="100"
required
placeholder="Add Todo"
>
<input type="hidden" name="list_title" value="{{ list.title }}">
<input
type="submit"
value="Add"
hx-post="/todo/api/tasks/"
hx-trigger="submit"
hx-target="#parent-div"
hx-swap="outerHTML"
class="flex-no-shrink p-2 border-2 rounded text-blue-500 border-blue-500 hover:text-white hover:bg-blue-500"
>
</form>

View File

@ -0,0 +1,13 @@
<div class="h-100 w-full flex items-center justify-center bg-blue-100 font-sans">
<div class="bg-white rounded shadow p-6 m-4 w-full lg:w-3/5 md:w-3/4">
<div class="mb-4">
<h2 class="text-gray-darkest">{{ list.title }}</h2>
{% include "simpletodo/partials/add_task_form.html" with task=task %}
</div>
{% for task in list.simpletask_set.all %}
{% include "simpletodo/partials/task.html" with task=task %}
{% endfor %}
</div>
</div>

View File

@ -0,0 +1,35 @@
<div class="task">
<div class="flex mb-4 items-center">
{% if task.done %}
<p class="flex-auto text-blue-500 line-through">
{% else %}
<p class="flex-auto text-blue-900">
{% endif %}
{{ task.title }}
</p>
<button
hx-post="/todo/api/tasks/{{ task.id }}/toggle_done/"
hx-swap="outerHTML swap:0.5s"
hx-target="closest .task"
{% if task.done %}
class="flex-no-shrink p-2 ml-4 mr-2 border-2 rounded hover:text-white text-gray-500 border-gray-500 hover:bg-gray-500"
{% else %}
class="flex-no-shrink p-2 ml-4 mr-2 border-2 rounded hover:text-white text-green-500 border-green-500 hover:bg-green-500"
{% endif %}
>
{% if task.done %}
Not Done
{% else %}
Done
{% endif %}
</button>
<button
hx-delete="/todo/api/tasks/{{ task.id }}/"
hx-swap="outerHTML swap:0.5s"
hx-target="closest .task"
class="flex-no-shrink p-2 ml-2 border-2 rounded text-red-500 border-red-500 hover:text-white hover:bg-red-500">
Remove
</button>
</div>
</div>

View File

@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,24 @@
from django.test import TestCase
from vbv_lernwelt.core.tests.factories import UserFactory
from vbv_lernwelt.simpletodo.models import SimpleTask
from vbv_lernwelt.simpletodo.serializers import SimpleTaskSerializer
class SimpleTaskSerializerTestCase(TestCase):
def setUp(self) -> None:
self.user = UserFactory()
def test_serializer(self):
serializer = SimpleTaskSerializer(data={
'title': 'Test',
'list_title': 'Todos',
})
serializer.is_valid(raise_exception=True)
serializer.save(user=self.user)
task = SimpleTask.objects.first()
self.assertEqual(task.title, 'Test')
self.assertEqual(task.list.title, 'Todos')

View File

@ -0,0 +1,13 @@
from django.conf.urls import url, include
from django.urls import path
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'tasks', views.SimpleTaskViewSet, basename='tasks')
urlpatterns = [
path("", views.index, name="index"),
url(r'^api/', include(router.urls)),
]

View File

@ -1,3 +1,85 @@
from django.shortcuts import render from django.http import HttpResponse
from django.shortcuts import redirect, render
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.renderers import TemplateHTMLRenderer, JSONRenderer
from rest_framework.response import Response
# Create your views here. from vbv_lernwelt.simpletodo.models import SimpleList, SimpleTask
from vbv_lernwelt.simpletodo.serializers import SimpleTaskSerializer
def index(request):
simple_lists = SimpleList.objects.filter(user=request.user)
return render(request, 'simpletodo/index.html', {
'simple_lists': simple_lists
})
class SimpleTaskViewSet(viewsets.ModelViewSet):
serializer_class = SimpleTaskSerializer
renderer_classes = [TemplateHTMLRenderer, JSONRenderer]
def get_queryset(self):
user = self.request.user
return SimpleTask.objects.filter(list__user=user)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
if request.accepted_renderer.format == 'html':
serializer.is_valid(raise_exception=True)
else:
serializer.is_valid(raise_exception=True)
serializer.save(user=request.user)
if request.accepted_renderer.format == 'html':
return redirect("/todo/")
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
instance.delete()
if request.htmx:
return HttpResponse(status=200, content='')
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['post',])
def toggle_done(self, request, pk=None):
task = self.get_object()
task.done = not task.done
task.save()
if request.htmx:
return render(request, 'simpletodo/partials/task.html', {
'task': task
})
return Response(self.get_serializer(task), status=status.HTTP_200_OK)
#
# def get_category_from_request(self, request):
# cat_name = request.query_params.get('cat_name')
# category_obj = None
#
# if cat_name:
# category_obj = VideoCategory.objects.filter(category__iexact=cat_name).first()
# if not category_obj:
# category_obj = VideoCategory.objects.first()
#
# return category_obj
#
# @action(detail=False, methods=['get'])
# def form(self, request):
# category_obj = self.get_category_from_request(request)
# return Response(template_name='videos/partials/video_form.html', data={'category': category_obj})
#
# @action(detail=False, methods=['get'])
# def cancel(self, request):
# category_obj = self.get_category_from_request(request)
# return Response(template_name='videos/partials/show_add_form.html', data={'category': category_obj})

View File

@ -0,0 +1,4 @@
.task.htmx-swapping {
opacity: 0;
transition: opacity 1s ease-out;
}

View File

@ -20,6 +20,9 @@
<!-- Le javascript <!-- Le javascript
================================================== --> ================================================== -->
{# Placed at the top of the document so pages load faster with defer #} {# Placed at the top of the document so pages load faster with defer #}
<script defer src="https://unpkg.com/htmx.org@1.6.1"></script>
{% block javascript %} {% block javascript %}
<!-- Your stuff: Third-party javascript libraries go here --> <!-- Your stuff: Third-party javascript libraries go here -->
<!-- place project specific Javascript in this file --> <!-- place project specific Javascript in this file -->
@ -57,115 +60,19 @@
{% block content %} {% block content %}
<div class="container mx-auto">
<div class="flex justify-between flex-col md:flex-row">
<img class="w-full md:w-2/4" src="https://www.thezebra.com/insurance-news/wp-content/uploads/2016/01/Tree-fallen-on-car-1024x682.jpeg"/>
<div class="w-full md:w-2/4 flex flex-col justify-center p-4 md:p-16">
<h2 class="text-xl md:text-3xl font-bold">Machen Sie hier eine Selbstevaluation</h2>
<p class="my-4 text-xl">Hier steht noch etwas mehr Text</p>
</div>
</div>
</div>
<div class="container mx-auto bg-blue-100 border-t-2 border-gray-900">
<div class="p-8 flex flex-col md:flex-row">
<div class="w-full md:w-1/2 xl:w-1/3 px-4">
<div class="bg-white rounded-lg overflow-hidden mb-10 shadow-md">
<img
src="https://cdn.tailgrids.com/1.0/assets/images/cards/card-01/image-01.jpg"
alt="image"
class="w-full"
/>
<div class="p-8 sm:p-9 md:p-7 xl:p-9 text-center">
<a
href="javascript:void(0)"
class="
inline-block
py-2
px-7
border border-[#E5E7EB]
rounded-full
text-base text-body-color
font-medium
hover:border-primary hover:bg-primary hover:text-white
transition
"
>
Kurs X
</a>
</div>
</div>
</div>
<div class="w-full md:w-1/2 xl:w-1/3 px-4">
<div class="bg-white rounded-lg overflow-hidden mb-10 shadow-md">
<img
src="https://cdn.tailgrids.com/1.0/assets/images/cards/card-01/image-02.jpg"
alt="image"
class="w-full"
/>
<div class="p-8 sm:p-9 md:p-7 xl:p-9 text-center">
<a
href="javascript:void(0)"
class="
inline-block
py-2
px-7
border border-[#E5E7EB]
rounded-full
text-base text-body-color
font-medium
hover:border-primary hover:bg-primary hover:text-white
transition
"
>
Kurs Y
</a>
</div>
</div>
</div>
<div class="w-full md:w-1/2 xl:w-1/3 px-4">
<div class="bg-white rounded-lg overflow-hidden mb-10 shadow-md">
<img
src="https://cdn.tailgrids.com/1.0/assets/images/cards/card-01/image-03.jpg"
alt="image"
class="w-full"
/>
<div class="p-8 sm:p-9 md:p-7 xl:p-9 text-center">
<a
href="javascript:void(0)"
class="
inline-block
py-2
px-7
border border-[#E5E7EB]
rounded-full
text-base text-body-color
font-medium
hover:border-primary hover:bg-primary hover:text-white
transition
"
>
Kurs Z
</a>
</div>
</div>
</div>
</div>
<p class="p-8 text-right">weitere Kurse entdecken und buchen</p>
<div class="border-t-2 border-blue-400 m-8 p-8"></div>
</div>
{% endblock content %} {% endblock content %}
{% block modal %}{% endblock modal %} {% block modal %}{% endblock modal %}
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
})
</script>
{% block inline_javascript %} {% block inline_javascript %}
{% comment %} {% comment %}
Script tags with only code, no src (defer by default). To run Script tags with only code, no src (defer by default). To run
with a "defer" so that you run inline code: with a "defer" so that you run inline code:

View File

@ -1 +1,108 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %}
<div class="container mx-auto">
<div class="flex justify-between flex-col md:flex-row">
<img class="w-full md:w-2/4"
src="https://www.thezebra.com/insurance-news/wp-content/uploads/2016/01/Tree-fallen-on-car-1024x682.jpeg"/>
<div class="w-full md:w-2/4 flex flex-col justify-center p-4 md:p-16">
<h2 class="text-xl md:text-3xl font-bold">Machen Sie hier eine Selbstevaluation</h2>
<p class="my-4 text-xl">Hier steht noch etwas mehr Text</p>
</div>
</div>
</div>
<div class="container mx-auto bg-blue-100 border-t-2 border-gray-900">
<div class="p-8 flex flex-col md:flex-row">
<div class="w-full md:w-1/2 xl:w-1/3 px-4">
<div class="bg-white rounded-lg overflow-hidden mb-10 shadow-md">
<img
src="https://cdn.tailgrids.com/1.0/assets/images/cards/card-01/image-01.jpg"
alt="image"
class="w-full"
/>
<div class="p-8 sm:p-9 md:p-7 xl:p-9 text-center">
<a
href="javascript:void(0)"
class="
inline-block
py-2
px-7
border border-[#E5E7EB]
rounded-full
text-base text-body-color
font-medium
hover:border-primary hover:bg-primary hover:text-white
transition
"
>
Kurs X
</a>
</div>
</div>
</div>
<div class="w-full md:w-1/2 xl:w-1/3 px-4">
<div class="bg-white rounded-lg overflow-hidden mb-10 shadow-md">
<img
src="https://cdn.tailgrids.com/1.0/assets/images/cards/card-01/image-02.jpg"
alt="image"
class="w-full"
/>
<div class="p-8 sm:p-9 md:p-7 xl:p-9 text-center">
<a
href="javascript:void(0)"
class="
inline-block
py-2
px-7
border border-[#E5E7EB]
rounded-full
text-base text-body-color
font-medium
hover:border-primary hover:bg-primary hover:text-white
transition
"
>
Kurs Y
</a>
</div>
</div>
</div>
<div class="w-full md:w-1/2 xl:w-1/3 px-4">
<div class="bg-white rounded-lg overflow-hidden mb-10 shadow-md">
<img
src="https://cdn.tailgrids.com/1.0/assets/images/cards/card-01/image-03.jpg"
alt="image"
class="w-full"
/>
<div class="p-8 sm:p-9 md:p-7 xl:p-9 text-center">
<a
href="javascript:void(0)"
class="
inline-block
py-2
px-7
border border-[#E5E7EB]
rounded-full
text-base text-body-color
font-medium
hover:border-primary hover:bg-primary hover:text-white
transition
"
>
Kurs Z
</a>
</div>
</div>
</div>
</div>
<p class="p-8 text-right">weitere Kurse entdecken und buchen</p>
<div class="border-t-2 border-blue-400 m-8 p-8"></div>
</div>
{% endblock %}