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
|