From 03a12b78891cd6e368ab18488812a5f6a5d24c41 Mon Sep 17 00:00:00 2001 From: digimint Date: Tue, 18 Nov 2025 02:51:11 -0600 Subject: [PATCH] Implement namespace creation --- src/taskflower/auth/permission/__init__.py | 15 +++ src/taskflower/db/model/namespace.py | 2 +- src/taskflower/db/model/task.py | 2 +- src/taskflower/form/namespace.py | 43 +++++++ src/taskflower/form/task.py | 6 +- src/taskflower/static/check-box-checked.svg | 62 ++++++++++ src/taskflower/static/check-box-unchecked.svg | 57 +++++++++ src/taskflower/templates/error/error.html | 1 + src/taskflower/templates/main.html | 1 + src/taskflower/templates/namespace/new.html | 17 +++ src/taskflower/templates/task/_shorttask.html | 8 +- src/taskflower/templates/task/_tasklist.html | 6 +- src/taskflower/web/errors/__init__.py | 19 ++- src/taskflower/web/namespace/__init__.py | 41 ++++++- src/taskflower/web/task/__init__.py | 111 +++++++++++++++++- 15 files changed, 378 insertions(+), 13 deletions(-) create mode 100644 src/taskflower/form/namespace.py create mode 100644 src/taskflower/static/check-box-checked.svg create mode 100644 src/taskflower/static/check-box-unchecked.svg create mode 100644 src/taskflower/templates/namespace/new.html diff --git a/src/taskflower/auth/permission/__init__.py b/src/taskflower/auth/permission/__init__.py index d6888b1..3b9d0c4 100644 --- a/src/taskflower/auth/permission/__init__.py +++ b/src/taskflower/auth/permission/__init__.py @@ -153,6 +153,21 @@ def initialize_user_permissions(user: User): lambda _: user ) +def initialize_namespace_permissions( + user: User, + ns: Namespace +) -> Either[Exception, Namespace]: + return _create_namespace_role( + ns + ).flat_map( + lambda ns_role: _associate_namespace_role( + user, + ns_role + ) + ).map( + lambda _: ns + ) + class AuthorizationError(Exception): def __init__( self, diff --git a/src/taskflower/db/model/namespace.py b/src/taskflower/db/model/namespace.py index f1f8499..9e32950 100644 --- a/src/taskflower/db/model/namespace.py +++ b/src/taskflower/db/model/namespace.py @@ -9,7 +9,7 @@ class Namespace(db.Model): __tablename__: str = 'namespace' id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(256)) + name: Mapped[str] = mapped_column(String(64)) description: Mapped[str] = mapped_column(String) created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/src/taskflower/db/model/task.py b/src/taskflower/db/model/task.py index ab271c4..a7ad605 100644 --- a/src/taskflower/db/model/task.py +++ b/src/taskflower/db/model/task.py @@ -9,7 +9,7 @@ class Task(db.Model): __tablename__: str = 'task' id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(256)) + name: Mapped[str] = mapped_column(String(64)) description: Mapped[str] = mapped_column(String) due: Mapped[datetime] = mapped_column(DateTime(timezone=True)) created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/src/taskflower/form/namespace.py b/src/taskflower/form/namespace.py new file mode 100644 index 0000000..1f0ccd9 --- /dev/null +++ b/src/taskflower/form/namespace.py @@ -0,0 +1,43 @@ +from typing import override +from wtforms import StringField, ValidationError +from wtforms.validators import Length +from taskflower.db.model.namespace import Namespace +from taskflower.form import FormCreatesObject +from taskflower.types.either import Either, Left, Right + + +class NamespaceForm(FormCreatesObject[Namespace]): + name: StringField = StringField( + 'Namespace Title', + [ + Length( + min=1, + max=64, + message='Namespace title must be between 1 and 64 characters!' + ) + ] + ) + description: StringField = StringField( + 'Namespace Description', + [ + Length( + min=1, + message='Namespace description must be at least 1 character long.' + ) + ], + default='A namespace.' + ) + + @override + def create_object(self) -> Either[Exception, Namespace]: + if self.validate(): + return Right( + Namespace( + name=self.name.data, # pyright:ignore[reportCallIssue] + description=self.description.data # pyright:ignore[reportCallIssue] + ) + ) + else: + return Left( + ValidationError('Form data failed validation!') + ) \ No newline at end of file diff --git a/src/taskflower/form/task.py b/src/taskflower/form/task.py index 8d0a012..49f3d4d 100644 --- a/src/taskflower/form/task.py +++ b/src/taskflower/form/task.py @@ -27,7 +27,11 @@ def task_form_for_user( name: StringField = StringField( 'Task Name', [ - validators.Length(min=1, message='Task name is too short!') + validators.Length( + min=1, + max=64, + message='Task name must be between 1 and 64 characters!' + ) ] ) due: DateTimeLocalField = DateTimeLocalField( diff --git a/src/taskflower/static/check-box-checked.svg b/src/taskflower/static/check-box-checked.svg new file mode 100644 index 0000000..800d021 --- /dev/null +++ b/src/taskflower/static/check-box-checked.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + diff --git a/src/taskflower/static/check-box-unchecked.svg b/src/taskflower/static/check-box-unchecked.svg new file mode 100644 index 0000000..bf8ff6b --- /dev/null +++ b/src/taskflower/static/check-box-unchecked.svg @@ -0,0 +1,57 @@ + + + + + + + + + + diff --git a/src/taskflower/templates/error/error.html b/src/taskflower/templates/error/error.html index face059..3a80089 100644 --- a/src/taskflower/templates/error/error.html +++ b/src/taskflower/templates/error/error.html @@ -5,6 +5,7 @@ {% block main_content %}

Error: {{ err_name }}

{{ err_description }}

+

Details: {{ err_details }}

Return Home

diff --git a/src/taskflower/templates/main.html b/src/taskflower/templates/main.html index 7f81977..e2b7ac1 100644 --- a/src/taskflower/templates/main.html +++ b/src/taskflower/templates/main.html @@ -10,6 +10,7 @@ Create Task {% if current_user.is_authenticated %} {{ namespace_list(fetch_namespaces(current_user)) }} + + New Namespace {% else %} {% endif %} diff --git a/src/taskflower/templates/namespace/new.html b/src/taskflower/templates/namespace/new.html new file mode 100644 index 0000000..69e7efb --- /dev/null +++ b/src/taskflower/templates/namespace/new.html @@ -0,0 +1,17 @@ +{% extends "main.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block head_extras %} + +

Create a Namespace

+
+ {{ render_field(form.name) }} + {{ render_field(form.description) }} +
+ + +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/task/_shorttask.html b/src/taskflower/templates/task/_shorttask.html index 95772e1..a000446 100644 --- a/src/taskflower/templates/task/_shorttask.html +++ b/src/taskflower/templates/task/_shorttask.html @@ -14,9 +14,13 @@ {% if task.complete %} - + + + {% else %} - + + + {% endif %} .list .checkbox { - padding: 1rem; + padding: 0.5rem 1rem; + } + + .list .checkbox img { + max-width: 1.5rem; } .list .task-due { diff --git a/src/taskflower/web/errors/__init__.py b/src/taskflower/web/errors/__init__.py index 0507949..d5366e1 100644 --- a/src/taskflower/web/errors/__init__.py +++ b/src/taskflower/web/errors/__init__.py @@ -3,6 +3,8 @@ import logging from typing import override from flask import render_template +from taskflower.auth.messages import not_found_or_not_authorized +from taskflower.auth.permission import AuthorizationError from taskflower.config import config from taskflower.types import FlaskViewReturnType from taskflower.types.option import Nothing, Option, Some @@ -40,6 +42,7 @@ class ResponseErrorType(Exception): ) self.page: str = page_name self.reason: str = reason + self.user_reason: str = user_reason as_str = ( f'Request: ``{str(method)} {page_name}`` resulted in {str(self.status)}. Reason: {reason}' @@ -116,10 +119,22 @@ def status_response(code: HTTPStatus, user_description: str|None = None) -> Flas ) def response_from_exception(exc: Exception|None = None) -> FlaskViewReturnType: + if isinstance(exc, AuthorizationError): + return status_response( + HTTPStatus.NOT_FOUND, + not_found_or_not_authorized( + type(exc.resource).__name__, + str(exc.resource.id) + ) + ) + if exc: log.warning(f'Request generated exception: {str(exc)}') - + if isinstance(exc, ResponseErrorType): - return status_response(exc.status) + return status_response( + exc.status, + exc.user_reason + ) else: return status_response(HTTPStatus.INTERNAL_SERVER_ERROR) \ No newline at end of file diff --git a/src/taskflower/web/namespace/__init__.py b/src/taskflower/web/namespace/__init__.py index 1f2f0b6..3be0d83 100644 --- a/src/taskflower/web/namespace/__init__.py +++ b/src/taskflower/web/namespace/__init__.py @@ -1,8 +1,8 @@ -from flask import Blueprint, render_template, request +from flask import Blueprint, redirect, render_template, request, url_for from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs] from taskflower.auth.messages import not_found_or_not_authorized -from taskflower.auth.permission import NamespacePermissionType +from taskflower.auth.permission import NamespacePermissionType, initialize_namespace_permissions from taskflower.auth.permission.checks import assert_user_perms_on_namespace, assert_user_perms_on_task from taskflower.auth.permission.lookups import get_namespaces_for_user from taskflower.auth.violations import check_for_auth_err_and_report @@ -10,9 +10,10 @@ 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.namespace import NamespaceForm from taskflower.sanitize.namespace import NamespaceForUser from taskflower.sanitize.task import TaskForUser -from taskflower.types.either import Left, Right, gather_successes +from taskflower.types.either import Either, Left, Right, gather_successes from taskflower.types.option import Option from taskflower.web.errors import ResponseErrorNotFound, response_from_exception @@ -106,7 +107,7 @@ def get(id: int): f'Namespace with id {id} not found!', not_found_or_not_authorized( 'Namespace', - 'id' + str(id) ) ) ) @@ -136,4 +137,36 @@ def get(id: int): lambda exc: response_from_exception( exc ) + ) + +@web_namespace.route('/new', methods=['GET', 'POST']) +@login_required +def new(): + cur_usr: User = current_user # pyright:ignore[reportAssignmentType] + def _add_to_db(ns: Namespace) -> Either[Exception, Namespace]: + try: + db.session.add(ns) + db.session.commit() + return Right(ns) + except Exception as e: + return Left(e) + + form_data = NamespaceForm(request.form) + + if request.method == 'POST' and form_data.validate(): + return form_data.create_object().flat_map( + _add_to_db + ).flat_map( + lambda ns: initialize_namespace_permissions( + cur_usr, + ns + ) + ).and_then( + lambda ns: redirect(url_for('web.namespace.get', id=ns.id)), + lambda err: response_from_exception(err) + ) + + return render_template( + 'namespace/new.html', + form=form_data ) \ No newline at end of file diff --git a/src/taskflower/web/task/__init__.py b/src/taskflower/web/task/__init__.py index fcccd58..1c69c41 100644 --- a/src/taskflower/web/task/__init__.py +++ b/src/taskflower/web/task/__init__.py @@ -1,14 +1,21 @@ from flask import Blueprint, redirect, render_template, request, url_for from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs] +from taskflower.auth.messages import not_found_or_not_authorized 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.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.sanitize.task import TaskForUser -from taskflower.types.either import reduce_either +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 ) @@ -81,4 +88,106 @@ def new(): return render_template( 'new_task.html', form=form_data + ) + +@web_tasks.route('//complete') +@login_required +def complete(id: int): + next = request.args.get('next') + if not next: + next = url_for('web.task.all') + + def _do_complete(tsk: Task) -> Either[Exception, Task]: + try: + tsk.complete = True + db.session.commit() + return Right(tsk) + except Exception as e: + db.session.rollback() + return Left(e) + + cur_usr: User = current_user # pyright:ignore[reportAssignmentType] + + return Option[Task].encapsulate( + db.session.query( + Task + ).filter( + Task.id == id + ).one_or_none() + ).and_then( + lambda v: Right[Exception, Task](v), + lambda: Left[Exception, Task](ResponseErrorNotFound( + request.method, + request.path, + f'Task with ID {id} not found!', + not_found_or_not_authorized( + 'Task', + str(id) + ) + )) + ).flat_map( + lambda tsk: assert_user_perms_on_task( + cur_usr, + tsk, + NamespacePermissionType.COMPLETE_ALL, + 'Complete task' + ) + ).lside_effect( + check_for_auth_err_and_report + ).flat_map( + _do_complete + ).and_then( + lambda usr_tsk: redirect(next), + lambda err: response_from_exception(err) + ) + +@web_tasks.route('//uncomplete') +@login_required +def uncomplete(id: int): + next = request.args.get('next') + if not next: + next = url_for('web.task.all') + + def _do_uncomplete(tsk: Task) -> Either[Exception, Task]: + try: + tsk.complete = False + db.session.commit() + return Right(tsk) + except Exception as e: + db.session.rollback() + return Left(e) + + cur_usr: User = current_user # pyright:ignore[reportAssignmentType] + + return Option[Task].encapsulate( + db.session.query( + Task + ).filter( + Task.id == id + ).one_or_none() + ).and_then( + lambda v: Right[Exception, Task](v), + lambda: Left[Exception, Task](ResponseErrorNotFound( + request.method, + request.path, + f'Task with ID {id} not found!', + not_found_or_not_authorized( + 'Task', + str(id) + ) + )) + ).flat_map( + lambda tsk: assert_user_perms_on_task( + cur_usr, + tsk, + NamespacePermissionType.UNCOMPLETE_ALL, + 'Complete task' + ) + ).lside_effect( + check_for_auth_err_and_report + ).flat_map( + _do_uncomplete + ).and_then( + lambda usr_tsk: redirect(next), + lambda err: response_from_exception(err) ) \ No newline at end of file