Finish namespace role implementation

Closes #3
This commit is contained in:
digimint 2025-11-20 11:01:35 -06:00
parent 113ebce9e1
commit 9136628cd3
Signed by: digimint
GPG key ID: 8DF1C6FD85ABF748
24 changed files with 1286 additions and 132 deletions

View file

@ -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]
)

View file

@ -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
))
)
)

View file

@ -148,4 +148,66 @@ def get_namespace_role_below(
else:
return closest_role
return closest_role
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()

View file

@ -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

View file

@ -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
))
))
@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)

View file

@ -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
)
)

View file

@ -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);

View file

@ -0,0 +1,100 @@
{% extends "main.html" %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
{% endblock %}
{% block title %}My Sign-Up Codes{% endblock %}
{% macro list_entry(code) %}
<tr>
<td>{{ code.code }}</td>
<td>{{ reltime(code.created) }}</td>
<td>{{ reltime(code.expires) }}</td>
<td style="padding: 0;"><a class="link-btn icon-btn red del-btn" href="{{url_for('web.invite.delete_sign_up', id=code.id)}}">{{icon('delete')|safe}} DEL</a></td>
</tr>
{% endmacro %}
{% macro zone_list_entry(code) %}
<tr>
<td>{{ code.code }}</td>
<td>{{ code.namespace_name }}</td>
<td>{{ code.role_name }}</td>
<td>{{ reltime(code.created) }}</td>
<td>{{ reltime(code.expires) }}</td>
<td style="padding: 0;"><a class="link-btn icon-btn red del-btn" href="{{url_for('web.invite.delete_zone_invite', id=code.id)}}">{{icon('delete')|safe}} DEL</a></td>
</tr>
{% endmacro %}
{% block main_content %}
<h1>Enter a Zone Invite</h1>
<form class="default-form" action="{{url_for('web.invite.enter')}}" method="POST">
<label name="enter-invite-code">Invite Code:</label>
<input name="enter-invite-code" type="text">
<button type="submit" class="icon-btn green">{{icon('add')|safe}} Submit Invite Code</button>
</form>
<hr/>
{% if can_make_sign_ups %}
<h1>My Sign-Up Codes</h1>
{% if sign_in_codes %}
<table class="list">
<tbody>
<tr>
<th>Code</th>
<th>Created</th>
<th>Expires</th>
<th>Delete</th>
</tr>
{% for code in sign_in_codes %}
{{ list_entry(code) }}
{% endfor %}
</tbody>
</table>
{% endif %}
<a class="link-btn icon-btn" href="{{url_for('web.invite.new_sign_up')}}">{{icon('add')|safe}} Generate a new sign-up code</a>
<hr/>
{% endif %}
<h1>My Zone Invite Codes</h1>
<table class="list">
<tbody>
<tr>
<th>Code</th>
<th>Zone</th>
<th>Role</th>
<th>Created</th>
<th>Expires</th>
<th>Delete</th>
</tr>
{% for code in zone_invite_codes %}
{{ zone_list_entry(code) }}
{% endfor %}
</tbody>
</table>
<a class="link-btn icon-btn" href="{{url_for('web.invite.new_zone_invite')}}">{{icon('add')|safe}} Generate a new zone invite</a>
<style>
.del-btn {
margin: 0.5rem auto;
}
h1 {
margin-top: 1rem;
margin-bottom: 1rem;
}
hr {
margin-top: 3rem;
}
</style>
{% endblock%}

View file

@ -1,48 +0,0 @@
{% extends "main.html" %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
{% endblock %}
{% block title %}My Sign-Up Codes{% endblock %}
{% macro list_entry(code) %}
<tr id="row-{{ code.id }}">
<td id="row-{{code.id}}-code">{{ code.code }}</td>
<td id="row-{{code.id}}-created">{{ reltime(code.created) }}</td>
<td id="row-{{code.id}}-expires">{{ reltime(code.expires) }}</td>
<td id="row-{{code.id}}-delete" style="padding: 0;"><a class="link-btn icon-btn red del-btn" href="{{url_for('web.invite.delete_sign_up', id=code.id)}}">{{icon('delete')|safe}} DEL</a></td>
</tr>
{% endmacro %}
{% block main_content %}
<h1>My Sign-Up Codes</h1>
{% if codes %}
<table class="list">
<tbody>
<tr>
<th>Code</th>
<th>Created</th>
<th>Expires</th>
<th>Delete</th>
</tr>
{% for code in codes %}
{{ list_entry(code) }}
{% endfor %}
</tbody>
</table>
{% endif %}
<a class="link-btn icon-btn" href="{{url_for('web.invite.new_sign_up')}}">{{icon('add')|safe}} Generate a new code</a>
<style>
.del-btn {
margin: 0.5rem auto;
}
</style>
{% endblock%}

View file

@ -0,0 +1,18 @@
{% extends "main.html" %}
{% from "_formhelpers.html" import render_field %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="forms.css") }} />
{% endblock %}
{% block title %}Generate Invite for {{zone_name}}{% endblock %}
{% block main_content %}
<form class="default-form" id="generate-zone-invite-form" method="POST">
<h1>Generate Invite for {{zone_name}}</h1>
<dl>
{{ render_field(form.role) }}
</dl>
<p><button id="submit-form" class="icon-btn green" type="submit">{{icon('add')|safe}} GENERATE INVITE</button></p>
</form>
{% endblock %}

View file

@ -0,0 +1,31 @@
{% extends "main.html" %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
{% endblock %}
{% block title %}Select a Zone{% endblock %}
{% block main_content %}
<h1>Select a Zone</h1>
<p>You can create an invite in any of the zones below. Please pick one to continue.</p>
<table class="list">
<tbody>
<tr>
<th>Zone</th>
<th>Link</th>
</tr>
{% for zone in zones %}
<tr>
<td>{{zone.name}}</td>
<td><a class="link-btn icon-btn" href="{{zone.link}}">{{icon('arrow-forward')|safe}} GO</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock%}

View file

@ -15,9 +15,7 @@
<div id="sidebar-spacer"></div>
{% endif %}
{% if current_user.is_authenticated %}
{% if can_generate_sign_up_codes(current_user) %}
<a href="{{url_for('web.invite.all_sign_up')}}">+ Invite</a>
{% endif %}
<a href="{{url_for('web.invite.all')}}">+ Invite</a>
<a href={{ url_for(
"web.user.profile",
id=current_user.id

View file

@ -0,0 +1,71 @@
{% extends "main.html" %}
{% from "_formhelpers.html" import render_field %}
{% block head_extras %}
<link rel="stylesheet" href="{{ url_for('static', filename='list-view.css') }}"/>
{% endblock %}
{% macro role_row(role) %}
<tr>
<td class="checkbox-container">
{% if role.assigned %}
{% if role.can_unassign %}
<a class="checkbox" aria-label="Unassign Role" href="{{ url_for('web.namespace.role.unassign_role', nid=namespace_id, uid=user_id, rid=role.id, next=request.path) }}">{{icon('check-box-checked')|safe}}</a>
{% else %}
<a class="checkbox" aria-label="Can't Unassign Role" title="You don't have permission to unassign this role">{{icon('check-box-checked')}}</a>
{% endif %}
{% else %}
{% if role.can_assign %}
<a class="checkbox" aria-label="Assign Role" href="{{ url_for('web.namespace.role.assign_role', nid=namespace_id, uid=user_id, rid=role.id, next=request.path) }}">{{icon('check-box-unchecked')|safe}}</a>
{% else %}
<a class="checkbox" aria-label="Can't Assign Role" title="You don't have permission to assign this role">{{icon('check-box-checked')}}</a>
{% endif %}
{% endif %}
</td>
<td>
{{ role.name }}
</td>
</tr>
{% endmacro %}
{% block main_content %}
<h1>User information for {{user_name}}</h1>
<h2>Roles:</h2>
<table class="list">
<tbody>
<colgroup>
<col span="1" style="width: 8rem;"/>
<col span="1"/>
</colgroup>
<tr>
<th>Assign</th>
<th>Name</th>
</tr>
{% for role in roles %}
{{ role_row(role) }}
{% endfor %}
</tbody>
</table>
<style>
.list {
.checkbox-container {
padding: 0;
}
.checkbox {
padding: 0.5rem 1rem;
.icon {
svg {
margin: auto;
display: block;
max-width: 1.5rem;
max-height: 1.5rem;
}
}
}
}
</style>
{% endblock %}

View file

@ -9,11 +9,15 @@
{% block main_content %}
<h1>{{ namespace.name }}</h1>
{% if namespace.perms.edit_roles or namespace.perms.administrate %}
<a class="link-btn icon-btn" href="{{url_for('web.namespace.role.all', id=namespace.id)}}">{{icon('edit')|safe}} Edit Roles and Users</a>
{% endif %}
<p>{{ namespace.description }}</p>
<h2>Tasks</h2>
{% if namespace.perms.create_tasks_in %}
{{ task_list(namespace.tasks, create_url=url_for('web.task.new', namespace=namespace.id)) }}
{{ task_list(namespace.tasks, create_url=url_for('web.task.new', namespace=namespace.id, next=request.path)) }}
{% else %}
{{ task_list(namespace.tasks) }}
{% endif %}

View file

@ -13,11 +13,15 @@
<td class="pad-even">
{% if role.can_edit %}
<a href="{{url_for('web.namespace.role.edit', rid=role.id, next=request.path)}}" aria-label="Edit Role" class="icon-only-btn">{{icon('edit')|safe}}</a>
{% else %}
<a aria-label="Can't Edit Role" title="You don't have permission to edit this role." class="icon-only-btn disabled">{{icon('not-allowed')|safe}}</a>
{% endif %}
</td>
<td class="pad-even">
{% if role.can_delete %}
<a href="{{url_for('web.namespace.role.delete', rid=role.id, next=request.path)}}" aria-label="Delete Role" class="icon-only-btn red">{{icon('delete')|safe}}</a>
{% else %}
<a aria-label="Can't Delete Role" title="You don't have permission to delete this role." class="icon-only-btn disabled">{{icon('not-allowed')|safe}}</a>
{% endif %}
</td>
<td>{{role.name}}</td>

View file

@ -1,6 +1,23 @@
{% extends "main.html" %}
{% from "role/_rolelist.html" import role_list with context %}
{% macro user_entry(user) %}
<tr>
<td class="pad-even">
{% if user.can_edit %}
<a class="icon-only-btn" aria-label="Edit User" href="{{ url_for('web.namespace.role.admin', nid=namespace_id, uid=user.id) }}">{{icon('edit')|safe}}</a>
{% else %}
<a class="icon-only-btn disabled" aria-label="Can't Edit User" title="You don't have permission to edit this user.">{{icon('not-allowed')|safe}}</a>
{% endif %}
</td>
<td>
{{ user.display_name }}
</td>
<td>
{{ user.username }}
</td>
</tr>
{% endmacro %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
@ -13,4 +30,31 @@
{{ role_list(roles, create_url) }}
<hr style="margin-top: 3rem;"/>
<h1>Users for {{namespace_name}}</h1>
{% if invite_user_url %}
<a class="link-btn icon-btn" href="{{invite_user_url}}">{{icon('add')|safe}} Invite a new user</a>
{% endif %}
<table class="list">
<tbody>
<colgroup>
<col span="1" style="width: 0;"/>
<col span="1"/>
<col span="1"/>
</colgroup>
<tr>
<th>Edit</th>
<th>Display Name</th>
<th>Username</th>
</tr>
{% for user in users %}
{{ user_entry(user) }}
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -14,13 +14,25 @@
<tr class="task-table-row" id="{{ tlist_cid('row', task.id, list_id) }}">
<td class="checkbox" id="{{ tlist_cid('check', task.id, list_id) }}">
{% if task.complete %}
<a id="{{ tlist_cid('check-inner', task.id, list_id) }}" href="{{ url_for('web.task.uncomplete', id=task.id, next=request.path) }}">
{{icon('check-box-checked')|safe}}
</a>
{% if task.can_uncomplete %}
<a id="{{ tlist_cid('check-inner', task.id, list_id) }}" href="{{ url_for('web.task.uncomplete', id=task.id, next=request.path) }}">
{{icon('check-box-checked')|safe}}
</a>
{% else %}
<a title="You aren't allowed to uncomplete this task." id="{{ tlist_cid('check-inner', task.id, list_id) }}">
{{icon('check-box-checked')|safe}}
</a>
{% endif %}
{% else %}
<a id="{{ tlist_cid('check-inner', task.id, list_id) }}" href="{{ url_for('web.task.complete', id=task.id, next=request.path) }}">
{{icon('check-box-unchecked')|safe}}
</a>
{% if task.can_complete %}
<a id="{{ tlist_cid('check-inner', task.id, list_id) }}" href="{{ url_for('web.task.complete', id=task.id, next=request.path) }}">
{{icon('check-box-unchecked')|safe}}
</a>
{% else %}
<a title="You aren't allowed to complete this task." id="{{ tlist_cid('check-inner', task.id, list_id) }}">
{{icon('check-box-unchecked')|safe}}
</a>
{% endif %}
{% endif %}
</td>
<td
@ -40,8 +52,12 @@
<h1>Task Details: {{ task.name }}</h1>
</div>
<div class="link-tray">
<a class="link-btn" href="{{url_for('web.task.edit', id=task.id, next=request.path)}}">{{icon('edit')|safe}}<span>Edit Task</span></a>
<a class="link-btn red" href="{{url_for('web.task.delete', id=task.id, next=request.path)}}">{{icon('delete')|safe}}<span>Delete Task</span></a>
{% if task.can_edit %}
<a class="link-btn icon-btn" href="{{url_for('web.task.edit', id=task.id, next=request.path)}}">{{icon('edit')|safe}}<span>Edit Task</span></a>
{% endif %}
{% if task.can_delete %}
<a class="link-btn icon-btn red" href="{{url_for('web.task.delete', id=task.id, next=request.path)}}">{{icon('delete')|safe}}<span>Delete Task</span></a>
{% endif %}
</div>
<p class="small-details">
Task ID: {{ task.id }}

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32mm"
height="32mm"
viewBox="0 0 32 32"
version="1.1"
id="svg5088"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="arrow-forward.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview5090"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:snap-bbox-midpoints="true"
inkscape:object-paths="true"
inkscape:snap-page="true"
inkscape:snap-intersection-paths="true"
inkscape:zoom="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" />
<defs
id="defs5085" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="path1698"
style="color:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 30.688308,15.867507 A 2.0002,2.0002 0 0 0 30.102814,14.45364 L 20.202643,4.5529521 a 2,2 0 0 0 -2.828251,0 2,2 0 0 0 0,2.8303182 l 6.601147,6.5995967 H 3.1964471 a 1.88562,1.88562 0 0 0 -1.884641,1.88464 1.88562,1.88562 0 0 0 1.884641,1.884639 H 23.975539 l -6.601147,6.599597 a 2,2 0 0 0 0,2.830319 2,2 0 0 0 2.828251,0 l 9.900171,-9.900688 a 2.0002,2.0002 0 0 0 0.585494,-1.413867 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -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">
<g
id="g8868"
transform="translate(-4e-7,-0.45988206)">
<path
style="fill:none;stroke-width:3.77124;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 15.999999,29.130346 V 3.6391323"
id="path5412"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 25.899494,13.538627 15.999999,3.6391323 6.1005048,13.538627"
id="path7210"
sodipodi:nodetypes="ccc" />
</g>
<path
id="path1698"
style="color:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:round;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
d="M 16.000057,1.1792562 A 2.0002,2.0002 0 0 0 14.58619,1.7647501 L 4.685502,11.664921 a 2,2 0 0 0 0,2.828251 2,2 0 0 0 2.8303182,0 L 14.115417,7.8920246 V 28.671117 a 1.88562,1.88562 0 0 0 1.88464,1.884641 1.88562,1.88562 0 0 0 1.884639,-1.884641 V 7.8920246 l 6.599597,6.6011474 a 2,2 0 0 0 2.830319,0 2,2 0 0 0 0,-2.828251 L 17.413924,1.7647501 A 2.0002,2.0002 0 0 0 16.000057,1.1792562 Z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

View file

@ -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]):

View file

@ -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(

View file

@ -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)
)
)
@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/<int:id>', 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/<int:id>/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)
)

View file

@ -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('/<int:id>/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/<int:rid>/promote')
@login_required
def promote(rid: int):
return promote_demote_role(rid, PromoteOrDemote.PROMOTE)
@web_namespace_roles.route('/role/<int:rid>/demote')
@login_required
def demote(rid: int):
return promote_demote_role(rid, PromoteOrDemote.DEMOTE)
@web_namespace_roles.route('/role/<int:rid>/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/<int:rid>/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)
)
)
@web_namespace_roles.route('/<int:nid>/user/<int:uid>/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('/<int:nid>/user/<int:uid>/admin/assign-role/<int:rid>')
@login_required
def assign_role(nid: int, uid: int, rid: int):
return assign_unassign_role(nid, uid, rid, RoleAsnAction.ASSIGN)
@web_namespace_roles.route('/<int:nid>/user/<int:uid>/admin/unassign-role/<int:rid>')
@login_required
def unassign_role(nid: int, uid: int, rid: int):
return assign_unassign_role(nid, uid, rid, RoleAsnAction.UNASSIGN)

View file

@ -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)
)