Basic task category/tag implementation
This commit is contained in:
parent
875c5f7f06
commit
c7f6f3f4f1
9 changed files with 317 additions and 21 deletions
43
src/migrations/versions/8576b056149e_add_category_to_task.py
Normal file
43
src/migrations/versions/8576b056149e_add_category_to_task.py
Normal 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 ###
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}})"
|
||||
>
|
||||
<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 class="task-due" id="{{ tlist_cid('task-due', task.id, list_id) }}">
|
||||
<p>{{ reltime(task.due)|safe }}</p>
|
||||
|
|
@ -51,6 +60,17 @@
|
|||
<div class="detail-view-header">
|
||||
<h1>Task Details: {{ task.name }}</h1>
|
||||
</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">
|
||||
{% 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>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
<h1>Edit Task</h1>
|
||||
<dl>
|
||||
{{ render_field(form.name) }}
|
||||
{{ render_field(form.category) }}
|
||||
{{ render_field(form.tags) }}
|
||||
{{ render_field(form.due) }}
|
||||
{{ render_field(form.description) }}
|
||||
<div id="tz-container">{{ render_field(form.timezone) }}</div>
|
||||
|
|
|
|||
|
|
@ -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.lookups import tasks_where_user_can
|
||||
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.model.namespace import Namespace
|
||||
from taskflower.db.model.task import Task
|
||||
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.sanitize.task import TaskForUser
|
||||
from taskflower.types.either import Either, Left, Right, reduce_either
|
||||
|
|
@ -246,10 +246,19 @@ def edit(id: int):
|
|||
).lside_effect(
|
||||
check_for_auth_err_and_report
|
||||
).flat_map(
|
||||
lambda tsk: Right[Exception, FormEditsObjectWithUser[Task]](
|
||||
task_edit_form_for_task(
|
||||
tsk
|
||||
)(request.form)
|
||||
lambda tsk: db_fetch_by_id(
|
||||
Namespace,
|
||||
tsk.namespace,
|
||||
db
|
||||
).map(
|
||||
lambda ns: task_edit_form_for_task(
|
||||
tsk,
|
||||
ns
|
||||
)(
|
||||
request.form
|
||||
if request.method == 'POST'
|
||||
else None
|
||||
)
|
||||
).map(
|
||||
lambda form: (tsk, form)
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue