parent
47b1e6ca82
commit
9508a8b132
9 changed files with 315 additions and 54 deletions
|
|
@ -1,7 +1,52 @@
|
|||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from taskflower.types.either import Either, Left, Right
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
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 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.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 import FormCreatesObjectWithUser, FormEditsObjectWithUser
|
||||
from taskflower.types import ann
|
||||
from taskflower.types.either import Either, Left, Right
|
||||
from taskflower.types.option import Option
|
||||
|
||||
class TaskEditForm(FormEditsObjectWithUser[Task]):
|
||||
name: StringField = StringField(
|
||||
'Task Name',
|
||||
[
|
||||
validators.Length(
|
||||
min=1,
|
||||
max=64,
|
||||
message='Task name must be between 1 and 64 characters!'
|
||||
)
|
||||
]
|
||||
)
|
||||
due: DateTimeLocalField = DateTimeLocalField(
|
||||
'Due Date',
|
||||
[
|
||||
validators.DataRequired()
|
||||
]
|
||||
)
|
||||
description: TextAreaField = TextAreaField(
|
||||
'Description',
|
||||
[
|
||||
validators.Optional()
|
||||
]
|
||||
)
|
||||
def task_edit_form_for_task(
|
||||
t: Task
|
||||
) -> type[FormEditsObjectWithUser[Task]]:
|
||||
class TaskEditForm(FormEditsObjectWithUser[Task]):
|
||||
name: StringField = StringField(
|
||||
'Task Name',
|
||||
[
|
||||
validators.Length(
|
||||
min=1,
|
||||
max=64,
|
||||
message='Task name must be between 1 and 64 characters!'
|
||||
)
|
||||
],
|
||||
default=t.name
|
||||
)
|
||||
due: DateTimeLocalField = DateTimeLocalField(
|
||||
'Due Date',
|
||||
[
|
||||
validators.DataRequired()
|
||||
],
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -22,17 +22,20 @@
|
|||
}
|
||||
|
||||
#submit-form {
|
||||
padding: 1rem;
|
||||
margin-top: 2rem;
|
||||
background-color: var(--accent-1-hlt);
|
||||
color: var(--on-accent-1);
|
||||
font-size: larger;
|
||||
background-color: var(--btn-1);
|
||||
color: var(--on-btn-1);
|
||||
width: max-content;
|
||||
padding: 1rem 1.5rem;
|
||||
border: 2px solid var(--btn-1-border);
|
||||
border-radius: 0.25rem;
|
||||
margin: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
border: 2px solid var(--accent-1);
|
||||
border-radius: 1rem;
|
||||
transition: background-color 0.5s;
|
||||
}
|
||||
font-size: larger;
|
||||
|
||||
#submit-form:hover {
|
||||
background-color: var(--accent-1);
|
||||
&:hover {
|
||||
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.description) }}
|
||||
</dl>
|
||||
<button type="submit">Create Task</button>
|
||||
<button type="submit" id="submit-form">Edit Task</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
@ -31,7 +31,7 @@ class Either[L, R](ABC):
|
|||
raise NotImplementedError()
|
||||
|
||||
@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()
|
||||
|
||||
@abstractmethod
|
||||
|
|
@ -46,6 +46,17 @@ class Either[L, R](ABC):
|
|||
) -> X:
|
||||
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
|
||||
class Left[L, R](Either[L, R]):
|
||||
def __init__(self, lf: L):
|
||||
|
|
@ -79,8 +90,10 @@ class Left[L, R](Either[L, R]):
|
|||
return self
|
||||
|
||||
@override
|
||||
def lside_effect[X](self, f: Callable[[L], X]) -> 'Either[L, R]':
|
||||
_ = f(self.val)
|
||||
def lside_effect[X](self, f: Callable[[L], X], filter: type|None = None) -> 'Either[L, R]':
|
||||
if (not filter) or isinstance(self.val, filter):
|
||||
_ = f(self.val)
|
||||
|
||||
return self
|
||||
|
||||
@override
|
||||
|
|
@ -145,7 +158,7 @@ class Right[L, R](Either[L, R]):
|
|||
return self
|
||||
|
||||
@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
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from http import HTTPStatus
|
||||
from flask import Blueprint, redirect, render_template, request, url_for
|
||||
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.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 import commit_update, db, do_delete
|
||||
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 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.types.either import Either, Left, Right, reduce_either
|
||||
from taskflower.types.option import Option
|
||||
from taskflower.web.errors import (
|
||||
ResponseErrorNotFound,
|
||||
response_from_exception
|
||||
response_from_exception,
|
||||
status_response
|
||||
)
|
||||
from taskflower.web.utils.request import get_next
|
||||
|
||||
web_tasks = Blueprint(
|
||||
'task',
|
||||
|
|
@ -93,9 +97,13 @@ def new():
|
|||
@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')
|
||||
next = get_next(
|
||||
request,
|
||||
'web.task.all'
|
||||
).and_then(
|
||||
lambda nxt: nxt,
|
||||
lambda exc: 'web.task.all'
|
||||
)
|
||||
|
||||
def _do_complete(tsk: Task) -> Either[Exception, Task]:
|
||||
try:
|
||||
|
|
@ -144,9 +152,13 @@ def complete(id: int):
|
|||
@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')
|
||||
next = get_next(
|
||||
request,
|
||||
'web.task.all'
|
||||
).and_then(
|
||||
lambda nxt: nxt,
|
||||
lambda exc: 'web.task.all'
|
||||
)
|
||||
|
||||
def _do_uncomplete(tsk: Task) -> Either[Exception, Task]:
|
||||
try:
|
||||
|
|
@ -192,12 +204,127 @@ def uncomplete(id: int):
|
|||
lambda err: response_from_exception(err)
|
||||
)
|
||||
|
||||
@web_tasks.route('/<int:id>/edit')
|
||||
@web_tasks.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
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
|
||||
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