Finish basic namespace / permission implementation
This commit is contained in:
parent
1204e50c52
commit
fe871625f4
30 changed files with 893 additions and 245 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
from typing import Any
|
||||||
from flask import Flask, render_template
|
from flask import Flask, render_template
|
||||||
|
|
||||||
from taskflower.auth import taskflower_login_manager
|
from taskflower.auth import taskflower_login_manager
|
||||||
|
|
@ -23,6 +24,26 @@ app.register_blueprint(hibp_bp)
|
||||||
APIBase.register(app)
|
APIBase.register(app)
|
||||||
# print(f'Routes: \n{"\n".join([str(r) for r in APIBase.routes])}')
|
# print(f'Routes: \n{"\n".join([str(r) for r in APIBase.routes])}')
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def template_utility_fns():
|
||||||
|
def literal_call(fname: str, *args: Any) -> str: # pyright:ignore[reportAny,reportExplicitAny]
|
||||||
|
# Generate a hard-coded function call.
|
||||||
|
# e.g. ``literal_call('set_active', id)`` will convert ``id`` to a
|
||||||
|
# string ('12345' in this example) and then generate a string:
|
||||||
|
#
|
||||||
|
# >>> literal_call('set_active', 12345)
|
||||||
|
# 'set_active(\'12345\')'
|
||||||
|
|
||||||
|
return (
|
||||||
|
fname + '('
|
||||||
|
+ ','.join([f'{str(a)}' for a in args]) # pyright:ignore[reportAny]
|
||||||
|
+ ')'
|
||||||
|
)
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
literal_call=literal_call
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
return render_template('home.html')
|
return render_template('home.html')
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,3 @@ def password_breach_count(password: str) -> Option[int]:
|
||||||
)
|
)
|
||||||
case HIBPMode.LOCAL_ONLY:
|
case HIBPMode.LOCAL_ONLY:
|
||||||
return _get_breach_count_through_local_cache(hash)
|
return _get_breach_count_through_local_cache(hash)
|
||||||
|
|
||||||
def report_incorrect_login_attempt(user: User):
|
|
||||||
# TODO: Implement account lockout
|
|
||||||
log.warning(f'Incorrect login attempt for user {user.username}!')
|
|
||||||
5
src/taskflower/auth/messages.py
Normal file
5
src/taskflower/auth/messages.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
def not_found_or_not_authorized(
|
||||||
|
res_type: str,
|
||||||
|
res_id: str
|
||||||
|
) -> str:
|
||||||
|
return f'{res_type} with id {res_id} was not found, or you don\'t have permission to view it.'
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
|
|
||||||
from enum import IntFlag
|
from enum import IntFlag
|
||||||
|
from typing import override
|
||||||
|
|
||||||
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.role import NamespaceRole, UserRole, UserToNamespaceRole
|
from taskflower.db.model.role import NamespaceRole, UserRole, UserToNamespaceRole
|
||||||
|
from taskflower.db.model.task import Task
|
||||||
from taskflower.db.model.user import User
|
from taskflower.db.model.user import User
|
||||||
from taskflower.types.either import Either, Left, Right
|
from taskflower.types.either import Either, Left, Right
|
||||||
|
from taskflower.types.option import Option, Some
|
||||||
|
|
||||||
|
ProtectedResourceType = User|Task|Namespace|NamespaceRole|UserRole
|
||||||
|
|
||||||
class NamespacePermissionType(IntFlag):
|
class NamespacePermissionType(IntFlag):
|
||||||
NO_PERMS = 0
|
NO_PERMS = 0
|
||||||
|
|
@ -148,3 +152,34 @@ def initialize_user_permissions(user: User):
|
||||||
).map(
|
).map(
|
||||||
lambda _: user
|
lambda _: user
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class AuthorizationError(Exception):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user : User|Option[User]|None,
|
||||||
|
resource : ProtectedResourceType,
|
||||||
|
action : str,
|
||||||
|
user_perms : NamespacePermissionType,
|
||||||
|
required_perms : NamespacePermissionType
|
||||||
|
) -> None:
|
||||||
|
self.user : Option[User] = Option[User].ensure(user)
|
||||||
|
self.resource : ProtectedResourceType = resource
|
||||||
|
self.action : str = action
|
||||||
|
self.user_perms : NamespacePermissionType = user_perms
|
||||||
|
self.required_perms : NamespacePermissionType = required_perms
|
||||||
|
super().__init__(str(self))
|
||||||
|
|
||||||
|
@override
|
||||||
|
def __str__(self) -> str:
|
||||||
|
user_str = (
|
||||||
|
f'User ``{self.user.val.display_name}`` (username ``{self.user.val.username}``; id ``{self.user.val.id}``)'
|
||||||
|
) if isinstance(self.user, Some) else (
|
||||||
|
'Anonymous User'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f'AuthorizationError: {user_str} tried to perform '
|
||||||
|
+ f'action ``{self.action}`` on {type(self.resource)} object '
|
||||||
|
+ f'``{str(self.resource)}``.'
|
||||||
|
+ f'\n - Required permissions: {str(self.required_perms)}'
|
||||||
|
+ f'\n - User has permissions: {str(self.user_perms)}'
|
||||||
|
)
|
||||||
70
src/taskflower/auth/permission/checks.py
Normal file
70
src/taskflower/auth/permission/checks.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
|
||||||
|
from taskflower.auth.permission import AuthorizationError, NamespacePermissionType
|
||||||
|
from taskflower.auth.permission.lookups import get_user_perms_on_namespace, get_user_perms_on_task
|
||||||
|
from taskflower.db.model.namespace import Namespace
|
||||||
|
from taskflower.db.model.user import User
|
||||||
|
from taskflower.db.model.task import Task
|
||||||
|
from taskflower.types.either import Either, Left, Right
|
||||||
|
from taskflower.types.option import Some
|
||||||
|
|
||||||
|
def check_user_perms_on_namespace(
|
||||||
|
usr: User,
|
||||||
|
ns: Namespace,
|
||||||
|
perms: NamespacePermissionType
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
(nsperms:=get_user_perms_on_namespace(usr, ns) & perms)
|
||||||
|
== perms
|
||||||
|
) or NamespacePermissionType.ADMINISTRATE in nsperms
|
||||||
|
|
||||||
|
def assert_user_perms_on_namespace(
|
||||||
|
usr: User,
|
||||||
|
ns: Namespace,
|
||||||
|
perms: NamespacePermissionType,
|
||||||
|
action: str = '[unspecified action]'
|
||||||
|
) -> Either[Exception, Namespace]:
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
Right(ns)
|
||||||
|
) if check_user_perms_on_namespace(usr, ns, perms)
|
||||||
|
else (
|
||||||
|
Left(AuthorizationError(
|
||||||
|
Some(usr),
|
||||||
|
ns,
|
||||||
|
action,
|
||||||
|
get_user_perms_on_namespace(usr, ns),
|
||||||
|
perms
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_user_perms_on_task(
|
||||||
|
usr: User,
|
||||||
|
tsk: Task,
|
||||||
|
perms: NamespacePermissionType
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
(tperms:=get_user_perms_on_task(usr, tsk) & perms)
|
||||||
|
== perms
|
||||||
|
) or NamespacePermissionType.ADMINISTRATE in tperms
|
||||||
|
|
||||||
|
def assert_user_perms_on_task(
|
||||||
|
usr: User,
|
||||||
|
tsk: Task,
|
||||||
|
perms: NamespacePermissionType,
|
||||||
|
action: str = '[unspecified action]'
|
||||||
|
) -> Either[Exception, Task]:
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
Right(tsk)
|
||||||
|
) if check_user_perms_on_task(usr, tsk, perms)
|
||||||
|
else(
|
||||||
|
Left(AuthorizationError(
|
||||||
|
Some(usr),
|
||||||
|
tsk,
|
||||||
|
action,
|
||||||
|
get_user_perms_on_task(usr, tsk),
|
||||||
|
perms
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
|
||||||
|
from taskflower.auth.permission import NamespacePermissionType
|
||||||
|
from taskflower.auth.permission.resolve import resolve_perms_on_namespace, resolve_perms_on_task
|
||||||
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.role import NamespaceRole, UserToNamespaceRole
|
from taskflower.db.model.role import NamespaceRole, UserToNamespaceRole
|
||||||
|
from taskflower.db.model.task import Task
|
||||||
from taskflower.db.model.user import User
|
from taskflower.db.model.user import User
|
||||||
|
|
||||||
def get_namespaces_for_user(user: User):
|
def get_namespaces_for_user(user: User):
|
||||||
|
|
@ -16,3 +19,73 @@ def get_namespaces_for_user(user: User):
|
||||||
).filter(
|
).filter(
|
||||||
UserToNamespaceRole.user == user.id
|
UserToNamespaceRole.user == user.id
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
def get_tasks_for_user(user: User) -> list[Task]:
|
||||||
|
return db.session.query(
|
||||||
|
Task
|
||||||
|
).join(
|
||||||
|
Namespace,
|
||||||
|
Namespace.id == Task.namespace
|
||||||
|
).join(
|
||||||
|
NamespaceRole,
|
||||||
|
Namespace.id == NamespaceRole.namespace
|
||||||
|
).join(
|
||||||
|
UserToNamespaceRole,
|
||||||
|
NamespaceRole.id == UserToNamespaceRole.role
|
||||||
|
).filter(
|
||||||
|
UserToNamespaceRole.user == user.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
def namespaces_where_user_can(
|
||||||
|
user: User,
|
||||||
|
permissions: NamespacePermissionType
|
||||||
|
):
|
||||||
|
return [
|
||||||
|
ns
|
||||||
|
for ns in get_namespaces_for_user(user)
|
||||||
|
if (
|
||||||
|
(nperms:=resolve_perms_on_namespace(user, ns) & permissions)
|
||||||
|
== permissions
|
||||||
|
) or (
|
||||||
|
NamespacePermissionType.ADMINISTRATE in nperms
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def tasks_where_user_can(
|
||||||
|
user: User,
|
||||||
|
permissions: NamespacePermissionType
|
||||||
|
):
|
||||||
|
return [
|
||||||
|
tsk
|
||||||
|
for tsk in get_tasks_for_user(user)
|
||||||
|
if (
|
||||||
|
(tperms:=resolve_perms_on_task(user, tsk) & permissions)
|
||||||
|
== permissions
|
||||||
|
) or (
|
||||||
|
NamespacePermissionType.ADMINISTRATE in tperms
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_user_perms_on_task(
|
||||||
|
usr: User,
|
||||||
|
tsk: Task
|
||||||
|
) -> NamespacePermissionType:
|
||||||
|
ns = db.session.query(
|
||||||
|
Namespace
|
||||||
|
).filter(
|
||||||
|
Namespace.id == tsk.namespace
|
||||||
|
).one()
|
||||||
|
|
||||||
|
return resolve_perms_on_namespace(
|
||||||
|
usr,
|
||||||
|
ns
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_user_perms_on_namespace(
|
||||||
|
usr: User,
|
||||||
|
ns: Namespace
|
||||||
|
) -> NamespacePermissionType:
|
||||||
|
return resolve_perms_on_namespace(
|
||||||
|
usr,
|
||||||
|
ns
|
||||||
|
)
|
||||||
|
|
@ -4,7 +4,9 @@ from taskflower.auth.permission import NamespacePermissionType
|
||||||
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.role import NamespaceRole, UserToNamespaceRole
|
from taskflower.db.model.role import NamespaceRole, UserToNamespaceRole
|
||||||
|
from taskflower.db.model.task import Task
|
||||||
from taskflower.db.model.user import User
|
from taskflower.db.model.user import User
|
||||||
|
from taskflower.types.option import Option
|
||||||
|
|
||||||
def resolve_perms_on_namespace(
|
def resolve_perms_on_namespace(
|
||||||
user: User,
|
user: User,
|
||||||
|
|
@ -38,3 +40,21 @@ def resolve_perms_on_namespace(
|
||||||
)
|
)
|
||||||
|
|
||||||
return perms
|
return perms
|
||||||
|
|
||||||
|
def resolve_perms_on_task(
|
||||||
|
user: User,
|
||||||
|
task: Task
|
||||||
|
):
|
||||||
|
return Option[Namespace].encapsulate(
|
||||||
|
db.session.query(
|
||||||
|
Namespace
|
||||||
|
).filter(
|
||||||
|
Namespace.id == task.namespace
|
||||||
|
).one_or_none()
|
||||||
|
).and_then(
|
||||||
|
lambda ns: resolve_perms_on_namespace(
|
||||||
|
user,
|
||||||
|
ns
|
||||||
|
),
|
||||||
|
lambda: NamespacePermissionType.NO_PERMS
|
||||||
|
)
|
||||||
20
src/taskflower/auth/violations.py
Normal file
20
src/taskflower/auth/violations.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from taskflower.auth.permission import AuthorizationError
|
||||||
|
from taskflower.db.model.user import User
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def report_incorrect_login_attempt(user: User):
|
||||||
|
# TODO: Implement account lockout
|
||||||
|
log.warning(f'Incorrect login attempt for user {user.username}!')
|
||||||
|
|
||||||
|
def report_authorization_error(vio: AuthorizationError):
|
||||||
|
log.warning(f'[ACCESS VIOLATION] {str(vio)}')
|
||||||
|
|
||||||
|
def check_for_auth_err_and_report(vio: Exception):
|
||||||
|
''' Checks if an ``Exception`` is an ``AuthorizationError``. If it is,
|
||||||
|
calls ``report_authorization_error()``
|
||||||
|
'''
|
||||||
|
if isinstance(vio, AuthorizationError):
|
||||||
|
report_authorization_error(vio)
|
||||||
|
|
@ -2,47 +2,10 @@ from datetime import datetime
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, DateTime
|
from sqlalchemy import Boolean, ForeignKey, Integer, String, DateTime
|
||||||
from typing import Self, override
|
|
||||||
from wtforms import DateTimeLocalField, Form, StringField, validators
|
|
||||||
import humanize
|
|
||||||
|
|
||||||
from taskflower.db import db
|
from taskflower.db import db
|
||||||
from taskflower.db.model.user import User
|
|
||||||
from taskflower.types import JSONSerializeableDict
|
|
||||||
from taskflower.types.either import Either, Left, Right
|
|
||||||
from taskflower.types.resource import APISerializable
|
|
||||||
|
|
||||||
def _due_str(due: datetime) -> str:
|
class Task(db.Model):
|
||||||
now = datetime.now()
|
|
||||||
delta = datetime.now() - due
|
|
||||||
if now > due:
|
|
||||||
return humanize.naturaldelta(
|
|
||||||
delta
|
|
||||||
) + ' ago'
|
|
||||||
else:
|
|
||||||
return 'in ' + humanize.naturaldelta(
|
|
||||||
delta
|
|
||||||
)
|
|
||||||
|
|
||||||
class TaskForm(Form):
|
|
||||||
name: StringField = StringField(
|
|
||||||
'Task Name',
|
|
||||||
[
|
|
||||||
validators.Length(min=1, message='Task name is too short!')
|
|
||||||
]
|
|
||||||
)
|
|
||||||
due: DateTimeLocalField = DateTimeLocalField(
|
|
||||||
'Due Date'
|
|
||||||
)
|
|
||||||
description: StringField = StringField(
|
|
||||||
'Description',
|
|
||||||
[
|
|
||||||
validators.Optional()
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Task(db.Model, APISerializable):
|
|
||||||
__tablename__: str = 'task'
|
__tablename__: str = 'task'
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
|
@ -51,47 +14,4 @@ class Task(db.Model, APISerializable):
|
||||||
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())
|
||||||
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
complete: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
owner: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id'))
|
||||||
|
|
||||||
@override
|
|
||||||
def _serialize(self) -> Either[Exception, JSONSerializeableDict]:
|
|
||||||
return Right(
|
|
||||||
{
|
|
||||||
'id' : self.id,
|
|
||||||
'name' : self.name,
|
|
||||||
'due' : str(self.due),
|
|
||||||
'due_rel' : _due_str(self.due),
|
|
||||||
'description' : self.description,
|
|
||||||
'created' : str(self.created),
|
|
||||||
'complete' : self.complete
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
form: type[Form] = TaskForm
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_form(
|
|
||||||
cls: type[Self],
|
|
||||||
form_data: Form,
|
|
||||||
current_user: User
|
|
||||||
) -> Either[Exception, Self]:
|
|
||||||
if isinstance(form_data, TaskForm):
|
|
||||||
if form_data.validate():
|
|
||||||
return Right(cls(
|
|
||||||
name=form_data.name.data, # pyright:ignore[reportCallIssue]
|
|
||||||
due=form_data.due.data, # pyright:ignore[reportCallIssue]
|
|
||||||
description=( # pyright:ignore[reportCallIssue]
|
|
||||||
form_data.description.data
|
|
||||||
if form_data.description.data
|
|
||||||
else ''
|
|
||||||
),
|
|
||||||
owner=current_user.id # pyright:ignore[reportCallIssue]
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
return Left(ValueError(
|
|
||||||
'``form_data`` failed validation!'
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
return Left(TypeError(
|
|
||||||
'``form_data`` must be a subclass of ``TaskForm``'
|
|
||||||
))
|
|
||||||
|
|
@ -13,7 +13,7 @@ class User(db.Model, UserMixin, APISerializable):
|
||||||
__tablename__: str = 'user'
|
__tablename__: str = 'user'
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
username: Mapped[str] = mapped_column(String, unique=True)
|
username: Mapped[str] = mapped_column(String(32), unique=True)
|
||||||
display_name: Mapped[str] = mapped_column(String(256))
|
display_name: Mapped[str] = mapped_column(String(256))
|
||||||
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
|
||||||
37
src/taskflower/form/__init__.py
Normal file
37
src/taskflower/form/__init__.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
from wtforms import Form
|
||||||
|
|
||||||
|
from taskflower.db.model.user import User
|
||||||
|
from taskflower.types.either import Either
|
||||||
|
|
||||||
|
|
||||||
|
class FormCreatesObject[T](Form):
|
||||||
|
''' Trait that indicates that this ``Form`` can be used directly to create
|
||||||
|
an object.
|
||||||
|
'''
|
||||||
|
|
||||||
|
def create_object(
|
||||||
|
self
|
||||||
|
) -> Either[Exception, T]:
|
||||||
|
''' Try to create a ``T``.
|
||||||
|
|
||||||
|
This function checks for authorization, and will return an
|
||||||
|
``AuthorizationError`` if the action is not authorized.
|
||||||
|
'''
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
class FormCreatesObjectWithUser[T](Form):
|
||||||
|
''' Trait that indicates that this ``Form`` cna be used to create an object,
|
||||||
|
if provided with a ``User`` object.
|
||||||
|
'''
|
||||||
|
|
||||||
|
def create_object(
|
||||||
|
self,
|
||||||
|
current_user: User # pyright:ignore[reportUnusedParameter]
|
||||||
|
) -> Either[Exception, T]:
|
||||||
|
''' Try to create a ``T`` on behalf of ``current_user``.
|
||||||
|
|
||||||
|
This function checks for authorization, and will return an
|
||||||
|
``AuthorizationError`` if ``current_user`` is not authorized to
|
||||||
|
create an object with the specified parameters.
|
||||||
|
'''
|
||||||
|
raise NotImplementedError
|
||||||
89
src/taskflower/form/task.py
Normal file
89
src/taskflower/form/task.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
from typing import override
|
||||||
|
from wtforms import DateTimeLocalField, SelectField, StringField, ValidationError, validators
|
||||||
|
|
||||||
|
from taskflower.auth.permission import NamespacePermissionType
|
||||||
|
from taskflower.auth.permission.checks import assert_user_perms_on_namespace
|
||||||
|
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
|
||||||
|
from taskflower.types.either import Either, Left, Right
|
||||||
|
from taskflower.types.option import Option
|
||||||
|
|
||||||
|
def task_form_for_user(
|
||||||
|
user: User
|
||||||
|
) -> type[FormCreatesObjectWithUser[Task]]:
|
||||||
|
namespace_choices = [
|
||||||
|
(ns.id, ns.name)
|
||||||
|
for ns in namespaces_where_user_can(
|
||||||
|
user,
|
||||||
|
NamespacePermissionType.CREATE_IN
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
class TaskForm(FormCreatesObjectWithUser[Task]):
|
||||||
|
name: StringField = StringField(
|
||||||
|
'Task Name',
|
||||||
|
[
|
||||||
|
validators.Length(min=1, message='Task name is too short!')
|
||||||
|
]
|
||||||
|
)
|
||||||
|
due: DateTimeLocalField = DateTimeLocalField(
|
||||||
|
'Due Date'
|
||||||
|
)
|
||||||
|
namespace: SelectField = SelectField(
|
||||||
|
'Select a namespace',
|
||||||
|
[
|
||||||
|
validators.DataRequired('Invalid namespace')
|
||||||
|
],
|
||||||
|
choices=namespace_choices,
|
||||||
|
coerce=int
|
||||||
|
)
|
||||||
|
description: StringField = StringField(
|
||||||
|
'Description',
|
||||||
|
[
|
||||||
|
validators.Optional()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def create_object(
|
||||||
|
self,
|
||||||
|
current_user: User
|
||||||
|
) -> Either[Exception, Task]:
|
||||||
|
if self.validate():
|
||||||
|
return Option[Namespace].encapsulate(
|
||||||
|
db.session.query(
|
||||||
|
Namespace
|
||||||
|
).filter(
|
||||||
|
Namespace.id == self.namespace.data # pyright:ignore[reportAny]
|
||||||
|
).one_or_none()
|
||||||
|
).and_then(
|
||||||
|
lambda val: Right[Exception, Namespace](val),
|
||||||
|
lambda: Left[Exception, Namespace](KeyError('Namespace not found!'))
|
||||||
|
).flat_map(
|
||||||
|
lambda ns: assert_user_perms_on_namespace(
|
||||||
|
current_user,
|
||||||
|
ns,
|
||||||
|
NamespacePermissionType.CREATE_IN,
|
||||||
|
'Create task'
|
||||||
|
)
|
||||||
|
).map(
|
||||||
|
lambda ns: Task(
|
||||||
|
name=self.name.data, # pyright:ignore[reportCallIssue]
|
||||||
|
due=self.due.data, # pyright:ignore[reportCallIssue]
|
||||||
|
description=( # pyright:ignore[reportCallIssue]
|
||||||
|
self.description.data
|
||||||
|
if self.description.data
|
||||||
|
else ''
|
||||||
|
),
|
||||||
|
namespace=ns.id # pyright:ignore[reportCallIssue]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Left(ValidationError('Form data did not validate correctly!'))
|
||||||
|
|
||||||
|
return TaskForm
|
||||||
|
|
||||||
0
src/taskflower/sanitize/__init__.py
Normal file
0
src/taskflower/sanitize/__init__.py
Normal file
74
src/taskflower/sanitize/namespace.py
Normal file
74
src/taskflower/sanitize/namespace.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from taskflower.auth.permission import NamespacePermissionType
|
||||||
|
from taskflower.auth.permission.resolve import resolve_perms_on_namespace
|
||||||
|
from taskflower.db.model.namespace import Namespace
|
||||||
|
from taskflower.db.model.user import User
|
||||||
|
from taskflower.sanitize.task import TaskForUser
|
||||||
|
from taskflower.types.either import Either, Left, Right
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NamespacePermsForUser:
|
||||||
|
read: bool
|
||||||
|
create_in: bool
|
||||||
|
complete_own: bool
|
||||||
|
complete_all: bool
|
||||||
|
uncomplete_own: bool
|
||||||
|
uncomplete_all: bool
|
||||||
|
edit_own: bool
|
||||||
|
edit_all: bool
|
||||||
|
delete_own: bool
|
||||||
|
delete_all: bool
|
||||||
|
edit_roles: bool
|
||||||
|
administrate: bool
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NamespaceForUser():
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
perms: NamespacePermsForUser
|
||||||
|
tasks: list[TaskForUser]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_user(
|
||||||
|
ns: Namespace,
|
||||||
|
user: User,
|
||||||
|
tasks: list[TaskForUser]|None = None
|
||||||
|
) -> Either[Exception, 'NamespaceForUser']:
|
||||||
|
perms = resolve_perms_on_namespace(
|
||||||
|
user,
|
||||||
|
ns
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
NamespacePermissionType.READ in perms
|
||||||
|
or NamespacePermissionType.ADMINISTRATE in perms
|
||||||
|
):
|
||||||
|
return Left(KeyError('No such namespace or insufficient permissions.'))
|
||||||
|
|
||||||
|
return Right(
|
||||||
|
NamespaceForUser(
|
||||||
|
ns.id,
|
||||||
|
ns.name,
|
||||||
|
ns.description,
|
||||||
|
NamespacePermsForUser(
|
||||||
|
NamespacePermissionType.READ in perms,
|
||||||
|
NamespacePermissionType.CREATE_IN in perms,
|
||||||
|
NamespacePermissionType.COMPLETE_OWN in perms,
|
||||||
|
NamespacePermissionType.COMPLETE_ALL in perms,
|
||||||
|
NamespacePermissionType.UNCOMPLETE_OWN in perms,
|
||||||
|
NamespacePermissionType.UNCOMPLETE_ALL in perms,
|
||||||
|
NamespacePermissionType.EDIT_OWN in perms,
|
||||||
|
NamespacePermissionType.EDIT_ALL in perms,
|
||||||
|
NamespacePermissionType.DELETE_OWN in perms,
|
||||||
|
NamespacePermissionType.DELETE_ALL in perms,
|
||||||
|
NamespacePermissionType.EDIT_ROLES in perms,
|
||||||
|
NamespacePermissionType.ADMINISTRATE in perms
|
||||||
|
),
|
||||||
|
(
|
||||||
|
tasks if tasks
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
66
src/taskflower/sanitize/task.py
Normal file
66
src/taskflower/sanitize/task.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
import humanize
|
||||||
|
|
||||||
|
from taskflower.auth.permission import NamespacePermissionType
|
||||||
|
from taskflower.auth.permission.checks import assert_user_perms_on_task
|
||||||
|
from taskflower.db.model.task import Task
|
||||||
|
from taskflower.db.model.user import User
|
||||||
|
from taskflower.types.either import Either
|
||||||
|
|
||||||
|
def _due_str(due: datetime) -> str:
|
||||||
|
now = datetime.now()
|
||||||
|
delta = datetime.now() - due
|
||||||
|
if now > due:
|
||||||
|
return humanize.naturaldelta(
|
||||||
|
delta
|
||||||
|
) + ' ago'
|
||||||
|
else:
|
||||||
|
return 'in ' + humanize.naturaldelta(
|
||||||
|
delta
|
||||||
|
)
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TaskForUser:
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
due: datetime
|
||||||
|
due_rel: str
|
||||||
|
created: datetime
|
||||||
|
complete: bool
|
||||||
|
namespace_id: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_task(
|
||||||
|
cls,
|
||||||
|
tsk: Task,
|
||||||
|
usr: User
|
||||||
|
) -> Either[Exception, Self]:
|
||||||
|
''' Returns a user-sanitized version of the task.
|
||||||
|
|
||||||
|
This function performs authorization checks on ``usr`` and will
|
||||||
|
return ``Left(AuthorizationError)`` if the action is unauthorized.
|
||||||
|
However, it is the caller's responsibility to report the violation
|
||||||
|
if warranted (use ``taskflower.auth.violations.check_for_auth_error_and_report``
|
||||||
|
in an ``lside_effect()``)
|
||||||
|
'''
|
||||||
|
return assert_user_perms_on_task(
|
||||||
|
usr,
|
||||||
|
tsk,
|
||||||
|
NamespacePermissionType.READ,
|
||||||
|
'Retrieve task'
|
||||||
|
).map(
|
||||||
|
lambda val: cls(
|
||||||
|
tsk.id,
|
||||||
|
tsk.name,
|
||||||
|
tsk.description,
|
||||||
|
tsk.due,
|
||||||
|
_due_str(tsk.due),
|
||||||
|
tsk.created,
|
||||||
|
tsk.complete,
|
||||||
|
tsk.namespace
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -52,8 +52,53 @@
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list .detail-view-elem h1 {
|
.list .detail-view-elem hr {
|
||||||
margin: 0;
|
color: var(--on-table-row);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list .detail-view-elem .detail-view-header h1 {
|
||||||
|
margin: 0 1rem;
|
||||||
|
font-size: larger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list .detail-view-elem .detail-view-header {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list .detail-view-elem .detail-view-header::before{
|
||||||
|
content: "";
|
||||||
|
flex: 1 1;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
60deg,
|
||||||
|
|
||||||
|
var(--on-table-row) 0,
|
||||||
|
var(--on-table-row) 0.25rem,
|
||||||
|
|
||||||
|
transparent 0.35rem,
|
||||||
|
transparent 1.0rem,
|
||||||
|
|
||||||
|
var(--on-table-row) 1.1rem
|
||||||
|
);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list .detail-view-elem .detail-view-header::after{
|
||||||
|
content: "";
|
||||||
|
flex: 1 1;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
60deg,
|
||||||
|
|
||||||
|
var(--on-table-row) 0,
|
||||||
|
var(--on-table-row) 0.25rem,
|
||||||
|
|
||||||
|
transparent 0.35rem,
|
||||||
|
transparent 1.0rem,
|
||||||
|
|
||||||
|
var(--on-table-row) 1.1rem
|
||||||
|
);
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list .detail-view-elem .main-description {
|
.list .detail-view-elem .main-description {
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,8 @@ a {
|
||||||
color: var(--on-block-1);
|
color: var(--on-block-1);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
max-width: 20%;
|
||||||
|
overflow: clip;
|
||||||
#sidebar a {
|
|
||||||
display: block;
|
|
||||||
border-top: 1px solid var(--on-block-1);
|
|
||||||
border-bottom: 1px solid var(--on-block-1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar-top {
|
#sidebar-top {
|
||||||
|
|
@ -68,12 +64,25 @@ a {
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar a {
|
#sidebar a {
|
||||||
|
display: block;
|
||||||
|
border-top: 1px solid var(--on-block-1);
|
||||||
|
border-bottom: 1px solid var(--on-block-1);
|
||||||
color: var(--on-block-1);
|
color: var(--on-block-1);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-family: var(--header-font);
|
font-family: var(--header-font);
|
||||||
padding: 0.5rem 2rem;
|
padding: 0.5rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#sidebar-namespace-list, #sidebar-spacer {
|
||||||
|
flex: 1 1;
|
||||||
|
border-top: 1px solid var(--on-block-1);
|
||||||
|
border-bottom: 1px solid var(--on-block-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-namespace-list a {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
#footer {
|
#footer {
|
||||||
background-color: var(--footer);
|
background-color: var(--footer);
|
||||||
color: var(--on-footer);
|
color: var(--on-footer);
|
||||||
|
|
@ -126,9 +135,3 @@ h3 {
|
||||||
font-size: large;
|
font-size: large;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar-spacer {
|
|
||||||
flex: 1 1;
|
|
||||||
border-top: 1px solid var(--on-block-1);
|
|
||||||
border-bottom: 1px solid var(--on-block-1);
|
|
||||||
}
|
|
||||||
11
src/taskflower/templates/_namespace-list.html
Normal file
11
src/taskflower/templates/_namespace-list.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{% macro namespace_entry(ns) %}
|
||||||
|
<a href={{ url_for('web.namespace.get', id=ns.id) }}>{{ ns.name }}</a>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro namespace_list(namespaces) %}
|
||||||
|
<div id="sidebar-namespace-list">
|
||||||
|
{% for ns in namespaces %}
|
||||||
|
{{ namespace_entry(ns) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "_namespace-list.html" import namespace_list %}
|
||||||
|
|
||||||
{% block raw_title %}{% block title %}{% endblock %} | Taskflower🌺{% endblock %}
|
{% block raw_title %}{% block title %}{% endblock %} | Taskflower🌺{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="sidebar">
|
<div id="sidebar">
|
||||||
|
|
@ -6,7 +8,11 @@
|
||||||
<a id="sidebar-top" href="/">Taskflower 🌺</a>
|
<a id="sidebar-top" href="/">Taskflower 🌺</a>
|
||||||
<a href={{ url_for("web.task.all") }}>My Tasks</a>
|
<a href={{ url_for("web.task.all") }}>My Tasks</a>
|
||||||
<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 %}
|
||||||
|
{{ namespace_list(fetch_namespaces(current_user)) }}
|
||||||
|
{% else %}
|
||||||
<div id="sidebar-spacer"></div>
|
<div id="sidebar-spacer"></div>
|
||||||
|
{% endif %}
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<a href={{ url_for(
|
<a href={{ url_for(
|
||||||
"web.user.profile",
|
"web.user.profile",
|
||||||
|
|
|
||||||
18
src/taskflower/templates/namespace/detail.html
Normal file
18
src/taskflower/templates/namespace/detail.html
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{% extends "main.html" %}
|
||||||
|
{% from "task/_tasklist.html" import task_list, task_list_script with context %}
|
||||||
|
|
||||||
|
{% block head_extras %}
|
||||||
|
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
|
||||||
|
{% endblock %}
|
||||||
|
{% block title %}{{ namespace.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
|
||||||
|
<h1>{{ namespace.name }}</h1>
|
||||||
|
<p>{{ namespace.description }}</p>
|
||||||
|
|
||||||
|
<h2>Tasks</h2>
|
||||||
|
{{ task_list(namespace.tasks) }}
|
||||||
|
{{ task_list_script() }}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
{{ render_field(form.name) }}
|
{{ render_field(form.name) }}
|
||||||
{{ render_field(form.due) }}
|
{{ render_field(form.due) }}
|
||||||
{{ render_field(form.description) }}
|
{{ render_field(form.description) }}
|
||||||
|
{{ render_field(form.namespace) }}
|
||||||
</dl>
|
</dl>
|
||||||
<p><input type="submit">Create Task</input></p>
|
<p><input type="submit">Create Task</input></p>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,46 @@
|
||||||
{% macro inline_task_header() %}
|
{% macro inline_task_header(list_id=0) %}
|
||||||
<tr class="task-header-row">
|
<tr class="task-header-row">
|
||||||
<th class="check-hdr" id="check-header"></th>
|
<th class="check-hdr" id={{ "check-header-"~list_id }}></th>
|
||||||
<th class="name-hdr" id="task-name-hdr">
|
<th class="name-hdr" id={{ "task-name-hdr-"~list_id }}>
|
||||||
<h3>Task</h3>
|
<h3>Task</h3>
|
||||||
</th>
|
</th>
|
||||||
<th class="due-hdr" id="task-due-hdr">
|
<th class="due-hdr" id={{ "task-due-hdr"~list_id }}>
|
||||||
<h3>Due</h3>
|
<h3>Due</h3>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro inline_task(task) %}
|
{% macro inline_task(task, list_id=0) %}
|
||||||
<tr class="task-table-row" id={{ "row-"~task.id }}>
|
<tr class="task-table-row" id="{{ tlist_cid('row', task.id, list_id) }}">
|
||||||
<td class="checkbox" id={{ "check-"~task.id }}>
|
<td class="checkbox" id="{{ tlist_cid('check', task.id, list_id) }}">
|
||||||
{% if task.complete %}
|
{% if task.complete %}
|
||||||
<input id={{ "check-inner-"~task.id }} type="checkbox" checked>
|
<input id="{{ tlist_cid('check-inner', task.id, list_id) }}" type="checkbox" checked>
|
||||||
{% else %}
|
{% else %}
|
||||||
<input id={{ "check-inner-"~task.id }} type="checkbox">
|
<input id="{{ tlist_cid('check-inner', task.id, list_id) }}" type="checkbox">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="task-name" id={{ "task-name-"~task.id }}
|
class="task-name" id="{{ tlist_cid('task-name', task.id, list_id) }}"
|
||||||
onclick={{"set_active('task-detail-"~task.id~"')"}}
|
onclick="set_active('{{ tlist_cid('task-detail', task.id, list_id) }}', {{list_id}})"
|
||||||
>
|
>
|
||||||
<label for={{ "check-inner-"~task.id }}>{{ task.name }}</label>
|
<label for="{{ tlist_cid('check-inner', task.id, list_id) }}">{{ task.name }}</label>
|
||||||
</td>
|
</td>
|
||||||
<td class="task-due" id={{ "task-due"~task.id }}>
|
<td class="task-due" id="{{ tlist_cid('task-due', task.id, list_id) }}">
|
||||||
<p>{{ task.due_rel }}</p>
|
<p>{{ task.due_rel }}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr></tr> <!-- placeholder for CSS styling -->
|
<tr></tr> <!-- placeholder for CSS styling -->
|
||||||
<tr class="detail-view" id={{"task-detail-"~task.id}}>
|
<tr class="detail-view tl-{{ list_id }}" id="{{ tlist_cid('task-detail', task.id, list_id) }}">
|
||||||
<td class="detail-view-elem" colspan=3>
|
<td class="detail-view-elem" colspan=3>
|
||||||
<h1>Task Details</h1>
|
<div class="detail-view-header">
|
||||||
|
<h1>Task Details: {{ task.name }}</h1>
|
||||||
|
</div>
|
||||||
<p class="small-details">
|
<p class="small-details">
|
||||||
Task ID: {{ task.id }}
|
Task ID: {{ task.id }}
|
||||||
<br/>Created: {{ task.created }}
|
<br/>Created: {{ task.created }} in namespace {{ task.namespace_id }}
|
||||||
<br/>Due: {{ task.due }}
|
<br/>Due: {{ task.due }}
|
||||||
</p>
|
</p>
|
||||||
|
<hr/>
|
||||||
<p class="main-description">{{ task.description }}</p>
|
<p class="main-description">{{ task.description }}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
42
src/taskflower/templates/task/_tasklist.html
Normal file
42
src/taskflower/templates/task/_tasklist.html
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{% from "task/_shorttask.html" import inline_task, inline_task_header with context %}
|
||||||
|
|
||||||
|
{% macro task_list(tasks, list_id=0) %}
|
||||||
|
<table class="list">
|
||||||
|
<colgroup>
|
||||||
|
<col span="1" style="width: 0;"/>
|
||||||
|
<col span="1"/>
|
||||||
|
<col span="1" style="width: 20%;"/>
|
||||||
|
</colgroup>
|
||||||
|
|
||||||
|
<tbody class="list">
|
||||||
|
{{ inline_task_header(list_id) }}
|
||||||
|
{% for task in tasks %}
|
||||||
|
{{ inline_task(task, list_id) }}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro task_list_script() %}
|
||||||
|
<style>
|
||||||
|
.list .checkbox {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list .task-due {
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function set_active(task_id, list_id){
|
||||||
|
Array.from(document.querySelectorAll(
|
||||||
|
`.list .detail-view.shown.tl-${list_id}`
|
||||||
|
)).forEach(
|
||||||
|
(el) => el.classList.remove('shown')
|
||||||
|
)
|
||||||
|
|
||||||
|
document.getElementById(task_id).classList.add('shown')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endmacro %}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends "main.html" %}
|
{% extends "main.html" %}
|
||||||
{% from "task/_shorttask.html" import inline_task, inline_task_header %}
|
{% from "task/_tasklist.html" import task_list, task_list_script with context %}
|
||||||
|
|
||||||
{% block head_extras %}
|
{% block head_extras %}
|
||||||
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
|
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
|
||||||
|
|
@ -9,41 +9,8 @@
|
||||||
|
|
||||||
{% block main_content %}
|
{% block main_content %}
|
||||||
<h1>My Tasks</h1>
|
<h1>My Tasks</h1>
|
||||||
<table class="list">
|
|
||||||
<colgroup>
|
|
||||||
<col span="1" style="width: 0;"/>
|
|
||||||
<col span="1"/>
|
|
||||||
<col span="1" style="width: 20%;"/>
|
|
||||||
</colgroup>
|
|
||||||
|
|
||||||
<tbody class="list">
|
{{ task_list(tasks) }}
|
||||||
{{ inline_task_header() }}
|
{{ task_list_script() }}
|
||||||
{% for task in tasks %}
|
|
||||||
{{ inline_task(task) }}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.list .checkbox {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list .task-due {
|
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function set_active(id){
|
|
||||||
Array.from(document.querySelectorAll(
|
|
||||||
'.task-list .detail-view.shown'
|
|
||||||
)).forEach(
|
|
||||||
(el) => el.classList.remove('shown')
|
|
||||||
)
|
|
||||||
|
|
||||||
document.getElementById(id).classList.add('shown')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -16,6 +16,8 @@ ArbitraryKwargs = dict[str, Any] # pyright:ignore[reportExplicitAny]
|
||||||
ViewFunction = Callable[..., FlaskViewReturnType]
|
ViewFunction = Callable[..., FlaskViewReturnType]
|
||||||
JSONSerializableData: TypeAlias = 'str|int|float|dict[str, JSONSerializableData]|list[JSONSerializableData]|bool|NoneType'
|
JSONSerializableData: TypeAlias = 'str|int|float|dict[str, JSONSerializableData]|list[JSONSerializableData]|bool|NoneType'
|
||||||
|
|
||||||
|
Stringable = str|Any # pyright:ignore[reportExplicitAny]
|
||||||
|
|
||||||
def ann[T](x: T|None) -> T:
|
def ann[T](x: T|None) -> T:
|
||||||
''' Assert Not None
|
''' Assert Not None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,79 @@ class Option[T](ABC):
|
||||||
if_some: Callable[[T], Y],
|
if_some: Callable[[T], Y],
|
||||||
if_none: Callable[[], Y]
|
if_none: Callable[[], Y]
|
||||||
) -> Y:
|
) -> Y:
|
||||||
|
''' Extract the value from the option, returning some other type ``Y``.
|
||||||
|
|
||||||
|
If this ``Option`` is ``Some``, then ``if_some()`` will be called
|
||||||
|
with the ``Option`` contents as a parameter. Otherwise,
|
||||||
|
``if_none()`` will be called wiht no parameters. Both functions must
|
||||||
|
return the same type - the called function's return value will be
|
||||||
|
returned from ``and_then()``
|
||||||
|
|
||||||
|
>>> Some(1).and_then(
|
||||||
|
... lambda v: 2*v,
|
||||||
|
... lambda: 0
|
||||||
|
... )
|
||||||
|
4
|
||||||
|
|
||||||
|
>>> Nothing().and_then(
|
||||||
|
... lambda v: 2*v,
|
||||||
|
... lambda: 0
|
||||||
|
... )
|
||||||
|
0
|
||||||
|
|
||||||
|
>>> Some(1).and_then(
|
||||||
|
... lambda v: Right(v),
|
||||||
|
... lambda: Left(KeyError('Key Not Found'))
|
||||||
|
... )
|
||||||
|
Right(1)
|
||||||
|
|
||||||
|
>>> Nothing().and_then(
|
||||||
|
... lambda v: Right(v),
|
||||||
|
... lambda: Left(KeyError('Key Not Found'))
|
||||||
|
... )
|
||||||
|
Left(KeyError('Key Not Found'))
|
||||||
|
'''
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encapsulate(v: T|None) -> 'Option[T]':
|
def encapsulate(v: T|None) -> 'Option[T]':
|
||||||
|
''' "Encapsulate" a nullable value (i.e. a value which can either be
|
||||||
|
an object of type ``T`` or ``None``) into an ``Option[T]``.
|
||||||
|
|
||||||
|
If ``v`` is ``None``, this returns ``Nothing()``; otherwise, it
|
||||||
|
returns ``Some(v)``
|
||||||
|
|
||||||
|
>>> Option.encapsulate(1)
|
||||||
|
Some(1)
|
||||||
|
>>> Option.encapsulate(None)
|
||||||
|
Nothing()
|
||||||
|
'''
|
||||||
|
|
||||||
return Some(v) if v is not None else Nothing()
|
return Some(v) if v is not None else Nothing()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ensure(v: 'Option[T]|T|None') -> 'Option[T]':
|
||||||
|
''' As ``encapsulate()``, but if ``v`` is already an ``Option``, it is
|
||||||
|
assumed to be an ``Option[T]`` and passed through transparently.
|
||||||
|
|
||||||
|
>>> Option.ensure(Some(1))
|
||||||
|
Some(1)
|
||||||
|
>>> Option.ensure(1)
|
||||||
|
Some(1)
|
||||||
|
>>> Option.ensure(None)
|
||||||
|
Nothing()
|
||||||
|
'''
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
v
|
||||||
|
) if isinstance(v, Option) # pyright:ignore[reportUnknownVariableType]
|
||||||
|
else (
|
||||||
|
Some(v)
|
||||||
|
) if v is not None
|
||||||
|
else (
|
||||||
|
Nothing()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@final
|
@final
|
||||||
class Some[T](Option[T]):
|
class Some[T](Option[T]):
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from flask_login import login_required, login_user, logout_user # pyright:ignore
|
||||||
from wtforms import Form, PasswordField, StringField
|
from wtforms import Form, PasswordField, StringField
|
||||||
from wtforms.validators import Length
|
from wtforms.validators import Length
|
||||||
|
|
||||||
from taskflower.auth import report_incorrect_login_attempt
|
from taskflower.auth.violations import report_incorrect_login_attempt
|
||||||
from taskflower.auth.hash import check_hash
|
from taskflower.auth.hash import check_hash
|
||||||
from taskflower.db import db
|
from taskflower.db import db
|
||||||
from taskflower.db.model.user import User
|
from taskflower.db.model.user import User
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
|
from flask import Blueprint, render_template, request
|
||||||
from dataclasses import dataclass
|
|
||||||
from flask import Blueprint, redirect, render_template, 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_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.permission.resolve import resolve_perms_on_namespace
|
from taskflower.auth.violations import check_for_auth_err_and_report
|
||||||
|
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.user import User
|
from taskflower.db.model.user import User
|
||||||
from taskflower.types.either import Either, Left, Right, gather_successes
|
from taskflower.sanitize.namespace import NamespaceForUser
|
||||||
|
from taskflower.sanitize.task import TaskForUser
|
||||||
|
from taskflower.types.either import Left, Right, gather_successes
|
||||||
|
from taskflower.types.option import Option
|
||||||
|
from taskflower.web.errors import ResponseErrorNotFound, response_from_exception
|
||||||
|
|
||||||
web_namespace = Blueprint(
|
web_namespace = Blueprint(
|
||||||
'namespace',
|
'namespace',
|
||||||
|
|
@ -18,65 +23,23 @@ web_namespace = Blueprint(
|
||||||
url_prefix='/namespace'
|
url_prefix='/namespace'
|
||||||
)
|
)
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@web_namespace.app_context_processor
|
||||||
class NamespacePermsForUser:
|
def namespace_processor():
|
||||||
read: bool
|
# Inject some namespace helper functions into the jinja template
|
||||||
create_in: bool
|
# context.
|
||||||
complete_own: bool
|
def fetch_namespaces(user: User) -> list[NamespaceForUser]:
|
||||||
complete_all: bool
|
namespace_list = get_namespaces_for_user(user)
|
||||||
uncomplete_own: bool
|
|
||||||
uncomplete_all: bool
|
|
||||||
edit_own: bool
|
|
||||||
edit_all: bool
|
|
||||||
delete_own: bool
|
|
||||||
delete_all: bool
|
|
||||||
edit_roles: bool
|
|
||||||
administrate: bool
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
return gather_successes([
|
||||||
class NamespaceForUser():
|
NamespaceForUser.from_user(
|
||||||
id: int
|
ns, user
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
perms: NamespacePermsForUser
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_user(ns: Namespace, user: User) -> Either[Exception, 'NamespaceForUser']:
|
|
||||||
perms = resolve_perms_on_namespace(
|
|
||||||
user,
|
|
||||||
ns
|
|
||||||
)
|
|
||||||
|
|
||||||
if not (
|
|
||||||
NamespacePermissionType.READ in perms
|
|
||||||
or NamespacePermissionType.ADMINISTRATE in perms
|
|
||||||
):
|
|
||||||
return Left(KeyError('No such namespace or insufficient permissions.'))
|
|
||||||
|
|
||||||
return Right(
|
|
||||||
NamespaceForUser(
|
|
||||||
ns.id,
|
|
||||||
ns.name,
|
|
||||||
ns.description,
|
|
||||||
NamespacePermsForUser(
|
|
||||||
NamespacePermissionType.READ in perms,
|
|
||||||
NamespacePermissionType.CREATE_IN in perms,
|
|
||||||
NamespacePermissionType.COMPLETE_OWN in perms,
|
|
||||||
NamespacePermissionType.COMPLETE_ALL in perms,
|
|
||||||
NamespacePermissionType.UNCOMPLETE_OWN in perms,
|
|
||||||
NamespacePermissionType.UNCOMPLETE_ALL in perms,
|
|
||||||
NamespacePermissionType.EDIT_OWN in perms,
|
|
||||||
NamespacePermissionType.EDIT_ALL in perms,
|
|
||||||
NamespacePermissionType.DELETE_OWN in perms,
|
|
||||||
NamespacePermissionType.DELETE_ALL in perms,
|
|
||||||
NamespacePermissionType.EDIT_ROLES in perms,
|
|
||||||
NamespacePermissionType.ADMINISTRATE in perms
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
for ns in namespace_list
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
fetch_namespaces=fetch_namespaces
|
||||||
|
)
|
||||||
|
|
||||||
@web_namespace.route('/')
|
@web_namespace.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
|
|
@ -99,4 +62,78 @@ def all():
|
||||||
@web_namespace.route('/<int:id>')
|
@web_namespace.route('/<int:id>')
|
||||||
@login_required
|
@login_required
|
||||||
def get(id: int):
|
def get(id: int):
|
||||||
return redirect(url_for('web.namespace.all'))
|
def _fetch_tasks_for(
|
||||||
|
ns: Namespace,
|
||||||
|
usr: User
|
||||||
|
) -> list[TaskForUser]:
|
||||||
|
tsks = db.session.query(
|
||||||
|
Task
|
||||||
|
).filter(
|
||||||
|
Task.namespace == ns.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return gather_successes(
|
||||||
|
[
|
||||||
|
assert_user_perms_on_task(
|
||||||
|
usr,
|
||||||
|
tsk,
|
||||||
|
NamespacePermissionType.READ,
|
||||||
|
'Read task'
|
||||||
|
).flat_map(
|
||||||
|
lambda t: TaskForUser.from_task(
|
||||||
|
t,
|
||||||
|
usr
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for tsk in tsks
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
cur_usr: User = current_user # pyright:ignore[reportAssignmentType]
|
||||||
|
|
||||||
|
return Option[Namespace].encapsulate(
|
||||||
|
db.session.query(
|
||||||
|
Namespace
|
||||||
|
).filter(
|
||||||
|
Namespace.id == id
|
||||||
|
).one_or_none()
|
||||||
|
).and_then(
|
||||||
|
lambda val: Right[Exception, Namespace](val),
|
||||||
|
lambda: Left[Exception, Namespace](
|
||||||
|
ResponseErrorNotFound(
|
||||||
|
request.method,
|
||||||
|
request.path,
|
||||||
|
f'Namespace with id {id} not found!',
|
||||||
|
not_found_or_not_authorized(
|
||||||
|
'Namespace',
|
||||||
|
'id'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).flat_map(
|
||||||
|
lambda ns: assert_user_perms_on_namespace(
|
||||||
|
cur_usr,
|
||||||
|
ns,
|
||||||
|
NamespacePermissionType.READ,
|
||||||
|
'Read'
|
||||||
|
)
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
).flat_map(
|
||||||
|
lambda ns: NamespaceForUser.from_user(
|
||||||
|
ns,
|
||||||
|
cur_usr,
|
||||||
|
_fetch_tasks_for(
|
||||||
|
ns,
|
||||||
|
cur_usr
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).and_then(
|
||||||
|
lambda ns_usr: render_template(
|
||||||
|
'namespace/detail.html',
|
||||||
|
namespace=ns_usr
|
||||||
|
),
|
||||||
|
lambda exc: response_from_exception(
|
||||||
|
exc
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
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.db import db
|
from taskflower.auth.permission import NamespacePermissionType
|
||||||
|
from taskflower.auth.permission.lookups import tasks_where_user_can
|
||||||
|
from taskflower.auth.violations import check_for_auth_err_and_report
|
||||||
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.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 reduce_either
|
||||||
from taskflower.web.errors import (
|
from taskflower.web.errors import (
|
||||||
response_from_exception
|
response_from_exception
|
||||||
|
|
@ -16,23 +19,37 @@ web_tasks = Blueprint(
|
||||||
url_prefix='/tasks'
|
url_prefix='/tasks'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@web_tasks.app_context_processor
|
||||||
|
def tasks_processor():
|
||||||
|
# Inject some task helper functions into the jinja template
|
||||||
|
# context.
|
||||||
|
def tlist_cid(component: str, task_id: str|int, list_id: str|int = 0) -> str:
|
||||||
|
# Generate a unique HTML ``id`` for a component in a task list.
|
||||||
|
return f'list-{list_id}-{component}-{task_id}'
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
tlist_cid=tlist_cid
|
||||||
|
)
|
||||||
|
|
||||||
@web_tasks.route('/')
|
@web_tasks.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
def all():
|
def all():
|
||||||
task_list: list[Task] = db.session.execute(
|
task_list = tasks_where_user_can(
|
||||||
db.select( # pyright:ignore[reportAssignmentType,reportAny]
|
current_user, # pyright:ignore[reportArgumentType]
|
||||||
Task
|
NamespacePermissionType.READ
|
||||||
).filter_by(
|
)
|
||||||
owner=current_user.id # pyright:ignore[reportAny]
|
|
||||||
).order_by(
|
|
||||||
Task.due
|
|
||||||
)
|
|
||||||
).scalars()
|
|
||||||
|
|
||||||
return reduce_either([
|
return reduce_either(
|
||||||
t.sanitize()
|
[
|
||||||
for t in task_list
|
TaskForUser.from_task(
|
||||||
]).and_then(
|
t,
|
||||||
|
current_user # pyright:ignore[reportArgumentType]
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
)
|
||||||
|
for t in task_list
|
||||||
|
]
|
||||||
|
).and_then(
|
||||||
lambda task_list_sanitized: render_template(
|
lambda task_list_sanitized: render_template(
|
||||||
'task/list.html',
|
'task/list.html',
|
||||||
tasks=task_list_sanitized
|
tasks=task_list_sanitized
|
||||||
|
|
@ -43,17 +60,20 @@ def all():
|
||||||
@web_tasks.route('/new', methods=['GET', 'POST'])
|
@web_tasks.route('/new', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def new():
|
def new():
|
||||||
form_data = Task.form(request.form)
|
form_data = task_form_for_user(
|
||||||
|
current_user # pyright:ignore[reportArgumentType]
|
||||||
|
)(request.form)
|
||||||
|
|
||||||
if request.method == 'POST' and form_data.validate():
|
if request.method == 'POST' and form_data.validate():
|
||||||
return Task.from_form(
|
return form_data.create_object(
|
||||||
form_data,
|
|
||||||
current_user # pyright:ignore[reportArgumentType]
|
current_user # pyright:ignore[reportArgumentType]
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
).flat_map(
|
).flat_map(
|
||||||
lambda task: add_to_db(task)
|
lambda task: add_to_db(task)
|
||||||
).and_then(
|
).and_then(
|
||||||
lambda task: redirect(url_for(
|
lambda task: redirect(url_for(
|
||||||
'web.task.all',
|
'web.task.all'
|
||||||
id=task.id
|
|
||||||
)),
|
)),
|
||||||
lambda exc: response_from_exception(exc)
|
lambda exc: response_from_exception(exc)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -127,8 +127,8 @@ class CreateUserForm(Form):
|
||||||
message='Display names must be at least one character long!'
|
message='Display names must be at least one character long!'
|
||||||
),
|
),
|
||||||
Length(
|
Length(
|
||||||
max=64,
|
max=256,
|
||||||
message='Display names can\'t be longer than 64 characters!'
|
message='Display names can\'t be longer than 256 characters!'
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
@ -271,4 +271,4 @@ def create_user_page():
|
||||||
|
|
||||||
@web_user.route('/profile/<int:id>')
|
@web_user.route('/profile/<int:id>')
|
||||||
def profile(id: int):
|
def profile(id: int):
|
||||||
return redirect(url_for('web.task.all_tasks_view'))
|
return redirect(url_for('web.task.all'))
|
||||||
Loading…
Add table
Add a link
Reference in a new issue