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
)
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,

View file

@ -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())

View file

@ -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())

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(
'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(

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 %}
<h1>Error: {{ err_name }}</h1>
<p>{{ err_description }}</p>
<p>Details: {{ err_details }}</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>
{% if current_user.is_authenticated %}
{{ namespace_list(fetch_namespaces(current_user)) }}
<a href="{{ url_for('web.namespace.new') }}">+ New Namespace</a>
{% else %}
<div id="sidebar-spacer"></div>
{% 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) }}">
<td class="checkbox" id="{{ tlist_cid('check', task.id, list_id) }}">
{% 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 %}
<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 %}
</td>
<td

View file

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

View file

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

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 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
)

View file

@ -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('/<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)
)