From 9136628cd36ea04e52ca4a5fbcc59dd7c2d3e74f Mon Sep 17 00:00:00 2001 From: digimint Date: Thu, 20 Nov 2025 11:01:35 -0600 Subject: [PATCH] Finish namespace role implementation Closes #3 --- src/taskflower/auth/permission/__init__.py | 6 +- src/taskflower/auth/permission/checks.py | 92 +++++- src/taskflower/auth/permission/lookups.py | 64 +++- src/taskflower/form/zone_invite.py | 105 +++++++ src/taskflower/sanitize/code.py | 64 +++- src/taskflower/sanitize/task.py | 16 +- src/taskflower/static/style.css | 28 +- src/taskflower/templates/codes/all_codes.html | 100 ++++++ .../templates/codes/all_sign_up_codes.html | 48 --- .../templates/codes/new_zone_invite.html | 18 ++ .../templates/codes/zone_picker.html | 31 ++ src/taskflower/templates/main.html | 4 +- .../templates/namespace/admin/admin_user.html | 71 +++++ .../templates/namespace/detail.html | 6 +- src/taskflower/templates/role/_rolelist.html | 4 + .../templates/role/namespace/list.html | 44 +++ src/taskflower/templates/task/_shorttask.html | 32 +- .../tools/icons/raw/arrow-forward.svg | 54 ++++ src/taskflower/tools/icons/raw/arrow-up.svg | 30 +- src/taskflower/types/either.py | 20 +- src/taskflower/web/errors/__init__.py | 2 +- src/taskflower/web/invite/__init__.py | 285 ++++++++++++++++-- src/taskflower/web/namespace/roles.py | 279 ++++++++++++++++- src/taskflower/web/task/__init__.py | 15 +- 24 files changed, 1286 insertions(+), 132 deletions(-) create mode 100644 src/taskflower/form/zone_invite.py create mode 100644 src/taskflower/templates/codes/all_codes.html delete mode 100644 src/taskflower/templates/codes/all_sign_up_codes.html create mode 100644 src/taskflower/templates/codes/new_zone_invite.html create mode 100644 src/taskflower/templates/codes/zone_picker.html create mode 100644 src/taskflower/templates/namespace/admin/admin_user.html create mode 100644 src/taskflower/tools/icons/raw/arrow-forward.svg diff --git a/src/taskflower/auth/permission/__init__.py b/src/taskflower/auth/permission/__init__.py index 346f0dd..eeffbac 100644 --- a/src/taskflower/auth/permission/__init__.py +++ b/src/taskflower/auth/permission/__init__.py @@ -3,7 +3,7 @@ from enum import IntFlag from typing import override from taskflower.db import db -from taskflower.db.model.codes import SignUpCode +from taskflower.db.model.codes import NamespaceInviteCode, SignUpCode from taskflower.db.model.namespace import Namespace from taskflower.db.model.role import NamespaceRole, UserRole, UserToNamespaceRole from taskflower.db.model.task import Task @@ -11,7 +11,7 @@ from taskflower.db.model.user import User from taskflower.types.either import Either, Left, Right from taskflower.types.option import Option, Some -ProtectedResourceType = User|Task|Namespace|NamespaceRole|UserRole|SignUpCode +ProtectedResourceType = User|Task|Namespace|NamespaceRole|UserRole|SignUpCode|NamespaceInviteCode class NamespacePermissionType(IntFlag): NO_PERMS = 0 @@ -134,7 +134,7 @@ def _create_user_role(user: User) -> Either[Exception, UserRole]: def _gen_user_namespace(user: User) -> Either[Exception, Namespace]: try: new_ns = Namespace( - name=f'@user@{user.id}\'s Namespace', # pyright:ignore[reportCallIssue] + name=f'{user.display_name}\'s Namespace'[:64], # pyright:ignore[reportCallIssue] description='Your default namespace!' # pyright:ignore[reportCallIssue] ) diff --git a/src/taskflower/auth/permission/checks.py b/src/taskflower/auth/permission/checks.py index c82308a..1abf04b 100644 --- a/src/taskflower/auth/permission/checks.py +++ b/src/taskflower/auth/permission/checks.py @@ -1,10 +1,10 @@ -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 import NPT, AuthorizationError, NamespacePermissionType +from taskflower.auth.permission.lookups import get_user_perms_on_namespace, get_user_perms_on_task, get_user_priority 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.role import NamespaceRole, UserToNamespaceRole from taskflower.db.model.user import User from taskflower.db.model.task import Task from taskflower.types.either import Either, Left, Right @@ -128,4 +128,90 @@ def assert_user_can_edit_role( None, None )) + ) + +def check_user_can_ns_administrate_user( + acting_user: User, + target_user: User, + ns_context: Namespace +) -> bool: + acting_user_perms = get_user_perms_on_namespace( + acting_user, + ns_context + ) + + if NPT.ADMINISTRATE in acting_user_perms: + return True + + if NPT.EDIT_ROLES not in acting_user_perms: + return False + + return get_user_priority( + acting_user, + ns_context + ).flat_map( + lambda acting_pri: get_user_priority( + target_user, + ns_context + ).map( + lambda target_pri: acting_pri < target_pri + ) + ).and_then( + lambda v: v, + lambda: False + ) + +def assert_user_can_ns_administrate_user( + acting_user: User, + target_user: User, + ns_context: Namespace, + reason: str = '[Unspecified administrate action]' +) -> Either[Exception, None]: + return ( + ( + Right(None) + ) if check_user_can_ns_administrate_user( + acting_user, + target_user, + ns_context + ) else ( + Left(AuthorizationError( + acting_user, + target_user, + reason, + None, + None + )) + ) + ) + +def check_user_has_ns_role( + user: User, + role: NamespaceRole +) -> bool: + return len(db.session.query( + UserToNamespaceRole + ).filter( + UserToNamespaceRole.user == user.id + ).filter( + UserToNamespaceRole.role == role.id + ).all()) > 0 + +def assert_user_has_ns_role( + user: User, + role: NamespaceRole, + reason: str = '[Unspecified Action]' +) -> Either[Exception, None]: + return ( + ( + Right(None) + ) if check_user_has_ns_role(user, role) else ( + Left(AuthorizationError( + user, + role, + reason, + 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 1ffc72f..b663ebe 100644 --- a/src/taskflower/auth/permission/lookups.py +++ b/src/taskflower/auth/permission/lookups.py @@ -148,4 +148,66 @@ def get_namespace_role_below( else: return closest_role - return closest_role \ No newline at end of file + return closest_role + +def get_user_priority( + user: User, + in_context: Namespace +) -> Option[int]: + return Option[NamespaceRole].encapsulate( + db.session.query( + NamespaceRole + ).filter( + NamespaceRole.namespace == in_context.id + ).join( + UserToNamespaceRole, + UserToNamespaceRole.role == NamespaceRole.id + ).filter( + UserToNamespaceRole.user == user.id + ).order_by( + NamespaceRole.priority.asc() + ).first() + ).map( + lambda role: role.priority + ) + +def get_user_roles_on_ns( + user: User, + ns: Namespace +) -> list[NamespaceRole]: + return db.session.query( + NamespaceRole + ).filter( + NamespaceRole.namespace == ns.id + ).join( + UserToNamespaceRole, + UserToNamespaceRole.role == NamespaceRole.id + ).filter( + UserToNamespaceRole.user == user.id + ).all() + +def get_all_roles_on_ns( + ns: Namespace +) -> list[NamespaceRole]: + return db.session.query( + NamespaceRole + ).filter( + NamespaceRole.namespace == ns.id + ).order_by( + NamespaceRole.priority.asc() + ).all() + +def get_all_users_on_ns( + ns: Namespace +) -> list[User]: + return db.session.query( + User + ).join( + UserToNamespaceRole, + UserToNamespaceRole.user == User.id + ).join( + NamespaceRole, + NamespaceRole.id == UserToNamespaceRole.role + ).filter( + NamespaceRole.namespace == ns.id + ).all() \ No newline at end of file diff --git a/src/taskflower/form/zone_invite.py b/src/taskflower/form/zone_invite.py new file mode 100644 index 0000000..b2f5210 --- /dev/null +++ b/src/taskflower/form/zone_invite.py @@ -0,0 +1,105 @@ + +from datetime import datetime, timedelta +from secrets import token_hex +from typing import override +from wtforms import SelectField +from wtforms.validators import DataRequired +from taskflower.auth.permission.checks import assert_user_can_edit_role +from taskflower.auth.violations import check_for_auth_err_and_report +from taskflower.db import db +from taskflower.db.model.codes import NamespaceInviteCode +from taskflower.db.model.namespace import Namespace +from taskflower.db.model.role import NamespaceRole +from taskflower.db.model.user import User +from taskflower.form import FormCreatesObjectWithUserAndNamespace +from taskflower.types import ann +from taskflower.types.either import Either, Left, Right, gather_successes +from taskflower.types.option import Option + + +def gen_namespace_invite( + ns: Namespace, + creator: User, + role: NamespaceRole +): + return Either.do_assert( + role.namespace == ns.id + ).flat_map( + lambda _: assert_user_can_edit_role( + creator, + role, + 'Generate invite for' + ) + ).lside_effect( + check_for_auth_err_and_report + ).map( + lambda _: NamespaceInviteCode( + code=token_hex(16), # pyright:ignore[reportCallIssue] + created_by=creator.id, # pyright:ignore[reportCallIssue] + for_role=role.id, # pyright:ignore[reportCallIssue] + expires=datetime.now() + timedelta(days=7) # pyright:ignore[reportCallIssue] + ) + ) + +def get_zone_invite_form_for( + user: User, + namespace: Namespace +) -> type[ + FormCreatesObjectWithUserAndNamespace[ + NamespaceInviteCode + ] +]: + editable_roles = gather_successes([ + assert_user_can_edit_role(user, role) + for role in db.session.query( + NamespaceRole + ).filter( + NamespaceRole.namespace == namespace.id + ).all() + ]) + + choices = [ + (role.id, role.name) + for role in editable_roles + ] + + class NamespaceInviteForm( + FormCreatesObjectWithUserAndNamespace[NamespaceInviteCode] + ): + role: SelectField = SelectField( + 'Select a role to grant', + [ + DataRequired() + ], + choices=choices, + coerce=int + ) + + @override + def create_object( + self, + current_user: User, + namespace: Namespace + ) -> Either[Exception, NamespaceInviteCode]: + return Either[Exception, None].do_assert( + self.validate() + ).flat_map( + lambda _: Option[NamespaceRole].encapsulate( + db.session.query( + NamespaceRole + ).filter( + NamespaceRole.id == int(ann(self.role.data)) # pyright:ignore[reportAny] + ).one_or_none() + ).and_then( + lambda val: Right[Exception, NamespaceRole](val), + lambda: Left[Exception, NamespaceRole](KeyError('Role ID not found.')) + ) + ).flat_map( + lambda role: gen_namespace_invite( + namespace, + current_user, + role + ) + ) + + return NamespaceInviteForm \ No newline at end of file diff --git a/src/taskflower/sanitize/code.py b/src/taskflower/sanitize/code.py index 0459106..f7d47dc 100644 --- a/src/taskflower/sanitize/code.py +++ b/src/taskflower/sanitize/code.py @@ -2,8 +2,12 @@ from dataclasses import dataclass from datetime import datetime from typing import Self -from taskflower.db.model.codes import SignUpCode -from taskflower.types.either import Either, Right +from taskflower.db import db +from taskflower.db.model.codes import NamespaceInviteCode, SignUpCode +from taskflower.db.model.namespace import Namespace +from taskflower.db.model.role import NamespaceRole +from taskflower.types.either import Either, Left, Right +from taskflower.types.option import Option @dataclass(frozen=True) class SignUpCodeForUser: @@ -24,4 +28,58 @@ class SignUpCodeForUser: code.created, code.expires, code.grants_admin - )) \ No newline at end of file + )) + +@dataclass(frozen=True) +class ZoneInviteCodeForUser: + id: int + code: str + created: datetime + expires: datetime + namespace_id: int + namespace_name: str + role_id: int + role_name: str + + @classmethod + def from_code( + cls, + code: NamespaceInviteCode + ) -> Either[Exception, Self]: + res = Option[NamespaceRole].encapsulate( + db.session.query( + NamespaceRole + ).filter( + NamespaceRole.id == code.for_role + ).one_or_none() + ).flat_map( + lambda role: Option[Namespace].encapsulate( + db.session.query( + Namespace + ).filter( + Namespace.id == role.namespace + ).one_or_none() + ).map( + lambda ns: (ns, role) + ) + ).and_then( + lambda val: Right[Exception, tuple[Namespace, NamespaceRole]](val), + lambda: Left[Exception, tuple[Namespace, NamespaceRole]]( + KeyError('Unable to lookup namespace from invite code.') + ) + ) + + if isinstance(res, Right): + ns, role = res.val + return Right(cls( + id=code.id, + code=code.code, + created=code.created, + expires=code.expires, + namespace_id=ns.id, + namespace_name=ns.name, + role_id=role.id, + role_name=role.name + )) + else: + return Left(res.assert_left().val) \ No newline at end of file diff --git a/src/taskflower/sanitize/task.py b/src/taskflower/sanitize/task.py index 9af24f9..fbc6410 100644 --- a/src/taskflower/sanitize/task.py +++ b/src/taskflower/sanitize/task.py @@ -4,8 +4,9 @@ from typing import Self import humanize -from taskflower.auth.permission import NamespacePermissionType +from taskflower.auth.permission import NPT, NamespacePermissionType from taskflower.auth.permission.checks import assert_user_perms_on_task +from taskflower.auth.permission.lookups import get_user_perms_on_task from taskflower.db.model.task import Task from taskflower.db.model.user import User from taskflower.types.either import Either @@ -33,6 +34,11 @@ class TaskForUser: complete: bool namespace_id: int + can_edit: bool + can_delete: bool + can_complete: bool + can_uncomplete: bool + @classmethod def from_task( cls, @@ -47,6 +53,8 @@ class TaskForUser: if warranted (use ``taskflower.auth.violations.check_for_auth_error_and_report`` in an ``lside_effect()``) ''' + perms = get_user_perms_on_task(usr, tsk) + return assert_user_perms_on_task( usr, tsk, @@ -61,6 +69,10 @@ class TaskForUser: _due_str(tsk.due), tsk.created, tsk.complete, - tsk.namespace + tsk.namespace, + NPT.EDIT_ALL_TASKS in perms, + NPT.DELETE_ALL_TASKS in perms, + NPT.COMPLETE_ALL_TASKS in perms, + NPT.UNCOMPLETE_ALL_TASKS in perms ) ) \ No newline at end of file diff --git a/src/taskflower/static/style.css b/src/taskflower/static/style.css index 328ae18..3763669 100644 --- a/src/taskflower/static/style.css +++ b/src/taskflower/static/style.css @@ -43,6 +43,20 @@ --green-bolder : #00ff88; --green-neutral-light : #87f1b0; + --grey-neutral-dark : #333333; + --grey-bolder : #bbbbbb; + --grey-neutral-light : #aaaaaa; + + --btn-disabled-brd : var(--grey-neutral-dark); + --btn-disabled-bg : var(--grey-bolder); + --btn-disabled-text : black; + --btn-disabled-hover-brd : var(--grey-neutral-dark); + --btn-disabled-hover-bg : var(--grey-neutral-light); + --btn-disabled-hover-text : black; + --btn-disabled-active-brd : var(--grey-neutral-dark); + --btn-disabled-active-bg : var(--grey-neutral-light); + --btn-disabled-active-text : black; + --btn-red-brd : var(--red-neutral-dark); --btn-red-bg : var(--red-bolder); --btn-red-text : black; @@ -227,7 +241,6 @@ h3 { display: flex; flex-direction: row; - .link-btn :first-child { margin-left: 0; } @@ -296,6 +309,19 @@ h3 { --text-color-active : var(--btn-green-active-text); } + &.disabled { + --outline-color : var(--btn-disabled-brd); + --bg-color : var(--btn-disabled-bg); + --text-color : var(--btn-disabled-text); + --outline-color-hover : var(--btn-disabled-hover-brd); + --bg-color-hover : var(--btn-disabled-hover-bg); + --text-color-hover : var(--btn-disabled-hover-text); + --outline-color-active : var(--btn-disabled-active-brd); + --bg-color-active : var(--btn-disabled-active-bg); + --text-color-active : var(--btn-disabled-active-text); + cursor: not-allowed; + } + &:hover { --outline-color: var(--outline-color-hover); diff --git a/src/taskflower/templates/codes/all_codes.html b/src/taskflower/templates/codes/all_codes.html new file mode 100644 index 0000000..90dfd6b --- /dev/null +++ b/src/taskflower/templates/codes/all_codes.html @@ -0,0 +1,100 @@ +{% extends "main.html" %} + +{% block head_extras %} + +{% endblock %} +{% block title %}My Sign-Up Codes{% endblock %} + +{% macro list_entry(code) %} + + {{ code.code }} + {{ reltime(code.created) }} + {{ reltime(code.expires) }} + {{icon('delete')|safe}} DEL + +{% endmacro %} + +{% macro zone_list_entry(code) %} + + {{ code.code }} + {{ code.namespace_name }} + {{ code.role_name }} + {{ reltime(code.created) }} + {{ reltime(code.expires) }} + {{icon('delete')|safe}} DEL + +{% endmacro %} + +{% block main_content %} +

Enter a Zone Invite

+
+ + + +
+ +
+{% if can_make_sign_ups %} +

My Sign-Up Codes

+ +{% if sign_in_codes %} + + + + + + + + + + + {% for code in sign_in_codes %} + {{ list_entry(code) }} + {% endfor %} + + +
CodeCreatedExpiresDelete
+ +{% endif %} + +{{icon('add')|safe}} Generate a new sign-up code + +
+ +{% endif %} + +

My Zone Invite Codes

+ + + + + + + + + + + + {% for code in zone_invite_codes %} + {{ zone_list_entry(code) }} + {% endfor %} + + +
CodeZoneRoleCreatedExpiresDelete
+{{icon('add')|safe}} Generate a new zone invite + + +{% endblock%} \ No newline at end of file diff --git a/src/taskflower/templates/codes/all_sign_up_codes.html b/src/taskflower/templates/codes/all_sign_up_codes.html deleted file mode 100644 index 3cb8232..0000000 --- a/src/taskflower/templates/codes/all_sign_up_codes.html +++ /dev/null @@ -1,48 +0,0 @@ -{% extends "main.html" %} - -{% block head_extras %} - -{% endblock %} -{% block title %}My Sign-Up Codes{% endblock %} - -{% macro list_entry(code) %} - - {{ code.code }} - {{ reltime(code.created) }} - {{ reltime(code.expires) }} - {{icon('delete')|safe}} DEL - -{% endmacro %} - -{% block main_content %} -

My Sign-Up Codes

- -{% if codes %} - - - - - - - - - - - {% for code in codes %} - {{ list_entry(code) }} - {% endfor %} - - -
CodeCreatedExpiresDelete
- -{% endif %} - -{{icon('add')|safe}} Generate a new code - - - -{% endblock%} \ No newline at end of file diff --git a/src/taskflower/templates/codes/new_zone_invite.html b/src/taskflower/templates/codes/new_zone_invite.html new file mode 100644 index 0000000..104d41f --- /dev/null +++ b/src/taskflower/templates/codes/new_zone_invite.html @@ -0,0 +1,18 @@ +{% extends "main.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block head_extras %} + +{% endblock %} + +{% block title %}Generate Invite for {{zone_name}}{% endblock %} + +{% block main_content %} +
+

Generate Invite for {{zone_name}}

+
+ {{ render_field(form.role) }} +
+

+
+{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/codes/zone_picker.html b/src/taskflower/templates/codes/zone_picker.html new file mode 100644 index 0000000..caf0d5b --- /dev/null +++ b/src/taskflower/templates/codes/zone_picker.html @@ -0,0 +1,31 @@ +{% extends "main.html" %} + +{% block head_extras %} + +{% endblock %} +{% block title %}Select a Zone{% endblock %} + +{% block main_content %} + +

Select a Zone

+ +

You can create an invite in any of the zones below. Please pick one to continue.

+ + + + + + + + + {% for zone in zones %} + + + + + {% endfor %} + + +
ZoneLink
{{zone.name}}{{icon('arrow-forward')|safe}} GO
+ +{% endblock%} \ No newline at end of file diff --git a/src/taskflower/templates/main.html b/src/taskflower/templates/main.html index d8d3e68..959b716 100644 --- a/src/taskflower/templates/main.html +++ b/src/taskflower/templates/main.html @@ -15,9 +15,7 @@ {% endif %} {% if current_user.is_authenticated %} - {% if can_generate_sign_up_codes(current_user) %} - + Invite - {% endif %} + + Invite +{% endblock %} + +{% macro role_row(role) %} + + + {% if role.assigned %} + {% if role.can_unassign %} + {{icon('check-box-checked')|safe}} + {% else %} + {{icon('check-box-checked')}} + {% endif %} + {% else %} + {% if role.can_assign %} + {{icon('check-box-unchecked')|safe}} + {% else %} + {{icon('check-box-checked')}} + {% endif %} + {% endif %} + + + {{ role.name }} + + +{% endmacro %} + +{% block main_content %} +

User information for {{user_name}}

+

Roles:

+ + + + + + + + + + + + + {% for role in roles %} + {{ role_row(role) }} + {% endfor %} + +
AssignName
+ + +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/namespace/detail.html b/src/taskflower/templates/namespace/detail.html index 65e643d..6d1df78 100644 --- a/src/taskflower/templates/namespace/detail.html +++ b/src/taskflower/templates/namespace/detail.html @@ -9,11 +9,15 @@ {% block main_content %}

{{ namespace.name }}

+{% if namespace.perms.edit_roles or namespace.perms.administrate %} +{{icon('edit')|safe}} Edit Roles and Users +{% endif %} +

{{ namespace.description }}

Tasks

{% if namespace.perms.create_tasks_in %} - {{ task_list(namespace.tasks, create_url=url_for('web.task.new', namespace=namespace.id)) }} + {{ task_list(namespace.tasks, create_url=url_for('web.task.new', namespace=namespace.id, next=request.path)) }} {% else %} {{ task_list(namespace.tasks) }} {% endif %} diff --git a/src/taskflower/templates/role/_rolelist.html b/src/taskflower/templates/role/_rolelist.html index 25aafd9..037efb7 100644 --- a/src/taskflower/templates/role/_rolelist.html +++ b/src/taskflower/templates/role/_rolelist.html @@ -13,11 +13,15 @@ {% if role.can_edit %} {{icon('edit')|safe}} + {% else %} + {{icon('not-allowed')|safe}} {% endif %} {% if role.can_delete %} {{icon('delete')|safe}} + {% else %} + {{icon('not-allowed')|safe}} {% endif %} {{role.name}} diff --git a/src/taskflower/templates/role/namespace/list.html b/src/taskflower/templates/role/namespace/list.html index 47186dc..afba780 100644 --- a/src/taskflower/templates/role/namespace/list.html +++ b/src/taskflower/templates/role/namespace/list.html @@ -1,6 +1,23 @@ {% extends "main.html" %} {% from "role/_rolelist.html" import role_list with context %} +{% macro user_entry(user) %} + + + {% if user.can_edit %} + {{icon('edit')|safe}} + {% else %} + {{icon('not-allowed')|safe}} + {% endif %} + + + {{ user.display_name }} + + + {{ user.username }} + + +{% endmacro %} {% block head_extras %} @@ -13,4 +30,31 @@ {{ role_list(roles, create_url) }} +
+ +

Users for {{namespace_name}}

+ +{% if invite_user_url %} +{{icon('add')|safe}} Invite a new user +{% endif %} + + + + + + + + + + + + + + + {% for user in users %} + {{ user_entry(user) }} + {% endfor %} + +
EditDisplay NameUsername
+ {% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/task/_shorttask.html b/src/taskflower/templates/task/_shorttask.html index 1b6d42d..a1bfb99 100644 --- a/src/taskflower/templates/task/_shorttask.html +++ b/src/taskflower/templates/task/_shorttask.html @@ -14,13 +14,25 @@ {% if task.complete %} - - {{icon('check-box-checked')|safe}} - + {% if task.can_uncomplete %} + + {{icon('check-box-checked')|safe}} + + {% else %} + + {{icon('check-box-checked')|safe}} + + {% endif %} {% else %} - - {{icon('check-box-unchecked')|safe}} - + {% if task.can_complete %} + + {{icon('check-box-unchecked')|safe}} + + {% else %} + + {{icon('check-box-unchecked')|safe}} + + {% endif %} {% endif %} Task Details: {{ task.name }}

Task ID: {{ task.id }} diff --git a/src/taskflower/tools/icons/raw/arrow-forward.svg b/src/taskflower/tools/icons/raw/arrow-forward.svg new file mode 100644 index 0000000..a88d574 --- /dev/null +++ b/src/taskflower/tools/icons/raw/arrow-forward.svg @@ -0,0 +1,54 @@ + + + + + + + + + + diff --git a/src/taskflower/tools/icons/raw/arrow-up.svg b/src/taskflower/tools/icons/raw/arrow-up.svg index ab12fc7..4149e08 100644 --- a/src/taskflower/tools/icons/raw/arrow-up.svg +++ b/src/taskflower/tools/icons/raw/arrow-up.svg @@ -31,12 +31,12 @@ 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:zoom="4" + inkscape:cx="76.5" + inkscape:cy="76" + inkscape:window-width="1680" + inkscape:window-height="988" + inkscape:window-x="3520" inkscape:window-y="0" inkscape:window-maximized="1" inkscape:current-layer="layer1" /> @@ -46,19 +46,9 @@ inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1"> - - - - + diff --git a/src/taskflower/types/either.py b/src/taskflower/types/either.py index 6458d4f..60a572c 100644 --- a/src/taskflower/types/either.py +++ b/src/taskflower/types/either.py @@ -72,15 +72,23 @@ class Either[L, R](ABC): raise NotImplementedError @staticmethod - def do_assert(x: bool, desc: str = '') -> 'Either[Exception, None]': + def do_assert( + x: bool, + desc: str = '', + on_fail: Exception|None = None + ) -> 'Either[Exception, None]': if x: return Right(None) else: - return Left(AssertionError( - 'Assertion failed' + ( - f': {desc}' - ) if desc else '' - )) + return Left( + on_fail + if on_fail is not None + else AssertionError( + 'Assertion failed' + ( + f': {desc}' + ) if desc else '' + ) + ) @final class Left[L, R](Either[L, R]): diff --git a/src/taskflower/web/errors/__init__.py b/src/taskflower/web/errors/__init__.py index b4c34b6..a971791 100644 --- a/src/taskflower/web/errors/__init__.py +++ b/src/taskflower/web/errors/__init__.py @@ -134,7 +134,7 @@ def response_from_exception(exc: Exception|None = None) -> FlaskViewReturnType: ) if exc: - log.warning(f'Request generated exception: {str(exc)}') + log.warning(f'Request generated exception {exc.__class__.__name__}: {str(exc)}') if isinstance(exc, ResponseErrorType): return status_response( diff --git a/src/taskflower/web/invite/__init__.py b/src/taskflower/web/invite/__init__.py index 53ab900..e64daee 100644 --- a/src/taskflower/web/invite/__init__.py +++ b/src/taskflower/web/invite/__init__.py @@ -1,17 +1,27 @@ from datetime import datetime, timedelta from secrets import token_hex -from flask import Blueprint, redirect, render_template, url_for +from flask import Blueprint, redirect, render_template, request, url_for from flask_login import current_user, login_required # pyright:ignore[reportMissingTypeStubs, reportUnknownVariableType] -from taskflower.auth.permission import AuthorizationError +from taskflower.auth.messages import not_found_or_not_authorized +from taskflower.auth.permission import NPT, AuthorizationError +from taskflower.auth.permission.checks import assert_user_perms_on_namespace, check_user_perms_on_namespace +from taskflower.auth.permission.lookups import get_namespaces_for_user +from taskflower.auth.violations import check_for_auth_err_and_report from taskflower.config import SignUpMode, config -from taskflower.db import db -from taskflower.db.model.codes import SignUpCode +from taskflower.db import db, db_fetch_by_id, do_commit, do_delete, insert_into_db +from taskflower.db.helpers import add_to_db +from taskflower.db.model.codes import NamespaceInviteCode, SignUpCode +from taskflower.db.model.namespace import Namespace +from taskflower.db.model.role import NamespaceRole, UserToNamespaceRole from taskflower.db.model.user import User -from taskflower.sanitize.code import SignUpCodeForUser +from taskflower.form.zone_invite import get_zone_invite_form_for +from taskflower.sanitize.code import SignUpCodeForUser, ZoneInviteCodeForUser +from taskflower.types import assert_usr from taskflower.types.either import Either, Left, Right, gather_successes from taskflower.types.option import Option from taskflower.web.errors import ResponseErrorBadRequest, ResponseErrorForbidden, ResponseErrorNotFound, response_from_exception +from taskflower.web.utils.request import get_next web_invite: Blueprint = Blueprint( @@ -54,14 +64,14 @@ def _can_make_sign_ups( return Right(usr) -@web_invite.route('/sign-up') +@web_invite.route('/') @login_required -def all_sign_up(): +def all(): cur_usr: User = current_user # pyright:ignore[reportAssignmentType] - return _can_make_sign_ups( + sign_up_codes = (can_make_sign_ups:=_can_make_sign_ups( cur_usr - ).flat_map( + )).flat_map( lambda _: Right( db.session.query( SignUpCode @@ -79,11 +89,26 @@ def all_sign_up(): ) ) ).and_then( - lambda codes: render_template( - 'codes/all_sign_up_codes.html', - codes=codes - ), - lambda exc: response_from_exception(exc) + lambda codes: codes, + lambda exc: [] + ) + + zone_invite_query = db.session.query( + NamespaceInviteCode + ).filter( + NamespaceInviteCode.created_by==cur_usr.id + ).all() + + zone_invite_codes = gather_successes([ + ZoneInviteCodeForUser.from_code(code) + for code in zone_invite_query + ]) + + return render_template( + 'codes/all_codes.html', + sign_in_codes=sign_up_codes, + can_make_sign_ups=isinstance(can_make_sign_ups, Right), + zone_invite_codes=zone_invite_codes ) @web_invite.route('/sign-up/new') @@ -105,7 +130,7 @@ def new_sign_up(): ) db.session.add(code) db.session.commit() - return redirect(url_for('web.invite.all_sign_up')) + return redirect(url_for('web.invite.all')) except Exception as e: return response_from_exception(e) @@ -157,7 +182,233 @@ def delete_sign_up(id: int): _do_delete ).and_then( lambda _: redirect( - url_for('web.invite.all_sign_up') + url_for('web.invite.all') ), lambda exc: response_from_exception(exc) - ) \ No newline at end of file + ) + +@web_invite.route('/zone/enter', methods=['POST']) +@login_required +def enter(): + cur_usr = assert_usr(current_user) + next = get_next( + request, + url_for('web.invite.all') + ).and_then( + lambda val: val, + lambda exc: url_for('web.invite.all') + ) + + def _try_add_code( + code: NamespaceInviteCode, + user: User + ) -> Either[Exception, UserToNamespaceRole]: + return Option[NamespaceRole].encapsulate( + db.session.query( + NamespaceRole + ).filter( + NamespaceRole.id == code.for_role + ).one_or_none() + ).and_then( + lambda val: Right[Exception, NamespaceRole](val), + lambda: Left[Exception, NamespaceRole](KeyError('No such namespace role')) + ).map( + lambda role: UserToNamespaceRole( + user=user.id, # pyright:ignore[reportCallIssue] + role=role.id # pyright:ignore[reportCallIssue] + ) + ) + + return Either.do_assert( + 'enter-invite-code' in request.form.keys(), + 'Invite code is present in form data' + ).flat_map( + lambda _: Option[NamespaceInviteCode].encapsulate( + db.session.query( + NamespaceInviteCode + ).filter( + NamespaceInviteCode.code == request.form['enter-invite-code'] + ).one_or_none() + ).and_then( + lambda val: Right[Exception, NamespaceInviteCode](val), + lambda: Left[Exception, NamespaceInviteCode](ResponseErrorNotFound( + request.method, + request.path, + 'Invite code is not valid!', + 'Invite code is not valid!' + )) + ) + ).flat_map( + lambda code: Either.do_assert( + code.expires > datetime.now(), + 'Code expiration date is in the future' + ).map( + lambda _: code + ) + ).flat_map( + lambda code: _try_add_code( + code, + cur_usr + ).flat_map( + lambda user_to_role: add_to_db(user_to_role) + ).flat_map( + lambda user_to_role: do_delete( + code, + db + ) + ) + ).and_then( + lambda _: redirect(next), + lambda exc: response_from_exception(exc) + ) + +@web_invite.route('/zone/new') +@login_required +def new_zone_invite(): + cur_usr = assert_usr(current_user) + next = get_next( + request, + url_for('web.invite.all') + ).and_then( + lambda v: v, + lambda exc: url_for('web.invite.all') + ) + + zones = [ + z + for z in get_namespaces_for_user(cur_usr) + if check_user_perms_on_namespace( + cur_usr, + z, + NPT.EDIT_ROLES + ) + ] + + zones_for_user = [ + { + 'name': z.name, + 'link': url_for( + 'web.invite.new_zone_invite_inner', + id=z.id, + next=next + ) + } + for z in zones + ] + + return render_template( + 'codes/zone_picker.html', + zones=zones_for_user + ) + +@web_invite.route('/zone/new/', methods=['GET', 'POST']) +@login_required +def new_zone_invite_inner(id: int): + cur_usr = assert_usr(current_user) + next = get_next( + request, + url_for('web.invite.all') + ).and_then( + lambda v: v, + lambda exc: url_for('web.invite.all') + ) + + ns_res = Option[Namespace].encapsulate( + db.session.query( + Namespace + ).filter( + Namespace.id==id + ).one_or_none() + ).and_then( + lambda v: Right[Exception, Namespace](v), + lambda: Left[Exception, Namespace](ResponseErrorNotFound( + method=request.method, + page_name=request.path, + reason=f'Namespace with ID {id} not found!', + user_reason=not_found_or_not_authorized( + 'Namespace', + str(id) + ) + )) + ).flat_map( + lambda ns: assert_user_perms_on_namespace( + cur_usr, + ns, + NPT.EDIT_ROLES, + 'Generate invite code' + ).lside_effect( + check_for_auth_err_and_report + ) + ) + + if isinstance(ns_res, Right): + ns = ns_res.val + form_data = get_zone_invite_form_for( + cur_usr, + ns + )(request.form) + + if request.method == 'POST' and form_data.validate(): + return form_data.create_object( + cur_usr, + ns + ).flat_map( + lambda code: insert_into_db( + code, + db + ) + ).flat_map( + lambda _: do_commit( + db + ) + ).and_then( + lambda _: redirect(next), + lambda exc: response_from_exception(exc) + ) + else: + return render_template( + 'codes/new_zone_invite.html', + zone_name=ns.name, + form=form_data + ) + else: + return response_from_exception( + ns_res.assert_left().val + ) + +@web_invite.route('/zone-invite//delete') +@login_required +def delete_zone_invite(id: int): + cur_usr = assert_usr(current_user) + next = get_next( + request, + url_for('web.invite.all') + ).and_then( + lambda v: v, + lambda exc: url_for('web.invite.all') + ) + + return db_fetch_by_id( + NamespaceInviteCode, + id, + db + ).flat_map( + lambda code: Either.do_assert( + code.created_by == cur_usr.id, + on_fail=AuthorizationError( + cur_usr, + code, + 'Delete', + None, + None + ) + ).map(lambda _: code) + ).flat_map( + lambda code: do_delete(code, db) + ).lside_effect( + check_for_auth_err_and_report + ).and_then( + lambda _: redirect(next), + lambda exc: response_from_exception(exc) + ) + diff --git a/src/taskflower/web/namespace/roles.py b/src/taskflower/web/namespace/roles.py index ac23d3e..98ff234 100644 --- a/src/taskflower/web/namespace/roles.py +++ b/src/taskflower/web/namespace/roles.py @@ -1,19 +1,22 @@ +from dataclasses import dataclass 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.permission import NPT, NamespacePermissionType +from taskflower.auth.permission.checks import assert_user_can_edit_role, assert_user_can_ns_administrate_user, assert_user_perms_on_namespace, check_user_can_edit_role, check_user_can_ns_administrate_user, check_user_has_ns_role +from taskflower.auth.permission.lookups import get_all_roles_on_ns, get_all_users_on_ns, 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.db.model.role import NamespaceRole, UserToNamespaceRole +from taskflower.db.model.user import User 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.types.option import Option +from taskflower.web.errors import ResponseErrorBadRequest, ResponseErrorNotFound, response_from_exception from taskflower.web.utils.request import get_next @@ -43,6 +46,30 @@ def all(id: int): except Exception as e: return Left(e) + @dataclass(frozen=True) + class UserData: + id: int + display_name: str + username: str + can_edit: bool + + def _fetch_users( + ns: Namespace + ) -> list[UserData]: + return [ + UserData( + usr.id, + usr.display_name, + usr.username, + check_user_can_ns_administrate_user( + cur_usr, + usr, + ns + ) + ) + for usr in get_all_users_on_ns(ns) + ] + return db_fetch_by_id( Namespace, id, @@ -68,19 +95,23 @@ def all(id: int): for dex, r in enumerate(roles) ] ).map( - lambda roles: (ns.name, roles) + lambda roles: (ns, roles, _fetch_users(ns)) ) ).and_then( lambda data: render_template( 'role/namespace/list.html', - namespace_name=data[0], + namespace_name=data[0].name, + namespace_id=data[0].id, roles=data[1], - create_url=url_for('web.namespace.role.new', id=id) + users=data[2], + create_url=url_for('web.namespace.role.new', id=id), + invite_user_url=url_for('web.invite.new_zone_invite_inner', id=data[0].id) ), lambda exc: response_from_exception(exc) ) @web_namespace_roles.route('//role/new', methods=['GET', 'POST']) +@login_required def new(id: int): cur_usr = assert_usr(current_user) next = get_next( @@ -230,14 +261,17 @@ def promote_demote_role( ) @web_namespace_roles.route('/role//promote') +@login_required def promote(rid: int): return promote_demote_role(rid, PromoteOrDemote.PROMOTE) @web_namespace_roles.route('/role//demote') +@login_required def demote(rid: int): return promote_demote_role(rid, PromoteOrDemote.DEMOTE) @web_namespace_roles.route('/role//edit', methods=['GET', 'POST']) +@login_required def edit(rid: int): cur_usr = assert_usr(current_user) next = get_next( @@ -312,6 +346,7 @@ def edit(rid: int): ) @web_namespace_roles.route('/role//delete') +@login_required def delete(rid: int): next = get_next( request @@ -338,4 +373,230 @@ def delete(rid: int): ).and_then( lambda _: redirect(next), lambda exc: response_from_exception(exc) - ) \ No newline at end of file + ) + +@web_namespace_roles.route('//user//admin') +@login_required +def admin(nid: int, uid: int): + cur_usr = assert_usr(current_user) + + @dataclass(frozen=True) + class RoleData: + id: int + name: str + assigned: bool + can_assign: bool + can_unassign: bool + + @dataclass(frozen=True) + class UserAndNS: + user: User + ns: Namespace + + return db_fetch_by_id( + Namespace, + nid, + db + ).flat_map( + lambda ns: db_fetch_by_id( + User, + uid, + db + ).map( + lambda usr: UserAndNS(usr, ns) + ) + ).flat_map( + lambda usr_ns: assert_user_perms_on_namespace( + cur_usr, + usr_ns.ns, + NPT.EDIT_ROLES, + f'Administrate user {usr_ns.user.username} (id {usr_ns.user.id}) in namespace context {usr_ns.ns.name} (id {usr_ns.ns.id})' + ).map(lambda _: usr_ns) + ).flat_map( + lambda user_ns: assert_user_can_ns_administrate_user( + cur_usr, + user_ns.user, + user_ns.ns, + 'Access administration interface' + ).map(lambda _: user_ns) + ).lside_effect( + check_for_auth_err_and_report + ).flat_map( + lambda user_ns: Right[Exception, list[NamespaceRole]](get_all_roles_on_ns( + user_ns.ns + )).map( + lambda roles: (user_ns, [ + RoleData( + role.id, + role.name, + check_user_has_ns_role(user_ns.user, role), + (can_as_un:=check_user_can_edit_role(cur_usr, role)), + can_as_un + ) + for role in roles + ]) + ) + ).and_then( + lambda res: render_template( + 'namespace/admin/admin_user.html', + user_name=res[0].user.display_name, + user_id=res[0].user.id, + namespace_id=res[0].ns.id, + roles=res[1] + ), + lambda exc: response_from_exception(exc) + ) + + + +class RoleAsnAction(Enum): + ASSIGN = auto() + UNASSIGN = auto() + +def assign_unassign_role( + nid: int, + uid: int, + rid: int, + action: RoleAsnAction +): + action_str = 'Assign' if action == RoleAsnAction.ASSIGN else 'Unassign' + cur_usr = assert_usr(current_user) + next = get_next( + request, + url_for('web.namespace.get', id=nid) + ).and_then( + lambda v: v, + lambda exc: url_for('web.namespace.get', id=id) + ) + + @dataclass(frozen=True) + class Context: + namespace: Namespace + target: User + role: NamespaceRole + + def _assign( + user: User, + role: NamespaceRole + ) -> Either[Exception, None]: + if db.session.query( + UserToNamespaceRole + ).filter( + UserToNamespaceRole.user == user.id + ).filter( + UserToNamespaceRole.role == role.id + ).count() > 0: + return Left(ResponseErrorBadRequest( + request.method, + request.path, + f'User {user.username} already has {role.name}!', + f'User {user.display_name} already has {role.name}!' + )) + + try: + db.session.add( + UserToNamespaceRole( + user=user.id, # pyright:ignore[reportCallIssue] + role=role.id # pyright:ignore[reportCallIssue] + ) + ) + db.session.commit() + return Right(None) + except Exception as e: + return Left(e) + + def _unassign( + user: User, + role: NamespaceRole + ) -> Either[Exception, None]: + return Option[UserToNamespaceRole].encapsulate( + db.session.query( + UserToNamespaceRole + ).filter( + UserToNamespaceRole.user == user.id + ).filter( + UserToNamespaceRole.role == role.id + ).one_or_none() + ).and_then( + lambda user_to_role: do_delete( + user_to_role, + db + ), + lambda: Left(ResponseErrorBadRequest( + request.method, + request.path, + f'User {user.username} doesn\'t have {role.name}!', + f'User {user.display_name} doesn\'t have {role.name}!' + )) + ).flat_map( + lambda _: do_commit(db) + ) + + def _get_context( + ns_id: int, + us_id: int, + rl_id: int + ) -> Either[Exception, Context]: + return db_fetch_by_id( + Namespace, + ns_id, + db + ).flat_map( + lambda ns: db_fetch_by_id( + User, + us_id, + db + ).flat_map( + lambda us: db_fetch_by_id( + NamespaceRole, + rl_id, + db + ).map( + lambda rl: Context( + ns, + us, + rl + ) + ) + ) + ) + + return _get_context( + nid, + uid, + rid + ).flat_map( + lambda ctx: assert_user_can_edit_role( + cur_usr, + ctx.role, + f'{action_str} role to {ctx.target.username} (id {ctx.target.id})' + ).map(lambda _: ctx) + ).flat_map( + lambda ctx: assert_user_can_ns_administrate_user( + cur_usr, + ctx.target, + ctx.namespace, + f'{action_str} role to {ctx.target.username} (id {ctx.target.id})' + ).map(lambda _: ctx) + ).lside_effect( + check_for_auth_err_and_report + ).flat_map( + lambda ctx: ( + _assign(ctx.target, ctx.role) + if action == RoleAsnAction.ASSIGN + else _unassign(ctx.target, ctx.role) + ) + ).and_then( + lambda _: redirect(next), + lambda exc: response_from_exception(exc) + ) + +@web_namespace_roles.route('//user//admin/assign-role/') +@login_required +def assign_role(nid: int, uid: int, rid: int): + return assign_unassign_role(nid, uid, rid, RoleAsnAction.ASSIGN) + +@web_namespace_roles.route('//user//admin/unassign-role/') +@login_required +def unassign_role(nid: int, uid: int, rid: int): + return assign_unassign_role(nid, uid, rid, RoleAsnAction.UNASSIGN) diff --git a/src/taskflower/web/task/__init__.py b/src/taskflower/web/task/__init__.py index f4b5458..a07dafb 100644 --- a/src/taskflower/web/task/__init__.py +++ b/src/taskflower/web/task/__init__.py @@ -1,4 +1,3 @@ -from http import HTTPStatus from flask import Blueprint, redirect, render_template, request, url_for from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs] @@ -18,8 +17,7 @@ from taskflower.types.either import Either, Left, Right, reduce_either from taskflower.types.option import Option from taskflower.web.errors import ( ResponseErrorNotFound, - response_from_exception, - status_response + response_from_exception ) from taskflower.web.utils.request import get_next @@ -71,6 +69,13 @@ def all(): @web_tasks.route('/new', methods=['GET', 'POST']) @login_required def new(): + next = get_next( + request, + url_for('web.task.all') + ).and_then( + lambda v: v, + lambda exc: url_for('web.task.all') + ) form_data = task_form_for_user( current_user # pyright:ignore[reportArgumentType] )(request.form) @@ -83,9 +88,7 @@ def new(): ).flat_map( lambda task: add_to_db(task) ).and_then( - lambda task: redirect(url_for( - 'web.task.all' - )), + lambda task: redirect(next), lambda exc: response_from_exception(exc) )