Finish task edit and delete pages

Closes #2
This commit is contained in:
digimint 2025-11-18 23:02:25 -06:00
parent 47b1e6ca82
commit 9508a8b132
Signed by: digimint
GPG key ID: 8DF1C6FD85ABF748
9 changed files with 315 additions and 54 deletions

View file

@ -1,7 +1,52 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from taskflower.types.either import Either, Left, Right
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
db = SQLAlchemy(model_class=Base) db = SQLAlchemy(model_class=Base)
def do_commit(
database: SQLAlchemy
) -> Either[Exception, None]:
try:
database.session.commit()
return Right(None)
except Exception as e:
database.session.rollback()
return Left(e)
def do_delete[T](
to_delete: T,
database: SQLAlchemy
) -> Either[Exception, None]:
try:
db.session.delete(to_delete)
return do_commit(database)
except Exception as e:
return Left(e)
def commit_update[T](
to_update: T,
database: SQLAlchemy
) -> Either[Exception, T]:
''' Convenience wrapper around ``do_commit()`` which passes the data to be
updated through the function. This function does not affect
``to_update`` - it only passes it through as the ``Right()`` value of
the response. Meant for use in ``flat_map()`` chains.
'''
return do_commit(database).map(
lambda _: to_update
)
def insert_into_db[T](
to_insert: T,
database: SQLAlchemy
) -> Either[Exception, T]:
try:
database.session.add(to_insert)
return do_commit(database).map(lambda _: to_insert)
except Exception as e:
return Left(e)

View file

@ -2,39 +2,74 @@ from typing import override
from wtforms import DateTimeLocalField, SelectField, StringField, TextAreaField, ValidationError, validators from wtforms import DateTimeLocalField, SelectField, 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 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
from taskflower.db.model.namespace import Namespace 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 FormCreatesObjectWithUser, FormEditsObjectWithUser from taskflower.form import FormCreatesObjectWithUser, FormEditsObjectWithUser
from taskflower.types import ann
from taskflower.types.either import Either, Left, Right from taskflower.types.either import Either, Left, Right
from taskflower.types.option import Option from taskflower.types.option import Option
class TaskEditForm(FormEditsObjectWithUser[Task]): def task_edit_form_for_task(
name: StringField = StringField( t: Task
'Task Name', ) -> type[FormEditsObjectWithUser[Task]]:
[ class TaskEditForm(FormEditsObjectWithUser[Task]):
validators.Length( name: StringField = StringField(
min=1, 'Task Name',
max=64, [
message='Task name must be between 1 and 64 characters!' validators.Length(
) min=1,
] max=64,
) message='Task name must be between 1 and 64 characters!'
due: DateTimeLocalField = DateTimeLocalField( )
'Due Date', ],
[ default=t.name
validators.DataRequired() )
] due: DateTimeLocalField = DateTimeLocalField(
) 'Due Date',
description: TextAreaField = TextAreaField( [
'Description', validators.DataRequired()
[ ],
validators.Optional() default=t.due # pyright:ignore[reportArgumentType]
] )
) description: TextAreaField = TextAreaField(
'Description',
[
validators.Optional()
],
default=t.description
)
@override
def edit_object(self, current_user: User, target_object: Task) -> Either[Exception, Task]:
def _do_edit(tsk: Task) -> Either[Exception, Task]:
tsk.name = ann(self.name.data)
tsk.due = ann(self.due.data)
tsk.description = (
self.description.data
) if self.description.data else ''
return Right(tsk)
if self.validate():
return assert_user_perms_on_task(
current_user,
target_object,
NamespacePermissionType.EDIT_ALL_TASKS,
'Edit'
).flat_map(
_do_edit
)
else:
return Left(
ValidationError(
f'{self.__class__.__name__} validation failed.'
)
)
return TaskEditForm
def task_form_for_user( def task_form_for_user(

View file

@ -22,17 +22,20 @@
} }
#submit-form { #submit-form {
padding: 1rem; background-color: var(--btn-1);
margin-top: 2rem; color: var(--on-btn-1);
background-color: var(--accent-1-hlt); width: max-content;
color: var(--on-accent-1); padding: 1rem 1.5rem;
font-size: larger; border: 2px solid var(--btn-1-border);
border-radius: 0.25rem;
margin: 0.5rem;
transition: all 0.2s;
text-decoration: none;
font-weight: bold; font-weight: bold;
border: 2px solid var(--accent-1); font-size: larger;
border-radius: 1rem;
transition: background-color 0.5s;
}
#submit-form:hover { &:hover {
background-color: var(--accent-1); background-color: var(--btn-1-hlt);
color: var(--on-btn-1-hlt);
}
} }

View file

@ -0,0 +1,16 @@
{% extends "main.html" %}
{% from "_formhelpers.html" import render_field %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="forms.css") }} />
{% endblock %}
{% block title %}Delete Task{% endblock %}
{% block main_content %}
<form class="default-form" id="delete-task-form" method="POST">
<h1>Delete Task</h1>
<h3>Are you sure you want to delete the task "{{ task.name }}"?</h3>
<button type="submit" id="submit-form">Confirm Deletion</button>
</form>
{% endblock %}

View file

@ -15,6 +15,6 @@
{{ render_field(form.due) }} {{ render_field(form.due) }}
{{ render_field(form.description) }} {{ render_field(form.description) }}
</dl> </dl>
<button type="submit">Create Task</button> <button type="submit" id="submit-form">Edit Task</button>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -31,7 +31,7 @@ class Either[L, R](ABC):
raise NotImplementedError() raise NotImplementedError()
@abstractmethod @abstractmethod
def lside_effect[X](self, f: Callable[[L], X]) -> 'Either[L, R]': def lside_effect[X](self, f: Callable[[L], X], filter: type|None=None) -> 'Either[L, R]':
raise NotImplementedError() raise NotImplementedError()
@abstractmethod @abstractmethod
@ -46,6 +46,17 @@ class Either[L, R](ABC):
) -> X: ) -> X:
raise NotImplementedError raise NotImplementedError
@staticmethod
def do_assert(x: bool, desc: str = '') -> 'Either[Exception, None]':
if x:
return Right(None)
else:
return Left(AssertionError(
'Assertion failed' + (
f': {desc}'
) if desc else ''
))
@final @final
class Left[L, R](Either[L, R]): class Left[L, R](Either[L, R]):
def __init__(self, lf: L): def __init__(self, lf: L):
@ -79,8 +90,10 @@ class Left[L, R](Either[L, R]):
return self return self
@override @override
def lside_effect[X](self, f: Callable[[L], X]) -> 'Either[L, R]': def lside_effect[X](self, f: Callable[[L], X], filter: type|None = None) -> 'Either[L, R]':
_ = f(self.val) if (not filter) or isinstance(self.val, filter):
_ = f(self.val)
return self return self
@override @override
@ -145,7 +158,7 @@ class Right[L, R](Either[L, R]):
return self return self
@override @override
def lside_effect[X](self, f: Callable[[L], X]) -> 'Either[L, R]': def lside_effect[X](self, f: Callable[[L], X], filter: type|None = None) -> 'Either[L, R]':
return self return self
@override @override

View file

@ -1,3 +1,4 @@
from http import HTTPStatus
from flask import Blueprint, redirect, render_template, request, url_for from flask import Blueprint, redirect, render_template, request, url_for
from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs] from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs]
@ -6,18 +7,21 @@ 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 db from taskflower.db import commit_update, db, do_delete
from taskflower.db.helpers import add_to_db from taskflower.db.helpers import add_to_db
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.task import task_form_for_user from taskflower.form import FormEditsObjectWithUser, task
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
from taskflower.types.option import Option from taskflower.types.option import Option
from taskflower.web.errors import ( from taskflower.web.errors import (
ResponseErrorNotFound, ResponseErrorNotFound,
response_from_exception response_from_exception,
status_response
) )
from taskflower.web.utils.request import get_next
web_tasks = Blueprint( web_tasks = Blueprint(
'task', 'task',
@ -93,9 +97,13 @@ def new():
@web_tasks.route('/<int:id>/complete') @web_tasks.route('/<int:id>/complete')
@login_required @login_required
def complete(id: int): def complete(id: int):
next = request.args.get('next') next = get_next(
if not next: request,
next = url_for('web.task.all') 'web.task.all'
).and_then(
lambda nxt: nxt,
lambda exc: 'web.task.all'
)
def _do_complete(tsk: Task) -> Either[Exception, Task]: def _do_complete(tsk: Task) -> Either[Exception, Task]:
try: try:
@ -144,9 +152,13 @@ def complete(id: int):
@web_tasks.route('/<int:id>/uncomplete') @web_tasks.route('/<int:id>/uncomplete')
@login_required @login_required
def uncomplete(id: int): def uncomplete(id: int):
next = request.args.get('next') next = get_next(
if not next: request,
next = url_for('web.task.all') 'web.task.all'
).and_then(
lambda nxt: nxt,
lambda exc: 'web.task.all'
)
def _do_uncomplete(tsk: Task) -> Either[Exception, Task]: def _do_uncomplete(tsk: Task) -> Either[Exception, Task]:
try: try:
@ -192,12 +204,127 @@ def uncomplete(id: int):
lambda err: response_from_exception(err) lambda err: response_from_exception(err)
) )
@web_tasks.route('/<int:id>/edit') @web_tasks.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required @login_required
def edit(id: int): def edit(id: int):
return redirect(url_for('web.task.all')) cur_usr: User = current_user # pyright:ignore[reportAssignmentType]
next = get_next(
request,
'web.task.all'
).and_then(
lambda nxt: nxt,
lambda exc: 'web.task.all'
)
@web_tasks.route('/<int:id>/delete') lookup_result = Option[Task].encapsulate(
db.session.query(
Task
).filter(
Task.id==id
).one_or_none()
).and_then(
lambda tsk: Right[Exception, Task](tsk),
lambda: Left[Exception, Task](ResponseErrorNotFound(
reason=f'User {cur_usr.id} ({cur_usr.username}) tried to edit a task with ID {id}, but that ID was not found!',
user_reason=not_found_or_not_authorized(
'Task',
str(id)
)
))
).flat_map(
lambda tsk: assert_user_perms_on_task(
cur_usr,
tsk,
NamespacePermissionType.EDIT_ALL_TASKS
)
).lside_effect(
check_for_auth_err_and_report
).flat_map(
lambda tsk: Right[Exception, FormEditsObjectWithUser[Task]](
task_edit_form_for_task(
tsk
)(request.form)
).map(
lambda form: (tsk, form)
)
)
if isinstance(lookup_result, Right):
task, task_form = lookup_result.val
if request.method == 'POST' and task_form.validate():
return task_form.edit_object(
cur_usr,
task
).lside_effect(
check_for_auth_err_and_report
).flat_map(
lambda tsk: commit_update(tsk, db)
).and_then(
lambda tsk: redirect(next),
lambda exc: response_from_exception(exc)
)
else:
return render_template(
'task/edit.html',
form=task_form
)
elif isinstance(lookup_result, Left):
return response_from_exception(lookup_result.val)
else:
return status_response(HTTPStatus.INTERNAL_SERVER_ERROR)
@web_tasks.route('/<int:id>/delete', methods=['GET', 'POST'])
@login_required @login_required
def delete(id: int): def delete(id: int):
return redirect(url_for('web.task.all')) cur_usr: User = current_user # pyright:ignore[reportAssignmentType]
next = get_next(
request,
'web.task.all'
).and_then(
lambda n: n,
lambda exc: 'web.task.all'
)
lookup_result = Option[Task].encapsulate(
db.session.query(
Task
).filter(
Task.id==id
).one_or_none()
).and_then(
lambda tsk: Right[Exception, Task](tsk),
lambda: Left[Exception, Task](ResponseErrorNotFound(
reason=f'User {cur_usr.id} ({cur_usr.username}) tried to delete a task with ID {id}, but that ID was not found!',
user_reason=not_found_or_not_authorized(
'Task',
str(id)
)
))
).flat_map(
lambda tsk: assert_user_perms_on_task(
cur_usr,
tsk,
NamespacePermissionType.DELETE_ALL_TASKS
)
).lside_effect(
check_for_auth_err_and_report
)
if isinstance(lookup_result, Right):
task = lookup_result.val
if request.method == 'POST':
return do_delete(task, db).and_then(
lambda _: redirect(next),
lambda exc: response_from_exception(exc)
)
else:
return render_template(
'task/confirm_delete.html',
task=task
)
elif isinstance(lookup_result, Left):
return response_from_exception(lookup_result.val)
else:
return status_response(HTTPStatus.INTERNAL_SERVER_ERROR)

View file

View file

@ -0,0 +1,22 @@
from flask import Request, url_for
from taskflower.types.either import Either, Right
def get_next(
request: Request,
default: str|None = None
) -> Either[Exception, str]:
''' Checks the ``next`` attribute and either returns it (if valid),
returns the default (if not present), or returns Left if ``next``
is present but invalid (as in a CSRF attack).
'''
next = request.args.get('next')
if next:
# TODO: CSRF protection
return Right(next)
else:
return Right(
default
if default is not None
else url_for('index')
)