Fix database migrations; add cat/field/tag editing
Progress toward #4, #5, and #26
This commit is contained in:
parent
ce18ed2fd4
commit
875c5f7f06
27 changed files with 1343 additions and 46 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,4 +4,5 @@
|
||||||
__pycache__
|
__pycache__
|
||||||
/instance
|
/instance
|
||||||
/src/instance
|
/src/instance
|
||||||
/.local
|
/.local
|
||||||
|
.secrets
|
||||||
|
|
@ -9,9 +9,11 @@ from alembic import context
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
# for some reason, flask-migrate decided it was a good idea to override our
|
||||||
# This line sets up loggers basically.
|
# logging config whenever we call any of their functions >:(
|
||||||
fileConfig(config.config_file_name)
|
#
|
||||||
|
# this line has been commented out in order to remove this "feature"
|
||||||
|
# fileConfig(config.config_file_name)
|
||||||
logger = logging.getLogger('alembic.env')
|
logger = logging.getLogger('alembic.env')
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Fix tag/field/category links
|
||||||
|
|
||||||
|
Revision ID: 067ee615c967
|
||||||
|
Revises: f34868d85e32
|
||||||
|
Create Date: 2025-11-28 12:03:26.164002
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '067ee615c967'
|
||||||
|
down_revision = 'f34868d85e32'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('cat_to_task',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('cat', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('task', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['cat'], ['category.id'], name='fk_c2t_cat', ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['task'], ['task.id'], name='fk_c2t_task', ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('field_to_task',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('field', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('task', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('raw_val', sa.String(length=1024), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['field'], ['field.id'], name='fk_f2t_field', ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['task'], ['task.id'], name='fk_f2t_task', ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('field', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('raw_val')
|
||||||
|
|
||||||
|
with op.batch_alter_table('task', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('soft_due', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
# ### 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_column('soft_due')
|
||||||
|
|
||||||
|
with op.batch_alter_table('field', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('raw_val', sa.VARCHAR(length=1024), nullable=False))
|
||||||
|
|
||||||
|
op.drop_table('field_to_task')
|
||||||
|
op.drop_table('cat_to_task')
|
||||||
|
# ### end Alembic commands ###
|
||||||
151
src/migrations/versions/6db9f9b83a8d_base.py
Normal file
151
src/migrations/versions/6db9f9b83a8d_base.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""base
|
||||||
|
|
||||||
|
Revision ID: 6db9f9b83a8d
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-11-28 11:36:25.704433
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '6db9f9b83a8d'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('namespace',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('description', sa.String(), nullable=False),
|
||||||
|
sa.Column('created', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('pwned_password',
|
||||||
|
sa.Column('hash', sa.String(), nullable=False),
|
||||||
|
sa.Column('count', sa.Integer(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('hash')
|
||||||
|
)
|
||||||
|
op.create_table('user',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('display_name', sa.String(length=256), nullable=False),
|
||||||
|
sa.Column('created', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('administrator', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('pr_sub', sa.String(), nullable=False),
|
||||||
|
sa.Column('pr_obj', sa.String(), nullable=False),
|
||||||
|
sa.Column('pr_dep', sa.String(), nullable=False),
|
||||||
|
sa.Column('pr_ind', sa.String(), nullable=False),
|
||||||
|
sa.Column('pr_ref', sa.String(), nullable=False),
|
||||||
|
sa.Column('pr_plr', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('password', sa.String(length=256), nullable=False),
|
||||||
|
sa.Column('salt', sa.String(length=256), nullable=False),
|
||||||
|
sa.Column('hash_params', sa.String(length=256), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('username')
|
||||||
|
)
|
||||||
|
op.create_table('namespace_role',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('permissions', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('perms_deny', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('priority', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('namespace', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['namespace'], ['namespace.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('sign_up_code',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('created', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('expires', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('grants_admin', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_by', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code')
|
||||||
|
)
|
||||||
|
op.create_table('task',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('description', sa.String(), nullable=False),
|
||||||
|
sa.Column('due', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('created', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('complete', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('namespace', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('owner', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['namespace'], ['namespace.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['owner'], ['user.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('user_role',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('is_self', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('permissions', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('perms_deny', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('priority', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('user', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user'], ['user.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('namespace_invite_code',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('code', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('created', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('expires', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('for_role', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['for_role'], ['namespace_role.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('code')
|
||||||
|
)
|
||||||
|
op.create_table('task_to_namespace',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('namespace', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('task', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['namespace'], ['namespace.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['task'], ['task.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('user_to_namespace_role',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('role', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['role'], ['namespace_role.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user'], ['user.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('user_to_user_role',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('role', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['role'], ['user_role.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user'], ['user.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('user_to_user_role')
|
||||||
|
op.drop_table('user_to_namespace_role')
|
||||||
|
op.drop_table('task_to_namespace')
|
||||||
|
op.drop_table('namespace_invite_code')
|
||||||
|
op.drop_table('user_role')
|
||||||
|
op.drop_table('task')
|
||||||
|
op.drop_table('sign_up_code')
|
||||||
|
op.drop_table('namespace_role')
|
||||||
|
op.drop_table('user')
|
||||||
|
op.drop_table('pwned_password')
|
||||||
|
op.drop_table('namespace')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
@ -11,7 +11,7 @@ import sqlalchemy as sa
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = '945140c35257'
|
revision = '945140c35257'
|
||||||
down_revision = None
|
down_revision = '6db9f9b83a8d'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from flask import Flask, Request, render_template, url_for
|
from flask import Flask, Request, render_template, url_for
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate, upgrade
|
||||||
|
|
||||||
from taskflower.auth import taskflower_login_manager
|
from taskflower.auth import taskflower_login_manager
|
||||||
from taskflower.auth.startup import startup_checks
|
from taskflower.auth.startup import startup_checks
|
||||||
|
|
@ -18,7 +18,14 @@ from taskflower.web import web_base
|
||||||
|
|
||||||
from taskflower.tools.hibp import hibp_bp
|
from taskflower.tools.hibp import hibp_bp
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
def _init_logs():
|
||||||
|
logging.basicConfig(
|
||||||
|
format='[%(levelname)-8s] %(name)-24s: %(message)s',
|
||||||
|
level=logging.INFO,
|
||||||
|
force=True
|
||||||
|
)
|
||||||
|
|
||||||
|
_init_logs()
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -48,7 +55,11 @@ 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():
|
with app.app_context():
|
||||||
db.create_all()
|
# db.create_all()
|
||||||
|
log.info('Checking for and applying database migrations')
|
||||||
|
upgrade()
|
||||||
|
_init_logs() # flask-migrate overrides our logging config >:(
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
log.info('Running startup checks...')
|
log.info('Running startup checks...')
|
||||||
res = startup_checks(db)
|
res = startup_checks(db)
|
||||||
|
|
@ -109,6 +120,9 @@ def template_utility_fns():
|
||||||
**new_args # pyright:ignore[reportAny]
|
**new_args # pyright:ignore[reportAny]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def t_len(ls: list[Any]) -> int: # pyright:ignore[reportExplicitAny]
|
||||||
|
return len(ls)
|
||||||
|
|
||||||
return dict(
|
return dict(
|
||||||
literal_call=literal_call,
|
literal_call=literal_call,
|
||||||
reltime=render_reltime,
|
reltime=render_reltime,
|
||||||
|
|
@ -116,7 +130,8 @@ def template_utility_fns():
|
||||||
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
|
cur_page_with_variables=cur_page_with_variables,
|
||||||
|
len=t_len
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ class NamespacePermissionType(IntFlag):
|
||||||
DELETE_ALL_TASKS = (1 << 10)
|
DELETE_ALL_TASKS = (1 << 10)
|
||||||
EDIT_TAGS = (1 << 12)
|
EDIT_TAGS = (1 << 12)
|
||||||
EDIT_FIELDS = (1 << 13)
|
EDIT_FIELDS = (1 << 13)
|
||||||
|
EDIT_CATEGORIES = (1 << 15)
|
||||||
EDIT_ROLES = (1 << 11)
|
EDIT_ROLES = (1 << 11)
|
||||||
RENAME = (1 << 14)
|
RENAME = (1 << 14)
|
||||||
ADMINISTRATE = (1 << 1)
|
ADMINISTRATE = (1 << 1)
|
||||||
|
|
@ -71,6 +72,7 @@ SELF_NAMESPACE_PERMISSIONS = (
|
||||||
| NamespacePermissionType.DELETE_ALL_TASKS
|
| NamespacePermissionType.DELETE_ALL_TASKS
|
||||||
| NamespacePermissionType.EDIT_TAGS
|
| NamespacePermissionType.EDIT_TAGS
|
||||||
| NamespacePermissionType.EDIT_FIELDS
|
| NamespacePermissionType.EDIT_FIELDS
|
||||||
|
| NamespacePermissionType.EDIT_CATEGORIES
|
||||||
| NamespacePermissionType.EDIT_ROLES
|
| NamespacePermissionType.EDIT_ROLES
|
||||||
| NamespacePermissionType.RENAME
|
| NamespacePermissionType.RENAME
|
||||||
)
|
)
|
||||||
|
|
@ -78,7 +80,7 @@ SELF_NAMESPACE_PERMISSIONS = (
|
||||||
def user_friendly_name(perm: NamespacePermissionType|UserPermissionType):
|
def user_friendly_name(perm: NamespacePermissionType|UserPermissionType):
|
||||||
match perm:
|
match perm:
|
||||||
case NamespacePermissionType.READ:
|
case NamespacePermissionType.READ:
|
||||||
return 'See the namespace.'
|
return 'See the Zone.'
|
||||||
case NamespacePermissionType.CREATE_TASKS_IN:
|
case NamespacePermissionType.CREATE_TASKS_IN:
|
||||||
return 'Create tasks.'
|
return 'Create tasks.'
|
||||||
case NamespacePermissionType.COMPLETE_ALL_TASKS:
|
case NamespacePermissionType.COMPLETE_ALL_TASKS:
|
||||||
|
|
@ -86,15 +88,19 @@ def user_friendly_name(perm: NamespacePermissionType|UserPermissionType):
|
||||||
case NamespacePermissionType.UNCOMPLETE_ALL_TASKS:
|
case NamespacePermissionType.UNCOMPLETE_ALL_TASKS:
|
||||||
return 'Uncomplete tasks.'
|
return 'Uncomplete tasks.'
|
||||||
case NamespacePermissionType.EDIT_ALL_TASKS:
|
case NamespacePermissionType.EDIT_ALL_TASKS:
|
||||||
return 'Edit tasks, including adding/removing tags and fields.'
|
return 'Edit tasks, including adding/removing tags, fields, and categories.'
|
||||||
case NamespacePermissionType.DELETE_ALL_TASKS:
|
case NamespacePermissionType.DELETE_ALL_TASKS:
|
||||||
return 'Delete tasks.'
|
return 'Delete tasks.'
|
||||||
case NamespacePermissionType.EDIT_TAGS:
|
case NamespacePermissionType.EDIT_TAGS:
|
||||||
return 'Create new tags and delete existing ones.'
|
return 'Create new tags and delete existing ones.'
|
||||||
case NamespacePermissionType.EDIT_FIELDS:
|
case NamespacePermissionType.EDIT_FIELDS:
|
||||||
return 'Create new fields and delete existing ones.'
|
return 'Create new fields and delete existing ones.'
|
||||||
|
case NamespacePermissionType.EDIT_CATEGORIES:
|
||||||
|
return 'Create new categories and delete existing ones.'
|
||||||
case NamespacePermissionType.EDIT_ROLES:
|
case NamespacePermissionType.EDIT_ROLES:
|
||||||
return 'Manage roles lower than this one.'
|
return 'Manage roles lower than this one.'
|
||||||
|
case NamespacePermissionType.RENAME:
|
||||||
|
return 'Change the name and description of this Zone.'
|
||||||
case NamespacePermissionType.ADMINISTRATE:
|
case NamespacePermissionType.ADMINISTRATE:
|
||||||
return 'Administrator. Users with this role bypass all other checks.'
|
return 'Administrator. Users with this role bypass all other checks.'
|
||||||
case UserPermissionType.READ_PROFILE:
|
case UserPermissionType.READ_PROFILE:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ from types import NoneType
|
||||||
from typing import Self
|
from typing import Self
|
||||||
from sqlalchemy import ForeignKey, Integer, String
|
from sqlalchemy import ForeignKey, Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from taskflower.db import db
|
from taskflower.db import db, db_fetch_by_id
|
||||||
|
from taskflower.db.model.namespace import Namespace
|
||||||
|
from taskflower.db.model.task import Task
|
||||||
from taskflower.types import AssertType, CheckType
|
from taskflower.types import AssertType, CheckType
|
||||||
from taskflower.types.either import Either, Left, Right
|
from taskflower.types.either import Either, Left, Right
|
||||||
from taskflower.util.time import destringify_dt, stringify_dt
|
from taskflower.util.time import destringify_dt, stringify_dt
|
||||||
|
|
@ -28,6 +30,14 @@ class FieldType(Enum):
|
||||||
FLOAT = float
|
FLOAT = float
|
||||||
DATETIME = datetime
|
DATETIME = datetime
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_str(cls, val: str) -> Either[Exception, FieldType]:
|
||||||
|
for k, v in cls._member_map_.items():
|
||||||
|
if k.upper() == val.upper():
|
||||||
|
return CheckType(FieldType)(v)
|
||||||
|
|
||||||
|
return Left(KeyError(f'FieldType key {val} not found!'))
|
||||||
|
|
||||||
def _check_len(v: str) -> Either[Exception, str]:
|
def _check_len(v: str) -> Either[Exception, str]:
|
||||||
if len(v) <= FIELD_VAL_MAX_LEN:
|
if len(v) <= FIELD_VAL_MAX_LEN:
|
||||||
return Right(v)
|
return Right(v)
|
||||||
|
|
@ -41,6 +51,15 @@ class Tag(db.Model):
|
||||||
name : Mapped[str] = mapped_column(String(TAG_FIELD_NAME_MAX_LEN))
|
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'))
|
namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE', name='fk_tag_ns'))
|
||||||
|
|
||||||
|
|
||||||
|
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'))
|
||||||
|
|
||||||
|
|
||||||
class TaskCategory(db.Model):
|
class TaskCategory(db.Model):
|
||||||
__tablename__: str = 'category'
|
__tablename__: str = 'category'
|
||||||
|
|
||||||
|
|
@ -48,26 +67,73 @@ class TaskCategory(db.Model):
|
||||||
name : Mapped[str] = mapped_column(String(TAG_FIELD_NAME_MAX_LEN))
|
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'))
|
namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE', name='fk_cat_ns'))
|
||||||
|
|
||||||
|
class CatToTask(db.Model):
|
||||||
|
__tablename__: str = 'cat_to_task'
|
||||||
|
|
||||||
|
id : Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
cat : Mapped[int] = mapped_column(Integer, ForeignKey('category.id', ondelete='CASCADE', name='fk_c2t_cat'))
|
||||||
|
task : Mapped[int] = mapped_column(Integer, ForeignKey('task.id', ondelete='CASCADE', name='fk_c2t_task'))
|
||||||
|
|
||||||
|
|
||||||
class Field(db.Model):
|
class Field(db.Model):
|
||||||
__tablename__: str = 'field'
|
__tablename__: str = 'field'
|
||||||
|
|
||||||
id : Mapped[int] = mapped_column(Integer, primary_key=True)
|
id : Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
name : Mapped[str] = mapped_column(String(TAG_FIELD_NAME_MAX_LEN))
|
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))
|
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'))
|
namespace : Mapped[int] = mapped_column(Integer, ForeignKey('namespace.id', ondelete='CASCADE', name='fk_field_ns'))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dtype(self) -> FieldType:
|
def dtype(self) -> FieldType:
|
||||||
for k, v in FieldType._value2member_map_.items(): # pyright:ignore[reportAny]
|
for k, v in FieldType._member_map_.items():
|
||||||
if self.raw_dtype == k:
|
if self.raw_dtype == k:
|
||||||
return AssertType(FieldType)(v)
|
return AssertType(FieldType)(v)
|
||||||
|
|
||||||
return FieldType.INVALID
|
return FieldType.INVALID
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_data(
|
||||||
|
cls,
|
||||||
|
name: str,
|
||||||
|
dtype: FieldType,
|
||||||
|
ns: Namespace
|
||||||
|
) -> Either[Exception, Field]:
|
||||||
|
if dtype == FieldType.INVALID:
|
||||||
|
return Left(
|
||||||
|
TypeError('Can\'t create a Field with an invalid data-type!')
|
||||||
|
)
|
||||||
|
|
||||||
|
return Right(cls(
|
||||||
|
name=name, # pyright:ignore[reportCallIssue]
|
||||||
|
raw_dtype=dtype.name, # pyright:ignore[reportCallIssue]
|
||||||
|
namespace=ns.id # pyright:ignore[reportCallIssue]
|
||||||
|
))
|
||||||
|
|
||||||
|
class FieldToTask(db.Model):
|
||||||
|
__tablename__: str = 'field_to_task'
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
field: Mapped[int] = mapped_column(Integer, ForeignKey('field.id', ondelete='CASCADE', name='fk_f2t_field'))
|
||||||
|
task: Mapped[int] = mapped_column(Integer, ForeignKey('task.id', ondelete='CASCADE', name='fk_f2t_task'))
|
||||||
|
|
||||||
|
raw_val: Mapped[str] = mapped_column(String(FIELD_VAL_MAX_LEN))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dtype(self) -> Either[Exception, FieldType]:
|
||||||
|
return db_fetch_by_id(
|
||||||
|
Field,
|
||||||
|
self.field,
|
||||||
|
db
|
||||||
|
).map(
|
||||||
|
lambda fld: fld.dtype
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def val(self) -> Either[Exception, FieldTypeAlias]:
|
def val(self) -> Either[Exception, FieldTypeAlias]:
|
||||||
match self.dtype:
|
if isinstance(self.dtype, Left):
|
||||||
|
return Left(self.dtype.val)
|
||||||
|
|
||||||
|
match self.dtype.assert_right().val:
|
||||||
case FieldType.INVALID:
|
case FieldType.INVALID:
|
||||||
return Left(TypeError(
|
return Left(TypeError(
|
||||||
'Field dtype is `FieldType.INVALID`!'
|
'Field dtype is `FieldType.INVALID`!'
|
||||||
|
|
@ -106,9 +172,14 @@ class Field(db.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
def val_as_type[T](self, t: type[T]) -> Either[Exception, T]:
|
def val_as_type[T](self, t: type[T]) -> Either[Exception, T]:
|
||||||
if self.dtype.value is not t:
|
if isinstance(self.dtype, Left):
|
||||||
|
return Left(self.dtype.val)
|
||||||
|
|
||||||
|
dtype = self.dtype.assert_right().val
|
||||||
|
|
||||||
|
if dtype.value is not t:
|
||||||
return Left(TypeError(
|
return Left(TypeError(
|
||||||
f'`Field` dtype is `{self.dtype.name}`, which corresponds to python type `{self.dtype.value.__name__}`, not `{t.__name__}`!'
|
f'`Field` dtype is `{dtype.name}`, which corresponds to python type `{dtype.value.__name__}`, not `{t.__name__}`!'
|
||||||
))
|
))
|
||||||
|
|
||||||
return self.val.flat_map(
|
return self.val.flat_map(
|
||||||
|
|
@ -116,24 +187,22 @@ class Field(db.Model):
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_vals(
|
def from_data(
|
||||||
cls,
|
cls,
|
||||||
name: str,
|
field: Field,
|
||||||
dtype: FieldType,
|
task: Task,
|
||||||
val: FieldTypeAlias,
|
val: FieldTypeAlias
|
||||||
namespace_id: int
|
|
||||||
) -> Either[Exception, Self]:
|
) -> Either[Exception, Self]:
|
||||||
def _mkobj(raw_data: str) -> Either[Exception, Self]:
|
def _mkobj(raw_data: str) -> Either[Exception, Self]:
|
||||||
return _check_len(raw_data).map(
|
return _check_len(raw_data).map(
|
||||||
lambda v: cls(
|
lambda v: cls(
|
||||||
name = name, # pyright:ignore[reportCallIssue]
|
field = field.id, # pyright:ignore[reportCallIssue]
|
||||||
raw_val = v, # pyright:ignore[reportCallIssue]
|
task = task.id, # pyright:ignore[reportCallIssue]
|
||||||
raw_dtype = dtype.name, # pyright:ignore[reportCallIssue]
|
raw_val = v, # pyright:ignore[reportCallIssue]
|
||||||
namespace = namespace_id # pyright:ignore[reportCallIssue]
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
match dtype:
|
match field.dtype:
|
||||||
case FieldType.INVALID:
|
case FieldType.INVALID:
|
||||||
return Left(KeyError(
|
return Left(KeyError(
|
||||||
'Can\'t insert a value of type `FieldType.INVALID` into the database!'
|
'Can\'t insert a value of type `FieldType.INVALID` into the database!'
|
||||||
|
|
@ -159,13 +228,6 @@ class Field(db.Model):
|
||||||
lambda v: _mkobj(stringify_dt(v))
|
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.
|
# Run a check on import to catch invalid `FieldType` members.
|
||||||
for v in FieldType._member_names_:
|
for v in FieldType._member_names_:
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class Task(db.Model):
|
||||||
name : Mapped[str] = mapped_column(String(64))
|
name : Mapped[str] = mapped_column(String(64))
|
||||||
description : Mapped[str] = mapped_column(String)
|
description : Mapped[str] = mapped_column(String)
|
||||||
|
|
||||||
|
soft_due : Mapped[datetime|None] = mapped_column(DateTime(timezone=False), nullable=True)
|
||||||
due : Mapped[datetime] = mapped_column(DateTime(timezone=False))
|
due : Mapped[datetime] = mapped_column(DateTime(timezone=False))
|
||||||
created : Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
created : Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now)
|
||||||
complete : Mapped[bool] = mapped_column(Boolean, default=False)
|
complete : Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class FormCreatesObject[T](Form):
|
||||||
``AuthorizationError`` if the action is not authorized.
|
``AuthorizationError`` if the action is not authorized.
|
||||||
'''
|
'''
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
FormCObj = FormCreatesObject
|
||||||
|
|
||||||
class FormCreatesObjectWithUser[T](Form):
|
class FormCreatesObjectWithUser[T](Form):
|
||||||
''' Trait that indicates that this ``Form`` cna be used to create an object,
|
''' Trait that indicates that this ``Form`` cna be used to create an object,
|
||||||
|
|
@ -36,7 +37,8 @@ class FormCreatesObjectWithUser[T](Form):
|
||||||
create an object with the specified parameters.
|
create an object with the specified parameters.
|
||||||
'''
|
'''
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
FormCObjUser = FormCreatesObjectWithUser
|
||||||
|
|
||||||
class FormEditsObjectWithUser[T](Form):
|
class FormEditsObjectWithUser[T](Form):
|
||||||
''' Trait that indicates that this ``Form`` can be used to edit an object
|
''' Trait that indicates that this ``Form`` can be used to edit an object
|
||||||
if provided with a ``User`` object.
|
if provided with a ``User`` object.
|
||||||
|
|
@ -54,6 +56,7 @@ class FormEditsObjectWithUser[T](Form):
|
||||||
edit an object with the specified parameters.
|
edit an object with the specified parameters.
|
||||||
'''
|
'''
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
FormEObjUser = FormEditsObjectWithUser
|
||||||
|
|
||||||
class FormCreatesObjectWithUserAndNamespace[T](Form):
|
class FormCreatesObjectWithUserAndNamespace[T](Form):
|
||||||
''' Trait that indicates that this ``Form`` can be used to create an object,
|
''' Trait that indicates that this ``Form`` can be used to create an object,
|
||||||
|
|
@ -72,7 +75,8 @@ class FormCreatesObjectWithUserAndNamespace[T](Form):
|
||||||
create an object with the specified parameters.
|
create an object with the specified parameters.
|
||||||
'''
|
'''
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
FormCObjUserNS = FormCreatesObjectWithUserAndNamespace
|
||||||
|
|
||||||
class FormEditsObjectWithUserAndNamespace[T](Form):
|
class FormEditsObjectWithUserAndNamespace[T](Form):
|
||||||
''' Trait that indicates that this ``Form`` can be used to edit an object
|
''' Trait that indicates that this ``Form`` can be used to edit an object
|
||||||
if provided with a ``User`` object and a ``Namespace``.
|
if provided with a ``User`` object and a ``Namespace``.
|
||||||
|
|
@ -91,6 +95,7 @@ class FormEditsObjectWithUserAndNamespace[T](Form):
|
||||||
edit an object with the specified parameters.
|
edit an object with the specified parameters.
|
||||||
'''
|
'''
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
FormEObjUserNS = FormEditsObjectWithUserAndNamespace
|
||||||
|
|
||||||
class FormCEObjUserNS[T](
|
class FormCEObjUserNS[T](
|
||||||
FormCreatesObjectWithUserAndNamespace[T],
|
FormCreatesObjectWithUserAndNamespace[T],
|
||||||
|
|
|
||||||
72
src/taskflower/form/category.py
Normal file
72
src/taskflower/form/category.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
from typing import final, override
|
||||||
|
from wtforms import Field, Form, StringField, ValidationError
|
||||||
|
from wtforms.validators import DataRequired, Length
|
||||||
|
from taskflower.auth.permission import NPT
|
||||||
|
from taskflower.auth.permission.checks import assert_user_perms_on_namespace
|
||||||
|
from taskflower.db import db
|
||||||
|
from taskflower.db.model.namespace import Namespace
|
||||||
|
from taskflower.db.model.tag import TAG_FIELD_NAME_MAX_LEN, TaskCategory
|
||||||
|
from taskflower.db.model.user import User
|
||||||
|
from taskflower.form import FormCObjUser
|
||||||
|
from taskflower.types import CheckType, ann
|
||||||
|
from taskflower.types.either import Either, Left
|
||||||
|
|
||||||
|
@final
|
||||||
|
class Unique:
|
||||||
|
def __init__(self, ns: Namespace):
|
||||||
|
self._ns = ns
|
||||||
|
|
||||||
|
def __call__(self, _: Form, fld: Field):
|
||||||
|
data = CheckType(str)(
|
||||||
|
fld.data # pyright:ignore[reportAny]
|
||||||
|
).val_or_raise(
|
||||||
|
lambda exc: ValidationError(f'Category name is of the wrong type: {str(exc)}!')
|
||||||
|
)
|
||||||
|
|
||||||
|
if db.session.query(
|
||||||
|
TaskCategory
|
||||||
|
).filter(
|
||||||
|
TaskCategory.namespace == self._ns.id
|
||||||
|
).filter(
|
||||||
|
TaskCategory.name == data
|
||||||
|
).one_or_none() is not None:
|
||||||
|
raise ValidationError('A category with that name already exists!')
|
||||||
|
|
||||||
|
|
||||||
|
def get_category_form_for(ns: Namespace):
|
||||||
|
class CategoryForm(FormCObjUser[TaskCategory]):
|
||||||
|
name: StringField = StringField(
|
||||||
|
'Category Name',
|
||||||
|
[
|
||||||
|
Length(
|
||||||
|
1,
|
||||||
|
TAG_FIELD_NAME_MAX_LEN,
|
||||||
|
f'Category name must be between 1 and {TAG_FIELD_NAME_MAX_LEN} characters.'
|
||||||
|
),
|
||||||
|
DataRequired(),
|
||||||
|
Unique(ns)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def create_object(
|
||||||
|
self,
|
||||||
|
current_user: User
|
||||||
|
) -> Either[Exception, TaskCategory]:
|
||||||
|
if self.validate():
|
||||||
|
return assert_user_perms_on_namespace(
|
||||||
|
current_user,
|
||||||
|
ns,
|
||||||
|
NPT.EDIT_CATEGORIES
|
||||||
|
).map(
|
||||||
|
lambda _: TaskCategory(
|
||||||
|
name=ann(self.name.data), # pyright:ignore[reportCallIssue]
|
||||||
|
namespace=ns.id # pyright:ignore[reportCallIssue]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Left(
|
||||||
|
ValidationError('Category form failed validation!')
|
||||||
|
)
|
||||||
|
|
||||||
|
return CategoryForm
|
||||||
90
src/taskflower/form/field.py
Normal file
90
src/taskflower/form/field.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
from typing import final, override
|
||||||
|
from wtforms import Field, Form, RadioField, StringField, ValidationError
|
||||||
|
from wtforms.validators import DataRequired, Length
|
||||||
|
from taskflower.auth.permission import NPT
|
||||||
|
from taskflower.auth.permission.checks import assert_user_perms_on_namespace
|
||||||
|
from taskflower.db import db
|
||||||
|
from taskflower.db.model.namespace import Namespace
|
||||||
|
from taskflower.db.model.tag import TAG_FIELD_NAME_MAX_LEN, Field as TagField, FieldType
|
||||||
|
from taskflower.db.model.user import User
|
||||||
|
from taskflower.form import FormCObjUser
|
||||||
|
from taskflower.types import CheckType, ann
|
||||||
|
from taskflower.types.either import Either, Left
|
||||||
|
|
||||||
|
@final
|
||||||
|
class Unique:
|
||||||
|
def __init__(self, ns: Namespace):
|
||||||
|
self._ns = ns
|
||||||
|
|
||||||
|
def __call__(self, _: Form, fld: Field):
|
||||||
|
data = CheckType(str)(
|
||||||
|
fld.data # pyright:ignore[reportAny]
|
||||||
|
).val_or_raise(
|
||||||
|
lambda exc: ValidationError(f'Field name is of the wrong type: {str(exc)}!')
|
||||||
|
)
|
||||||
|
|
||||||
|
if db.session.query(
|
||||||
|
TagField
|
||||||
|
).filter(
|
||||||
|
TagField.namespace == self._ns.id
|
||||||
|
).filter(
|
||||||
|
TagField.name == data
|
||||||
|
).one_or_none() is not None:
|
||||||
|
raise ValidationError('A field with that name already exists!')
|
||||||
|
|
||||||
|
def _gen_field_choices():
|
||||||
|
return [
|
||||||
|
(k, k.title())
|
||||||
|
for k, _ in FieldType._member_map_.items()
|
||||||
|
if k != 'INVALID'
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_field_form_for(ns: Namespace):
|
||||||
|
class TagFieldForm(FormCObjUser[TagField]):
|
||||||
|
name: StringField = StringField(
|
||||||
|
'Tag Name',
|
||||||
|
[
|
||||||
|
Length(
|
||||||
|
1,
|
||||||
|
TAG_FIELD_NAME_MAX_LEN,
|
||||||
|
f'Tag name must be between 1 and {TAG_FIELD_NAME_MAX_LEN} characters.'
|
||||||
|
),
|
||||||
|
DataRequired(),
|
||||||
|
Unique(ns)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data_type: RadioField = RadioField(
|
||||||
|
'Field data type',
|
||||||
|
[
|
||||||
|
DataRequired()
|
||||||
|
],
|
||||||
|
choices=_gen_field_choices
|
||||||
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def create_object(
|
||||||
|
self,
|
||||||
|
current_user: User
|
||||||
|
) -> Either[Exception, TagField]:
|
||||||
|
if self.validate():
|
||||||
|
return assert_user_perms_on_namespace(
|
||||||
|
current_user,
|
||||||
|
ns,
|
||||||
|
NPT.EDIT_TAGS
|
||||||
|
).flat_map(
|
||||||
|
lambda _: CheckType(str)(self.data_type.data) # pyright:ignore[reportAny]
|
||||||
|
).flat_map(
|
||||||
|
lambda dtype_name: FieldType.from_str(dtype_name)
|
||||||
|
).flat_map(
|
||||||
|
lambda dtype: TagField.from_data(
|
||||||
|
ann(self.name.data),
|
||||||
|
dtype,
|
||||||
|
ns
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Left(
|
||||||
|
ValidationError('Category form failed validation!')
|
||||||
|
)
|
||||||
|
|
||||||
|
return TagFieldForm
|
||||||
72
src/taskflower/form/tag.py
Normal file
72
src/taskflower/form/tag.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
from typing import final, override
|
||||||
|
from wtforms import Field, Form, StringField, ValidationError
|
||||||
|
from wtforms.validators import DataRequired, Length
|
||||||
|
from taskflower.auth.permission import NPT
|
||||||
|
from taskflower.auth.permission.checks import assert_user_perms_on_namespace
|
||||||
|
from taskflower.db import db
|
||||||
|
from taskflower.db.model.namespace import Namespace
|
||||||
|
from taskflower.db.model.tag import TAG_FIELD_NAME_MAX_LEN, Tag
|
||||||
|
from taskflower.db.model.user import User
|
||||||
|
from taskflower.form import FormCObjUser
|
||||||
|
from taskflower.types import CheckType, ann
|
||||||
|
from taskflower.types.either import Either, Left
|
||||||
|
|
||||||
|
@final
|
||||||
|
class Unique:
|
||||||
|
def __init__(self, ns: Namespace):
|
||||||
|
self._ns = ns
|
||||||
|
|
||||||
|
def __call__(self, _: Form, fld: Field):
|
||||||
|
data = CheckType(str)(
|
||||||
|
fld.data # pyright:ignore[reportAny]
|
||||||
|
).val_or_raise(
|
||||||
|
lambda exc: ValidationError(f'Tag name is of the wrong type: {str(exc)}!')
|
||||||
|
)
|
||||||
|
|
||||||
|
if db.session.query(
|
||||||
|
Tag
|
||||||
|
).filter(
|
||||||
|
Tag.namespace == self._ns.id
|
||||||
|
).filter(
|
||||||
|
Tag.name == data
|
||||||
|
).one_or_none() is not None:
|
||||||
|
raise ValidationError('A tag with that name already exists!')
|
||||||
|
|
||||||
|
|
||||||
|
def get_tag_form_for(ns: Namespace):
|
||||||
|
class TagForm(FormCObjUser[Tag]):
|
||||||
|
name: StringField = StringField(
|
||||||
|
'Tag Name',
|
||||||
|
[
|
||||||
|
Length(
|
||||||
|
1,
|
||||||
|
TAG_FIELD_NAME_MAX_LEN,
|
||||||
|
f'Tag name must be between 1 and {TAG_FIELD_NAME_MAX_LEN} characters.'
|
||||||
|
),
|
||||||
|
DataRequired(),
|
||||||
|
Unique(ns)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def create_object(
|
||||||
|
self,
|
||||||
|
current_user: User
|
||||||
|
) -> Either[Exception, Tag]:
|
||||||
|
if self.validate():
|
||||||
|
return assert_user_perms_on_namespace(
|
||||||
|
current_user,
|
||||||
|
ns,
|
||||||
|
NPT.EDIT_TAGS
|
||||||
|
).map(
|
||||||
|
lambda _: Tag(
|
||||||
|
name=ann(self.name.data), # pyright:ignore[reportCallIssue]
|
||||||
|
namespace=ns.id # pyright:ignore[reportCallIssue]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Left(
|
||||||
|
ValidationError('Category form failed validation!')
|
||||||
|
)
|
||||||
|
|
||||||
|
return TagForm
|
||||||
97
src/taskflower/templates/namespace/admin/_helpers.html
Normal file
97
src/taskflower/templates/namespace/admin/_helpers.html
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
{% macro link_button_elem(obj_type, action_type, enabled, url, btn_icon, extra_cls='') %}
|
||||||
|
<td class="pad-even">
|
||||||
|
{% if enabled %}
|
||||||
|
<a
|
||||||
|
class="icon-only-btn {{extra_cls}}"
|
||||||
|
aria-label="{{action_type}} {{obj_type}}"
|
||||||
|
href="{{url}}"
|
||||||
|
>{{btn_icon|safe}}</a>
|
||||||
|
{% else %}
|
||||||
|
<a
|
||||||
|
class="icon-only-btn disabled"
|
||||||
|
aria-label="Can't {{action_type}} {{obj_type}}"
|
||||||
|
title="You don't have permission to {{action_type}} this {{obj_type}}"
|
||||||
|
>{{icon('not-allowed')|safe}}</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro obj_entry(ns, obj, obj_type, edit_endpoint, delete_endpoint, tab, entries) %}
|
||||||
|
<tr>
|
||||||
|
{{
|
||||||
|
link_button_elem(
|
||||||
|
obj_type,
|
||||||
|
'edit',
|
||||||
|
obj.can_edit,
|
||||||
|
url_for(
|
||||||
|
edit_endpoint,
|
||||||
|
id=ns.id,
|
||||||
|
tid=obj.id,
|
||||||
|
next=cur_page_with_variables(
|
||||||
|
request,
|
||||||
|
active_tab=tab
|
||||||
|
)
|
||||||
|
),
|
||||||
|
icon('cog')
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{
|
||||||
|
link_button_elem(
|
||||||
|
obj_type,
|
||||||
|
'delete',
|
||||||
|
obj.can_edit,
|
||||||
|
url_for(
|
||||||
|
delete_endpoint,
|
||||||
|
id=ns.id,
|
||||||
|
tid=obj.id,
|
||||||
|
next=cur_page_with_variables(
|
||||||
|
request,
|
||||||
|
active_tab=tab
|
||||||
|
)
|
||||||
|
),
|
||||||
|
icon('delete'),
|
||||||
|
'red'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{% for entry in entries %}
|
||||||
|
<td>{{entry}}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro obj_colgroup(entries) %}
|
||||||
|
<colgroup>
|
||||||
|
<col span="1" style="width: 0;"/>
|
||||||
|
<col span="1" style="width: 0;"/>
|
||||||
|
{% for entry in entries %}
|
||||||
|
<col span="1"/>
|
||||||
|
{% endfor %}
|
||||||
|
</colgroup>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro obj_header(entries) %}
|
||||||
|
<tr>
|
||||||
|
<th>Edit</th>
|
||||||
|
<th>Del</th>
|
||||||
|
{% for entry in entries %}
|
||||||
|
<th>{{entry}}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro obj_add_row(obj_type, can_add, url, entries) %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="{{len(entries) + 2}}">
|
||||||
|
{% if can_add %}
|
||||||
|
<a
|
||||||
|
class="link-btn icon-btn"
|
||||||
|
href="{{url}}"
|
||||||
|
>{{icon('add')|safe}} Add a new {{obj_type}}</a>
|
||||||
|
{% else %}
|
||||||
|
<a
|
||||||
|
class="link-btn icon-btn disabled"
|
||||||
|
>{{icon('not-allowed')|safe}} Add a new {{obj_type}}</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endmacro %}
|
||||||
|
|
@ -43,12 +43,12 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<table class="list">
|
<table class="list">
|
||||||
|
<colgroup>
|
||||||
|
<col span="1" style="width: 0;"/>
|
||||||
|
<col span="1"/>
|
||||||
|
<col span="1"/>
|
||||||
|
</colgroup>
|
||||||
<tbody>
|
<tbody>
|
||||||
<colgroup>
|
|
||||||
<col span="1" style="width: 0;"/>
|
|
||||||
<col span="1"/>
|
|
||||||
<col span="1"/>
|
|
||||||
</colgroup>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th>Edit</th>
|
<th>Edit</th>
|
||||||
<th>Display Name</th>
|
<th>Display Name</th>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
{% from "_formhelpers.html" import render_field with context %}
|
||||||
|
{% from "namespace/admin/_helpers.html" import obj_entry, obj_header, obj_colgroup, obj_add_row with context %}
|
||||||
|
|
||||||
|
{% macro cat_entry(ns, cat) %}
|
||||||
|
{{
|
||||||
|
obj_entry(
|
||||||
|
ns,
|
||||||
|
cat,
|
||||||
|
'category',
|
||||||
|
'web.namespace.category.edit',
|
||||||
|
'web.namespace.category.delete',
|
||||||
|
'tags',
|
||||||
|
[
|
||||||
|
cat.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro tag_entry(ns, tag)%}
|
||||||
|
{{
|
||||||
|
obj_entry(
|
||||||
|
ns,
|
||||||
|
tag,
|
||||||
|
'tag',
|
||||||
|
'web.namespace.tag.edit',
|
||||||
|
'web.namespace.tag.delete',
|
||||||
|
'tags',
|
||||||
|
[
|
||||||
|
tag.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro field_entry(ns, field) %}
|
||||||
|
{{
|
||||||
|
obj_entry(
|
||||||
|
ns,
|
||||||
|
field,
|
||||||
|
'field',
|
||||||
|
'web.namespace.field.edit',
|
||||||
|
'web.namespace.field.delete',
|
||||||
|
'tags',
|
||||||
|
[
|
||||||
|
field.name,
|
||||||
|
field.field_type
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
<h1>Tags, Fields & Categories</h1>
|
||||||
|
<h2>Tags</h2>
|
||||||
|
<table class="list">
|
||||||
|
{{obj_colgroup(['Name'])}}
|
||||||
|
<tbody>
|
||||||
|
{{obj_header(['Name'])}}
|
||||||
|
{{obj_add_row(
|
||||||
|
'tag',
|
||||||
|
can_create_tag,
|
||||||
|
url_for(
|
||||||
|
'web.namespace.tag.new',
|
||||||
|
id=ns.id,
|
||||||
|
next=cur_page_with_variables(
|
||||||
|
request,
|
||||||
|
active_tab='tags'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
['Name']
|
||||||
|
)}}
|
||||||
|
{% for tag in tags %}
|
||||||
|
{{tag_entry(ns, tag)}}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr style="margin-top: 3rem;"/>
|
||||||
|
|
||||||
|
<h2>Fields</h2>
|
||||||
|
<table class="list">
|
||||||
|
{{obj_colgroup(['Name', 'Type'])}}
|
||||||
|
<tbody>
|
||||||
|
{{obj_header(['Name', 'Type'])}}
|
||||||
|
{{obj_add_row(
|
||||||
|
'field',
|
||||||
|
can_create_field,
|
||||||
|
url_for(
|
||||||
|
'web.namespace.field.new',
|
||||||
|
id=ns.id,
|
||||||
|
next=cur_page_with_variables(
|
||||||
|
request,
|
||||||
|
active_tab='tags'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
['Name', 'Type']
|
||||||
|
)}}
|
||||||
|
{% for field in fields %}
|
||||||
|
{{field_entry(ns, field)}}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<hr style="margin-top: 3rem;"/>
|
||||||
|
|
||||||
|
<h2>Categories</h2>
|
||||||
|
|
||||||
|
<table class="list">
|
||||||
|
{{obj_colgroup(['Name'])}}
|
||||||
|
<tbody>
|
||||||
|
{{obj_header(['Name'])}}
|
||||||
|
{{obj_add_row(
|
||||||
|
'category',
|
||||||
|
can_create_cat,
|
||||||
|
url_for(
|
||||||
|
'web.namespace.category.new',
|
||||||
|
id=ns.id,
|
||||||
|
next=cur_page_with_variables(
|
||||||
|
request,
|
||||||
|
active_tab='tags'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
['Name']
|
||||||
|
)}}
|
||||||
|
{% for cat in cats %}
|
||||||
|
{{cat_entry(ns, cat)}}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
16
src/taskflower/templates/namespace/category/new.html
Normal file
16
src/taskflower/templates/namespace/category/new.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends "main.html" %}
|
||||||
|
{% from "_formhelpers.html" import render_field %}
|
||||||
|
|
||||||
|
{% block head_extras %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='forms.css') }}"
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<form class="default-form" id="create-category-form" method="POST">
|
||||||
|
<h1>Create a Category</h1>
|
||||||
|
<dl>
|
||||||
|
{{ render_field(form.name) }}
|
||||||
|
</dl>
|
||||||
|
<button class="icon-btn green" id="submit-form" type="submit">{{icon('add')|safe}}CREATE CATEGORY</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
17
src/taskflower/templates/namespace/field/new.html
Normal file
17
src/taskflower/templates/namespace/field/new.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{% extends "main.html" %}
|
||||||
|
{% from "_formhelpers.html" import render_field %}
|
||||||
|
|
||||||
|
{% block head_extras %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='forms.css') }}"
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<form class="default-form" id="create-field-form" method="POST">
|
||||||
|
<h1>Create a Field</h1>
|
||||||
|
<dl>
|
||||||
|
{{ render_field(form.name) }}
|
||||||
|
{{ render_field(form.data_type) }}
|
||||||
|
</dl>
|
||||||
|
<button class="icon-btn green" id="submit-form" type="submit">{{icon('add')|safe}}CREATE FIELD</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -7,11 +7,11 @@
|
||||||
|
|
||||||
{% block main_content %}
|
{% block main_content %}
|
||||||
<form class="default-form" id="create-namespace-form" method="POST">
|
<form class="default-form" id="create-namespace-form" method="POST">
|
||||||
<h1>Create a Namespace</h1>
|
<h1>Create a Zone</h1>
|
||||||
<dl>
|
<dl>
|
||||||
{{ render_field(form.name) }}
|
{{ render_field(form.name) }}
|
||||||
{{ render_field(form.description) }}
|
{{ render_field(form.description) }}
|
||||||
</dl>
|
</dl>
|
||||||
<button class="icon-btn green" id="submit-form" type="submit">{{icon('add')|safe}}CREATE NAMESPACE</button>
|
<button class="icon-btn green" id="submit-form" type="submit">{{icon('add')|safe}}CREATE ZONE</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
16
src/taskflower/templates/namespace/tag/new.html
Normal file
16
src/taskflower/templates/namespace/tag/new.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends "main.html" %}
|
||||||
|
{% from "_formhelpers.html" import render_field %}
|
||||||
|
|
||||||
|
{% block head_extras %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='forms.css') }}"
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<form class="default-form" id="create-tag-form" method="POST">
|
||||||
|
<h1>Create a Tag</h1>
|
||||||
|
<dl>
|
||||||
|
{{ render_field(form.name) }}
|
||||||
|
</dl>
|
||||||
|
<button class="icon-btn green" id="submit-form" type="submit">{{icon('add')|safe}}CREATE TAG</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -73,7 +73,7 @@ class CheckType[T]:
|
||||||
|
|
||||||
def __call__(self, x: T|Any) -> Either[Exception, T]: # pyright:ignore[reportExplicitAny]
|
def __call__(self, x: T|Any) -> Either[Exception, T]: # pyright:ignore[reportExplicitAny]
|
||||||
try:
|
try:
|
||||||
org = get_origin(self._t) or NoneType
|
org = get_origin(self._t) or self._t
|
||||||
if isinstance(x, org):
|
if isinstance(x, org):
|
||||||
return Right[Exception, T](x)
|
return Right[Exception, T](x)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,19 @@ class Either[L, R](ABC):
|
||||||
) -> X:
|
) -> X:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def val_or_raise(
|
||||||
|
self,
|
||||||
|
exc: Callable[[L], Exception]|None=None
|
||||||
|
) -> R:
|
||||||
|
''' Returns ``R`` if this is a ``Right``; otherwise, raise an exception.
|
||||||
|
|
||||||
|
If ``exc`` is defined, it will be raised in case of a ``Left``. If
|
||||||
|
not and if this is a ``Left[Exception, *]``, then its ``val`` will
|
||||||
|
be raised. Otherwise, ``AssertionError(str(val))`` will be raised.
|
||||||
|
'''
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def do_assert(
|
def do_assert(
|
||||||
x: bool,
|
x: bool,
|
||||||
|
|
@ -149,6 +162,18 @@ class Left[L, R](Either[L, R]):
|
||||||
) -> X:
|
) -> X:
|
||||||
return if_not(self.val)
|
return if_not(self.val)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def val_or_raise(
|
||||||
|
self,
|
||||||
|
exc: Callable[[L], Exception]|None=None
|
||||||
|
) -> R:
|
||||||
|
if exc:
|
||||||
|
raise exc(self.val)
|
||||||
|
elif isinstance(self.val, Exception):
|
||||||
|
raise self.val
|
||||||
|
else:
|
||||||
|
raise AssertionError(str(self.val))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def __eq__(self, value: object) -> bool:
|
def __eq__(self, value: object) -> bool:
|
||||||
if isinstance(value, Left):
|
if isinstance(value, Left):
|
||||||
|
|
@ -222,6 +247,13 @@ class Right[L, R](Either[L, R]):
|
||||||
) -> X:
|
) -> X:
|
||||||
return if_okay(self.val)
|
return if_okay(self.val)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def val_or_raise(
|
||||||
|
self,
|
||||||
|
exc: Callable[[L], Exception]|None=None
|
||||||
|
) -> R:
|
||||||
|
return self.val
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def __eq__(self, value: object) -> bool:
|
def __eq__(self, value: object) -> bool:
|
||||||
if isinstance(value, Right):
|
if isinstance(value, Right):
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ 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.admin import web_namespace_admin
|
||||||
|
from taskflower.web.namespace.category import web_namespace_cat
|
||||||
|
from taskflower.web.namespace.field import web_namespace_field
|
||||||
from taskflower.web.namespace.roles import web_namespace_roles
|
from taskflower.web.namespace.roles import web_namespace_roles
|
||||||
|
from taskflower.web.namespace.tag import web_namespace_tag
|
||||||
|
|
||||||
web_namespace = Blueprint(
|
web_namespace = Blueprint(
|
||||||
'namespace',
|
'namespace',
|
||||||
|
|
@ -28,6 +31,9 @@ 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.register_blueprint(web_namespace_admin)
|
||||||
|
web_namespace.register_blueprint(web_namespace_tag)
|
||||||
|
web_namespace.register_blueprint(web_namespace_cat)
|
||||||
|
web_namespace.register_blueprint(web_namespace_field)
|
||||||
|
|
||||||
@web_namespace.app_context_processor
|
@web_namespace.app_context_processor
|
||||||
def namespace_processor():
|
def namespace_processor():
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ 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 import db, db_fetch_by_id, do_commit
|
||||||
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.tag import Field, Tag, TaskCategory
|
||||||
from taskflower.db.model.user import User
|
from taskflower.db.model.user import User
|
||||||
from taskflower.form.namespace import namespace_edit_form_for_ns
|
from taskflower.form.namespace import namespace_edit_form_for_ns
|
||||||
from taskflower.types import assert_usr
|
from taskflower.types import assert_usr
|
||||||
|
|
@ -45,6 +46,67 @@ def render_general_page(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def render_tags_page(
|
||||||
|
ns: Namespace,
|
||||||
|
cur_usr: User
|
||||||
|
) -> Either[Exception, str]:
|
||||||
|
can_edit_tags = check_user_perms_on_namespace(
|
||||||
|
cur_usr,
|
||||||
|
ns,
|
||||||
|
NPT.EDIT_TAGS
|
||||||
|
)
|
||||||
|
can_edit_fields = check_user_perms_on_namespace(
|
||||||
|
cur_usr,
|
||||||
|
ns,
|
||||||
|
NPT.EDIT_FIELDS
|
||||||
|
)
|
||||||
|
can_edit_cats = check_user_perms_on_namespace(
|
||||||
|
cur_usr,
|
||||||
|
ns,
|
||||||
|
NPT.EDIT_CATEGORIES
|
||||||
|
)
|
||||||
|
|
||||||
|
tags = [
|
||||||
|
{'name': t.name, 'id': t.id, 'can_edit': can_edit_tags}
|
||||||
|
for t in db.session.query(
|
||||||
|
Tag
|
||||||
|
).filter(
|
||||||
|
Tag.namespace == ns.id
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
fields = [
|
||||||
|
{
|
||||||
|
'name': f.name,
|
||||||
|
'id': f.id,
|
||||||
|
'field_type': f.dtype.name.title(),
|
||||||
|
'can_edit': can_edit_fields
|
||||||
|
}
|
||||||
|
for f in db.session.query(
|
||||||
|
Field
|
||||||
|
).filter(
|
||||||
|
Field.namespace == ns.id
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
cats = [
|
||||||
|
{'name': c.name, 'id': c.id, 'can_edit': can_edit_cats}
|
||||||
|
for c in db.session.query(
|
||||||
|
TaskCategory
|
||||||
|
).filter(
|
||||||
|
TaskCategory.namespace == ns.id
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
return Right(render_template(
|
||||||
|
'namespace/admin/tags_fields_cats_page.html',
|
||||||
|
can_create_tag=can_edit_tags,
|
||||||
|
can_create_field=can_edit_fields,
|
||||||
|
can_create_cat=can_edit_cats,
|
||||||
|
tags=tags,
|
||||||
|
fields=fields,
|
||||||
|
cats=cats,
|
||||||
|
ns=ns
|
||||||
|
))
|
||||||
|
|
||||||
@web_namespace_admin.route('/')
|
@web_namespace_admin.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
def settings(id: int):
|
def settings(id: int):
|
||||||
|
|
@ -60,6 +122,15 @@ def settings(id: int):
|
||||||
Namespace,
|
Namespace,
|
||||||
id,
|
id,
|
||||||
db
|
db
|
||||||
|
).flat_map(
|
||||||
|
lambda ns: assert_user_perms_on_namespace(
|
||||||
|
cur_usr,
|
||||||
|
ns,
|
||||||
|
NPT.READ,
|
||||||
|
'Open namespace settings'
|
||||||
|
)
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
).map(
|
).map(
|
||||||
lambda ns: (
|
lambda ns: (
|
||||||
ns,
|
ns,
|
||||||
|
|
@ -76,12 +147,19 @@ def settings(id: int):
|
||||||
).and_then(
|
).and_then(
|
||||||
lambda val: val,
|
lambda val: val,
|
||||||
lambda exc: None
|
lambda exc: None
|
||||||
|
),
|
||||||
|
render_tags_page(
|
||||||
|
ns,
|
||||||
|
cur_usr
|
||||||
|
).and_then(
|
||||||
|
lambda val: val,
|
||||||
|
lambda exc: None
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(res, Right):
|
if isinstance(res, Right):
|
||||||
ns, role_page, general_page = res.val
|
ns, role_page, general_page, tags_page = res.val
|
||||||
tfc_page = '<h1>Tags, Fields and Categories</h1>'
|
tfc_page = '<h1>Tags, Fields and Categories</h1>'
|
||||||
if not active_tab:
|
if not active_tab:
|
||||||
active_tab = (
|
active_tab = (
|
||||||
|
|
@ -97,7 +175,7 @@ def settings(id: int):
|
||||||
return render_template(
|
return render_template(
|
||||||
'namespace/admin/base.html',
|
'namespace/admin/base.html',
|
||||||
general_page=general_page,
|
general_page=general_page,
|
||||||
tfc_page='<h1>Tags, Fields and Categories</h1>',
|
tfc_page=tags_page,
|
||||||
role_page=role_page,
|
role_page=role_page,
|
||||||
namespace_id=ns.id,
|
namespace_id=ns.id,
|
||||||
namespace_name=ns.name,
|
namespace_name=ns.name,
|
||||||
|
|
|
||||||
124
src/taskflower/web/namespace/category.py
Normal file
124
src/taskflower/web/namespace/category.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
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_perms_on_namespace
|
||||||
|
from taskflower.auth.violations import check_for_auth_err_and_report
|
||||||
|
from taskflower.db import db, db_fetch_by_id, do_delete
|
||||||
|
from taskflower.db.helpers import add_to_db
|
||||||
|
from taskflower.db.model.namespace import Namespace
|
||||||
|
from taskflower.db.model.tag import TaskCategory
|
||||||
|
from taskflower.form.category import get_category_form_for
|
||||||
|
from taskflower.types import assert_usr
|
||||||
|
from taskflower.types.either import Right
|
||||||
|
from taskflower.web.errors import response_from_exception
|
||||||
|
from taskflower.web.utils.request import get_next
|
||||||
|
|
||||||
|
web_namespace_cat = Blueprint(
|
||||||
|
'category',
|
||||||
|
__name__,
|
||||||
|
url_prefix='/<int:id>/cat'
|
||||||
|
)
|
||||||
|
|
||||||
|
@web_namespace_cat.route('/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def new(id: int):
|
||||||
|
cur_usr = assert_usr(current_user)
|
||||||
|
next = get_next(
|
||||||
|
request,
|
||||||
|
get_ns:=url_for('web.namespace.get', id=id)
|
||||||
|
).and_then(
|
||||||
|
lambda val: val,
|
||||||
|
lambda err: get_ns
|
||||||
|
)
|
||||||
|
|
||||||
|
res = db_fetch_by_id(
|
||||||
|
Namespace,
|
||||||
|
id,
|
||||||
|
db
|
||||||
|
).flat_map(
|
||||||
|
lambda ns: assert_user_perms_on_namespace(
|
||||||
|
cur_usr,
|
||||||
|
ns,
|
||||||
|
NPT.EDIT_CATEGORIES,
|
||||||
|
'Create category'
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(res, Right):
|
||||||
|
ns = res.val
|
||||||
|
form = get_category_form_for(ns)(request.form)
|
||||||
|
|
||||||
|
if request.method == 'POST' and form.validate():
|
||||||
|
return form.create_object(
|
||||||
|
cur_usr
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
).flat_map(
|
||||||
|
add_to_db
|
||||||
|
).and_then(
|
||||||
|
lambda _: redirect(next),
|
||||||
|
lambda exc: response_from_exception(exc)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return render_template(
|
||||||
|
'namespace/category/new.html',
|
||||||
|
form=form
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return response_from_exception(
|
||||||
|
res.assert_left().val
|
||||||
|
)
|
||||||
|
|
||||||
|
@web_namespace_cat.route('/<int:tid>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def edit(id: int, tid: int):
|
||||||
|
next = get_next(
|
||||||
|
request,
|
||||||
|
get_ns:=url_for('web.namespace.get', id=id)
|
||||||
|
).and_then(
|
||||||
|
lambda val: val,
|
||||||
|
lambda err: get_ns
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(next)
|
||||||
|
|
||||||
|
@web_namespace_cat.route('/<int:tid>/delete')
|
||||||
|
@login_required
|
||||||
|
def delete(id: int, tid: int):
|
||||||
|
cur_usr = assert_usr(current_user)
|
||||||
|
next = get_next(
|
||||||
|
request,
|
||||||
|
get_ns:=url_for('web.namespace.get', id=id)
|
||||||
|
).and_then(
|
||||||
|
lambda val: val,
|
||||||
|
lambda err: get_ns
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_fetch_by_id(
|
||||||
|
Namespace,
|
||||||
|
id,
|
||||||
|
db
|
||||||
|
).flat_map(
|
||||||
|
lambda ns: assert_user_perms_on_namespace(
|
||||||
|
cur_usr,
|
||||||
|
ns,
|
||||||
|
NPT.EDIT_CATEGORIES,
|
||||||
|
'Delete category'
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
).flat_map(
|
||||||
|
lambda _: db_fetch_by_id(
|
||||||
|
TaskCategory,
|
||||||
|
tid,
|
||||||
|
db
|
||||||
|
)
|
||||||
|
).flat_map(
|
||||||
|
lambda cat: do_delete(cat, db)
|
||||||
|
)
|
||||||
|
).and_then(
|
||||||
|
lambda _: redirect(next),
|
||||||
|
lambda exc: response_from_exception(exc)
|
||||||
|
)
|
||||||
124
src/taskflower/web/namespace/field.py
Normal file
124
src/taskflower/web/namespace/field.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
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_perms_on_namespace
|
||||||
|
from taskflower.auth.violations import check_for_auth_err_and_report
|
||||||
|
from taskflower.db import db, db_fetch_by_id, do_delete
|
||||||
|
from taskflower.db.helpers import add_to_db
|
||||||
|
from taskflower.db.model.namespace import Namespace
|
||||||
|
from taskflower.db.model.tag import Field
|
||||||
|
from taskflower.form.field import get_field_form_for
|
||||||
|
from taskflower.types import assert_usr
|
||||||
|
from taskflower.types.either import Right
|
||||||
|
from taskflower.web.errors import response_from_exception
|
||||||
|
from taskflower.web.utils.request import get_next
|
||||||
|
|
||||||
|
web_namespace_field = Blueprint(
|
||||||
|
'field',
|
||||||
|
__name__,
|
||||||
|
url_prefix='/<int:id>/field'
|
||||||
|
)
|
||||||
|
|
||||||
|
@web_namespace_field.route('/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def new(id: int):
|
||||||
|
cur_usr = assert_usr(current_user)
|
||||||
|
next = get_next(
|
||||||
|
request,
|
||||||
|
get_ns:=url_for('web.namespace.get', id=id)
|
||||||
|
).and_then(
|
||||||
|
lambda val: val,
|
||||||
|
lambda err: get_ns
|
||||||
|
)
|
||||||
|
|
||||||
|
res = db_fetch_by_id(
|
||||||
|
Namespace,
|
||||||
|
id,
|
||||||
|
db
|
||||||
|
).flat_map(
|
||||||
|
lambda ns: assert_user_perms_on_namespace(
|
||||||
|
cur_usr,
|
||||||
|
ns,
|
||||||
|
NPT.EDIT_FIELDS,
|
||||||
|
'Create field'
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(res, Right):
|
||||||
|
ns = res.val
|
||||||
|
form = get_field_form_for(ns)(request.form)
|
||||||
|
|
||||||
|
if request.method == 'POST' and form.validate():
|
||||||
|
return form.create_object(
|
||||||
|
cur_usr
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
).flat_map(
|
||||||
|
add_to_db
|
||||||
|
).and_then(
|
||||||
|
lambda _: redirect(next),
|
||||||
|
lambda exc: response_from_exception(exc)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return render_template(
|
||||||
|
'namespace/field/new.html',
|
||||||
|
form=form
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return response_from_exception(
|
||||||
|
res.assert_left().val
|
||||||
|
)
|
||||||
|
|
||||||
|
@web_namespace_field.route('/<int:tid>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def edit(id: int, tid: int):
|
||||||
|
next = get_next(
|
||||||
|
request,
|
||||||
|
get_ns:=url_for('web.namespace.get', id=id)
|
||||||
|
).and_then(
|
||||||
|
lambda val: val,
|
||||||
|
lambda err: get_ns
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(next)
|
||||||
|
|
||||||
|
@web_namespace_field.route('/<int:tid>/delete')
|
||||||
|
@login_required
|
||||||
|
def delete(id: int, tid: int):
|
||||||
|
cur_usr = assert_usr(current_user)
|
||||||
|
next = get_next(
|
||||||
|
request,
|
||||||
|
get_ns:=url_for('web.namespace.get', id=id)
|
||||||
|
).and_then(
|
||||||
|
lambda val: val,
|
||||||
|
lambda err: get_ns
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_fetch_by_id(
|
||||||
|
Namespace,
|
||||||
|
id,
|
||||||
|
db
|
||||||
|
).flat_map(
|
||||||
|
lambda ns: assert_user_perms_on_namespace(
|
||||||
|
cur_usr,
|
||||||
|
ns,
|
||||||
|
NPT.EDIT_FIELDS,
|
||||||
|
'Delete field'
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
).flat_map(
|
||||||
|
lambda _: db_fetch_by_id(
|
||||||
|
Field,
|
||||||
|
tid,
|
||||||
|
db
|
||||||
|
)
|
||||||
|
).flat_map(
|
||||||
|
lambda cat: do_delete(cat, db)
|
||||||
|
)
|
||||||
|
).and_then(
|
||||||
|
lambda _: redirect(next),
|
||||||
|
lambda exc: response_from_exception(exc)
|
||||||
|
)
|
||||||
124
src/taskflower/web/namespace/tag.py
Normal file
124
src/taskflower/web/namespace/tag.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
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_perms_on_namespace
|
||||||
|
from taskflower.auth.violations import check_for_auth_err_and_report
|
||||||
|
from taskflower.db import db, db_fetch_by_id, do_delete
|
||||||
|
from taskflower.db.helpers import add_to_db
|
||||||
|
from taskflower.db.model.namespace import Namespace
|
||||||
|
from taskflower.db.model.tag import Tag
|
||||||
|
from taskflower.form.tag import get_tag_form_for
|
||||||
|
from taskflower.types import assert_usr
|
||||||
|
from taskflower.types.either import Right
|
||||||
|
from taskflower.web.errors import response_from_exception
|
||||||
|
from taskflower.web.utils.request import get_next
|
||||||
|
|
||||||
|
web_namespace_tag = Blueprint(
|
||||||
|
'tag',
|
||||||
|
__name__,
|
||||||
|
url_prefix='/<int:id>/tag'
|
||||||
|
)
|
||||||
|
|
||||||
|
@web_namespace_tag.route('/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def new(id: int):
|
||||||
|
cur_usr = assert_usr(current_user)
|
||||||
|
next = get_next(
|
||||||
|
request,
|
||||||
|
get_ns:=url_for('web.namespace.get', id=id)
|
||||||
|
).and_then(
|
||||||
|
lambda val: val,
|
||||||
|
lambda err: get_ns
|
||||||
|
)
|
||||||
|
|
||||||
|
res = db_fetch_by_id(
|
||||||
|
Namespace,
|
||||||
|
id,
|
||||||
|
db
|
||||||
|
).flat_map(
|
||||||
|
lambda ns: assert_user_perms_on_namespace(
|
||||||
|
cur_usr,
|
||||||
|
ns,
|
||||||
|
NPT.EDIT_TAGS,
|
||||||
|
'Create tag'
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(res, Right):
|
||||||
|
ns = res.val
|
||||||
|
form = get_tag_form_for(ns)(request.form)
|
||||||
|
|
||||||
|
if request.method == 'POST' and form.validate():
|
||||||
|
return form.create_object(
|
||||||
|
cur_usr
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
).flat_map(
|
||||||
|
add_to_db
|
||||||
|
).and_then(
|
||||||
|
lambda _: redirect(next),
|
||||||
|
lambda exc: response_from_exception(exc)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return render_template(
|
||||||
|
'namespace/tag/new.html',
|
||||||
|
form=form
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return response_from_exception(
|
||||||
|
res.assert_left().val
|
||||||
|
)
|
||||||
|
|
||||||
|
@web_namespace_tag.route('/<int:tid>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def edit(id: int, tid: int):
|
||||||
|
next = get_next(
|
||||||
|
request,
|
||||||
|
get_ns:=url_for('web.namespace.get', id=id)
|
||||||
|
).and_then(
|
||||||
|
lambda val: val,
|
||||||
|
lambda err: get_ns
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(next)
|
||||||
|
|
||||||
|
@web_namespace_tag.route('/<int:tid>/delete')
|
||||||
|
@login_required
|
||||||
|
def delete(id: int, tid: int):
|
||||||
|
cur_usr = assert_usr(current_user)
|
||||||
|
next = get_next(
|
||||||
|
request,
|
||||||
|
get_ns:=url_for('web.namespace.get', id=id)
|
||||||
|
).and_then(
|
||||||
|
lambda val: val,
|
||||||
|
lambda err: get_ns
|
||||||
|
)
|
||||||
|
|
||||||
|
return db_fetch_by_id(
|
||||||
|
Namespace,
|
||||||
|
id,
|
||||||
|
db
|
||||||
|
).flat_map(
|
||||||
|
lambda ns: assert_user_perms_on_namespace(
|
||||||
|
cur_usr,
|
||||||
|
ns,
|
||||||
|
NPT.EDIT_TAGS,
|
||||||
|
'Delete tag'
|
||||||
|
).lside_effect(
|
||||||
|
check_for_auth_err_and_report
|
||||||
|
).flat_map(
|
||||||
|
lambda _: db_fetch_by_id(
|
||||||
|
Tag,
|
||||||
|
tid,
|
||||||
|
db
|
||||||
|
)
|
||||||
|
).flat_map(
|
||||||
|
lambda tag: do_delete(tag, db)
|
||||||
|
)
|
||||||
|
).and_then(
|
||||||
|
lambda _: redirect(next),
|
||||||
|
lambda exc: response_from_exception(exc)
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue