Compare commits

..
12 Commits
Author SHA1 Message Date
KKlochko 2ccd9edc03 Add the detailed item for a Task model.
continuous-integration/drone/push Build is passing
2023-07-25 15:31:39 +03:00
KKlochko 4f648a1389 Add the Tasks model to manage tasks.
continuous-integration/drone/push Build is passing
2023-07-24 15:02:23 +03:00
KKlochko de611a5a14 Add the CI/CD configuration. 2023-07-24 14:50:16 +03:00
KKlochko bc585a1c60 Update the database to use Postgres. 2023-07-24 13:24:35 +03:00
KKlochko b1e9c7e713 Add the action to set the circular next status for Task items. 2023-07-23 19:20:57 +03:00
KKlochko 9f6934d076 Add the tests for circular next methods. 2023-07-23 19:15:33 +03:00
KKlochko f18912bed4 Add the circular next methods for Task. 2023-07-23 19:14:38 +03:00
KKlochko 31c8770439 Update the style of buttons. 2023-07-22 13:47:13 +03:00
KKlochko 1711ef1d50 Add right alignment for buttons in a task item. 2023-07-21 15:21:26 +03:00
KKlochko 4fed486a58 Add the cancel button for a task form. 2023-07-21 15:20:44 +03:00
KKlochko 5e202fb29c Add the update button for a task. 2023-07-20 19:28:44 +03:00
KKlochko a929846d13 Add the delete button for a task. 2023-07-17 19:25:47 +03:00
18 changed files with 361 additions and 14 deletions
+44
View File
@@ -0,0 +1,44 @@
kind: pipeline
type: docker
name: default
steps:
- name: install dependencies
image: python:3.10-slim
volumes:
- name: package_cache
path: /package_cache
commands:
- cp .env.example .env
- pip install -r dev_requirements.txt --cache-dir=/package_cache
- name: run migrations
image: python:3.10-slim
volumes:
- name: package_cache
path: /package_cache
commands:
- pip install -r dev_requirements.txt --cache-dir=/package_cache
- python manage.py migrate
- name: run tests
image: python:3.10-slim
volumes:
- name: package_cache
path: /package_cache
commands:
- pip install -r dev_requirements.txt --cache-dir=/package_cache
- python manage.py test
volumes:
- name: package_cache
temp: {}
services:
- name: postgres
image: postgres:15-alpine
environment:
POSTGRES_DB: postgres_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: testpassword
-2
View File
@@ -1,2 +0,0 @@
DEBUG=True
SECRET_KEY='your-secure-key'
+9
View File
@@ -0,0 +1,9 @@
DEBUG=True
SECRET_KEY='your-secure-key'
DATABASE_NAME=postgres_dev
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=testpassword
DATABASE_HOST=postgres
DATABASE_PORT=5432
+23
View File
@@ -15,4 +15,27 @@
Add the htmx (CDN version).
Add the task form.
Add logic to swap the form with the task via htmx.
** 0.3.1 <2023-07-17 Mon>
Add the delete button for a task.
** 0.3.2 <2023-07-20 Thu>
Add the update button for a task.
** 0.3.3 <2023-07-21 Fri>
Add the cancel button for a task form.
** 0.3.4 <2023-07-21 Fri>
Add right alignment for buttons in a task item.
** 0.3.5 <2023-07-22 Sat>
Update the style of buttons.
** 0.3.6 <2023-07-23 Sun>
Add the circular next methods for Task.
Add the tests for circular next methods.
** 0.3.7 <2023-07-23 Sun>
Add the action to set the circular next status for Task items.
** 0.3.8 <2023-07-24 Mon>
Update the database to use Postgres.
** 0.3.9 <2023-07-24 Mon>
Add the CI/CD configuration.
** 0.3.10 <2023-07-24 Mon>
Add the Tasks model to manage tasks.
** 0.4.0 <2023-07-24 Mon>
Add the detailed item for a Task model.
+12
View File
@@ -0,0 +1,12 @@
asgiref==3.7.2
crispy-tailwind==0.5.0
Django==4.2.3
django-browser-reload==1.11.0
django-crispy-forms==2.0
django-tailwind==3.6.0
gunicorn==21.2.0
packaging==23.1
psycopg2-binary==2.9.6
python-dotenv==1.0.0
sqlparse==0.4.4
typing_extensions==4.7.1
+4
View File
@@ -1,8 +1,12 @@
asgiref==3.7.2
crispy-tailwind==0.5.0
Django==4.2.3
django-browser-reload==1.11.0
django-crispy-forms==2.0
django-tailwind==3.6.0
gunicorn==21.2.0
packaging==23.1
psycopg2-binary==2.9.6
python-dotenv==1.0.0
sqlparse==0.4.4
typing_extensions==4.7.1
+6 -2
View File
@@ -75,8 +75,12 @@ WSGI_APPLICATION = "simple_todo_list.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"ENGINE": "django.db.backends.postgresql",
'NAME': os.environ.get('DATABASE_NAME'),
'USER': os.environ.get('DATABASE_USERNAME'),
'PASSWORD': os.environ.get('DATABASE_PASSWORD'),
'HOST': os.environ.get('DATABASE_HOST'),
'PORT': os.environ.get('DATABASE_PORT'),
}
}
+25
View File
@@ -25,3 +25,28 @@ class Task(models.Model):
def __str__(self):
return self.name
@staticmethod
def get_statuses():
return list(map(lambda _: _[1], Task.STATUSES))
def _get_status_index(self):
return Task.get_statuses().index(self.status)
def circular_next_status_index(self):
current_index = self._get_status_index()
next_index = (current_index + 1) % len(Task.STATUSES)
return next_index
def circular_next_status(self):
next_index = self.circular_next_status_index()
return Task.get_statuses()[next_index]
def set_circular_next_status(self):
self.status = self.circular_next_status()
self.save()
class Tasks:
@staticmethod
def get_count_by_status(status: str):
return Task.objects.filter(status=status).count()
-2
View File
@@ -1,2 +0,0 @@
from django.test import TestCase
View File
+53
View File
@@ -0,0 +1,53 @@
from django.test import TestCase
from tasks.models import Task
class TaskTestCase(TestCase):
def setUp(self):
statuses = ["TODO", "DOING", "DONE", "CANCELED"]
names = [f"Test Task {i}" for i in statuses]
description = ""
tasks = [
Task(name=name, description=description, status=status)
for name, status in zip(names, statuses)
]
Task.objects.bulk_create(tasks)
def test__get_status_index_with_existing(self):
task = Task.objects.filter(status="DONE").first()
index = task._get_status_index()
self.assertEqual(index, 2)
def test_circular_next_status_index_for_todo(self):
task = Task.objects.filter(status="TODO").first()
index = task.circular_next_status_index()
self.assertEqual(index, 1)
def test_circular_next_status_index_for_canceled(self):
task = Task.objects.filter(status="CANCELED").first()
next_index = task.circular_next_status_index()
self.assertEqual(next_index, 0)
def test_circular_next_status_for_todo(self):
task = Task.objects.filter(status="TODO").first()
next_status = task.circular_next_status()
self.assertEqual(next_status, "DOING")
def test_circular_next_status_for_canceled(self):
task = Task.objects.filter(status="CANCELED").first()
next_status = task.circular_next_status()
self.assertEqual(next_status, "TODO")
def test_set_circular_next_status_for_todo(self):
task = Task.objects.filter(status="TODO").first()
task.set_circular_next_status()
task.refresh_from_db()
self.assertEqual(task.status, "DOING")
def test_set_circular_next_status_for_canceled(self):
task = Task.objects.filter(status="CANCELED").first()
task.set_circular_next_status()
task.refresh_from_db()
self.assertEqual(task.status, "TODO")
+29
View File
@@ -0,0 +1,29 @@
from django.test import TestCase
from tasks.models import Task, Tasks
class TasksTestCase(TestCase):
todo_count = 2
doing_count = 3
done_count = 4
cancel_count = 1
def setUp(self):
names = [f"Test Task {i}" for i in range(10)]
description = ""
statuses = ["TODO"] * self.todo_count
statuses += ["DOING"] * self.doing_count
statuses += ["DONE"] * self.done_count
statuses += ["CANCELED"] * self.cancel_count
tasks = [
Task(name=name, description=description, status=status)
for name, status in zip(names, statuses)
]
Task.objects.bulk_create(tasks)
def test_done_count(self):
count = Tasks.get_count_by_status("DONE")
self.assertEqual(count, self.done_count)
+6
View File
@@ -9,5 +9,11 @@ urlpatterns = [
path('', views.index, name='index'),
path('htmx/create-task-form/', views.create_task_form, name='create-task-form'),
path('htmx/task-item/<id>/', views.task_item, name='task-item'),
path('htmx/task-item/<id>/detailed/', views.task_detailed, name='task-detailed'),
path('htmx/task-item/<id>/update/', views.task_update, name='task-update'),
path('htmx/task-item/<id>/set-circular-next-status/',
views.task_set_circular_next_status,
name='task-set-circular-next-status'),
path('htmx/task-item/<id>/delete/', views.task_delete, name='task-delete'),
]
+46
View File
@@ -1,4 +1,5 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.http.response import HttpResponse, HttpResponseNotAllowed
from .models import Task
from .forms import TaskForm
@@ -42,3 +43,48 @@ def task_item(request, id):
return render(request, "partials/task_item.html", context)
def task_detailed(request, id):
task = get_object_or_404(Task, id=id)
context = {
"task": task
}
return render(request, "partials/task_detailed.html", context)
def task_update(request, id):
task = get_object_or_404(Task, id=id)
task_form = TaskForm(request.POST or None, instance=task)
if request.method == "POST":
if task_form.is_valid():
task_form.save()
return redirect("tasks:task-item", id=task.id)
context = {
'task': task,
'task_form': task_form,
}
return render(request, "partials/task_form.html", context)
def task_set_circular_next_status(request, id):
task = get_object_or_404(Task, id=id)
if request.method == "POST":
task.set_circular_next_status()
context = {
'task': task,
}
return render(request, "partials/task_item.html", context)
def task_delete(request, id):
task = get_object_or_404(Task, id=id)
if request.method == "POST":
task.delete()
return HttpResponse("")
return HttpResponseNotAllowed([ "POST" ])
+57
View File
@@ -0,0 +1,57 @@
<div hx-target="this" class="p-2 border rounded-lg inline-flex gap-2 my-2 bg-gray-200">
<div class="grid grid-cols-3 gap-2 justify-center w-full max-w-2xl mx-auto">
<div class="col-span-3">
<dl class="row flex flex-inline">
<dt class="col-sm-3 font-bold mr-2">Status:</dt>
{% if task.status == "TODO" %}
<dd class="font-semibold text-green-500"
{% elif task.status == "DOING" %}
<dd class="font-semibold text-yellow-500"
{% elif task.status == "DONE" or task.status == "CANCELED" %}
<dd class="font-semibold text-gray-500"
{% endif %}
>
{{ task.status }}
</dd>
</dl>
<dl class="row flex flex-inline flex-wrap">
<dt class="col-sm-3 font-bold mr-2">Name:</dt>
<dd class="col-sm-9 whitespace-pre-line">{{ task.name }}</dd>
</dl>
<dl class="flex flex-col flex-inline">
<dt class="col-sm-3 font-bold mr-2">Description:</dt>
<dd class="col-sm-9 whitespace-pre-line">{{ task.description }}</dd>
</dl>
</div>
<button type="button"
hx-post="{% url 'tasks:task-item' task.id %}"
hx-swap="outerHTML"
class="inline-block px-5 py-1.5 bg-green-600 text-white font-medium text-xs leading-tight
uppercase rounded shadow-md hover:bg-green-700 hover:shadow-lg focus:bg-green-700
focus:shadow-lg focus:outline-none focus:ring-0 active:bg-green-800 active:shadow-lg
transition duration-150 ease-in-out">
Back
</button>
<button type="button"
hx-post="{% url 'tasks:task-update' task.id %}"
hx-swap="outerHTML"
class="inline-block px-5 py-1.5 bg-blue-600 text-white font-medium text-xs leading-tight
uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700
focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg
transition duration-150 ease-in-out">
Update
</button>
<button type="button"
hx-post="{% url 'tasks:task-delete' task.id %}"
hx-swap="outerHTML"
class="inline-block px-5 py-1.5 bg-red-600 text-white font-medium text-xs leading-tight
uppercase rounded shadow-md hover:bg-red-700 hover:shadow-lg focus:bg-red-700
focus:shadow-lg focus:outline-none focus:ring-0 active:bg-red-800 active:shadow-lg
transition duration-150 ease-in-out">
Delete
</button>
</div>
</div>
+6 -1
View File
@@ -4,6 +4,11 @@
<form method="POST">
{% csrf_token %}
{{ task_form|crispy }}
<button type="submit" hx-post=".">Submit</button>
{% if task %}
<button type="submit" hx-post="{% url 'tasks:task-update' task.id %}">Submit</button>
<button hx-post="{% url 'tasks:task-item' task.id %}">Cancel</button>
{% else %}
<button type="submit" hx-post=".">Submit</button>
{% endif %}
</form>
</div>
+33 -6
View File
@@ -1,13 +1,40 @@
<div class="p-2 border rounded-lg inline-flex gap-2 my-2 bg-gray-200">
<div hx-target="this" class="p-2 border rounded-lg inline-flex gap-2 my-2 bg-gray-200">
{% if task.status == "TODO" %}
<p class="font-semibold text-green-500">
<p class="font-semibold text-green-500"
{% elif task.status == "DOING" %}
<p class="font-semibold text-yellow-500">
<p class="font-semibold text-yellow-500"
{% elif task.status == "DONE" or task.status == "CANCELED" %}
<p class="font-semibold text-gray-500">
<p class="font-semibold text-gray-500"
{% endif %}
hx-post="{% url 'tasks:task-set-circular-next-status' task.id %}"
hx-swap="outerHTML">
{{ task.status }}
</p>
<p class="font-semibold">{{ task.name }}</p>
<p>{{ task.description | truncatewords:10 }}</p>
<p class="font-semibold"
hx-post="{% url 'tasks:task-detailed' task.id %}"
hx-swap="outerHTML">
{{ task.name }}
</p>
<p>{{ task.description | truncatechars:40 }}</p>
<button type="button"
hx-post="{% url 'tasks:task-update' task.id %}"
hx-swap="outerHTML"
class="inline-block px-5 py-1.5 bg-blue-600 text-white font-medium text-xs leading-tight
uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700
focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg
transition duration-150 ease-in-out ml-auto">
Update
</button>
<button type="button"
hx-post="{% url 'tasks:task-delete' task.id %}"
hx-swap="outerHTML"
class="inline-block px-5 py-1.5 bg-red-600 text-white font-medium text-xs leading-tight
uppercase rounded shadow-md hover:bg-red-700 hover:shadow-lg focus:bg-red-700
focus:shadow-lg focus:outline-none focus:ring-0 active:bg-red-800 active:shadow-lg
transition duration-150 ease-in-out">
Delete
</button>
</div>
+8 -1
View File
@@ -4,7 +4,14 @@
{% block content %}
<h1 class="text-3xl font-bold text-center my-2">Tasks</h1>
<button type="button" hx-get="{% url 'tasks:create-task-form' %}" hx-target="#task-items" hx-swap="beforeend">
<button type="button"
hx-get="{% url 'tasks:create-task-form' %}"
hx-target="#task-items"
hx-swap="beforeend"
class="inline-block px-14 py-2.5 bg-green-600 text-white font-medium text-xs leading-tight
uppercase rounded shadow-md hover:bg-green-700 hover:shadow-lg focus:bg-green-700
focus:shadow-lg focus:outline-none focus:ring-0 active:bg-green-800 active:shadow-lg
transition duration-150 ease-in-out mx-auto">
Add a new task
</button>