From adecdd4cd96d0257cf3eab40ac09becc986e7724 Mon Sep 17 00:00:00 2001 From: rr- Date: Sat, 16 Apr 2016 10:57:42 +0200 Subject: [PATCH] server/tags: add tag updating --- API.md | 55 ++- server/szurubooru/api/tag_api.py | 34 +- .../szurubooru/tests/api/test_tag_creating.py | 351 +++++++++--------- .../szurubooru/tests/api/test_tag_updating.py | 256 +++++++++++++ server/szurubooru/tests/conftest.py | 16 +- server/szurubooru/tests/util/test_misc.py | 11 +- server/szurubooru/util/tags.py | 104 ++++-- 7 files changed, 615 insertions(+), 212 deletions(-) create mode 100644 server/szurubooru/tests/api/test_tag_updating.py diff --git a/API.md b/API.md index 3f30730..9987408 100644 --- a/API.md +++ b/API.md @@ -85,6 +85,7 @@ data. ## Listing tags Not yet implemented. + ## Creating tag - **Request** @@ -116,6 +117,8 @@ Not yet implemented. - any name, implication or suggestion has invalid name - category is invalid - no name was specified + - implications or suggestions contain any item from names (e.g. there's a + shallow cyclic dependency) - privileges are too low - **Description** @@ -128,12 +131,58 @@ Not yet implemented. no suggestions, one name and their category is set to the first item of `tag_categories` from server's configuration. + ## Updating tag -Not yet implemented. +- **Request** + + `PUT /tags/` + +- **Input** + + ```json5 + { + "names": [, , ...], + "category": , + "implications": [, , ...], + "suggestions": [, , ...] + } + ``` + +- **Output** + + ```json5 + { + "tag": + } + ``` + ...where `` is a [tag resource](#tag). + +- **Errors** + + - any name is used by an existing tag (names are case insensitive) + - any name, implication or suggestion has invalid name + - category is invalid + - no name was specified + - implications or suggestions contain any item from names (e.g. there's a + shallow cyclic dependency) + - privileges are too low + +- **Description** + + Updates an existing tag using specified parameters. Names, suggestions and + implications must match `tag_name_regex` from server's configuration. + Category must be one of `tag_categories` from server's configuration. + If specified implied tags or suggested tags do not exist yet, they will + be automatically created. Tags created automatically have no implications, + no suggestions, one name and their category is set to the first item of + `tag_categories` from server's configuration. All fields are optional - + update concerns only provided fields. + ## Getting tag Not yet implemented. + ## Removing tag Not yet implemented. @@ -429,7 +478,9 @@ Not yet implemented. "names": ["tag1", "tag2", "tag3"], "category": "plain", // one of values controlled by server's configuration "implications": ["implied-tag1", "implied-tag2", "implied-tag3"], - "suggestions": ["suggested-tag1", "suggested-tag2", "suggested-tag3"] + "suggestions": ["suggested-tag1", "suggested-tag2", "suggested-tag3"], + "creationTime": "2016-03-28T13:37:01.755461", + "lastEditTime": "2016-04-08T20:20:16.570517" } ``` diff --git a/server/szurubooru/api/tag_api.py b/server/szurubooru/api/tag_api.py index 99b385e..1e32afc 100644 --- a/server/szurubooru/api/tag_api.py +++ b/server/szurubooru/api/tag_api.py @@ -1,3 +1,4 @@ +import datetime from szurubooru import errors from szurubooru.util import auth, tags from szurubooru.api.base_api import BaseApi @@ -10,6 +11,8 @@ def _serialize_tag(tag): relation.child_tag.names[0].name for relation in tag.suggestions], 'implications': [ relation.child_tag.names[0].name for relation in tag.implications], + 'creationTime': tag.creation_time, + 'lastEditTime': tag.last_edit_time, } class TagListApi(BaseApi): @@ -27,7 +30,6 @@ class TagListApi(BaseApi): tag = tags.create_tag( ctx.session, names, category, suggestions, implications) ctx.session.add(tag) - ctx.session.flush() ctx.session.commit() return {'tag': _serialize_tag(tag)} @@ -35,8 +37,34 @@ class TagDetailApi(BaseApi): def get(self, ctx): raise NotImplementedError() - def put(self, ctx): - raise NotImplementedError() + def put(self, ctx, tag_name): + tag = tags.get_by_name(ctx.session, tag_name) + if not tag: + raise tags.TagNotFoundError('Tag %r not found.' % tag_name) + + if ctx.has_param('names'): + auth.verify_privilege(ctx.user, 'tags:edit:names') + tags.update_names( + ctx.session, tag, ctx.get_param_as_list('names')) + + if ctx.has_param('category'): + auth.verify_privilege(ctx.user, 'tags:edit:category') + tags.update_category( + ctx.session, tag, ctx.get_param_as_string('category')) + + if ctx.has_param('suggestions'): + auth.verify_privilege(ctx.user, 'tags:edit:suggestions') + tags.update_suggestions( + ctx.session, tag, ctx.get_param_as_list('suggestions')) + + if ctx.has_param('implications'): + auth.verify_privilege(ctx.user, 'tags:edit:implications') + tags.update_implications( + ctx.session, tag, ctx.get_param_as_list('implications')) + + tag.last_edit_time = datetime.datetime.now() + ctx.session.commit() + return {'tag': _serialize_tag(tag)} def delete(self, ctx): raise NotImplementedError() diff --git a/server/szurubooru/tests/api/test_tag_creating.py b/server/szurubooru/tests/api/test_tag_creating.py index 1e35c18..e645df3 100644 --- a/server/szurubooru/tests/api/test_tag_creating.py +++ b/server/szurubooru/tests/api/test_tag_creating.py @@ -1,241 +1,256 @@ +import datetime import pytest -from datetime import datetime from szurubooru import api, db, errors -from szurubooru.util import auth +from szurubooru.util import misc, tags + +def get_tag(session, name): + return session.query(db.Tag) \ + .join(db.TagName) \ + .filter(db.TagName.name==name) \ + .first() + +def assert_relations(relations, expected_tag_names): + actual_names = [rel.child_tag.names[0].name for rel in relations] + assert actual_names == expected_tag_names @pytest.fixture -def tag_config(config_injector): +def test_ctx( + session, config_injector, context_factory, user_factory, tag_factory): config_injector({ 'tag_categories': ['meta', 'character', 'copyright'], 'tag_name_regex': '^[^!]*$', 'ranks': ['regular_user'], 'privileges': {'tags:create': 'regular_user'}, }) + ret = misc.dotdict() + ret.session = session + ret.context_factory = context_factory + ret.user_factory = user_factory + ret.tag_factory = tag_factory + ret.api = api.TagListApi() + return ret -@pytest.fixture -def tag_list_api(tag_config): - return api.TagListApi() - -def get_tag(session, name): - return session.query(db.Tag) \ - .join(db.TagName) \ - .filter(db.TagName.name==name) \ - .one() - -def assert_relations(relations, expected_tag_names): - actual_names = [rel.child_tag.names[0].name for rel in relations] - assert actual_names == expected_tag_names - -def test_creating_simple_tags( - session, context_factory, user_factory, tag_list_api): - result = tag_list_api.post( - context_factory( +def test_creating_simple_tags(test_ctx, fake_datetime): + fake_datetime(datetime.datetime(1997, 12, 1)) + result = test_ctx.api.post( + test_ctx.context_factory( input={ 'names': ['tag1', 'tag2'], 'category': 'meta', - 'implications': [], 'suggestions': [], + 'implications': [], }, - user=user_factory(rank='regular_user'))) + user=test_ctx.user_factory(rank='regular_user'))) assert result == { 'tag': { 'names': ['tag1', 'tag2'], 'category': 'meta', 'suggestions': [], 'implications': [], + 'creationTime': datetime.datetime(1997, 12, 1), + 'lastEditTime': None, } } - tag = get_tag(session, 'tag1') + tag = get_tag(test_ctx.session, 'tag1') assert [tag_name.name for tag_name in tag.names] == ['tag1', 'tag2'] assert tag.category == 'meta' - #TODO: assert tag.creation_time == something assert tag.last_edit_time is None assert tag.post_count == 0 assert_relations(tag.suggestions, []) assert_relations(tag.implications, []) -def test_duplicating_names( - session, context_factory, user_factory, tag_list_api): - result = tag_list_api.post( - context_factory( +def test_duplicating_names(test_ctx): + result = test_ctx.api.post( + test_ctx.context_factory( input={ 'names': ['tag1', 'TAG1'], 'category': 'meta', - 'implications': [], 'suggestions': [], + 'implications': [], }, - user=user_factory(rank='regular_user'))) + user=test_ctx.user_factory(rank='regular_user'))) assert result['tag']['names'] == ['tag1'] assert result['tag']['category'] == 'meta' - tag = get_tag(session, 'tag1') + tag = get_tag(test_ctx.session, 'tag1') assert [tag_name.name for tag_name in tag.names] == ['tag1'] -def test_trying_to_create_tag_without_names( - session, context_factory, user_factory, tag_list_api): - with pytest.raises(errors.ValidationError): - tag_list_api.post( - context_factory( +def test_trying_to_create_tag_without_names(test_ctx): + with pytest.raises(tags.InvalidNameError): + test_ctx.api.post( + test_ctx.context_factory( input={ 'names': [], 'category': 'meta', - 'implications': [], 'suggestions': [], + 'implications': [], }, - user=user_factory(rank='regular_user'))) + user=test_ctx.user_factory(rank='regular_user'))) -def test_trying_to_use_existing_name( - session, context_factory, user_factory, tag_factory, tag_list_api): - session.add(tag_factory(names=['used1'], category='meta')) - session.add(tag_factory(names=['used2'], category='meta')) - session.commit() - with pytest.raises(errors.ValidationError): - tag_list_api.post( - context_factory( - input={ - 'names': ['used1', 'unused'], - 'category': 'meta', - 'implications': [], - 'suggestions': [], - }, - user=user_factory(rank='regular_user'))) - with pytest.raises(errors.ValidationError): - tag_list_api.post( - context_factory( - input={ - 'names': ['USED2', 'unused'], - 'category': 'meta', - 'implications': [], - 'suggestions': [], - }, - user=user_factory(rank='regular_user'))) - -def test_trying_to_create_tag_with_invalid_name( - session, context_factory, user_factory, tag_list_api): - with pytest.raises(errors.ValidationError): - tag_list_api.post( - context_factory( +def test_trying_to_create_tag_with_invalid_name(test_ctx): + with pytest.raises(tags.InvalidNameError): + test_ctx.api.post( + test_ctx.context_factory( input={ 'names': ['!'], 'category': 'meta', - 'implications': [], 'suggestions': [], - }, - user=user_factory(rank='regular_user'))) - with pytest.raises(errors.ValidationError): - tag_list_api.post( - context_factory( - input={ - 'names': ['ok'], - 'category': 'meta', - 'implications': ['!'], - 'suggestions': [], - }, - user=user_factory(rank='regular_user'))) - with pytest.raises(errors.ValidationError): - tag_list_api.post( - context_factory( - input={ - 'names': ['ok'], - 'category': 'meta', 'implications': [], - 'suggestions': ['!'], }, - user=user_factory(rank='regular_user'))) + user=test_ctx.user_factory(rank='regular_user'))) + assert get_tag(test_ctx.session, 'ok') is None + assert get_tag(test_ctx.session, '!') is None -def test_trying_to_create_tag_with_invalid_category( - session, context_factory, user_factory, tag_list_api): - with pytest.raises(errors.ValidationError): - tag_list_api.post( - context_factory( +def test_trying_to_use_existing_name(test_ctx): + test_ctx.session.add_all([ + test_ctx.tag_factory(names=['used1'], category='meta'), + test_ctx.tag_factory(names=['used2'], category='meta'), + ]) + test_ctx.session.commit() + with pytest.raises(tags.TagAlreadyExistsError): + test_ctx.api.post( + test_ctx.context_factory( + input={ + 'names': ['used1', 'unused'], + 'category': 'meta', + 'suggestions': [], + 'implications': [], + }, + user=test_ctx.user_factory(rank='regular_user'))) + with pytest.raises(tags.TagAlreadyExistsError): + test_ctx.api.post( + test_ctx.context_factory( + input={ + 'names': ['USED2', 'unused'], + 'category': 'meta', + 'suggestions': [], + 'implications': [], + }, + user=test_ctx.user_factory(rank='regular_user'))) + assert get_tag(test_ctx.session, 'unused') is None + +def test_trying_to_create_tag_with_invalid_category(test_ctx): + with pytest.raises(tags.InvalidCategoryError): + test_ctx.api.post( + test_ctx.context_factory( input={ 'names': ['ok'], 'category': 'invalid', - 'implications': [], 'suggestions': [], + 'implications': [], }, - user=user_factory(rank='regular_user'))) + user=test_ctx.user_factory(rank='regular_user'))) + assert get_tag(test_ctx.session, 'ok') is None +@pytest.mark.parametrize('input,expected_suggestions,expected_implications', [ + # new relations + ({ + 'names': ['main'], + 'category': 'meta', + 'suggestions': ['sug1', 'sug2'], + 'implications': ['imp1', 'imp2'], + }, ['sug1', 'sug2'], ['imp1', 'imp2']), + # overlapping relations + ({ + 'names': ['main'], + 'category': 'meta', + 'suggestions': ['sug', 'shared'], + 'implications': ['shared', 'imp'], + }, ['sug', 'shared'], ['shared', 'imp']), + # duplicate relations + ({ + 'names': ['main'], + 'category': 'meta', + 'suggestions': ['sug', 'SUG'], + 'implications': ['imp', 'IMP'], + }, ['sug'], ['imp']), + # overlapping duplicate relations + ({ + 'names': ['main'], + 'category': 'meta', + 'suggestions': ['shared1', 'shared2'], + 'implications': ['SHARED1', 'SHARED2'], + }, ['shared1', 'shared2'], ['shared1', 'shared2']), +]) def test_creating_new_suggestions_and_implications( - session, context_factory, user_factory, tag_list_api): - result = tag_list_api.post( - context_factory( - input={ - 'names': ['tag1'], - 'category': 'meta', - 'implications': ['tag2', 'tag3'], - 'suggestions': ['tag4', 'tag5'], - }, - user=user_factory(rank='regular_user'))) - assert result['tag']['implications'] == ['tag2', 'tag3'] - assert result['tag']['suggestions'] == ['tag4', 'tag5'] - tag = get_tag(session, 'tag1') - assert_relations(tag.implications, ['tag2', 'tag3']) - assert_relations(tag.suggestions, ['tag4', 'tag5']) + test_ctx, input, expected_suggestions, expected_implications): + result = test_ctx.api.post( + test_ctx.context_factory( + input=input, user=test_ctx.user_factory(rank='regular_user'))) + assert result['tag']['suggestions'] == expected_suggestions + assert result['tag']['implications'] == expected_implications + tag = get_tag(test_ctx.session, 'main') + assert_relations(tag.suggestions, expected_suggestions) + assert_relations(tag.implications, expected_implications) + for name in ['main'] + expected_suggestions + expected_implications: + assert get_tag(test_ctx.session, name) is not None -def test_duplicating_suggestions_and_implications( - session, context_factory, user_factory, tag_list_api): - result = tag_list_api.post( - context_factory( - input={ - 'names': ['tag1'], - 'category': 'meta', - 'implications': ['tag2', 'TAG2'], - 'suggestions': ['tag3', 'TAG3'], - }, - user=user_factory(rank='regular_user'))) - assert result['tag']['implications'] == ['tag2'] - assert result['tag']['suggestions'] == ['tag3'] - tag = get_tag(session, 'tag1') - assert_relations(tag.implications, ['tag2']) - assert_relations(tag.suggestions, ['tag3']) - -def test_reusing_suggestions_and_implications( - session, - context_factory, - user_factory, - tag_factory, - tag_list_api): - session.add(tag_factory(names=['tag1', 'tag2'], category='meta')) - session.add(tag_factory(names=['tag3'], category='meta')) - session.commit() - result = tag_list_api.post( - context_factory( +def test_reusing_suggestions_and_implications(test_ctx): + test_ctx.session.add_all([ + test_ctx.tag_factory(names=['tag1', 'tag2'], category='meta'), + test_ctx.tag_factory(names=['tag3'], category='meta'), + ]) + test_ctx.session.commit() + result = test_ctx.api.post( + test_ctx.context_factory( input={ 'names': ['new'], 'category': 'meta', - 'implications': ['tag1'], 'suggestions': ['TAG2'], + 'implications': ['tag1'], }, - user=user_factory(rank='regular_user'))) - assert result['tag']['implications'] == ['tag1'] + user=test_ctx.user_factory(rank='regular_user'))) # NOTE: it should export only the first name assert result['tag']['suggestions'] == ['tag1'] - tag = get_tag(session, 'new') - assert_relations(tag.implications, ['tag1']) + assert result['tag']['implications'] == ['tag1'] + tag = get_tag(test_ctx.session, 'new') assert_relations(tag.suggestions, ['tag1']) + assert_relations(tag.implications, ['tag1']) -def test_tag_trying_to_imply_or_suggest_itself( - session, context_factory, user_factory, tag_list_api): - with pytest.raises(errors.ValidationError): - tag_list_api.post( - context_factory( - input={ - 'names': ['tag1'], - 'category': 'meta', - 'implications': ['tag1'], - 'suggestions': [], - }, - user=user_factory(rank='regular_user'))) - with pytest.raises(errors.ValidationError): - tag_list_api.post( - context_factory( - input={ - 'names': ['tag1'], - 'category': 'meta', - 'implications': [], - 'suggestions': ['tag1'], - }, - user=user_factory(rank='regular_user'))) +@pytest.mark.parametrize('input', [ + { + 'names': ['ok'], + 'category': 'meta', + 'suggestions': [], + 'implications': ['ok2', '!'], + }, + { + 'names': ['ok'], + 'category': 'meta', + 'suggestions': ['ok2', '!'], + 'implications': [], + } +]) +def test_trying_to_create_tag_with_invalid_relation(test_ctx, input): + with pytest.raises(tags.InvalidNameError): + test_ctx.api.post( + test_ctx.context_factory( + input=input, user=test_ctx.user_factory(rank='regular_user'))) + assert get_tag(test_ctx.session, 'ok') is None + assert get_tag(test_ctx.session, 'ok2') is None + assert get_tag(test_ctx.session, '!') is None + +@pytest.mark.parametrize('input', [ + { + 'names': ['tag'], + 'category': 'meta', + 'suggestions': ['tag'], + 'implications': [], + }, + { + 'names': ['tag'], + 'category': 'meta', + 'suggestions': [], + 'implications': ['tag'], + } +]) +def test_tag_trying_to_relate_to_itself(test_ctx, input): + assert get_tag(test_ctx.session, 'tag') is None + with pytest.raises(tags.RelationError): + test_ctx.api.post( + test_ctx.context_factory( + input=input, + user=test_ctx.user_factory(rank='regular_user'))) + assert get_tag(test_ctx.session, 'tag') is None # TODO: test bad privileges # TODO: test max length diff --git a/server/szurubooru/tests/api/test_tag_updating.py b/server/szurubooru/tests/api/test_tag_updating.py new file mode 100644 index 0000000..12f43de --- /dev/null +++ b/server/szurubooru/tests/api/test_tag_updating.py @@ -0,0 +1,256 @@ +import datetime +import pytest +from szurubooru import api, db, errors +from szurubooru.util import misc, tags + +def get_tag(session, name): + return session.query(db.Tag) \ + .join(db.TagName) \ + .filter(db.TagName.name==name) \ + .first() + +def assert_relations(relations, expected_tag_names): + actual_names = [rel.child_tag.names[0].name for rel in relations] + assert actual_names == expected_tag_names + +@pytest.fixture +def test_ctx( + session, config_injector, context_factory, user_factory, tag_factory): + config_injector({ + 'tag_categories': ['meta', 'character', 'copyright'], + 'tag_name_regex': '^[^!]*$', + 'ranks': ['regular_user'], + 'privileges': { + 'tags:edit:names': 'regular_user', + 'tags:edit:category': 'regular_user', + 'tags:edit:suggestions': 'regular_user', + 'tags:edit:implications': 'regular_user', + }, + }) + ret = misc.dotdict() + ret.session = session + ret.context_factory = context_factory + ret.user_factory = user_factory + ret.tag_factory = tag_factory + ret.api = api.TagDetailApi() + return ret + +def test_simple_updating(test_ctx, fake_datetime): + fake_datetime(datetime.datetime(1997, 12, 1)) + tag = test_ctx.tag_factory(names=['tag1', 'tag2'], category='meta') + test_ctx.session.add(tag) + test_ctx.session.commit() + result = test_ctx.api.put( + test_ctx.context_factory( + input={ + 'names': ['tag3'], + 'category': 'character', + }, + user=test_ctx.user_factory(rank='regular_user')), + 'tag1') + assert result == { + 'tag': { + 'names': ['tag3'], + 'category': 'character', + 'suggestions': [], + 'implications': [], + 'creationTime': datetime.datetime(1996, 1, 1), + 'lastEditTime': datetime.datetime(1997, 12, 1), + } + } + assert get_tag(test_ctx.session, 'tag1') is None + assert get_tag(test_ctx.session, 'tag2') is None + tag = get_tag(test_ctx.session, 'tag3') + assert tag is not None + assert [tag_name.name for tag_name in tag.names] == ['tag3'] + assert tag.category == 'character' + assert tag.suggestions == [] + assert tag.implications == [] + +def test_trying_to_update_non_existing_tag(test_ctx): + with pytest.raises(tags.TagNotFoundError): + test_ctx.api.put( + test_ctx.context_factory( + input={'names': ['dummy']}, + user=test_ctx.user_factory(rank='regular_user')), + 'tag1') + +@pytest.mark.parametrize('dup_name', ['tag1', 'TAG1']) +def test_reusing_own_name(test_ctx, dup_name): + test_ctx.session.add( + test_ctx.tag_factory(names=['tag1', 'tag2'], category='meta')) + test_ctx.session.commit() + result = test_ctx.api.put( + test_ctx.context_factory( + input={'names': [dup_name, 'tag3']}, + user=test_ctx.user_factory(rank='regular_user')), + 'tag1') + assert result['tag']['names'] == ['tag1', 'tag3'] + assert get_tag(test_ctx.session, 'tag2') is None + tag1 = get_tag(test_ctx.session, 'tag1') + tag2 = get_tag(test_ctx.session, 'tag3') + assert tag1.tag_id == tag2.tag_id + assert [name.name for name in tag1.names] == ['tag1', 'tag3'] + +def test_duplicating_names(test_ctx): + test_ctx.session.add( + test_ctx.tag_factory(names=['tag1', 'tag2'], category='meta')) + result = test_ctx.api.put( + test_ctx.context_factory( + input={'names': ['tag3', 'TAG3']}, + user=test_ctx.user_factory(rank='regular_user')), + 'tag1') + assert result['tag']['names'] == ['tag3'] + assert get_tag(test_ctx.session, 'tag1') is None + assert get_tag(test_ctx.session, 'tag2') is None + tag = get_tag(test_ctx.session, 'tag3') + assert tag is not None + assert [tag_name.name for tag_name in tag.names] == ['tag3'] + +@pytest.mark.parametrize('input', [ + {'names': []}, + {'names': ['!']}, +]) +def test_trying_to_set_invalid_name(test_ctx, input): + test_ctx.session.add(test_ctx.tag_factory(names=['tag1'], category='meta')) + test_ctx.session.commit() + with pytest.raises(tags.InvalidNameError): + test_ctx.api.put( + test_ctx.context_factory( + input=input, + user=test_ctx.user_factory(rank='regular_user')), + 'tag1') + +@pytest.mark.parametrize('dup_name', ['tag1', 'TAG1', 'tag2', 'TAG2']) +def test_trying_to_use_existing_name(test_ctx, dup_name): + test_ctx.session.add_all([ + test_ctx.tag_factory(names=['tag1', 'tag2'], category='meta'), + test_ctx.tag_factory(names=['tag3', 'tag4'], category='meta')]) + test_ctx.session.commit() + with pytest.raises(tags.TagAlreadyExistsError): + test_ctx.api.put( + test_ctx.context_factory( + input={'names': [dup_name]}, + user=test_ctx.user_factory(rank='regular_user')), + 'tag3') + +def test_trying_to_update_tag_with_invalid_category(test_ctx): + test_ctx.session.add(test_ctx.tag_factory(names=['tag1'], category='meta')) + test_ctx.session.commit() + with pytest.raises(tags.InvalidCategoryError): + test_ctx.api.put( + test_ctx.context_factory( + input={ + 'names': ['ok'], + 'category': 'invalid', + 'suggestions': [], + 'implications': [], + }, + user=test_ctx.user_factory(rank='regular_user')), + 'tag1') + +@pytest.mark.parametrize('input,expected_suggestions,expected_implications', [ + # new relations + ({ + 'suggestions': ['sug1', 'sug2'], + 'implications': ['imp1', 'imp2'], + }, ['sug1', 'sug2'], ['imp1', 'imp2']), + # overlapping relations + ({ + 'suggestions': ['sug', 'shared'], + 'implications': ['shared', 'imp'], + }, ['sug', 'shared'], ['shared', 'imp']), + # duplicate relations + ({ + 'suggestions': ['sug', 'SUG'], + 'implications': ['imp', 'IMP'], + }, ['sug'], ['imp']), + # overlapping duplicate relations + ({ + 'suggestions': ['shared1', 'shared2'], + 'implications': ['SHARED1', 'SHARED2'], + }, ['shared1', 'shared2'], ['shared1', 'shared2']), +]) +def test_updating_new_suggestions_and_implications( + test_ctx, input, expected_suggestions, expected_implications): + test_ctx.session.add(test_ctx.tag_factory(names=['main'], category='meta')) + test_ctx.session.commit() + result = test_ctx.api.put( + test_ctx.context_factory( + input=input, user=test_ctx.user_factory(rank='regular_user')), + 'main') + assert result['tag']['suggestions'] == expected_suggestions + assert result['tag']['implications'] == expected_implications + tag = get_tag(test_ctx.session, 'main') + assert_relations(tag.suggestions, expected_suggestions) + assert_relations(tag.implications, expected_implications) + for name in ['main'] + expected_suggestions + expected_implications: + assert get_tag(test_ctx.session, name) is not None + +def test_reusing_suggestions_and_implications(test_ctx): + test_ctx.session.add_all([ + test_ctx.tag_factory(names=['tag1', 'tag2'], category='meta'), + test_ctx.tag_factory(names=['tag3'], category='meta'), + test_ctx.tag_factory(names=['tag4'], category='meta'), + ]) + test_ctx.session.commit() + result = test_ctx.api.put( + test_ctx.context_factory( + input={ + 'names': ['new'], + 'category': 'meta', + 'suggestions': ['TAG2'], + 'implications': ['tag1'], + }, + user=test_ctx.user_factory(rank='regular_user')), + 'tag4') + # NOTE: it should export only the first name + assert result['tag']['suggestions'] == ['tag1'] + assert result['tag']['implications'] == ['tag1'] + tag = get_tag(test_ctx.session, 'new') + assert_relations(tag.suggestions, ['tag1']) + assert_relations(tag.implications, ['tag1']) + +@pytest.mark.parametrize('input', [ + {'names': ['ok'], 'suggestions': ['ok2', '!']}, + {'names': ['ok'], 'implications': ['ok2', '!']}, +]) +def test_trying_to_update_tag_with_invalid_relation(test_ctx, input): + test_ctx.session.add(test_ctx.tag_factory(names=['tag'], category='meta')) + test_ctx.session.commit() + with pytest.raises(tags.InvalidNameError): + test_ctx.api.put( + test_ctx.context_factory( + input=input, user=test_ctx.user_factory(rank='regular_user')), + 'tag') + test_ctx.session.rollback() + assert get_tag(test_ctx.session, 'tag') is not None + assert get_tag(test_ctx.session, '!') is None + assert get_tag(test_ctx.session, 'ok') is None + assert get_tag(test_ctx.session, 'ok2') is None + +@pytest.mark.parametrize('input', [ + { + 'names': ['tag1'], + 'category': 'meta', + 'suggestions': ['tag1'], + 'implications': [], + }, + { + 'names': ['tag1'], + 'category': 'meta', + 'suggestions': [], + 'implications': ['tag1'], + } +]) +def test_tag_trying_to_relate_to_itself(test_ctx, input): + test_ctx.session.add(test_ctx.tag_factory(names=['tag1'], category='meta')) + test_ctx.session.commit() + with pytest.raises(tags.RelationError): + test_ctx.api.put( + test_ctx.context_factory( + input=input, user=test_ctx.user_factory(rank='regular_user')), + 'tag1') + +# TODO: test bad privileges +# TODO: test max length diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py index 5eb3e54..04ddf90 100644 --- a/server/szurubooru/tests/conftest.py +++ b/server/szurubooru/tests/conftest.py @@ -1,9 +1,19 @@ -from datetime import datetime +import datetime import pytest import sqlalchemy from szurubooru import api, config, db from szurubooru.util import misc +@pytest.fixture +def fake_datetime(monkeypatch): + def injector(now): + class mydatetime(datetime.datetime): + @staticmethod + def now(tz=None): + return now + monkeypatch.setattr(datetime, 'datetime', mydatetime) + return injector + @pytest.fixture def session(): import logging @@ -44,7 +54,7 @@ def user_factory(): user.password_hash = 'dummy' user.email = 'dummy' user.rank = rank - user.creation_time = datetime(1997, 1, 1) + user.creation_time = datetime.datetime(1997, 1, 1) user.avatar_style = db.User.AVATAR_GRAVATAR return user return factory @@ -55,6 +65,6 @@ def tag_factory(): tag = db.Tag() tag.names = [db.TagName(name) for name in (names or ['dummy'])] tag.category = category - tag.creation_time = datetime(1996, 1, 1) + tag.creation_time = datetime.datetime(1996, 1, 1) return tag return factory diff --git a/server/szurubooru/tests/util/test_misc.py b/server/szurubooru/tests/util/test_misc.py index c8c99cf..355772e 100644 --- a/server/szurubooru/tests/util/test_misc.py +++ b/server/szurubooru/tests/util/test_misc.py @@ -1,15 +1,10 @@ import pytest -from datetime import datetime from szurubooru import errors from szurubooru.util import misc +from datetime import datetime dt = datetime -class FakeDatetime(datetime): - @staticmethod - def now(tz=None): - return datetime(1997, 1, 2, 3, 4, 5, tzinfo=tz) - def test_parsing_empty_date_time(): with pytest.raises(errors.ValidationError): misc.parse_time_range('') @@ -25,8 +20,8 @@ def test_parsing_empty_date_time(): ('1999-2-06', (dt(1999, 2, 6, 0, 0, 0), dt(1999, 2, 6, 23, 59, 59))), ('1999-02-06', (dt(1999, 2, 6, 0, 0, 0), dt(1999, 2, 6, 23, 59, 59))), ]) -def test_parsing_date_time(input, output): - misc.datetime.datetime = FakeDatetime +def test_parsing_date_time(fake_datetime, input, output): + fake_datetime(datetime(1997, 1, 2, 3, 4, 5)) assert misc.parse_time_range(input) == output @pytest.mark.parametrize('input,output', [ diff --git a/server/szurubooru/util/tags.py b/server/szurubooru/util/tags.py index 1ebd102..58a308f 100644 --- a/server/szurubooru/util/tags.py +++ b/server/szurubooru/util/tags.py @@ -4,6 +4,48 @@ import sqlalchemy from szurubooru import config, db, errors from szurubooru.util import misc +class TagNotFoundError(errors.NotFoundError): + def __init__(self, tag): + super().__init__('Tag %r not found') + +class TagAlreadyExistsError(errors.ValidationError): + def __init__(self): + super().__init__('One of names is already used by another tag.') + +class InvalidNameError(errors.ValidationError): + def __init__(self, message): + super().__init__(message) + +class InvalidCategoryError(errors.ValidationError): + def __init__(self, category, valid_categories): + super().__init__( + 'Category %r is invalid. Valid categories: %r.' % ( + category, valid_categories)) + +class RelationError(errors.ValidationError): + def __init__(self, message): + super().__init__(message) + +def _verify_name_validity(name): + name_regex = config.config['tag_name_regex'] + if not re.match(name_regex, name): + raise InvalidNameError('Name must satisfy regex %r.' % name_regex) + +def _get_plain_names(tag): + return [tag_name.name for tag_name in tag.names] + +def _lower_list(names): + return [name.lower() for name in names] + +def _check_name_intersection(names1, names2): + return len(set(_lower_list(names1)).intersection(_lower_list(names2))) > 0 + +def get_by_name(session, name): + return session.query(db.Tag) \ + .join(db.TagName) \ + .filter(db.TagName.name.ilike(name)) \ + .first() + def get_by_names(session, names): names = misc.icase_unique(names) if len(names) == 0: @@ -14,17 +56,17 @@ def get_by_names(session, names): return session.query(db.Tag).join(db.TagName).filter(expr).all() def get_or_create_by_names(session, names): + names = misc.icase_unique(names) + for name in names: + _verify_name_validity(name) related_tags = get_by_names(session, names) + new_tags = [] for name in names: found = False for related_tag in related_tags: - for tag_name in related_tag.names: - if tag_name.name.lower() == name.lower(): - found = True - break - if found: + if _check_name_intersection(_get_plain_names(related_tag), [name]): + found = True break - if not found: new_tag = create_tag( session, @@ -33,34 +75,29 @@ def get_or_create_by_names(session, names): suggestions=[], implications=[]) session.add(new_tag) - session.commit() # need to get id for use in association tables - related_tags.append(new_tag) - return related_tags + new_tags.append(new_tag) + return related_tags, new_tags def create_tag(session, names, category, suggestions, implications): tag = db.Tag() tag.creation_time = datetime.datetime.now() - update_category(tag, category) update_names(session, tag, names) + update_category(session, tag, category) update_suggestions(session, tag, suggestions) update_implications(session, tag, implications) return tag -def update_category(tag, category): +def update_category(session, tag, category): if not category in config.config['tag_categories']: - raise errors.ValidationError( - 'Category must be either of %r.', config.config['tag_categories']) + raise InvalidCategoryError(category, config.config['tag_categories']) tag.category = category def update_names(session, tag, names): names = misc.icase_unique(names) if not len(names): - raise errors.ValidationError('At least one name must be specified.') + raise InvalidNameError('At least one name must be specified.') for name in names: - name_regex = config.config['tag_name_regex'] - if not re.match(name_regex, name): - raise errors.ValidationError( - 'Name must satisfy regex %r.' % name_regex) + _verify_name_validity(name) expr = sqlalchemy.sql.false() for name in names: expr = expr | db.TagName.name.ilike(name) @@ -68,22 +105,33 @@ def update_names(session, tag, names): expr = expr & (db.TagName.tag_id != tag.tag_id) existing_tags = session.query(db.TagName).filter(expr).all() if len(existing_tags): - raise errors.ValidationError( - 'One of names is already used by another tag.') - tag.names = [] + raise TagAlreadyExistsError() + tag_names_to_remove = [] + for tag_name in tag.names: + if tag_name.name.lower() not in [name.lower() for name in names]: + tag_names_to_remove.append(tag_name) + for tag_name in tag_names_to_remove: + tag.names.remove(tag_name) for name in names: - tag_name = db.TagName(name) - session.add(tag_name) - tag.names.append(tag_name) + if name.lower() not in [tag_name.name.lower() for tag_name in tag.names]: + tag_name = db.TagName(name) + session.add(tag_name) + tag.names.append(tag_name) def update_implications(session, tag, relations): - related_tags = get_or_create_by_names(session, relations) + if _check_name_intersection(_get_plain_names(tag), relations): + raise RelationError('Tag cannot imply itself.') + related_tags, new_tags = get_or_create_by_names(session, relations) + session.flush() tag.implications = [ db.TagImplication(tag.tag_id, other_tag.tag_id) \ - for other_tag in related_tags] + for other_tag in related_tags + new_tags] def update_suggestions(session, tag, relations): - related_tags = get_or_create_by_names(session, relations) + if _check_name_intersection(_get_plain_names(tag), relations): + raise RelationError('Tag cannot suggest itself.') + related_tags, new_tags = get_or_create_by_names(session, relations) + session.flush() tag.suggestions = [ db.TagSuggestion(tag.tag_id, other_tag.tag_id) \ - for other_tag in related_tags] + for other_tag in related_tags + new_tags]