server/snapshots: add snapshot lists
This commit is contained in:
parent
c2a39a0fd5
commit
46ee9faf72
13 changed files with 227 additions and 77 deletions
59
API.md
59
API.md
|
@ -36,6 +36,8 @@
|
|||
- Password reset
|
||||
- [Password reset - step 1: mail request](#password-reset---step-2-confirmation)
|
||||
- [Password reset - step 2: confirmation](#password-reset---step-2-confirmation)
|
||||
- Snapshots
|
||||
- [Listing snapshots](#listing-snapshots)
|
||||
|
||||
3. [Resources](#resources)
|
||||
|
||||
|
@ -602,8 +604,6 @@ data.
|
|||
"pageSize": <page-size>,
|
||||
"total": <total-count>,
|
||||
"users": [
|
||||
<user>,
|
||||
<user>,
|
||||
<user>,
|
||||
<user>,
|
||||
<user>
|
||||
|
@ -861,6 +861,61 @@ data.
|
|||
it is recommended to connect through HTTPS.
|
||||
|
||||
|
||||
## Listing snapshots
|
||||
- **Request**
|
||||
|
||||
`GET /snapshots/?page=<page>&pageSize=<page-size>&query=<query>`
|
||||
|
||||
- **Output**
|
||||
|
||||
```json5
|
||||
{
|
||||
"query": <query>, // same as in input
|
||||
"page": <page>, // same as in input
|
||||
"pageSize": <page-size>,
|
||||
"total": <total-count>,
|
||||
"snapshots": [
|
||||
<snapshot>,
|
||||
<snapshot>,
|
||||
<snapshot>
|
||||
]
|
||||
}
|
||||
```
|
||||
...where `<snapshot>` is a [snapshot resource](#snapshot) and `query`
|
||||
contains standard [search query](#search).
|
||||
|
||||
- **Errors**
|
||||
|
||||
- privileges are too low
|
||||
|
||||
- **Description**
|
||||
|
||||
Lists recent resource snapshots.
|
||||
|
||||
**Anonymous tokens**
|
||||
|
||||
Not supported.
|
||||
|
||||
**Named tokens**
|
||||
|
||||
| `<value>` | Description |
|
||||
| ----------------- | --------------------------------------------- |
|
||||
| `type` | involving given resource type |
|
||||
| `id` | involving given resource id |
|
||||
| `date` | created at given date |
|
||||
| `time` | alias of `date` |
|
||||
| `operation` | `changed`, `created` or `deleted` |
|
||||
| `user` | name of the user that created given snapshot |
|
||||
|
||||
**Order tokens**
|
||||
|
||||
None. The snapshots are always sorted by creation time.
|
||||
|
||||
**Special tokens**
|
||||
|
||||
None.
|
||||
|
||||
|
||||
|
||||
# Resources
|
||||
|
||||
|
|
|
@ -63,47 +63,47 @@ password_regex: '^.{5,}$'
|
|||
user_name_regex: '^[a-zA-Z0-9_-]{1,32}$'
|
||||
|
||||
privileges:
|
||||
'users:create': anonymous
|
||||
'users:list': regular_user
|
||||
'users:view': regular_user
|
||||
'users:edit:any:name': mod
|
||||
'users:edit:any:pass': mod
|
||||
'users:edit:any:email': mod
|
||||
'users:edit:any:avatar': mod
|
||||
'users:edit:any:rank': mod
|
||||
'users:edit:self:name': regular_user
|
||||
'users:edit:self:pass': regular_user
|
||||
'users:edit:self:email': regular_user
|
||||
'users:edit:self:avatar': regular_user
|
||||
'users:edit:self:rank': mod # one can't promote themselves or anyone to upper rank than their own.
|
||||
'users:delete:any': admin
|
||||
'users:delete:self': regular_user
|
||||
'users:create': anonymous
|
||||
'users:list': regular_user
|
||||
'users:view': regular_user
|
||||
'users:edit:any:name': mod
|
||||
'users:edit:any:pass': mod
|
||||
'users:edit:any:email': mod
|
||||
'users:edit:any:avatar': mod
|
||||
'users:edit:any:rank': mod
|
||||
'users:edit:self:name': regular_user
|
||||
'users:edit:self:pass': regular_user
|
||||
'users:edit:self:email': regular_user
|
||||
'users:edit:self:avatar': regular_user
|
||||
'users:edit:self:rank': mod # one can't promote themselves or anyone to upper rank than their own.
|
||||
'users:delete:any': admin
|
||||
'users:delete:self': regular_user
|
||||
|
||||
'posts:create:anonymous': regular_user
|
||||
'posts:create:identified': regular_user
|
||||
'posts:list': anonymous
|
||||
'posts:view': anonymous
|
||||
'posts:edit:content': power_user
|
||||
'posts:edit:flags': regular_user
|
||||
'posts:edit:notes': regular_user
|
||||
'posts:edit:relations': regular_user
|
||||
'posts:edit:safety': power_user
|
||||
'posts:edit:source': regular_user
|
||||
'posts:edit:tags': regular_user
|
||||
'posts:edit:thumbnail': power_user
|
||||
'posts:feature': mod
|
||||
'posts:delete': mod
|
||||
'posts:create:anonymous': regular_user
|
||||
'posts:create:identified': regular_user
|
||||
'posts:list': anonymous
|
||||
'posts:view': anonymous
|
||||
'posts:edit:content': power_user
|
||||
'posts:edit:flags': regular_user
|
||||
'posts:edit:notes': regular_user
|
||||
'posts:edit:relations': regular_user
|
||||
'posts:edit:safety': power_user
|
||||
'posts:edit:source': regular_user
|
||||
'posts:edit:tags': regular_user
|
||||
'posts:edit:thumbnail': power_user
|
||||
'posts:feature': mod
|
||||
'posts:delete': mod
|
||||
|
||||
'tags:create': regular_user
|
||||
'tags:edit:names': power_user
|
||||
'tags:edit:category': power_user
|
||||
'tags:edit:implications': power_user
|
||||
'tags:edit:suggestions': power_user
|
||||
'tags:list': regular_user # note: will be available as data_url/tags.json anyway
|
||||
'tags:view': anonymous
|
||||
'tags:masstag': power_user
|
||||
'tags:merge': mod
|
||||
'tags:delete': mod
|
||||
'tags:create': regular_user
|
||||
'tags:edit:names': power_user
|
||||
'tags:edit:category': power_user
|
||||
'tags:edit:implications': power_user
|
||||
'tags:edit:suggestions': power_user
|
||||
'tags:list': regular_user # note: will be available as data_url/tags.json anyway
|
||||
'tags:view': anonymous
|
||||
'tags:masstag': power_user
|
||||
'tags:merge': mod
|
||||
'tags:delete': mod
|
||||
|
||||
'tag_categories:create': mod
|
||||
'tag_categories:edit:name': mod
|
||||
|
@ -112,11 +112,11 @@ privileges:
|
|||
'tag_categories:view': anonymous
|
||||
'tag_categories:delete': mod
|
||||
|
||||
'comments:create': regular_user
|
||||
'comments:delete:any': mod
|
||||
'comments:delete:own': regular_user
|
||||
'comments:edit:any': mod
|
||||
'comments:edit:own': regular_user
|
||||
'comments:list': regular_user
|
||||
'comments:create': regular_user
|
||||
'comments:delete:any': mod
|
||||
'comments:delete:own': regular_user
|
||||
'comments:edit:any': mod
|
||||
'comments:edit:own': regular_user
|
||||
'comments:list': regular_user
|
||||
|
||||
'history:view': power_user
|
||||
'snapshots:list': power_user
|
||||
|
|
|
@ -4,4 +4,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, TagMergeApi, TagSiblingsApi
|
||||
from szurubooru.api.tag_category_api import TagCategoryListApi, TagCategoryDetailApi
|
||||
from szurubooru.api.snapshot_api import SnapshotListApi
|
||||
from szurubooru.api.context import Context, Request
|
||||
|
|
17
server/szurubooru/api/snapshot_api.py
Normal file
17
server/szurubooru/api/snapshot_api.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
from szurubooru import search
|
||||
from szurubooru.api.base_api import BaseApi
|
||||
from szurubooru.func import auth, snapshots
|
||||
|
||||
def _serialize_snapshot(snapshot):
|
||||
earlier_snapshot = snapshots.get_previous_snapshot(snapshot)
|
||||
return snapshots.serialize_snapshot(snapshot, earlier_snapshot)
|
||||
|
||||
class SnapshotListApi(BaseApi):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._search_executor = search.SearchExecutor(search.SnapshotSearchConfig())
|
||||
|
||||
def get(self, ctx):
|
||||
auth.verify_privilege(ctx.user, 'snapshots:list')
|
||||
return self._search_executor.execute_and_serialize(
|
||||
ctx, _serialize_snapshot, 'snapshots')
|
|
@ -28,18 +28,8 @@ class TagListApi(BaseApi):
|
|||
|
||||
def get(self, ctx):
|
||||
auth.verify_privilege(ctx.user, 'tags:list')
|
||||
query = ctx.get_param_as_string('query')
|
||||
page = ctx.get_param_as_int('page', default=1, min=1)
|
||||
page_size = ctx.get_param_as_int(
|
||||
'pageSize', default=100, min=1, max=100)
|
||||
count, tag_list = self._search_executor.execute(query, page, page_size)
|
||||
return {
|
||||
'query': query,
|
||||
'page': page,
|
||||
'pageSize': page_size,
|
||||
'total': count,
|
||||
'tags': [_serialize_tag(tag) for tag in tag_list],
|
||||
}
|
||||
return self._search_executor.execute_and_serialize(
|
||||
ctx, _serialize_tag, 'tags')
|
||||
|
||||
def post(self, ctx):
|
||||
auth.verify_privilege(ctx.user, 'tags:create')
|
||||
|
|
|
@ -35,18 +35,8 @@ class UserListApi(BaseApi):
|
|||
|
||||
def get(self, ctx):
|
||||
auth.verify_privilege(ctx.user, 'users:list')
|
||||
query = ctx.get_param_as_string('query')
|
||||
page = ctx.get_param_as_int('page', default=1, min=1)
|
||||
page_size = ctx.get_param_as_int(
|
||||
'pageSize', default=100, min=1, max=100)
|
||||
count, user_list = self._search_executor.execute(query, page, page_size)
|
||||
return {
|
||||
'query': query,
|
||||
'page': page,
|
||||
'pageSize': page_size,
|
||||
'total': count,
|
||||
'users': [_serialize_user(ctx.user, user) for user in user_list],
|
||||
}
|
||||
return self._search_executor.execute_and_serialize(
|
||||
ctx, lambda user: _serialize_user(ctx.user, user), 'users')
|
||||
|
||||
def post(self, ctx):
|
||||
auth.verify_privilege(ctx.user, 'users:create')
|
||||
|
|
|
@ -56,6 +56,7 @@ def create_app():
|
|||
tag_merge_api = api.TagMergeApi()
|
||||
tag_siblings_api = api.TagSiblingsApi()
|
||||
password_reset_api = api.PasswordResetApi()
|
||||
snapshot_list_api = api.SnapshotListApi()
|
||||
|
||||
app.add_error_handler(errors.AuthError, _on_auth_error)
|
||||
app.add_error_handler(errors.IntegrityError, _on_integrity_error)
|
||||
|
@ -73,5 +74,6 @@ def create_app():
|
|||
app.add_route('/tag-merge/', tag_merge_api)
|
||||
app.add_route('/tag-siblings/{tag_name}', tag_siblings_api)
|
||||
app.add_route('/password-reset/{user_name}', password_reset_api)
|
||||
app.add_route('/snapshots/', snapshot_list_api)
|
||||
|
||||
return app
|
||||
|
|
|
@ -43,6 +43,16 @@ def get_resource_info(entity):
|
|||
|
||||
return (resource_type, resource_id, resource_repr)
|
||||
|
||||
def get_previous_snapshot(snapshot):
|
||||
return db.session \
|
||||
.query(db.Snapshot) \
|
||||
.filter(db.Snapshot.resource_type == snapshot.resource_type) \
|
||||
.filter(db.Snapshot.resource_id == snapshot.resource_id) \
|
||||
.filter(db.Snapshot.creation_time < snapshot.creation_time) \
|
||||
.order_by(db.Snapshot.creation_time.desc()) \
|
||||
.limit(1) \
|
||||
.first()
|
||||
|
||||
def get_snapshots(entity):
|
||||
resource_type, resource_id, _ = get_resource_info(entity)
|
||||
return db.session \
|
||||
|
@ -57,7 +67,7 @@ def serialize_snapshot(snapshot, earlier_snapshot):
|
|||
'operation': snapshot.operation,
|
||||
'type': snapshot.resource_type,
|
||||
'id': snapshot.resource_repr,
|
||||
'user': snapshot.user.name,
|
||||
'user': snapshot.user.name if snapshot.user else None,
|
||||
'data': snapshot.data,
|
||||
'earlier-data': earlier_snapshot.data if earlier_snapshot else None,
|
||||
'time': snapshot.creation_time,
|
||||
|
|
|
@ -2,4 +2,5 @@
|
|||
|
||||
from szurubooru.search.search_executor import SearchExecutor
|
||||
from szurubooru.search.user_search_config import UserSearchConfig
|
||||
from szurubooru.search.snapshot_search_config import SnapshotSearchConfig
|
||||
from szurubooru.search.tag_search_config import TagSearchConfig
|
||||
|
|
|
@ -12,19 +12,19 @@ class BaseSearchConfig(object):
|
|||
|
||||
@property
|
||||
def anonymous_filter(self):
|
||||
raise NotImplementedError()
|
||||
return None
|
||||
|
||||
@property
|
||||
def special_filters(self):
|
||||
raise NotImplementedError()
|
||||
return {}
|
||||
|
||||
@property
|
||||
def named_filters(self):
|
||||
raise NotImplementedError()
|
||||
return {}
|
||||
|
||||
@property
|
||||
def order_columns(self):
|
||||
raise NotImplementedError()
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _apply_num_criterion_to_column(column, criterion):
|
||||
|
|
|
@ -28,6 +28,19 @@ class SearchExecutor(object):
|
|||
.scalar()
|
||||
return (count, entities)
|
||||
|
||||
def execute_and_serialize(self, ctx, serializer, key_name):
|
||||
query = ctx.get_param_as_string('query')
|
||||
page = ctx.get_param_as_int('page', default=1, min=1)
|
||||
page_size = ctx.get_param_as_int('pageSize', default=100, min=1, max=100)
|
||||
count, entities = self.execute(query, page, page_size)
|
||||
return {
|
||||
'query': query,
|
||||
'page': page,
|
||||
'pageSize': page_size,
|
||||
'total': count,
|
||||
key_name: [serializer(entity) for entity in entities],
|
||||
}
|
||||
|
||||
def _prepare(self, query_text):
|
||||
''' Parse input and return SQLAlchemy query. '''
|
||||
query = self._search_config.create_query() \
|
||||
|
|
20
server/szurubooru/search/snapshot_search_config.py
Normal file
20
server/szurubooru/search/snapshot_search_config.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
from szurubooru import db
|
||||
from szurubooru.search.base_search_config import BaseSearchConfig
|
||||
|
||||
class SnapshotSearchConfig(BaseSearchConfig):
|
||||
def create_query(self):
|
||||
return db.session.query(db.Snapshot)
|
||||
|
||||
def finalize_query(self, query):
|
||||
return query.order_by(db.Snapshot.creation_time.desc())
|
||||
|
||||
@property
|
||||
def named_filters(self):
|
||||
return {
|
||||
'type': self._create_str_filter(db.Snapshot.resource_type),
|
||||
'id': self._create_str_filter(db.Snapshot.resource_repr),
|
||||
'date': self._create_date_filter(db.Snapshot.creation_time),
|
||||
'time': self._create_str_filter(db.Snapshot.creation_time),
|
||||
'operation': self._create_str_filter(db.Snapshot.operation),
|
||||
'user': self._create_str_filter(db.User.name),
|
||||
}
|
51
server/szurubooru/tests/api/test_snapshot_retrieving.py
Normal file
51
server/szurubooru/tests/api/test_snapshot_retrieving.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
import datetime
|
||||
import pytest
|
||||
from szurubooru import api, db, errors
|
||||
from szurubooru.func import util, tags
|
||||
|
||||
def snapshot_factory():
|
||||
snapshot = db.Snapshot()
|
||||
snapshot.creation_time = datetime.datetime(1999, 1, 1)
|
||||
snapshot.resource_type = 'dummy'
|
||||
snapshot.resource_id = 1
|
||||
snapshot.resource_repr = 'dummy'
|
||||
snapshot.operation = 'added'
|
||||
snapshot.data = '{}'
|
||||
return snapshot
|
||||
|
||||
@pytest.fixture
|
||||
def test_ctx(context_factory, config_injector, user_factory):
|
||||
config_injector({
|
||||
'privileges': {
|
||||
'snapshots:list': 'regular_user',
|
||||
},
|
||||
'thumbnails': {'avatar_width': 200},
|
||||
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
|
||||
'rank_names': {'regular_user': 'Peasant'},
|
||||
})
|
||||
ret = util.dotdict()
|
||||
ret.context_factory = context_factory
|
||||
ret.user_factory = user_factory
|
||||
ret.api = api.SnapshotListApi()
|
||||
return ret
|
||||
|
||||
def test_retrieving_multiple(test_ctx):
|
||||
snapshot1 = snapshot_factory()
|
||||
snapshot2 = snapshot_factory()
|
||||
db.session.add_all([snapshot1, snapshot2])
|
||||
result = test_ctx.api.get(
|
||||
test_ctx.context_factory(
|
||||
input={'query': '', 'page': 1},
|
||||
user=test_ctx.user_factory(rank='regular_user')))
|
||||
assert result['query'] == ''
|
||||
assert result['page'] == 1
|
||||
assert result['pageSize'] == 100
|
||||
assert result['total'] == 2
|
||||
assert len(result['snapshots']) == 2
|
||||
|
||||
def test_trying_to_retrieve_multiple_without_privileges(test_ctx):
|
||||
with pytest.raises(errors.AuthError):
|
||||
test_ctx.api.get(
|
||||
test_ctx.context_factory(
|
||||
input={'query': '', 'page': 1},
|
||||
user=test_ctx.user_factory(rank='anonymous')))
|
Loading…
Reference in a new issue