From 97bd935bb6d64dbb08d331b68341026b8f4c5914 Mon Sep 17 00:00:00 2001 From: rr- Date: Wed, 20 Apr 2016 21:31:46 +0200 Subject: [PATCH] server/tags: add listing tag siblings --- API.md | 38 +++++++- server/szurubooru/api/__init__.py | 2 +- server/szurubooru/api/tag_api.py | 17 +++- server/szurubooru/app.py | 6 +- server/szurubooru/func/tags.py | 15 +++ .../szurubooru/tests/api/test_tag_merging.py | 2 +- .../tests/api/test_tag_siblings_retrieving.py | 96 +++++++++++++++++++ 7 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 server/szurubooru/tests/api/test_tag_siblings_retrieving.py diff --git a/API.md b/API.md index b868d38..609977f 100644 --- a/API.md +++ b/API.md @@ -26,6 +26,7 @@ - [Getting tag](#getting-tag) - [Deleting tag](#deleting-tag) - [Merging tags](#merging-tags) + - [Listing tag siblings](#listing-tag-siblings) - Users - [Listing users](#listing-users) - [Creating user](#creating-user) @@ -116,7 +117,7 @@ data. - **Description** - Lists all tag categories. Doesn't support paging. + Lists all tag categories. Doesn't use paging. **Note**: independently, the server exports current tag category list snapshots to the data directory under `tags.json` name. Its purpose is to @@ -552,6 +553,41 @@ data. exception of the set of posts it's used in. +## Listing tag siblings +- **Request** + + `GET /tag-siblings/` + +- **Output** + + ```json5 + { + "siblings": [ + { + "tag": , + "occurrences": 2 + }, + { + "tag": , + "occurrences": 1 + } + ] + } + ``` + ...where `` is a [tag resource](#tag). + +- **Errors** + + - privileges are too low + +- **Description** + + Lists siblings of given tag, e.g. tags that were used in the same posts as + the given tag. `occurrences` field signifies how many times a given sibling + appears with given tag. Results are sorted by occurrences count and the + list is truncated to the first 50 elements. Doesn't use paging. + + ## Listing users - **Request** diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index 953caa0..5ee5339 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -2,6 +2,6 @@ from szurubooru.api.password_reset_api import PasswordResetApi from szurubooru.api.user_api import UserListApi, UserDetailApi -from szurubooru.api.tag_api import TagListApi, TagDetailApi, TagMergingApi +from szurubooru.api.tag_api import TagListApi, TagDetailApi, TagMergeApi, TagSiblingsApi from szurubooru.api.tag_category_api import TagCategoryListApi, TagCategoryDetailApi from szurubooru.api.context import Context, Request diff --git a/server/szurubooru/api/tag_api.py b/server/szurubooru/api/tag_api.py index 1528acf..6783cd2 100644 --- a/server/szurubooru/api/tag_api.py +++ b/server/szurubooru/api/tag_api.py @@ -110,7 +110,7 @@ class TagDetailApi(BaseApi): tags.export_to_json() return {} -class TagMergingApi(BaseApi): +class TagMergeApi(BaseApi): def post(self, ctx): source_tag_name = ctx.get_param_as_string('remove', required=True) or '' target_tag_name = ctx.get_param_as_string('merge-to', required=True) or '' @@ -131,3 +131,18 @@ class TagMergingApi(BaseApi): ctx.session.commit() tags.export_to_json() return _serialize_tag_with_details(target_tag) + +class TagSiblingsApi(BaseApi): + def get(self, ctx, tag_name): + auth.verify_privilege(ctx.user, 'tags:view') + tag = tags.get_tag_by_name(tag_name) + if not tag: + raise tags.TagNotFoundError('Tag %r not found.' % tag_name) + result = tags.get_siblings(tag) + serialized_siblings = [] + for sibling, occurrences in result: + serialized_siblings.append({ + 'tag': _serialize_tag(sibling), + 'occurrences': occurrences + }) + return {'siblings': serialized_siblings} diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py index 742db79..b10e53d 100644 --- a/server/szurubooru/app.py +++ b/server/szurubooru/app.py @@ -53,7 +53,8 @@ def create_app(): tag_category_detail_api = api.TagCategoryDetailApi() tag_list_api = api.TagListApi() tag_detail_api = api.TagDetailApi() - tag_merging_api = api.TagMergingApi() + tag_merge_api = api.TagMergeApi() + tag_siblings_api = api.TagSiblingsApi() password_reset_api = api.PasswordResetApi() app.add_error_handler(errors.AuthError, _on_auth_error) @@ -69,7 +70,8 @@ def create_app(): app.add_route('/tag-category/{category_name}', tag_category_detail_api) app.add_route('/tags/', tag_list_api) app.add_route('/tag/{tag_name}', tag_detail_api) - app.add_route('/tag-merge/', tag_merging_api) + app.add_route('/tag-merge/', tag_merge_api) + app.add_route('/tag-siblings/{tag_name}', tag_siblings_api) app.add_route('/password-reset/{user_name}', password_reset_api) return app diff --git a/server/szurubooru/func/tags.py b/server/szurubooru/func/tags.py index f733902..ad559f5 100644 --- a/server/szurubooru/func/tags.py +++ b/server/szurubooru/func/tags.py @@ -98,6 +98,21 @@ def get_or_create_tags_by_names(names): new_tags.append(new_tag) return related_tags, new_tags +def get_siblings(tag): + tag_alias = sqlalchemy.orm.aliased(db.Tag) + pt_alias1 = sqlalchemy.orm.aliased(db.PostTag) + pt_alias2 = sqlalchemy.orm.aliased(db.PostTag) + result = db.session \ + .query(tag_alias, sqlalchemy.func.count(tag_alias.tag_id)) \ + .join(pt_alias1, pt_alias1.tag_id == tag_alias.tag_id) \ + .join(pt_alias2, pt_alias2.post_id == pt_alias1.post_id) \ + .filter(pt_alias2.tag_id == tag.tag_id) \ + .filter(pt_alias1.tag_id != tag.tag_id) \ + .group_by(tag_alias.tag_id) \ + .order_by(tag_alias.post_count.desc()) \ + .limit(50) + return result + def merge_tags(source_tag, target_tag): db.session.execute( sqlalchemy.sql.expression.update(db.PostTag) \ diff --git a/server/szurubooru/tests/api/test_tag_merging.py b/server/szurubooru/tests/api/test_tag_merging.py index 40a5e93..cba74e4 100644 --- a/server/szurubooru/tests/api/test_tag_merging.py +++ b/server/szurubooru/tests/api/test_tag_merging.py @@ -25,7 +25,7 @@ def test_ctx( ret.context_factory = context_factory ret.user_factory = user_factory ret.tag_factory = tag_factory - ret.api = api.TagMergingApi() + ret.api = api.TagMergeApi() return ret def test_merging_without_usages(test_ctx, fake_datetime): diff --git a/server/szurubooru/tests/api/test_tag_siblings_retrieving.py b/server/szurubooru/tests/api/test_tag_siblings_retrieving.py new file mode 100644 index 0000000..f57c575 --- /dev/null +++ b/server/szurubooru/tests/api/test_tag_siblings_retrieving.py @@ -0,0 +1,96 @@ +import datetime +import pytest +from szurubooru import api, db, errors +from szurubooru.func import util, tags + +def assert_results(result, expected_tag_names_and_occurrences): + actual_tag_names_and_occurences = {} + for item in result['siblings']: + tag_name = item['tag']['names'][0] + occurrences = item['occurrences'] + actual_tag_names_and_occurences[tag_name] = occurrences + assert actual_tag_names_and_occurences == expected_tag_names_and_occurrences + +@pytest.fixture +def test_ctx( + context_factory, config_injector, user_factory, tag_factory, post_factory): + config_injector({ + 'privileges': { + 'tags:view': 'regular_user', + }, + 'thumbnails': {'avatar_width': 200}, + 'ranks': ['anonymous', 'regular_user'], + }) + ret = util.dotdict() + ret.context_factory = context_factory + ret.user_factory = user_factory + ret.tag_factory = tag_factory + ret.post_factory = post_factory + ret.api = api.TagSiblingsApi() + return ret + +def test_unused(test_ctx): + db.session.add(test_ctx.tag_factory(names=['tag'])) + result = test_ctx.api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), 'tag') + assert_results(result, {}) + +def test_used_alone(test_ctx): + tag = test_ctx.tag_factory(names=['tag']) + post = test_ctx.post_factory() + post.tags = [tag] + db.session.add_all([post, tag]) + result = test_ctx.api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), 'tag') + assert_results(result, {}) + +def test_used_with_others(test_ctx): + tag1 = test_ctx.tag_factory(names=['tag1']) + tag2 = test_ctx.tag_factory(names=['tag2']) + post = test_ctx.post_factory() + post.tags = [tag1, tag2] + db.session.add_all([post, tag1, tag2]) + result = test_ctx.api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), 'tag1') + assert_results(result, {'tag2': 1}) + result = test_ctx.api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), 'tag2') + assert_results(result, {'tag1': 1}) + +def test_used_with_multiple_others(test_ctx): + tag1 = test_ctx.tag_factory(names=['tag1']) + tag2 = test_ctx.tag_factory(names=['tag2']) + tag3 = test_ctx.tag_factory(names=['tag3']) + post1 = test_ctx.post_factory() + post2 = test_ctx.post_factory() + post1.tags = [tag1, tag2, tag3] + post2.tags = [tag1, tag3] + db.session.add_all([post1, post2, tag1, tag2, tag3]) + result = test_ctx.api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), 'tag1') + assert_results(result, {'tag2': 1, 'tag3': 2}) + result = test_ctx.api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), 'tag2') + assert_results(result, {'tag1': 1, 'tag3': 1}) + result = test_ctx.api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), 'tag3') + assert_results(result, {'tag1': 2, 'tag2': 1}) + +def test_trying_to_retrieve_non_existing(test_ctx): + with pytest.raises(tags.TagNotFoundError): + test_ctx.api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), '-') + +def test_trying_to_retrieve_without_privileges(test_ctx): + with pytest.raises(errors.AuthError): + test_ctx.api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='anonymous')), '-')