From 9508a8b1327da0f579cc0028729864c78a53efc2 Mon Sep 17 00:00:00 2001 From: digimint Date: Tue, 18 Nov 2025 23:02:25 -0600 Subject: [PATCH] Finish task edit and delete pages Closes #2 --- src/taskflower/db/__init__.py | 47 +++++- src/taskflower/form/task.py | 83 +++++++--- src/taskflower/static/forms.css | 25 +-- .../templates/task/confirm_delete.html | 16 ++ src/taskflower/templates/task/edit.html | 2 +- src/taskflower/types/either.py | 21 ++- src/taskflower/web/task/__init__.py | 153 ++++++++++++++++-- src/taskflower/web/utils/__init__.py | 0 src/taskflower/web/utils/request.py | 22 +++ 9 files changed, 315 insertions(+), 54 deletions(-) create mode 100644 src/taskflower/templates/task/confirm_delete.html create mode 100644 src/taskflower/web/utils/__init__.py create mode 100644 src/taskflower/web/utils/request.py diff --git a/src/taskflower/db/__init__.py b/src/taskflower/db/__init__.py index 2d70b23..85cdf70 100644 --- a/src/taskflower/db/__init__.py +++ b/src/taskflower/db/__init__.py @@ -1,7 +1,52 @@ from flask_sqlalchemy import SQLAlchemy from sqlalchemy.orm import DeclarativeBase +from taskflower.types.either import Either, Left, Right + class Base(DeclarativeBase): pass -db = SQLAlchemy(model_class=Base) \ No newline at end of file +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) \ No newline at end of file diff --git a/src/taskflower/form/task.py b/src/taskflower/form/task.py index 6344267..55411c3 100644 --- a/src/taskflower/form/task.py +++ b/src/taskflower/form/task.py @@ -2,39 +2,74 @@ from typing import override from wtforms import DateTimeLocalField, SelectField, StringField, TextAreaField, ValidationError, validators 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.db import 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 FormCreatesObjectWithUser, FormEditsObjectWithUser +from taskflower.types import ann from taskflower.types.either import Either, Left, Right from taskflower.types.option import Option -class TaskEditForm(FormEditsObjectWithUser[Task]): - name: StringField = StringField( - 'Task Name', - [ - validators.Length( - min=1, - max=64, - message='Task name must be between 1 and 64 characters!' - ) - ] - ) - due: DateTimeLocalField = DateTimeLocalField( - 'Due Date', - [ - validators.DataRequired() - ] - ) - description: TextAreaField = TextAreaField( - 'Description', - [ - validators.Optional() - ] - ) +def task_edit_form_for_task( + t: Task +) -> type[FormEditsObjectWithUser[Task]]: + class TaskEditForm(FormEditsObjectWithUser[Task]): + name: StringField = StringField( + 'Task Name', + [ + validators.Length( + min=1, + max=64, + message='Task name must be between 1 and 64 characters!' + ) + ], + default=t.name + ) + due: DateTimeLocalField = DateTimeLocalField( + 'Due Date', + [ + validators.DataRequired() + ], + 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( diff --git a/src/taskflower/static/forms.css b/src/taskflower/static/forms.css index e93d347..90e43d4 100644 --- a/src/taskflower/static/forms.css +++ b/src/taskflower/static/forms.css @@ -22,17 +22,20 @@ } #submit-form { - padding: 1rem; - margin-top: 2rem; - background-color: var(--accent-1-hlt); - color: var(--on-accent-1); - font-size: larger; + background-color: var(--btn-1); + color: var(--on-btn-1); + width: max-content; + padding: 1rem 1.5rem; + border: 2px solid var(--btn-1-border); + border-radius: 0.25rem; + margin: 0.5rem; + transition: all 0.2s; + text-decoration: none; font-weight: bold; - border: 2px solid var(--accent-1); - border-radius: 1rem; - transition: background-color 0.5s; -} + font-size: larger; -#submit-form:hover { - background-color: var(--accent-1); + &:hover { + background-color: var(--btn-1-hlt); + color: var(--on-btn-1-hlt); + } } \ No newline at end of file diff --git a/src/taskflower/templates/task/confirm_delete.html b/src/taskflower/templates/task/confirm_delete.html new file mode 100644 index 0000000..924ba43 --- /dev/null +++ b/src/taskflower/templates/task/confirm_delete.html @@ -0,0 +1,16 @@ +{% extends "main.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block head_extras %} + +{% endblock %} + +{% block title %}Delete Task{% endblock %} + +{% block main_content %} +
+

Delete Task

+

Are you sure you want to delete the task "{{ task.name }}"?

+ +
+{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/task/edit.html b/src/taskflower/templates/task/edit.html index 3e5fa8e..9a3da2d 100644 --- a/src/taskflower/templates/task/edit.html +++ b/src/taskflower/templates/task/edit.html @@ -15,6 +15,6 @@ {{ render_field(form.due) }} {{ render_field(form.description) }} - + {% endblock %} \ No newline at end of file diff --git a/src/taskflower/types/either.py b/src/taskflower/types/either.py index b92a565..45c539a 100644 --- a/src/taskflower/types/either.py +++ b/src/taskflower/types/either.py @@ -31,7 +31,7 @@ class Either[L, R](ABC): raise NotImplementedError() @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() @abstractmethod @@ -46,6 +46,17 @@ class Either[L, R](ABC): ) -> X: 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 class Left[L, R](Either[L, R]): def __init__(self, lf: L): @@ -79,8 +90,10 @@ class Left[L, R](Either[L, R]): return self @override - def lside_effect[X](self, f: Callable[[L], X]) -> 'Either[L, R]': - _ = f(self.val) + def lside_effect[X](self, f: Callable[[L], X], filter: type|None = None) -> 'Either[L, R]': + if (not filter) or isinstance(self.val, filter): + _ = f(self.val) + return self @override @@ -145,7 +158,7 @@ class Right[L, R](Either[L, R]): return self @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 @override diff --git a/src/taskflower/web/task/__init__.py b/src/taskflower/web/task/__init__.py index 193ed79..b80d774 100644 --- a/src/taskflower/web/task/__init__.py +++ b/src/taskflower/web/task/__init__.py @@ -1,3 +1,4 @@ +from http import HTTPStatus from flask import Blueprint, redirect, render_template, request, url_for 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.lookups import tasks_where_user_can 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.model.task import Task 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.types.either import Either, Left, Right, reduce_either from taskflower.types.option import Option from taskflower.web.errors import ( ResponseErrorNotFound, - response_from_exception + response_from_exception, + status_response ) +from taskflower.web.utils.request import get_next web_tasks = Blueprint( 'task', @@ -93,9 +97,13 @@ def new(): @web_tasks.route('//complete') @login_required def complete(id: int): - next = request.args.get('next') - if not next: - next = url_for('web.task.all') + next = get_next( + request, + 'web.task.all' + ).and_then( + lambda nxt: nxt, + lambda exc: 'web.task.all' + ) def _do_complete(tsk: Task) -> Either[Exception, Task]: try: @@ -144,9 +152,13 @@ def complete(id: int): @web_tasks.route('//uncomplete') @login_required def uncomplete(id: int): - next = request.args.get('next') - if not next: - next = url_for('web.task.all') + next = get_next( + request, + 'web.task.all' + ).and_then( + lambda nxt: nxt, + lambda exc: 'web.task.all' + ) def _do_uncomplete(tsk: Task) -> Either[Exception, Task]: try: @@ -192,12 +204,127 @@ def uncomplete(id: int): lambda err: response_from_exception(err) ) -@web_tasks.route('//edit') +@web_tasks.route('//edit', methods=['GET', 'POST']) @login_required 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('//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('//delete', methods=['GET', 'POST']) @login_required def delete(id: int): - return redirect(url_for('web.task.all')) \ No newline at end of file + 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) \ No newline at end of file diff --git a/src/taskflower/web/utils/__init__.py b/src/taskflower/web/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/taskflower/web/utils/request.py b/src/taskflower/web/utils/request.py new file mode 100644 index 0000000..bd55aca --- /dev/null +++ b/src/taskflower/web/utils/request.py @@ -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') + ) \ No newline at end of file