From 9e873145a4c3e3c602f90797c497655ae60a7e2e Mon Sep 17 00:00:00 2001 From: rr- Date: Fri, 15 Apr 2016 23:02:30 +0200 Subject: [PATCH] server/tags: add tag creating --- API.md | 81 +++++- config.yaml.dist | 4 +- server/szurubooru/api/__init__.py | 1 + server/szurubooru/api/tag_api.py | 42 +++ server/szurubooru/app.py | 5 +- server/szurubooru/config.py | 3 + .../szurubooru/tests/api/test_tag_creating.py | 241 ++++++++++++++++++ .../tests/api/test_user_creating.py | 4 + .../tests/api/test_user_updating.py | 2 + server/szurubooru/util/tags.py | 89 +++++++ 10 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 server/szurubooru/api/tag_api.py create mode 100644 server/szurubooru/tests/api/test_tag_creating.py create mode 100644 server/szurubooru/util/tags.py diff --git a/API.md b/API.md index 0744c91f..3f30730a 100644 --- a/API.md +++ b/API.md @@ -13,6 +13,11 @@ 2. [API reference](#api-reference) + - [Listing tags](#listing-tags) + - [Creating tag](#creating-tag) + - [Updating tag](#updating-tag) + - [Getting tag](#getting-tag) + - [Removing tag](#removing-tag) - [Listing users](#listing-users) - [Creating user](#creating-user) - [Updating user](#updating-user) @@ -24,6 +29,7 @@ 3. [Resources](#resources) - [User](#user) + - [Tag](#tag) 4. [Search](#search) @@ -76,6 +82,62 @@ as `/api/`. Values denoted with diamond braces (``) signify variable data. +## Listing tags +Not yet implemented. + +## Creating tag +- **Request** + + `POST /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 + - privileges are too low + +- **Description** + + Creates a new 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. + +## Updating tag +Not yet implemented. + +## Getting tag +Not yet implemented. + +## Removing tag +Not yet implemented. + + ## Listing users - **Request** @@ -98,7 +160,7 @@ data. "total": 7 } ``` - ...where `` is an [user resource](#user) and `query` contains standard + ...where `` is a [user resource](#user) and `query` contains standard [search query](#search). - **Errors** @@ -164,7 +226,7 @@ data. "user": } ``` - ...where `` is an [user resource](#user). + ...where `` is a [user resource](#user). - **Errors** @@ -211,7 +273,7 @@ data. "user": } ``` - ...where `` is an [user resource](#user). + ...where `` is a [user resource](#user). - **Errors** @@ -247,7 +309,7 @@ data. "user": } ``` - ...where `` is an [user resource](#user). + ...where `` is a [user resource](#user). - **Errors** @@ -360,6 +422,17 @@ data. } ``` +## Tag + +```json5 +{ + "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"] +} +``` + # Search Search queries are built of tokens that are separated by spaces. Each token can diff --git a/config.yaml.dist b/config.yaml.dist index df2a943c..ead32cbd 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -36,7 +36,9 @@ limits: posts_per_page: 40 max_comment_length: 5000 +tag_name_regex: ^:?[a-zA-Z0-9_-]+$ tag_categories: + - plain - meta - artist - character @@ -99,7 +101,7 @@ privileges: 'posts:delete': mod 'tags:create': regular_user - 'tags:edit:name': power_user + 'tags:edit:names': power_user 'tags:edit:category': power_user 'tags:edit:implications': power_user 'tags:edit:suggestions': power_user diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index b51523e4..ebc27a3c 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -2,4 +2,5 @@ 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.context import Context, Request diff --git a/server/szurubooru/api/tag_api.py b/server/szurubooru/api/tag_api.py new file mode 100644 index 00000000..99b385e3 --- /dev/null +++ b/server/szurubooru/api/tag_api.py @@ -0,0 +1,42 @@ +from szurubooru import errors +from szurubooru.util import auth, tags +from szurubooru.api.base_api import BaseApi + +def _serialize_tag(tag): + return { + 'names': [tag_name.name for tag_name in tag.names], + 'category': tag.category, + 'suggestions': [ + relation.child_tag.names[0].name for relation in tag.suggestions], + 'implications': [ + relation.child_tag.names[0].name for relation in tag.implications], + } + +class TagListApi(BaseApi): + def get(self, ctx): + raise NotImplementedError() + + def post(self, ctx): + auth.verify_privilege(ctx.user, 'tags:create') + + names = ctx.get_param_as_list('names', required=True) + category = ctx.get_param_as_string('category', required=True) + suggestions = ctx.get_param_as_list('suggestions', required=True) + implications = ctx.get_param_as_list('implications', required=True) + + 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)} + +class TagDetailApi(BaseApi): + def get(self, ctx): + raise NotImplementedError() + + def put(self, ctx): + raise NotImplementedError() + + def delete(self, ctx): + raise NotImplementedError() diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py index 0481cea7..84a34d59 100644 --- a/server/szurubooru/app.py +++ b/server/szurubooru/app.py @@ -4,7 +4,6 @@ import falcon import sqlalchemy import sqlalchemy.orm from szurubooru import api, config, errors, middleware -from szurubooru.util import misc def _on_auth_error(ex, _request, _response, _params): raise falcon.HTTPForbidden( @@ -50,6 +49,8 @@ def create_app(): user_list_api = api.UserListApi() user_detail_api = api.UserDetailApi() + tag_list_api = api.TagListApi() + tag_detail_api = api.TagDetailApi() password_reset_api = api.PasswordResetApi() app.add_error_handler(errors.AuthError, _on_auth_error) @@ -61,6 +62,8 @@ def create_app(): app.add_route('/users/', user_list_api) app.add_route('/user/{user_name}', user_detail_api) + app.add_route('/tags/', tag_list_api) + app.add_route('/tag/{tag_name}', tag_detail_api) app.add_route('/password-reset/{user_name}', password_reset_api) return app diff --git a/server/szurubooru/config.py b/server/szurubooru/config.py index bc9c6412..5d2bb242 100644 --- a/server/szurubooru/config.py +++ b/server/szurubooru/config.py @@ -58,4 +58,7 @@ class Config(object): raise errors.ConfigError( 'Database is not configured: %r is missing' % key) + if not len(self['tag_categories']): + raise errors.ConfigError('Must have at least one tag category') + config = Config() # pylint: disable=invalid-name diff --git a/server/szurubooru/tests/api/test_tag_creating.py b/server/szurubooru/tests/api/test_tag_creating.py new file mode 100644 index 00000000..1e35c184 --- /dev/null +++ b/server/szurubooru/tests/api/test_tag_creating.py @@ -0,0 +1,241 @@ +import pytest +from datetime import datetime +from szurubooru import api, db, errors +from szurubooru.util import auth + +@pytest.fixture +def tag_config(config_injector): + config_injector({ + 'tag_categories': ['meta', 'character', 'copyright'], + 'tag_name_regex': '^[^!]*$', + 'ranks': ['regular_user'], + 'privileges': {'tags:create': 'regular_user'}, + }) + +@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( + input={ + 'names': ['tag1', 'tag2'], + 'category': 'meta', + 'implications': [], + 'suggestions': [], + }, + user=user_factory(rank='regular_user'))) + assert result == { + 'tag': { + 'names': ['tag1', 'tag2'], + 'category': 'meta', + 'suggestions': [], + 'implications': [], + } + } + tag = get_tag(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( + input={ + 'names': ['tag1', 'TAG1'], + 'category': 'meta', + 'implications': [], + 'suggestions': [], + }, + user=user_factory(rank='regular_user'))) + assert result['tag']['names'] == ['tag1'] + assert result['tag']['category'] == 'meta' + tag = get_tag(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( + input={ + 'names': [], + 'category': 'meta', + 'implications': [], + 'suggestions': [], + }, + user=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( + 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'))) + +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( + input={ + 'names': ['ok'], + 'category': 'invalid', + 'implications': [], + 'suggestions': [], + }, + user=user_factory(rank='regular_user'))) + +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']) + +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( + input={ + 'names': ['new'], + 'category': 'meta', + 'implications': ['tag1'], + 'suggestions': ['TAG2'], + }, + user=user_factory(rank='regular_user'))) + assert result['tag']['implications'] == ['tag1'] + # NOTE: it should export only the first name + assert result['tag']['suggestions'] == ['tag1'] + tag = get_tag(session, 'new') + assert_relations(tag.implications, ['tag1']) + assert_relations(tag.suggestions, ['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'))) + +# TODO: test bad privileges +# TODO: test max length diff --git a/server/szurubooru/tests/api/test_user_creating.py b/server/szurubooru/tests/api/test_user_creating.py index c999b72d..c36a5d97 100644 --- a/server/szurubooru/tests/api/test_user_creating.py +++ b/server/szurubooru/tests/api/test_user_creating.py @@ -110,3 +110,7 @@ def test_missing_field( user_list_api.post( context_factory( input=request, user=user_factory(rank='regular_user'))) + +# TODO: test too long name +# TODO: test bad password, email or name +# TODO: support avatar and avatarStyle diff --git a/server/szurubooru/tests/api/test_user_updating.py b/server/szurubooru/tests/api/test_user_updating.py index 38e95b49..a4e11a65 100644 --- a/server/szurubooru/tests/api/test_user_updating.py +++ b/server/szurubooru/tests/api/test_user_updating.py @@ -235,3 +235,5 @@ def test_uploading_avatar( assert user.avatar_style == user.AVATAR_MANUAL assert response['user']['avatarUrl'] == \ 'http://example.com/data/avatars/u1.jpg' + +# TODO: test too long name diff --git a/server/szurubooru/util/tags.py b/server/szurubooru/util/tags.py new file mode 100644 index 00000000..1ebd102f --- /dev/null +++ b/server/szurubooru/util/tags.py @@ -0,0 +1,89 @@ +import datetime +import re +import sqlalchemy +from szurubooru import config, db, errors +from szurubooru.util import misc + +def get_by_names(session, names): + names = misc.icase_unique(names) + if len(names) == 0: + return [] + expr = sqlalchemy.sql.false() + for name in names: + expr = expr | db.TagName.name.ilike(name) + return session.query(db.Tag).join(db.TagName).filter(expr).all() + +def get_or_create_by_names(session, names): + related_tags = get_by_names(session, names) + 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: + break + + if not found: + new_tag = create_tag( + session, + names=[name], + category=config.config['tag_categories'][0], + 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 + +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_suggestions(session, tag, suggestions) + update_implications(session, tag, implications) + return tag + +def update_category(tag, category): + if not category in config.config['tag_categories']: + raise errors.ValidationError( + 'Category must be either of %r.', 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.') + 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) + expr = sqlalchemy.sql.false() + for name in names: + expr = expr | db.TagName.name.ilike(name) + if tag.tag_id: + 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 = [] + for name in 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) + tag.implications = [ + db.TagImplication(tag.tag_id, other_tag.tag_id) \ + for other_tag in related_tags] + +def update_suggestions(session, tag, relations): + related_tags = get_or_create_by_names(session, relations) + tag.suggestions = [ + db.TagSuggestion(tag.tag_id, other_tag.tag_id) \ + for other_tag in related_tags]