From c7f6f3f4f1561e5f4b093237ca4b4663bab72981 Mon Sep 17 00:00:00 2001 From: digimint Date: Wed, 3 Dec 2025 19:44:18 -0600 Subject: [PATCH] Basic task category/tag implementation --- .../8576b056149e_add_category_to_task.py | 43 +++++ src/taskflower/db/model/tag.py | 7 - src/taskflower/db/model/task.py | 1 + src/taskflower/form/task.py | 167 +++++++++++++++++- src/taskflower/sanitize/task.py | 44 ++++- src/taskflower/static/list-view.css | 31 ++++ src/taskflower/templates/task/_shorttask.html | 22 ++- src/taskflower/templates/task/edit.html | 2 + src/taskflower/web/task/__init__.py | 21 ++- 9 files changed, 317 insertions(+), 21 deletions(-) create mode 100644 src/migrations/versions/8576b056149e_add_category_to_task.py diff --git a/src/migrations/versions/8576b056149e_add_category_to_task.py b/src/migrations/versions/8576b056149e_add_category_to_task.py new file mode 100644 index 0000000..55dc128 --- /dev/null +++ b/src/migrations/versions/8576b056149e_add_category_to_task.py @@ -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 ### diff --git a/src/taskflower/db/model/tag.py b/src/taskflower/db/model/tag.py index f7a0bba..b3eea0c 100644 --- a/src/taskflower/db/model/tag.py +++ b/src/taskflower/db/model/tag.py @@ -67,13 +67,6 @@ class TaskCategory(db.Model): 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')) -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): __tablename__: str = 'field' diff --git a/src/taskflower/db/model/task.py b/src/taskflower/db/model/task.py index 18abe71..12358bc 100644 --- a/src/taskflower/db/model/task.py +++ b/src/taskflower/db/model/task.py @@ -11,6 +11,7 @@ class Task(db.Model): id : Mapped[int] = mapped_column(Integer, primary_key=True) 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) + 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)) description : Mapped[str] = mapped_column(String) diff --git a/src/taskflower/form/task.py b/src/taskflower/form/task.py index a3ec961..6ec4734 100644 --- a/src/taskflower/form/task.py +++ b/src/taskflower/form/task.py @@ -1,18 +1,19 @@ from datetime import timezone -from typing import override +from typing import final, override 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.checks import assert_user_perms_on_namespace, assert_user_perms_on_task 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.tag import Tag, TagToTask, TaskCategory from taskflower.db.model.task import Task from taskflower.db.model.user import User from taskflower.form import FormCreatesObjectWithUser, FormEditsObjectWithUser -from taskflower.types import ann -from taskflower.types.either import Either, Left, Right +from taskflower.types import AssertType, CheckType, ann +from taskflower.types.either import Either, Left, Right, reduce_either from taskflower.types.option import Option 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): 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( - t: Task + t: Task, + ns: Namespace ) -> 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]): name: StringField = StringField( 'Task Name', @@ -59,6 +150,28 @@ def task_edit_form_for_task( ], 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 Date', [ @@ -87,6 +200,48 @@ def task_edit_form_for_task( def _do_edit(tsk: Task) -> Either[Exception, Task]: 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: # We already check the validity during validation, # so this call should always be Right diff --git a/src/taskflower/sanitize/task.py b/src/taskflower/sanitize/task.py index 6ae04ca..c1d98cb 100644 --- a/src/taskflower/sanitize/task.py +++ b/src/taskflower/sanitize/task.py @@ -7,9 +7,11 @@ import humanize from taskflower.auth.permission import NPT, NamespacePermissionType from taskflower.auth.permission.checks import assert_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.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 def _due_str(due: datetime) -> str: @@ -37,6 +39,8 @@ class TaskForUser: completed: datetime|None overdue: bool namespace_id: int + category: str|None + tags: list[str] can_edit: bool can_delete: bool @@ -85,6 +89,42 @@ class TaskForUser: in an ``lside_effect()``) ''' 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( usr, @@ -107,6 +147,8 @@ class TaskForUser: ), tsk.due.replace(tzinfo=timezone.utc) < now(), tsk.namespace, + cat, + tags, NPT.EDIT_ALL_TASKS in perms, NPT.DELETE_ALL_TASKS in perms, NPT.COMPLETE_ALL_TASKS in perms, diff --git a/src/taskflower/static/list-view.css b/src/taskflower/static/list-view.css index a663391..c29f64e 100644 --- a/src/taskflower/static/list-view.css +++ b/src/taskflower/static/list-view.css @@ -164,6 +164,19 @@ &.pad-even { padding: 0.5rem; } + + &.task-name{ + max-width: 10rem; + + .tag-tray { + max-width: 30%; + } + + label { + overflow: hidden; + text-wrap: nowrap; + } + } } 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 { padding: 1rem; diff --git a/src/taskflower/templates/task/_shorttask.html b/src/taskflower/templates/task/_shorttask.html index d94df58..36b13a3 100644 --- a/src/taskflower/templates/task/_shorttask.html +++ b/src/taskflower/templates/task/_shorttask.html @@ -39,7 +39,16 @@ 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}})" > - +
+ +
+ {% if task.tags %} + {% for tag in task.tags %} +

{{ tag }}

+ {% endfor %} + {% endif %} +
+

{{ reltime(task.due)|safe }}

@@ -51,6 +60,17 @@

Task Details: {{ task.name }}

+ {% if task.category %} +

Category: {{ task.category }}

+ {% endif %} + {% if task.tags %} +
+

Tags:

+ {% for tag in task.tags %} +

{{ tag }}

+ {% endfor %} +
+ {% endif %}