diff options
| author | Shyam Sunder | 2020-06-04 21:01:28 -0400 |
|---|---|---|
| committer | Shyam Sunder | 2020-06-04 21:01:28 -0400 |
| commit | c5358f7f83ef49f51d5ba557dde1b16ce2c83d1d (patch) | |
| tree | 253e0be234540c266202993567d998a5c951947d /server | |
| parent | 4329b1620fe38c2a7bf44615cfb176daa2837590 (diff) | |
| parent | b0f1b8c2309277a7b170972d463b4e8d16a79ecc (diff) | |
client+server: add post pools feature
Diffstat (limited to 'server')
32 files changed, 2478 insertions, 3 deletions
diff --git a/server/config.yaml.dist b/server/config.yaml.dist index 4f8f769..937c138 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -49,6 +49,9 @@ enable_safety: yes tag_name_regex: ^\S+$ tag_category_name_regex: ^[^\s%+#/]+$ +pool_name_regex: ^\S+$ +pool_category_name_regex: ^[^\s%+#/]+$ + # don't make these more restrictive unless you want to annoy people; if you do # customize them, make sure to update the instructions in the registration form # template as well. @@ -125,6 +128,24 @@ privileges: 'tag_categories:delete': moderator 'tag_categories:set_default': moderator + 'pools:create': regular + 'pools:edit:names': power + 'pools:edit:category': power + 'pools:edit:description': power + 'pools:edit:posts': power + 'pools:list': regular + 'pools:view': anonymous + 'pools:merge': moderator + 'pools:delete': moderator + + 'pool_categories:create': moderator + 'pool_categories:edit:name': moderator + 'pool_categories:edit:color': moderator + 'pool_categories:list': anonymous + 'pool_categories:view': anonymous + 'pool_categories:delete': moderator + 'pool_categories:set_default': moderator + 'comments:create': regular 'comments:delete:any': moderator 'comments:delete:own': regular diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index 0d7f75f..28f0e98 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -4,6 +4,8 @@ import szurubooru.api.user_token_api import szurubooru.api.post_api import szurubooru.api.tag_api import szurubooru.api.tag_category_api +import szurubooru.api.pool_api +import szurubooru.api.pool_category_api import szurubooru.api.comment_api import szurubooru.api.password_reset_api import szurubooru.api.snapshot_api diff --git a/server/szurubooru/api/pool_api.py b/server/szurubooru/api/pool_api.py new file mode 100644 index 0000000..9627ed2 --- /dev/null +++ b/server/szurubooru/api/pool_api.py @@ -0,0 +1,106 @@ +from typing import Optional, List, Dict +from datetime import datetime +from szurubooru import db, model, search, rest +from szurubooru.func import auth, pools, snapshots, serialization, versions + + +_search_executor = search.Executor(search.configs.PoolSearchConfig()) + + +def _serialize(ctx: rest.Context, pool: model.Pool) -> rest.Response: + return pools.serialize_pool( + pool, options=serialization.get_serialization_options(ctx)) + + +def _get_pool(params: Dict[str, str]) -> model.Pool: + return pools.get_pool_by_id(params['pool_id']) + + +@rest.routes.get('/pools/?') +def get_pools( + ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: + auth.verify_privilege(ctx.user, 'pools:list') + return _search_executor.execute_and_serialize( + ctx, lambda pool: _serialize(ctx, pool)) + + +@rest.routes.post('/pool/?') +def create_pool( + ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: + auth.verify_privilege(ctx.user, 'pools:create') + + names = ctx.get_param_as_string_list('names') + category = ctx.get_param_as_string('category') + description = ctx.get_param_as_string('description', default='') + posts = ctx.get_param_as_int_list('posts', default=[]) + + pool = pools.create_pool(names, category, posts) + pool.last_edit_time = datetime.utcnow() + pools.update_pool_description(pool, description) + ctx.session.add(pool) + ctx.session.flush() + snapshots.create(pool, ctx.user) + ctx.session.commit() + return _serialize(ctx, pool) + + +@rest.routes.get('/pool/(?P<pool_id>[^/]+)/?') +def get_pool(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + auth.verify_privilege(ctx.user, 'pools:view') + pool = _get_pool(params) + return _serialize(ctx, pool) + + +@rest.routes.put('/pool/(?P<pool_id>[^/]+)/?') +def update_pool(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + pool = _get_pool(params) + versions.verify_version(pool, ctx) + versions.bump_version(pool) + if ctx.has_param('names'): + auth.verify_privilege(ctx.user, 'pools:edit:names') + pools.update_pool_names(pool, ctx.get_param_as_string_list('names')) + if ctx.has_param('category'): + auth.verify_privilege(ctx.user, 'pools:edit:category') + pools.update_pool_category_name( + pool, ctx.get_param_as_string('category')) + if ctx.has_param('description'): + auth.verify_privilege(ctx.user, 'pools:edit:description') + pools.update_pool_description( + pool, ctx.get_param_as_string('description')) + if ctx.has_param('posts'): + auth.verify_privilege(ctx.user, 'pools:edit:posts') + posts = ctx.get_param_as_int_list('posts') + pools.update_pool_posts(pool, posts) + pool.last_edit_time = datetime.utcnow() + ctx.session.flush() + snapshots.modify(pool, ctx.user) + ctx.session.commit() + return _serialize(ctx, pool) + + +@rest.routes.delete('/pool/(?P<pool_id>[^/]+)/?') +def delete_pool(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + pool = _get_pool(params) + versions.verify_version(pool, ctx) + auth.verify_privilege(ctx.user, 'pools:delete') + snapshots.delete(pool, ctx.user) + pools.delete(pool) + ctx.session.commit() + return {} + + +@rest.routes.post('/pool-merge/?') +def merge_pools( + ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: + source_pool_id = ctx.get_param_as_string('remove') + target_pool_id = ctx.get_param_as_string('mergeTo') + source_pool = pools.get_pool_by_id(source_pool_id) + target_pool = pools.get_pool_by_id(target_pool_id) + versions.verify_version(source_pool, ctx, 'removeVersion') + versions.verify_version(target_pool, ctx, 'mergeToVersion') + versions.bump_version(target_pool) + auth.verify_privilege(ctx.user, 'pools:merge') + pools.merge_pools(source_pool, target_pool) + snapshots.merge(source_pool, target_pool, ctx.user) + ctx.session.commit() + return _serialize(ctx, target_pool) diff --git a/server/szurubooru/api/pool_category_api.py b/server/szurubooru/api/pool_category_api.py new file mode 100644 index 0000000..f293724 --- /dev/null +++ b/server/szurubooru/api/pool_category_api.py @@ -0,0 +1,89 @@ +from typing import Dict +from szurubooru import model, rest +from szurubooru.func import ( + auth, pools, pool_categories, snapshots, serialization, versions) + + +def _serialize( + ctx: rest.Context, category: model.PoolCategory) -> rest.Response: + return pool_categories.serialize_category( + category, options=serialization.get_serialization_options(ctx)) + + +@rest.routes.get('/pool-categories/?') +def get_pool_categories( + ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: + auth.verify_privilege(ctx.user, 'pool_categories:list') + categories = pool_categories.get_all_categories() + return { + 'results': [_serialize(ctx, category) for category in categories], + } + + +@rest.routes.post('/pool-categories/?') +def create_pool_category( + ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: + auth.verify_privilege(ctx.user, 'pool_categories:create') + name = ctx.get_param_as_string('name') + color = ctx.get_param_as_string('color') + category = pool_categories.create_category(name, color) + ctx.session.add(category) + ctx.session.flush() + snapshots.create(category, ctx.user) + ctx.session.commit() + return _serialize(ctx, category) + + +@rest.routes.get('/pool-category/(?P<category_name>[^/]+)/?') +def get_pool_category( + ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + auth.verify_privilege(ctx.user, 'pool_categories:view') + category = pool_categories.get_category_by_name(params['category_name']) + return _serialize(ctx, category) + + +@rest.routes.put('/pool-category/(?P<category_name>[^/]+)/?') +def update_pool_category( + ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + category = pool_categories.get_category_by_name( + params['category_name'], lock=True) + versions.verify_version(category, ctx) + versions.bump_version(category) + if ctx.has_param('name'): + auth.verify_privilege(ctx.user, 'pool_categories:edit:name') + pool_categories.update_category_name( + category, ctx.get_param_as_string('name')) + if ctx.has_param('color'): + auth.verify_privilege(ctx.user, 'pool_categories:edit:color') + pool_categories.update_category_color( + category, ctx.get_param_as_string('color')) + ctx.session.flush() + snapshots.modify(category, ctx.user) + ctx.session.commit() + return _serialize(ctx, category) + + +@rest.routes.delete('/pool-category/(?P<category_name>[^/]+)/?') +def delete_pool_category( + ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + category = pool_categories.get_category_by_name( + params['category_name'], lock=True) + versions.verify_version(category, ctx) + auth.verify_privilege(ctx.user, 'pool_categories:delete') + pool_categories.delete_category(category) + snapshots.delete(category, ctx.user) + ctx.session.commit() + return {} + + +@rest.routes.put('/pool-category/(?P<category_name>[^/]+)/default/?') +def set_pool_category_as_default( + ctx: rest.Context, params: Dict[str, str]) -> rest.Response: + auth.verify_privilege(ctx.user, 'pool_categories:set_default') + category = pool_categories.get_category_by_name( + params['category_name'], lock=True) + pool_categories.set_default_category(category) + ctx.session.flush() + snapshots.modify(category, ctx.user) + ctx.session.commit() + return _serialize(ctx, category) diff --git a/server/szurubooru/func/pool_categories.py b/server/szurubooru/func/pool_categories.py new file mode 100644 index 0000000..83305de --- /dev/null +++ b/server/szurubooru/func/pool_categories.py @@ -0,0 +1,199 @@ +import re +from typing import Any, Optional, Dict, List, Callable +import sqlalchemy as sa +from szurubooru import config, db, model, errors, rest +from szurubooru.func import util, serialization, cache + + +DEFAULT_CATEGORY_NAME_CACHE_KEY = 'default-pool-category' + + +class PoolCategoryNotFoundError(errors.NotFoundError): + pass + + +class PoolCategoryAlreadyExistsError(errors.ValidationError): + pass + + +class PoolCategoryIsInUseError(errors.ValidationError): + pass + + +class InvalidPoolCategoryNameError(errors.ValidationError): + pass + + +class InvalidPoolCategoryColorError(errors.ValidationError): + pass + + +def _verify_name_validity(name: str) -> None: + name_regex = config.config['pool_category_name_regex'] + if not re.match(name_regex, name): + raise InvalidPoolCategoryNameError( + 'Name must satisfy regex %r.' % name_regex) + + +class PoolCategorySerializer(serialization.BaseSerializer): + def __init__(self, category: model.PoolCategory) -> None: + self.category = category + + def _serializers(self) -> Dict[str, Callable[[], Any]]: + return { + 'name': self.serialize_name, + 'version': self.serialize_version, + 'color': self.serialize_color, + 'usages': self.serialize_usages, + 'default': self.serialize_default, + } + + def serialize_name(self) -> Any: + return self.category.name + + def serialize_version(self) -> Any: + return self.category.version + + def serialize_color(self) -> Any: + return self.category.color + + def serialize_usages(self) -> Any: + return self.category.pool_count + + def serialize_default(self) -> Any: + return self.category.default + + +def serialize_category( + category: Optional[model.PoolCategory], + options: List[str] = []) -> Optional[rest.Response]: + if not category: + return None + return PoolCategorySerializer(category).serialize(options) + + +def create_category(name: str, color: str) -> model.PoolCategory: + category = model.PoolCategory() + update_category_name(category, name) + update_category_color(category, color) + if not get_all_categories(): + category.default = True + return category + + +def update_category_name(category: model.PoolCategory, name: str) -> None: + assert category + if not name: + raise InvalidPoolCategoryNameError('Name cannot be empty.') + expr = sa.func.lower(model.PoolCategory.name) == name.lower() + if category.pool_category_id: + expr = expr & ( + model.PoolCategory.pool_category_id != category.pool_category_id) + already_exists = ( + db.session.query(model.PoolCategory).filter(expr).count() > 0) + if already_exists: + raise PoolCategoryAlreadyExistsError( + 'A category with this name already exists.') + if util.value_exceeds_column_size(name, model.PoolCategory.name): + raise InvalidPoolCategoryNameError('Name is too long.') + _verify_name_validity(name) + category.name = name + cache.remove(DEFAULT_CATEGORY_NAME_CACHE_KEY) + + +def update_category_color(category: model.PoolCategory, color: str) -> None: + assert category + if not color: + raise InvalidPoolCategoryColorError('Color cannot be empty.') + if not re.match(r'^#?[0-9a-z]+$', color): + raise InvalidPoolCategoryColorError('Invalid color.') + if util.value_exceeds_column_size(color, model.PoolCategory.color): + raise InvalidPoolCategoryColorError('Color is too long.') + category.color = color + + +def try_get_category_by_name( + name: str, lock: bool = False) -> Optional[model.PoolCategory]: + query = ( + db.session + .query(model.PoolCategory) + .filter(sa.func.lower(model.PoolCategory.name) == name.lower())) + if lock: + query = query.with_for_update() + return query.one_or_none() + + +def get_category_by_name(name: str, lock: bool = False) -> model.PoolCategory: + category = try_get_category_by_name(name, lock) + if not category: + raise PoolCategoryNotFoundError('Pool category %r not found.' % name) + return category + + +def get_all_category_names() -> List[str]: + return [cat.name for cat in get_all_categories()] + + +def get_all_categories() -> List[model.PoolCategory]: + return db.session.query(model.PoolCategory).order_by( + model.PoolCategory.name.asc()).all() + + +def try_get_default_category( + lock: bool = False) -> Optional[model.PoolCategory]: + query = ( + db.session + .query(model.PoolCategory) + .filter(model.PoolCategory.default)) + if lock: + query = query.with_for_update() + category = query.first() + # if for some reason (e.g. as a result of migration) there's no default + # category, get the first record available. + if not category: + query = ( + db.session + .query(model.PoolCategory) + .order_by(model.PoolCategory.pool_category_id.asc())) + if lock: + query = query.with_for_update() + category = query.first() + return category + + +def get_default_category(lock: bool = False) -> model.PoolCategory: + category = try_get_default_category(lock) + if not category: + raise PoolCategoryNotFoundError('No pool category created yet.') + return category + + +def get_default_category_name() -> str: + if cache.has(DEFAULT_CATEGORY_NAME_CACHE_KEY): + return cache.get(DEFAULT_CATEGORY_NAME_CACHE_KEY) + default_category = get_default_category() + default_category_name = default_category.name + cache.put(DEFAULT_CATEGORY_NAME_CACHE_KEY, default_category_name) + return default_category_name + + +def set_default_category(category: model.PoolCategory) -> None: + assert category + old_category = try_get_default_category(lock=True) + if old_category: + db.session.refresh(old_category) + old_category.default = False + db.session.refresh(category) + category.default = True + cache.remove(DEFAULT_CATEGORY_NAME_CACHE_KEY) + + +def delete_category(category: model.PoolCategory) -> None: + assert category + if len(get_all_category_names()) == 1: + raise PoolCategoryIsInUseError('Cannot delete the last category.') + if (category.pool_count or 0) > 0: + raise PoolCategoryIsInUseError( + 'Pool category has some usages and cannot be deleted. ' + + 'Please remove this category from relevant pools first.') + db.session.delete(category) diff --git a/server/szurubooru/func/pools.py b/server/szurubooru/func/pools.py new file mode 100644 index 0000000..4acf9df --- /dev/null +++ b/server/szurubooru/func/pools.py @@ -0,0 +1,321 @@ +import re +from typing import Any, Optional, Tuple, List, Dict, Callable +from datetime import datetime +import sqlalchemy as sa +from szurubooru import config, db, model, errors, rest +from szurubooru.func import util, pool_categories, posts, serialization + + +class PoolNotFoundError(errors.NotFoundError): + pass + + +class PoolAlreadyExistsError(errors.ValidationError): + pass + + +class PoolIsInUseError(errors.ValidationError): + pass + + +class InvalidPoolNameError(errors.ValidationError): + pass + + +class InvalidPoolDuplicateError(errors.ValidationError): + pass + + +class InvalidPoolCategoryError(errors.ValidationError): + pass + + +class InvalidPoolDescriptionError(errors.ValidationError): + pass + + +class InvalidPoolRelationError(errors.ValidationError): + pass + + +class InvalidPoolNonexistentPostError(errors.ValidationError): + pass + + +def _verify_name_validity(name: str) -> None: + if util.value_exceeds_column_size(name, model.PoolName.name): + raise InvalidPoolNameError('Name is too long.') + name_regex = config.config['pool_name_regex'] + if not re.match(name_regex, name): + raise InvalidPoolNameError('Name must satisfy regex %r.' % name_regex) + + +def _get_names(pool: model.Pool) -> List[str]: + assert pool + return [pool_name.name for pool_name in pool.names] + + +def _lower_list(names: List[str]) -> List[str]: + return [name.lower() for name in names] + + +def _check_name_intersection( + names1: List[str], names2: List[str], case_sensitive: bool) -> bool: + if not case_sensitive: + names1 = _lower_list(names1) + names2 = _lower_list(names2) + return len(set(names1).intersection(names2)) > 0 + + +def _duplicates(a: List[int]) -> List[int]: + seen = set() + dupes = [] + for x in a: + if x not in seen: + seen.add(x) + else: + dupes.append(x) + return dupes + + +def sort_pools(pools: List[model.Pool]) -> List[model.Pool]: + default_category_name = pool_categories.get_default_category_name() + return sorted( + pools, + key=lambda pool: ( + default_category_name == pool.category.name, + pool.category.name, + pool.names[0].name) + ) + + +class PoolSerializer(serialization.BaseSerializer): + def __init__(self, pool: model.Pool) -> None: + self.pool = pool + + def _serializers(self) -> Dict[str, Callable[[], Any]]: + return { + 'id': self.serialize_id, + 'names': self.serialize_names, + 'category': self.serialize_category, + 'version': self.serialize_version, + 'description': self.serialize_description, + 'creationTime': self.serialize_creation_time, + 'lastEditTime': self.serialize_last_edit_time, + 'postCount': self.serialize_post_count, + 'posts': self.serialize_posts + } + + def serialize_id(self) -> Any: + return self.pool.pool_id + + def serialize_names(self) -> Any: + return [pool_name.name for pool_name in self.pool.names] + + def serialize_category(self) -> Any: + return self.pool.category.name + + def serialize_version(self) -> Any: + return self.pool.version + + def serialize_description(self) -> Any: + return self.pool.description + + def serialize_creation_time(self) -> Any: + return self.pool.creation_time + + def serialize_last_edit_time(self) -> Any: + return self.pool.last_edit_time + + def serialize_post_count(self) -> Any: + return self.pool.post_count + + def serialize_posts(self) -> Any: + return [ + post for post in [ + posts.serialize_micro_post(rel, None) + for rel in self.pool.posts + ] + ] + + +def serialize_pool( + pool: model.Pool, options: List[str] = []) -> Optional[rest.Response]: + if not pool: + return None + return PoolSerializer(pool).serialize(options) + + +def try_get_pool_by_id(pool_id: int) -> Optional[model.Pool]: + return ( + db.session + .query(model.Pool) + .filter(model.Pool.pool_id == pool_id) + .one_or_none()) + + +def get_pool_by_id(pool_id: int) -> model.Pool: + pool = try_get_pool_by_id(pool_id) + if not pool: + raise PoolNotFoundError('Pool %r not found.' % pool_id) + return pool + + +def try_get_pool_by_name(name: str) -> Optional[model.Pool]: + return ( + db.session + .query(model.Pool) + .join(model.PoolName) + .filter(sa.func.lower(model.PoolName.name) == name.lower()) + .one_or_none()) + + +def get_pool_by_name(name: str) -> model.Pool: + pool = try_get_pool_by_name(name) + if not pool: + raise PoolNotFoundError('Pool %r not found.' % name) + return pool + + +def get_pools_by_names(names: List[str]) -> List[model.Pool]: + names = util.icase_unique(names) + if len(names) == 0: + return [] + return ( + db.session.query(model.Pool) + .join(model.PoolName) + .filter( + sa.sql.or_( + sa.func.lower(model.PoolName.name) == name.lower() + for name in names)) + .all()) + + +def get_or_create_pools_by_names( + names: List[str]) -> Tuple[List[model.Pool], List[model.Pool]]: + names = util.icase_unique(names) + existing_pools = get_pools_by_names(names) + new_pools = [] + pool_category_name = pool_categories.get_default_category_name() + for name in names: + found = False + for existing_pool in existing_pools: + if _check_name_intersection( + _get_names(existing_pool), [name], False): + found = True + break + if not found: + new_pool = create_pool( + names=[name], + category_name=pool_category_name, + post_ids=[]) + db.session.add(new_pool) + new_pools.append(new_pool) + return existing_pools, new_pools + + +def delete(source_pool: model.Pool) -> None: + assert source_pool + db.session.delete(source_pool) + + +def merge_pools(source_pool: model.Pool, target_pool: model.Pool) -> None: + assert source_pool + assert target_pool + if source_pool.pool_id == target_pool.pool_id: + raise InvalidPoolRelationError('Cannot merge pool with itself.') + + def merge_pool_posts(source_pool_id: int, target_pool_id: int) -> None: + alias1 = model.PoolPost + alias2 = sa.orm.util.aliased(model.PoolPost) + update_stmt = ( + sa.sql.expression.update(alias1) + .where(alias1.pool_id == source_pool_id)) + update_stmt = ( + update_stmt + .where( + ~sa.exists() + .where(alias1.post_id == alias2.post_id) + .where(alias2.pool_id == target_pool_id))) + update_stmt = update_stmt.values(pool_id=target_pool_id) + db.session.execute(update_stmt) + + merge_pool_posts(source_pool.pool_id, target_pool.pool_id) + delete(source_pool) + + +def create_pool( + names: List[str], + category_name: str, + post_ids: List[int]) -> model.Pool: + pool = model.Pool() + pool.creation_time = datetime.utcnow() + update_pool_names(pool, names) + update_pool_category_name(pool, category_name) + update_pool_posts(pool, post_ids) + return pool + + +def update_pool_category_name(pool: model.Pool, category_name: str) -> None: + assert pool + pool.category = pool_categories.get_category_by_name(category_name) + + +def update_pool_names(pool: model.Pool, names: List[str]) -> None: + # sanitize + assert pool + names = util.icase_unique([name for name in names if name]) + if not len(names): + raise InvalidPoolNameError('At least one name must be specified.') + for name in names: + _verify_name_validity(name) + + # check for existing pools + expr = sa.sql.false() + for name in names: + expr = expr | (sa.func.lower(model.PoolName.name) == name.lower()) + if pool.pool_id: + expr = expr & (model.PoolName.pool_id != pool.pool_id) + existing_pools = db.session.query(model.PoolName).filter(expr).all() + if len(existing_pools): + raise PoolAlreadyExistsError( + 'One of names is already used by another pool.') + + # remove unwanted items + for pool_name in pool.names[:]: + if not _check_name_intersection([pool_name.name], names, True): + pool.names.remove(pool_name) + # add wanted items + for name in names: + if not _check_name_intersection(_get_names(pool), [name], True): + pool.names.append(model.PoolName(name, -1)) + + # set alias order to match the request + for i, name in enumerate(names): + for pool_name in pool.names: + if pool_name.name.lower() == name.lower(): + pool_name.order = i + + +def update_pool_description(pool: model.Pool, description: str) -> None: + assert pool + if util.value_exceeds_column_size(description, model.Pool.description): + raise InvalidPoolDescriptionError('Description is too long.') + pool.description = description or None + + +def update_pool_posts(pool: model.Pool, post_ids: List[int]) -> None: + assert pool + dupes = _duplicates(post_ids) + if len(dupes) > 0: + dupes = ', '.join(list(str(x) for x in dupes)) + raise InvalidPoolDuplicateError('Duplicate post(s) in pool: ' + dupes) + ret = posts.get_posts_by_ids(post_ids) + if len(post_ids) != len(ret): + missing = set(post_ids) - set(post.post_id for post in ret) + missing = ', '.join(list(str(x) for x in missing)) + raise InvalidPoolNonexistentPostError( + 'The following posts do not exist: ' + missing) + pool.posts.clear() + for post in ret: + pool.posts.append(post) diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index f0224b4..d8e984b 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -5,7 +5,7 @@ from datetime import datetime import sqlalchemy as sa from szurubooru import config, db, model, errors, rest from szurubooru.func import ( - users, scores, comments, tags, util, + users, scores, comments, tags, pools, util, mime, images, files, image_hash, serialization, snapshots) @@ -176,6 +176,7 @@ class PostSerializer(serialization.BaseSerializer): 'hasCustomThumbnail': self.serialize_has_custom_thumbnail, 'notes': self.serialize_notes, 'comments': self.serialize_comments, + 'pools': self.serialize_pools, } def serialize_id(self) -> Any: @@ -299,6 +300,13 @@ class PostSerializer(serialization.BaseSerializer): self.post.comments, key=lambda comment: comment.creation_time)] + def serialize_pools(self) -> List[Any]: + return [ + pools.serialize_pool(pool) + for pool in sorted( + self.post.pools, + key=lambda pool: pool.creation_time)] + def serialize_post( post: Optional[model.Post], @@ -334,6 +342,22 @@ def get_post_by_id(post_id: int) -> model.Post: return post +def get_posts_by_ids(ids: List[int]) -> List[model.Post]: + if len(ids) == 0: + return [] + posts = ( + db.session.query(model.Post) + .filter( + sa.sql.or_( + model.Post.post_id == post_id + for post_id in ids)) + .all()) + id_order = { + v: k for k, v in enumerate(ids) + } + return sorted(posts, key=lambda post: id_order.get(post.post_id)) + + def try_get_current_post_feature() -> Optional[model.PostFeature]: return ( db.session diff --git a/server/szurubooru/func/snapshots.py b/server/szurubooru/func/snapshots.py index eade3a1..6646467 100644 --- a/server/szurubooru/func/snapshots.py +++ b/server/szurubooru/func/snapshots.py @@ -24,6 +24,24 @@ def get_tag_snapshot(tag: model.Tag) -> Dict[str, Any]: } +def get_pool_category_snapshot(category: model.PoolCategory) -> Dict[str, Any]: + assert category + return { + 'name': category.name, + 'color': category.color, + 'default': True if category.default else False, + } + + +def get_pool_snapshot(pool: model.Pool) -> Dict[str, Any]: + assert pool + return { + 'names': [pool_name.name for pool_name in pool.names], + 'category': pool.category.name, + 'posts': [post.post_id for post in pool.posts] + } + + def get_post_snapshot(post: model.Post) -> Dict[str, Any]: assert post return { @@ -47,6 +65,8 @@ _snapshot_factories = { 'tag_category': lambda entity: get_tag_category_snapshot(entity), 'tag': lambda entity: get_tag_snapshot(entity), 'post': lambda entity: get_post_snapshot(entity), + 'pool_category': lambda entity: get_pool_category_snapshot(entity), + 'pool': lambda entity: get_pool_snapshot(entity), } # type: Dict[model.Base, Callable[[model.Base], Dict[str ,Any]]] diff --git a/server/szurubooru/func/tags.py b/server/szurubooru/func/tags.py index 7d92f1e..60eda50 100644 --- a/server/szurubooru/func/tags.py +++ b/server/szurubooru/func/tags.py @@ -1,5 +1,3 @@ -import json -import os import re from typing import Any, Optional, Tuple, List, Dict, Callable from datetime import datetime diff --git a/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py b/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py new file mode 100644 index 0000000..eaa68f2 --- /dev/null +++ b/server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py @@ -0,0 +1,61 @@ +''' +add default pool category + +Revision ID: 54de8acc6cef +Created at: 2020-05-03 14:57:46.825766 +''' + +import sqlalchemy as sa +from alembic import op + + +revision = '54de8acc6cef' +down_revision = '6a2f424ec9d2' +branch_labels = None +depends_on = None + + +Base = sa.ext.declarative.declarative_base() + + +class PoolCategory(Base): + __tablename__ = 'pool_category' + __table_args__ = {'extend_existing': True} + + pool_category_id = sa.Column('id', sa.Integer, primary_key=True) + version = sa.Column('version', sa.Integer, nullable=False) + name = sa.Column('name', sa.Unicode(32), nullable=False) + color = sa.Column('color', sa.Unicode(32), nullable=False) + default = sa.Column('default', sa.Boolean, nullable=False) + + __mapper_args__ = { + 'version_id_col': version, + 'version_id_generator': False, + } + + +def upgrade(): + session = sa.orm.session.Session(bind=op.get_bind()) + if session.query(PoolCategory).count() == 0: + category = PoolCategory() + category.name = 'default' + category.color = 'default' + category.version = 1 + category.default = True + session.add(category) + session.commit() + + +def downgrade(): + session = sa.orm.session.Session(bind=op.get_bind()) + default_category = ( + session + .query(PoolCategory) + .filter(PoolCategory.name == 'default') + .filter(PoolCategory.color == 'default') + .filter(PoolCategory.version == 1) + .filter(PoolCategory.default == 1) + .one_or_none()) + if default_category: + session.delete(default_category) + session.commit() diff --git a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py new file mode 100644 index 0000000..18a0d7a --- /dev/null +++ b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py @@ -0,0 +1,64 @@ +''' +create pool tables + +Revision ID: 6a2f424ec9d2 +Created at: 2020-05-03 14:47:59.136410 +''' + +import sqlalchemy as sa +from alembic import op + + +revision = '6a2f424ec9d2' +down_revision = '1e280b5d5df1' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'pool_category', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False, default=1), + sa.Column('name', sa.Unicode(length=32), nullable=False), + sa.Column('color', sa.Unicode(length=32), nullable=False), + sa.Column('default', sa.Boolean(), nullable=False, default=False), + sa.PrimaryKeyConstraint('id')) + + op.create_table( + 'pool', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('version', sa.Integer(), nullable=False, default=1), + sa.Column('description', sa.UnicodeText(), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=False), + sa.Column('creation_time', sa.DateTime(), nullable=False), + sa.Column('last_edit_time', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['category_id'], ['pool_category.id']), + sa.PrimaryKeyConstraint('id')) + + op.create_table( + 'pool_name', + sa.Column('pool_name_id', sa.Integer(), nullable=False), + sa.Column('pool_id', sa.Integer(), nullable=False), + sa.Column('name', sa.Unicode(length=256), nullable=False), + sa.Column('ord', sa.Integer(), nullable=False, index=True), + sa.ForeignKeyConstraint(['pool_id'], ['pool.id']), + sa.PrimaryKeyConstraint('pool_name_id'), + sa.UniqueConstraint('name')) + + op.create_table( + 'pool_post', + sa.Column('pool_id', sa.Integer(), nullable=False), + sa.Column('post_id', sa.Integer(), nullable=False, index=True), + sa.Column('ord', sa.Integer(), nullable=False, index=True), + sa.ForeignKeyConstraint(['pool_id'], ['pool.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['post_id'], ['post.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('pool_id', 'post_id')) + + +def downgrade(): + op.drop_index(op.f('ix_pool_name_ord'), table_name='pool_name') + op.drop_table('pool_post') + op.drop_table('pool_name') + op.drop_table('pool') + op.drop_table('pool_category') diff --git a/server/szurubooru/model/__init__.py b/server/szurubooru/model/__init__.py index a043bd1..4f6cb2a 100644 --- a/server/szurubooru/model/__init__.py +++ b/server/szurubooru/model/__init__.py @@ -11,6 +11,8 @@ from szurubooru.model.post import ( PostNote, PostFeature, PostSignature) +from szurubooru.model.pool import Pool, PoolName, PoolPost +from szurubooru.model.pool_category import PoolCategory from szurubooru.model.comment import Comment, CommentScore from szurubooru.model.snapshot import Snapshot import szurubooru.model.util diff --git a/server/szurubooru/model/pool.py b/server/szurubooru/model/pool.py new file mode 100644 index 0000000..6fc8361 --- /dev/null +++ b/server/szurubooru/model/pool.py @@ -0,0 +1,103 @@ +import sqlalchemy as sa +from sqlalchemy.ext.orderinglist import ordering_list +from sqlalchemy.ext.associationproxy import association_proxy +from szurubooru.model.base import Base + + +class PoolName(Base): + __tablename__ = 'pool_name' + + pool_name_id = sa.Column('pool_name_id', sa.Integer, primary_key=True) + pool_id = sa.Column( + 'pool_id', + sa.Integer, + sa.ForeignKey('pool.id'), + nullable=False, + index=True) + name = sa.Column('name', sa.Unicode(128), nullable=False, unique=True) + order = sa.Column('ord', sa.Integer, nullable=False, index=True) + + def __init__(self, name: str, order: int) -> None: + self.name = name + self.order = order + + +class PoolPost(Base): + __tablename__ = 'pool_post' + + pool_id = sa.Column( + 'pool_id', + sa.Integer, + sa.ForeignKey('pool.id'), + nullable=False, + primary_key=True, + index=True) + post_id = sa.Column( + 'post_id', + sa.Integer, + sa.ForeignKey('post.id'), + nullable=False, + primary_key=True, + index=True) + order = sa.Column('ord', sa.Integer, nullable=False, index=True) + + pool = sa.orm.relationship('Pool', back_populates='_posts') + post = sa.orm.relationship('Post', back_populates='_pools') + + def __init__(self, post) -> None: + self.post_id = post.post_id + + +class Pool(Base): + __tablename__ = 'pool' + + pool_id = sa.Column('id', sa.Integer, primary_key=True) + category_id = sa.Column( + 'category_id', + sa.Integer, + sa.ForeignKey('pool_category.id'), + nullable=False, + index=True) + version = sa.Column('version', sa.Integer, default=1, nullable=False) + creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) + last_edit_time = sa.Column('last_edit_time', sa.DateTime) + description = sa.Column('description', sa.UnicodeText, default=None) + + category = sa.orm.relationship('PoolCategory', lazy='joined') + names = sa.orm.relationship( + 'PoolName', + cascade='all,delete-orphan', + lazy='joined', + order_by='PoolName.order') + _posts = sa.orm.relationship( + 'PoolPost', + cascade='all,delete-orphan', + lazy='joined', + back_populates='pool', + order_by='PoolPost.order', + collection_class=ordering_list('order')) + posts = association_proxy('_posts', 'post') + + post_count = sa.orm.column_property( + ( + sa.sql.expression.select( + [sa.sql.expression.func.count(PoolPost.post_id)]) + .where(PoolPost.pool_id == pool_id) + .as_scalar() + ), + deferred=True) + + first_name = sa.orm.column_property( + ( + sa.sql.expression.select([PoolName.name]) + .where(PoolName.pool_id == pool_id) + .order_by(PoolName.order) + .limit(1) + .as_scalar() + ), + deferred=True) + + __mapper_args__ = { + 'version_id_col': version, + 'version_id_generator': False, + } diff --git a/server/szurubooru/model/pool_category.py b/server/szurubooru/model/pool_category.py new file mode 100644 index 0000000..a54e5a6 --- /dev/null +++ b/server/szurubooru/model/pool_category.py @@ -0,0 +1,29 @@ +from typing import Optional +import sqlalchemy as sa +from szurubooru.model.base import Base +from szurubooru.model.pool import Pool + + +class PoolCategory(Base): + __tablename__ = 'pool_category' + + pool_category_id = sa.Column('id', sa.Integer, primary_key=True) + version = sa.Column('version', sa.Integer, default=1, nullable=False) + name = sa.Column('name', sa.Unicode(32), nullable=False) + color = sa.Column( + 'color', sa.Unicode(32), nullable=False, default='#000000') + default = sa.Column('default', sa.Boolean, nullable=False, default=False) + + def __init__(self, name: Optional[str] = None) -> None: + self.name = name + + pool_count = sa.orm.column_property( + sa.sql.expression.select( + [sa.sql.expression.func.count('Pool.pool_id')]) + .where(Pool.category_id == pool_category_id) + .correlate_except(sa.table('Pool'))) + + __mapper_args__ = { + 'version_id_col': version, + 'version_id_generator': False, + } diff --git a/server/szurubooru/model/post.py b/server/szurubooru/model/post.py index f8f5c34..11da866 100644 --- a/server/szurubooru/model/post.py +++ b/server/szurubooru/model/post.py @@ -2,7 +2,10 @@ from typing import List import sqlalchemy as sa from szurubooru.model.base import Base from szurubooru.model.comment import Comment +from szurubooru.model.pool import PoolPost +from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.ext.orderinglist import ordering_list class PostFeature(Base): @@ -224,6 +227,13 @@ class Post(Base): notes = sa.orm.relationship( 'PostNote', cascade='all, delete-orphan', lazy='joined') comments = sa.orm.relationship('Comment', cascade='all, delete-orphan') + _pools = sa.orm.relationship( + 'PoolPost', + cascade='all,delete-orphan', + lazy='select', + order_by='PoolPost.order', + back_populates='post') + pools = association_proxy('_pools', 'pool') # dynamic columns tag_count = sa.orm.column_property( diff --git a/server/szurubooru/model/util.py b/server/szurubooru/model/util.py index e82539f..ab86100 100644 --- a/server/szurubooru/model/util.py +++ b/server/szurubooru/model/util.py @@ -10,6 +10,8 @@ def get_resource_info(entity: Base) -> Tuple[Any, Any, Union[str, int]]: 'tag_category': lambda category: category.name, 'comment': lambda comment: comment.comment_id, 'post': lambda post: post.post_id, + 'pool': lambda pool: pool.pool_id, + 'pool_category': lambda category: category.name, } # type: Dict[str, Callable[[Base], Any]] resource_type = entity.__table__.name diff --git a/server/szurubooru/search/configs/__init__.py b/server/szurubooru/search/configs/__init__.py index c7e3102..c6a3ea4 100644 --- a/server/szurubooru/search/configs/__init__.py +++ b/server/szurubooru/search/configs/__init__.py @@ -3,3 +3,4 @@ from .tag_search_config import TagSearchConfig from .post_search_config import PostSearchConfig from .snapshot_search_config import SnapshotSearchConfig from .comment_search_config import CommentSearchConfig +from .pool_search_config import PoolSearchConfig diff --git a/server/szurubooru/search/configs/pool_search_config.py b/server/szurubooru/search/configs/pool_search_config.py new file mode 100644 index 0000000..ddc325a --- /dev/null +++ b/server/szurubooru/search/configs/pool_search_config.py @@ -0,0 +1,109 @@ +from typing import Tuple, Dict +import sqlalchemy as sa +from szurubooru import db, model +from szurubooru.func import util +from szurubooru.search.typing import SaColumn, SaQuery +from szurubooru.search.configs import util as search_util +from szurubooru.search.configs.base_search_config import ( + BaseSearchConfig, Filter) + + +class PoolSearchConfig(BaseSearchConfig): + def create_filter_query(self, _disable_eager_loads: bool) -> SaQuery: + strategy = ( + sa.orm.lazyload + if _disable_eager_loads + else sa.orm.subqueryload) + return ( + db.session.query(model.Pool) + .join(model.PoolCategory) + .options( + strategy(model.Pool.names))) + + def create_count_query(self, _disable_eager_loads: bool) -> SaQuery: + return db.session.query(model.Pool) + + def create_around_query(self) -> SaQuery: + raise NotImplementedError() + + def finalize_query(self, query: SaQuery) -> SaQuery: + return query.order_by(model.Pool.first_name.asc()) + + @property + def anonymous_filter(self) -> Filter: + return search_util.create_subquery_filter( + model.Pool.pool_id, + model.PoolName.pool_id, + model.PoolName.name, + search_util.create_str_filter) + + @property + def named_filters(self) -> Dict[str, Filter]: + return util.unalias_dict([ + ( + ['name'], + search_util.create_subquery_filter( + model.Pool.pool_id, + model.PoolName.pool_id, + model.PoolName.name, + search_util.create_str_filter) + ), + + ( + ['category'], + search_util.create_subquery_filter( + model.Pool.category_id, + model.PoolCategory.pool_category_id, + model.PoolCategory.name, + search_util.create_str_filter) + ), + + ( + ['creation-date', 'creation-time'], + search_util.create_date_filter(model.Pool.creation_time) + ), + + ( + ['last-edit-date', 'last-edit-time', 'edit-date', 'edit-time'], + search_util.create_date_filter(model.Pool.last_edit_time) + ), + + ( + ['post-count'], + search_util.create_num_filter(model.Pool.post_count) + ), + ]) + + @property + def sort_columns(self) -> Dict[str, Tuple[SaColumn, str]]: + return util.unalias_dict([ + ( + ['random'], + (sa.sql.expression.func.random(), self.SORT_NONE) + ), + + ( + ['name'], + (model.Pool.first_name, self.SORT_ASC) + ), + + ( + ['category'], + (model.PoolCategory.name, self.SORT_ASC) + ), + + ( + ['creation-date', 'creation-time'], + (model.Pool.creation_time, self.SORT_DESC) + ), + + ( + ['last-edit-date', 'last-edit-time', 'edit-date', 'edit-time'], + (model.Pool.last_edit_time, self.SORT_DESC) + ), + + ( + ['post-count'], + (model.Pool.post_count, self.SORT_DESC) + ), + ]) diff --git a/server/szurubooru/search/configs/post_search_config.py b/server/szurubooru/search/configs/post_search_config.py index 9c8de2e..281826f 100644 --- a/server/szurubooru/search/configs/post_search_config.py +++ b/server/szurubooru/search/configs/post_search_config.py @@ -104,6 +104,18 @@ def _note_filter( search_util.create_str_filter)(query, criterion, negated) +def _pool_filter( + query: SaQuery, + criterion: Optional[criteria.BaseCriterion], + negated: bool) -> SaQuery: + assert criterion + return search_util.create_subquery_filter( + model.Post.post_id, + model.PoolPost.post_id, + model.PoolPost.pool_id, + search_util.create_num_filter)(query, criterion, negated) + + class PostSearchConfig(BaseSearchConfig): def __init__(self) -> None: self.user = None # type: Optional[model.User] @@ -350,6 +362,11 @@ class PostSearchConfig(BaseSearchConfig): search_util.create_str_filter( model.Post.flags_string, _flag_transformer) ), + + ( + ['pool'], + _pool_filter + ), ]) @property diff --git a/server/szurubooru/tests/api/test_pool_category_creating.py b/server/szurubooru/tests/api/test_pool_category_creating.py new file mode 100644 index 0000000..5e932a6 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_category_creating.py @@ -0,0 +1,60 @@ +from unittest.mock import patch +import pytest +from szurubooru import api, db, model, errors +from szurubooru.func import pool_categories, snapshots + + +def _update_category_name(category, name): + category.name = name + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({ + 'privileges': {'pool_categories:create': model.User.RANK_REGULAR}, + }) + + +def test_creating_category( + pool_category_factory, user_factory, context_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + category = pool_category_factory(name='meta') + db.session.add(category) + + with patch('szurubooru.func.pool_categories.create_category'), \ + patch('szurubooru.func.pool_categories.serialize_category'), \ + patch('szurubooru.func.pool_categories.update_category_name'), \ + patch('szurubooru.func.snapshots.create'): + pool_categories.create_category.return_value = category + pool_categories.update_category_name.side_effect = \ + _update_category_name + pool_categories.serialize_category.return_value = 'serialized category' + result = api.pool_category_api.create_pool_category( + context_factory( + params={'name': 'meta', 'color': 'black'}, user=auth_user)) + assert result == 'serialized category' + pool_categories.create_category.assert_called_once_with( + 'meta', 'black') + snapshots.create.assert_called_once_with(category, auth_user) + + +@pytest.mark.parametrize('field', ['name', 'color']) +def test_trying_to_omit_mandatory_field(user_factory, context_factory, field): + params = { + 'name': 'meta', + 'color': 'black', + } + del params[field] + with pytest.raises(errors.ValidationError): + api.pool_category_api.create_pool_category( + context_factory( + params=params, + user=user_factory(rank=model.User.RANK_REGULAR))) + + +def test_trying_to_create_without_privileges(user_factory, context_factory): + with pytest.raises(errors.AuthError): + api.pool_category_api.create_pool_category( + context_factory( + params={'name': 'meta', 'color': 'black'}, + user=user_factory(rank=model.User.RANK_ANONYMOUS))) diff --git a/server/szurubooru/tests/api/test_pool_category_deleting.py b/server/szurubooru/tests/api/test_pool_category_deleting.py new file mode 100644 index 0000000..72853ac --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_category_deleting.py @@ -0,0 +1,76 @@ +from unittest.mock import patch +import pytest +from szurubooru import api, db, model, errors +from szurubooru.func import pool_categories, snapshots + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({ + 'privileges': {'pool_categories:delete': model.User.RANK_REGULAR}, + }) + + +def test_deleting(user_factory, pool_category_factory, context_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + category = pool_category_factory(name='category') + db.session.add(pool_category_factory(name='root')) + db.session.add(category) + db.session.flush() + with patch('szurubooru.func.snapshots.delete'): + result = api.pool_category_api.delete_pool_category( + context_factory(params={'version': 1}, user=auth_user), + {'category_name': 'category'}) + assert result == {} + assert db.session.query(model.PoolCategory).count() == 1 + assert db.session.query(model.PoolCategory).one().name == 'root' + snapshots.delete.assert_called_once_with(category, auth_user) + + +def test_trying_to_delete_used( + user_factory, pool_category_factory, pool_factory, context_factory): + category = pool_category_factory(name='category') + db.session.add(category) + db.session.flush() + pool = pool_factory(names=['pool'], category=category) + db.session.add(pool) + db.session.commit() + with pytest.raises(pool_categories.PoolCategoryIsInUseError): + api.pool_category_api.delete_pool_category( + context_factory( + params={'version': 1}, + user=user_factory(rank=model.User.RANK_REGULAR)), + {'category_name': 'category'}) + assert db.session.query(model.PoolCategory).count() == 1 + + +def test_trying_to_delete_last( + user_factory, pool_category_factory, context_factory): + db.session.add(pool_category_factory(name='root')) + db.session.commit() + with pytest.raises(pool_categories.PoolCategoryIsInUseError): + api.pool_category_api.delete_pool_category( + context_factory( + params={'version': 1}, + user=user_factory(rank=model.User.RANK_REGULAR)), + {'category_name': 'root'}) + + +def test_trying_to_delete_non_existing(user_factory, context_factory): + with pytest.raises(pool_categories.PoolCategoryNotFoundError): + api.pool_category_api.delete_pool_category( + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {'category_name': 'bad'}) + + +def test_trying_to_delete_without_privileges( + user_factory, pool_category_factory, context_factory): + db.session.add(pool_category_factory(name='category')) + db.session.commit() + with pytest.raises(errors.AuthError): + api.pool_category_api.delete_pool_category( + context_factory( + params={'version': 1}, + user=user_factory(rank=model.User.RANK_ANONYMOUS)), + {'category_name': 'category'}) + assert db.session.query(model.PoolCategory).count() == 1 diff --git a/server/szurubooru/tests/api/test_pool_category_retrieving.py b/server/szurubooru/tests/api/test_pool_category_retrieving.py new file mode 100644 index 0000000..4a467c0 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_category_retrieving.py @@ -0,0 +1,56 @@ +import pytest +from szurubooru import api, db, model, errors +from szurubooru.func import pool_categories + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({ + 'privileges': { + 'pool_categories:list': model.User.RANK_REGULAR, + 'pool_categories:view': model.User.RANK_REGULAR, + }, + }) + + +def test_retrieving_multiple( + user_factory, pool_category_factory, context_factory): + db.session.add_all([ + pool_category_factory(name='c1'), + pool_category_factory(name='c2'), + ]) + db.session.flush() + result = api.pool_category_api.get_pool_categories( + context_factory(user=user_factory(rank=model.User.RANK_REGULAR))) + assert [cat['name'] for cat in result['results']] == ['c1', 'c2'] + + +def test_retrieving_single( + user_factory, pool_category_factory, context_factory): + db.session.add(pool_category_factory(name='cat')) + db.session.flush() + result = api.pool_category_api.get_pool_category( + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {'category_name': 'cat'}) + assert result == { + 'name': 'cat', + 'color': 'dummy', + 'usages': 0, + 'default': False, + 'version': 1, + } + + +def test_trying_to_retrieve_single_non_existing(user_factory, context_factory): + with pytest.raises(pool_categories.PoolCategoryNotFoundError): + api.pool_category_api.get_pool_category( + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {'category_name': '-'}) + + +def test_trying_to_retrieve_single_without_privileges( + user_factory, context_factory): + with pytest.raises(errors.AuthError): + api.pool_category_api.get_pool_category( + context_factory(user=user_factory(rank=model.User.RANK_ANONYMOUS)), + {'category_name': '-'}) diff --git a/server/szurubooru/tests/api/test_pool_category_updating.py b/server/szurubooru/tests/api/test_pool_category_updating.py new file mode 100644 index 0000000..028c520 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_category_updating.py @@ -0,0 +1,110 @@ +from unittest.mock import patch +import pytest +from szurubooru import api, db, model, errors +from szurubooru.func import pool_categories, snapshots + + +def _update_category_name(category, name): + category.name = name + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({ + 'privileges': { + 'pool_categories:edit:name': model.User.RANK_REGULAR, + 'pool_categories:edit:color': model.User.RANK_REGULAR, + 'pool_categories:set_default': model.User.RANK_REGULAR, + }, + }) + + +def test_simple_updating(user_factory, pool_category_factory, context_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + category = pool_category_factory(name='name', color='black') + db.session.add(category) + db.session.flush() + with patch('szurubooru.func.pool_categories.serialize_category'), \ + patch('szurubooru.func.pool_categories.update_category_name'), \ + patch('szurubooru.func.pool_categories.update_category_color'), \ + patch('szurubooru.func.snapshots.modify'): + pool_categories.update_category_name.side_effect = \ + _update_category_name + pool_categories.serialize_category.return_value = 'serialized category' + result = api.pool_category_api.update_pool_category( + context_factory( + params={'name': 'changed', 'color': 'white', 'version': 1}, + user=auth_user), + {'category_name': 'name'}) + assert result == 'serialized category' + pool_categories.update_category_name.assert_called_once_with( + category, 'changed') + pool_categories.update_category_color.assert_called_once_with( + category, 'white') + snapshots.modify.assert_called_once_with(category, auth_user) + + +@pytest.mark.parametrize('field', ['name', 'color']) +def test_omitting_optional_field( + user_factory, pool_category_factory, context_factory, field): + db.session.add(pool_category_factory(name='name', color='black')) + db.session.commit() + params = { + 'name': 'changed', + 'color': 'white', + } + del params[field] + with patch('szurubooru.func.pool_categories.serialize_category'), \ + patch('szurubooru.func.pool_categories.update_category_name'): + api.pool_category_api.update_pool_category( + context_factory( + params={**params, **{'version': 1}}, + user=user_factory(rank=model.User.RANK_REGULAR)), + {'category_name': 'name'}) + + +def test_trying_to_update_non_existing(user_factory, context_factory): + with pytest.raises(pool_categories.PoolCategoryNotFoundError): + api.pool_category_api.update_pool_category( + context_factory( + params={'name': ['dummy']}, + user=user_factory(rank=model.User.RANK_REGULAR)), + {'category_name': 'bad'}) + + +@pytest.mark.parametrize('params', [ + {'name': 'whatever'}, + {'color': 'whatever'}, +]) +def test_trying_to_update_without_privileges( + user_factory, pool_category_factory, context_factory, params): + db.session.add(pool_category_factory(name='dummy')) + db.session.commit() + with pytest.raises(errors.AuthError): + api.pool_category_api.update_pool_category( + context_factory( + params={**params, **{'version': 1}}, + user=user_factory(rank=model.User.RANK_ANONYMOUS)), + {'category_name': 'dummy'}) + + +def test_set_as_default(user_factory, pool_category_factory, context_factory): + category = pool_category_factory(name='name', color='black') + db.session.add(category) + db.session.commit() + with patch('szurubooru.func.pool_categories.serialize_category'), \ + patch('szurubooru.func.pool_categories.set_default_category'): + pool_categories.update_category_name.side_effect = \ + _update_category_name + pool_categories.serialize_category.return_value = 'serialized category' + result = api.pool_category_api.set_pool_category_as_default( + context_factory( + params={ + 'name': 'changed', + 'color': 'white', + 'version': 1, + }, + user=user_factory(rank=model.User.RANK_REGULAR)), + {'category_name': 'name'}) + assert result == 'serialized category' + pool_categories.set_default_category.assert_called_once_with(category) diff --git a/server/szurubooru/tests/api/test_pool_creating.py b/server/szurubooru/tests/api/test_pool_creating.py new file mode 100644 index 0000000..5eeb2e2 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_creating.py @@ -0,0 +1,82 @@ +from unittest.mock import patch +import pytest +from szurubooru import api, model, errors +from szurubooru.func import pools, posts, snapshots + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({'privileges': {'pools:create': model.User.RANK_REGULAR}}) + + +def test_creating_simple_pools(pool_factory, user_factory, context_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + pool = pool_factory() + with patch('szurubooru.func.pools.create_pool'), \ + patch('szurubooru.func.pools.get_or_create_pools_by_names'), \ + patch('szurubooru.func.pools.serialize_pool'), \ + patch('szurubooru.func.snapshots.create'): + posts.get_posts_by_ids.return_value = ([], []) + pools.create_pool.return_value = pool + pools.serialize_pool.return_value = 'serialized pool' + result = api.pool_api.create_pool( + context_factory( + params={ + 'names': ['pool1', 'pool2'], + 'category': 'default', + 'description': 'desc', + 'posts': [1, 2], + }, + user=auth_user)) + assert result == 'serialized pool' + pools.create_pool.assert_called_once_with( + ['pool1', 'pool2'], 'default', [1, 2]) + snapshots.create.assert_called_once_with(pool, auth_user) + + +@pytest.mark.parametrize('field', ['names', 'category']) +def test_trying_to_omit_mandatory_field(user_factory, context_factory, field): + params = { + 'names': ['pool1', 'pool2'], + 'category': 'default', + 'description': 'desc', + 'posts': [], + } + del params[field] + with pytest.raises(errors.ValidationError): + api.pool_api.create_pool( + context_factory( + params=params, + user=user_factory(rank=model.User.RANK_REGULAR))) + + +@pytest.mark.parametrize('field', ['description', 'posts']) +def test_omitting_optional_field( + pool_factory, user_factory, context_factory, field): + params = { + 'names': ['pool1', 'pool2'], + 'category': 'default', + 'description': 'desc', + 'posts': [], + } + del params[field] + with patch('szurubooru.func.pools.create_pool'), \ + patch('szurubooru.func.pools.serialize_pool'): + pools.create_pool.return_value = pool_factory() + api.pool_api.create_pool( + context_factory( + params=params, + user=user_factory(rank=model.User.RANK_REGULAR))) + + +def test_trying_to_create_pool_without_privileges( + user_factory, context_factory): + with pytest.raises(errors.AuthError): + api.pool_api.create_pool( + context_factory( + params={ + 'names': ['pool'], + 'category': 'default', + 'posts': [], + }, + user=user_factory(rank=model.User.RANK_ANONYMOUS))) diff --git a/server/szurubooru/tests/api/test_pool_deleting.py b/server/szurubooru/tests/api/test_pool_deleting.py new file mode 100644 index 0000000..e29656d --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_deleting.py @@ -0,0 +1,61 @@ +from unittest.mock import patch +import pytest +from szurubooru import api, db, model, errors +from szurubooru.func import pools, snapshots + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({'privileges': {'pools:delete': model.User.RANK_REGULAR}}) + + +def test_deleting(user_factory, pool_factory, context_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + pool = pool_factory(id=1) + db.session.add(pool) + db.session.commit() + with patch('szurubooru.func.snapshots.delete'): + result = api.pool_api.delete_pool( + context_factory(params={'version': 1}, user=auth_user), + {'pool_id': 1}) + assert result == {} + assert db.session.query(model.Pool).count() == 0 + snapshots.delete.assert_called_once_with(pool, auth_user) + + +def test_deleting_used( + user_factory, pool_factory, context_factory, post_factory): + pool = pool_factory(id=1) + post = post_factory(id=1) + pool.posts.append(post) + db.session.add_all([pool, post]) + db.session.commit() + api.pool_api.delete_pool( + context_factory( + params={'version': 1}, + user=user_factory(rank=model.User.RANK_REGULAR)), + {'pool_id': 1}) + db.session.refresh(post) + assert db.session.query(model.Pool).count() == 0 + assert db.session.query(model.PoolPost).count() == 0 + assert post.pools == [] + + +def test_trying_to_delete_non_existing(user_factory, context_factory): + with pytest.raises(pools.PoolNotFoundError): + api.pool_api.delete_pool( + context_factory(user=user_factory(rank=model.User.RANK_REGULAR)), + {'pool_id': 9999}) + + +def test_trying_to_delete_without_privileges( + user_factory, pool_factory, context_factory): + db.session.add(pool_factory(id=1)) + db.session.commit() + with pytest.raises(errors.AuthError): + api.pool_api.delete_pool( + context_factory( + params={'version': 1}, + user=user_factory(rank=model.User.RANK_ANONYMOUS)), + {'pool_id': 1}) + assert db.session.query(model.Pool).count() == 1 diff --git a/server/szurubooru/tests/api/test_pool_merging.py b/server/szurubooru/tests/api/test_pool_merging.py new file mode 100644 index 0000000..dc462d2 --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_merging.py @@ -0,0 +1,98 @@ +from unittest.mock import patch +import pytest +from szurubooru import api, db, model, errors +from szurubooru.func import pools, snapshots + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({'privileges': {'pools:merge': model.User.RANK_REGULAR}}) + + +def test_merging(user_factory, pool_factory, context_factory, post_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + source_pool = pool_factory(id=1) + target_pool = pool_factory(id=2) + db.session.add_all([source_pool, target_pool]) + db.session.flush() + assert source_pool.post_count == 0 + assert target_pool.post_count == 0 + post = post_factory(id=1) + source_pool.posts = [post] + db.session.add(post) + db.session.commit() + assert source_pool.post_count == 1 + assert target_pool.post_count == 0 + with patch('szurubooru.func.pools.serialize_pool'), \ + patch('szurubooru.func.pools.merge_pools'), \ + patch('szurubooru.func.snapshots.merge'): + api.pool_api.merge_pools( + context_factory( + params={ + 'removeVersion': 1, + 'mergeToVersion': 1, + 'remove': 1, + 'mergeTo': 2, + }, + user=auth_user)) + pools.merge_pools.called_once_with(source_pool, target_pool) + snapshots.merge.assert_called_once_with( + source_pool, target_pool, auth_user) + + +@pytest.mark.parametrize( + 'field', ['remove', 'mergeTo', 'removeVersion', 'mergeToVersion']) +def test_trying_to_omit_mandatory_field( + user_factory, pool_factory, context_factory, field): + db.session.add_all([ + pool_factory(id=1), + pool_factory(id=2), + ]) + db.session.commit() + params = { + 'removeVersion': 1, + 'mergeToVersion': 1, + 'remove': 1, + 'mergeTo': 2, + } + del params[field] + with pytest.raises(errors.ValidationError): + api.pool_api.merge_pools( + context_factory( + params=params, + user=user_factory(rank=model.User.RANK_REGULAR))) + + +def test_trying_to_merge_non_existing( + user_factory, pool_factory, context_factory): + db.session.add(pool_factory(id=1)) + db.session.commit() + with pytest.raises(pools.PoolNotFoundError): + api.pool_api.merge_pools( + context_factory( + params={'remove': 1, 'mergeTo': 9999}, + user=user_factory(rank=model.User.RANK_REGULAR))) + with pytest.raises(pools.PoolNotFoundError): + api.pool_api.merge_pools( + context_factory( + params={'remove': 9999, 'mergeTo': 1}, + user=user_factory(rank=model.User.RANK_REGULAR))) + + +def test_trying_to_merge_without_privileges( + user_factory, pool_factory, context_factory): + db.session.add_all([ + pool_factory(id=1), + pool_factory(id=2), + ]) + db.session.commit() + with pytest.raises(errors.AuthError): + api.pool_api.merge_pools( + context_factory( + params={ + 'removeVersion': 1, + 'mergeToVersion': 1, + 'remove': 1, + 'mergeTo': 2, + }, + user=user_factory(rank=model.User.RANK_ANONYMOUS))) diff --git a/server/szurubooru/tests/api/test_pool_retrieving.py b/server/szurubooru/tests/api/test_pool_retrieving.py new file mode 100644 index 0000000..e48565a --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_retrieving.py @@ -0,0 +1,72 @@ +from unittest.mock import patch +import pytest +from szurubooru import api, db, model, errors +from szurubooru.func import pools + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({ + 'privileges': { + 'pools:list': model.User.RANK_REGULAR, + 'pools:view': model.User.RANK_REGULAR, + }, + }) + + +def test_retrieving_multiple(user_factory, pool_factory, context_factory): + pool1 = pool_factory(id=1) + pool2 = pool_factory(id=2) + db.session.add_all([pool2, pool1]) + db.session.flush() + with patch('szurubooru.func.pools.serialize_pool'): + pools.serialize_pool.return_value = 'serialized pool' + result = api.pool_api.get_pools( + context_factory( + params={'query': '', 'offset': 0}, + user=user_factory(rank=model.User.RANK_REGULAR))) + assert result == { + 'query': '', + 'offset': 0, + 'limit': 100, + 'total': 2, + 'results': ['serialized pool', 'serialized pool'], + } + + +def test_trying_to_retrieve_multiple_without_privileges( + user_factory, context_factory): + with pytest.raises(errors.AuthError): + api.pool_api.get_pools( + context_factory( + params={'query': '', 'offset': 0}, + user=user_factory(rank=model.User.RANK_ANONYMOUS))) + + +def test_retrieving_single(user_factory, pool_factory, context_factory): + db.session.add(pool_factory(id=1)) + db.session.flush() + with patch('szurubooru.func.pools.serialize_pool'): + pools.serialize_pool.return_value = 'serialized pool' + result = api.pool_api.get_pool( + context_factory( + user=user_factory(rank=model.User.RANK_REGULAR)), + {'pool_id': 1}) + assert result == 'serialized pool' + + +def test_trying_to_retrieve_single_non_existing(user_factory, context_factory): + with pytest.raises(pools.PoolNotFoundError): + api.pool_api.get_pool( + context_factory( + user=user_factory(rank=model.User.RANK_REGULAR)), + {'pool_id': 1}) + + +def test_trying_to_retrieve_single_without_privileges( + user_factory, context_factory): + with pytest.raises(errors.AuthError): + api.pool_api.get_pool( + context_factory( + user=user_factory(rank=model.User.RANK_ANONYMOUS)), + {'pool_id': 1}) diff --git a/server/szurubooru/tests/api/test_pool_updating.py b/server/szurubooru/tests/api/test_pool_updating.py new file mode 100644 index 0000000..bd6b71c --- /dev/null +++ b/server/szurubooru/tests/api/test_pool_updating.py @@ -0,0 +1,131 @@ +from unittest.mock import patch +import pytest +from szurubooru import api, db, model, errors +from szurubooru.func import pools, posts, snapshots + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({ + 'privileges': { + 'pools:create': model.User.RANK_REGULAR, + 'pools:edit:names': model.User.RANK_REGULAR, + 'pools:edit:category': model.User.RANK_REGULAR, + 'pools:edit:description': model.User.RANK_REGULAR, + 'pools:edit:posts': model.User.RANK_REGULAR, + }, + }) + + +def test_simple_updating(user_factory, pool_factory, context_factory): + auth_user = user_factory(rank=model.User.RANK_REGULAR) + pool = pool_factory(id=1, names=['pool1', 'pool2']) + db.session.add(pool) + db.session.commit() + with patch('szurubooru.func.pools.create_pool'), \ + patch('szurubooru.func.posts.get_posts_by_ids'), \ + patch('szurubooru.func.pools.update_pool_names'), \ + patch('szurubooru.func.pools.update_pool_category_name'), \ + patch('szurubooru.func.pools.update_pool_description'), \ + patch('szurubooru.func.pools.update_pool_posts'), \ + patch('szurubooru.func.pools.serialize_pool'), \ + patch('szurubooru.func.snapshots.modify'): + posts.get_posts_by_ids.return_value = ([], []) + pools.serialize_pool.return_value = 'serialized pool' + result = api.pool_api.update_pool( + context_factory( + params={ + 'version': 1, + 'names': ['pool3'], + 'category': 'series', + 'description': 'desc', + 'posts': [1, 2] + }, + user=auth_user), + {'pool_id': 1}) + assert result == 'serialized pool' + pools.create_pool.assert_not_called() + pools.update_pool_names.assert_called_once_with(pool, ['pool3']) + pools.update_pool_category_name.assert_called_once_with(pool, 'series') + pools.update_pool_description.assert_called_once_with(pool, 'desc') + pools.update_pool_posts.assert_called_once_with(pool, [1, 2]) + pools.serialize_pool.assert_called_once_with(pool, options=[]) + snapshots.modify.assert_called_once_with(pool, auth_user) + + +@pytest.mark.parametrize( + 'field', [ + 'names', + 'category', + 'description', + 'posts', + ]) +def test_omitting_optional_field( + user_factory, pool_factory, context_factory, field): + db.session.add(pool_factory(id=1)) + db.session.commit() + params = { + 'names': ['pool1', 'pool2'], + 'category': 'default', + 'description': 'desc', + 'posts': [], + } + del params[field] + with patch('szurubooru.func.pools.create_pool'), \ + patch('szurubooru.func.pools.update_pool_names'), \ + patch('szurubooru.func.pools.update_pool_category_name'), \ + patch('szurubooru.func.pools.serialize_pool'): + api.pool_api.update_pool( + context_factory( + params={**params, **{'version': 1}}, + user=user_factory(rank=model.User.RANK_REGULAR)), + {'pool_id': 1}) + + +def test_trying_to_update_non_existing(user_factory, context_factory): + with pytest.raises(pools.PoolNotFoundError): + api.pool_api.update_pool( + context_factory( + params={'names': ['dummy']}, + user=user_factory(rank=model.User.RANK_REGULAR)), + {'pool_id': 9999}) + + +@pytest.mark.parametrize('params', [ + {'names': ['whatever']}, + {'category': 'whatever'}, + {'posts': [1]}, +]) +def test_trying_to_update_without_privileges( + user_factory, pool_factory, context_factory, params): + db.session.add(pool_factory(id=1)) + db.session.commit() + with pytest.raises(errors.AuthError): + api.pool_api.update_pool( + context_factory( + params={**params, **{'version': 1}}, + user=user_factory(rank=model.User.RANK_ANONYMOUS)), + {'pool_id': 1}) + + +def test_trying_to_create_pools_without_privileges( + config_injector, context_factory, pool_factory, user_factory): + pool = pool_factory(id=1) + db.session.add(pool) + db.session.commit() + config_injector( + { + 'privileges': { + 'pools:create': model.User.RANK_ADMINISTRATOR, + 'pools:edit:posts': model.User.RANK_REGULAR, + }, + 'delete_source_files': False, + }) + with patch('szurubooru.func.posts.get_posts_by_ids'): + posts.get_posts_by_ids.return_value = ([], ['new-post']) + with pytest.raises(errors.AuthError): + api.pool_api.create_pool( + context_factory( + params={'posts': [1, 2], 'version': 1}, + user=user_factory(rank=model.User.RANK_REGULAR)), + {'pool_id': 1}) diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py index a131fec..13b1c00 100644 --- a/server/szurubooru/tests/conftest.py +++ b/server/szurubooru/tests/conftest.py @@ -202,6 +202,53 @@ def post_favorite_factory(user_factory, post_factory): @pytest.fixture +def pool_category_factory(): + def factory(name=None, color='dummy', default=False): + category = model.PoolCategory() + category.name = name or get_unique_name() + category.color = color + category.default = default + return category + return factory + + +@pytest.fixture +def pool_factory(): + def factory( + id=None, names=None, description=None, category=None, time=None): + if not category: + category = model.PoolCategory(get_unique_name()) + db.session.add(category) + pool = model.Pool() + pool.pool_id = id + pool.names = [] + for i, name in enumerate(names or [get_unique_name()]): + pool.names.append(model.PoolName(name, i)) + pool.description = description + pool.category = category + pool.creation_time = time or datetime(1996, 1, 1) + return pool + return factory + + +@pytest.fixture +def pool_post_factory(pool_factory, post_factory): + def factory(pool=None, post=None, order=None): + if not pool: + pool = pool_factory() + db.session.add(pool) + if not post: + post = post_factory() + db.session.add(post) + pool_post = model.PoolPost(post) + pool_post.pool = pool + pool_post.post = post + pool_post.order = order or 0 + return pool_post + return factory + + +@pytest.fixture def read_asset(): def get(path): path = os.path.join(os.path.dirname(__file__), 'assets', path) diff --git a/server/szurubooru/tests/func/test_posts.py b/server/szurubooru/tests/func/test_posts.py index 49f93be..ed00879 100644 --- a/server/szurubooru/tests/func/test_posts.py +++ b/server/szurubooru/tests/func/test_posts.py @@ -79,6 +79,8 @@ def test_serialize_post( comment_factory, tag_factory, tag_category_factory, + pool_factory, + pool_category_factory, config_injector): config_injector({'data_url': 'http://example.com/', 'secret': 'test'}) with patch('szurubooru.func.comments.serialize_comment'), \ @@ -150,6 +152,23 @@ def test_serialize_post( time=datetime(1800, 1, 1))]) db.session.flush() + pool1 = pool_factory(id=1, + names=['pool1', 'pool2'], + description='desc', + category=pool_category_factory('test-cat1')) + pool1.last_edit_time = datetime(1998, 1, 1) + pool1.posts.append(post) + + pool2 = pool_factory(id=2, + names=['pool3'], + description='desc2', + category=pool_category_factory('test-cat2')) + pool2.last_edit_time = datetime(1998, 1, 1) + pool2.posts.append(post) + + db.session.add_all([pool1, pool2]) + db.session.flush() + result = posts.serialize_post(post, auth_user) result['tags'].sort(key=lambda tag: tag['names'][0]) @@ -183,6 +202,44 @@ def test_serialize_post( ], 'relations': [], 'notes': [], + 'pools': [ + { + 'id': 1, + 'names': ['pool1', 'pool2'], + 'description': 'desc', + 'category': 'test-cat1', + 'postCount': 1, + 'posts': [ + { + 'id': 1, + 'thumbnailUrl': + 'http://example.com/' + 'generated-thumbnails/1_244c8840887984c4.jpg', + } + ], + 'version': 1, + 'creationTime': datetime(1996, 1, 1), + 'lastEditTime': datetime(1998, 1, 1), + }, + { + 'id': 2, + 'names': ['pool3'], + 'description': 'desc2', + 'category': 'test-cat2', + 'postCount': 1, + 'posts': [ + { + 'id': 1, + 'thumbnailUrl': + 'http://example.com/' + 'generated-thumbnails/1_244c8840887984c4.jpg', + } + ], + 'version': 1, + 'creationTime': datetime(1996, 1, 1), + 'lastEditTime': datetime(1998, 1, 1), + } + ], 'user': 'post author', 'score': 1, 'ownFavorite': False, diff --git a/server/szurubooru/tests/model/test_pool.py b/server/szurubooru/tests/model/test_pool.py new file mode 100644 index 0000000..589e9ba --- /dev/null +++ b/server/szurubooru/tests/model/test_pool.py @@ -0,0 +1,97 @@ +from datetime import datetime +import pytest +from szurubooru import db, model + + +@pytest.fixture(autouse=True) +def inject_config(config_injector): + config_injector({ + 'delete_source_files': False, + 'secret': 'secret', + 'data_dir': '' + }) + + +def test_saving_pool(pool_factory, post_factory): + post1 = post_factory() + post2 = post_factory() + pool = model.Pool() + pool.names = [model.PoolName('alias1', 0), model.PoolName('alias2', 1)] + pool.posts = [] + pool.category = model.PoolCategory('category') + pool.creation_time = datetime(1997, 1, 1) + pool.last_edit_time = datetime(1998, 1, 1) + db.session.add_all([pool, post1, post2]) + db.session.commit() + + assert pool.pool_id is not None + pool.posts.append(post1) + pool.posts.append(post2) + db.session.commit() + + pool = ( + db.session + .query(model.Pool) + .join(model.PoolName) + .filter(model.PoolName.name == 'alias1') + .one()) + assert [pool_name.name for pool_name in pool.names] == ['alias1', 'alias2'] + assert pool.category.name == 'category' + assert pool.creation_time == datetime(1997, 1, 1) + assert pool.last_edit_time == datetime(1998, 1, 1) + assert [post.post_id for post in pool.posts] == [1, 2] + + +def test_cascade_deletions(pool_factory, post_factory): + post1 = post_factory() + post2 = post_factory() + pool = model.Pool() + pool.names = [model.PoolName('alias1', 0), model.PoolName('alias2', 1)] + pool.posts = [] + pool.category = model.PoolCategory('category') + pool.creation_time = datetime(1997, 1, 1) + pool.last_edit_time = datetime(1998, 1, 1) + db.session.add_all([pool, post1, post2]) + db.session.commit() + + assert pool.pool_id is not None + pool.posts.append(post1) + pool.posts.append(post2) + db.session.commit() + + db.session.delete(pool) + db.session.commit() + assert db.session.query(model.Pool).count() == 0 + assert db.session.query(model.PoolName).count() == 0 + assert db.session.query(model.PoolPost).count() == 0 + assert db.session.query(model.PoolCategory).count() == 1 + assert db.session.query(model.Post).count() == 2 + + +def test_tracking_post_count(post_factory, pool_factory): + pool1 = pool_factory() + pool2 = pool_factory() + post1 = post_factory() + post2 = post_factory() + db.session.add_all([pool1, pool2, post1, post2]) + db.session.flush() + assert pool1.pool_id is not None + assert pool2.pool_id is not None + pool1.posts.append(post1) + pool2.posts.append(post1) + pool2.posts.append(post2) + db.session.commit() + assert len(post1.pools) == 2 + assert len(post2.pools) == 1 + assert pool1.post_count == 1 + assert pool2.post_count == 2 + db.session.delete(post1) + db.session.commit() + db.session.refresh(pool1) + db.session.refresh(pool2) + assert pool1.post_count == 0 + assert pool2.post_count == 1 + db.session.delete(post2) + db.session.commit() + db.session.refresh(pool2) + assert pool2.post_count == 0 diff --git a/server/szurubooru/tests/search/configs/test_pool_search_config.py b/server/szurubooru/tests/search/configs/test_pool_search_config.py new file mode 100644 index 0000000..730511a --- /dev/null +++ b/server/szurubooru/tests/search/configs/test_pool_search_config.py @@ -0,0 +1,350 @@ +# pylint: disable=redefined-outer-name +from datetime import datetime +import pytest +from szurubooru import db, errors, search + + +@pytest.fixture +def executor(): + return search.Executor(search.configs.PoolSearchConfig()) + + +@pytest.fixture +def verify_unpaged(executor): + def verify(input, expected_pool_names): + actual_count, actual_pools = executor.execute( + input, offset=0, limit=100) + actual_pool_names = [u.names[0].name for u in actual_pools] + assert actual_count == len(expected_pool_names) + assert actual_pool_names == expected_pool_names + return verify + + +@pytest.mark.parametrize('input,expected_pool_names', [ + ('', ['t1', 't2']), + ('t1', ['t1']), + ('t2', ['t2']), + ('t1,t2', ['t1', 't2']), + ('T1,T2', ['t1', 't2']), +]) +def test_filter_anonymous( + verify_unpaged, pool_factory, input, expected_pool_names): + db.session.add(pool_factory(id=1, names=['t1'])) + db.session.add(pool_factory(id=2, names=['t2'])) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize('db_driver,input,expected_pool_names', [ + (None, ',', None), + (None, 't1,', None), + (None, 't1,t2', ['t1', 't2']), + (None, 't1\\,', []), + (None, 'asd..asd', None), + (None, 'asd\\..asd', []), + (None, 'asd.\\.asd', []), + (None, 'asd\\.\\.asd', []), + (None, '-', None), + (None, '\\-', ['-']), + (None, '--', [ + 't1', 't2', '*', '*asd*', ':', 'asd:asd', '\\', '\\asd', '-asd', + ]), + (None, '\\--', []), + (None, '-\\-', [ + 't1', 't2', '*', '*asd*', ':', 'asd:asd', '\\', '\\asd', '-asd', + ]), + (None, '-*', []), + (None, '\\-*', ['-', '-asd']), + (None, ':', None), + (None, '\\:', [':']), + (None, '\\:asd', []), + (None, '*\\:*', [':', 'asd:asd']), + (None, 'asd:asd', None), + (None, 'asd\\:asd', ['asd:asd']), + (None, '*', [ + 't1', 't2', '*', '*asd*', ':', 'asd:asd', '\\', '\\asd', '-', '-asd' + ]), + (None, '\\*', ['*']), + (None, '\\', None), + (None, '\\asd', None), + ('psycopg2', '\\\\', ['\\']), + ('psycopg2', '\\\\asd', ['\\asd']), +]) +def test_escaping( + executor, pool_factory, input, expected_pool_names, db_driver): + db.session.add_all([ + pool_factory(id=1, names=['t1']), + pool_factory(id=2, names=['t2']), + pool_factory(id=3, names=['*']), + pool_factory(id=4, names=['*asd*']), + pool_factory(id=5, names=[':']), + pool_factory(id=6, names=['asd:asd']), + pool_factory(id=7, names=['\\']), + pool_factory(id=8, names=['\\asd']), + pool_factory(id=9, names=['-']), + pool_factory(id=10, names=['-asd']) + ]) + db.session.flush() + + if db_driver and db.session.get_bind().driver != db_driver: + pytest.xfail() + if expected_pool_names is None: + with pytest.raises(errors.SearchError): + executor.execute(input, offset=0, limit=100) + else: + actual_count, actual_pools = executor.execute( + input, offset=0, limit=100) + actual_pool_names = [u.names[0].name for u in actual_pools] + assert actual_count == len(expected_pool_names) + assert sorted(actual_pool_names) == sorted(expected_pool_names) + + +def test_filter_anonymous_starting_with_colon(verify_unpaged, pool_factory): + db.session.add(pool_factory(id=1, names=[':t'])) + db.session.flush() + with pytest.raises(errors.SearchError): + verify_unpaged(':t', [':t']) + verify_unpaged('\\:t', [':t']) + + +@pytest.mark.parametrize('input,expected_pool_names', [ + ('name:pool1', ['pool1']), + ('name:pool2', ['pool2']), + ('name:none', []), + ('name:', []), + ('name:*1', ['pool1']), + ('name:*2', ['pool2']), + ('name:*', ['pool1', 'pool2', 'pool3', 'pool4']), + ('name:p*', ['pool1', 'pool2', 'pool3', 'pool4']), + ('name:*o*', ['pool1', 'pool2', 'pool3', 'pool4']), + ('name:*!*', []), + ('name:!*', []), + ('name:*!', []), + ('-name:pool1', ['pool2', 'pool3', 'pool4']), + ('-name:pool2', ['pool1', 'pool3', 'pool4']), + ('name:pool1,pool2', ['pool1', 'pool2']), + ('-name:pool1,pool3', ['pool2', 'pool4']), + ('name:pool4', ['pool4']), + ('name:pool5', ['pool4']), + ('name:pool4,pool5', ['pool4']), +]) +def test_filter_by_name( + verify_unpaged, pool_factory, input, expected_pool_names): + db.session.add(pool_factory(id=1, names=['pool1'])) + db.session.add(pool_factory(id=2, names=['pool2'])) + db.session.add(pool_factory(id=3, names=['pool3'])) + db.session.add(pool_factory(id=4, names=['pool4', 'pool5', 'pool6'])) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize('input,expected_pool_names', [ + ('category:cat1', ['t1', 't2']), + ('category:cat2', ['t3']), + ('category:cat1,cat2', ['t1', 't2', 't3']), +]) +def test_filter_by_category( + verify_unpaged, + pool_factory, + pool_category_factory, + input, + expected_pool_names): + cat1 = pool_category_factory(name='cat1') + cat2 = pool_category_factory(name='cat2') + pool1 = pool_factory(id=1, names=['t1'], category=cat1) + pool2 = pool_factory(id=2, names=['t2'], category=cat1) + pool3 = pool_factory(id=3, names=['t3'], category=cat2) + db.session.add_all([pool1, pool2, pool3]) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize('input,expected_pool_names', [ + ('creation-time:2014', ['t1', 't2']), + ('creation-date:2014', ['t1', 't2']), + ('-creation-time:2014', ['t3']), + ('-creation-date:2014', ['t3']), + ('creation-time:2014..2014-06', ['t1', 't2']), + ('creation-time:2014-06..2015-01-01', ['t2', 't3']), + ('creation-time:2014-06..', ['t2', 't3']), + ('creation-time:..2014-06', ['t1', 't2']), + ('-creation-time:2014..2014-06', ['t3']), + ('-creation-time:2014-06..2015-01-01', ['t1']), + ('creation-date:2014..2014-06', ['t1', 't2']), + ('creation-date:2014-06..2015-01-01', ['t2', 't3']), + ('creation-date:2014-06..', ['t2', 't3']), + ('creation-date:..2014-06', ['t1', 't2']), + ('-creation-date:2014..2014-06', ['t3']), + ('-creation-date:2014-06..2015-01-01', ['t1']), + ('creation-time:2014-01,2015', ['t1', 't3']), + ('creation-date:2014-01,2015', ['t1', 't3']), + ('-creation-time:2014-01,2015', ['t2']), + ('-creation-date:2014-01,2015', ['t2']), +]) +def test_filter_by_creation_time( + verify_unpaged, pool_factory, input, expected_pool_names): + pool1 = pool_factory(id=1, names=['t1']) + pool2 = pool_factory(id=2, names=['t2']) + pool3 = pool_factory(id=3, names=['t3']) + pool1.creation_time = datetime(2014, 1, 1) + pool2.creation_time = datetime(2014, 6, 1) + pool3.creation_time = datetime(2015, 1, 1) + db.session.add_all([pool1, pool2, pool3]) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize('input,expected_pool_names', [ + ('last-edit-date:2014', ['t1', 't3']), + ('last-edit-time:2014', ['t1', 't3']), + ('edit-date:2014', ['t1', 't3']), + ('edit-time:2014', ['t1', 't3']), +]) +def test_filter_by_edit_time( + verify_unpaged, pool_factory, input, expected_pool_names): + pool1 = pool_factory(id=1, names=['t1']) + pool2 = pool_factory(id=2, names=['t2']) + pool3 = pool_factory(id=3, names=['t3']) + pool1.last_edit_time = datetime(2014, 1, 1) + pool2.last_edit_time = datetime(2015, 1, 1) + pool3.last_edit_time = datetime(2014, 1, 1) + db.session.add_all([pool1, pool2, pool3]) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize('input,expected_pool_names', [ + ('post-count:2', ['t1']), + ('post-count:1', ['t2']), + ('post-count:1..', ['t1', 't2']), + ('post-count-min:1', ['t1', 't2']), + ('post-count:..1', ['t2']), + ('post-count-max:1', ['t2']), +]) +def test_filter_by_post_count( + verify_unpaged, + pool_factory, + post_factory, + input, + expected_pool_names): + post1 = post_factory(id=1) + post2 = post_factory(id=2) + pool1 = pool_factory(id=1, names=['t1']) + pool2 = pool_factory(id=2, names=['t2']) + db.session.add_all([post1, post2, pool1, pool2]) + pool1.posts.append(post1) + pool1.posts.append(post2) + pool2.posts.append(post1) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize('input', [ + 'post-count:..', + 'post-count:asd', + 'post-count:asd,1', + 'post-count:1,asd', + 'post-count:asd..1', + 'post-count:1..asd', +]) +def test_filter_by_invalid_input(executor, input): + with pytest.raises(errors.SearchError): + executor.execute(input, offset=0, limit=100) + + +@pytest.mark.parametrize('input,expected_pool_names', [ + ('', ['t1', 't2']), + ('sort:name', ['t1', 't2']), + ('-sort:name', ['t2', 't1']), + ('sort:name,asc', ['t1', 't2']), + ('sort:name,desc', ['t2', 't1']), + ('-sort:name,asc', ['t2', 't1']), + ('-sort:name,desc', ['t1', 't2']), +]) +def test_sort_by_name( + verify_unpaged, + pool_factory, + input, + expected_pool_names): + db.session.add(pool_factory(id=2, names=['t2'])) + db.session.add(pool_factory(id=1, names=['t1'])) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize('input,expected_pool_names', [ + ('', ['t1', 't2', 't3']), + ('sort:creation-date', ['t3', 't2', 't1']), + ('sort:creation-time', ['t3', 't2', 't1']), +]) +def test_sort_by_creation_time( + verify_unpaged, pool_factory, input, expected_pool_names): + pool1 = pool_factory(id=1, names=['t1']) + pool2 = pool_factory(id=2, names=['t2']) + pool3 = pool_factory(id=3, names=['t3']) + pool1.creation_time = datetime(1991, 1, 1) + pool2.creation_time = datetime(1991, 1, 2) + pool3.creation_time = datetime(1991, 1, 3) + db.session.add_all([pool3, pool1, pool2]) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize('input,expected_pool_names', [ + ('', ['t1', 't2', 't3']), + ('sort:last-edit-date', ['t3', 't2', 't1']), + ('sort:last-edit-time', ['t3', 't2', 't1']), + ('sort:edit-date', ['t3', 't2', 't1']), + ('sort:edit-time', ['t3', 't2', 't1']), +]) +def test_sort_by_last_edit_time( + verify_unpaged, pool_factory, input, expected_pool_names): + pool1 = pool_factory(id=1, names=['t1']) + pool2 = pool_factory(id=2, names=['t2']) + pool3 = pool_factory(id=3, names=['t3']) + pool1.last_edit_time = datetime(1991, 1, 1) + pool2.last_edit_time = datetime(1991, 1, 2) + pool3.last_edit_time = datetime(1991, 1, 3) + db.session.add_all([pool3, pool1, pool2]) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize('input,expected_pool_names', [ + ('sort:post-count', ['t2', 't1']), +]) +def test_sort_by_post_count( + verify_unpaged, + pool_factory, + post_factory, + input, + expected_pool_names): + post1 = post_factory(id=1) + post2 = post_factory(id=2) + pool1 = pool_factory(id=1, names=['t1']) + pool2 = pool_factory(id=2, names=['t2']) + db.session.add_all([post1, post2, pool1, pool2]) + pool1.posts.append(post1) + pool2.posts.append(post1) + pool2.posts.append(post2) + db.session.flush() + verify_unpaged(input, expected_pool_names) + + +@pytest.mark.parametrize('input,expected_pool_names', [ + ('sort:category', ['t3', 't1', 't2']), +]) +def test_sort_by_category( + verify_unpaged, + pool_factory, + pool_category_factory, + input, + expected_pool_names): + cat1 = pool_category_factory(name='cat1') + cat2 = pool_category_factory(name='cat2') + pool1 = pool_factory(id=1, names=['t1'], category=cat2) + pool2 = pool_factory(id=2, names=['t2'], category=cat2) + pool3 = pool_factory(id=3, names=['t3'], category=cat1) + db.session.add_all([pool1, pool2, pool3]) + db.session.flush() + verify_unpaged(input, expected_pool_names) |