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 typing import override
from taskflower.db import db 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.namespace import Namespace
from taskflower.db.model.role import NamespaceRole, UserRole, UserToNamespaceRole from taskflower.db.model.role import NamespaceRole, UserRole, UserToNamespaceRole
from taskflower.db.model.task import Task 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.either import Either, Left, Right
from taskflower.types.option import Option, Some 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): class NamespacePermissionType(IntFlag):
NO_PERMS = 0 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]: def _gen_user_namespace(user: User) -> Either[Exception, Namespace]:
try: try:
new_ns = Namespace( 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] description='Your default namespace!' # pyright:ignore[reportCallIssue]
) )

View file

@ -1,10 +1,10 @@
from taskflower.auth.permission import AuthorizationError, NamespacePermissionType from taskflower.auth.permission import NPT, AuthorizationError, NamespacePermissionType
from taskflower.auth.permission.lookups import get_user_perms_on_namespace, get_user_perms_on_task 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.auth.permission.resolve import namespace_permission_priority
from taskflower.db import db from taskflower.db import db
from taskflower.db.model.namespace import Namespace 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.user import User
from taskflower.db.model.task import Task from taskflower.db.model.task import Task
from taskflower.types.either import Either, Left, Right from taskflower.types.either import Either, Left, Right
@ -128,4 +128,90 @@ def assert_user_can_edit_role(
None, None,
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: else:
return closest_role 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 datetime import datetime
from typing import Self from typing import Self
from taskflower.db.model.codes import SignUpCode from taskflower.db import db
from taskflower.types.either import Either, Right 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) @dataclass(frozen=True)
class SignUpCodeForUser: class SignUpCodeForUser:
@ -24,4 +28,58 @@ class SignUpCodeForUser:
code.created, code.created,
code.expires, code.expires,
code.grants_admin 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 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.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.task import Task
from taskflower.db.model.user import User from taskflower.db.model.user import User
from taskflower.types.either import Either from taskflower.types.either import Either
@ -33,6 +34,11 @@ class TaskForUser:
complete: bool complete: bool
namespace_id: int namespace_id: int
can_edit: bool
can_delete: bool
can_complete: bool
can_uncomplete: bool
@classmethod @classmethod
def from_task( def from_task(
cls, cls,
@ -47,6 +53,8 @@ class TaskForUser:
if warranted (use ``taskflower.auth.violations.check_for_auth_error_and_report`` if warranted (use ``taskflower.auth.violations.check_for_auth_error_and_report``
in an ``lside_effect()``) in an ``lside_effect()``)
''' '''
perms = get_user_perms_on_task(usr, tsk)
return assert_user_perms_on_task( return assert_user_perms_on_task(
usr, usr,
tsk, tsk,
@ -61,6 +69,10 @@ class TaskForUser:
_due_str(tsk.due), _due_str(tsk.due),
tsk.created, tsk.created,
tsk.complete, 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-bolder : #00ff88;
--green-neutral-light : #87f1b0; --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-brd : var(--red-neutral-dark);
--btn-red-bg : var(--red-bolder); --btn-red-bg : var(--red-bolder);
--btn-red-text : black; --btn-red-text : black;
@ -227,7 +241,6 @@ h3 {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
.link-btn :first-child { .link-btn :first-child {
margin-left: 0; margin-left: 0;
} }
@ -296,6 +309,19 @@ h3 {
--text-color-active : var(--btn-green-active-text); --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 { &:hover {
--outline-color: var(--outline-color-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> <div id="sidebar-spacer"></div>
{% endif %} {% endif %}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
{% if can_generate_sign_up_codes(current_user) %} <a href="{{url_for('web.invite.all')}}">+ Invite</a>
<a href="{{url_for('web.invite.all_sign_up')}}">+ Invite</a>
{% endif %}
<a href={{ url_for( <a href={{ url_for(
"web.user.profile", "web.user.profile",
id=current_user.id 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 %} {% block main_content %}
<h1>{{ namespace.name }}</h1> <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> <p>{{ namespace.description }}</p>
<h2>Tasks</h2> <h2>Tasks</h2>
{% if namespace.perms.create_tasks_in %} {% 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 %} {% else %}
{{ task_list(namespace.tasks) }} {{ task_list(namespace.tasks) }}
{% endif %} {% endif %}

View file

@ -13,11 +13,15 @@
<td class="pad-even"> <td class="pad-even">
{% if role.can_edit %} {% 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> <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 %} {% endif %}
</td> </td>
<td class="pad-even"> <td class="pad-even">
{% if role.can_delete %} {% 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> <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 %} {% endif %}
</td> </td>
<td>{{role.name}}</td> <td>{{role.name}}</td>

View file

@ -1,6 +1,23 @@
{% extends "main.html" %} {% extends "main.html" %}
{% from "role/_rolelist.html" import role_list with context %} {% 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 %} {% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} /> <link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
@ -13,4 +30,31 @@
{{ role_list(roles, create_url) }} {{ 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 %} {% endblock %}

View file

@ -14,13 +14,25 @@
<tr class="task-table-row" id="{{ tlist_cid('row', task.id, list_id) }}"> <tr class="task-table-row" id="{{ tlist_cid('row', task.id, list_id) }}">
<td class="checkbox" id="{{ tlist_cid('check', task.id, list_id) }}"> <td class="checkbox" id="{{ tlist_cid('check', task.id, list_id) }}">
{% if task.complete %} {% 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) }}"> {% if task.can_uncomplete %}
{{icon('check-box-checked')|safe}} <a id="{{ tlist_cid('check-inner', task.id, list_id) }}" href="{{ url_for('web.task.uncomplete', id=task.id, next=request.path) }}">
</a> {{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 %} {% else %}
<a id="{{ tlist_cid('check-inner', task.id, list_id) }}" href="{{ url_for('web.task.complete', id=task.id, next=request.path) }}"> {% if task.can_complete %}
{{icon('check-box-unchecked')|safe}} <a id="{{ tlist_cid('check-inner', task.id, list_id) }}" href="{{ url_for('web.task.complete', id=task.id, next=request.path) }}">
</a> {{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 %} {% endif %}
</td> </td>
<td <td
@ -40,8 +52,12 @@
<h1>Task Details: {{ task.name }}</h1> <h1>Task Details: {{ task.name }}</h1>
</div> </div>
<div class="link-tray"> <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> {% if task.can_edit %}
<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> <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> </div>
<p class="small-details"> <p class="small-details">
Task ID: {{ task.id }} 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:object-paths="true"
inkscape:snap-page="true" inkscape:snap-page="true"
inkscape:snap-intersection-paths="true" inkscape:snap-intersection-paths="true"
inkscape:zoom="3.5273148" inkscape:zoom="4"
inkscape:cx="23.530647" inkscape:cx="76.5"
inkscape:cy="56.416852" inkscape:cy="76"
inkscape:window-width="1920" inkscape:window-width="1680"
inkscape:window-height="1018" inkscape:window-height="988"
inkscape:window-x="1600" inkscape:window-x="3520"
inkscape:window-y="0" inkscape:window-y="0"
inkscape:window-maximized="1" inkscape:window-maximized="1"
inkscape:current-layer="layer1" /> inkscape:current-layer="layer1" />
@ -46,19 +46,9 @@
inkscape:label="Layer 1" inkscape:label="Layer 1"
inkscape:groupmode="layer" inkscape:groupmode="layer"
id="layer1"> id="layer1">
<g <path
id="g8868" id="path1698"
transform="translate(-4e-7,-0.45988206)"> style="color:#000000;fill-opacity:1;stroke:none;stroke-width:0;stroke-linecap:round;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"
<path 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" />
style="fill:none;stroke-width:3.77124;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 15.999999,29.130346 V 3.6391323"
id="path5412"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 25.899494,13.538627 15.999999,3.6391323 6.1005048,13.538627"
id="path7210"
sodipodi:nodetypes="ccc" />
</g>
</g> </g>
</svg> </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 raise NotImplementedError
@staticmethod @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: if x:
return Right(None) return Right(None)
else: else:
return Left(AssertionError( return Left(
'Assertion failed' + ( on_fail
f': {desc}' if on_fail is not None
) if desc else '' else AssertionError(
)) 'Assertion failed' + (
f': {desc}'
) if desc else ''
)
)
@final @final
class Left[L, R](Either[L, R]): class Left[L, R](Either[L, R]):

View file

@ -134,7 +134,7 @@ def response_from_exception(exc: Exception|None = None) -> FlaskViewReturnType:
) )
if exc: 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): if isinstance(exc, ResponseErrorType):
return status_response( return status_response(

View file

@ -1,17 +1,27 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from secrets import token_hex 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 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.config import SignUpMode, config
from taskflower.db import db from taskflower.db import db, db_fetch_by_id, do_commit, do_delete, insert_into_db
from taskflower.db.model.codes import SignUpCode 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.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.either import Either, Left, Right, gather_successes
from taskflower.types.option import Option from taskflower.types.option import Option
from taskflower.web.errors import ResponseErrorBadRequest, ResponseErrorForbidden, ResponseErrorNotFound, response_from_exception from taskflower.web.errors import ResponseErrorBadRequest, ResponseErrorForbidden, ResponseErrorNotFound, response_from_exception
from taskflower.web.utils.request import get_next
web_invite: Blueprint = Blueprint( web_invite: Blueprint = Blueprint(
@ -54,14 +64,14 @@ def _can_make_sign_ups(
return Right(usr) return Right(usr)
@web_invite.route('/sign-up') @web_invite.route('/')
@login_required @login_required
def all_sign_up(): def all():
cur_usr: User = current_user # pyright:ignore[reportAssignmentType] 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 cur_usr
).flat_map( )).flat_map(
lambda _: Right( lambda _: Right(
db.session.query( db.session.query(
SignUpCode SignUpCode
@ -79,11 +89,26 @@ def all_sign_up():
) )
) )
).and_then( ).and_then(
lambda codes: render_template( lambda codes: codes,
'codes/all_sign_up_codes.html', lambda exc: []
codes=codes )
),
lambda exc: response_from_exception(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') @web_invite.route('/sign-up/new')
@ -105,7 +130,7 @@ def new_sign_up():
) )
db.session.add(code) db.session.add(code)
db.session.commit() db.session.commit()
return redirect(url_for('web.invite.all_sign_up')) return redirect(url_for('web.invite.all'))
except Exception as e: except Exception as e:
return response_from_exception(e) return response_from_exception(e)
@ -157,7 +182,233 @@ def delete_sign_up(id: int):
_do_delete _do_delete
).and_then( ).and_then(
lambda _: redirect( lambda _: redirect(
url_for('web.invite.all_sign_up') url_for('web.invite.all')
), ),
lambda exc: response_from_exception(exc) 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 enum import Enum, auto
from flask import Blueprint, redirect, render_template, request, url_for from flask import Blueprint, redirect, render_template, request, url_for
from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs] from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs]
from taskflower.auth.permission import NamespacePermissionType from taskflower.auth.permission import NPT, NamespacePermissionType
from taskflower.auth.permission.checks import assert_user_can_edit_role, assert_user_perms_on_namespace 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_namespace_role_above, get_namespace_role_below 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.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 import db, db_fetch_by_id, do_commit, do_delete, insert_into_db
from taskflower.db.model.namespace import Namespace 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.form.namespace import get_namespace_role_form_for
from taskflower.sanitize.namespace import NamespaceRoleForUser from taskflower.sanitize.namespace import NamespaceRoleForUser
from taskflower.types import assert_usr from taskflower.types import assert_usr
from taskflower.types.either import Either, Left, Right 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 from taskflower.web.utils.request import get_next
@ -43,6 +46,30 @@ def all(id: int):
except Exception as e: except Exception as e:
return Left(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( return db_fetch_by_id(
Namespace, Namespace,
id, id,
@ -68,19 +95,23 @@ def all(id: int):
for dex, r in enumerate(roles) for dex, r in enumerate(roles)
] ]
).map( ).map(
lambda roles: (ns.name, roles) lambda roles: (ns, roles, _fetch_users(ns))
) )
).and_then( ).and_then(
lambda data: render_template( lambda data: render_template(
'role/namespace/list.html', 'role/namespace/list.html',
namespace_name=data[0], namespace_name=data[0].name,
namespace_id=data[0].id,
roles=data[1], 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) lambda exc: response_from_exception(exc)
) )
@web_namespace_roles.route('/<int:id>/role/new', methods=['GET', 'POST']) @web_namespace_roles.route('/<int:id>/role/new', methods=['GET', 'POST'])
@login_required
def new(id: int): def new(id: int):
cur_usr = assert_usr(current_user) cur_usr = assert_usr(current_user)
next = get_next( next = get_next(
@ -230,14 +261,17 @@ def promote_demote_role(
) )
@web_namespace_roles.route('/role/<int:rid>/promote') @web_namespace_roles.route('/role/<int:rid>/promote')
@login_required
def promote(rid: int): def promote(rid: int):
return promote_demote_role(rid, PromoteOrDemote.PROMOTE) return promote_demote_role(rid, PromoteOrDemote.PROMOTE)
@web_namespace_roles.route('/role/<int:rid>/demote') @web_namespace_roles.route('/role/<int:rid>/demote')
@login_required
def demote(rid: int): def demote(rid: int):
return promote_demote_role(rid, PromoteOrDemote.DEMOTE) return promote_demote_role(rid, PromoteOrDemote.DEMOTE)
@web_namespace_roles.route('/role/<int:rid>/edit', methods=['GET', 'POST']) @web_namespace_roles.route('/role/<int:rid>/edit', methods=['GET', 'POST'])
@login_required
def edit(rid: int): def edit(rid: int):
cur_usr = assert_usr(current_user) cur_usr = assert_usr(current_user)
next = get_next( next = get_next(
@ -312,6 +346,7 @@ def edit(rid: int):
) )
@web_namespace_roles.route('/role/<int:rid>/delete') @web_namespace_roles.route('/role/<int:rid>/delete')
@login_required
def delete(rid: int): def delete(rid: int):
next = get_next( next = get_next(
request request
@ -338,4 +373,230 @@ def delete(rid: int):
).and_then( ).and_then(
lambda _: redirect(next), lambda _: redirect(next),
lambda exc: response_from_exception(exc) 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 import Blueprint, redirect, render_template, request, url_for
from flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs] 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.types.option import Option
from taskflower.web.errors import ( from taskflower.web.errors import (
ResponseErrorNotFound, ResponseErrorNotFound,
response_from_exception, response_from_exception
status_response
) )
from taskflower.web.utils.request import get_next from taskflower.web.utils.request import get_next
@ -71,6 +69,13 @@ def all():
@web_tasks.route('/new', methods=['GET', 'POST']) @web_tasks.route('/new', methods=['GET', 'POST'])
@login_required @login_required
def new(): 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( form_data = task_form_for_user(
current_user # pyright:ignore[reportArgumentType] current_user # pyright:ignore[reportArgumentType]
)(request.form) )(request.form)
@ -83,9 +88,7 @@ def new():
).flat_map( ).flat_map(
lambda task: add_to_db(task) lambda task: add_to_db(task)
).and_then( ).and_then(
lambda task: redirect(url_for( lambda task: redirect(next),
'web.task.all'
)),
lambda exc: response_from_exception(exc) lambda exc: response_from_exception(exc)
) )