Skip to content
1 change: 1 addition & 0 deletions settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ default_db_name = "kanban.db"
task__title_max_length = 50
category__name_max_length = 50
statuses = ["To-do", "In progress", "Review", "Done"]
priorities = ["Lowest", "Low", "Normal", "High", "Highest"]
4 changes: 3 additions & 1 deletion src/tasks/controllers/add_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from src.tasks.models.task import Task
from src.tasks.prompts.category_prompt import CategoryPrompt
from src.tasks.prompts.details_prompt import DetailsPrompt
from src.tasks.prompts.priority_prompt import PriorityPrompt
from src.tasks.prompts.status_prompt import StatusPrompt
from src.tasks.prompts.title_prompt import TitlePrompt

Expand All @@ -11,11 +12,12 @@ def add_controller() -> None:
"""Run a sequence of prompts to add a new task"""
title = TitlePrompt().prompt()
status = StatusPrompt().prompt()
priority = PriorityPrompt().prompt()
category_names = Category.category_names()
category_name = CategoryPrompt(category_names=category_names).prompt()
details = DetailsPrompt().prompt()

Task.add_from_prompt(title, status, category_name, details)
Task.add_from_prompt(title, status, priority, category_name, details)

# Print all tasks back
view_all_controller()
3 changes: 3 additions & 0 deletions src/tasks/controllers/edit_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from src.tasks.presenters.no_task_presenter import NoTaskPresenter
from src.tasks.prompts.category_prompt import CategoryPrompt
from src.tasks.prompts.details_prompt import DetailsPrompt
from src.tasks.prompts.priority_prompt import PriorityPrompt
from src.tasks.prompts.status_prompt import StatusPrompt
from src.tasks.prompts.title_prompt import TitlePrompt

Expand All @@ -17,6 +18,7 @@ def edit_controller(task_id: int) -> None:

title = TitlePrompt(default_value=task.title).prompt()
status = StatusPrompt(default_value=task.status).prompt()
priority = PriorityPrompt(default_value=task.priority).prompt()
category_names = Category.category_names()
category_name = CategoryPrompt(
default_value=task.category_name, category_names=category_names
Expand All @@ -28,6 +30,7 @@ def edit_controller(task_id: int) -> None:
task.edit_from_prompt(
title=title,
status=status,
priority=priority,
category_name=category_name,
details=details,
)
Expand Down
40 changes: 35 additions & 5 deletions src/tasks/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class Task(BaseModel):
NO_CATEGORY_STR = "<No category>"
STATUS_CHOICES = [(i, val) for i, val in enumerate(settings.statuses)]
_allowed_statuses = ", ".join(str(i) for i, _ in STATUS_CHOICES)
PRIORITY_CHOICES = [(i, val) for i, val in enumerate(settings.priorities)]
_allowed_priorities = ", ".join(str(i) for i, _ in PRIORITY_CHOICES)

title = pw.CharField(
max_length=settings.task__title_max_length,
Expand All @@ -26,6 +28,11 @@ class Task(BaseModel):
default=0, # first status by default
constraints=[pw.Check(f"status IN ({_allowed_statuses})")],
)
priority = pw.IntegerField(
choices=PRIORITY_CHOICES,
default=len(PRIORITY_CHOICES) // 2, # average priority by default
constraints=[pw.Check(f"priority IN ({_allowed_priorities})")],
)
category = pw.ForeignKeyField(Category, backref="tasks", null=True)
created_at = pw.DateTimeField(default=datetime.now())
details = pw.TextField(null=True)
Expand All @@ -34,6 +41,7 @@ def __str__(self) -> str:
return (
f"Title: {self.title}; "
f"Status: {self.status_str}; "
f"Priority: {self.priority_str}; "
f"Category: {self.category}; "
f"Created at: {self.created_at_str}"
)
Expand All @@ -43,6 +51,11 @@ def status_str(self) -> str:
"""Human-readable visualization of status"""
return settings.statuses[self.status]

@property
def priority_str(self) -> str:
"""Human-readable visualization of priority"""
return settings.priorities[self.priority]

@property
def created_at_str(self) -> str:
"""Human-readable visualization of creation date"""
Expand All @@ -65,15 +78,18 @@ def iter_status_indices() -> Iterator[int]:

@staticmethod
def group_by_status() -> dict[int, list[Task]]:
"""List all existing tasks by status and sorted by creation date"""
"""
List all existing tasks by status and sorted by priority (descending)
and creation date (ascending)
"""
tasks = (
Task.select()
.join(
Category,
on=(Task.category == Category.id),
join_type=pw.JOIN.LEFT_OUTER,
)
.order_by(Task.created_at)
.order_by(Task.priority.desc(), Task.created_at)
)

tasks_by_status = {i: [] for i, _ in enumerate(settings.statuses)}
Expand All @@ -84,19 +100,32 @@ def group_by_status() -> dict[int, list[Task]]:

@staticmethod
def add_from_prompt(
title: str, status: int, category_name: str, details: str
title: str,
status: int,
priority: int,
category_name: str,
details: str,
) -> Task:
category, _ = (
Category.get_or_create(name=category_name)
if category_name
else (None, False)
)
return Task.create(
title=title, status=status, category=category, details=details
title=title,
status=status,
priority=priority,
category=category,
details=details,
)

def edit_from_prompt(
self, title: str, status: int, category_name: str, details: str
self,
title: str,
status: int,
priority: int,
category_name: str,
details: str,
) -> None:
category, _ = (
Category.get_or_create(name=category_name)
Expand All @@ -107,6 +136,7 @@ def edit_from_prompt(
{
Task.title: title,
Task.status: status,
Task.priority: priority,
Task.category: category,
Task.details: details,
}
Expand Down
10 changes: 8 additions & 2 deletions src/tasks/presenters/view_all_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,15 @@ def _present_task(self, task: Task) -> str:

Writes a task in the format:

#<task_id> [<task_category>]
#<task_id> [<task_priority>] [<task_category>]
<task_title>
"""
category_str = f"[{task.category_name}]"
priority_str = f"[{task.priority_str}]"

return f"#{task.id} [bold]{escape(category_str)}[/bold]\n{task.title}"
return (
f"#{task.id} "
f"[underline]{escape(priority_str)}[/underline] "
f"[bold]{escape(category_str)}[/bold]\n"
f"{task.title}"
)
1 change: 1 addition & 0 deletions src/tasks/presenters/view_presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def present(self, task: Task) -> None:
table.add_row("Id", str(task.id))
table.add_row("Title", task.title)
table.add_row("Status", task.status_str)
table.add_row("Priority", task.priority_str)
table.add_row("Category", task.category_name)
table.add_row("Created at", str(task.created_at_str))
table.add_row(
Expand Down
18 changes: 18 additions & 0 deletions src/tasks/prompts/priority_prompt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from prompt_toolkit.shortcuts import choice

from config import settings


class PriorityPrompt:
"""Select among possible priorities"""

def __init__(
self, default_value: int = len(settings.priorities) // 2
) -> None:
self._default_value = default_value

def prompt(self) -> int:
options = [(i, status) for i, status in enumerate(settings.priorities)]
return choice(
message="Priority: ", options=options, default=self._default_value
)
12 changes: 12 additions & 0 deletions tests/tasks/controllers/test_add_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ def test_add_controller(
mocked_prompt_input.send_text(KEY_MAPPINGS["<Down>"])
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"])

# Fill priority by pressing up once
priority = 1
mocked_prompt_input.send_text(KEY_MAPPINGS["<Up>"])
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"])

# Fill category name
category_name = "personal"
mocked_prompt_input.send_text(category_name)
Expand All @@ -53,6 +58,7 @@ def test_add_controller(

assert task.title == title
assert task.status == status
assert task.priority == priority
assert task.category.name == category_name
assert task.details == details

Expand All @@ -73,6 +79,11 @@ def test_add_controller__no_category(
mocked_prompt_input.send_text(KEY_MAPPINGS["<Down>"])
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"])

# Fill priority by pressing up once
priority = 1
mocked_prompt_input.send_text(KEY_MAPPINGS["<Up>"])
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"])

# Fill no category name by just pressing ENTER
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"])

Expand All @@ -95,6 +106,7 @@ def test_add_controller__no_category(

assert task.title == title
assert task.status == status
assert task.priority == priority
assert task.category is None
assert task.details == details

Expand Down
11 changes: 10 additions & 1 deletion tests/tasks/controllers/test_edit_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ def test_edit_defaults(
task = Task.create(
title="Buy milk",
status=1,
priority=1,
category=category,
details="Buy milk because...",
)
assert Category.select().count() == 1, "sanity check: one category exists"

mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"]) # accept title
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"]) # accept status
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"]) # accept priority
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"]) # accept category
mocked_prompt_input.send_text(
KEY_MAPPINGS["<Enter>"]
Expand All @@ -63,6 +65,7 @@ def test_edit_defaults(
# Sanity check: no property got changed
assert edited_task.title == task.title
assert edited_task.status == task.status
assert edited_task.priority == task.priority
assert edited_task.category == task.category
assert edited_task.details == task.details
assert Category.select().count() == 1, (
Expand All @@ -81,17 +84,19 @@ def test_edit_defaults__can_update_attribute(
task = Task.create(
title="Buy milk",
status=1,
priority=1,
category=category,
details="Buy milk becausi", # with a typo at the last character
)
assert Category.select().count() == 1, "sanity check: one category exists"

mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"]) # accept title
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"]) # accept status
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"]) # accept priority
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"]) # accept category

# For details: press "Down" to select "yes"
mocked_prompt_input.send_text(KEY_MAPPINGS["<Down>"]) # accept category
mocked_prompt_input.send_text(KEY_MAPPINGS["<Down>"])
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"])

# Press backspace to fix the typo and add a couple more characters
Expand All @@ -109,6 +114,7 @@ def test_edit_defaults__can_update_attribute(
# Sanity check: no property but the details got changed
assert edited_task.title == task.title
assert edited_task.status == task.status
assert edited_task.priority == task.priority
assert edited_task.category == task.category
assert edited_task.details == "Buy milk because...", "details got updated"
assert Category.select().count() == 1, (
Expand All @@ -127,12 +133,14 @@ def test_edit_defaults__can_update_category(
task = Task.create(
title="Buy milk",
status=1,
priority=1,
category=category,
)
assert Category.select().count() == 1, "sanity check: one category exists"

mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"]) # accept title
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"]) # accept status
mocked_prompt_input.send_text(KEY_MAPPINGS["<Enter>"]) # accept priority

# Edit category name
mocked_prompt_input.send_text(KEY_MAPPINGS["<Backspace>"])
Expand All @@ -154,4 +162,5 @@ def test_edit_defaults__can_update_category(
# Sanity check: no property but the category got changed
assert edited_task.title == task.title
assert edited_task.status == task.status
assert edited_task.priority == task.priority
assert edited_task.details == task.details
Loading