Implement namespace creation

This commit is contained in:
digimint 2025-11-18 02:51:11 -06:00
parent fe871625f4
commit 03a12b7889
Signed by: digimint
GPG key ID: 8DF1C6FD85ABF748
15 changed files with 378 additions and 13 deletions

View file

@ -153,6 +153,21 @@ def initialize_user_permissions(user: User):
lambda _: 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): class AuthorizationError(Exception):
def __init__( def __init__(
self, self,

View file

@ -9,7 +9,7 @@ class Namespace(db.Model):
__tablename__: str = 'namespace' __tablename__: str = 'namespace'
id: Mapped[int] = mapped_column(Integer, primary_key=True) 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) description: Mapped[str] = mapped_column(String)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View file

@ -9,7 +9,7 @@ class Task(db.Model):
__tablename__: str = 'task' __tablename__: str = 'task'
id: Mapped[int] = mapped_column(Integer, primary_key=True) 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) description: Mapped[str] = mapped_column(String)
due: Mapped[datetime] = mapped_column(DateTime(timezone=True)) due: Mapped[datetime] = mapped_column(DateTime(timezone=True))
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View file

@ -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!')
)

View file

@ -27,7 +27,11 @@ def task_form_for_user(
name: StringField = StringField( name: StringField = StringField(
'Task Name', '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( due: DateTimeLocalField = DateTimeLocalField(

View file

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32mm"
height="32mm"
viewBox="0 0 32 32"
version="1.1"
id="svg5"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="check-box-checked.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
inkscape:snap-bbox="true"
inkscape:snap-bbox-midpoints="true"
inkscape:snap-object-midpoints="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:bbox-nodes="true"
inkscape:bbox-paths="true"
inkscape:snap-page="true"
inkscape:zoom="3.0003928"
inkscape:cx="113.98508"
inkscape:cy="79.322947"
inkscape:window-width="1920"
inkscape:window-height="1018"
inkscape:window-x="1600"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:none;stroke:#ffffff;stroke-width:1.9973;stroke-opacity:1"
id="rect846"
width="26.458271"
height="26.458271"
x="2.7708645"
y="2.7708645"
ry="4.2728744" />
<path
style="fill:none;stroke:#ffffff;stroke-width:4.665;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 8.2015025,17.385392 7.0201045,5.164044 8.57689,-13.0987712"
id="path1171"
sodipodi:nodetypes="ccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32mm"
height="32mm"
viewBox="0 0 32 32"
version="1.1"
id="svg5"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="check-box-unchecked.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
inkscape:snap-bbox="true"
inkscape:snap-bbox-midpoints="true"
inkscape:snap-object-midpoints="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:bbox-nodes="true"
inkscape:bbox-paths="true"
inkscape:snap-page="true"
inkscape:zoom="3.0003928"
inkscape:cx="38.994894"
inkscape:cy="72.657153"
inkscape:window-width="1920"
inkscape:window-height="1018"
inkscape:window-x="1600"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:none;stroke:#ffffff;stroke-width:1.9973;stroke-opacity:1"
id="rect846"
width="26.458271"
height="26.458271"
x="2.7708645"
y="2.7708645"
ry="4.2728744" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -5,6 +5,7 @@
{% block main_content %} {% block main_content %}
<h1>Error: {{ err_name }}</h1> <h1>Error: {{ err_name }}</h1>
<p>{{ err_description }}</p> <p>{{ err_description }}</p>
<p>Details: {{ err_details }}</p>
<p><a class="button" href="/">Return Home</a></p> <p><a class="button" href="/">Return Home</a></p>

View file

@ -10,6 +10,7 @@
<a href={{ url_for("web.task.new") }}>Create Task</a> <a href={{ url_for("web.task.new") }}>Create Task</a>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{{ namespace_list(fetch_namespaces(current_user)) }} {{ namespace_list(fetch_namespaces(current_user)) }}
<a href="{{ url_for('web.namespace.new') }}">+ New Namespace</a>
{% else %} {% else %}
<div id="sidebar-spacer"></div> <div id="sidebar-spacer"></div>
{% endif %} {% endif %}

View file

@ -0,0 +1,17 @@
{% extends "main.html" %}
{% from "_formhelpers.html" import render_field %}
{% block head_extras %}
<link rel="stylesheet" href="{{ url_for('static', filename='forms.css') }}"
{% endblock %}
{% block main_content %}
<form class="default-form" id="create-namespace-form" method="POST">
<h1>Create a Namespace</h1>
<dl>
{{ render_field(form.name) }}
{{ render_field(form.description) }}
</dl>
<input id="submit-form" type="submit" value="CREATE NAMESPACE" />
</form>
{% endblock %}

View file

@ -14,9 +14,13 @@
<tr class="task-table-row" id="{{ tlist_cid('row', task.id, list_id) }}"> <tr class="task-table-row" id="{{ tlist_cid('row', task.id, list_id) }}">
<td class="checkbox" id="{{ tlist_cid('check', task.id, list_id) }}"> <td class="checkbox" id="{{ tlist_cid('check', task.id, list_id) }}">
{% if task.complete %} {% if task.complete %}
<input id="{{ tlist_cid('check-inner', task.id, list_id) }}" type="checkbox" checked> <a id="{{ tlist_cid('check-inner', task.id, list_id) }}" href="{{ url_for('web.task.uncomplete', id=task.id, next=request.path) }}">
<img src="{{ url_for('static', filename='check-box-checked.svg') }}" />
</a>
{% else %} {% else %}
<input id="{{ tlist_cid('check-inner', task.id, list_id) }}" type="checkbox"> <a id="{{ tlist_cid('check-inner', task.id, list_id) }}" href="{{ url_for('web.task.complete', id=task.id, next=request.path) }}">
<img src="{{ url_for('static', filename='check-box-unchecked.svg') }}" />
</a>
{% endif %} {% endif %}
</td> </td>
<td <td

View file

@ -20,7 +20,11 @@
{% macro task_list_script() %} {% macro task_list_script() %}
<style> <style>
.list .checkbox { .list .checkbox {
padding: 1rem; padding: 0.5rem 1rem;
}
.list .checkbox img {
max-width: 1.5rem;
} }
.list .task-due { .list .task-due {

View file

@ -3,6 +3,8 @@ import logging
from typing import override from typing import override
from flask import render_template 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.config import config
from taskflower.types import FlaskViewReturnType from taskflower.types import FlaskViewReturnType
from taskflower.types.option import Nothing, Option, Some from taskflower.types.option import Nothing, Option, Some
@ -40,6 +42,7 @@ class ResponseErrorType(Exception):
) )
self.page: str = page_name self.page: str = page_name
self.reason: str = reason self.reason: str = reason
self.user_reason: str = user_reason
as_str = ( as_str = (
f'Request: ``{str(method)} {page_name}`` resulted in {str(self.status)}. Reason: {reason}' 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: 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: if exc:
log.warning(f'Request generated exception: {str(exc)}') log.warning(f'Request generated exception: {str(exc)}')
if isinstance(exc, ResponseErrorType): if isinstance(exc, ResponseErrorType):
return status_response(exc.status) return status_response(
exc.status,
exc.user_reason
)
else: else:
return status_response(HTTPStatus.INTERNAL_SERVER_ERROR) return status_response(HTTPStatus.INTERNAL_SERVER_ERROR)

View file

@ -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 flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs]
from taskflower.auth.messages import not_found_or_not_authorized 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.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.permission.lookups import get_namespaces_for_user
from taskflower.auth.violations import check_for_auth_err_and_report 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.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.namespace import NamespaceForm
from taskflower.sanitize.namespace import NamespaceForUser from taskflower.sanitize.namespace import NamespaceForUser
from taskflower.sanitize.task import TaskForUser 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.types.option import Option
from taskflower.web.errors import ResponseErrorNotFound, response_from_exception from taskflower.web.errors import ResponseErrorNotFound, response_from_exception
@ -106,7 +107,7 @@ def get(id: int):
f'Namespace with id {id} not found!', f'Namespace with id {id} not found!',
not_found_or_not_authorized( not_found_or_not_authorized(
'Namespace', 'Namespace',
'id' str(id)
) )
) )
) )
@ -136,4 +137,36 @@ def get(id: int):
lambda exc: response_from_exception( lambda exc: response_from_exception(
exc 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
) )

View file

@ -1,14 +1,21 @@
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]
from taskflower.auth.messages import not_found_or_not_authorized
from taskflower.auth.permission import NamespacePermissionType 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.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.helpers import add_to_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.form.task import task_form_for_user
from taskflower.sanitize.task import TaskForUser 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 ( from taskflower.web.errors import (
ResponseErrorNotFound,
response_from_exception response_from_exception
) )
@ -81,4 +88,106 @@ def new():
return render_template( return render_template(
'new_task.html', 'new_task.html',
form=form_data form=form_data
)
@web_tasks.route('/<int:id>/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('/<int:id>/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)
) )