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)
|
- [Updating tag](#updating-tag)
|
||||||
- [Getting tag](#getting-tag)
|
- [Getting tag](#getting-tag)
|
||||||
- [Deleting tag](#deleting-tag)
|
- [Deleting tag](#deleting-tag)
|
||||||
|
- [Merging tags](#merging-tags)
|
||||||
- Users
|
- Users
|
||||||
- [Listing users](#listing-users)
|
- [Listing users](#listing-users)
|
||||||
- [Creating user](#creating-user)
|
- [Creating user](#creating-user)
|
||||||
|
@ -508,6 +509,49 @@ data.
|
||||||
Deletes existing tag. The tag to be deleted must have no usages.
|
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
|
## Listing users
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,6 @@
|
||||||
|
|
||||||
from szurubooru.api.password_reset_api import PasswordResetApi
|
from szurubooru.api.password_reset_api import PasswordResetApi
|
||||||
from szurubooru.api.user_api import UserListApi, UserDetailApi
|
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.tag_category_api import TagCategoryListApi, TagCategoryDetailApi
|
||||||
from szurubooru.api.context import Context, Request
|
from szurubooru.api.context import Context, Request
|
||||||
|
|
|
@ -109,3 +109,25 @@ class TagDetailApi(BaseApi):
|
||||||
ctx.session.commit()
|
ctx.session.commit()
|
||||||
tags.export_to_json()
|
tags.export_to_json()
|
||||||
return {}
|
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_category_detail_api = api.TagCategoryDetailApi()
|
||||||
tag_list_api = api.TagListApi()
|
tag_list_api = api.TagListApi()
|
||||||
tag_detail_api = api.TagDetailApi()
|
tag_detail_api = api.TagDetailApi()
|
||||||
|
tag_merging_api = api.TagMergingApi()
|
||||||
password_reset_api = api.PasswordResetApi()
|
password_reset_api = api.PasswordResetApi()
|
||||||
|
|
||||||
app.add_error_handler(errors.AuthError, _on_auth_error)
|
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('/tag-category/{category_name}', tag_category_detail_api)
|
||||||
app.add_route('/tags/', tag_list_api)
|
app.add_route('/tags/', tag_list_api)
|
||||||
app.add_route('/tag/{tag_name}', tag_detail_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)
|
app.add_route('/password-reset/{user_name}', password_reset_api)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
|
@ -98,6 +98,13 @@ def get_or_create_tags_by_names(names):
|
||||||
new_tags.append(new_tag)
|
new_tags.append(new_tag)
|
||||||
return related_tags, new_tags
|
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):
|
def create_tag(names, category_name, suggestions, implications):
|
||||||
tag = db.Tag()
|
tag = db.Tag()
|
||||||
tag.creation_time = datetime.datetime.now()
|
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