server/tags: add tag merging
This commit is contained in:
parent
74fb297584
commit
747c730688
6 changed files with 244 additions and 1 deletions
44
API.md
44
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": <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**
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
168
server/szurubooru/tests/api/test_tag_merging.py
Normal file
168
server/szurubooru/tests/api/test_tag_merging.py
Normal 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')))
|
Loading…
Reference in a new issue