parent
47b1e6ca82
commit
9508a8b132
9 changed files with 315 additions and 54 deletions
|
|
@ -1,7 +1,52 @@
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from taskflower.types.either import Either, Left, Right
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
db = SQLAlchemy(model_class=Base)
|
db = SQLAlchemy(model_class=Base)
|
||||||
|
|
||||||
|
def do_commit(
|
||||||
|
database: SQLAlchemy
|
||||||
|
) -> Either[Exception, None]:
|
||||||
|
try:
|
||||||
|
database.session.commit()
|
||||||
|
return Right(None)
|
||||||
|
except Exception as e:
|
||||||
|
database.session.rollback()
|
||||||
|
return Left(e)
|
||||||
|
|
||||||
|
def do_delete[T](
|
||||||
|
to_delete: T,
|
||||||
|
database: SQLAlchemy
|
||||||
|
) -> Either[Exception, None]:
|
||||||
|
try:
|
||||||
|
db.session.delete(to_delete)
|
||||||
|
return do_commit(database)
|
||||||
|
except Exception as e:
|
||||||
|
return Left(e)
|
||||||
|
|
||||||
|
def commit_update[T](
|
||||||
|
to_update: T,
|
||||||
|
database: SQLAlchemy
|
||||||
|
) -> Either[Exception, T]:
|
||||||
|
''' Convenience wrapper around ``do_commit()`` which passes the data to be
|
||||||
|
updated through the function. This function does not affect
|
||||||
|
``to_update`` - it only passes it through as the ``Right()`` value of
|
||||||
|
the response. Meant for use in ``flat_map()`` chains.
|
||||||
|
'''
|
||||||
|
return do_commit(database).map(
|
||||||
|
lambda _: to_update
|
||||||
|
)
|
||||||
|
|
||||||
|
def insert_into_db[T](
|
||||||
|
to_insert: T,
|
||||||
|
database: SQLAlchemy
|
||||||
|
) -> Either[Exception, T]:
|
||||||
|
try:
|
||||||
|
database.session.add(to_insert)
|
||||||
|
return do_commit(database).map(lambda _: to_insert)
|
||||||
|
except Exception as e:
|
||||||
|
return Left(e)
|
||||||
|
|
@ -2,39 +2,74 @@ from typing import override
|
||||||
from wtforms import DateTimeLocalField, SelectField, StringField, TextAreaField, ValidationError, validators
|
from wtforms import DateTimeLocalField, SelectField, StringField, TextAreaField, ValidationError, validators
|
||||||
|
|
||||||
from taskflower.auth.permission import NamespacePermissionType
|
from taskflower.auth.permission import NamespacePermissionType
|
||||||
from taskflower.auth.permission.checks import assert_user_perms_on_namespace
|
from taskflower.auth.permission.checks import assert_user_perms_on_namespace, assert_user_perms_on_task
|
||||||
from taskflower.auth.permission.lookups import namespaces_where_user_can
|
from taskflower.auth.permission.lookups import namespaces_where_user_can
|
||||||
from taskflower.db import db
|
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 import FormCreatesObjectWithUser, FormEditsObjectWithUser
|
from taskflower.form import FormCreatesObjectWithUser, FormEditsObjectWithUser
|
||||||
|
from taskflower.types import ann
|
||||||
from taskflower.types.either import Either, Left, Right
|
from taskflower.types.either import Either, Left, Right
|
||||||
from taskflower.types.option import Option
|
from taskflower.types.option import Option
|
||||||
|
|
||||||
class TaskEditForm(FormEditsObjectWithUser[Task]):
|
def task_edit_form_for_task(
|
||||||
name: StringField = StringField(
|
t: Task
|
||||||
'Task Name',
|
) -> type[FormEditsObjectWithUser[Task]]:
|
||||||
[
|
class TaskEditForm(FormEditsObjectWithUser[Task]):
|
||||||
validators.Length(
|
name: StringField = StringField(
|
||||||
min=1,
|
'Task Name',
|
||||||
max=64,
|
[
|
||||||
message='Task name must be between 1 and 64 characters!'
|
validators.Length(
|
||||||
)
|
min=1,
|
||||||
]
|
max=64,
|
||||||
)
|
message='Task name must be between 1 and 64 characters!'
|
||||||
due: DateTimeLocalField = DateTimeLocalField(
|
)
|
||||||
'Due Date',
|
],
|
||||||
[
|
default=t.name
|
||||||
validators.DataRequired()
|
)
|
||||||
]
|
due: DateTimeLocalField = DateTimeLocalField(
|
||||||
)
|
'Due Date',
|
||||||
description: TextAreaField = TextAreaField(
|
[
|
||||||
'Description',
|
validators.DataRequired()
|
||||||
[
|
],
|
||||||
validators.Optional()
|
default=t.due # pyright:ignore[reportArgumentType]
|
||||||
]
|
)
|
||||||
)
|
description: TextAreaField = TextAreaField(
|
||||||
|
'Description',
|
||||||
|
[
|
||||||
|
validators.Optional()
|
||||||
|
],
|
||||||
|
default=t.description
|
||||||
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def edit_object(self, current_user: User, target_object: Task) -> Either[Exception, Task]:
|
||||||
|
def _do_edit(tsk: Task) -> Either[Exception, Task]:
|
||||||
|
tsk.name = ann(self.name.data)
|
||||||
|
tsk.due = ann(self.due.data)
|
||||||
|
tsk.description = (
|
||||||
|
self.description.data
|
||||||
|
) if self.description.data else ''
|
||||||
|
|
||||||
|
return Right(tsk)
|
||||||
|
|
||||||
|
if self.validate():
|
||||||
|
return assert_user_perms_on_task(
|
||||||
|
current_user,
|
||||||
|
target_object,
|
||||||
|
NamespacePermissionType.EDIT_ALL_TASKS,
|
||||||
|
'Edit'
|
||||||
|
).flat_map(
|
||||||
|
_do_edit
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Left(
|
||||||
|
ValidationError(
|
||||||
|
f'{self.__class__.__name__} validation failed.'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return TaskEditForm
|
||||||
|
|
||||||
|
|
||||||
def task_form_for_user(
|
def task_form_for_user(
|
||||||
|
|
|
||||||
|
|
@ -22,17 +22,20 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#submit-form {
|
#submit-form {
|
||||||
padding: 1rem;
|
background-color: var(--btn-1);
|
||||||
margin-top: 2rem;
|
color: var(--on-btn-1);
|
||||||
background-color: var(--accent-1-hlt);
|
width: max-content;
|
||||||
color: var(--on-accent-1);
|
padding: 1rem 1.5rem;
|
||||||
font-size: larger;
|
border: 2px solid var(--btn-1-border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin: 0.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
border: 2px solid var(--accent-1);
|
font-size: larger;
|
||||||
border-radius: 1rem;
|
|
||||||
transition: background-color 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
#submit-form:hover {
|
&:hover {
|
||||||
background-color: var(--accent-1);
|
background-color: var(--btn-1-hlt);
|
||||||
|
color: var(--on-btn-1-hlt);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
16
src/taskflower/templates/task/confirm_delete.html
Normal file
16
src/taskflower/templates/task/confirm_delete.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends "main.html" %}
|
||||||
|
{% from "_formhelpers.html" import render_field %}
|
||||||
|
|
||||||
|
{% block head_extras %}
|
||||||
|
<link rel="stylesheet" href={{ url_for("static", filename="forms.css") }} />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block title %}Delete Task{% endblock %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<form class="default-form" id="delete-task-form" method="POST">
|
||||||
|
<h1>Delete Task</h1>
|
||||||
|
<h3>Are you sure you want to delete the task "{{ task.name }}"?</h3>
|
||||||
|
<button type="submit" id="submit-form">Confirm Deletion</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -15,6 +15,6 @@
|
||||||
{{ render_field(form.due) }}
|
{{ render_field(form.due) }}
|
||||||
{{ render_field(form.description) }}
|
{{ render_field(form.description) }}
|
||||||
</dl>
|
</dl>
|
||||||
<button type="submit">Create Task</button>
|
<button type="submit" id="submit-form">Edit Task</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -31,7 +31,7 @@ class Either[L, R](ABC):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def lside_effect[X](self, f: Callable[[L], X]) -> 'Either[L, R]':
|
def lside_effect[X](self, f: Callable[[L], X], filter: type|None=None) -> 'Either[L, R]':
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|
@ -46,6 +46,17 @@ class Either[L, R](ABC):
|
||||||
) -> X:
|
) -> X:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def do_assert(x: bool, desc: str = '') -> 'Either[Exception, None]':
|
||||||
|
if x:
|
||||||
|
return Right(None)
|
||||||
|
else:
|
||||||
|
return Left(AssertionError(
|
||||||
|
'Assertion failed' + (
|
||||||
|
f': {desc}'
|
||||||
|
) if desc else ''
|
||||||
|
))
|
||||||
|
|
||||||
@final
|
@final
|
||||||
class Left[L, R](Either[L, R]):
|
class Left[L, R](Either[L, R]):
|
||||||
def __init__(self, lf: L):
|
def __init__(self, lf: L):
|
||||||
|
|
@ -79,8 +90,10 @@ class Left[L, R](Either[L, R]):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def lside_effect[X](self, f: Callable[[L], X]) -> 'Either[L, R]':
|
def lside_effect[X](self, f: Callable[[L], X], filter: type|None = None) -> 'Either[L, R]':
|
||||||
_ = f(self.val)
|
if (not filter) or isinstance(self.val, filter):
|
||||||
|
_ = f(self.val)
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -145,7 +158,7 @@ class Right[L, R](Either[L, R]):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def lside_effect[X](self, f: Callable[[L], X]) -> 'Either[L, R]':
|
def lside_effect[X](self, f: Callable[[L], X], filter: type|None = None) -> 'Either[L, R]':
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from http import HTTPStatus
|
||||||
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]
|
||||||
|
|
||||||
|
|
@ -6,18 +7,21 @@ from taskflower.auth.permission import NamespacePermissionType
|
||||||
from taskflower.auth.permission.checks import assert_user_perms_on_task
|
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 import commit_update, db, do_delete
|
||||||
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.task import Task
|
||||||
from taskflower.db.model.user import User
|
from taskflower.db.model.user import User
|
||||||
from taskflower.form.task import task_form_for_user
|
from taskflower.form import FormEditsObjectWithUser, task
|
||||||
|
from taskflower.form.task import task_edit_form_for_task, task_form_for_user
|
||||||
from taskflower.sanitize.task import TaskForUser
|
from taskflower.sanitize.task import TaskForUser
|
||||||
from taskflower.types.either import Either, Left, Right, reduce_either
|
from taskflower.types.either import Either, Left, Right, reduce_either
|
||||||
from taskflower.types.option import Option
|
from taskflower.types.option import Option
|
||||||
from taskflower.web.errors import (
|
from taskflower.web.errors import (
|
||||||
ResponseErrorNotFound,
|
ResponseErrorNotFound,
|
||||||
response_from_exception
|
response_from_exception,
|
||||||
|
status_response
|
||||||
)
|
)
|
||||||
|
from taskflower.web.utils.request import get_next
|
||||||
|
|
||||||
web_tasks = Blueprint(
|
web_tasks = Blueprint(
|
||||||
'task',
|
'task',
|
||||||
|
|
@ -93,9 +97,13 @@ def new():
|
||||||
@web_tasks.route('/<int:id>/complete')
|
@web_tasks.route('/<int:id>/complete')
|
||||||
@login_required
|
@login_required
|
||||||
def complete(id: int):
|
def complete(id: int):
|
||||||
next = request.args.get('next')
|
next = get_next(
|
||||||
if not next:
|
request,
|
||||||
next = url_for('web.task.all')
|
'web.task.all'
|
||||||
|
).and_then(
|
||||||
|
lambda nxt: nxt,
|
||||||
|
lambda exc: 'web.task.all'
|
||||||
|
)
|
||||||
|
|
||||||
def _do_complete(tsk: Task) -> Either[Exception, Task]:
|
def _do_complete(tsk: Task) -> Either[Exception, Task]:
|
||||||
try:
|
try:
|
||||||
|
|
@ -144,9 +152,13 @@ def complete(id: int):
|
||||||
@web_tasks.route('/<int:id>/uncomplete')
|
@web_tasks.route('/<int:id>/uncomplete')
|
||||||
@login_required
|
@login_required
|
||||||
def uncomplete(id: int):
|
def uncomplete(id: int):
|
||||||
next = request.args.get('next')
|
next = get_next(
|
||||||
if not next:
|
request,
|
||||||
next = url_for('web.task.all')
|
'web.task.all'
|
||||||
|
).and_then(
|
||||||
|
lambda nxt: nxt,
|
||||||
|
lambda exc: 'web.task.all'
|
||||||
|
)
|
||||||
|
|
||||||
def _do_uncomplete(tsk: Task) -> Either[Exception, Task]:
|
def _do_uncomplete(tsk: Task) -> Either[Exception, Task]:
|
||||||
try:
|
try:
|
||||||
|
|
@ -192,12 +204,127 @@ def uncomplete(id: int):
|
||||||
lambda err: response_from_exception(err)
|
lambda err: response_from_exception(err)
|
||||||
)
|
)
|
||||||
|
|
||||||
@web_tasks.route('/<int:id>/edit')
|
@web_tasks.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def edit(id: int):
|
def edit(id: int):
|
||||||
return redirect(url_for('web.task.all'))
|
cur_usr: User = current_user # pyright:ignore[reportAssignmentType]
|
||||||
|
next = get_next(
|
||||||
|
request,
|
||||||
|
'web.task.all'
|
||||||
|
).and_then(
|
||||||
|
lambda nxt: nxt,
|
||||||
|
lambda exc: 'web.task.all'
|
||||||
|
)
|
||||||
|
|
||||||
@web_tasks.route('/<int:id>/delete')
|
lookup_result = Option[Task].encapsulate(
|
||||||
|
db.session.query(
|
||||||
|
Task
|
||||||
|
).filter(
|
||||||
|
Task.id==id
|
||||||
|
).one_or_none()
|
||||||
|
).and_then(
|
||||||
|
lambda tsk: Right[Exception, Task](tsk),
|
||||||
|
lambda: Left[Exception, Task](ResponseErrorNotFound(
|
||||||
|
reason=f'User {cur_usr.id} ({cur_usr.username}) tried to edit a task with ID {id}, but that ID was not found!',
|
||||||
|
user_reason=not_found_or_not_authorized(
|
||||||
|
'Task',
|
||||||
|
str(id)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
).flat_map(
|
||||||
|
lambda tsk: assert_user_perms_on_task(
|
||||||
|
cur_usr,
|
||||||
|
tsk,
|
||||||
|
NamespacePermissionType.EDIT_ALL_TASKS
|
||||||
|
)
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
).flat_map(
|
||||||
|
lambda tsk: Right[Exception, FormEditsObjectWithUser[Task]](
|
||||||
|
task_edit_form_for_task(
|
||||||
|
tsk
|
||||||
|
)(request.form)
|
||||||
|
).map(
|
||||||
|
lambda form: (tsk, form)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(lookup_result, Right):
|
||||||
|
task, task_form = lookup_result.val
|
||||||
|
|
||||||
|
if request.method == 'POST' and task_form.validate():
|
||||||
|
return task_form.edit_object(
|
||||||
|
cur_usr,
|
||||||
|
task
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
).flat_map(
|
||||||
|
lambda tsk: commit_update(tsk, db)
|
||||||
|
).and_then(
|
||||||
|
lambda tsk: redirect(next),
|
||||||
|
lambda exc: response_from_exception(exc)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return render_template(
|
||||||
|
'task/edit.html',
|
||||||
|
form=task_form
|
||||||
|
)
|
||||||
|
|
||||||
|
elif isinstance(lookup_result, Left):
|
||||||
|
return response_from_exception(lookup_result.val)
|
||||||
|
else:
|
||||||
|
return status_response(HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
@web_tasks.route('/<int:id>/delete', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def delete(id: int):
|
def delete(id: int):
|
||||||
return redirect(url_for('web.task.all'))
|
cur_usr: User = current_user # pyright:ignore[reportAssignmentType]
|
||||||
|
next = get_next(
|
||||||
|
request,
|
||||||
|
'web.task.all'
|
||||||
|
).and_then(
|
||||||
|
lambda n: n,
|
||||||
|
lambda exc: 'web.task.all'
|
||||||
|
)
|
||||||
|
|
||||||
|
lookup_result = Option[Task].encapsulate(
|
||||||
|
db.session.query(
|
||||||
|
Task
|
||||||
|
).filter(
|
||||||
|
Task.id==id
|
||||||
|
).one_or_none()
|
||||||
|
).and_then(
|
||||||
|
lambda tsk: Right[Exception, Task](tsk),
|
||||||
|
lambda: Left[Exception, Task](ResponseErrorNotFound(
|
||||||
|
reason=f'User {cur_usr.id} ({cur_usr.username}) tried to delete a task with ID {id}, but that ID was not found!',
|
||||||
|
user_reason=not_found_or_not_authorized(
|
||||||
|
'Task',
|
||||||
|
str(id)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
).flat_map(
|
||||||
|
lambda tsk: assert_user_perms_on_task(
|
||||||
|
cur_usr,
|
||||||
|
tsk,
|
||||||
|
NamespacePermissionType.DELETE_ALL_TASKS
|
||||||
|
)
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(lookup_result, Right):
|
||||||
|
task = lookup_result.val
|
||||||
|
if request.method == 'POST':
|
||||||
|
return do_delete(task, db).and_then(
|
||||||
|
lambda _: redirect(next),
|
||||||
|
lambda exc: response_from_exception(exc)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return render_template(
|
||||||
|
'task/confirm_delete.html',
|
||||||
|
task=task
|
||||||
|
)
|
||||||
|
elif isinstance(lookup_result, Left):
|
||||||
|
return response_from_exception(lookup_result.val)
|
||||||
|
else:
|
||||||
|
return status_response(HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
0
src/taskflower/web/utils/__init__.py
Normal file
0
src/taskflower/web/utils/__init__.py
Normal file
22
src/taskflower/web/utils/request.py
Normal file
22
src/taskflower/web/utils/request.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from flask import Request, url_for
|
||||||
|
|
||||||
|
from taskflower.types.either import Either, Right
|
||||||
|
|
||||||
|
def get_next(
|
||||||
|
request: Request,
|
||||||
|
default: str|None = None
|
||||||
|
) -> Either[Exception, str]:
|
||||||
|
''' Checks the ``next`` attribute and either returns it (if valid),
|
||||||
|
returns the default (if not present), or returns Left if ``next``
|
||||||
|
is present but invalid (as in a CSRF attack).
|
||||||
|
'''
|
||||||
|
next = request.args.get('next')
|
||||||
|
if next:
|
||||||
|
# TODO: CSRF protection
|
||||||
|
return Right(next)
|
||||||
|
else:
|
||||||
|
return Right(
|
||||||
|
default
|
||||||
|
if default is not None
|
||||||
|
else url_for('index')
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue