Rearrange settings; migrate database in prep for tags/fields/categories

This commit is contained in:
digimint 2025-11-24 11:21:34 -06:00
parent 35b53653a1
commit 05f47c71af
Signed by: digimint
GPG key ID: 8DF1C6FD85ABF748
27 changed files with 995 additions and 238 deletions

View file

@ -0,0 +1,78 @@
"""Associate category with task
Revision ID: f34868d85e32
Revises: 945140c35257
Create Date: 2025-11-24 06:40:40.711809
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f34868d85e32'
down_revision = '945140c35257'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('category',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=16), nullable=False),
sa.Column('namespace', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['namespace'], ['namespace.id'], name='fk_cat_ns', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('field',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=16), nullable=False),
sa.Column('raw_val', sa.String(length=1024), nullable=False),
sa.Column('raw_dtype', sa.String(length=8), nullable=False),
sa.Column('namespace', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['namespace'], ['namespace.id'], name='fk_field_ns', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('tag',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=16), nullable=False),
sa.Column('namespace', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['namespace'], ['namespace.id'], name='fk_tag_ns', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('tag_to_task',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tag', sa.Integer(), nullable=False),
sa.Column('task', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['tag'], ['tag.id'], name='fk_t2t_tag', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['task'], ['task.id'], name='fk_t2t_task', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('task_assignment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('task', sa.Integer(), nullable=False),
sa.Column('user', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['task'], ['task.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.add_column(sa.Column('parent', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_task_parent', 'task', ['parent'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.drop_constraint('fk_task_parent', type_='foreignkey')
batch_op.drop_column('parent')
op.drop_table('task_assignment')
op.drop_table('tag_to_task')
op.drop_table('tag')
op.drop_table('field')
op.drop_table('category')
# ### end Alembic commands ###

View file

@ -1,15 +1,18 @@
import logging
from typing import Any
from flask import Flask, render_template, url_for
from flask import Flask, Request, render_template, url_for
from flask_migrate import Migrate
from taskflower.auth import taskflower_login_manager
from taskflower.auth.startup import startup_checks
from taskflower.config import SignUpMode, config
from taskflower.db import db
from taskflower.api import APIBase
from taskflower.db.model.user import User
from taskflower.sanitize.markdown import SafeHTML, render_and_sanitize
from taskflower.tools.icons import get_icon, svg_bp
from taskflower.types import ann
from taskflower.types.either import Left
from taskflower.util.time import render_abstime, render_reltime
from taskflower.web import web_base
@ -44,6 +47,17 @@ app.register_blueprint(svg_bp)
APIBase.register(app)
# print(f'Routes: \n{"\n".join([str(r) for r in APIBase.routes])}')
with app.app_context():
db.create_all()
log.info('Running startup checks...')
res = startup_checks(db)
if isinstance(res, Left):
log.error(f'Startup checks failed: {res.val}')
raise res.val
else:
log.info('Startup checks succeeded!')
@app.context_processor
def template_utility_fns():
def literal_call(fname: str, *args: Any) -> str: # pyright:ignore[reportAny,reportExplicitAny]
@ -82,13 +96,27 @@ def template_utility_fns():
lambda exc: f'<div class="icon"><img class="icon" src="{url_for('static', filename='bad-icon.png')}" alt="Error retrieving icon."/></div>'
)
def cur_page_with_variables(request: Request, **overrides: str):
new_args: dict[str, Any] = { # pyright:ignore[reportExplicitAny]
k:v
for k, v in request.args.items()
}
if request.view_args is not None:
new_args.update(request.view_args)
new_args.update(overrides)
return url_for(
ann(request.endpoint),
**new_args # pyright:ignore[reportAny]
)
return dict(
literal_call=literal_call,
reltime=render_reltime,
abstime=render_abstime,
can_generate_sign_up_codes=can_generate_sign_up_codes,
render_as_markdown=render_as_markdown,
icon=icon
icon=icon,
cur_page_with_variables=cur_page_with_variables
)
@app.route('/')

View file

@ -1,21 +1,8 @@
import logging
from taskflower import app
from taskflower.auth.startup import startup_checks
from taskflower.config import config
from taskflower.db import db
from taskflower.types.either import Left
log = logging.getLogger(__name__)
if __name__ == '__main__':
with app.app_context():
db.create_all()
log.info('Running startup checks...')
res = startup_checks(db)
if isinstance(res, Left):
log.error(f'Startup checks failed: {res.val}')
raise res.val
else:
log.info('Startup checks succeeded!')
app.run(debug=config.debug)

View file

@ -28,6 +28,7 @@ class NamespacePermissionType(IntFlag):
EDIT_TAGS = (1 << 12)
EDIT_FIELDS = (1 << 13)
EDIT_ROLES = (1 << 11)
RENAME = (1 << 14)
ADMINISTRATE = (1 << 1)
NPT = NamespacePermissionType
@ -71,6 +72,7 @@ SELF_NAMESPACE_PERMISSIONS = (
| NamespacePermissionType.EDIT_TAGS
| NamespacePermissionType.EDIT_FIELDS
| NamespacePermissionType.EDIT_ROLES
| NamespacePermissionType.RENAME
)
def user_friendly_name(perm: NamespacePermissionType|UserPermissionType):

View file

@ -108,7 +108,7 @@ class HIBPLocalCacheMode(EnumFromEnv):
@dataclass(frozen=True)
class ConfigType:
# Application secrets
db_secret : str = 'potato' # Secret value used to 'pepper' password hashes. This MUST
db_secret : str # Secret value used to 'pepper' password hashes. This MUST
# be generated randomly and cryptographically securely.
# For an example of how to do this:
# https://flask.palletsprojects.com/en/stable/config/#SECRET_KEY
@ -116,7 +116,7 @@ class ConfigType:
# In a multi-node environment, this key must be the same
# across all nodes.
app_secret : str = 'potato' # Secret value used to generate session tokens. This should
app_secret : str # Secret value used to generate session tokens. This should
# ALSO be generated securely, and it should be different from
# ``db_secret``

View file

@ -0,0 +1,173 @@
from datetime import datetime
from enum import Enum
from types import NoneType
from typing import Self
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from taskflower.db import db
from taskflower.types import AssertType, CheckType
from taskflower.types.either import Either, Left, Right
from taskflower.util.time import destringify_dt, stringify_dt
# These values are used for column lengths. If you change these values, you MUST
# create a database migration.
TAG_FIELD_NAME_MAX_LEN = 16 # Maximum possible length for a category, field or tag name
FIELD_TYPE_NAME_MAX_LEN = 8 # Maximum possible length for a FieldType name (the part before the equals in the enum below)
FIELD_VAL_MAX_LEN = 1024 # Maximum possible length for a raw field value (AFTER string conversion)
FieldTypeAlias = str|int|float|datetime
class FieldType(Enum):
# Do NOT add a new FieldType with a name longer than 8 characters!
# (or, if you must, then increase `FIELD_TYPE_NAME_MAX_LEN` above and make
# a database migration)
INVALID = NoneType
STRING = str
BOOLEAN = bool
INTEGER = int
FLOAT = float
DATETIME = datetime
def _check_len(v: str) -> Either[Exception, str]:
if len(v) <= FIELD_VAL_MAX_LEN:
return Right(v)
else:
return Left(ValueError(f'Field raw value `{v}` exceeds the maximum allowed length{FIELD_VAL_MAX_LEN}!'))
class Tag(db.Model):
__tablename__: str = 'tag'
id : Mapped[int] = mapped_column(Integer, primary_key=True)
name : Mapped[str] = mapped_column(String(TAG_FIELD_NAME_MAX_LEN))
namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE', name='fk_tag_ns'))
class TaskCategory(db.Model):
__tablename__: str = 'category'
id : Mapped[int] = mapped_column(Integer, primary_key=True)
name : Mapped[str] = mapped_column(String(TAG_FIELD_NAME_MAX_LEN))
namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE', name='fk_cat_ns'))
class Field(db.Model):
__tablename__: str = 'field'
id : Mapped[int] = mapped_column(Integer, primary_key=True)
name : Mapped[str] = mapped_column(String(TAG_FIELD_NAME_MAX_LEN))
raw_val : Mapped[str] = mapped_column(String(FIELD_VAL_MAX_LEN))
raw_dtype : Mapped[str] = mapped_column(String(FIELD_TYPE_NAME_MAX_LEN))
namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE', name='fk_field_ns'))
@property
def dtype(self) -> FieldType:
for k, v in FieldType._value2member_map_.items(): # pyright:ignore[reportAny]
if self.raw_dtype == k:
return AssertType(FieldType)(v)
return FieldType.INVALID
@property
def val(self) -> Either[Exception, FieldTypeAlias]:
match self.dtype:
case FieldType.INVALID:
return Left(TypeError(
'Field dtype is `FieldType.INVALID`!'
))
case FieldType.STRING:
return Right(self.raw_val)
case FieldType.BOOLEAN:
if self.raw_val == 'true':
return Right(True)
elif self.raw_val == 'false':
return Right(False)
else:
return Left(ValueError(
f'Field dtype is `FieldType.BOOLEAN` but raw value `{self.raw_val}` can\'t be interpeted as true or false!'
))
case FieldType.INTEGER:
try:
return Right(int(self.raw_val))
except Exception:
return Left(ValueError(
f'Field dtype is `FieldType.INTEGER`, but raw value `{self.raw_val}` can\'t be interpreted as `int`!'
))
case FieldType.FLOAT:
try:
return Right(float(self.raw_val))
except Exception:
return Left(ValueError(
f'Field dtype is `FieldType.FLOAT`, but raw value `{self.raw_val}` can\'t be interpreted as `float`!'
))
case FieldType.DATETIME:
return destringify_dt(self.raw_val).and_then(
lambda v: Right[Exception, datetime](v),
lambda exc: Left[Exception, datetime](ValueError(
f'Field dtype is `FieldType.DATETIME`, but raw value `{self.raw_val}` can\'t be interpreted as `datetime`'
))
)
def val_as_type[T](self, t: type[T]) -> Either[Exception, T]:
if self.dtype.value is not t:
return Left(TypeError(
f'`Field` dtype is `{self.dtype.name}`, which corresponds to python type `{self.dtype.value.__name__}`, not `{t.__name__}`!'
))
return self.val.flat_map(
lambda v: CheckType(t)(v)
)
@classmethod
def from_vals(
cls,
name: str,
dtype: FieldType,
val: FieldTypeAlias,
namespace_id: int
) -> Either[Exception, Self]:
def _mkobj(raw_data: str) -> Either[Exception, Self]:
return _check_len(raw_data).map(
lambda v: cls(
name = name, # pyright:ignore[reportCallIssue]
raw_val = v, # pyright:ignore[reportCallIssue]
raw_dtype = dtype.name, # pyright:ignore[reportCallIssue]
namespace = namespace_id # pyright:ignore[reportCallIssue]
)
)
match dtype:
case FieldType.INVALID:
return Left(KeyError(
'Can\'t insert a value of type `FieldType.INVALID` into the database!'
))
case FieldType.STRING:
return CheckType(str)(val).flat_map(
_mkobj
)
case FieldType.BOOLEAN:
return CheckType(bool)(val).flat_map(
lambda v: _mkobj('true' if v else 'false')
)
case FieldType.INTEGER:
return CheckType(int)(val).flat_map(
lambda v: _mkobj(str(v))
)
case FieldType.FLOAT:
return CheckType(float)(val).flat_map(
lambda v: _mkobj(str(v))
)
case FieldType.DATETIME:
return CheckType(datetime)(val).flat_map(
lambda v: _mkobj(stringify_dt(v))
)
class TagToTask(db.Model):
__tablename__: str = 'tag_to_task'
id : Mapped[int] = mapped_column(Integer, primary_key=True)
tag : Mapped[int] = mapped_column(Integer, ForeignKey('tag.id', ondelete='CASCADE', name='fk_t2t_tag'))
task : Mapped[int] = mapped_column(Integer, ForeignKey('task.id', ondelete='CASCADE', name='fk_t2t_task'))
# Run a check on import to catch invalid `FieldType` members.
for v in FieldType._member_names_:
if len(v) > FIELD_TYPE_NAME_MAX_LEN:
raise KeyError(f'New FieldType key {v} is too long! The current maximum is {FIELD_TYPE_NAME_MAX_LEN}. Please shorten it or, if you absolutely cannot shorten it, widen the column by increasing `taskflower.db.model.tag.FIELD_TYPE_NAME_MAX_LEN` and then create a database migration')

View file

@ -8,12 +8,23 @@ from taskflower.util.time import now
class Task(db.Model):
__tablename__: str = 'task'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64))
description: Mapped[str] = mapped_column(String)
due: Mapped[datetime] = mapped_column(DateTime(timezone=False))
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
complete: Mapped[bool] = mapped_column(Boolean, default=False)
completed: Mapped[datetime|None] = mapped_column(DateTime(timezone=False), nullable=True)
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE'))
owner: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
id : Mapped[int] = mapped_column(Integer, primary_key=True)
namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE'))
parent : Mapped[int|None] = mapped_column(Integer, ForeignKey('task.id', name='fk_task_parent'), nullable=True)
name : Mapped[str] = mapped_column(String(64))
description : Mapped[str] = mapped_column(String)
due : Mapped[datetime] = mapped_column(DateTime(timezone=False))
created : Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
complete : Mapped[bool] = mapped_column(Boolean, default=False)
completed : Mapped[datetime|None] = mapped_column(DateTime(timezone=False), nullable=True)
owner : Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))
class TaskAssignment(db.Model):
__tablename__: str = 'task_assignment'
id : Mapped[int] = mapped_column(Integer, primary_key=True)
task : Mapped[int] = mapped_column(Integer, ForeignKey('task.id', ondelete='CASCADE'))
user : Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE'))

View file

@ -8,7 +8,7 @@ from taskflower.auth.violations import check_for_auth_err_and_report
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 FormCEObjUserNS, FormCreatesObject
from taskflower.form import FormCEObjUserNS, FormCreatesObject, FormEditsObjectWithUser
from taskflower.types import ann
from taskflower.types.either import Either, Left, Right
@ -21,7 +21,8 @@ class NamespaceForm(FormCreatesObject[Namespace]):
min=1,
max=64,
message='Namespace title must be between 1 and 64 characters!'
)
),
DataRequired()
]
)
description: StringField = StringField(
@ -29,8 +30,10 @@ class NamespaceForm(FormCreatesObject[Namespace]):
[
Length(
min=1,
message='Namespace description must be at least 1 character long.'
)
max=1024,
message='Namespace description must be between 1 and 1024 characters long.'
),
DataRequired()
],
default='A namespace.'
)
@ -49,6 +52,61 @@ class NamespaceForm(FormCreatesObject[Namespace]):
ValidationError('Form data failed validation!')
)
def namespace_edit_form_for_ns(ns: Namespace) -> type[FormEditsObjectWithUser[Namespace]]:
class NamespaceEditForm(FormEditsObjectWithUser[Namespace]):
name: StringField = StringField(
'Namespace Title',
[
Length(
min=1,
max=64,
message='Namespace title must be between 1 and 64 characters!'
),
DataRequired()
],
default=ns.name
)
description: StringField = StringField(
'Namespace Description',
[
Length(
min=1,
max=1024,
message='Namespace description must be between 1 and 1024 characters long.'
),
DataRequired()
],
default=ns.description
)
@override
def edit_object(
self,
current_user: User,
target_object: Namespace
) -> Either[Exception, Namespace]:
def _do_edit(ns: Namespace) -> Either[Exception, Namespace]:
try:
ns.name = ann(self.name.data)
ns.description = ann(self.description.data)
return Right(ns)
except Exception as e:
return Left(e)
return assert_user_perms_on_namespace(
current_user,
target_object,
NPT.RENAME,
'Rename zone'
).flat_map(
lambda ns: _do_edit(ns)
).lside_effect(
check_for_auth_err_and_report
)
return NamespaceEditForm
def allowlist_and_denylist_from_form_data(
data: list[Field]
) -> tuple[NPT, NPT]:

View file

@ -132,36 +132,6 @@
--btn-green-active-brd : var(--green-bolder);
--btn-green-active-bg : var(--green-neutral-dark);
--btn-green-active-text : white;
/*--btn-green: #3ac579;
--btn-green-bg: #416350;
--btn-green-text: #fff;
--btn-green-hover: #00ff73;
--btn-green-hover-bg: #1f7946;
--btn-green-hover-text: #fff;
--btn-green-active: #00ff73;
--btn-green-hover-bg: #1f7946;
--btn-green-text-hover: #fff;*/
/*--btn-red: #f8476d;
--btn-red-hover: #ff0037;
--btn-red-bg: #5a363e;
--btn-red-hover-bg: #662231;
--btn-red-text: #fff;
--btn-yellow: #c9ba39;
--btn-yellow-hover: #ffe600;
--btn-yellow-bg: #615d3a;
--btn-yellow-hover-bg: #615914;
--btn-yellow-text: #fff;
--btn-green: #3ac579;
--btn-green-hover: #00ff73;
--btn-green-bg: #416350;
--btn-green-hover-bg: #1f7946;
--btn-green-text: #fff;*/
}
html {
@ -246,13 +216,13 @@ a {
}
#scelune-logo {
min-width: 8rem;
.icon svg {
fill: var(--fg);
stroke: var(--fg);
max-width: none;
max-height: none;
width: 8rem;
height: auto;
}
}
@ -392,8 +362,8 @@ h3 {
.icon svg {
stroke: var(--icon-color);
fill: var(--icon-color);
max-height: 1rem;
max-width: 1rem;
height: 1rem;
width: 1rem;
transition: all 0.1s;
display: block;
}
@ -427,6 +397,6 @@ a.icon-only-btn, .icon-only-btn.link-btn{
.icon svg {
stroke: var(--accent-1-hlt);
fill: var(--accent-1-hlt);
max-height: 1rem;
max-width: 1rem;
height: 1rem;
width: 1rem;
}

View file

@ -0,0 +1,46 @@
.v-tab-group {
display: flex;
flex-direction: row;
.v-tab-list {
flex: 0 0;
display: flex;
flex-direction: column;
label {
flex: 0 0;
width: max-content;
text-wrap: nowrap;
padding: 1rem 0.2rem;
border-radius: 0.5rem 0 0 0.5rem;
/* height: 1.5rem; */
writing-mode: sideways-lr;
background-color: var(--block-2-bg);
border: 1px solid var(--block-2-text);
color: var(--block-2-text);
cursor: pointer;
user-select: none;
input {
opacity: 0;
position: absolute;
pointer-events: none;
}
&:has(input:checked) {
background-color: var(--block-3-bg);
border: 1px solid var(--block-3-text);
color: var(--block-3-text);
}
}
}
.v-tab-contents {
flex: 1 0;
width: 100%;
padding: 1rem;
border: 2px solid var(--block-2-text);
}
}

View file

@ -18,7 +18,7 @@
<div id="scelune-logo" class="svg-filter-default">{{icon('scelune-logo-narrow')|safe}}</div>
</div>
<div class="footer-content">
{% block footer_content%}
{% block footer_content %}
<p>&copy; Copyright 2025 City of Scelune.</p>
<p>This program is open-source! See <a href="/license">this page</a> for details.</p>
{% endblock %}

View file

@ -0,0 +1,53 @@
{% extends "main.html" %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
<link rel="stylesheet" href={{ url_for("static", filename="tab-group.css") }} />
{% endblock %}
{% block title %}⚙ Zone Settings for {{namespace_name}}{% endblock %}
{% block main_content %}
<h1>Zone Settings for {{namespace_name}}</h1>
<a class="link-btn icon-btn" href="{{url_for('web.namespace.get', id=namespace_id)}}">{{icon('arrow-back')|safe}} Return</a>
<div class="v-tab-group">
<div class="v-tab-list" aria-label="Settings Tab Selector">
{% if general_page %}
<label><input type="radio" name="vtl1" id="vtl1-general" {{('checked' if active_tab == 'general' else '')|safe}}>General</label>
{% endif %}
{% if tfc_page %}
<label><input type="radio" name="vtl1" id="vtl1-tags" {{('checked' if active_tab == 'tags' else '')|safe}}>Tags, Fields & Categories</label>
{% endif %}
{% if role_page %}
<label><input type="radio" name="vtl1" id="vtl1-roles"{{('checked' if active_tab == 'roles' else '')|safe}}>Roles & Users</label>
{% endif %}
</div>
<div class="v-tab-contents">
{% if general_page %}
<div tabindex=0 id="tab-general">
{{general_page|safe}}
</div>
{% endif %}
{% if tfc_page %}
<div tabindex="0" id="tab-tags">
{{tfc_page|safe}}
</div>
{% endif %}
{% if role_page %}
<div tabindex="0" id="tab-roles">
{{ role_page|safe }}
</div>
{% endif %}
</div>
</div>
<style>
.v-tab-group:has(#vtl1-general:not(:checked)) #tab-general,
.v-tab-group:has(#vtl1-tags:not(:checked)) #tab-tags,
.v-tab-group:has(#vtl1-roles:not(:checked)) #tab-roles {
display: none;
}
</style>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% from "_formhelpers.html" import render_field with context %}
<h1>General Settings</h1>
<form action="{{url_for(
'web.namespace.admin.rename',
id=namespace_id,
next=cur_page_with_variables(
request,
active_tab='general'
)
)}}" method="post">
{{render_field(form.name)}}
{{render_field(form.description)}}
<button id="submit-form" class="icon-btn green" type="submit">{{icon('edit')|safe}} Edit Settings</button>
</form>

View file

@ -1,11 +1,21 @@
{% 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>
<a
class="icon-only-btn"
aria-label="Edit User"
href="{{
url_for(
'web.namespace.admin.edit_user',
id=namespace_id,
uid=user.id,
next=cur_page_with_variables(request, active_tab='roles')
)
}}"
>{{icon('cog')|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 %}
@ -19,20 +29,14 @@
</tr>
{% endmacro %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} />
{% endblock %}
<h1>Roles & Users</h1>
<h2>Roles</h2>
{% block title %}Roles for {{namespace_name}}{% endblock %}
{% block main_content %}
<h1>Roles for {{namespace_name}}</h1>
{{ role_list(roles, create_url) }}
{{ role_list(roles, create_url, next=cur_page_with_variables(request, active_tab='roles')) }}
<hr style="margin-top: 3rem;"/>
<h1>Users for {{namespace_name}}</h1>
<h2>Users</h2>
{% if invite_user_url %}
<a class="link-btn icon-btn" href="{{invite_user_url}}">{{icon('add')|safe}} Invite a new user</a>
@ -56,5 +60,3 @@
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -10,7 +10,7 @@
<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>
<a class="link-btn icon-btn" href="{{url_for('web.namespace.admin.settings', id=namespace.id)}}">{{icon('cog')|safe}} Zone Settings</a>
{% endif %}
<p>{{ namespace.description }}</p>

View file

@ -1,25 +1,25 @@
{% macro entry(role) %}
{% macro entry(role, next) %}
<tr>
<td class="pad-even">
{% if role.can_promote %}
<a href="{{url_for('web.namespace.role.promote', rid=role.id, next=request.path)}}" aria-label="Increase Priority" class="icon-only-btn promote-btn">{{icon('arrow-up')|safe}}</a>
<a href="{{url_for('web.namespace.role.promote', rid=role.id, next=next)}}" aria-label="Increase Priority" class="icon-only-btn promote-btn">{{icon('arrow-up')|safe}}</a>
{% endif %}
</td>
<td class="pad-even">
{% if role.can_demote %}
<a href="{{url_for('web.namespace.role.demote', rid=role.id, next=request.path)}}" aria-label="Decrease Priority" class="icon-only-btn demote-btn">{{icon('arrow-down')|safe}}</a>
<a href="{{url_for('web.namespace.role.demote', rid=role.id, next=next)}}" aria-label="Decrease Priority" class="icon-only-btn demote-btn">{{icon('arrow-down')|safe}}</a>
{% endif %}
</td>
<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>
<a href="{{url_for('web.namespace.role.edit', rid=role.id, next=next)}}" 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>
<a href="{{url_for('web.namespace.role.delete', rid=role.id, next=next)}}" 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 %}
@ -28,7 +28,7 @@
</tr>
{% endmacro %}
{% macro role_list( roles, create_url=None )%}
{% macro role_list( roles, create_url=None, next='')%}
{% if create_url %}
<a class="link-btn icon-btn" href="{{create_url}}">{{icon('add')|safe}} Create a new role</a>
{% endif %}
@ -49,7 +49,7 @@
<th>Role</th>
</tr>
{% for role in roles %}
{{entry(role)}}
{{entry(role, next)}}
{% endfor %}
</tbody>
</table>

View file

@ -37,8 +37,8 @@
svg {
margin: auto;
display: block;
max-width: 1.5rem;
max-height: 1.5rem;
width: 1.5rem;
height: 1.5rem;
}
}
}

View file

@ -0,0 +1,56 @@
<?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.4.2 (ebf0e940d0, 2025-05-08)"
sodipodi:docname="arrow-back.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="1920"
inkscape:window-height="1018"
inkscape:window-x="1600"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
inkscape:showpageshadow="0"
inkscape:deskcolor="#505050" />
<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 1.3118061,15.867507 a 2.0002,2.0002 0 0 0 0.585494,1.413867 l 9.9001709,9.900688 a 2,2 0 0 0 2.828251,0 2,2 0 0 0 0,-2.830318 L 8.0245751,17.752147 H 28.803667 a 1.88562,1.88562 0 0 0 1.884641,-1.88464 1.88562,1.88562 0 0 0 -1.884641,-1.884639 H 8.0245751 L 14.625722,7.3832711 a 2,2 0 0 0 0,-2.830319 2,2 0 0 0 -2.828251,0 L 1.8973001,14.45364 a 2.0002,2.0002 0 0 0 -0.585494,1.413867 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="100%"
height="100%"
viewBox="0 0 32 32"
version="1.1"
xml:space="preserve"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
id="svg2"
sodipodi:docname="cog.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
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"><defs
id="defs2" /><sodipodi:namedview
id="namedview2"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showgrid="false"
inkscape:zoom="24.4375"
inkscape:cx="16"
inkscape:cy="16"
inkscape:window-width="1680"
inkscape:window-height="988"
inkscape:window-x="3520"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<g
transform="matrix(0.0338701,0,0,0.0338701,-1.34151,-1.34151)"
id="g1"
style="stroke-width:0;stroke-dasharray:none">
<path
d="M580.397,44.585L592.137,166.265C630.239,175.097 666.619,190.166 699.806,210.863L794.148,133.124C830.913,160.503 863.497,193.087 890.876,229.852L813.137,324.194C833.834,357.381 848.903,393.761 857.735,431.863L979.415,443.603C986.052,488.96 986.052,535.04 979.415,580.397L857.735,592.137C848.903,630.239 833.834,666.619 813.137,699.806L890.876,794.148C863.497,830.913 830.913,863.497 794.148,890.876L699.806,813.137C666.619,833.834 630.239,848.903 592.137,857.735L580.397,979.415C535.04,986.052 488.96,986.052 443.603,979.415L431.863,857.735C393.761,848.903 357.381,833.834 324.194,813.137L229.852,890.876C193.087,863.497 160.503,830.913 133.124,794.148L210.863,699.806C190.166,666.619 175.097,630.239 166.265,592.137L44.585,580.397C37.948,535.04 37.948,488.96 44.585,443.603L166.265,431.863C175.097,393.761 190.166,357.381 210.863,324.194L133.124,229.852C160.503,193.087 193.087,160.503 229.852,133.124L324.194,210.863C357.381,190.166 393.761,175.097 431.863,166.265L443.603,44.585C488.96,37.948 535.04,37.948 580.397,44.585ZM512,314.201C621.168,314.201 709.799,402.832 709.799,512C709.799,621.168 621.168,709.799 512,709.799C402.832,709.799 314.201,621.168 314.201,512C314.201,402.832 402.832,314.201 512,314.201Z"
id="path1"
style="stroke-width:0;stroke-dasharray:none" />
</g>
<g
transform="matrix(0.0338701,0,0,0.0338701,-1.34151,-1.34151)"
id="g2"
style="stroke-width:0;stroke-dasharray:none">
<circle
cx="512"
cy="512"
r="94.672"
id="circle1"
style="stroke-width:0;stroke-dasharray:none" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -1,11 +1,12 @@
from http import HTTPStatus
from types import NoneType
from typing import Any, Callable, TypeAlias
from typing import Any, Callable, TypeAlias, get_origin, override
from flask import Response
from werkzeug.local import LocalProxy
from taskflower.db.model.user import User
from taskflower.types.either import Either, Left, Right
FlaskViewReturnType = (
Response
@ -34,3 +35,49 @@ def ann[T](x: T|None) -> T:
def assert_usr(current_user: LocalProxy[Any|None]) -> User: # pyright:ignore[reportExplicitAny]
return current_user # pyright:ignore[reportReturnType]
class AssertType[T]:
''' Used to assert that a given value MUST be of a certain type. This is
mostly just to make the type-checker happy - only use if you're SURE the
types already match. `AssertType` does basic type-checking, and raises
an `AssertionError` if the value fails the check.
'''
def __init__(self, t: type[T]):
self._t: type[T] = t
def __call__(self, x: T|Any) -> T: # pyright:ignore[reportExplicitAny]
try:
org = get_origin(self._t) or NoneType
if isinstance(x, org):
return x
else:
raise AssertionError(f'`AssertType({self._t})` type mismatch! `x` is `{repr(x)}`, but T is `{repr(self._t)}`')
except Exception:
return x
class UnsafeCoerceType[T](AssertType[T]):
''' As `AssertType[T]`, but it doesn't do ANY checking on the type. Only use
if you're absolutely sure the types need to be coerced (e.g. if using a
library function that does weird 'magic' with the types).
'''
@override
def __call__(self, x: T | Any) -> T: # pyright:ignore[reportExplicitAny]
return x
class CheckType[T]:
''' As `AssertType[T]`, but its `__call__()` returns an Either instead of
raising an exception.
'''
def __init__(self, t: type[T]):
self._t: type[T] = t
def __call__(self, x: T|Any) -> Either[Exception, T]: # pyright:ignore[reportExplicitAny]
try:
org = get_origin(self._t) or NoneType
if isinstance(x, org):
return Right[Exception, T](x)
else:
return Left[Exception, T](AssertionError(f'`AssertType({self._t})` type mismatch! `x` is `{repr(x)}`, but T is `{repr(self._t)}`'))
except Exception:
return Right[Exception, T](x)

View file

@ -23,7 +23,7 @@ class Either[L, R](ABC):
raise NotImplementedError
@abstractmethod
def lmap[X](self, f: Callable[[L], X]) -> 'Either[X, R]':
def lmap(self, f: Callable[[L], L]) -> 'Either[L, R]':
raise NotImplementedError()
@abstractmethod
@ -115,8 +115,8 @@ class Left[L, R](Either[L, R]):
return Left[L, X](self.val)
@override
def lmap[X](self, f: Callable[[L], X]) -> 'Either[X, R]':
return Left[X, R](f(self.val))
def lmap(self, f: Callable[[L], L]) -> 'Either[L, R]':
return Left[L, R](f(self.val))
@override
def side_effect[X](self, f: Callable[[R], X]) -> 'Either[L, R]':
@ -190,8 +190,8 @@ class Right[L, R](Either[L, R]):
return Right[L, X](f(self.val))
@override
def lmap[X](self, f: Callable[[L], X]) -> 'Either[X, R]':
return Right[X, R](self.val)
def lmap(self, f: Callable[[L], L]) -> 'Either[L, R]':
return Right[L, R](self.val)
@override
def side_effect[X](self, f: Callable[[R], X]) -> 'Either[L, R]':

View file

@ -33,6 +33,35 @@ def ensure_timezone_aware(dt: TAD|TND) -> TAD:
def now():
return datetime.now(timezone.utc)
DB_DTSTR_FMT = '%Y-%m-%d %H:%M:%S'
def stringify_dt(dt: TAD) -> str:
if not is_timezone_aware(dt):
log.warning(f'[`taskflower.util.time.stringify`] Asked to stringify a non-timezone-aware datetime object `{dt!s}`! Assuming it\'s UTC!')
return ensure_timezone_aware(
dt
).astimezone(
timezone.utc
).strftime(
DB_DTSTR_FMT
)
def destringify_dt(raw: str) -> Either[Exception, TAD]:
''' This is meant for parsing datetimes stored by `stringify_dt()`, not
user-provided strings. For the latter, use
`from_sophont_provided_data()`
'''
try:
return Right(datetime.strptime(
raw,
DB_DTSTR_FMT
).replace(
tzinfo=timezone.utc
))
except Exception as e:
return Left(e)
def get_reltime(until: TAD|TND) -> timedelta:
return ensure_timezone_aware(until) - now()

View file

@ -16,6 +16,7 @@ from taskflower.sanitize.task import TaskForUser
from taskflower.types.either import Either, Left, Right, gather_successes
from taskflower.types.option import Option
from taskflower.web.errors import ResponseErrorNotFound, response_from_exception
from taskflower.web.namespace.admin import web_namespace_admin
from taskflower.web.namespace.roles import web_namespace_roles
web_namespace = Blueprint(
@ -26,6 +27,7 @@ web_namespace = Blueprint(
)
web_namespace.register_blueprint(web_namespace_roles)
web_namespace.register_blueprint(web_namespace_admin)
@web_namespace.app_context_processor
def namespace_processor():

View file

@ -0,0 +1,231 @@
from dataclasses import dataclass
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 NPT
from taskflower.auth.permission.checks import assert_user_can_ns_administrate_user, assert_user_perms_on_namespace, check_user_can_edit_role, check_user_has_ns_role, check_user_perms_on_namespace
from taskflower.auth.permission.lookups import get_all_roles_on_ns
from taskflower.auth.violations import check_for_auth_err_and_report
from taskflower.db import db, db_fetch_by_id, do_commit
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.role import NamespaceRole
from taskflower.db.model.user import User
from taskflower.form.namespace import namespace_edit_form_for_ns
from taskflower.types import assert_usr
from taskflower.types.either import Either, Right
from taskflower.web.errors import response_from_exception
from taskflower.web.namespace.roles import render_role_admin_page
from taskflower.web.utils.request import get_next
web_namespace_admin = Blueprint(
'admin',
__name__,
url_prefix='/<int:id>/admin'
)
def render_general_page(
ns: Namespace,
cur_usr: User
) -> Either[Exception, str]:
return assert_user_perms_on_namespace(
cur_usr,
ns,
NPT.RENAME,
'Access settings'
).map(
lambda _: namespace_edit_form_for_ns(
ns
)()
).map(
lambda form: render_template(
'namespace/admin/general_settings_page.html',
form=form,
namespace_id=ns.id
)
)
@web_namespace_admin.route('/')
@login_required
def settings(id: int):
cur_usr = assert_usr(current_user)
active_tab = (
request.args['active_tab']
if 'active_tab' in request.args.keys()
else None
)
res = db_fetch_by_id(
Namespace,
id,
db
).map(
lambda ns: (
ns,
render_role_admin_page(
ns,
cur_usr
).and_then(
lambda val: val,
lambda exc: None
),
render_general_page(
ns,
cur_usr
).and_then(
lambda val: val,
lambda exc: None
)
)
)
if isinstance(res, Right):
ns, role_page, general_page = res.val
tfc_page = '<h1>Tags, Fields and Categories</h1>'
if not active_tab:
active_tab = (
(
'general'
) if general_page else (
'tags'
) if tfc_page else (
'roles'
)
)
return render_template(
'namespace/admin/base.html',
general_page=general_page,
tfc_page='<h1>Tags, Fields and Categories</h1>',
role_page=role_page,
namespace_id=ns.id,
namespace_name=ns.name,
active_tab=active_tab
)
else:
return response_from_exception(res.assert_left().val)
@web_namespace_admin.route('/user/<int:uid>/edit')
@login_required
def edit_user(id: 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,
id,
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/edit_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)
)
@web_namespace_admin.route('/rename', methods=['POST'])
def rename(id: int):
cur_usr = assert_usr(current_user)
next = get_next(
request,
dflt:=url_for(
'web.namespace.admin.settings',
id=id
)
).and_then(
lambda v: v,
lambda exc: dflt
)
res = db_fetch_by_id(
Namespace,
id,
db
).flat_map(
lambda ns: assert_user_perms_on_namespace(
cur_usr,
ns,
NPT.RENAME,
'Rename namespace'
)
)
if isinstance(res, Right):
ns = res.val
form_data = namespace_edit_form_for_ns(ns)(request.form)
if form_data.validate():
return form_data.edit_object(
cur_usr,
ns
).flat_map(
lambda _: do_commit(db)
).and_then(
lambda _: redirect(next),
lambda exc: response_from_exception(exc)
)
else:
return render_template(
'namespace/admin/general_settings_page.html',
form=form_data,
namespace_id=ns.id
)
else:
return response_from_exception(res.assert_left().val)

View file

@ -4,8 +4,8 @@ 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 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.permission.checks import assert_user_can_edit_role, assert_user_can_ns_administrate_user, assert_user_perms_on_namespace, check_user_can_ns_administrate_user
from taskflower.auth.permission.lookups import 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
@ -26,10 +26,10 @@ web_namespace_roles = Blueprint(
url_prefix=''
)
@web_namespace_roles.route('/<int:id>/role')
@login_required
def all(id: int):
cur_usr = assert_usr(current_user)
def render_role_admin_page(
ns: Namespace,
cur_usr: User
):
def _fetch_roles(
ns: Namespace
) -> Either[Exception, list[NamespaceRole]]:
@ -70,18 +70,11 @@ def all(id: int):
for usr in get_all_users_on_ns(ns)
]
return db_fetch_by_id(
Namespace,
id,
db
).flat_map(
lambda ns: assert_user_perms_on_namespace(
return assert_user_perms_on_namespace(
cur_usr,
ns,
NamespacePermissionType.EDIT_ROLES,
NPT.EDIT_ROLES,
'View role list'
).lside_effect(
check_for_auth_err_and_report
).flat_map(
_fetch_roles
).map(
@ -95,19 +88,14 @@ def all(id: int):
for dex, r in enumerate(roles)
]
).map(
lambda roles: (ns, roles, _fetch_users(ns))
)
).and_then(
lambda data: render_template(
'role/namespace/list.html',
namespace_name=data[0].name,
namespace_id=data[0].id,
roles=data[1],
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)
'namespace/admin/role_user_page.html',
roles=data,
users=_fetch_users(ns),
namespace_id=ns.id,
create_url=url_for('web.namespace.role.new', id=ns.id, next=url_for('web.namespace.admin.settings', id=ns.id, active_tab='roles')),
invite_user_url=url_for('web.invite.new_zone_invite_inner', id=ns.id)
)
)
@web_namespace_roles.route('/<int:id>/role/new', methods=['GET', 'POST'])
@ -116,10 +104,10 @@ def new(id: int):
cur_usr = assert_usr(current_user)
next = get_next(
request,
url_for('web.namespace.role.all', id=id)
url_for('web.namespace.admin.settings', id=id)
).and_then(
lambda val: val,
lambda exc: url_for('web.namespace.role.all', id=id)
lambda exc: url_for('web.namespace.admin.settings', id=id)
)
res = db_fetch_by_id(
@ -162,7 +150,7 @@ def new(id: int):
)
else:
return render_template(
'role/namespace/new_or_edit.html',
'namespace/admin/edit_role.html',
form=form_data,
action='CREATE'
)
@ -276,10 +264,10 @@ def edit(rid: int):
cur_usr = assert_usr(current_user)
next = get_next(
request,
url_for('web.namespace.role.all', id=rid)
url_for('web.namespace.admin.settings', id=rid)
).and_then(
lambda val: val,
lambda exc: url_for('web.namespace.role.all', id=rid)
lambda exc: url_for('web.namespace.admin.settings', id=rid)
)
res = db_fetch_by_id(
@ -336,7 +324,7 @@ def edit(rid: int):
)
else:
return render_template(
'role/namespace/new_or_edit.html',
'namespace/admin/edit_role.html',
form=form_data,
action='EDIT'
)
@ -375,80 +363,6 @@ def delete(rid: int):
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()
@ -591,12 +505,12 @@ def assign_unassign_role(
lambda exc: response_from_exception(exc)
)
@web_namespace_roles.route('/<int:nid>/user/<int:uid>/admin/assign-role/<int:rid>')
@web_namespace_roles.route('/<int:nid>/admin/user/<int:uid>/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>')
@web_namespace_roles.route('/<int:nid>/user/<int:uid>/unassign-role/<int:rid>')
@login_required
def unassign_role(nid: int, uid: int, rid: int):
return assign_unassign_role(nid, uid, rid, RoleAsnAction.UNASSIGN)