From 747c73068868f6f47de14a9b87e6fa5defc16661 Mon Sep 17 00:00:00 2001 From: rr- Date: Wed, 20 Apr 2016 19:02:39 +0200 Subject: [PATCH] server/tags: add tag merging --- API.md | 44 +++++ server/szurubooru/api/__init__.py | 2 +- server/szurubooru/api/tag_api.py | 22 +++ server/szurubooru/app.py | 2 + server/szurubooru/func/tags.py | 7 + .../szurubooru/tests/api/test_tag_merging.py | 168 ++++++++++++++++++ 6 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 server/szurubooru/tests/api/test_tag_merging.py diff --git a/API.md b/API.md index 3c51c4f..b868d38 100644 --- a/API.md +++ b/API.md @@ -25,6 +25,7 @@ - [Updating tag](#updating-tag) - [Getting tag](#getting-tag) - [Deleting tag](#deleting-tag) + - [Merging tags](#merging-tags) - Users - [Listing users](#listing-users) - [Creating user](#creating-user) @@ -508,6 +509,49 @@ data. Deletes existing tag. The tag to be deleted must have no usages. +## Merging tags +- **Request** + + `POST /tag-merge/` + +- **Input** + + ```json5 + { + "remove": "source-tag", + "merge-to": "target-tag" + } + ``` + +- **Output** + + ```json5 + { + "tag": , + "snapshots": [ + {"data": , "time": }, + {"data": , "time": }, + {"data": , "time": } + ] + } + ``` + ...where `` is the target [tag resource](#tag), and `snapshots` + contain its earlier versions. + +- **Errors** + + - the source or target tag does not exist + - the source tag is the same as the target tag + - privileges are too low + +- **Description** + + Removes source tag and merges all of its usages to the target tag. Source + tag properties such as category, tag relations etc. do not get transferred + and are discarded. The target tag effectively remains unchanged with the + exception of the set of posts it's used in. + + ## Listing users - **Request** diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index 800de1c..953caa0 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 +from szurubooru.api.tag_api import TagListApi, TagDetailApi, TagMergingApi 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 6f91c96..1528acf 100644 --- a/server/szurubooru/api/tag_api.py +++ b/server/szurubooru/api/tag_api.py @@ -109,3 +109,25 @@ class TagDetailApi(BaseApi): ctx.session.commit() tags.export_to_json() return {} + +class TagMergingApi(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 '' + source_tag = tags.get_tag_by_name(source_tag_name) + target_tag = tags.get_tag_by_name(target_tag_name) + if not source_tag: + raise tags.TagNotFoundError( + 'Source tag %r not found.' % source_tag_name) + if not target_tag: + raise tags.TagNotFoundError( + 'Source tag %r not found.' % target_tag_name) + if source_tag.tag_id == target_tag.tag_id: + raise tags.InvalidTagRelationError( + 'Cannot merge tag with itself.') + auth.verify_privilege(ctx.user, 'tags:merge') + tags.merge_tags(source_tag, target_tag) + snapshots.delete(source_tag, ctx.user) + ctx.session.commit() + tags.export_to_json() + return _serialize_tag_with_details(target_tag) diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py index 1643b93..742db79 100644 --- a/server/szurubooru/app.py +++ b/server/szurubooru/app.py @@ -53,6 +53,7 @@ def create_app(): tag_category_detail_api = api.TagCategoryDetailApi() tag_list_api = api.TagListApi() tag_detail_api = api.TagDetailApi() + tag_merging_api = api.TagMergingApi() password_reset_api = api.PasswordResetApi() app.add_error_handler(errors.AuthError, _on_auth_error) @@ -68,6 +69,7 @@ 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('/password-reset/{user_name}', password_reset_api) return app diff --git a/server/szurubooru/func/tags.py b/server/szurubooru/func/tags.py index 469ab70..f733902 100644 --- a/server/szurubooru/func/tags.py +++ b/server/szurubooru/func/tags.py @@ -98,6 +98,13 @@ def get_or_create_tags_by_names(names): new_tags.append(new_tag) return related_tags, new_tags +def merge_tags(source_tag, target_tag): + db.session.execute( + sqlalchemy.sql.expression.update(db.PostTag) \ + .where(db.PostTag.tag_id == source_tag.tag_id) \ + .values(tag_id=target_tag.tag_id)) + db.session.delete(source_tag) + def create_tag(names, category_name, suggestions, implications): tag = db.Tag() tag.creation_time = datetime.datetime.now() diff --git a/server/szurubooru/tests/api/test_tag_merging.py b/server/szurubooru/tests/api/test_tag_merging.py new file mode 100644 index 0000000..40a5e93 --- /dev/null +++ b/server/szurubooru/tests/api/test_tag_merging.py @@ -0,0 +1,168 @@ +import datetime +import os +import pytest +from szurubooru import api, config, db, errors +from szurubooru.func import util, tags + +def get_tag(name): + return db.session \ + .query(db.Tag) \ + .join(db.TagName) \ + .filter(db.TagName.name==name) \ + .first() + +@pytest.fixture +def test_ctx( + tmpdir, config_injector, context_factory, user_factory, tag_factory): + config_injector({ + 'data_dir': str(tmpdir), + 'ranks': ['anonymous', 'regular_user'], + 'privileges': { + 'tags:merge': 'regular_user', + }, + }) + ret = util.dotdict() + ret.context_factory = context_factory + ret.user_factory = user_factory + ret.tag_factory = tag_factory + ret.api = api.TagMergingApi() + return ret + +def test_merging_without_usages(test_ctx, fake_datetime): + source_tag = test_ctx.tag_factory(names=['source'], category_name='meta') + target_tag = test_ctx.tag_factory(names=['target'], category_name='meta') + db.session.add_all([source_tag, target_tag]) + db.session.commit() + with fake_datetime('1997-12-01'): + result = test_ctx.api.post( + test_ctx.context_factory( + input={ + 'remove': 'source', + 'merge-to': 'target', + }, + user=test_ctx.user_factory(rank='regular_user'))) + assert result['tag'] == { + 'names': ['target'], + 'category': 'meta', + 'suggestions': [], + 'implications': [], + 'creationTime': datetime.datetime(1996, 1, 1), + 'lastEditTime': None, + } + assert 'snapshots' in result + assert get_tag('source') is None + tag = get_tag('target') + assert tag is not None + assert os.path.exists(os.path.join(config.config['data_dir'], 'tags.json')) + +def test_merging_with_usages(test_ctx, fake_datetime, post_factory): + source_tag = test_ctx.tag_factory(names=['source'], category_name='meta') + target_tag = test_ctx.tag_factory(names=['target'], category_name='meta') + db.session.add_all([source_tag, target_tag]) + db.session.flush() + assert source_tag.post_count == 0 + assert target_tag.post_count == 0 + post = post_factory() + post.tags = [source_tag] + db.session.add(post) + db.session.commit() + assert source_tag.post_count == 1 + assert target_tag.post_count == 0 + with fake_datetime('1997-12-01'): + result = test_ctx.api.post( + test_ctx.context_factory( + input={ + 'remove': 'source', + 'merge-to': 'target', + }, + user=test_ctx.user_factory(rank='regular_user'))) + assert get_tag('source') is None + assert get_tag('target').post_count == 1 + +@pytest.mark.parametrize('input,expected_exception', [ + ({'remove': None}, tags.TagNotFoundError), + ({'remove': ''}, tags.TagNotFoundError), + ({'remove': []}, tags.TagNotFoundError), + ({'merge-to': None}, tags.TagNotFoundError), + ({'merge-to': ''}, tags.TagNotFoundError), + ({'merge-to': []}, tags.TagNotFoundError), +]) +def test_trying_to_pass_invalid_input(test_ctx, input, expected_exception): + source_tag = test_ctx.tag_factory(names=['source'], category_name='meta') + target_tag = test_ctx.tag_factory(names=['target'], category_name='meta') + db.session.add_all([source_tag, target_tag]) + db.session.commit() + real_input = { + 'remove': 'source', + 'merge-to': 'target', + } + for key, value in input.items(): + real_input[key] = value + with pytest.raises(expected_exception): + test_ctx.api.post( + test_ctx.context_factory( + input=real_input, + user=test_ctx.user_factory(rank='regular_user'))) + +@pytest.mark.parametrize( + 'field', ['remove', 'merge-to']) +def test_trying_to_omit_mandatory_field(test_ctx, tmpdir, field): + db.session.add_all([ + test_ctx.tag_factory(names=['source'], category_name='meta'), + test_ctx.tag_factory(names=['target'], category_name='meta'), + ]) + db.session.commit() + input = { + 'remove': 'source', + 'merge-to': 'target', + } + del input[field] + with pytest.raises(errors.ValidationError): + test_ctx.api.post( + test_ctx.context_factory( + input=input, + user=test_ctx.user_factory(rank='regular_user'))) + +def test_trying_to_merge_non_existing(test_ctx): + db.session.add(test_ctx.tag_factory(names=['good'], category_name='meta')) + db.session.commit() + with pytest.raises(tags.TagNotFoundError): + test_ctx.api.post( + test_ctx.context_factory( + input={'remove': 'good', 'merge-to': 'bad'}, + user=test_ctx.user_factory(rank='regular_user'))) + with pytest.raises(tags.TagNotFoundError): + test_ctx.api.post( + test_ctx.context_factory( + input={'remove': 'bad', 'merge-to': 'good'}, + user=test_ctx.user_factory(rank='regular_user'))) + +def test_trying_to_merge_to_itself(test_ctx): + db.session.add(test_ctx.tag_factory(names=['good'], category_name='meta')) + db.session.commit() + with pytest.raises(tags.InvalidTagRelationError): + test_ctx.api.post( + test_ctx.context_factory( + input={'remove': 'good', 'merge-to': 'good'}, + user=test_ctx.user_factory(rank='regular_user'))) + +@pytest.mark.parametrize('input', [ + {'names': 'whatever'}, + {'category': 'whatever'}, + {'suggestions': ['whatever']}, + {'implications': ['whatever']}, +]) +def test_trying_to_merge_without_privileges(test_ctx, input): + db.session.add_all([ + test_ctx.tag_factory(names=['source'], category_name='meta'), + test_ctx.tag_factory(names=['target'], category_name='meta'), + ]) + db.session.commit() + with pytest.raises(errors.AuthError): + test_ctx.api.post( + test_ctx.context_factory( + input={ + 'remove': 'source', + 'merge-to': 'target', + }, + user=test_ctx.user_factory(rank='anonymous')))