Implement namespace creation
This commit is contained in:
parent
fe871625f4
commit
03a12b7889
15 changed files with 378 additions and 13 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
43
src/taskflower/form/namespace.py
Normal file
43
src/taskflower/form/namespace.py
Normal 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!')
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
62
src/taskflower/static/check-box-checked.svg
Normal file
62
src/taskflower/static/check-box-checked.svg
Normal 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 |
57
src/taskflower/static/check-box-unchecked.svg
Normal file
57
src/taskflower/static/check-box-unchecked.svg
Normal 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 |
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
17
src/taskflower/templates/namespace/new.html
Normal file
17
src/taskflower/templates/namespace/new.html
Normal 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 %}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue