server/tags: add tag creating
This commit is contained in:
parent
ec4cba94a9
commit
9e873145a4
81
API.md
81
API.md
|
@ -13,6 +13,11 @@
|
|||
|
||||
2. [API reference](#api-reference)
|
||||
|
||||
- [Listing tags](#listing-tags)
|
||||
- [Creating tag](#creating-tag)
|
||||
- [Updating tag](#updating-tag)
|
||||
- [Getting tag](#getting-tag)
|
||||
- [Removing tag](#removing-tag)
|
||||
- [Listing users](#listing-users)
|
||||
- [Creating user](#creating-user)
|
||||
- [Updating user](#updating-user)
|
||||
|
@ -24,6 +29,7 @@
|
|||
3. [Resources](#resources)
|
||||
|
||||
- [User](#user)
|
||||
- [Tag](#tag)
|
||||
|
||||
4. [Search](#search)
|
||||
|
||||
|
@ -76,6 +82,62 @@ as `/api/`. Values denoted with diamond braces (`<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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
Loading…
Reference in New Issue