Rearrange settings; migrate database in prep for tags/fields/categories
This commit is contained in:
parent
35b53653a1
commit
05f47c71af
27 changed files with 995 additions and 238 deletions
|
|
@ -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 ###
|
||||
|
|
@ -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('/')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
|
||||
|
|
|
|||
173
src/taskflower/db/model/tag.py
Normal file
173
src/taskflower/db/model/tag.py
Normal 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')
|
||||
|
|
@ -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'))
|
||||
|
|
@ -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,16 +21,19 @@ class NamespaceForm(FormCreatesObject[Namespace]):
|
|||
min=1,
|
||||
max=64,
|
||||
message='Namespace title must be between 1 and 64 characters!'
|
||||
)
|
||||
),
|
||||
DataRequired()
|
||||
]
|
||||
)
|
||||
description: StringField = StringField(
|
||||
'Namespace Description',
|
||||
[
|
||||
Length(
|
||||
min=1,
|
||||
message='Namespace description must be at least 1 character long.'
|
||||
)
|
||||
min=1,
|
||||
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]:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
46
src/taskflower/static/tab-group.css
Normal file
46
src/taskflower/static/tab-group.css
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>© Copyright 2025 City of Scelune.</p>
|
||||
<p>This program is open-source! See <a href="/license">this page</a> for details.</p>
|
||||
{% endblock %}
|
||||
|
|
|
|||
53
src/taskflower/templates/namespace/admin/base.html
Normal file
53
src/taskflower/templates/namespace/admin/base.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@
|
|||
svg {
|
||||
margin: auto;
|
||||
display: block;
|
||||
max-width: 1.5rem;
|
||||
max-height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
src/taskflower/tools/icons/raw/arrow-back.svg
Normal file
56
src/taskflower/tools/icons/raw/arrow-back.svg
Normal 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 |
55
src/taskflower/tools/icons/raw/cog.svg
Normal file
55
src/taskflower/tools/icons/raw/cog.svg
Normal 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 |
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]':
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
231
src/taskflower/web/namespace/admin.py
Normal file
231
src/taskflower/web/namespace/admin.py
Normal 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)
|
||||
|
|
@ -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,25 +26,25 @@ 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]]:
|
||||
try:
|
||||
return Right(
|
||||
db.session.query(
|
||||
NamespaceRole
|
||||
).filter(
|
||||
NamespaceRole.namespace == ns.id
|
||||
).order_by(
|
||||
NamespaceRole.priority.asc()
|
||||
).all()
|
||||
)
|
||||
except Exception as e:
|
||||
return Left(e)
|
||||
ns: Namespace
|
||||
) -> Either[Exception, list[NamespaceRole]]:
|
||||
try:
|
||||
return Right(
|
||||
db.session.query(
|
||||
NamespaceRole
|
||||
).filter(
|
||||
NamespaceRole.namespace == ns.id
|
||||
).order_by(
|
||||
NamespaceRole.priority.asc()
|
||||
).all()
|
||||
)
|
||||
except Exception as e:
|
||||
return Left(e)
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserData:
|
||||
|
|
@ -70,44 +70,32 @@ def all(id: int):
|
|||
for usr in get_all_users_on_ns(ns)
|
||||
]
|
||||
|
||||
return db_fetch_by_id(
|
||||
Namespace,
|
||||
id,
|
||||
db
|
||||
return assert_user_perms_on_namespace(
|
||||
cur_usr,
|
||||
ns,
|
||||
NPT.EDIT_ROLES,
|
||||
'View role list'
|
||||
).flat_map(
|
||||
lambda ns: assert_user_perms_on_namespace(
|
||||
cur_usr,
|
||||
ns,
|
||||
NamespacePermissionType.EDIT_ROLES,
|
||||
'View role list'
|
||||
).lside_effect(
|
||||
check_for_auth_err_and_report
|
||||
).flat_map(
|
||||
_fetch_roles
|
||||
).map(
|
||||
lambda roles: [
|
||||
NamespaceRoleForUser.from_role(
|
||||
r,
|
||||
cur_usr,
|
||||
dex==0,
|
||||
dex==len(roles)-1
|
||||
)
|
||||
for dex, r in enumerate(roles)
|
||||
]
|
||||
).map(
|
||||
lambda roles: (ns, roles, _fetch_users(ns))
|
||||
)
|
||||
).and_then(
|
||||
_fetch_roles
|
||||
).map(
|
||||
lambda roles: [
|
||||
NamespaceRoleForUser.from_role(
|
||||
r,
|
||||
cur_usr,
|
||||
dex==0,
|
||||
dex==len(roles)-1
|
||||
)
|
||||
for dex, r in enumerate(roles)
|
||||
]
|
||||
).map(
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue