CSS improvements and partial implementation for #3

- SVG icons are now preprocessed into raw HTML at first request. This allows them to be inlined (use `{{icon(icon_name)|safe}}`) and thus styled by CSS.
- General CSS improvements (especially around buttons)
- A basic role editor is now implemented. Go to `/namespace/<id>/role` to see it.
- Task and invite lists now have an "add new" button on the list page.
- Slight permission fixes
- Added `assert_left()` and `assert_right()` to `Either`s. Now, if you do `if isinstance(x, Right)`, you can `x.assert_left()` in the `else` to make the type checker happy.
This commit is contained in:
digimint 2025-11-20 06:16:26 -06:00
parent 9707dbe45e
commit 113ebce9e1
Signed by: digimint
GPG key ID: 8DF1C6FD85ABF748
50 changed files with 1928 additions and 156 deletions

View file

@ -8,6 +8,7 @@
- pyargon2 (for HashV1)
- humanize (for generating human-readable timedeltas)
- nh3 (for markdown and general HTML sanitization)
- scour (for SVG icon processing)
## Already Included
- Certain tag definitions from bleach-allowlist, used in markdown sanitization.

View file

@ -2,18 +2,21 @@ from datetime import datetime
import logging
import humanize
from typing import Any
from flask import Flask, render_template
from flask import Flask, render_template, url_for
from taskflower.auth import taskflower_login_manager
from taskflower.config import SignUpMode, config
from taskflower.db import db
from taskflower.api import APIBase
from taskflower.db.model.user import User
from taskflower.sanitize.markdown import render_and_sanitize
from taskflower.sanitize.markdown import SafeHTML, render_and_sanitize
from taskflower.tools.icons import get_icon, svg_bp
from taskflower.web import web_base
from taskflower.tools.hibp import hibp_bp
logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)
log.info('Initializing Taskflower...')
@ -35,6 +38,7 @@ log.info(' > Building routes...')
app.register_blueprint(web_base)
app.register_blueprint(hibp_bp)
app.register_blueprint(svg_bp)
APIBase.register(app)
# print(f'Routes: \n{"\n".join([str(r) for r in APIBase.routes])}')
@ -84,11 +88,20 @@ def template_utility_fns():
def render_as_markdown(raw: str):
return render_and_sanitize(raw)
def icon(name: str) -> SafeHTML:
return get_icon(name).lside_effect(
lambda exc: log.warning(f'Error while generating icon: {exc}')
).and_then(
lambda val: f'<div class="icon">{val}</div>',
lambda exc: f'<div class="icon"><img class="icon" src="{url_for('static', filename='bad-icon.png')}" alt="Error retrieving icon."/></div>'
)
return dict(
literal_call=literal_call,
reltime=reltime,
can_generate_sign_up_codes=can_generate_sign_up_codes,
render_as_markdown=render_as_markdown
render_as_markdown=render_as_markdown,
icon=icon
)
@app.route('/')

View file

@ -1,6 +1,7 @@
import logging
from taskflower import app
from taskflower.auth.startup import startup_checks
from taskflower.config import config
from taskflower.db import db
from taskflower.types.either import Left
@ -16,5 +17,5 @@ if __name__ == '__main__':
log.error(f'Startup checks failed: {res.val}')
raise res.val
else:
log.error('Startup checks succeeded!')
app.run(debug=True)
log.info('Startup checks succeeded!')
app.run(debug=config.debug)

View file

@ -16,7 +16,6 @@ ProtectedResourceType = User|Task|Namespace|NamespaceRole|UserRole|SignUpCode
class NamespacePermissionType(IntFlag):
NO_PERMS = 0
READ = (1 << 0)
ADMINISTRATE = (1 << 1)
CREATE_TASKS_IN = (1 << 2)
COMPLETE_OWN_TASKS = (1 << 3)
COMPLETE_ALL_TASKS = (1 << 4)
@ -29,6 +28,9 @@ class NamespacePermissionType(IntFlag):
EDIT_TAGS = (1 << 12)
EDIT_FIELDS = (1 << 13)
EDIT_ROLES = (1 << 11)
ADMINISTRATE = (1 << 1)
NPT = NamespacePermissionType
class UserPermissionType(IntFlag):
NO_PERMS = 0
@ -41,6 +43,8 @@ class UserPermissionType(IntFlag):
EDIT_ROLES = (1 << 9)
ADMINISTRATE = (1 << 10)
UPT = UserPermissionType
SELF_USER_PERMISSIONS = (
UserPermissionType.READ_PROFILE
| UserPermissionType.EDIT_DISPLAY_NAME
@ -65,15 +69,55 @@ SELF_NAMESPACE_PERMISSIONS = (
| NamespacePermissionType.DELETE_OWN_TASKS
| NamespacePermissionType.DELETE_ALL_TASKS
| NamespacePermissionType.EDIT_TAGS
| NamespacePermissionType.EDIT_FIELDS
| NamespacePermissionType.EDIT_ROLES
)
def user_friendly_name(perm: NamespacePermissionType|UserPermissionType):
match perm:
case NamespacePermissionType.READ:
return 'See the namespace.'
case NamespacePermissionType.CREATE_TASKS_IN:
return 'Create tasks.'
case NamespacePermissionType.COMPLETE_ALL_TASKS:
return 'Complete tasks.'
case NamespacePermissionType.UNCOMPLETE_ALL_TASKS:
return 'Uncomplete tasks.'
case NamespacePermissionType.EDIT_ALL_TASKS:
return 'Edit tasks, including adding/removing tags and fields.'
case NamespacePermissionType.DELETE_ALL_TASKS:
return 'Delete tasks.'
case NamespacePermissionType.EDIT_TAGS:
return 'Create new tags and delete existing ones.'
case NamespacePermissionType.EDIT_FIELDS:
return 'Create new fields and delete existing ones.'
case NamespacePermissionType.EDIT_ROLES:
return 'Manage roles lower than this one.'
case NamespacePermissionType.ADMINISTRATE:
return 'Administrator. Users with this role bypass all other checks.'
case UserPermissionType.READ_PROFILE:
return 'View the subject user\'s profile.'
case UserPermissionType.EDIT_DISPLAY_NAME:
return 'Edit the subject user\'s display name.'
case UserPermissionType.EDIT_USERNAME:
return 'Edit the subject user\'s username.'
case UserPermissionType.EDIT_PROFILE:
return 'Edit the subject user\'s profile.'
case UserPermissionType.SEE_ALL_TASKS_OF:
return 'View any tasks the subject user can view.'
case UserPermissionType.ACT_AS:
return 'Perform namespace or task actions on behalf of the subject user. This permission allows managing users to perform any such action the subject user could perform.'
case UserPermissionType.ADMINISTRATE:
return 'Administrator. Users with this role bypass other checks.'
case _:
return str(perm.name)
def _create_user_role(user: User) -> Either[Exception, UserRole]:
try:
self_role = UserRole(
is_self=True, # pyright:ignore[reportCallIssue]
name='Self', # pyright:ignore[reportCallIssue]
description=f'Self-role for @user@{user.id}', # pyright:ignore[reportCallIssue]
permissions=int(SELF_USER_PERMISSIONS), # pyright:ignore[reportCallIssue]
perms_deny=0, # pyright:ignore[reportCallIssue]
priority=0, # pyright:ignore[reportCallIssue]
@ -106,7 +150,6 @@ def _create_namespace_role(
try:
new_ns_role = NamespaceRole(
name='Administrator', # pyright:ignore[reportCallIssue]
description='Default role for the namespace administrator.', # pyright:ignore[reportCallIssue]
permissions = SELF_NAMESPACE_PERMISSIONS, # pyright:ignore[reportCallIssue]
perms_deny = 0, # pyright:ignore[reportCallIssue]
priority = 0, # pyright:ignore[reportCallIssue]
@ -193,6 +236,6 @@ class AuthorizationError(Exception):
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) if self.required_perms else "N/A"}'
+ f'\n - User has permissions: {str(self.user_perms) if self.user_perms else "N/A"}'
+ f'\n - Required permissions: {repr(self.required_perms) if self.required_perms else "N/A"}'
+ f'\n - User has permissions: {repr(self.user_perms) if self.user_perms else "N/A"}'
)

View file

@ -1,11 +1,29 @@
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.auth.permission.resolve import namespace_permission_priority
from taskflower.db import db
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.role import NamespaceRole
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
from taskflower.types.option import Option, Some
def sufficient_priority_to_edit(user_perm_priority: int, role_to_edit_priority: int) -> bool:
''' Used instead of simple comparisons to ensure consistent behavior.
Any role that grants ``ADMINISTRATE`` permission ignores priority in all
checks EXCEPT when checking whether another role denies
``ADMINISTRATE``. In other words, if a user has a role that grants
``ADMINISTRATE`` and a higher-priority role that denies it, they are not
considered to have ``ADMINISTRATE``, but any user that *does* have
``ADMINISTRATE`` can ignore priority checks.
'''
return (
(user_perm_priority < role_to_edit_priority)
or (user_perm_priority == -1) # (administrator)
)
def check_user_perms_on_namespace(
usr: User,
@ -13,7 +31,7 @@ def check_user_perms_on_namespace(
perms: NamespacePermissionType
) -> bool:
return (
(nsperms:=get_user_perms_on_namespace(usr, ns) & perms)
((nsperms:=get_user_perms_on_namespace(usr, ns)) & perms)
== perms
) or NamespacePermissionType.ADMINISTRATE in nsperms
@ -68,3 +86,46 @@ def assert_user_perms_on_task(
))
)
)
def check_user_can_edit_role(
usr: User,
role: NamespaceRole
) -> bool:
return Option[Namespace].encapsulate(
db.session.query(
Namespace
).filter(
Namespace.id == role.namespace
).one_or_none()
).flat_map(
lambda ns: namespace_permission_priority(
usr,
ns,
NamespacePermissionType.EDIT_ROLES
)
).map(
lambda pri: sufficient_priority_to_edit(
pri,
role.priority
)
).and_then(
lambda val: val,
lambda: False
)
def assert_user_can_edit_role(
user: User,
role: NamespaceRole,
action: str = '[Unspecified Action]'
) -> Either[Exception, NamespaceRole]:
return (
Right(role)
) if check_user_can_edit_role(user, role) else (
Left(AuthorizationError(
Some(user),
role,
action,
None,
None
))
)

View file

@ -6,6 +6,7 @@ from taskflower.db.model.namespace import Namespace
from taskflower.db.model.role import NamespaceRole, UserToNamespaceRole
from taskflower.db.model.task import Task
from taskflower.db.model.user import User
from taskflower.types.option import Nothing, Option, Some
def get_namespaces_for_user(user: User):
return db.session.query(
@ -89,3 +90,62 @@ def get_user_perms_on_namespace(
usr,
ns
)
def get_priority_for_new_namespace_role(
ns: Namespace
) -> int:
roles = db.session.query(
NamespaceRole
).filter(
NamespaceRole.namespace == ns.id
).all()
lowest_pri = 0
for role in roles:
if role.priority > lowest_pri:
lowest_pri = role.priority
return lowest_pri+1
def get_namespace_role_above(
role: NamespaceRole
) -> Option[NamespaceRole]:
roles = db.session.query(
NamespaceRole
).filter(
NamespaceRole.namespace == role.namespace
).order_by(
NamespaceRole.priority.asc()
).all()
closest_role: Option[NamespaceRole] = Nothing()
for db_role in roles:
if db_role.priority < role.priority:
closest_role = Some(db_role)
else:
return closest_role
return closest_role
def get_namespace_role_below(
role: NamespaceRole
) -> Option[NamespaceRole]:
roles = db.session.query(
NamespaceRole
).filter(
NamespaceRole.namespace == role.namespace
).order_by(
NamespaceRole.priority.desc()
).all()
closest_role: Option[NamespaceRole] = Nothing()
for db_role in roles:
if db_role.priority > role.priority:
closest_role = Some(db_role)
else:
return closest_role
return closest_role

View file

@ -6,7 +6,7 @@ from taskflower.db.model.namespace import Namespace
from taskflower.db.model.role import NamespaceRole, UserToNamespaceRole
from taskflower.db.model.task import Task
from taskflower.db.model.user import User
from taskflower.types.option import Option
from taskflower.types.option import Nothing, Option, Some
def resolve_perms_on_namespace(
user: User,
@ -14,19 +14,13 @@ def resolve_perms_on_namespace(
) -> NamespacePermissionType:
roles = db.session.query(
NamespaceRole
).join(
Namespace,
NamespaceRole.namespace == Namespace.id
).join(
UserToNamespaceRole,
NamespaceRole.id == UserToNamespaceRole.role
).join(
User,
User.id == UserToNamespaceRole.user
).filter(
User.id == user.id
UserToNamespaceRole.user == user.id
).filter(
Namespace.id == namespace.id
NamespaceRole.namespace == namespace.id
).order_by(
NamespaceRole.priority.desc()
).all()
@ -41,6 +35,46 @@ def resolve_perms_on_namespace(
return perms
def namespace_permission_priority(
user: User,
namespace: Namespace,
perm: NamespacePermissionType
) -> Option[int]:
current_highest: Option[int] = Nothing()
admin: bool = False
roles = db.session.query(
NamespaceRole
).join(
UserToNamespaceRole,
NamespaceRole.id == UserToNamespaceRole.role
).filter(
UserToNamespaceRole.user == user.id
).filter(
NamespaceRole.namespace == namespace.id
).order_by(
NamespaceRole.priority.desc()
).all()
for role in roles:
role_allow = NamespacePermissionType(role.permissions)
role_deny = NamespacePermissionType(role.perms_deny)
if perm in role_allow:
current_highest = Some(role.priority)
if perm in role_deny:
current_highest = Nothing()
if NamespacePermissionType.ADMINISTRATE in role_allow:
admin = True
if NamespacePermissionType.ADMINISTRATE in role_deny:
admin = False
if admin:
return Some(-1)
else:
return current_highest
def resolve_perms_on_task(
user: User,
task: Task

View file

@ -136,6 +136,16 @@ class ConfigType:
# Database connection URL
db_url: str = 'sqlite:///site.db'
# Data directory path
data_path: str = 'instance'
# Debug settings
debug: bool = False
# Regenerate icon file with each request. Useful during development, but it
# should never be enabled in production.
debug_always_regen_icon_file: bool = False
@classmethod
def from_env(cls) -> Either[list[ConfigKeyError[Any]], Self]: # pyright:ignore[reportExplicitAny]

View file

@ -50,3 +50,19 @@ def insert_into_db[T](
return do_commit(database).map(lambda _: to_insert)
except Exception as e:
return Left(e)
def db_fetch_by_id[T](
to_fetch: type[T],
id: int,
database: SQLAlchemy
) -> Either[Exception, T]:
try:
return Right(
database.session.query(
to_fetch
).filter(
to_fetch.id == id # pyright:ignore[reportUnknownMemberType,reportAttributeAccessIssue,reportUnknownArgumentType]
).one()
)
except Exception as e:
return Left(e)

View file

@ -8,8 +8,7 @@ class NamespaceRole(db.Model):
__tablename__: str = 'namespace_role'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(256))
description: Mapped[str] = mapped_column(String)
name: Mapped[str] = mapped_column(String(32))
permissions: Mapped[int] = mapped_column(Integer)
perms_deny: Mapped[int] = mapped_column(Integer)
priority: Mapped[int] = mapped_column(Integer)
@ -27,8 +26,7 @@ class UserRole(db.Model):
__tablename__: str = 'user_role'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
is_self: Mapped[bool] = mapped_column(Boolean, default=False) # Self-roles can't be assigned to anyone other than the user whose account they are associated with
name: Mapped[str] = mapped_column(String(256))
description: Mapped[str] = mapped_column(String)
name: Mapped[str] = mapped_column(String(32))
permissions: Mapped[int] = mapped_column(Integer)
perms_deny: Mapped[int] = mapped_column(Integer)
priority: Mapped[int] = mapped_column(Integer)

View file

@ -4,12 +4,11 @@ from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from taskflower.db import db
from taskflower.types.resource import APISerializable
from flask_login import UserMixin # pyright:ignore[reportMissingTypeStubs]
class User(db.Model, UserMixin, APISerializable):
class User(db.Model, UserMixin):
__tablename__: str = 'user'
id: Mapped[int] = mapped_column(Integer, primary_key=True)

View file

@ -1,5 +1,6 @@
from wtforms import Form
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.user import User
from taskflower.types.either import Either
@ -53,3 +54,52 @@ class FormEditsObjectWithUser[T](Form):
edit an object with the specified parameters.
'''
raise NotImplementedError()
class FormCreatesObjectWithUserAndNamespace[T](Form):
''' Trait that indicates that this ``Form`` can be used to create an object,
if provided with a ``User`` object and a ``Namespace``.
'''
def create_object(
self,
current_user : User, # pyright:ignore[reportUnusedParameter]
namespace : Namespace # 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
class FormEditsObjectWithUserAndNamespace[T](Form):
''' Trait that indicates that this ``Form`` can be used to edit an object
if provided with a ``User`` object and a ``Namespace``.
'''
def edit_object(
self,
current_user : User, # pyright:ignore[reportUnusedParameter]
namespace : Namespace, # pyright:ignore[reportUnusedParameter]
target_object : T # pyright:ignore[reportUnusedParameter]
) -> Either[Exception, T]:
''' Try to edit ``target_object`` on behalf of ``current_user``.
This function checks for authorization, and will return an
``AuthorizationError`` if ``current_user`` is not authorized to
edit an object with the specified parameters.
'''
raise NotImplementedError()
class FormCEObjUserNS[T](
FormCreatesObjectWithUserAndNamespace[T],
FormEditsObjectWithUserAndNamespace[T]
):
''' Form Creates and Edits Object With User and Namespace.
A ``Form`` that can both create (``create_object()``) and edit
(``edit_object()``) its objects when provided with a ``User`` and a
``Namespace``.
'''
pass

View file

@ -1,8 +1,15 @@
from typing import override
from wtforms import StringField, ValidationError
from wtforms.validators import Length
from typing import Any, override
from wtforms import Field, RadioField, StringField, ValidationError
from wtforms.validators import DataRequired, Length
from taskflower.auth.permission import NPT, NamespacePermissionType
from taskflower.auth.permission.checks import assert_user_perms_on_namespace, check_user_perms_on_namespace
from taskflower.auth.permission.lookups import get_priority_for_new_namespace_role
from taskflower.auth.violations import check_for_auth_err_and_report
from taskflower.db.model.namespace import Namespace
from taskflower.form import FormCreatesObject
from taskflower.db.model.role import NamespaceRole
from taskflower.db.model.user import User
from taskflower.form import FormCEObjUserNS, FormCreatesObject
from taskflower.types import ann
from taskflower.types.either import Either, Left, Right
@ -41,3 +48,183 @@ class NamespaceForm(FormCreatesObject[Namespace]):
return Left(
ValidationError('Form data failed validation!')
)
def allowlist_and_denylist_from_form_data(
data: list[Field]
) -> tuple[NPT, NPT]:
allowlist = NPT.NO_PERMS
denylist = NPT.NO_PERMS
for field in data:
if (
isinstance(field.data, str) # pyright:ignore[reportAny]
and field.name.upper() in NPT._member_names_
):
perm: NPT = NPT._member_map_[field.name.upper()] # pyright:ignore[reportAssignmentType]
if field.data == 'deny':
denylist |= perm
elif field.data == 'allow':
allowlist |= perm
return allowlist, denylist
def get_namespace_role_form_for(
usr: User,
ns: Namespace,
cur: NamespaceRole|None = None
) -> Either[Exception, type[FormCEObjUserNS[NamespaceRole]]]:
class NamespaceRoleForm(FormCEObjUserNS[NamespaceRole]):
name: StringField = StringField(
'Role Name',
[
Length(
min=1,
max=32,
message='Role name must be between 1 and 32 characters.'
)
],
default=(
'New Role'
) if cur is None else (
cur.name
)
)
@override
def create_object(
self,
current_user: User,
namespace: Namespace
) -> Either[Exception, NamespaceRole]:
return assert_user_perms_on_namespace(
current_user,
namespace,
NPT.EDIT_ROLES
).map(
lambda _: allowlist_and_denylist_from_form_data(
list(self._fields.values())
)
).flat_map(
lambda allow_deny: assert_user_perms_on_namespace(
current_user,
namespace,
allow_deny[0] | allow_deny[1]
).map(
lambda _: allow_deny
)
).lside_effect(
check_for_auth_err_and_report
).map(
lambda allow_deny: NamespaceRole(
name=ann(self.name.data), # pyright:ignore[reportCallIssue]
permissions=allow_deny[0].value, # pyright:ignore[reportCallIssue]
perms_deny=allow_deny[1].value, # pyright:ignore[reportCallIssue]
priority=get_priority_for_new_namespace_role(namespace), # pyright:ignore[reportCallIssue]
namespace=namespace.id # pyright:ignore[reportCallIssue]
)
)
def get_field(self, name: str) -> RadioField|Any: # pyright:ignore[reportExplicitAny]
return getattr(self, name) # pyright:ignore[reportAny]
@override
def edit_object(
self,
current_user: User,
namespace: Namespace,
target_object: NamespaceRole
) -> Either[Exception, NamespaceRole]:
def _do_edits(
target: NamespaceRole,
new_allowlist: NPT,
new_denylist: NPT
) -> Either[Exception, NamespaceRole]:
try:
target.name = ann(self.name.data)
target.permissions = new_allowlist
target.perms_deny = new_denylist
return Right(target)
except Exception as e:
return Left(e)
return assert_user_perms_on_namespace(
current_user,
namespace,
NPT.EDIT_ROLES
).map(
lambda _: allowlist_and_denylist_from_form_data(
list(self._fields.values())
)
).flat_map(
lambda allow_deny: assert_user_perms_on_namespace(
current_user,
namespace,
allow_deny[0] | allow_deny[1]
).map(
lambda _: allow_deny
)
).lside_effect(
check_for_auth_err_and_report
).flat_map(
lambda allow_deny: _do_edits(
target_object,
allow_deny[0],
allow_deny[1]
)
)
perm_fields: list[str] = []
for perm in NamespacePermissionType:
if perm in [
NPT.EDIT_OWN_TASKS,
NPT.DELETE_OWN_TASKS,
NPT.COMPLETE_OWN_TASKS,
NPT.UNCOMPLETE_OWN_TASKS
]:
continue # these permissions aren't implemented yet - hide them from the user.
if check_user_perms_on_namespace(
usr,
ns,
perm
):
field = RadioField(
ann(perm.name),
[
DataRequired()
],
choices=[
('allow', 'Allow'),
('clear', 'Clear'),
('deny', 'Deny')
],
default=(
'clear'
) if cur is None else (
(
'deny'
) if (
perm in NamespacePermissionType(cur.perms_deny)
) else (
'allow'
) if (
perm in NamespacePermissionType(cur.permissions)
) else (
'clear'
)
)
)
setattr(
NamespaceRoleForm,
ann(perm.name),
field
)
perm_fields.append(ann(perm.name))
setattr(NamespaceRoleForm, 'perm_fields', perm_fields)
return Right(NamespaceRoleForm)

View file

@ -1,8 +1,12 @@
from dataclasses import dataclass
from typing import Self
from taskflower.auth.permission import NamespacePermissionType
from taskflower.auth.permission.checks import assert_user_can_edit_role, check_user_can_edit_role
from taskflower.auth.permission.lookups import get_namespace_role_above
from taskflower.auth.permission.resolve import resolve_perms_on_namespace
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.role import NamespaceRole
from taskflower.db.model.user import User
from taskflower.sanitize.task import TaskForUser
from taskflower.types.either import Either, Left, Right
@ -76,3 +80,108 @@ class NamespaceForUser():
)
)
)
@dataclass(frozen=True)
class PermEntry:
allow : bool
deny : bool
@dataclass(frozen=True)
class RolePerms:
read : PermEntry
create_tasks_in : PermEntry
complete_own_tasks : PermEntry
complete_all_tasks : PermEntry
uncomplete_own_tasks : PermEntry
uncomplete_all_tasks : PermEntry
edit_own_tasks : PermEntry
edit_all_tasks : PermEntry
delete_own_tasks : PermEntry
delete_all_tasks : PermEntry
edit_roles : PermEntry
edit_tags : PermEntry
edit_fields : PermEntry
administrate : PermEntry
@classmethod
def from_fields(
cls,
allow: NamespacePermissionType,
deny: NamespacePermissionType
) -> Self:
def _entry_for(
perm: NamespacePermissionType
) -> PermEntry:
return ((
PermEntry(False, True)
) if perm in deny
else (
PermEntry(True, False)
) if perm in allow
else (
PermEntry(False, False)
))
return cls(
_entry_for(NamespacePermissionType.READ),
_entry_for(NamespacePermissionType.CREATE_TASKS_IN),
_entry_for(NamespacePermissionType.COMPLETE_OWN_TASKS),
_entry_for(NamespacePermissionType.COMPLETE_ALL_TASKS),
_entry_for(NamespacePermissionType.UNCOMPLETE_OWN_TASKS),
_entry_for(NamespacePermissionType.UNCOMPLETE_ALL_TASKS),
_entry_for(NamespacePermissionType.EDIT_OWN_TASKS),
_entry_for(NamespacePermissionType.EDIT_ALL_TASKS),
_entry_for(NamespacePermissionType.DELETE_OWN_TASKS),
_entry_for(NamespacePermissionType.DELETE_ALL_TASKS),
_entry_for(NamespacePermissionType.EDIT_ROLES),
_entry_for(NamespacePermissionType.EDIT_TAGS),
_entry_for(NamespacePermissionType.EDIT_FIELDS),
_entry_for(NamespacePermissionType.ADMINISTRATE)
)
@dataclass(frozen=True)
class NamespaceRoleForUser():
id: int
name: str
priority: int
perms: RolePerms
can_edit: bool
can_promote: bool
can_demote: bool
can_delete: bool
@classmethod
def from_role(
cls,
role: NamespaceRole,
for_user: User,
is_first: bool,
is_last: bool
) -> Self:
return cls(
role.id,
role.name,
role.priority,
RolePerms.from_fields(
NamespacePermissionType(role.permissions),
NamespacePermissionType(role.perms_deny)
),
can_edit:=check_user_can_edit_role(for_user, role),
(not is_first) and get_namespace_role_above(
role
).and_then(
lambda val: Right[Exception, NamespaceRole](val),
lambda: Left[Exception, NamespaceRole](ValueError())
).flat_map(
lambda next_r: assert_user_can_edit_role(
for_user,
next_r
)
).and_then(
lambda _: True,
lambda _: False
),
can_edit and not is_last,
can_edit
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 B

View file

@ -22,20 +22,113 @@
}
#submit-form {
background-color: var(--btn-1);
color: var(--on-btn-1);
width: max-content;
padding: 1rem 1.5rem;
border: 2px solid var(--btn-1-border);
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
margin: 0.5rem;
transition: all 0.2s;
text-decoration: none;
font-weight: bold;
font-size: larger;
font-size: 2rem;
&:hover {
background-color: var(--btn-1-hlt);
color: var(--on-btn-1-hlt);
svg {
max-width: 2rem;
max-height: 2rem;
}
}
.three-way-select {
--tws-bg : var(--btn-1);
--tws-border : var(--btn-1-border);
--tws-text : var(--on-btn-1);
--tws-hover-bg : var(--btn-1-hlt);
--tws-hover-border : var(--btn-1-border-hlt);
--tws-hover-text : var(--on-btn-1-hlt);
--tws-active-bg : var(--tws-hover-border);
--tws-active-border : var(--tws-hover-border);
--tws-active-text : var(--tws-hover-bg);
display: flex;
flex-direction: row;
list-style-type: none;
max-width: 20rem;
padding: 0;
margin-bottom: 2rem;
user-select: none;
label {
background-color: var(--tws-bg);
color: var(--tws-text);
border: 2px solid var(--tws-border);
flex: 1 0;
text-align: center;
padding: 1rem;
cursor: pointer;
font-weight: bolder;
box-shadow: #000 0 0 0.1rem 0 inset;
transition: color 0.2s;
transition: background-color 0.2s;
input {
opacity: 0;
position: absolute;
pointer-events: none;
}
&:nth-child(1){
--tws-border : var(--btn-green-brd);
--tws-bg : var(--btn-green-bg);
--tws-text : var(--btn-green-text);
--tws-hover-border : var(--btn-green-hover-brd);
--tws-hover-bg : var(--btn-green-hover-bg);
--tws-hover-text : var(--btn-green-hover-text);
--tws-active-border : var(--btn-green-active-brd);
--tws-active-bg : var(--btn-green-active-bg);
--tws-active-text : var(--btn-green-active-text);
border-radius: 1rem 0 0 1rem;
}
&:nth-child(2){
--tws-border : var(--btn-yellow-brd);
--tws-bg : var(--btn-yellow-bg);
--tws-text : var(--btn-yellow-text);
--tws-hover-border : var(--btn-yellow-hover-brd);
--tws-hover-bg : var(--btn-yellow-hover-bg);
--tws-hover-text : var(--btn-yellow-hover-text);
--tws-active-border : var(--btn-yellow-active-brd);
--tws-active-bg : var(--btn-yellow-active-bg);
--tws-active-text : var(--btn-yellow-active-text);
}
&:nth-child(3){
--tws-border : var(--btn-red-brd);
--tws-bg : var(--btn-red-bg);
--tws-text : var(--btn-red-text);
--tws-hover-border : var(--btn-red-hover-brd);
--tws-hover-bg : var(--btn-red-hover-bg);
--tws-hover-text : var(--btn-red-hover-text);
--tws-active-border : var(--btn-red-active-brd);
--tws-active-bg : var(--btn-red-active-bg);
--tws-active-text : var(--btn-red-active-text);
border-radius: 0 1rem 1rem 0;
}
&:hover {
box-shadow: inset 0 0 0.1rem var(--tws-border);
--tws-border : var(--tws-hover-border);
--tws-bg : var(--tws-hover-bg);
--tws-text : var(--tws-hover-text);
}
&:active,&:has(input:checked) {
box-shadow: none;
--tws-border : var(--tws-active-border);
--tws-bg : var(--tws-active-bg);
--tws-text : var(--tws-active-text);
}
}
}

View file

@ -27,6 +27,14 @@
.list td {
padding-left: 1rem;
&.nopad {
padding-left: 0;
}
&.pad-even {
padding: 0.5rem;
}
}
.list p {

View file

@ -1,9 +1,9 @@
:root {
--bg: #0e0c15;
--fg: #ffffff;
--accent-1: #d12f7b;
--accent-1: #f8479a;
--on-accent-1: var(--bg);
--accent-1-hlt: #c7749b;
--accent-1-hlt: #eb95bd;
--footer: #222027;
--on-footer: #9b9b9b;
@ -24,11 +24,84 @@
--on-form: #ffc4d1;
--btn-1: #7a3053;
--btn-1-border: #d77faa;
--btn-1-border: #ff91c6;
--on-btn-1: #ffffff;
--btn-1-hlt: var(--btn-1-border);
--on-btn-1-hlt: #462837;
--btn-1-border-hlt: #ff6bb2;
--btn-1-hlt: #7e1a49;
--on-btn-1-hlt: #fff;
--red-neutral-dark : #5a363e;
--red-bolder : #f52351;
--red-neutral-light : #f36a88;
--yellow-neutral-dark : #615d3a;
--yellow-bolder : #ffe600;
--yellow-neutral-light : #e6dc87;
--green-neutral-dark : #255c4a;
--green-bolder : #00ff88;
--green-neutral-light : #87f1b0;
--btn-red-brd : var(--red-neutral-dark);
--btn-red-bg : var(--red-bolder);
--btn-red-text : black;
--btn-red-hover-brd : var(--red-neutral-dark);
--btn-red-hover-bg : var(--red-neutral-light);
--btn-red-hover-text : black;
--btn-red-active-brd : var(--red-bolder);
--btn-red-active-bg : var(--red-neutral-dark);
--btn-red-active-text : white;
--btn-yellow-brd : var(--yellow-neutral-dark);
--btn-yellow-bg : var(--yellow-bolder);
--btn-yellow-text : black;
--btn-yellow-hover-brd : var(--yellow-neutral-dark);
--btn-yellow-hover-bg : var(--yellow-neutral-light);
--btn-yellow-hover-text : black;
--btn-yellow-active-brd : var(--yellow-bolder);
--btn-yellow-active-bg : var(--yellow-neutral-dark);
--btn-yellow-active-text : white;
--btn-green-brd : var(--green-neutral-dark);
--btn-green-bg : var(--green-bolder);
--btn-green-text : black;
--btn-green-hover-brd : var(--green-neutral-dark);
--btn-green-hover-bg : var(--green-neutral-light);
--btn-green-hover-text : black;
--btn-green-active-brd : var(--green-bolder);
--btn-green-active-bg : var(--green-neutral-dark);
--btn-green-active-text : white;
/*--btn-green: #3ac579;
--btn-green-bg: #416350;
--btn-green-text: #fff;
--btn-green-hover: #00ff73;
--btn-green-hover-bg: #1f7946;
--btn-green-hover-text: #fff;
--btn-green-active: #00ff73;
--btn-green-hover-bg: #1f7946;
--btn-green-text-hover: #fff;*/
/*--btn-red: #f8476d;
--btn-red-hover: #ff0037;
--btn-red-bg: #5a363e;
--btn-red-hover-bg: #662231;
--btn-red-text: #fff;
--btn-yellow: #c9ba39;
--btn-yellow-hover: #ffe600;
--btn-yellow-bg: #615d3a;
--btn-yellow-hover-bg: #615914;
--btn-yellow-text: #fff;
--btn-green: #3ac579;
--btn-green-hover: #00ff73;
--btn-green-bg: #416350;
--btn-green-hover-bg: #1f7946;
--btn-green-text: #fff;*/
}
html {
@ -114,6 +187,13 @@ a {
#scelune-logo {
min-width: 8rem;
.icon svg {
fill: var(--fg);
stroke: var(--fg);
max-width: none;
max-height: none;
}
}
#main-content {
@ -146,41 +226,117 @@ h3 {
.link-tray {
display: flex;
flex-direction: row;
}
.link-btn {
display: flex;
flex-direction: row;
flex: 0 0;
background-color: var(--btn-1);
color: var(--on-btn-1);
width: max-content;
padding: 0.2rem 0.5rem;
border: 2px solid var(--btn-1-border);
border-radius: 0.25rem;
margin: 0.5rem;
transition: all 0.2s;
text-decoration: none;
&:hover {
background-color: var(--btn-1-hlt);
color: var(--on-btn-1-hlt);
}
&:first-child {
.link-btn :first-child {
margin-left: 0;
}
&:last-child {
.link-btn :last-child {
margin-right: 0;
}
img {
flex: 0 0;
max-height: 1rem;
margin: auto;
}
.icon-btn, .icon-only-btn, .link-btn, .btn {
--outline-color: var(--btn-1-border);
--bg-color: var(--btn-1);
--text-color: var(--on-btn-1);
--outline-color-hover: var(--btn-1-border-hlt);
--bg-color-hover: var(--btn-1-hlt);
--text-color-hover: var(--on-btn-1-hlt);
--outline-color-active: var(--outline-color-hover);
--bg-color-active: var(--outline-color-hover);
--text-color-active: var(--bg-color-hover);
transition: all 0.2s;
background-color: var(--bg-color);
color: var(--text-color);
padding: 0.2rem 0.5rem;
border: 2px solid var(--outline-color);
border-radius: 0.25rem;
margin: 0.5rem;
box-shadow: inset 0 0 0.1rem #fff;
&.red {
--outline-color : var(--btn-red-brd);
--bg-color : var(--btn-red-bg);
--text-color : var(--btn-red-text);
--outline-color-hover : var(--btn-red-hover-brd);
--bg-color-hover : var(--btn-red-hover-bg);
--text-color-hover : var(--btn-red-hover-text);
--outline-color-active : var(--btn-red-active-brd);
--bg-color-active : var(--btn-red-active-bg);
--text-color-active : var(--btn-red-active-text);
}
&.yellow {
--outline-color : var(--btn-yellow-brd);
--bg-color : var(--btn-yellow-bg);
--text-color : var(--btn-yellow-text);
--outline-color-hover : var(--btn-yellow-hover-brd);
--bg-color-hover : var(--btn-yellow-hover-bg);
--text-color-hover : var(--btn-yellow-hover-text);
--outline-color-active : var(--btn-yellow-active-brd);
--bg-color-active : var(--btn-yellow-active-bg);
--text-color-active : var(--btn-yellow-active-text);
}
&.green {
--outline-color : var(--btn-green-brd);
--bg-color : var(--btn-green-bg);
--text-color : var(--btn-green-text);
--outline-color-hover : var(--btn-green-hover-brd);
--bg-color-hover : var(--btn-green-hover-bg);
--text-color-hover : var(--btn-green-hover-text);
--outline-color-active : var(--btn-green-active-brd);
--bg-color-active : var(--btn-green-active-bg);
--text-color-active : var(--btn-green-active-text);
}
&:hover {
--outline-color: var(--outline-color-hover);
--bg-color: var(--bg-color-hover);
--text-color: var(--text-color-hover);
}
&:active {
--outline-color: var(--outline-color-active);
--bg-color: var(--bg-color-active);
--text-color: var(--text-color-active);
box-shadow: inset 0 0 0.1rem #000;
}
}
.icon-btn,.icon-only-btn {
--icon-color: var(--text-color);
width: max-content;
transition: all 0.1s;
text-decoration: none;
.icon svg {
stroke: var(--icon-color);
fill: var(--icon-color);
max-height: 1rem;
max-width: 1rem;
transition: all 0.1s;
display: block;
}
}
a.icon-only-btn, .icon-only-btn.link-btn{
display: block;
margin: auto;
}
.icon-btn {
display: flex;
flex-direction: row;
flex: 0 0;
span {
flex: 1 0;
margin: auto;
@ -188,4 +344,17 @@ h3 {
margin-left: 0.5rem;
width: max-content;
}
.icon {
flex: 0 0;
margin: auto;
margin-right: 0.5rem;
}
}
.icon svg {
stroke: var(--accent-1-hlt);
fill: var(--accent-1-hlt);
max-height: 1rem;
max-width: 1rem;
}

View file

@ -22,7 +22,7 @@
</div>
{% endif %}
<input id="submit-form" type="submit" value="LOG IN"/>
<button class="btn" id="submit-form" type="submit">LOG IN</button>
</form>
<p>No account? <a href={{ url_for("web.user.create_user_page") }}>Register here</a>!</p>

View file

@ -14,7 +14,7 @@
<div id="footer">
{% block footer %}
<div class="footer-logo">
<img id="scelune-logo" src={{ url_for("static", filename="scelune-logo-narrow.svg") }} class="svg-filter-default" />
<div id="scelune-logo" class="svg-filter-default">{{icon('scelune-logo-narrow')|safe}}</div>
</div>
<div class="footer-content">
{% block footer_content%}

View file

@ -10,19 +10,15 @@
<td id="row-{{code.id}}-code">{{ code.code }}</td>
<td id="row-{{code.id}}-created">{{ reltime(code.created) }}</td>
<td id="row-{{code.id}}-expires">{{ reltime(code.expires) }}</td>
<td id="row-{{code.id}}-delete"><a href="{{url_for('web.invite.delete_sign_up', id=code.id)}}">🗑 DEL</a></td>
</tr>
{% endmacro %}
{% macro add_new() %}
<tr id="row-add-new">
<td colspan="4"><a href="{{url_for('web.invite.new_sign_up')}}">⊞ Generate a new code</a></td>
<td id="row-{{code.id}}-delete" style="padding: 0;"><a class="link-btn icon-btn red del-btn" href="{{url_for('web.invite.delete_sign_up', id=code.id)}}">{{icon('delete')|safe}} DEL</a></td>
</tr>
{% endmacro %}
{% block main_content %}
<h1>My Sign-Up Codes</h1>
{% if codes %}
<table class="list">
<tbody>
<tr>
@ -32,8 +28,6 @@
<th>Delete</th>
</tr>
{{ add_new() }}
{% for code in codes %}
{{ list_entry(code) }}
{% endfor %}
@ -41,4 +35,14 @@
</tbody>
</table>
{% endif %}
<a class="link-btn icon-btn" href="{{url_for('web.invite.new_sign_up')}}">{{icon('add')|safe}} Generate a new code</a>
<style>
.del-btn {
margin: 0.5rem auto;
}
</style>
{% endblock%}

View file

@ -5,4 +5,11 @@
{% block main_content %}
<h1>Main Content</h1>
<p>Here's the content of the page. Some text, some text, some text.</p>
<button class="btn">Normal</button>
<button class="icon-btn red">{{icon('delete')|safe}} Bad</button>
<button class="icon-btn yellow">{{icon('not-allowed')|safe}} Neutral</button>
<button class="icon-btn green">{{icon('add')|safe}} Good</button>
{% endblock %}

View file

@ -12,7 +12,11 @@
<p>{{ namespace.description }}</p>
<h2>Tasks</h2>
{{ task_list(namespace.tasks) }}
{% if namespace.perms.create_tasks_in %}
{{ task_list(namespace.tasks, create_url=url_for('web.task.new', namespace=namespace.id)) }}
{% else %}
{{ task_list(namespace.tasks) }}
{% endif %}
{{ task_list_script() }}
{% endblock %}

View file

@ -12,6 +12,6 @@
{{ render_field(form.name) }}
{{ render_field(form.description) }}
</dl>
<input id="submit-form" type="submit" value="CREATE NAMESPACE" />
<button class="icon-btn green" id="submit-form" type="submit">{{icon('add')|safe}}CREATE NAMESPACE</button>
</form>
{% endblock %}

View file

@ -16,6 +16,6 @@
{{ render_field(form.description) }}
{{ render_field(form.namespace) }}
</dl>
<p><button type="submit">Create Task</button></p>
<p><button id="submit-form" class="icon-btn green" type="submit">{{icon('add')|safe}}CREATE TASK</button></p>
</form>
{% endblock %}

View file

@ -0,0 +1,52 @@
{% macro entry(role) %}
<tr>
<td class="pad-even">
{% if role.can_promote %}
<a href="{{url_for('web.namespace.role.promote', rid=role.id, next=request.path)}}" aria-label="Increase Priority" class="icon-only-btn promote-btn">{{icon('arrow-up')|safe}}</a>
{% endif %}
</td>
<td class="pad-even">
{% if role.can_demote %}
<a href="{{url_for('web.namespace.role.demote', rid=role.id, next=request.path)}}" aria-label="Decrease Priority" class="icon-only-btn demote-btn">{{icon('arrow-down')|safe}}</a>
{% endif %}
</td>
<td class="pad-even">
{% if role.can_edit %}
<a href="{{url_for('web.namespace.role.edit', rid=role.id, next=request.path)}}" aria-label="Edit Role" class="icon-only-btn">{{icon('edit')|safe}}</a>
{% endif %}
</td>
<td class="pad-even">
{% if role.can_delete %}
<a href="{{url_for('web.namespace.role.delete', rid=role.id, next=request.path)}}" aria-label="Delete Role" class="icon-only-btn red">{{icon('delete')|safe}}</a>
{% endif %}
</td>
<td>{{role.name}}</td>
</tr>
{% endmacro %}
{% macro role_list( roles, create_url=None )%}
{% if create_url %}
<a class="link-btn icon-btn" href="{{create_url}}">{{icon('add')|safe}} Create a new role</a>
{% endif %}
<table class="list" id="role-table">
<colgroup>
<col span="1" style="width:0;"/>
<col span="1" style="width:0;"/>
<col span="1" style="width:0;"/>
<col span="1" style="width:0;"/>
<col span="1"/>
</colgroup>
<tbody>
<tr>
<th colspan="2">Rearrange</th>
<th>Edit</th>
<th>Delete</th>
<th>Role</th>
</tr>
{% for role in roles %}
{{entry(role)}}
{% endfor %}
</tbody>
</table>
{% endmacro %}

View file

@ -0,0 +1,13 @@
{% macro three_way_select(field) %}
<dt>{{ field.label }}</dt>
<dd class="three-way-select">
{% for choice in field.choices %}
{% if field.default == choice[0] %}
<label><input type="radio" name="{{field.name}}" value="{{choice[0]}}" checked>{{choice[1]}}</label>
{% else %}
<label><input type="radio" name="{{field.name}}" value="{{choice[0]}}">{{choice[1]}}</label>
{% endif %}
{% endfor %}
</dd>
{% endmacro %}

View file

@ -0,0 +1,16 @@
{% extends "main.html" %}
{% from "role/_rolelist.html" import role_list with context %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
{% endblock %}
{% block title %}Roles for {{namespace_name}}{% endblock %}
{% block main_content %}
<h1>Roles for {{namespace_name}}</h1>
{{ role_list(roles, create_url) }}
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends "main.html" %}
{% from "_formhelpers.html" import render_field %}
{% from "role/_three_way_select.html" import three_way_select, three_way_select_css %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="forms.css") }} />
{% endblock %}
{% block title %}New Role{% endblock %}
{% block main_content %}
<form class="default-form" id="create-role-form" method="POST">
<h1>New Role</h1>
<dl>
{{ render_field(form.name) }}
</dl>
<h2>Permissions</h2>
<p>You can allow or deny this role any permission on the list below.</p>
<p>Roles higher up on the role list have higher priority.</p>
<p>Permissions set to "deny" can override "allow" permissions from roles with lower priority.</p>
<dl>
{% for field in form.perm_fields %}
{{ three_way_select(form.get_field(field)) }}
{% endfor %}
</dl>
<p><button id="submit-form" class="icon-btn green" type="submit">{{icon('add')|safe}}{{action}} ROLE</button></p>
</form>
{% endblock %}

View file

@ -15,11 +15,11 @@
<td class="checkbox" id="{{ tlist_cid('check', task.id, list_id) }}">
{% if task.complete %}
<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') }}" />
{{icon('check-box-checked')|safe}}
</a>
{% else %}
<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') }}" />
{{icon('check-box-unchecked')|safe}}
</a>
{% endif %}
</td>
@ -40,8 +40,8 @@
<h1>Task Details: {{ task.name }}</h1>
</div>
<div class="link-tray">
<a class="link-btn" href="{{url_for('web.task.edit', id=task.id, next=request.path)}}"><img src="{{url_for('static', filename='edit.svg')}}"/><span>Edit Task</span></a>
<a class="link-btn" href="{{url_for('web.task.delete', id=task.id, next=request.path)}}"><img src="{{url_for('static', filename='delete.svg')}}"/><span>Delete Task</span></a>
<a class="link-btn" href="{{url_for('web.task.edit', id=task.id, next=request.path)}}">{{icon('edit')|safe}}<span>Edit Task</span></a>
<a class="link-btn red" href="{{url_for('web.task.delete', id=task.id, next=request.path)}}">{{icon('delete')|safe}}<span>Delete Task</span></a>
</div>
<p class="small-details">
Task ID: {{ task.id }}

View file

@ -1,6 +1,13 @@
{% from "task/_shorttask.html" import inline_task, inline_task_header with context %}
{% macro task_list(tasks, list_id=0) %}
{% macro task_list(tasks, list_id=0, create_url=None) %}
<noscript>
<style>
.detail-view {
display: table-row !important;
}
</style>
</noscript>
<table class="list">
<colgroup>
<col span="1" style="width: 0;"/>
@ -15,16 +22,25 @@
{% endfor %}
</tbody>
</table>
{% if create_url %}
<a href="{{create_url}}" class="link-btn icon-btn">{{icon('add')|safe}} Create a New Task</a>
{% endif %}
{% endmacro %}
{% macro task_list_script() %}
<style>
.list .checkbox {
padding: 0.5rem 1rem;
}
.list .checkbox img {
max-width: 1.5rem;
.icon {
svg {
margin: auto;
display: block;
max-width: 1.5rem;
max-height: 1.5rem;
}
}
}
.list .task-due {

View file

@ -11,6 +11,6 @@
<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>
<button class="icon-btn red" type="submit" id="submit-form">{{icon('delete')|safe}}Confirm Deletion</button>
</form>
{% endblock %}

View file

@ -15,6 +15,6 @@
{{ render_field(form.due) }}
{{ render_field(form.description) }}
</dl>
<button type="submit" id="submit-form">Edit Task</button>
<button class="icon-btn green" type="submit" id="submit-form">{{icon('add')|safe}}Edit Task</button>
</form>
{% endblock %}

View file

@ -10,7 +10,7 @@
{% block main_content %}
<h1>My Tasks</h1>
{{ task_list(tasks) }}
{{ task_list(tasks, create_url=url_for('web.task.new')) }}
{{ task_list_script() }}
{% endblock %}

View file

@ -63,7 +63,7 @@
{{ render_field(form.sign_up_code) }}
</dl>
{% endif %}
<input id="submit-form" type="submit" value="REGISTER USER"/>
<button class="icon-btn green" id="submit-form" type="submit">{{icon('add')|safe}}CREATE USER</button>
</form>
<style>

View file

@ -0,0 +1,132 @@
import re
from flask import Blueprint
import json
import logging
import os
from scour.scour import scourString # pyright:ignore[reportMissingTypeStubs, reportUnknownVariableType]
from typing import Any, final
from taskflower.config import config
from taskflower.types.either import Either, Left, Right, gather_successes
from taskflower.types.option import Nothing, Option, Some
log = logging.getLogger(__name__)
svg_bp = Blueprint(
'svg_tools',
__name__,
cli_group='runtool'
)
def get_icon(name: str) -> Either[Exception, str]:
''' Get the SVG data for a given icon name.
'''
try:
raw_pth = os.path.dirname(os.path.realpath(__file__))
if config.debug_always_regen_icon_file or not os.path.isfile(f'{config.data_path}/icons.json'):
log.info('Icon file not found (or always-regenerate is on). Regenerating it...')
build_svg_data(
f'{raw_pth}/raw',
f'{config.data_path}/icons.json'
)
if not os.path.isfile(f'{config.data_path}/icons.json'):
return Left(FileNotFoundError(f'Icons file {config.data_path}/icons.json wasn\'t regenerated properly!'))
with open(f'{config.data_path}/icons.json') as fin:
icon_data: dict[str, str]|Any = json.load(fin) # pyright:ignore[reportExplicitAny, reportAny]
if not isinstance(icon_data, dict):
return Left(TypeError('Icon data file contains unexpected data!'))
if name in icon_data.keys():
return Right(str(icon_data[name])) # pyright:ignore[reportUnknownArgumentType]
return Left(KeyError(f'Unknown icon {name}'))
except Exception as e:
return Left(e)
@final
class _SVGOpts:
strip_xml_prolog=True
strip_comments=True
remove_descriptive_elements=True
enable_viewboxing=True
newlines=False
strip_xml_space_attribute=True
strip_ids=True
shorten_ids=True
def _get_svg(fname: str) -> Either[Exception, str]:
try:
with open(fname, 'r') as fin:
return Right[Exception, str](
scourString( # pyright:ignore[reportUnknownArgumentType]
''.join(fin.readlines()),
_SVGOpts
)
)
except Exception as e:
return Left(e)
@svg_bp.cli.command('build-svg-data')
def build_svg_data_wrapper(
dir_base: str = 'taskflower/tools/icons/raw',
output: str = f'{config.data_path}/icons.json'
):
return build_svg_data(dir_base, output)
def build_svg_data(
dir_base: str = 'taskflower/tools/icons/raw',
output: str = f'{config.data_path}/icons.json'
):
''' Bundles the SVG files from `taskflower/tools/icons/raw` into a JSON
file of html-compatible strings that ``get_icon()`` can pull from.
'''
log.info(f'Scanning `{dir_base}` for SVG files...')
fnames = [
f'{dir_base}/{fname}'
for fname in os.listdir(dir_base)
]
log.info(f'Found {len(fnames)} files.')
titled_fnames = gather_successes([
Option[re.Match[str]].encapsulate(
re.match('(?:.*\\/)?([A-z0-9_-]+)\\.svg', fname)
).map(
lambda match: match.group(1)
).flat_map(
lambda val: (
Some(val)
) if isinstance(val, str) else Nothing()
).map(
lambda val: (val, fname)
).and_then(
lambda val: Right[None, tuple[str, str]](val),
lambda: Left[None, tuple[str, str]](None)
)
for fname in fnames
])
log.info(f'{len(titled_fnames)} files have valid icon names.')
results = gather_successes([
_get_svg(fname).map(
lambda val: (title, val)
)
for title, fname in titled_fnames
])
log.info(f'Successfully parsed {len(results)} files.')
try:
with open(output, 'w') as fout:
json.dump(
dict(results),
fout
)
except Exception as e:
log.error(f'Exception while writing output data to file: {e}')

View file

@ -0,0 +1,73 @@
<?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="svg4219"
sodipodi:docname="add.svg"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
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="namedview4221"
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:bbox-paths="true"
inkscape:snap-page="true"
inkscape:zoom="1.5189194"
inkscape:cx="69.128092"
inkscape:cy="79.661896"
inkscape:window-width="1680"
inkscape:window-height="988"
inkscape:window-x="3520"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g10289"
showguides="false"
units="mm"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:snap-bbox-midpoints="true"
inkscape:snap-object-midpoints="true"
inkscape:snap-global="false" />
<defs
id="defs4216" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g10289"
style="stroke:none;stroke-width:0;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none">
<g
id="g9960"
transform="matrix(0.27258368,0.27258368,-0.27258368,0.27258368,15.999999,7.277322)"
style="stroke-width:0;stroke:none;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none">
<path
style="color:#000000;stroke:none;stroke-opacity:1;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none"
d="m 4.34375,-0.45898438 a 4.8027873,4.8027873 0 0 0 -3.39648437,1.40625001 4.8027873,4.8027873 0 0 0 0,6.79296877 L 24.259766,31.052734 a 4.8027873,4.8027873 0 0 0 6.792968,0 4.8027873,4.8027873 0 0 0 0,-6.792968 L 7.7402344,0.94726563 A 4.8027873,4.8027873 0 0 0 4.34375,-0.45898438 Z"
id="path4313" />
<path
style="color:#000000;stroke:none;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 27.65625,-0.45898438 A 4.8027873,4.8027873 0 0 0 24.259766,0.94726563 L 0.94726563,24.259766 a 4.8027873,4.8027873 0 0 0 0,6.792968 4.8027873,4.8027873 0 0 0 6.79296877,0 L 31.052734,7.7402344 a 4.8027873,4.8027873 0 0 0 0,-6.79296877 4.8027873,4.8027873 0 0 0 -3.396484,-1.40625001 z"
id="path4428" />
</g>
<path
style="color:#000000;stroke:none;stroke-width:0;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="M 16,1.9027052 C 8.2361994,1.9027052 1.9027052,8.2361994 1.9027052,16 1.9027052,23.763801 8.2361994,30.097295 16,30.097295 23.763801,30.097295 30.097295,23.763801 30.097295,16 30.097295,8.2361994 23.763801,1.9027052 16,1.9027052 Z m 0,3.7028678 c 5.762624,0 10.394427,4.631803 10.394427,10.394427 0,5.762624 -4.631803,10.394427 -10.394427,10.394427 C 10.237376,26.394427 5.605573,21.762624 5.605573,16 5.605573,10.237376 10.237376,5.605573 16,5.605573 Z"
id="path10039" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

@ -0,0 +1,64 @@
<?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="svg5088"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="arrow-down.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="namedview5090"
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:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:snap-bbox-midpoints="true"
inkscape:object-paths="true"
inkscape:snap-page="true"
inkscape:snap-intersection-paths="true"
inkscape:zoom="3.5273148"
inkscape:cx="23.530647"
inkscape:cy="56.416852"
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="defs5085" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g8868"
transform="rotate(180,15.999999,16.09765)">
<path
style="fill:none;stroke-width:3.77124;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 15.999999,29.130346 V 3.6391323"
id="path5412"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 25.899494,13.538627 15.999999,3.6391323 6.1005048,13.538627"
id="path7210"
sodipodi:nodetypes="ccc" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,64 @@
<?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="svg5088"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="arrow-up.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="namedview5090"
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:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:snap-bbox-midpoints="true"
inkscape:object-paths="true"
inkscape:snap-page="true"
inkscape:snap-intersection-paths="true"
inkscape:zoom="3.5273148"
inkscape:cx="23.530647"
inkscape:cy="56.416852"
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="defs5085" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g8868"
transform="translate(-4e-7,-0.45988206)">
<path
style="fill:none;stroke-width:3.77124;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 15.999999,29.130346 V 3.6391323"
id="path5412"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 25.899494,13.538627 15.999999,3.6391323 6.1005048,13.538627"
id="path7210"
sodipodi:nodetypes="ccc" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -46,7 +46,7 @@
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:none;stroke:#ffffff;stroke-width:1.9973;stroke-opacity:1"
style="fill:none;stroke-width:1.9973;stroke-opacity:1"
id="rect846"
width="26.458271"
height="26.458271"
@ -54,7 +54,7 @@
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"
style="fill:none;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" />

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before After
Before After

View file

@ -46,7 +46,7 @@
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:none;stroke:#ffffff;stroke-width:1.9973;stroke-opacity:1"
style="fill:none;stroke-width:1.9973;stroke-opacity:1"
id="rect846"
width="26.458271"
height="26.458271"

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

View file

@ -42,11 +42,11 @@
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:none;stroke:#ffffff;stroke-width:4;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
style="fill:none;stroke:default;stroke-width:4;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 4.3442615,4.3442615 27.655737,27.655737"
id="path4313" />
<path
style="fill:none;stroke:#ffffff;stroke-width:4;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
style="fill:none;stroke:default;stroke-width:4;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 27.655737,4.3442615 4.3442615,27.655737"
id="path4428" />
</g>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before After
Before After

View file

@ -54,21 +54,21 @@
style="stroke-width:3.11833246;stroke-miterlimit:4;stroke-dasharray:none">
<path
id="rect1114"
style="fill:none;stroke:#ffffff;stroke-width:3.11833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none"
style="fill:none;stroke-width:3.11833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none"
d="M 30.150425,6.2476261 25.752374,1.8495753 9.7345348,17.867413 6.6776561,25.788471 14.132586,22.265464 Z"
sodipodi:nodetypes="cccccc" />
<path
style="fill:none;stroke:#ffffff;stroke-width:3.11833;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
style="fill:none;stroke-width:3.11833;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 20.912356,6.5215018 25.17026,10.953753"
id="path1229" />
</g>
<path
style="fill:none;stroke:#ffffff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
style="fill:none;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 19.207329,2.6958367 -16.5114923,0 V 29.304162 H 29.304162 l 10e-7,-17.319006"
id="path1933"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
style="fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 9.8263677,20.024836 8.4242261,23.592142 11.986204,21.939401 Z"
id="path3583"
sodipodi:nodetypes="cccc" />

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100pc"
height="100pc"
viewBox="0 0 423.33332 423.33335"
version="1.1"
id="svg9262"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="none.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="namedview9264"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
units="pc"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:snap-bbox-midpoints="true"
inkscape:object-paths="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-object-midpoints="true"
inkscape:snap-page="true"
inkscape:zoom="0.3750491"
inkscape:cx="695.90888"
inkscape:cy="759.9005"
inkscape:window-width="1680"
inkscape:window-height="988"
inkscape:window-x="3520"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs9259" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="path9345"
d="M 800 80.134766 A 719.8654 719.8654 0 0 0 80.134766 800 A 719.8654 719.8654 0 0 0 800 1519.8652 A 719.8654 719.8654 0 0 0 1519.8652 800 A 719.8654 719.8654 0 0 0 800 80.134766 z M 355.74023 613.92383 L 1244.2598 613.92383 C 1306.6566 613.92383 1356.8887 664.15789 1356.8887 726.55469 L 1356.8887 873.44531 C 1356.8887 935.84211 1306.6566 986.07617 1244.2598 986.07617 L 355.74023 986.07617 C 293.34344 986.07617 243.10937 935.84211 243.10938 873.44531 L 243.10938 726.55469 C 243.10938 664.15789 293.34344 613.92383 355.74023 613.92383 z "
transform="scale(0.26458333)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -1,127 +1,126 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1300 450" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g transform="matrix(1.61079e-16,-2.63062,2.63062,1.61079e-16,-766.099,2116.12)">
<g transform="matrix(0.944084,0,0,0.944084,95.7903,252.28)">
<circle cx="600" cy="420" r="10" style="fill:none;stroke:white;stroke-width:4.03px;"/>
<circle cx="600" cy="420" r="10" style="fill:none;stroke-width:4.03px;"/>
</g>
<g transform="matrix(0.944084,0,0,0.944084,133.554,290.044)">
<circle cx="600" cy="420" r="10" style="fill:none;stroke:white;stroke-width:4.03px;"/>
<circle cx="600" cy="420" r="10" style="fill:none;stroke-width:4.03px;"/>
</g>
<g transform="matrix(0.944084,0,0,0.944084,171.317,252.28)">
<circle cx="600" cy="420" r="10" style="fill:none;stroke:white;stroke-width:4.03px;"/>
<circle cx="600" cy="420" r="10" style="fill:none;stroke-width:4.03px;"/>
</g>
<g transform="matrix(0.944084,0,0,0.944084,209.08,290.044)">
<circle cx="600" cy="420" r="10" style="fill:none;stroke:white;stroke-width:4.03px;"/>
<circle cx="600" cy="420" r="10" style="fill:none;stroke-width:4.03px;"/>
</g>
<g transform="matrix(0.944084,0,0,0.944084,95.7903,252.28)">
<path d="M600,410L600,380L660,320L660,261.333" style="fill:none;stroke:white;stroke-width:4.03px;"/>
<path d="M600,410L600,380L660,320L660,261.333" style="fill:none;stroke-width:4.03px;"/>
</g>
<g transform="matrix(0.944084,0,0,0.944084,95.7903,252.28)">
<path d="M660,320L680.294,340.294L680,410" style="fill:none;stroke:white;stroke-width:4.03px;"/>
<path d="M660,320L680.294,340.294L680,410" style="fill:none;stroke-width:4.03px;"/>
</g>
<g transform="matrix(0.944084,0,0,0.944084,95.7903,252.28)">
<path d="M640,450L640,340" style="fill:none;stroke:white;stroke-width:4.03px;"/>
<path d="M640,450L640,340" style="fill:none;stroke-width:4.03px;"/>
</g>
<g transform="matrix(0.944084,0,0,0.944084,95.7903,252.28)">
<path d="M680.294,340L720.065,379.77L720,450" style="fill:none;stroke:white;stroke-width:4.03px;"/>
<path d="M680.294,340L720.065,379.77L720,450" style="fill:none;stroke-width:4.03px;"/>
</g>
<g transform="matrix(0.786737,0,0,0.944084,199.64,252.28)">
<path d="M660,261.333C660,261.333 653.123,230 630,230L600,230C600,230 570,229.983 570,200L570,140C570,140 565.072,80 624,60" style="fill:none;stroke:white;stroke-width:4.37px;"/>
<path d="M660,261.333C660,261.333 653.123,230 630,230L600,230C600,230 570,229.983 570,200L570,140C570,140 565.072,80 624,60" style="fill:none;stroke-width:4.37px;"/>
</g>
<g transform="matrix(-0.786737,0,0,0.944084,1238.13,251.022)">
<path d="M660,261.333C660,261.333 653.123,230 630,230L600,230C600,230 570,229.983 570,200L570,140C570,140 565.072,80 624,60" style="fill:none;stroke:white;stroke-width:4.37px;"/>
<path d="M660,261.333C660,261.333 653.123,230 630,230L600,230C600,230 570,229.983 570,200L570,140C570,140 565.072,80 624,60" style="fill:none;stroke-width:4.37px;"/>
</g>
<g transform="matrix(0.707975,0,0,0.944084,251.623,252.28)">
<path d="M630,230C630,230 600,230.661 600,200C600,169.339 599.993,180 599.993,180" style="fill:none;stroke:white;stroke-width:4.56px;"/>
<path d="M630,230C630,230 600,230.661 600,200C600,169.339 599.993,180 599.993,180" style="fill:none;stroke-width:4.56px;"/>
</g>
<g transform="matrix(-0.707975,0,0,0.944084,1186.15,251.022)">
<path d="M630,230C630,230 600,230.661 600,200C600,169.339 599.993,180 599.993,180" style="fill:none;stroke:white;stroke-width:4.56px;"/>
<path d="M630,230C630,230 600,230.661 600,200C600,169.339 599.993,180 599.993,180" style="fill:none;stroke-width:4.56px;"/>
</g>
<g transform="matrix(0.944084,0,0,0.944084,95.7903,252.28)">
<circle cx="659.992" cy="140" r="28.889" style="fill:white;"/>
<circle cx="659.992" cy="140" r="28.889" style=""/>
<g transform="matrix(0.5,0,0,0.488889,430,31.3679)">
<circle cx="460" cy="140" r="10" style="fill:white;"/>
<circle cx="460" cy="140" r="10" style=""/>
</g>
<g transform="matrix(0.5,0,0,0.488889,458.294,43.3491)">
<circle cx="460" cy="140" r="10" style="fill:white;"/>
<circle cx="460" cy="140" r="10" style=""/>
</g>
<g transform="matrix(0.5,0,0,0.488889,470.294,71.3491)">
<circle cx="460" cy="140" r="10" style="fill:white;"/>
<circle cx="460" cy="140" r="10" style=""/>
</g>
<g transform="matrix(0.5,0,0,0.488889,458.294,100.349)">
<circle cx="460" cy="140" r="10" style="fill:white;"/>
<circle cx="460" cy="140" r="10" style=""/>
</g>
<g transform="matrix(0.5,0,0,0.488889,430.294,111.349)">
<circle cx="460" cy="140" r="10" style="fill:white;"/>
<circle cx="460" cy="140" r="10" style=""/>
</g>
<g transform="matrix(0.5,0,0,0.488889,401.294,100.349)">
<circle cx="460" cy="140" r="10" style="fill:white;"/>
<circle cx="460" cy="140" r="10" style=""/>
</g>
<g transform="matrix(0.5,0,0,0.488889,390.294,71.3491)">
<circle cx="460" cy="140" r="10" style="fill:white;"/>
<circle cx="460" cy="140" r="10" style=""/>
</g>
<g transform="matrix(0.5,0,0,0.488889,401.294,43.3491)">
<circle cx="460" cy="140" r="10" style="fill:white;"/>
<circle cx="460" cy="140" r="10" style=""/>
</g>
</g>
</g>
<g transform="matrix(0.803876,0,0,0.803876,970.174,-237.986)">
<g transform="matrix(1,0,0,0.866667,68,306)">
<path d="M100,300C200.663,300 200,100 200,100C200,100 199.709,300 300,300" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M100,300C200.663,300 200,100 200,100C200,100 199.709,300 300,300" style="fill:none;stroke-width:13.29px;"/>
</g>
<g transform="matrix(0.36,0,0,0.312,196,472.4)">
<path d="M100,300C200.663,300 200,100 200,100C200,100 199.709,300 300,300" style="fill:none;stroke:white;stroke-width:36.93px;"/>
<path d="M100,300C200.663,300 200,100 200,100C200,100 199.709,300 300,300" style="fill:none;stroke-width:36.93px;"/>
</g>
<g transform="matrix(1,0,0,0.866667,68,306)">
<path d="M212,180L280,180L245,180C245,180 240.628,220 280,220" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M212,180L280,180L245,180C245,180 240.628,220 280,220" style="fill:none;stroke-width:13.29px;"/>
</g>
<g transform="matrix(1,0,0,0.866667,68,306)">
<path d="M229,160L260,140" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M229,160L260,140" style="fill:none;stroke-width:13.29px;"/>
</g>
<g transform="matrix(0.666667,0,0,0.577778,134.667,259.778)">
<path d="M200,80C160.795,80 140,102.825 140,125C140,147.175 158.179,180 200,180C241.821,180 260,162.31 260,140C260,106.69 200,80.692 200,160" style="fill:none;stroke:white;stroke-width:19.94px;"/>
<path d="M200,80C160.795,80 140,102.825 140,125C140,147.175 158.179,180 200,180C241.821,180 260,162.31 260,140C260,106.69 200,80.692 200,160" style="fill:none;stroke-width:19.94px;"/>
</g>
</g>
<g transform="matrix(0.803846,0,0,0.803846,809.415,-12.9769)">
<g transform="matrix(1,0,0,0.866667,-72.0075,219.333)">
<path d="M500,100C500.157,173.385 535,200 535,250C535,300 499.816,341.427 500,400" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M500,100C500.157,173.385 535,200 535,250C535,300 499.816,341.427 500,400" style="fill:none;stroke-width:13.29px;"/>
</g>
<g transform="matrix(1,0,0,0.866667,-72.0075,219.333)">
<path d="M560,100L560,400" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M560,100L560,400" style="fill:none;stroke-width:13.29px;"/>
</g>
<g transform="matrix(1,0,0,0.866667,-72.0075,219.333)">
<path d="M440,120C440.547,227.667 465.807,240 530,240" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M440,120C440.547,227.667 465.807,240 530,240" style="fill:none;stroke-width:13.29px;"/>
</g>
<g transform="matrix(1,0,0,0.988889,-62.0075,197.667)">
<circle cx="460" cy="140" r="10" style="fill:white;"/>
<circle cx="460" cy="140" r="10" style=""/>
</g>
<g transform="matrix(1,0,0,0.988889,-62.0075,237.444)">
<circle cx="460" cy="140" r="10" style="fill:white;"/>
<circle cx="460" cy="140" r="10" style=""/>
</g>
<g transform="matrix(1,0,0,0.988889,-32.0082,261.667)">
<circle cx="460" cy="140" r="10" style="fill:white;"/>
<circle cx="460" cy="140" r="10" style=""/>
</g>
<g transform="matrix(1,0,0,0.866667,-72.0075,219.333)">
<path d="M560,240L620,240" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M560,240L620,240" style="fill:none;stroke-width:13.29px;"/>
</g>
<g transform="matrix(1,0,0,0.866667,-72.0075,219.333)">
<path d="M600,240L600,220" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M600,240L600,220" style="fill:none;stroke-width:13.29px;"/>
</g>
<g transform="matrix(1,0,0,0.866667,-72.0075,219.333)">
<path d="M580,280L580,380" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M580,280L580,380" style="fill:none;stroke-width:13.29px;"/>
</g>
<g transform="matrix(1,0,0,0.866667,-72.0075,219.333)">
<path d="M600,280L600,380" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M600,280L600,380" style="fill:none;stroke-width:13.29px;"/>
</g>
<g transform="matrix(1,0,0,0.866667,-72.0075,219.333)">
<path d="M580,310L640,310" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M580,310L640,310" style="fill:none;stroke-width:13.29px;"/>
</g>
<g transform="matrix(1,0,0,0.866667,-72.0075,219.333)">
<path d="M600,350L640,350" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M600,350L640,350" style="fill:none;stroke-width:13.29px;"/>
</g>
<g transform="matrix(1,0,0,0.866667,-92.0075,219.333)">
<path d="M620,200C640.628,200.001 660.449,216.662 660,240" style="fill:none;stroke:white;stroke-width:13.29px;"/>
<path d="M620,200C640.628,200.001 660.449,216.662 660,240" style="fill:none;stroke-width:13.29px;"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before After
Before After

View file

@ -3,6 +3,9 @@ from types import NoneType
from typing import Any, Callable, TypeAlias
from flask import Response
from werkzeug.local import LocalProxy
from taskflower.db.model.user import User
FlaskViewReturnType = (
Response
@ -28,3 +31,6 @@ def ann[T](x: T|None) -> T:
return x
raise AssertionError('``ann()`` called on None!')
def assert_usr(current_user: LocalProxy[Any|None]) -> User: # pyright:ignore[reportExplicitAny]
return current_user # pyright:ignore[reportReturnType]

View file

@ -38,6 +38,31 @@ class Either[L, R](ABC):
def flat_map[X](self, f: Callable[[R], 'Either[L, X]']) -> 'Either[L, X]':
raise NotImplementedError
@abstractmethod
def assert_left(self) -> 'Left[L, R]':
''' Since python has no way of defining a class as closed (i.e. there is
no way of saying that ``Either`` must be either ``Right`` or
``Left``), then ``not isinstance(x, Right)`` does not inherently
prove that ``isinstance(x, Left)``. This leads to annoyances with
type-checking. This function simply asserts that the ``Either``
value must be a ``Left()``, thus satisfying the type-checker.
If ``assert_left()`` is called on a ``Right()`` instance, then it
will raise (NOT return) an ``AssertionError``, so be careful that,
when you use this, the result is actually guaranteed to be
``Left()``.
If there is a chance it could be ``Right()``, you should be using
``and_then()``, ``flat_map()``, ``lmap()``, etc. instead.
'''
raise NotImplementedError
@abstractmethod
def assert_right(self) -> 'Right[L, R]':
''' As ``assert_left()``, except it asserts that this is ``Right()``.
'''
raise NotImplementedError
@abstractmethod
def and_then[X](
self,
@ -100,6 +125,14 @@ class Left[L, R](Either[L, R]):
def flat_map[X](self, f: Callable[[R], 'Either[L, X]']) -> 'Either[L, X]':
return Left[L, X](self.val)
@override
def assert_left(self) -> 'Left[L, R]':
return self
@override
def assert_right(self) -> 'Right[L, R]':
raise AssertionError('`Left()` asserted as `Right()`!')
@override
def and_then[X](
self,
@ -165,6 +198,14 @@ class Right[L, R](Either[L, R]):
def flat_map[X](self, f: Callable[[R], 'Either[L, X]']) -> 'Either[L, X]':
return f(self.val)
@override
def assert_left(self) -> 'Left[L, R]':
raise AssertionError('`Right()` asserted as `Left()`!')
@override
def assert_right(self) -> 'Right[L, R]':
return self
@override
def and_then[X](
self,

View file

@ -16,6 +16,7 @@ from taskflower.sanitize.task import TaskForUser
from taskflower.types.either import Either, Left, Right, gather_successes
from taskflower.types.option import Option
from taskflower.web.errors import ResponseErrorNotFound, response_from_exception
from taskflower.web.namespace.roles import web_namespace_roles
web_namespace = Blueprint(
'namespace',
@ -24,6 +25,8 @@ web_namespace = Blueprint(
url_prefix='/namespace'
)
web_namespace.register_blueprint(web_namespace_roles)
@web_namespace.app_context_processor
def namespace_processor():
# Inject some namespace helper functions into the jinja template

View file

@ -0,0 +1,341 @@
from enum import Enum, auto
from flask import Blueprint, redirect, render_template, request, url_for
from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs]
from taskflower.auth.permission import NamespacePermissionType
from taskflower.auth.permission.checks import assert_user_can_edit_role, assert_user_perms_on_namespace
from taskflower.auth.permission.lookups import get_namespace_role_above, get_namespace_role_below
from taskflower.auth.violations import check_for_auth_err_and_report
from taskflower.db import db, db_fetch_by_id, do_commit, do_delete, insert_into_db
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.role import NamespaceRole
from taskflower.form.namespace import get_namespace_role_form_for
from taskflower.sanitize.namespace import NamespaceRoleForUser
from taskflower.types import assert_usr
from taskflower.types.either import Either, Left, Right
from taskflower.web.errors import ResponseErrorNotFound, response_from_exception
from taskflower.web.utils.request import get_next
web_namespace_roles = Blueprint(
'role',
__name__,
url_prefix=''
)
@web_namespace_roles.route('/<int:id>/role')
@login_required
def all(id: int):
cur_usr = assert_usr(current_user)
def _fetch_roles(
ns: Namespace
) -> Either[Exception, list[NamespaceRole]]:
try:
return Right(
db.session.query(
NamespaceRole
).filter(
NamespaceRole.namespace == ns.id
).order_by(
NamespaceRole.priority.asc()
).all()
)
except Exception as e:
return Left(e)
return db_fetch_by_id(
Namespace,
id,
db
).flat_map(
lambda ns: assert_user_perms_on_namespace(
cur_usr,
ns,
NamespacePermissionType.EDIT_ROLES,
'View role list'
).lside_effect(
check_for_auth_err_and_report
).flat_map(
_fetch_roles
).map(
lambda roles: [
NamespaceRoleForUser.from_role(
r,
cur_usr,
dex==0,
dex==len(roles)-1
)
for dex, r in enumerate(roles)
]
).map(
lambda roles: (ns.name, roles)
)
).and_then(
lambda data: render_template(
'role/namespace/list.html',
namespace_name=data[0],
roles=data[1],
create_url=url_for('web.namespace.role.new', id=id)
),
lambda exc: response_from_exception(exc)
)
@web_namespace_roles.route('/<int:id>/role/new', methods=['GET', 'POST'])
def new(id: int):
cur_usr = assert_usr(current_user)
next = get_next(
request,
url_for('web.namespace.role.all', id=id)
).and_then(
lambda val: val,
lambda exc: url_for('web.namespace.role.all', id=id)
)
res = db_fetch_by_id(
Namespace,
id,
db
).flat_map(
lambda ns: assert_user_perms_on_namespace(
cur_usr,
ns,
NamespacePermissionType.EDIT_ROLES,
'Create role'
)
).lside_effect(
check_for_auth_err_and_report
).flat_map(
lambda ns: get_namespace_role_form_for(
cur_usr,
ns
).map(
lambda form: (ns, form)
)
)
if isinstance(res, Right):
ns, form = res.val
form_data = form(request.form)
if request.method == 'POST' and form_data.validate():
return form_data.create_object(
cur_usr,
ns
).lside_effect(
check_for_auth_err_and_report
).flat_map(
lambda role: insert_into_db(role, db)
).and_then(
lambda role: redirect(next),
lambda exc: response_from_exception(exc)
)
else:
return render_template(
'role/namespace/new_or_edit.html',
form=form_data,
action='CREATE'
)
else:
return response_from_exception(
res.assert_left().val
)
class PromoteOrDemote(Enum):
PROMOTE = auto()
DEMOTE = auto()
def swap_roles(
role_a: NamespaceRole,
role_b: NamespaceRole
) -> Either[Exception, tuple[NamespaceRole, NamespaceRole]]:
try:
pri = role_a.priority
role_a.priority = role_b.priority
role_b.priority = pri
return Right((role_a, role_b))
except Exception as e:
return Left(e)
def _get_role_to_swap_with(
cur_role: NamespaceRole,
action: PromoteOrDemote
) -> Either[Exception, NamespaceRole]:
match action:
case PromoteOrDemote.PROMOTE:
return get_namespace_role_above(
cur_role
).and_then(
lambda val: Right[Exception, NamespaceRole](val),
lambda: Left[Exception, NamespaceRole](ResponseErrorNotFound(
reason='Can\'t promote the topmost role!',
user_reason='Can\'t promote the topmost role!'
))
)
case PromoteOrDemote.DEMOTE:
return get_namespace_role_below(
cur_role
).and_then(
lambda val: Right[Exception, NamespaceRole](val),
lambda: Left[Exception, NamespaceRole](ResponseErrorNotFound(
reason='Can\'t promote the topmost role!',
user_reason='Can\'t promote the topmost role!'
))
)
def promote_demote_role(
rid: int,
action: PromoteOrDemote
):
cur_usr = assert_usr(current_user)
next = get_next(
request
).and_then(
lambda val: val,
lambda exc: url_for('index')
)
return db_fetch_by_id(
NamespaceRole,
rid,
db
).flat_map(
lambda row_a: _get_role_to_swap_with(
row_a,
action
).map(
lambda row_b: (row_a, row_b)
)
).flat_map(
lambda roles: assert_user_can_edit_role(
cur_usr,
roles[0],
'Change Priority'
).map(lambda _: roles)
).flat_map(
lambda roles: assert_user_can_edit_role(
cur_usr,
roles[1],
'Change Priority'
).map(lambda _: roles)
).lside_effect(
check_for_auth_err_and_report
).flat_map(
lambda roles: swap_roles(roles[0], roles[1])
).flat_map(
lambda _: do_commit(db)
).and_then(
lambda _: redirect(next),
lambda exc: response_from_exception(exc)
)
@web_namespace_roles.route('/role/<int:rid>/promote')
def promote(rid: int):
return promote_demote_role(rid, PromoteOrDemote.PROMOTE)
@web_namespace_roles.route('/role/<int:rid>/demote')
def demote(rid: int):
return promote_demote_role(rid, PromoteOrDemote.DEMOTE)
@web_namespace_roles.route('/role/<int:rid>/edit', methods=['GET', 'POST'])
def edit(rid: int):
cur_usr = assert_usr(current_user)
next = get_next(
request,
url_for('web.namespace.role.all', id=rid)
).and_then(
lambda val: val,
lambda exc: url_for('web.namespace.role.all', id=rid)
)
res = db_fetch_by_id(
NamespaceRole,
rid,
db
).flat_map(
lambda role: db_fetch_by_id(
Namespace,
role.namespace,
db
).flat_map(
lambda ns: assert_user_perms_on_namespace(
cur_usr,
ns,
NamespacePermissionType.EDIT_ROLES,
'Edit role'
)
).flat_map(
lambda ns: assert_user_can_edit_role(
cur_usr,
role,
'Edit role'
).map(lambda _: ns)
).lside_effect(
check_for_auth_err_and_report
).flat_map(
lambda ns: get_namespace_role_form_for(
cur_usr,
ns,
role
).map(
lambda form: (role, ns, form)
)
)
)
if isinstance(res, Right):
role, ns, form = res.val
form_data = form(request.form)
if request.method == 'POST' and form_data.validate():
return form_data.edit_object(
cur_usr,
ns,
role
).lside_effect(
check_for_auth_err_and_report
).flat_map(
lambda role: do_commit(db)
).and_then(
lambda _: redirect(next),
lambda exc: response_from_exception(exc)
)
else:
return render_template(
'role/namespace/new_or_edit.html',
form=form_data,
action='EDIT'
)
else:
return response_from_exception(
res.assert_left().val
)
@web_namespace_roles.route('/role/<int:rid>/delete')
def delete(rid: int):
next = get_next(
request
).and_then(
lambda val: val,
lambda _: url_for('index')
)
cur_usr = assert_usr(current_user)
return db_fetch_by_id(
NamespaceRole,
rid,
db
).flat_map(
lambda role: assert_user_can_edit_role(
cur_usr,
role,
'Delete'
)
).lside_effect(
check_for_auth_err_and_report
).flat_map(
lambda role: do_delete(role, db)
).and_then(
lambda _: redirect(next),
lambda exc: response_from_exception(exc)
)

View file

@ -270,10 +270,10 @@ def edit(id: int):
form=task_form
)
elif isinstance(lookup_result, Left):
return response_from_exception(lookup_result.val)
else:
return status_response(HTTPStatus.INTERNAL_SERVER_ERROR)
return response_from_exception(
lookup_result.assert_left().val
)
@web_tasks.route('/<int:id>/delete', methods=['GET', 'POST'])
@login_required
@ -324,7 +324,7 @@ def delete(id: int):
'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)
return response_from_exception(
lookup_result.assert_left().val
)