aboutsummaryrefslogtreecommitdiff
path: root/server
diff options
context:
space:
mode:
authorShyam Sunder2020-06-04 21:01:28 -0400
committerShyam Sunder2020-06-04 21:01:28 -0400
commitc5358f7f83ef49f51d5ba557dde1b16ce2c83d1d (patch)
tree253e0be234540c266202993567d998a5c951947d /server
parent4329b1620fe38c2a7bf44615cfb176daa2837590 (diff)
parentb0f1b8c2309277a7b170972d463b4e8d16a79ecc (diff)
client+server: add post pools feature
Diffstat (limited to 'server')
-rw-r--r--server/config.yaml.dist21
-rw-r--r--server/szurubooru/api/__init__.py2
-rw-r--r--server/szurubooru/api/pool_api.py106
-rw-r--r--server/szurubooru/api/pool_category_api.py89
-rw-r--r--server/szurubooru/func/pool_categories.py199
-rw-r--r--server/szurubooru/func/pools.py321
-rw-r--r--server/szurubooru/func/posts.py26
-rw-r--r--server/szurubooru/func/snapshots.py20
-rw-r--r--server/szurubooru/func/tags.py2
-rw-r--r--server/szurubooru/migrations/versions/54de8acc6cef_add_default_pool_category.py61
-rw-r--r--server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py64
-rw-r--r--server/szurubooru/model/__init__.py2
-rw-r--r--server/szurubooru/model/pool.py103
-rw-r--r--server/szurubooru/model/pool_category.py29
-rw-r--r--server/szurubooru/model/post.py10
-rw-r--r--server/szurubooru/model/util.py2
-rw-r--r--server/szurubooru/search/configs/__init__.py1
-rw-r--r--server/szurubooru/search/configs/pool_search_config.py109
-rw-r--r--server/szurubooru/search/configs/post_search_config.py17
-rw-r--r--server/szurubooru/tests/api/test_pool_category_creating.py60
-rw-r--r--server/szurubooru/tests/api/test_pool_category_deleting.py76
-rw-r--r--server/szurubooru/tests/api/test_pool_category_retrieving.py56
-rw-r--r--server/szurubooru/tests/api/test_pool_category_updating.py110
-rw-r--r--server/szurubooru/tests/api/test_pool_creating.py82
-rw-r--r--server/szurubooru/tests/api/test_pool_deleting.py61
-rw-r--r--server/szurubooru/tests/api/test_pool_merging.py98
-rw-r--r--server/szurubooru/tests/api/test_pool_retrieving.py72
-rw-r--r--server/szurubooru/tests/api/test_pool_updating.py131
-rw-r--r--server/szurubooru/tests/conftest.py47
-rw-r--r--server/szurubooru/tests/func/test_posts.py57
-rw-r--r--server/szurubooru/tests/model/test_pool.py97
-rw-r--r--server/szurubooru/tests/search/configs/test_pool_search_config.py350
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)

© 2015 - 2026 Jakob L. Kreuze