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.
|
|
@ -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.
|
||||
|
|
@ -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('/')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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"}'
|
||||
)
|
||||
|
|
@ -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
|
||||
))
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
BIN
src/taskflower/static/bad-icon.png
Normal file
|
After Width: | Height: | Size: 328 B |
|
|
@ -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;
|
||||
|
||||
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 {
|
||||
background-color: var(--btn-1-hlt);
|
||||
color: var(--on-btn-1-hlt);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,14 @@
|
|||
|
||||
.list td {
|
||||
padding-left: 1rem;
|
||||
|
||||
&.nopad {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&.pad-even {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list p {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%}
|
||||
|
|
|
|||
|
|
@ -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%}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -12,7 +12,11 @@
|
|||
<p>{{ namespace.description }}</p>
|
||||
|
||||
<h2>Tasks</h2>
|
||||
{% 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
52
src/taskflower/templates/role/_rolelist.html
Normal 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 %}
|
||||
13
src/taskflower/templates/role/_three_way_select.html
Normal 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 %}
|
||||
16
src/taskflower/templates/role/namespace/list.html
Normal 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 %}
|
||||
29
src/taskflower/templates/role/namespace/new_or_edit.html
Normal 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 %}
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
.icon {
|
||||
svg {
|
||||
margin: auto;
|
||||
display: block;
|
||||
max-width: 1.5rem;
|
||||
max-height: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list .task-due {
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
132
src/taskflower/tools/icons/__init__.py
Normal 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}')
|
||||
73
src/taskflower/tools/icons/raw/add.svg
Normal 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 |
64
src/taskflower/tools/icons/raw/arrow-down.svg
Normal 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 |
64
src/taskflower/tools/icons/raw/arrow-up.svg
Normal 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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
56
src/taskflower/tools/icons/raw/not-allowed.svg
Normal 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 |
|
|
@ -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 |
|
|
@ -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]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
341
src/taskflower/web/namespace/roles.py
Normal 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)
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||