From 2fba374e65dfddd796d992b06a73257eff219d4e Mon Sep 17 00:00:00 2001 From: rr- Date: Tue, 19 Apr 2016 11:56:09 +0200 Subject: [PATCH] server/tags: introduce tag category API --- API.md | 133 +++++++++++++++++- config.yaml.dist | 7 + server/szurubooru/api/__init__.py | 1 + server/szurubooru/api/tag_category_api.py | 68 +++++++++ server/szurubooru/app.py | 4 + server/szurubooru/db/tag_category.py | 12 +- .../tests/api/test_tag_category_creating.py | 96 +++++++++++++ .../tests/api/test_tag_category_deleting.py | 69 +++++++++ .../tests/api/test_tag_category_retrieving.py | 61 ++++++++ .../tests/api/test_tag_category_updating.py | 133 ++++++++++++++++++ .../szurubooru/tests/api/test_tag_deleting.py | 20 +-- server/szurubooru/tests/conftest.py | 16 ++- server/szurubooru/util/tag_categories.py | 60 ++++++++ server/szurubooru/util/tags.py | 13 +- 14 files changed, 663 insertions(+), 30 deletions(-) create mode 100644 server/szurubooru/api/tag_category_api.py create mode 100644 server/szurubooru/tests/api/test_tag_category_creating.py create mode 100644 server/szurubooru/tests/api/test_tag_category_deleting.py create mode 100644 server/szurubooru/tests/api/test_tag_category_retrieving.py create mode 100644 server/szurubooru/tests/api/test_tag_category_updating.py create mode 100644 server/szurubooru/util/tag_categories.py diff --git a/API.md b/API.md index 301c7fb..495dcca 100644 --- a/API.md +++ b/API.md @@ -92,28 +92,151 @@ data. ## Listing tag categories +- **Request** -Not implemented yet. + `GET /tag-categories` + +- **Output** + + ```json5 + { + "tagCategories": [ + , + , + + ] + } + ``` + ...where `` is a [tag category resource](#tag-category). + +- **Errors** + + - privileges are too low + +- **Description** + + Lists all tag categories. Doesn't support paging. ## Creating tag category +- **Request** -Not implemented yet. + `POST /tag-categories` + +- **Input** + + ```json5 + { + "name": , + "color": + } + ``` + +- **Output** + + ```json5 + { + "tagCategory": + } + ``` + ...where `` is a [tag category resource](#tag-category). + +- **Errors** + + - the name is used by an existing tag category (names are case insensitive) + - the name is invalid or missing + - the color is invalid or missing + - privileges are too low + +- **Description** + + Creates a new tag category using specified parameters. Name must match + `tag_category_name_regex` from server's configuration. ## Updating tag category +- **Request** -Not implemented yet. + `PUT /tag-category/` + +- **Input** + + ```json5 + { + "name": , // optional + "color": , // optional + } + ``` + +- **Output** + + ```json5 + { + "tagCategory": + } + ``` + ...where `` is a [tag category resource](#tag-category). + +- **Errors** + + - the tag category does not exist + - the name is used by an existing tag category (names are case insensitive) + - the name is invalid + - the color is invalid + - privileges are too low + +- **Description** + + Updates an existing tag category using specified parameters. Name must + match `tag_category_name_regex` from server's configuration. All fields are + optional - update concerns only provided fields. ## Getting tag category +- **Request** -Not implemented yet. + `GET /tag-category/` + +- **Output** + + ```json5 + { + "tagCategory": + } + ``` + ...where `` is a [tag category resource](#tag-category). + +- **Errors** + + - the tag category does not exist + - privileges are too low + +- **Description** + + Retrieves information about an existing tag category. ## Deleting tag category +- **Request** -Not implemented yet. + `DELETE /tag-category/` + +- **Output** + + ```json5 + {} + ``` + +- **Errors** + + - the tag category does not exist + - the tag category is used by some tags + - privileges are too low + +- **Description** + + Deletes existing tag category. The tag category to be deleted must have no + usages. ## Listing tags diff --git a/config.yaml.dist b/config.yaml.dist index b055814..c2c35d2 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -37,6 +37,7 @@ limits: max_comment_length: 5000 tag_name_regex: ^:?[a-zA-Z0-9_-]+$ +tag_category_name_regex: ^.{1,}$ # changing ranks after deployment may require manual tweaks to the database. ranks: @@ -103,6 +104,12 @@ privileges: 'tags:merge': mod 'tags:delete': mod + 'tag_categories:create': mod + 'tag_categories:edit:name': mod + 'tag_categories:edit:color': mod + 'tag_categories:list': anonymous + 'tag_categories:delete': mod + 'comments:create': regular_user 'comments:delete:any': mod 'comments:delete:own': regular_user diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index ebc27a3..800de1c 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -3,4 +3,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.tag_category_api import TagCategoryListApi, TagCategoryDetailApi from szurubooru.api.context import Context, Request diff --git a/server/szurubooru/api/tag_category_api.py b/server/szurubooru/api/tag_category_api.py new file mode 100644 index 0000000..ccbe4e8 --- /dev/null +++ b/server/szurubooru/api/tag_category_api.py @@ -0,0 +1,68 @@ +from szurubooru.util import auth, tags, tag_categories +from szurubooru.api.base_api import BaseApi + +def _serialize_category(category): + return { + 'name': category.name, + 'color': category.color, + } + +class TagCategoryListApi(BaseApi): + def get(self, ctx): + auth.verify_privilege(ctx.user, 'tag_categories:list') + categories = tag_categories.get_all_categories() + return { + 'tagCategories': [ + _serialize_category(category) for category in categories], + } + + def post(self, ctx): + auth.verify_privilege(ctx.user, 'tag_categories:create') + name = ctx.get_param_as_string('name', required=True) + color = ctx.get_param_as_string('color', required=True) + category = tag_categories.create_category(name, color) + ctx.session.add(category) + ctx.session.commit() + tags.export_to_json() + return {'tagCategory': _serialize_category(category)} + +class TagCategoryDetailApi(BaseApi): + def get(self, ctx, category_name): + auth.verify_privilege(ctx.user, 'tag_categories:view') + category = tag_categories.get_category_by_name(category_name) + if not category: + raise tag_categories.TagCategoryNotFoundError( + 'Tag category %r not found.' % category_name) + return {'tagCategory': _serialize_category(category)} + + def put(self, ctx, category_name): + category = tag_categories.get_category_by_name(category_name) + if not category: + raise tag_categories.TagCategoryNotFoundError( + 'Tag category %r not found.' % category_name) + if ctx.has_param('name'): + auth.verify_privilege(ctx.user, 'tag_categories:edit:name') + tag_categories.update_name( + category, ctx.get_param_as_string('name')) + if ctx.has_param('color'): + auth.verify_privilege(ctx.user, 'tag_categories:edit:color') + tag_categories.update_color( + category, ctx.get_param_as_string('color')) + ctx.session.commit() + tags.export_to_json() + return {'tagCategory': _serialize_category(category)} + + def delete(self, ctx, category_name): + category = tag_categories.get_category_by_name(category_name) + if not category: + raise tag_categories.TagCategoryNotFoundError( + 'Tag category %r not found.' % category_name) + if category.tag_count > 0: + raise tag_categories.TagCategoryIsInUseError( + 'Tag category has some usages and cannot be deleted. ' + + 'Please remove this category from relevant tags first..') + auth.verify_privilege(ctx.user, 'tag_categories:delete') + ctx.session.delete(category) + ctx.session.commit() + tags.export_to_json() + return {} diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py index 50ad1ee..fd4dd6c 100644 --- a/server/szurubooru/app.py +++ b/server/szurubooru/app.py @@ -36,6 +36,8 @@ def create_app(): user_list_api = api.UserListApi() user_detail_api = api.UserDetailApi() + tag_category_list_api = api.TagCategoryListApi() + tag_category_detail_api = api.TagCategoryDetailApi() tag_list_api = api.TagListApi() tag_detail_api = api.TagDetailApi() password_reset_api = api.PasswordResetApi() @@ -49,6 +51,8 @@ def create_app(): app.add_route('/users/', user_list_api) app.add_route('/user/{user_name}', user_detail_api) + app.add_route('/tag-categories/', tag_category_list_api) + 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('/password-reset/{user_name}', password_reset_api) diff --git a/server/szurubooru/db/tag_category.py b/server/szurubooru/db/tag_category.py index 40d9af0..1a4acd7 100644 --- a/server/szurubooru/db/tag_category.py +++ b/server/szurubooru/db/tag_category.py @@ -1,5 +1,8 @@ -from sqlalchemy import Column, Integer, String +from sqlalchemy import Column, Integer, String, table +from sqlalchemy.orm import column_property +from sqlalchemy.sql.expression import func, select from szurubooru.db.base import Base +from szurubooru.db.tag import Tag class TagCategory(Base): __tablename__ = 'tag_category' @@ -8,5 +11,10 @@ class TagCategory(Base): name = Column('name', String(32), nullable=False) color = Column('color', String(32), nullable=False, default='#000000') - def __init__(self, name): + def __init__(self, name=None): self.name = name + + tag_count = column_property( + select([func.count('Tag.tag_id')]) \ + .where(Tag.category_id == tag_category_id) \ + .correlate(table('TagCategory'))) diff --git a/server/szurubooru/tests/api/test_tag_category_creating.py b/server/szurubooru/tests/api/test_tag_category_creating.py new file mode 100644 index 0000000..e1f3f39 --- /dev/null +++ b/server/szurubooru/tests/api/test_tag_category_creating.py @@ -0,0 +1,96 @@ +import os +import pytest +from szurubooru import api, config, db, errors +from szurubooru.util import misc, tag_categories + +@pytest.fixture +def test_ctx(tmpdir, config_injector, context_factory, user_factory): + config_injector({ + 'data_dir': str(tmpdir), + 'tag_category_name_regex': '^[^!]+$', + 'ranks': ['anonymous', 'regular_user'], + 'privileges': {'tag_categories:create': 'regular_user'}, + }) + ret = misc.dotdict() + ret.context_factory = context_factory + ret.user_factory = user_factory + ret.api = api.TagCategoryListApi() + return ret + +def test_creating_category(test_ctx): + result = test_ctx.api.post( + test_ctx.context_factory( + input={'name': 'meta', 'color': 'black'}, + user=test_ctx.user_factory(rank='regular_user'))) + assert result == { + 'tagCategory': {'name': 'meta', 'color': 'black'}, + } + category = db.session.query(db.TagCategory).one() + assert category.name == 'meta' + assert category.color == 'black' + assert category.tag_count == 0 + assert os.path.exists(os.path.join(config.config['data_dir'], 'tags.json')) + +@pytest.mark.parametrize('input', [ + {'name': None}, + {'name': ''}, + {'name': '!bad'}, + {'color': None}, + {'color': ''}, +]) +def test_invalid_inputs(test_ctx, input): + real_input = { + 'name': 'okay', + 'color': 'okay', + } + for key, value in input.items(): + real_input[key] = value + with pytest.raises(errors.ValidationError): + test_ctx.api.post( + test_ctx.context_factory( + input=real_input, + user=test_ctx.user_factory(rank='regular_user'))) + +@pytest.mark.parametrize('field', ['name', 'color']) +def test_missing_mandatory_field(test_ctx, field): + input = { + 'name': 'meta', + 'color': 'black', + } + 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_use_existing_name(test_ctx): + result = test_ctx.api.post( + test_ctx.context_factory( + input={'name': 'meta', 'color': 'black'}, + user=test_ctx.user_factory(rank='regular_user'))) + with pytest.raises(tag_categories.TagCategoryAlreadyExistsError): + result = test_ctx.api.post( + test_ctx.context_factory( + input={'name': 'meta', 'color': 'black'}, + user=test_ctx.user_factory(rank='regular_user'))) + with pytest.raises(tag_categories.TagCategoryAlreadyExistsError): + result = test_ctx.api.post( + test_ctx.context_factory( + input={'name': 'META', 'color': 'black'}, + user=test_ctx.user_factory(rank='regular_user'))) + +def test_trying_to_create_tag_with_invalid_color(test_ctx): + with pytest.raises(tag_categories.InvalidTagCategoryColorError): + test_ctx.api.post( + test_ctx.context_factory( + input={'name': 'meta', 'color': 'a' * 100}, + user=test_ctx.user_factory(rank='regular_user'))) + assert db.session.query(db.TagCategory).filter_by(name='meta').count() == 0 + +def test_trying_to_create_tag_without_privileges(test_ctx): + with pytest.raises(errors.AuthError): + test_ctx.api.post( + test_ctx.context_factory( + input={'name': 'meta', 'colro': 'black'}, + user=test_ctx.user_factory(rank='anonymous'))) diff --git a/server/szurubooru/tests/api/test_tag_category_deleting.py b/server/szurubooru/tests/api/test_tag_category_deleting.py new file mode 100644 index 0000000..76bb1ce --- /dev/null +++ b/server/szurubooru/tests/api/test_tag_category_deleting.py @@ -0,0 +1,69 @@ +import pytest +import os +from datetime import datetime +from szurubooru import api, config, db, errors +from szurubooru.util import misc, tags, tag_categories + +@pytest.fixture +def test_ctx( + tmpdir, + config_injector, + context_factory, + tag_factory, + tag_category_factory, + user_factory): + config_injector({ + 'data_dir': str(tmpdir), + 'privileges': { + 'tag_categories:delete': 'regular_user', + }, + 'ranks': ['anonymous', 'regular_user'], + }) + ret = misc.dotdict() + ret.context_factory = context_factory + ret.user_factory = user_factory + ret.tag_factory = tag_factory + ret.tag_category_factory = tag_category_factory + ret.api = api.TagCategoryDetailApi() + return ret + +def test_deleting(test_ctx): + db.session().add(test_ctx.tag_category_factory(name='category')) + db.session().commit() + result = test_ctx.api.delete( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), + 'category') + assert result == {} + assert db.session().query(db.TagCategory).count() == 0 + assert os.path.exists(os.path.join(config.config['data_dir'], 'tags.json')) + +def test_deleting_with_usages(test_ctx, tag_factory): + category = test_ctx.tag_category_factory(name='category') + db.session().add(category) + db.session().flush() + tag = test_ctx.tag_factory(names=['tag'], category=category) + db.session().add(tag) + db.session().commit() + with pytest.raises(tag_categories.TagCategoryIsInUseError): + test_ctx.api.delete( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), + 'category') + assert db.session().query(db.TagCategory).count() == 1 + +def test_deleting_non_existing(test_ctx): + with pytest.raises(tag_categories.TagCategoryNotFoundError): + test_ctx.api.delete( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), 'bad') + +def test_deleting_without_privileges(test_ctx): + db.session().add(test_ctx.tag_category_factory(name='category')) + db.session().commit() + with pytest.raises(errors.AuthError): + test_ctx.api.delete( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='anonymous')), + 'category') + assert db.session().query(db.TagCategory).count() == 1 diff --git a/server/szurubooru/tests/api/test_tag_category_retrieving.py b/server/szurubooru/tests/api/test_tag_category_retrieving.py new file mode 100644 index 0000000..1db04f4 --- /dev/null +++ b/server/szurubooru/tests/api/test_tag_category_retrieving.py @@ -0,0 +1,61 @@ +import datetime +import pytest +from szurubooru import api, db, errors +from szurubooru.util import misc, tag_categories + +@pytest.fixture +def test_ctx( + context_factory, config_injector, user_factory, tag_category_factory): + config_injector({ + 'privileges': { + 'tag_categories:list': 'regular_user', + 'tag_categories:view': 'regular_user', + }, + 'thumbnails': {'avatar_width': 200}, + 'ranks': ['anonymous', 'regular_user', 'mod', 'admin'], + 'rank_names': {'regular_user': 'Peasant'}, + }) + ret = misc.dotdict() + ret.context_factory = context_factory + ret.user_factory = user_factory + ret.tag_category_factory = tag_category_factory + ret.list_api = api.TagCategoryListApi() + ret.detail_api = api.TagCategoryDetailApi() + return ret + +def test_retrieving_multiple(test_ctx): + db.session().add_all([ + test_ctx.tag_category_factory(name='c1'), + test_ctx.tag_category_factory(name='c2'), + ]) + result = test_ctx.list_api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user'))) + assert [cat['name'] for cat in result['tagCategories']] == ['c1', 'c2'] + +def test_retrieving_single(test_ctx): + db.session().add(test_ctx.tag_category_factory(name='cat')) + result = test_ctx.detail_api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), + 'cat') + assert result == { + 'tagCategory': { + 'name': 'cat', + 'color': 'dummy', + } + } + +def test_retrieving_non_existing(test_ctx): + with pytest.raises(tag_categories.TagCategoryNotFoundError): + test_ctx.detail_api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='regular_user')), + '-') + +def test_retrieving_single_without_privileges(test_ctx): + with pytest.raises(errors.AuthError): + test_ctx.detail_api.get( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='anonymous')), + '-') diff --git a/server/szurubooru/tests/api/test_tag_category_updating.py b/server/szurubooru/tests/api/test_tag_category_updating.py new file mode 100644 index 0000000..737091f --- /dev/null +++ b/server/szurubooru/tests/api/test_tag_category_updating.py @@ -0,0 +1,133 @@ +import os +import pytest +from szurubooru import api, config, db, errors +from szurubooru.util import misc, tag_categories + +@pytest.fixture +def test_ctx( + tmpdir, + config_injector, + context_factory, + user_factory, + tag_category_factory): + config_injector({ + 'data_dir': str(tmpdir), + 'tag_category_name_regex': '^[^!]*$', + 'ranks': ['anonymous', 'regular_user'], + 'privileges': { + 'tag_categories:edit:name': 'regular_user', + 'tag_categories:edit:color': 'regular_user', + }, + }) + ret = misc.dotdict() + ret.context_factory = context_factory + ret.user_factory = user_factory + ret.tag_category_factory = tag_category_factory + ret.api = api.TagCategoryDetailApi() + return ret + +def test_simple_updating(test_ctx): + category = test_ctx.tag_category_factory(name='name', color='black') + db.session().add(category) + db.session().commit() + result = test_ctx.api.put( + test_ctx.context_factory( + input={ + 'name': 'changed', + 'color': 'white', + }, + user=test_ctx.user_factory(rank='regular_user')), + 'name') + assert result == { + 'tagCategory': { + 'name': 'changed', + 'color': 'white', + } + } + assert tag_categories.get_category_by_name('name') is None + category = tag_categories.get_category_by_name('changed') + assert category is not None + assert category.name == 'changed' + assert category.color == 'white' + assert os.path.exists(os.path.join(config.config['data_dir'], 'tags.json')) + +@pytest.mark.parametrize('input', [ + {'name': None}, + {'name': ''}, + {'name': '!bad'}, + {'color': None}, + {'color': ''}, +]) +def test_invalid_inputs(test_ctx, input): + db.session().add(test_ctx.tag_category_factory(name='meta', color='black')) + db.session().commit() + with pytest.raises(tag_categories.InvalidTagCategoryNameError): + test_ctx.api.put( + test_ctx.context_factory( + input=input, + user=test_ctx.user_factory(rank='regular_user')), + 'meta') + +@pytest.mark.parametrize('field', ['name', 'color']) +def test_missing_optional_field(test_ctx, tmpdir, field): + db.session().add(test_ctx.tag_category_factory(name='name', color='black')) + db.session().commit() + input = { + 'name': 'changed', + 'color': 'white', + } + del input[field] + result = test_ctx.api.put( + test_ctx.context_factory( + input=input, + user=test_ctx.user_factory(rank='regular_user')), + 'name') + assert result is not None + +def test_trying_to_update_non_existing(test_ctx): + with pytest.raises(tag_categories.TagCategoryNotFoundError): + test_ctx.api.put( + test_ctx.context_factory( + input={'name': ['dummy']}, + user=test_ctx.user_factory(rank='regular_user')), + 'bad') + +@pytest.mark.parametrize('new_name', ['cat', 'CAT']) +def test_reusing_own_name(test_ctx, new_name): + db.session().add(test_ctx.tag_category_factory(name='cat', color='black')) + db.session().commit() + result = test_ctx.api.put( + test_ctx.context_factory( + input={'name': new_name}, + user=test_ctx.user_factory(rank='regular_user')), + 'cat') + assert result['tagCategory']['name'] == new_name + category = tag_categories.get_category_by_name('cat') + assert category.name == new_name + +@pytest.mark.parametrize('dup_name', ['cat1', 'CAT1']) +def test_trying_to_use_existing_name(test_ctx, dup_name): + db.session().add_all([ + test_ctx.tag_category_factory(name='cat1', color='black'), + test_ctx.tag_category_factory(name='cat2', color='black')]) + db.session().commit() + with pytest.raises(tag_categories.TagCategoryAlreadyExistsError): + test_ctx.api.put( + test_ctx.context_factory( + input={'name': dup_name}, + user=test_ctx.user_factory(rank='regular_user')), + 'cat2') + +@pytest.mark.parametrize('input', [ + {'name': 'whatever'}, + {'color': 'whatever'}, +]) +def test_trying_to_update_tag_without_privileges(test_ctx, input): + db.session().add(test_ctx.tag_category_factory(name='dummy')) + db.session().commit() + with pytest.raises(errors.AuthError): + test_ctx.api.put( + test_ctx.context_factory( + input=input, + user=test_ctx.user_factory(rank='anonymous')), + 'dummy') diff --git a/server/szurubooru/tests/api/test_tag_deleting.py b/server/szurubooru/tests/api/test_tag_deleting.py index 5aedb34..c164ca2 100644 --- a/server/szurubooru/tests/api/test_tag_deleting.py +++ b/server/szurubooru/tests/api/test_tag_deleting.py @@ -38,16 +38,6 @@ def test_deleting(test_ctx): assert test_ctx.session.query(db.Tag).count() == 0 assert os.path.exists(os.path.join(config.config['data_dir'], 'tags.json')) -def test_deleting_without_privileges(test_ctx): - test_ctx.session.add(test_ctx.tag_factory(names=['tag'])) - test_ctx.session.commit() - with pytest.raises(errors.AuthError): - test_ctx.api.delete( - test_ctx.context_factory( - user=test_ctx.user_factory(rank='anonymous')), - 'tag') - assert test_ctx.session.query(db.Tag).count() == 1 - def test_deleting_with_usages(test_ctx, post_factory): tag = test_ctx.tag_factory(names=['tag']) post = post_factory() @@ -66,3 +56,13 @@ def test_deleting_non_existing(test_ctx): test_ctx.api.delete( test_ctx.context_factory( user=test_ctx.user_factory(rank='regular_user')), 'bad') + +def test_deleting_without_privileges(test_ctx): + test_ctx.session.add(test_ctx.tag_factory(names=['tag'])) + test_ctx.session.commit() + with pytest.raises(errors.AuthError): + test_ctx.api.delete( + test_ctx.context_factory( + user=test_ctx.user_factory(rank='anonymous')), + 'tag') + assert test_ctx.session.query(db.Tag).count() == 1 diff --git a/server/szurubooru/tests/conftest.py b/server/szurubooru/tests/conftest.py index 2e1ab3b..2981e10 100644 --- a/server/szurubooru/tests/conftest.py +++ b/server/szurubooru/tests/conftest.py @@ -66,11 +66,21 @@ def user_factory(): return user return factory +@pytest.fixture +def tag_category_factory(session): + def factory(name='dummy', color='dummy'): + category = db.TagCategory() + category.name = name + category.color = color + return category + return factory + @pytest.fixture def tag_factory(session): - def factory(names=None, category_name='dummy'): - category = db.TagCategory(category_name) - session.add(category) + def factory(names=None, category=None, category_name='dummy'): + if not category: + category = db.TagCategory(category_name) + session.add(category) tag = db.Tag() tag.names = [db.TagName(name) for name in (names or [get_unique_name()])] tag.category = category diff --git a/server/szurubooru/util/tag_categories.py b/server/szurubooru/util/tag_categories.py new file mode 100644 index 0000000..33dd0ba --- /dev/null +++ b/server/szurubooru/util/tag_categories.py @@ -0,0 +1,60 @@ +import re +from szurubooru import config, db, errors +from szurubooru.util import misc + +class TagCategoryNotFoundError(errors.NotFoundError): pass +class TagCategoryAlreadyExistsError(errors.ValidationError): pass +class TagCategoryIsInUseError(errors.ValidationError): pass +class InvalidTagCategoryNameError(errors.ValidationError): pass +class InvalidTagCategoryColorError(errors.ValidationError): pass + +def _verify_name_validity(name): + name_regex = config.config['tag_category_name_regex'] + if not re.match(name_regex, name): + raise InvalidTagCategoryNameError( + 'Name must satisfy regex %r.' % name_regex) + +def create_category(name, color): + category = db.TagCategory() + update_name(category, name) + update_color(category, color) + return category + +def update_name(category, name): + if not name: + raise InvalidTagCategoryNameError('Name cannot be empty.') + expr = db.TagCategory.name.ilike(name) + if category.tag_category_id: + expr = expr & (db.TagCategory.tag_category_id != category.tag_category_id) + already_exists = db.session().query(db.TagCategory).filter(expr).count() > 0 + if already_exists: + raise TagCategoryAlreadyExistsError( + 'A category with this name already exists.') + if misc.value_exceeds_column_size(name, db.TagCategory.name): + raise InvalidTagCategoryNameError('Name is too long.') + _verify_name_validity(name) + category.name = name + +def update_color(category, color): + if not color: + raise InvalidTagCategoryNameError('Color cannot be empty.') + if misc.value_exceeds_column_size(color, db.TagCategory.color): + raise InvalidTagCategoryColorError('Color is too long.') + category.color = color + +def get_category_by_name(name): + return db.session.query(db.TagCategory) \ + .filter(db.TagCategory.name.ilike(name)) \ + .first() + +def get_all_category_names(): + return [row[0] for row in db.session.query(db.TagCategory.name).all()] + +def get_all_categories(): + return db.session.query(db.TagCategory).all() + +def get_default_category(): + return db.session().query(db.TagCategory) \ + .order_by(db.TagCategory.tag_category_id.asc()) \ + .limit(1) \ + .one() diff --git a/server/szurubooru/util/tags.py b/server/szurubooru/util/tags.py index 9e19761..24b8cad 100644 --- a/server/szurubooru/util/tags.py +++ b/server/szurubooru/util/tags.py @@ -4,7 +4,7 @@ import os import re import sqlalchemy from szurubooru import config, db, errors -from szurubooru.util import misc +from szurubooru.util import misc, tag_categories class TagNotFoundError(errors.NotFoundError): pass class TagAlreadyExistsError(errors.ValidationError): pass @@ -51,12 +51,6 @@ def get_tag_by_name(name): .filter(db.TagName.name.ilike(name)) \ .first() -def get_default_category(): - return db.session().query(db.TagCategory) \ - .order_by(db.TagCategory.tag_category_id.asc()) \ - .limit(1) \ - .one() - def get_tags_by_names(names): names = misc.icase_unique(names) if len(names) == 0: @@ -81,7 +75,7 @@ def get_or_create_tags_by_names(names): if not found: new_tag = create_tag( names=[name], - category_name=get_default_category().name, + category_name=tag_categories.get_default_category().name, suggestions=[], implications=[]) db.session().add(new_tag) @@ -103,8 +97,7 @@ def update_category_name(tag, category_name): .filter(db.TagCategory.name == category_name) \ .first() if not category: - category_names = [ - name[0] for name in session.query(db.TagCategory.name).all()] + category_names = tag_categories.get_all_category_names() raise InvalidTagCategoryError( 'Category %r is invalid. Valid categories: %r.' % ( category_name, category_names))