Basic task category/tag implementation

This commit is contained in:
digimint 2025-12-03 19:44:18 -06:00
parent 875c5f7f06
commit c7f6f3f4f1
Signed by: digimint
GPG key ID: 8DF1C6FD85ABF748
9 changed files with 317 additions and 21 deletions

View file

@ -0,0 +1,43 @@
"""Add category to task
Revision ID: 8576b056149e
Revises: 067ee615c967
Create Date: 2025-12-03 12:45:29.641119
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8576b056149e'
down_revision = '067ee615c967'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('cat_to_task')
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.add_column(sa.Column('category', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_task_cat', 'category', ['category'], ['id'], ondelete='SET NULL')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.drop_constraint('fk_task_cat', type_='foreignkey')
batch_op.drop_column('category')
op.create_table('cat_to_task',
sa.Column('id', sa.INTEGER(), nullable=False),
sa.Column('cat', sa.INTEGER(), nullable=False),
sa.Column('task', sa.INTEGER(), nullable=False),
sa.ForeignKeyConstraint(['cat'], ['category.id'], name=op.f('fk_c2t_cat'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['task'], ['task.id'], name=op.f('fk_c2t_task'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###

View file

@ -67,13 +67,6 @@ class TaskCategory(db.Model):
name : Mapped[str] = mapped_column(String(TAG_FIELD_NAME_MAX_LEN)) name : Mapped[str] = mapped_column(String(TAG_FIELD_NAME_MAX_LEN))
namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE', name='fk_cat_ns')) namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE', name='fk_cat_ns'))
class CatToTask(db.Model):
__tablename__: str = 'cat_to_task'
id : Mapped[int] = mapped_column(Integer, primary_key=True)
cat : Mapped[int] = mapped_column(Integer, ForeignKey('category.id', ondelete='CASCADE', name='fk_c2t_cat'))
task : Mapped[int] = mapped_column(Integer, ForeignKey('task.id', ondelete='CASCADE', name='fk_c2t_task'))
class Field(db.Model): class Field(db.Model):
__tablename__: str = 'field' __tablename__: str = 'field'

View file

@ -11,6 +11,7 @@ class Task(db.Model):
id : Mapped[int] = mapped_column(Integer, primary_key=True) id : Mapped[int] = mapped_column(Integer, primary_key=True)
namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE')) namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE'))
parent : Mapped[int|None] = mapped_column(Integer, ForeignKey('task.id', name='fk_task_parent'), nullable=True) parent : Mapped[int|None] = mapped_column(Integer, ForeignKey('task.id', name='fk_task_parent'), nullable=True)
category : Mapped[int|None] = mapped_column(Integer, ForeignKey('category.id', ondelete='SET NULL', name='fk_task_cat'), nullable=True)
name : Mapped[str] = mapped_column(String(64)) name : Mapped[str] = mapped_column(String(64))
description : Mapped[str] = mapped_column(String) description : Mapped[str] = mapped_column(String)

View file

@ -1,18 +1,19 @@
from datetime import timezone from datetime import timezone
from typing import override from typing import final, override
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from wtforms import Field, Form, SelectField, StringField, TextAreaField, ValidationError, validators from wtforms import Field, Form, SelectField, SelectMultipleField, StringField, TextAreaField, ValidationError, validators
from taskflower.auth.permission import NamespacePermissionType from taskflower.auth.permission import NamespacePermissionType
from taskflower.auth.permission.checks import assert_user_perms_on_namespace, assert_user_perms_on_task from taskflower.auth.permission.checks import assert_user_perms_on_namespace, assert_user_perms_on_task
from taskflower.auth.permission.lookups import namespaces_where_user_can from taskflower.auth.permission.lookups import namespaces_where_user_can
from taskflower.db import db from taskflower.db import db, db_fetch_by_id
from taskflower.db.model.namespace import Namespace from taskflower.db.model.namespace import Namespace
from taskflower.db.model.tag import Tag, TagToTask, TaskCategory
from taskflower.db.model.task import Task from taskflower.db.model.task import Task
from taskflower.db.model.user import User from taskflower.db.model.user import User
from taskflower.form import FormCreatesObjectWithUser, FormEditsObjectWithUser from taskflower.form import FormCreatesObjectWithUser, FormEditsObjectWithUser
from taskflower.types import ann from taskflower.types import AssertType, CheckType, ann
from taskflower.types.either import Either, Left, Right from taskflower.types.either import Either, Left, Right, reduce_either
from taskflower.types.option import Option from taskflower.types.option import Option
from taskflower.util.time import from_sophont_provided_data from taskflower.util.time import from_sophont_provided_data
@ -44,9 +45,99 @@ def is_valid_timestamp(form: Form, field: Field):
if isinstance(res, Left): if isinstance(res, Left):
raise ValidationError(f'Parse failure: {res.val!s}') raise ValidationError(f'Parse failure: {res.val!s}')
@final
class ValidCategory:
def __init__(self, ns: Namespace) -> None:
self._ns = ns
def __call__(self, _: Form, fld: Field) -> None:
if fld.data == -1: # pyright:ignore[reportAny]
return
res = CheckType(int)(
fld.data # pyright:ignore[reportAny]
).flat_map(
lambda cid: db_fetch_by_id(
TaskCategory,
cid,
db
).flat_map(
lambda cat: Either.do_assert(
cat.namespace == self._ns.id
)
)
)
if isinstance(res, Left):
raise ValidationError('Invalid category!')
@final
class ValidTag:
def __init__(self, ns: Namespace) -> None:
self._ns = ns
def __call__(self, _: Form, fraw: Field) -> None:
res = CheckType(list[int])(
fraw.data # pyright:ignore[reportAny]
).flat_map(
lambda tag_ids: reduce_either([
CheckType(int)(
tag_id
).flat_map(
lambda tid: db_fetch_by_id(
Tag,
tid,
db
)
).flat_map(
lambda tag: Either.do_assert(
tag.namespace == self._ns.id
)
)
for tag_id in tag_ids
])
)
if isinstance(res, Left):
raise ValidationError('Invalid tag!')
def task_edit_form_for_task( def task_edit_form_for_task(
t: Task t: Task,
ns: Namespace
) -> type[FormEditsObjectWithUser[Task]]: ) -> type[FormEditsObjectWithUser[Task]]:
cat_choices = [(-1, 'None')] + [
(cat.id, cat.name)
for cat in db.session.query(
TaskCategory
).filter(
TaskCategory.namespace == t.namespace
).all()
]
tag_choices = [
(tag.id, tag.name)
for tag in db.session.query(
Tag
).filter(
Tag.namespace == t.namespace
).all()
]
cur_tags = db.session.query(
Tag
).filter(
Tag.namespace == t.namespace
).join(
TagToTask,
TagToTask.tag == Tag.id
).filter(
TagToTask.task == t.id
).all()
tag_defaults = [
tg.id
for tg in cur_tags
]
class TaskEditForm(FormEditsObjectWithUser[Task]): class TaskEditForm(FormEditsObjectWithUser[Task]):
name: StringField = StringField( name: StringField = StringField(
'Task Name', 'Task Name',
@ -59,6 +150,28 @@ def task_edit_form_for_task(
], ],
default=t.name default=t.name
) )
category: SelectField = SelectField(
'Category',
[
ValidCategory(ns)
],
coerce=int,
choices=cat_choices,
default=(
t.category
if t.category
else -1
)
)
tags: SelectMultipleField = SelectMultipleField(
'Tags',
[
ValidTag(ns)
],
coerce=int,
choices=tag_choices,
default=tag_defaults
)
due: StringField = StringField( due: StringField = StringField(
'Due Date', 'Due Date',
[ [
@ -87,6 +200,48 @@ def task_edit_form_for_task(
def _do_edit(tsk: Task) -> Either[Exception, Task]: def _do_edit(tsk: Task) -> Either[Exception, Task]:
tsk.name = ann(self.name.data) tsk.name = ann(self.name.data)
if self.category.data: # pyright:ignore[reportAny]
cat_id = AssertType(int)(
self.category.data # pyright:ignore[reportAny]
)
tsk.category = (
None if cat_id == -1 else cat_id
)
if self.tags.data:
res = CheckType(list[int])(
self.tags.data
).flat_map(
lambda tids: reduce_either([
db_fetch_by_id(
Tag,
tid,
db
)
for tid in tids
])
)
if isinstance(res, Right):
tags_to_apply = res.val
_ = db.session.query(
TagToTask
).filter(
TagToTask.task == tsk.id
).delete()
for tag in tags_to_apply:
t2t = TagToTask(
tag=tag.id, # pyright:ignore[reportCallIssue]
task=tsk.id # pyright:ignore[reportCallIssue]
)
print('Added tag')
# Queue the tag associations, but don't commit them
# yet - let the caller decide whether to commit
# along with the task edits.
db.session.add(t2t)
if self.due.data: if self.due.data:
# We already check the validity during validation, # We already check the validity during validation,
# so this call should always be Right # so this call should always be Right

View file

@ -7,9 +7,11 @@ import humanize
from taskflower.auth.permission import NPT, NamespacePermissionType from taskflower.auth.permission import NPT, NamespacePermissionType
from taskflower.auth.permission.checks import assert_user_perms_on_task from taskflower.auth.permission.checks import assert_user_perms_on_task
from taskflower.auth.permission.lookups import get_user_perms_on_task from taskflower.auth.permission.lookups import get_user_perms_on_task
from taskflower.db import db, db_fetch_by_id
from taskflower.db.model.tag import Tag, TagToTask, TaskCategory
from taskflower.db.model.task import Task from taskflower.db.model.task import Task
from taskflower.db.model.user import User from taskflower.db.model.user import User
from taskflower.types.either import Either from taskflower.types.either import Either, gather_successes
from taskflower.util.time import ensure_timezone_aware, now from taskflower.util.time import ensure_timezone_aware, now
def _due_str(due: datetime) -> str: def _due_str(due: datetime) -> str:
@ -37,6 +39,8 @@ class TaskForUser:
completed: datetime|None completed: datetime|None
overdue: bool overdue: bool
namespace_id: int namespace_id: int
category: str|None
tags: list[str]
can_edit: bool can_edit: bool
can_delete: bool can_delete: bool
@ -85,6 +89,42 @@ class TaskForUser:
in an ``lside_effect()``) in an ``lside_effect()``)
''' '''
perms = get_user_perms_on_task(usr, tsk) perms = get_user_perms_on_task(usr, tsk)
cat = (
db_fetch_by_id(
TaskCategory,
tsk.category,
db
).flat_map(
lambda ct: Either.do_assert(
ct.namespace == tsk.namespace
).map(lambda _: ct)
).and_then(
lambda cat: cat.name,
lambda exc: None
)
if tsk.category is not None
else None
)
tags = gather_successes([
db_fetch_by_id(
Tag,
t2t.tag,
db
).flat_map(
lambda tag: Either.do_assert(
tag.namespace == tsk.namespace
).map(lambda _: tag)
).map(
lambda tag: tag.name
)
for t2t in db.session.query(
TagToTask
).filter(
TagToTask.task == tsk.id
).all()
])
return assert_user_perms_on_task( return assert_user_perms_on_task(
usr, usr,
@ -107,6 +147,8 @@ class TaskForUser:
), ),
tsk.due.replace(tzinfo=timezone.utc) < now(), tsk.due.replace(tzinfo=timezone.utc) < now(),
tsk.namespace, tsk.namespace,
cat,
tags,
NPT.EDIT_ALL_TASKS in perms, NPT.EDIT_ALL_TASKS in perms,
NPT.DELETE_ALL_TASKS in perms, NPT.DELETE_ALL_TASKS in perms,
NPT.COMPLETE_ALL_TASKS in perms, NPT.COMPLETE_ALL_TASKS in perms,

View file

@ -164,6 +164,19 @@
&.pad-even { &.pad-even {
padding: 0.5rem; padding: 0.5rem;
} }
&.task-name{
max-width: 10rem;
.tag-tray {
max-width: 30%;
}
label {
overflow: hidden;
text-wrap: nowrap;
}
}
} }
p { p {
@ -180,6 +193,24 @@
} }
} }
.tag-tray {
display: flex;
flex-direction: row;
flex: 0 1 auto;
flex-wrap: nowrap;
overflow: hidden;
p.tag {
background-color: #d6a5ff;
color: #000;
border-radius: 0.5rem;
padding: 0.1rem 0.5rem;
margin: 0.1rem 0.2rem;
font-size: small;
text-wrap: nowrap;
}
}
.detail-view-elem { .detail-view-elem {
padding: 1rem; padding: 1rem;

View file

@ -39,7 +39,16 @@
class="task-name" id="{{ tlist_cid('task-name', task.id, list_id) }}" class="task-name" id="{{ tlist_cid('task-name', task.id, list_id) }}"
onclick="set_active('{{ tlist_cid('task-detail', task.id, list_id) }}', {{list_id}})" onclick="set_active('{{ tlist_cid('task-detail', task.id, list_id) }}', {{list_id}})"
> >
<label for="{{ tlist_cid('check-inner', task.id, list_id) }}">{{ task.name }}</label> <div style="display: flex;">
<label style="flex: 1 0;" for="{{ tlist_cid('check-inner', task.id, list_id) }}">{{ task.name }}</label>
<div class="tag-tray">
{% if task.tags %}
{% for tag in task.tags %}
<p class="tag">{{ tag }}</p>
{% endfor %}
{% endif %}
</div>
</div>
</td> </td>
<td class="task-due" id="{{ tlist_cid('task-due', task.id, list_id) }}"> <td class="task-due" id="{{ tlist_cid('task-due', task.id, list_id) }}">
<p>{{ reltime(task.due)|safe }}</p> <p>{{ reltime(task.due)|safe }}</p>
@ -51,6 +60,17 @@
<div class="detail-view-header"> <div class="detail-view-header">
<h1>Task Details: {{ task.name }}</h1> <h1>Task Details: {{ task.name }}</h1>
</div> </div>
{% if task.category %}
<p class="category">Category: {{ task.category }}</p>
{% endif %}
{% if task.tags %}
<div class="tag-tray">
<p class="tag-header">Tags: </p>
{% for tag in task.tags %}
<p class="tag">{{ tag }}</p>
{% endfor %}
</div>
{% endif %}
<div class="link-tray"> <div class="link-tray">
{% if task.can_edit %} {% if task.can_edit %}
<a class="link-btn icon-btn" href="{{url_for('web.task.edit', id=task.id, next=request.path)}}">{{icon('edit')|safe}}<span>Edit Task</span></a> <a class="link-btn icon-btn" href="{{url_for('web.task.edit', id=task.id, next=request.path)}}">{{icon('edit')|safe}}<span>Edit Task</span></a>

View file

@ -12,6 +12,8 @@
<h1>Edit Task</h1> <h1>Edit Task</h1>
<dl> <dl>
{{ render_field(form.name) }} {{ render_field(form.name) }}
{{ render_field(form.category) }}
{{ render_field(form.tags) }}
{{ render_field(form.due) }} {{ render_field(form.due) }}
{{ render_field(form.description) }} {{ render_field(form.description) }}
<div id="tz-container">{{ render_field(form.timezone) }}</div> <div id="tz-container">{{ render_field(form.timezone) }}</div>

View file

@ -6,11 +6,11 @@ from taskflower.auth.permission import NamespacePermissionType
from taskflower.auth.permission.checks import assert_user_perms_on_task from taskflower.auth.permission.checks import assert_user_perms_on_task
from taskflower.auth.permission.lookups import tasks_where_user_can from taskflower.auth.permission.lookups import tasks_where_user_can
from taskflower.auth.violations import check_for_auth_err_and_report from taskflower.auth.violations import check_for_auth_err_and_report
from taskflower.db import commit_update, db, do_delete from taskflower.db import commit_update, db, db_fetch_by_id, do_delete
from taskflower.db.helpers import add_to_db from taskflower.db.helpers import add_to_db
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.task import Task from taskflower.db.model.task import Task
from taskflower.db.model.user import User from taskflower.db.model.user import User
from taskflower.form import FormEditsObjectWithUser
from taskflower.form.task import task_edit_form_for_task, task_form_for_user from taskflower.form.task import task_edit_form_for_task, task_form_for_user
from taskflower.sanitize.task import TaskForUser from taskflower.sanitize.task import TaskForUser
from taskflower.types.either import Either, Left, Right, reduce_either from taskflower.types.either import Either, Left, Right, reduce_either
@ -246,10 +246,19 @@ def edit(id: int):
).lside_effect( ).lside_effect(
check_for_auth_err_and_report check_for_auth_err_and_report
).flat_map( ).flat_map(
lambda tsk: Right[Exception, FormEditsObjectWithUser[Task]]( lambda tsk: db_fetch_by_id(
task_edit_form_for_task( Namespace,
tsk tsk.namespace,
)(request.form) db
).map(
lambda ns: task_edit_form_for_task(
tsk,
ns
)(
request.form
if request.method == 'POST'
else None
)
).map( ).map(
lambda form: (tsk, form) lambda form: (tsk, form)
) )