server/tags: introduce tag category API
This commit is contained in:
parent
83784c5e76
commit
2fba374e65
133
API.md
133
API.md
|
@ -92,28 +92,151 @@ data.
|
||||||
|
|
||||||
|
|
||||||
## Listing tag categories
|
## Listing tag categories
|
||||||
|
- **Request**
|
||||||
|
|
||||||
Not implemented yet.
|
`GET /tag-categories`
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"tagCategories": [
|
||||||
|
<tag-category>,
|
||||||
|
<tag-category>,
|
||||||
|
<tag-category>
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
...where `<tag-category>` is a [tag category resource](#tag-category).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Lists all tag categories. Doesn't support paging.
|
||||||
|
|
||||||
|
|
||||||
## Creating tag category
|
## Creating tag category
|
||||||
|
- **Request**
|
||||||
|
|
||||||
Not implemented yet.
|
`POST /tag-categories`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"name": <name>,
|
||||||
|
"color": <color>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"tagCategory": <tag-category>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
...where `<tag-category>` 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
|
## Updating tag category
|
||||||
|
- **Request**
|
||||||
|
|
||||||
Not implemented yet.
|
`PUT /tag-category/<name>`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"name": <name>, // optional
|
||||||
|
"color": <color>, // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"tagCategory": <tag-category>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
...where `<tag-category>` 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
|
## Getting tag category
|
||||||
|
- **Request**
|
||||||
|
|
||||||
Not implemented yet.
|
`GET /tag-category/<name>`
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"tagCategory": <tag-category>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
...where `<tag-category>` 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
|
## Deleting tag category
|
||||||
|
- **Request**
|
||||||
|
|
||||||
Not implemented yet.
|
`DELETE /tag-category/<name>`
|
||||||
|
|
||||||
|
- **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
|
## Listing tags
|
||||||
|
|
|
@ -37,6 +37,7 @@ limits:
|
||||||
max_comment_length: 5000
|
max_comment_length: 5000
|
||||||
|
|
||||||
tag_name_regex: ^:?[a-zA-Z0-9_-]+$
|
tag_name_regex: ^:?[a-zA-Z0-9_-]+$
|
||||||
|
tag_category_name_regex: ^.{1,}$
|
||||||
|
|
||||||
# changing ranks after deployment may require manual tweaks to the database.
|
# changing ranks after deployment may require manual tweaks to the database.
|
||||||
ranks:
|
ranks:
|
||||||
|
@ -103,6 +104,12 @@ privileges:
|
||||||
'tags:merge': mod
|
'tags:merge': mod
|
||||||
'tags:delete': 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:create': regular_user
|
||||||
'comments:delete:any': mod
|
'comments:delete:any': mod
|
||||||
'comments:delete:own': regular_user
|
'comments:delete:own': regular_user
|
||||||
|
|
|
@ -3,4 +3,5 @@
|
||||||
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
|
||||||
|
from szurubooru.api.tag_category_api import TagCategoryListApi, TagCategoryDetailApi
|
||||||
from szurubooru.api.context import Context, Request
|
from szurubooru.api.context import Context, Request
|
||||||
|
|
|
@ -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 {}
|
|
@ -36,6 +36,8 @@ def create_app():
|
||||||
|
|
||||||
user_list_api = api.UserListApi()
|
user_list_api = api.UserListApi()
|
||||||
user_detail_api = api.UserDetailApi()
|
user_detail_api = api.UserDetailApi()
|
||||||
|
tag_category_list_api = api.TagCategoryListApi()
|
||||||
|
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()
|
||||||
password_reset_api = api.PasswordResetApi()
|
password_reset_api = api.PasswordResetApi()
|
||||||
|
@ -49,6 +51,8 @@ def create_app():
|
||||||
|
|
||||||
app.add_route('/users/', user_list_api)
|
app.add_route('/users/', user_list_api)
|
||||||
app.add_route('/user/{user_name}', user_detail_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('/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('/password-reset/{user_name}', password_reset_api)
|
app.add_route('/password-reset/{user_name}', password_reset_api)
|
||||||
|
|
|
@ -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.base import Base
|
||||||
|
from szurubooru.db.tag import Tag
|
||||||
|
|
||||||
class TagCategory(Base):
|
class TagCategory(Base):
|
||||||
__tablename__ = 'tag_category'
|
__tablename__ = 'tag_category'
|
||||||
|
@ -8,5 +11,10 @@ class TagCategory(Base):
|
||||||
name = Column('name', String(32), nullable=False)
|
name = Column('name', String(32), nullable=False)
|
||||||
color = Column('color', String(32), nullable=False, default='#000000')
|
color = Column('color', String(32), nullable=False, default='#000000')
|
||||||
|
|
||||||
def __init__(self, name):
|
def __init__(self, name=None):
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
|
tag_count = column_property(
|
||||||
|
select([func.count('Tag.tag_id')]) \
|
||||||
|
.where(Tag.category_id == tag_category_id) \
|
||||||
|
.correlate(table('TagCategory')))
|
||||||
|
|
|
@ -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')))
|
|
@ -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
|
|
@ -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')),
|
||||||
|
'-')
|
|
@ -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')
|
|
@ -38,16 +38,6 @@ def test_deleting(test_ctx):
|
||||||
assert test_ctx.session.query(db.Tag).count() == 0
|
assert test_ctx.session.query(db.Tag).count() == 0
|
||||||
assert os.path.exists(os.path.join(config.config['data_dir'], 'tags.json'))
|
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):
|
def test_deleting_with_usages(test_ctx, post_factory):
|
||||||
tag = test_ctx.tag_factory(names=['tag'])
|
tag = test_ctx.tag_factory(names=['tag'])
|
||||||
post = post_factory()
|
post = post_factory()
|
||||||
|
@ -66,3 +56,13 @@ def test_deleting_non_existing(test_ctx):
|
||||||
test_ctx.api.delete(
|
test_ctx.api.delete(
|
||||||
test_ctx.context_factory(
|
test_ctx.context_factory(
|
||||||
user=test_ctx.user_factory(rank='regular_user')), 'bad')
|
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
|
||||||
|
|
|
@ -66,11 +66,21 @@ def user_factory():
|
||||||
return user
|
return user
|
||||||
return factory
|
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
|
@pytest.fixture
|
||||||
def tag_factory(session):
|
def tag_factory(session):
|
||||||
def factory(names=None, category_name='dummy'):
|
def factory(names=None, category=None, category_name='dummy'):
|
||||||
category = db.TagCategory(category_name)
|
if not category:
|
||||||
session.add(category)
|
category = db.TagCategory(category_name)
|
||||||
|
session.add(category)
|
||||||
tag = db.Tag()
|
tag = db.Tag()
|
||||||
tag.names = [db.TagName(name) for name in (names or [get_unique_name()])]
|
tag.names = [db.TagName(name) for name in (names or [get_unique_name()])]
|
||||||
tag.category = category
|
tag.category = category
|
||||||
|
|
|
@ -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()
|
|
@ -4,7 +4,7 @@ import os
|
||||||
import re
|
import re
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from szurubooru import config, db, errors
|
from szurubooru import config, db, errors
|
||||||
from szurubooru.util import misc
|
from szurubooru.util import misc, tag_categories
|
||||||
|
|
||||||
class TagNotFoundError(errors.NotFoundError): pass
|
class TagNotFoundError(errors.NotFoundError): pass
|
||||||
class TagAlreadyExistsError(errors.ValidationError): pass
|
class TagAlreadyExistsError(errors.ValidationError): pass
|
||||||
|
@ -51,12 +51,6 @@ def get_tag_by_name(name):
|
||||||
.filter(db.TagName.name.ilike(name)) \
|
.filter(db.TagName.name.ilike(name)) \
|
||||||
.first()
|
.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):
|
def get_tags_by_names(names):
|
||||||
names = misc.icase_unique(names)
|
names = misc.icase_unique(names)
|
||||||
if len(names) == 0:
|
if len(names) == 0:
|
||||||
|
@ -81,7 +75,7 @@ def get_or_create_tags_by_names(names):
|
||||||
if not found:
|
if not found:
|
||||||
new_tag = create_tag(
|
new_tag = create_tag(
|
||||||
names=[name],
|
names=[name],
|
||||||
category_name=get_default_category().name,
|
category_name=tag_categories.get_default_category().name,
|
||||||
suggestions=[],
|
suggestions=[],
|
||||||
implications=[])
|
implications=[])
|
||||||
db.session().add(new_tag)
|
db.session().add(new_tag)
|
||||||
|
@ -103,8 +97,7 @@ def update_category_name(tag, category_name):
|
||||||
.filter(db.TagCategory.name == category_name) \
|
.filter(db.TagCategory.name == category_name) \
|
||||||
.first()
|
.first()
|
||||||
if not category:
|
if not category:
|
||||||
category_names = [
|
category_names = tag_categories.get_all_category_names()
|
||||||
name[0] for name in session.query(db.TagCategory.name).all()]
|
|
||||||
raise InvalidTagCategoryError(
|
raise InvalidTagCategoryError(
|
||||||
'Category %r is invalid. Valid categories: %r.' % (
|
'Category %r is invalid. Valid categories: %r.' % (
|
||||||
category_name, category_names))
|
category_name, category_names))
|
||||||
|
|
Loading…
Reference in New Issue