diff --git a/.gitignore b/.gitignore index b8b2201..0b8f436 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ __pycache__ /instance /src/instance -/.local \ No newline at end of file +/.local +.secrets \ No newline at end of file diff --git a/src/migrations/env.py b/src/migrations/env.py index 4c97092..92177cc 100644 --- a/src/migrations/env.py +++ b/src/migrations/env.py @@ -9,9 +9,11 @@ from alembic import context # access to the values within the .ini file in use. config = context.config -# Interpret the config file for Python logging. -# This line sets up loggers basically. -fileConfig(config.config_file_name) +# for some reason, flask-migrate decided it was a good idea to override our +# logging config whenever we call any of their functions >:( +# +# this line has been commented out in order to remove this "feature" +# fileConfig(config.config_file_name) logger = logging.getLogger('alembic.env') diff --git a/src/migrations/versions/067ee615c967_fix_tag_field_category_links.py b/src/migrations/versions/067ee615c967_fix_tag_field_category_links.py new file mode 100644 index 0000000..3a84fdf --- /dev/null +++ b/src/migrations/versions/067ee615c967_fix_tag_field_category_links.py @@ -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 ### diff --git a/src/migrations/versions/6db9f9b83a8d_base.py b/src/migrations/versions/6db9f9b83a8d_base.py new file mode 100644 index 0000000..fed760f --- /dev/null +++ b/src/migrations/versions/6db9f9b83a8d_base.py @@ -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 ### diff --git a/src/migrations/versions/945140c35257_add_completed_key.py b/src/migrations/versions/945140c35257_add_completed_key.py index 6d3a48b..d1cced6 100644 --- a/src/migrations/versions/945140c35257_add_completed_key.py +++ b/src/migrations/versions/945140c35257_add_completed_key.py @@ -11,7 +11,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '945140c35257' -down_revision = None +down_revision = '6db9f9b83a8d' branch_labels = None depends_on = None diff --git a/src/taskflower/__init__.py b/src/taskflower/__init__.py index 201852c..e8130d4 100644 --- a/src/taskflower/__init__.py +++ b/src/taskflower/__init__.py @@ -1,7 +1,7 @@ import logging from typing import Any 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.startup import startup_checks @@ -18,7 +18,14 @@ from taskflower.web import web_base 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__) @@ -48,7 +55,11 @@ APIBase.register(app) # print(f'Routes: \n{"\n".join([str(r) for r in APIBase.routes])}') 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...') res = startup_checks(db) @@ -109,6 +120,9 @@ def template_utility_fns(): **new_args # pyright:ignore[reportAny] ) + def t_len(ls: list[Any]) -> int: # pyright:ignore[reportExplicitAny] + return len(ls) + return dict( literal_call=literal_call, reltime=render_reltime, @@ -116,7 +130,8 @@ def template_utility_fns(): can_generate_sign_up_codes=can_generate_sign_up_codes, render_as_markdown=render_as_markdown, icon=icon, - cur_page_with_variables=cur_page_with_variables + cur_page_with_variables=cur_page_with_variables, + len=t_len ) @app.route('/') diff --git a/src/taskflower/auth/permission/__init__.py b/src/taskflower/auth/permission/__init__.py index c0125ad..7b8a5c0 100644 --- a/src/taskflower/auth/permission/__init__.py +++ b/src/taskflower/auth/permission/__init__.py @@ -27,6 +27,7 @@ class NamespacePermissionType(IntFlag): DELETE_ALL_TASKS = (1 << 10) EDIT_TAGS = (1 << 12) EDIT_FIELDS = (1 << 13) + EDIT_CATEGORIES = (1 << 15) EDIT_ROLES = (1 << 11) RENAME = (1 << 14) ADMINISTRATE = (1 << 1) @@ -71,6 +72,7 @@ SELF_NAMESPACE_PERMISSIONS = ( | NamespacePermissionType.DELETE_ALL_TASKS | NamespacePermissionType.EDIT_TAGS | NamespacePermissionType.EDIT_FIELDS + | NamespacePermissionType.EDIT_CATEGORIES | NamespacePermissionType.EDIT_ROLES | NamespacePermissionType.RENAME ) @@ -78,7 +80,7 @@ SELF_NAMESPACE_PERMISSIONS = ( def user_friendly_name(perm: NamespacePermissionType|UserPermissionType): match perm: case NamespacePermissionType.READ: - return 'See the namespace.' + return 'See the Zone.' case NamespacePermissionType.CREATE_TASKS_IN: return 'Create tasks.' case NamespacePermissionType.COMPLETE_ALL_TASKS: @@ -86,15 +88,19 @@ def user_friendly_name(perm: NamespacePermissionType|UserPermissionType): case NamespacePermissionType.UNCOMPLETE_ALL_TASKS: return 'Uncomplete 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: return 'Delete tasks.' case NamespacePermissionType.EDIT_TAGS: return 'Create new tags and delete existing ones.' case NamespacePermissionType.EDIT_FIELDS: return 'Create new fields and delete existing ones.' + case NamespacePermissionType.EDIT_CATEGORIES: + return 'Create new categories and delete existing ones.' case NamespacePermissionType.EDIT_ROLES: return 'Manage roles lower than this one.' + case NamespacePermissionType.RENAME: + return 'Change the name and description of this Zone.' case NamespacePermissionType.ADMINISTRATE: return 'Administrator. Users with this role bypass all other checks.' case UserPermissionType.READ_PROFILE: diff --git a/src/taskflower/db/model/tag.py b/src/taskflower/db/model/tag.py index 92b290c..f7a0bba 100644 --- a/src/taskflower/db/model/tag.py +++ b/src/taskflower/db/model/tag.py @@ -4,7 +4,9 @@ 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.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.either import Either, Left, Right from taskflower.util.time import destringify_dt, stringify_dt @@ -28,6 +30,14 @@ class FieldType(Enum): FLOAT = float 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]: if len(v) <= FIELD_VAL_MAX_LEN: return Right(v) @@ -41,6 +51,15 @@ class Tag(db.Model): 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 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): __tablename__: str = 'category' @@ -48,26 +67,73 @@ class TaskCategory(db.Model): 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 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): __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] + for k, v in FieldType._member_map_.items(): if self.raw_dtype == k: return AssertType(FieldType)(v) 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 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: return Left(TypeError( '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]: - 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( - 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( @@ -116,24 +187,22 @@ class Field(db.Model): ) @classmethod - def from_vals( + def from_data( cls, - name: str, - dtype: FieldType, - val: FieldTypeAlias, - namespace_id: int + field: Field, + task: Task, + val: FieldTypeAlias ) -> 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] + field = field.id, # pyright:ignore[reportCallIssue] + task = task.id, # pyright:ignore[reportCallIssue] + raw_val = v, # pyright:ignore[reportCallIssue] ) ) - match dtype: + match field.dtype: case FieldType.INVALID: return Left(KeyError( '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)) ) -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_: diff --git a/src/taskflower/db/model/task.py b/src/taskflower/db/model/task.py index 66ec629..18abe71 100644 --- a/src/taskflower/db/model/task.py +++ b/src/taskflower/db/model/task.py @@ -15,6 +15,7 @@ class Task(db.Model): name : Mapped[str] = mapped_column(String(64)) 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)) created : Mapped[datetime] = mapped_column(DateTime(timezone=False), default=now) complete : Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/src/taskflower/form/__init__.py b/src/taskflower/form/__init__.py index b031a01..83c23b8 100644 --- a/src/taskflower/form/__init__.py +++ b/src/taskflower/form/__init__.py @@ -19,6 +19,7 @@ class FormCreatesObject[T](Form): ``AuthorizationError`` if the action is not authorized. ''' raise NotImplementedError +FormCObj = FormCreatesObject class FormCreatesObjectWithUser[T](Form): ''' 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. ''' raise NotImplementedError - +FormCObjUser = FormCreatesObjectWithUser + class FormEditsObjectWithUser[T](Form): ''' Trait that indicates that this ``Form`` can be used to edit an object if provided with a ``User`` object. @@ -54,6 +56,7 @@ class FormEditsObjectWithUser[T](Form): edit an object with the specified parameters. ''' raise NotImplementedError() +FormEObjUser = FormEditsObjectWithUser class FormCreatesObjectWithUserAndNamespace[T](Form): ''' 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. ''' raise NotImplementedError - +FormCObjUserNS = FormCreatesObjectWithUserAndNamespace + class FormEditsObjectWithUserAndNamespace[T](Form): ''' Trait that indicates that this ``Form`` can be used to edit an object if provided with a ``User`` object and a ``Namespace``. @@ -91,6 +95,7 @@ class FormEditsObjectWithUserAndNamespace[T](Form): edit an object with the specified parameters. ''' raise NotImplementedError() +FormEObjUserNS = FormEditsObjectWithUserAndNamespace class FormCEObjUserNS[T]( FormCreatesObjectWithUserAndNamespace[T], diff --git a/src/taskflower/form/category.py b/src/taskflower/form/category.py new file mode 100644 index 0000000..d1f7145 --- /dev/null +++ b/src/taskflower/form/category.py @@ -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 \ No newline at end of file diff --git a/src/taskflower/form/field.py b/src/taskflower/form/field.py new file mode 100644 index 0000000..20bc3fb --- /dev/null +++ b/src/taskflower/form/field.py @@ -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 \ No newline at end of file diff --git a/src/taskflower/form/tag.py b/src/taskflower/form/tag.py new file mode 100644 index 0000000..94005d9 --- /dev/null +++ b/src/taskflower/form/tag.py @@ -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 \ No newline at end of file diff --git a/src/taskflower/templates/namespace/admin/_helpers.html b/src/taskflower/templates/namespace/admin/_helpers.html new file mode 100644 index 0000000..d8547c5 --- /dev/null +++ b/src/taskflower/templates/namespace/admin/_helpers.html @@ -0,0 +1,97 @@ +{% macro link_button_elem(obj_type, action_type, enabled, url, btn_icon, extra_cls='') %} + + {% if enabled %} + {{btn_icon|safe}} + {% else %} + {{icon('not-allowed')|safe}} + {% endif %} + +{% endmacro %} + +{% macro obj_entry(ns, obj, obj_type, edit_endpoint, delete_endpoint, tab, entries) %} + + {{ + 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 %} + {{entry}} + {% endfor %} + +{% endmacro %} + +{% macro obj_colgroup(entries) %} + + + + {% for entry in entries %} + + {% endfor %} + +{% endmacro %} + +{% macro obj_header(entries) %} + + Edit + Del + {% for entry in entries %} + {{entry}} + {% endfor %} + +{% endmacro %} + +{% macro obj_add_row(obj_type, can_add, url, entries) %} + + + {% if can_add %} + {{icon('add')|safe}} Add a new {{obj_type}} + {% else %} + {{icon('not-allowed')|safe}} Add a new {{obj_type}} + {% endif %} + + +{% endmacro %} \ No newline at end of file diff --git a/src/taskflower/templates/namespace/admin/role_user_page.html b/src/taskflower/templates/namespace/admin/role_user_page.html index 9eeca20..a52be5c 100644 --- a/src/taskflower/templates/namespace/admin/role_user_page.html +++ b/src/taskflower/templates/namespace/admin/role_user_page.html @@ -43,12 +43,12 @@ {% endif %} + + + + + - - - - - diff --git a/src/taskflower/templates/namespace/admin/tags_fields_cats_page.html b/src/taskflower/templates/namespace/admin/tags_fields_cats_page.html new file mode 100644 index 0000000..cdb11b6 --- /dev/null +++ b/src/taskflower/templates/namespace/admin/tags_fields_cats_page.html @@ -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 %} + +

Tags, Fields & Categories

+

Tags

+
Edit Display Name
+ {{obj_colgroup(['Name'])}} + + {{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 %} + +
+ +
+ +

Fields

+ + {{obj_colgroup(['Name', 'Type'])}} + + {{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 %} + +
+ +
+ +

Categories

+ + + {{obj_colgroup(['Name'])}} + + {{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 %} + +
\ No newline at end of file diff --git a/src/taskflower/templates/namespace/category/new.html b/src/taskflower/templates/namespace/category/new.html new file mode 100644 index 0000000..2d2083a --- /dev/null +++ b/src/taskflower/templates/namespace/category/new.html @@ -0,0 +1,16 @@ +{% extends "main.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block head_extras %} + +

Create a Category

+
+ {{ render_field(form.name) }} +
+ + +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/namespace/field/new.html b/src/taskflower/templates/namespace/field/new.html new file mode 100644 index 0000000..503a982 --- /dev/null +++ b/src/taskflower/templates/namespace/field/new.html @@ -0,0 +1,17 @@ +{% extends "main.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block head_extras %} + +

Create a Field

+
+ {{ render_field(form.name) }} + {{ render_field(form.data_type) }} +
+ + +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/namespace/new.html b/src/taskflower/templates/namespace/new.html index 141e11c..2a61aea 100644 --- a/src/taskflower/templates/namespace/new.html +++ b/src/taskflower/templates/namespace/new.html @@ -7,11 +7,11 @@ {% block main_content %}
-

Create a Namespace

+

Create a Zone

{{ render_field(form.name) }} {{ render_field(form.description) }}
- +
{% endblock %} \ No newline at end of file diff --git a/src/taskflower/templates/namespace/tag/new.html b/src/taskflower/templates/namespace/tag/new.html new file mode 100644 index 0000000..7e8ce7c --- /dev/null +++ b/src/taskflower/templates/namespace/tag/new.html @@ -0,0 +1,16 @@ +{% extends "main.html" %} +{% from "_formhelpers.html" import render_field %} + +{% block head_extras %} + +

Create a Tag

+
+ {{ render_field(form.name) }} +
+ + +{% endblock %} \ No newline at end of file diff --git a/src/taskflower/types/__init__.py b/src/taskflower/types/__init__.py index 37d7f0b..d3b90a7 100644 --- a/src/taskflower/types/__init__.py +++ b/src/taskflower/types/__init__.py @@ -73,7 +73,7 @@ class CheckType[T]: def __call__(self, x: T|Any) -> Either[Exception, T]: # pyright:ignore[reportExplicitAny] try: - org = get_origin(self._t) or NoneType + org = get_origin(self._t) or self._t if isinstance(x, org): return Right[Exception, T](x) else: diff --git a/src/taskflower/types/either.py b/src/taskflower/types/either.py index 634f0f9..ed5de98 100644 --- a/src/taskflower/types/either.py +++ b/src/taskflower/types/either.py @@ -71,6 +71,19 @@ class Either[L, R](ABC): ) -> X: 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 def do_assert( x: bool, @@ -149,6 +162,18 @@ class Left[L, R](Either[L, R]): ) -> X: 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 def __eq__(self, value: object) -> bool: if isinstance(value, Left): @@ -222,6 +247,13 @@ class Right[L, R](Either[L, R]): ) -> X: return if_okay(self.val) + @override + def val_or_raise( + self, + exc: Callable[[L], Exception]|None=None + ) -> R: + return self.val + @override def __eq__(self, value: object) -> bool: if isinstance(value, Right): diff --git a/src/taskflower/web/namespace/__init__.py b/src/taskflower/web/namespace/__init__.py index 34fb6b8..541bec5 100644 --- a/src/taskflower/web/namespace/__init__.py +++ b/src/taskflower/web/namespace/__init__.py @@ -17,7 +17,10 @@ from taskflower.types.either import Either, Left, Right, gather_successes from taskflower.types.option import Option from taskflower.web.errors import ResponseErrorNotFound, response_from_exception from taskflower.web.namespace.admin import web_namespace_admin +from taskflower.web.namespace.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.tag import web_namespace_tag web_namespace = Blueprint( 'namespace', @@ -28,6 +31,9 @@ web_namespace = Blueprint( web_namespace.register_blueprint(web_namespace_roles) 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 def namespace_processor(): diff --git a/src/taskflower/web/namespace/admin.py b/src/taskflower/web/namespace/admin.py index c7d2d2b..46f681a 100644 --- a/src/taskflower/web/namespace/admin.py +++ b/src/taskflower/web/namespace/admin.py @@ -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.model.namespace import Namespace 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.form.namespace import namespace_edit_form_for_ns 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('/') @login_required def settings(id: int): @@ -60,6 +122,15 @@ def settings(id: int): Namespace, id, 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( lambda ns: ( ns, @@ -76,12 +147,19 @@ def settings(id: int): ).and_then( lambda val: val, lambda exc: None + ), + render_tags_page( + ns, + cur_usr + ).and_then( + lambda val: val, + lambda exc: None ) ) ) if isinstance(res, Right): - ns, role_page, general_page = res.val + ns, role_page, general_page, tags_page = res.val tfc_page = '

Tags, Fields and Categories

' if not active_tab: active_tab = ( @@ -97,7 +175,7 @@ def settings(id: int): return render_template( 'namespace/admin/base.html', general_page=general_page, - tfc_page='

Tags, Fields and Categories

', + tfc_page=tags_page, role_page=role_page, namespace_id=ns.id, namespace_name=ns.name, diff --git a/src/taskflower/web/namespace/category.py b/src/taskflower/web/namespace/category.py new file mode 100644 index 0000000..caad01a --- /dev/null +++ b/src/taskflower/web/namespace/category.py @@ -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='//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('//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('//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) + ) \ No newline at end of file diff --git a/src/taskflower/web/namespace/field.py b/src/taskflower/web/namespace/field.py new file mode 100644 index 0000000..2887e92 --- /dev/null +++ b/src/taskflower/web/namespace/field.py @@ -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='//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('//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('//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) + ) \ No newline at end of file diff --git a/src/taskflower/web/namespace/tag.py b/src/taskflower/web/namespace/tag.py new file mode 100644 index 0000000..b9be18b --- /dev/null +++ b/src/taskflower/web/namespace/tag.py @@ -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='//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('//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('//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) + ) \ No newline at end of file