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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
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(
|
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(
|
||||||
|
|
|
||||||
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 %}
|
{% 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
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) }}">
|
<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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue