parent
113ebce9e1
commit
9136628cd3
24 changed files with 1286 additions and 132 deletions
|
|
@ -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]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
))
|
||||
)
|
||||
)
|
||||
|
|
@ -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()
|
||||
105
src/taskflower/form/zone_invite.py
Normal file
105
src/taskflower/form/zone_invite.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
100
src/taskflower/templates/codes/all_codes.html
Normal file
100
src/taskflower/templates/codes/all_codes.html
Normal 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%}
|
||||
|
|
@ -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%}
|
||||
18
src/taskflower/templates/codes/new_zone_invite.html
Normal file
18
src/taskflower/templates/codes/new_zone_invite.html
Normal 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 %}
|
||||
31
src/taskflower/templates/codes/zone_picker.html
Normal file
31
src/taskflower/templates/codes/zone_picker.html
Normal 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%}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
71
src/taskflower/templates/namespace/admin/admin_user.html
Normal file
71
src/taskflower/templates/namespace/admin/admin_user.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
54
src/taskflower/tools/icons/raw/arrow-forward.svg
Normal file
54
src/taskflower/tools/icons/raw/arrow-forward.svg
Normal 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 |
|
|
@ -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 |
|
|
@ -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]):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue