server/tags: introduce tag category API

This commit is contained in:
rr- 2016-04-19 11:56:09 +02:00
parent 83784c5e76
commit 2fba374e65
14 changed files with 663 additions and 30 deletions

133
API.md
View File

@ -92,28 +92,151 @@ data.
## 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
- **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
- **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
- **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
- **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

View File

@ -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

View File

@ -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

View File

@ -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 {}

View File

@ -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)

View File

@ -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')))

View File

@ -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')))

View File

@ -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

View File

@ -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')),
'-')

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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))