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='') %} +
| Edit | Display Name | 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 %} + +
|---|