server/tags: add tag creating

This commit is contained in:
rr- 2016-04-15 23:02:30 +02:00
parent ec4cba94a9
commit 9e873145a4
10 changed files with 466 additions and 6 deletions

81
API.md
View file

@ -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 (`<like this>`) signify variable
data.
## Listing tags
Not yet implemented.
## Creating tag
- **Request**
`POST /tags`
- **Input**
```json5
{
"names": [<name1>, <name2>, ...],
"category": <category>,
"implications": [<name1>, <name2>, ...],
"suggestions": [<name1>, <name2>, ...]
}
```
- **Output**
```json5
{
"tag": <tag>
}
```
...where `<tag>` 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 `<user>` is an [user resource](#user) and `query` contains standard
...where `<user>` is a [user resource](#user) and `query` contains standard
[search query](#search).
- **Errors**
@ -164,7 +226,7 @@ data.
"user": <user>
}
```
...where `<user>` is an [user resource](#user).
...where `<user>` is a [user resource](#user).
- **Errors**
@ -211,7 +273,7 @@ data.
"user": <user>
}
```
...where `<user>` is an [user resource](#user).
...where `<user>` is a [user resource](#user).
- **Errors**
@ -247,7 +309,7 @@ data.
"user": <user>
}
```
...where `<user>` is an [user resource](#user).
...where `<user>` 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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