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) %}
+
You can create an invite in any of the zones below. Please pick one to continue.
+
+
|
{% 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)
)
|