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 import logging
from typing import Any 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 flask_migrate import Migrate
from taskflower.auth import taskflower_login_manager from taskflower.auth import taskflower_login_manager
from taskflower.auth.startup import startup_checks
from taskflower.config import SignUpMode, config from taskflower.config import SignUpMode, config
from taskflower.db import db from taskflower.db import db
from taskflower.api import APIBase from taskflower.api import APIBase
from taskflower.db.model.user import User from taskflower.db.model.user import User
from taskflower.sanitize.markdown import SafeHTML, render_and_sanitize from taskflower.sanitize.markdown import SafeHTML, render_and_sanitize
from taskflower.tools.icons import get_icon, svg_bp 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.util.time import render_abstime, render_reltime
from taskflower.web import web_base from taskflower.web import web_base
@ -44,6 +47,17 @@ app.register_blueprint(svg_bp)
APIBase.register(app) APIBase.register(app)
# print(f'Routes: \n{"\n".join([str(r) for r in APIBase.routes])}') # 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 @app.context_processor
def template_utility_fns(): def template_utility_fns():
def literal_call(fname: str, *args: Any) -> str: # pyright:ignore[reportAny,reportExplicitAny] 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>' 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( return dict(
literal_call=literal_call, literal_call=literal_call,
reltime=render_reltime, reltime=render_reltime,
abstime=render_abstime, abstime=render_abstime,
can_generate_sign_up_codes=can_generate_sign_up_codes, can_generate_sign_up_codes=can_generate_sign_up_codes,
render_as_markdown=render_as_markdown, render_as_markdown=render_as_markdown,
icon=icon icon=icon,
cur_page_with_variables=cur_page_with_variables
) )
@app.route('/') @app.route('/')

View file

@ -1,21 +1,8 @@
import logging import logging
from taskflower import app from taskflower import app
from taskflower.auth.startup import startup_checks
from taskflower.config import config from taskflower.config import config
from taskflower.db import db
from taskflower.types.either import Left
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
if __name__ == '__main__': 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) app.run(debug=config.debug)

View file

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

View file

@ -108,7 +108,7 @@ class HIBPLocalCacheMode(EnumFromEnv):
@dataclass(frozen=True) @dataclass(frozen=True)
class ConfigType: class ConfigType:
# Application secrets # 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. # be generated randomly and cryptographically securely.
# For an example of how to do this: # For an example of how to do this:
# https://flask.palletsprojects.com/en/stable/config/#SECRET_KEY # 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 # In a multi-node environment, this key must be the same
# across all nodes. # 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 # ALSO be generated securely, and it should be different from
# ``db_secret`` # ``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): class Task(db.Model):
__tablename__: str = 'task' __tablename__: str = 'task'
id: Mapped[int] = mapped_column(Integer, primary_key=True) id : Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(64)) namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE'))
description: Mapped[str] = mapped_column(String) parent : Mapped[int|None] = mapped_column(Integer, ForeignKey('task.id', name='fk_task_parent'), nullable=True)
due: Mapped[datetime] = mapped_column(DateTime(timezone=False))
created: Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now) name : Mapped[str] = mapped_column(String(64))
complete: Mapped[bool] = mapped_column(Boolean, default=False) description : Mapped[str] = mapped_column(String)
completed: Mapped[datetime|None] = mapped_column(DateTime(timezone=False), nullable=True)
namespace: Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE')) due : Mapped[datetime] = mapped_column(DateTime(timezone=False))
owner: Mapped[int] = mapped_column(Integer, ForeignKey('user.id', ondelete='CASCADE')) 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.namespace import Namespace
from taskflower.db.model.role import NamespaceRole from taskflower.db.model.role import NamespaceRole
from taskflower.db.model.user import User 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 import ann
from taskflower.types.either import Either, Left, Right from taskflower.types.either import Either, Left, Right
@ -21,16 +21,19 @@ class NamespaceForm(FormCreatesObject[Namespace]):
min=1, min=1,
max=64, max=64,
message='Namespace title must be between 1 and 64 characters!' message='Namespace title must be between 1 and 64 characters!'
) ),
DataRequired()
] ]
) )
description: StringField = StringField( description: StringField = StringField(
'Namespace Description', 'Namespace Description',
[ [
Length( Length(
min=1, 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.' default='A namespace.'
) )
@ -49,6 +52,61 @@ class NamespaceForm(FormCreatesObject[Namespace]):
ValidationError('Form data failed validation!') 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( def allowlist_and_denylist_from_form_data(
data: list[Field] data: list[Field]
) -> tuple[NPT, NPT]: ) -> tuple[NPT, NPT]:

View file

@ -132,36 +132,6 @@
--btn-green-active-brd : var(--green-bolder); --btn-green-active-brd : var(--green-bolder);
--btn-green-active-bg : var(--green-neutral-dark); --btn-green-active-bg : var(--green-neutral-dark);
--btn-green-active-text : white; --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 { html {
@ -246,13 +216,13 @@ a {
} }
#scelune-logo { #scelune-logo {
min-width: 8rem;
.icon svg { .icon svg {
fill: var(--fg); fill: var(--fg);
stroke: var(--fg); stroke: var(--fg);
max-width: none; max-width: none;
max-height: none; max-height: none;
width: 8rem;
height: auto;
} }
} }
@ -392,8 +362,8 @@ h3 {
.icon svg { .icon svg {
stroke: var(--icon-color); stroke: var(--icon-color);
fill: var(--icon-color); fill: var(--icon-color);
max-height: 1rem; height: 1rem;
max-width: 1rem; width: 1rem;
transition: all 0.1s; transition: all 0.1s;
display: block; display: block;
} }
@ -427,6 +397,6 @@ a.icon-only-btn, .icon-only-btn.link-btn{
.icon svg { .icon svg {
stroke: var(--accent-1-hlt); stroke: var(--accent-1-hlt);
fill: var(--accent-1-hlt); fill: var(--accent-1-hlt);
max-height: 1rem; height: 1rem;
max-width: 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 id="scelune-logo" class="svg-filter-default">{{icon('scelune-logo-narrow')|safe}}</div>
</div> </div>
<div class="footer-content"> <div class="footer-content">
{% block footer_content%} {% block footer_content %}
<p>&copy; Copyright 2025 City of Scelune.</p> <p>&copy; Copyright 2025 City of Scelune.</p>
<p>This program is open-source! See <a href="/license">this page</a> for details.</p> <p>This program is open-source! See <a href="/license">this page</a> for details.</p>
{% endblock %} {% 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 %} {% from "role/_rolelist.html" import role_list with context %}
{% macro user_entry(user) %} {% macro user_entry(user) %}
<tr> <tr>
<td class="pad-even"> <td class="pad-even">
{% if user.can_edit %} {% 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 %} {% 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> <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 %} {% endif %}
@ -19,20 +29,14 @@
</tr> </tr>
{% endmacro %} {% endmacro %}
{% block head_extras %} <h1>Roles & Users</h1>
<link rel="stylesheet" href={{ url_for("static", filename="list-view.css") }} /> <h2>Roles</h2>
{% endblock %}
{% block title %}Roles for {{namespace_name}}{% endblock %} {{ role_list(roles, create_url, next=cur_page_with_variables(request, active_tab='roles')) }}
{% block main_content %}
<h1>Roles for {{namespace_name}}</h1>
{{ role_list(roles, create_url) }}
<hr style="margin-top: 3rem;"/> <hr style="margin-top: 3rem;"/>
<h1>Users for {{namespace_name}}</h1> <h2>Users</h2>
{% if invite_user_url %} {% if invite_user_url %}
<a class="link-btn icon-btn" href="{{invite_user_url}}">{{icon('add')|safe}} Invite a new user</a> <a class="link-btn icon-btn" href="{{invite_user_url}}">{{icon('add')|safe}} Invite a new user</a>
@ -55,6 +59,4 @@
{{ user_entry(user) }} {{ user_entry(user) }}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endblock %}

View file

@ -10,7 +10,7 @@
<h1>{{ namespace.name }}</h1> <h1>{{ namespace.name }}</h1>
{% if namespace.perms.edit_roles or namespace.perms.administrate %} {% 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 %} {% endif %}
<p>{{ namespace.description }}</p> <p>{{ namespace.description }}</p>

View file

@ -1,25 +1,25 @@
{% macro entry(role) %} {% macro entry(role, next) %}
<tr> <tr>
<td class="pad-even"> <td class="pad-even">
{% if role.can_promote %} {% 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 %} {% endif %}
</td> </td>
<td class="pad-even"> <td class="pad-even">
{% if role.can_demote %} {% 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 %} {% endif %}
</td> </td>
<td class="pad-even"> <td class="pad-even">
{% if role.can_edit %} {% if role.can_edit %}
<a href="{{url_for('web.namespace.role.edit', rid=role.id, next=request.path)}}" aria-label="Edit Role" class="icon-only-btn">{{icon('edit')|safe}}</a> <a href="{{url_for('web.namespace.role.edit', rid=role.id, next=next)}}" aria-label="Edit Role" class="icon-only-btn">{{icon('edit')|safe}}</a>
{% else %} {% 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> <a aria-label="Can't Edit Role" title="You don't have permission to edit this role." class="icon-only-btn disabled">{{icon('not-allowed')|safe}}</a>
{% endif %} {% endif %}
</td> </td>
<td class="pad-even"> <td class="pad-even">
{% if role.can_delete %} {% if role.can_delete %}
<a href="{{url_for('web.namespace.role.delete', rid=role.id, next=request.path)}}" aria-label="Delete Role" class="icon-only-btn red">{{icon('delete')|safe}}</a> <a href="{{url_for('web.namespace.role.delete', rid=role.id, next=next)}}" aria-label="Delete Role" class="icon-only-btn red">{{icon('delete')|safe}}</a>
{% else %} {% 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> <a aria-label="Can't Delete Role" title="You don't have permission to delete this role." class="icon-only-btn disabled">{{icon('not-allowed')|safe}}</a>
{% endif %} {% endif %}
@ -28,7 +28,7 @@
</tr> </tr>
{% endmacro %} {% endmacro %}
{% macro role_list( roles, create_url=None )%} {% macro role_list( roles, create_url=None, next='')%}
{% if create_url %} {% if create_url %}
<a class="link-btn icon-btn" href="{{create_url}}">{{icon('add')|safe}} Create a new role</a> <a class="link-btn icon-btn" href="{{create_url}}">{{icon('add')|safe}} Create a new role</a>
{% endif %} {% endif %}
@ -49,7 +49,7 @@
<th>Role</th> <th>Role</th>
</tr> </tr>
{% for role in roles %} {% for role in roles %}
{{entry(role)}} {{entry(role, next)}}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View file

@ -37,8 +37,8 @@
svg { svg {
margin: auto; margin: auto;
display: block; display: block;
max-width: 1.5rem; width: 1.5rem;
max-height: 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 http import HTTPStatus
from types import NoneType from types import NoneType
from typing import Any, Callable, TypeAlias from typing import Any, Callable, TypeAlias, get_origin, override
from flask import Response from flask import Response
from werkzeug.local import LocalProxy from werkzeug.local import LocalProxy
from taskflower.db.model.user import User from taskflower.db.model.user import User
from taskflower.types.either import Either, Left, Right
FlaskViewReturnType = ( FlaskViewReturnType = (
Response Response
@ -33,4 +34,50 @@ def ann[T](x: T|None) -> T:
raise AssertionError('``ann()`` called on None!') raise AssertionError('``ann()`` called on None!')
def assert_usr(current_user: LocalProxy[Any|None]) -> User: # pyright:ignore[reportExplicitAny] def assert_usr(current_user: LocalProxy[Any|None]) -> User: # pyright:ignore[reportExplicitAny]
return current_user # pyright:ignore[reportReturnType] 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 raise NotImplementedError
@abstractmethod @abstractmethod
def lmap[X](self, f: Callable[[L], X]) -> 'Either[X, R]': def lmap(self, f: Callable[[L], L]) -> 'Either[L, R]':
raise NotImplementedError() raise NotImplementedError()
@abstractmethod @abstractmethod
@ -115,8 +115,8 @@ class Left[L, R](Either[L, R]):
return Left[L, X](self.val) return Left[L, X](self.val)
@override @override
def lmap[X](self, f: Callable[[L], X]) -> 'Either[X, R]': def lmap(self, f: Callable[[L], L]) -> 'Either[L, R]':
return Left[X, R](f(self.val)) return Left[L, R](f(self.val))
@override @override
def side_effect[X](self, f: Callable[[R], X]) -> 'Either[L, R]': 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)) return Right[L, X](f(self.val))
@override @override
def lmap[X](self, f: Callable[[L], X]) -> 'Either[X, R]': def lmap(self, f: Callable[[L], L]) -> 'Either[L, R]':
return Right[X, R](self.val) return Right[L, R](self.val)
@override @override
def side_effect[X](self, f: Callable[[R], X]) -> 'Either[L, R]': 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(): def now():
return datetime.now(timezone.utc) 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: def get_reltime(until: TAD|TND) -> timedelta:
return ensure_timezone_aware(until) - now() 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.either import Either, Left, Right, gather_successes
from taskflower.types.option import Option from taskflower.types.option import Option
from taskflower.web.errors import ResponseErrorNotFound, response_from_exception 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 from taskflower.web.namespace.roles import web_namespace_roles
web_namespace = Blueprint( web_namespace = Blueprint(
@ -26,6 +27,7 @@ web_namespace = Blueprint(
) )
web_namespace.register_blueprint(web_namespace_roles) web_namespace.register_blueprint(web_namespace_roles)
web_namespace.register_blueprint(web_namespace_admin)
@web_namespace.app_context_processor @web_namespace.app_context_processor
def namespace_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 flask_login import current_user, login_required # pyright:ignore[reportUnknownVariableType,reportMissingTypeStubs]
from taskflower.auth.permission import NPT, NamespacePermissionType 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.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_roles_on_ns, get_all_users_on_ns, get_namespace_role_above, get_namespace_role_below 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.auth.violations import check_for_auth_err_and_report
from taskflower.db import db, db_fetch_by_id, do_commit, do_delete, insert_into_db from taskflower.db import db, db_fetch_by_id, do_commit, do_delete, insert_into_db
from taskflower.db.model.namespace import Namespace from taskflower.db.model.namespace import Namespace
@ -26,25 +26,25 @@ web_namespace_roles = Blueprint(
url_prefix='' url_prefix=''
) )
@web_namespace_roles.route('/<int:id>/role') def render_role_admin_page(
@login_required ns: Namespace,
def all(id: int): cur_usr: User
cur_usr = assert_usr(current_user) ):
def _fetch_roles( def _fetch_roles(
ns: Namespace ns: Namespace
) -> Either[Exception, list[NamespaceRole]]: ) -> Either[Exception, list[NamespaceRole]]:
try: try:
return Right( return Right(
db.session.query( db.session.query(
NamespaceRole NamespaceRole
).filter( ).filter(
NamespaceRole.namespace == ns.id NamespaceRole.namespace == ns.id
).order_by( ).order_by(
NamespaceRole.priority.asc() NamespaceRole.priority.asc()
).all() ).all()
) )
except Exception as e: except Exception as e:
return Left(e) return Left(e)
@dataclass(frozen=True) @dataclass(frozen=True)
class UserData: class UserData:
@ -70,44 +70,32 @@ def all(id: int):
for usr in get_all_users_on_ns(ns) for usr in get_all_users_on_ns(ns)
] ]
return db_fetch_by_id( return assert_user_perms_on_namespace(
Namespace, cur_usr,
id, ns,
db NPT.EDIT_ROLES,
'View role list'
).flat_map( ).flat_map(
lambda ns: assert_user_perms_on_namespace( _fetch_roles
cur_usr, ).map(
ns, lambda roles: [
NamespacePermissionType.EDIT_ROLES, NamespaceRoleForUser.from_role(
'View role list' r,
).lside_effect( cur_usr,
check_for_auth_err_and_report dex==0,
).flat_map( dex==len(roles)-1
_fetch_roles )
).map( for dex, r in enumerate(roles)
lambda roles: [ ]
NamespaceRoleForUser.from_role( ).map(
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(
lambda data: render_template( lambda data: render_template(
'role/namespace/list.html', 'namespace/admin/role_user_page.html',
namespace_name=data[0].name, roles=data,
namespace_id=data[0].id, users=_fetch_users(ns),
roles=data[1], namespace_id=ns.id,
users=data[2], create_url=url_for('web.namespace.role.new', id=ns.id, next=url_for('web.namespace.admin.settings', id=ns.id, active_tab='roles')),
create_url=url_for('web.namespace.role.new', id=id), invite_user_url=url_for('web.invite.new_zone_invite_inner', id=ns.id)
invite_user_url=url_for('web.invite.new_zone_invite_inner', id=data[0].id) )
),
lambda exc: response_from_exception(exc)
) )
@web_namespace_roles.route('/<int:id>/role/new', methods=['GET', 'POST']) @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) cur_usr = assert_usr(current_user)
next = get_next( next = get_next(
request, request,
url_for('web.namespace.role.all', id=id) url_for('web.namespace.admin.settings', id=id)
).and_then( ).and_then(
lambda val: val, 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( res = db_fetch_by_id(
@ -162,7 +150,7 @@ def new(id: int):
) )
else: else:
return render_template( return render_template(
'role/namespace/new_or_edit.html', 'namespace/admin/edit_role.html',
form=form_data, form=form_data,
action='CREATE' action='CREATE'
) )
@ -276,10 +264,10 @@ def edit(rid: int):
cur_usr = assert_usr(current_user) cur_usr = assert_usr(current_user)
next = get_next( next = get_next(
request, request,
url_for('web.namespace.role.all', id=rid) url_for('web.namespace.admin.settings', id=rid)
).and_then( ).and_then(
lambda val: val, 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( res = db_fetch_by_id(
@ -336,7 +324,7 @@ def edit(rid: int):
) )
else: else:
return render_template( return render_template(
'role/namespace/new_or_edit.html', 'namespace/admin/edit_role.html',
form=form_data, form=form_data,
action='EDIT' action='EDIT'
) )
@ -375,80 +363,6 @@ def delete(rid: int):
lambda exc: response_from_exception(exc) lambda exc: response_from_exception(exc)
) )
@web_namespace_roles.route('/<int:nid>/user/<int:uid>/admin')
@login_required
def admin(nid: int, uid: int):
cur_usr = assert_usr(current_user)
@dataclass(frozen=True)
class RoleData:
id: int
name: str
assigned: bool
can_assign: bool
can_unassign: bool
@dataclass(frozen=True)
class UserAndNS:
user: User
ns: Namespace
return db_fetch_by_id(
Namespace,
nid,
db
).flat_map(
lambda ns: db_fetch_by_id(
User,
uid,
db
).map(
lambda usr: UserAndNS(usr, ns)
)
).flat_map(
lambda usr_ns: assert_user_perms_on_namespace(
cur_usr,
usr_ns.ns,
NPT.EDIT_ROLES,
f'Administrate user {usr_ns.user.username} (id {usr_ns.user.id}) in namespace context {usr_ns.ns.name} (id {usr_ns.ns.id})'
).map(lambda _: usr_ns)
).flat_map(
lambda user_ns: assert_user_can_ns_administrate_user(
cur_usr,
user_ns.user,
user_ns.ns,
'Access administration interface'
).map(lambda _: user_ns)
).lside_effect(
check_for_auth_err_and_report
).flat_map(
lambda user_ns: Right[Exception, list[NamespaceRole]](get_all_roles_on_ns(
user_ns.ns
)).map(
lambda roles: (user_ns, [
RoleData(
role.id,
role.name,
check_user_has_ns_role(user_ns.user, role),
(can_as_un:=check_user_can_edit_role(cur_usr, role)),
can_as_un
)
for role in roles
])
)
).and_then(
lambda res: render_template(
'namespace/admin/admin_user.html',
user_name=res[0].user.display_name,
user_id=res[0].user.id,
namespace_id=res[0].ns.id,
roles=res[1]
),
lambda exc: response_from_exception(exc)
)
class RoleAsnAction(Enum): class RoleAsnAction(Enum):
ASSIGN = auto() ASSIGN = auto()
UNASSIGN = auto() UNASSIGN = auto()
@ -591,12 +505,12 @@ def assign_unassign_role(
lambda exc: response_from_exception(exc) 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 @login_required
def assign_role(nid: int, uid: int, rid: int): def assign_role(nid: int, uid: int, rid: int):
return assign_unassign_role(nid, uid, rid, RoleAsnAction.ASSIGN) 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 @login_required
def unassign_role(nid: int, uid: int, rid: int): def unassign_role(nid: int, uid: int, rid: int):
return assign_unassign_role(nid, uid, rid, RoleAsnAction.UNASSIGN) return assign_unassign_role(nid, uid, rid, RoleAsnAction.UNASSIGN)