From 113ebce9e168b3c6076ee343471447388143f881 Mon Sep 17 00:00:00 2001 From: digimint Date: Thu, 20 Nov 2025 06:16:26 -0600 Subject: [PATCH] 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//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. --- src/dependencies.md | 1 + src/taskflower/__init__.py | 19 +- src/taskflower/__main__.py | 5 +- src/taskflower/auth/permission/__init__.py | 53 ++- src/taskflower/auth/permission/checks.py | 65 +++- src/taskflower/auth/permission/lookups.py | 62 +++- src/taskflower/auth/permission/resolve.py | 52 ++- src/taskflower/config/__init__.py | 10 + src/taskflower/db/__init__.py | 16 + src/taskflower/db/model/role.py | 6 +- src/taskflower/db/model/user.py | 3 +- src/taskflower/form/__init__.py | 52 ++- src/taskflower/form/namespace.py | 197 +++++++++- src/taskflower/sanitize/namespace.py | 111 +++++- src/taskflower/static/bad-icon.png | Bin 0 -> 328 bytes src/taskflower/static/forms.css | 109 +++++- src/taskflower/static/list-view.css | 8 + src/taskflower/static/style.css | 229 ++++++++++-- src/taskflower/templates/auth/login.html | 2 +- src/taskflower/templates/base.html | 2 +- .../templates/codes/all_sign_up_codes.html | 22 +- src/taskflower/templates/home.html | 7 + .../templates/namespace/detail.html | 6 +- src/taskflower/templates/namespace/new.html | 2 +- src/taskflower/templates/new_task.html | 2 +- src/taskflower/templates/role/_rolelist.html | 52 +++ .../templates/role/_three_way_select.html | 13 + .../templates/role/namespace/list.html | 16 + .../templates/role/namespace/new_or_edit.html | 29 ++ src/taskflower/templates/task/_shorttask.html | 8 +- src/taskflower/templates/task/_tasklist.html | 26 +- .../templates/task/confirm_delete.html | 2 +- src/taskflower/templates/task/edit.html | 2 +- src/taskflower/templates/task/list.html | 2 +- src/taskflower/templates/user/new_user.html | 2 +- src/taskflower/tools/icons/__init__.py | 132 +++++++ src/taskflower/tools/icons/raw/add.svg | 73 ++++ src/taskflower/tools/icons/raw/arrow-down.svg | 64 ++++ src/taskflower/tools/icons/raw/arrow-up.svg | 64 ++++ .../icons/raw}/check-box-checked.svg | 4 +- .../icons/raw}/check-box-unchecked.svg | 2 +- .../{static => tools/icons/raw}/delete.svg | 4 +- .../{static => tools/icons/raw}/edit.svg | 8 +- .../tools/icons/raw/not-allowed.svg | 56 +++ .../icons/raw}/scelune-logo-narrow.svg | 79 ++-- src/taskflower/types/__init__.py | 8 +- src/taskflower/types/either.py | 41 +++ src/taskflower/web/namespace/__init__.py | 3 + src/taskflower/web/namespace/roles.py | 341 ++++++++++++++++++ src/taskflower/web/task/__init__.py | 12 +- 50 files changed, 1928 insertions(+), 156 deletions(-) create mode 100644 src/taskflower/static/bad-icon.png create mode 100644 src/taskflower/templates/role/_rolelist.html create mode 100644 src/taskflower/templates/role/_three_way_select.html create mode 100644 src/taskflower/templates/role/namespace/list.html create mode 100644 src/taskflower/templates/role/namespace/new_or_edit.html create mode 100644 src/taskflower/tools/icons/__init__.py create mode 100644 src/taskflower/tools/icons/raw/add.svg create mode 100644 src/taskflower/tools/icons/raw/arrow-down.svg create mode 100644 src/taskflower/tools/icons/raw/arrow-up.svg rename src/taskflower/{static => tools/icons/raw}/check-box-checked.svg (87%) rename src/taskflower/{static => tools/icons/raw}/check-box-unchecked.svg (95%) rename src/taskflower/{static => tools/icons/raw}/delete.svg (92%) rename src/taskflower/{static => tools/icons/raw}/edit.svg (79%) create mode 100644 src/taskflower/tools/icons/raw/not-allowed.svg rename src/taskflower/{static => tools/icons/raw}/scelune-logo-narrow.svg (63%) create mode 100644 src/taskflower/web/namespace/roles.py diff --git a/src/dependencies.md b/src/dependencies.md index fe4a495..8d600d9 100644 --- a/src/dependencies.md +++ b/src/dependencies.md @@ -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. \ No newline at end of file diff --git a/src/taskflower/__init__.py b/src/taskflower/__init__.py index a11c0af..f96148d 100644 --- a/src/taskflower/__init__.py +++ b/src/taskflower/__init__.py @@ -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'
{val}
', + lambda exc: f'
Error retrieving icon.
' + ) + 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('/') diff --git a/src/taskflower/__main__.py b/src/taskflower/__main__.py index 6d397c7..8346bf0 100644 --- a/src/taskflower/__main__.py +++ b/src/taskflower/__main__.py @@ -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) \ No newline at end of file + log.info('Startup checks succeeded!') + app.run(debug=config.debug) \ No newline at end of file diff --git a/src/taskflower/auth/permission/__init__.py b/src/taskflower/auth/permission/__init__.py index c66b2b3..346f0dd 100644 --- a/src/taskflower/auth/permission/__init__.py +++ b/src/taskflower/auth/permission/__init__.py @@ -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"}' ) \ No newline at end of file diff --git a/src/taskflower/auth/permission/checks.py b/src/taskflower/auth/permission/checks.py index 6492cf3..c82308a 100644 --- a/src/taskflower/auth/permission/checks.py +++ b/src/taskflower/auth/permission/checks.py @@ -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 @@ -67,4 +85,47 @@ def assert_user_perms_on_task( perms )) ) + ) + +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 + )) ) \ No newline at end of file diff --git a/src/taskflower/auth/permission/lookups.py b/src/taskflower/auth/permission/lookups.py index 16abb1e..1ffc72f 100644 --- a/src/taskflower/auth/permission/lookups.py +++ b/src/taskflower/auth/permission/lookups.py @@ -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( @@ -88,4 +89,63 @@ def get_user_perms_on_namespace( return resolve_perms_on_namespace( usr, ns - ) \ No newline at end of file + ) + +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 \ No newline at end of file diff --git a/src/taskflower/auth/permission/resolve.py b/src/taskflower/auth/permission/resolve.py index 34a5469..f4debec 100644 --- a/src/taskflower/auth/permission/resolve.py +++ b/src/taskflower/auth/permission/resolve.py @@ -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 diff --git a/src/taskflower/config/__init__.py b/src/taskflower/config/__init__.py index 06a1606..39b0143 100644 --- a/src/taskflower/config/__init__.py +++ b/src/taskflower/config/__init__.py @@ -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] diff --git a/src/taskflower/db/__init__.py b/src/taskflower/db/__init__.py index 3fef5ef..50ae881 100644 --- a/src/taskflower/db/__init__.py +++ b/src/taskflower/db/__init__.py @@ -48,5 +48,21 @@ def insert_into_db[T]( try: database.session.add(to_insert) 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) \ No newline at end of file diff --git a/src/taskflower/db/model/role.py b/src/taskflower/db/model/role.py index 85bfdca..4964bef 100644 --- a/src/taskflower/db/model/role.py +++ b/src/taskflower/db/model/role.py @@ -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) diff --git a/src/taskflower/db/model/user.py b/src/taskflower/db/model/user.py index 6a54781..52f840c 100644 --- a/src/taskflower/db/model/user.py +++ b/src/taskflower/db/model/user.py @@ -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) diff --git a/src/taskflower/form/__init__.py b/src/taskflower/form/__init__.py index 8b10662..b031a01 100644 --- a/src/taskflower/form/__init__.py +++ b/src/taskflower/form/__init__.py @@ -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 @@ -52,4 +53,53 @@ class FormEditsObjectWithUser[T](Form): ``AuthorizationError`` if ``current_user`` is not authorized to edit an object with the specified parameters. ''' - raise NotImplementedError() \ No newline at end of file + 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 \ No newline at end of file diff --git a/src/taskflower/form/namespace.py b/src/taskflower/form/namespace.py index 1f0ccd9..6a75cb4 100644 --- a/src/taskflower/form/namespace.py +++ b/src/taskflower/form/namespace.py @@ -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 @@ -40,4 +47,184 @@ class NamespaceForm(FormCreatesObject[Namespace]): else: return Left( ValidationError('Form data failed validation!') - ) \ No newline at end of file + ) + +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) + diff --git a/src/taskflower/sanitize/namespace.py b/src/taskflower/sanitize/namespace.py index b87e3a9..1db7ade 100644 --- a/src/taskflower/sanitize/namespace.py +++ b/src/taskflower/sanitize/namespace.py @@ -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 @@ -75,4 +79,109 @@ class NamespaceForUser(): else [] ) ) - ) \ No newline at end of file + ) + +@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 + ) diff --git a/src/taskflower/static/bad-icon.png b/src/taskflower/static/bad-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fb2a14be2b32aba542a10f07509d7610e2d72f9e GIT binary patch literal 328 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^TIrS%ftyxXLaqrzhmT@&l<}pEU$i6M_gPu zIl-d!6@%KWn{Fx+1?&kfjCLDzG&r1fvhG^#sJODLbW8DpO$!v-{` {% endif %} - +

No account? Register here!

diff --git a/src/taskflower/templates/base.html b/src/taskflower/templates/base.html index f6ef605..28b0f3b 100644 --- a/src/taskflower/templates/base.html +++ b/src/taskflower/templates/base.html @@ -14,7 +14,7 @@