server/tags: add tag merging

This commit is contained in:
rr- 2016-04-20 19:02:39 +02:00
parent 74fb297584
commit 747c730688
6 changed files with 244 additions and 1 deletions

44
API.md
View file

@ -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": <tag>,
"snapshots": [
{"data": <tag-snapshot>, "time": <snapshot-time>},
{"data": <tag-snapshot>, "time": <snapshot-time>},
{"data": <tag-snapshot>, "time": <snapshot-time>}
]
}
```
...where `<tag>` 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**

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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')))