diff --git a/API.md b/API.md index 5e30d55..8cabcd1 100644 --- a/API.md +++ b/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": , "total": , "users": [ - , - , , , @@ -861,6 +861,61 @@ data. it is recommended to connect through HTTPS. +## Listing snapshots +- **Request** + + `GET /snapshots/?page=&pageSize=&query=` + +- **Output** + + ```json5 + { + "query": , // same as in input + "page": , // same as in input + "pageSize": , + "total": , + "snapshots": [ + , + , + + ] + } + ``` + ...where `` 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** + + | `` | 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 diff --git a/config.yaml.dist b/config.yaml.dist index 9558220..07126e2 100644 --- a/config.yaml.dist +++ b/config.yaml.dist @@ -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 diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py index 5ee5339..c694418 100644 --- a/server/szurubooru/api/__init__.py +++ b/server/szurubooru/api/__init__.py @@ -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 diff --git a/server/szurubooru/api/snapshot_api.py b/server/szurubooru/api/snapshot_api.py new file mode 100644 index 0000000..ec51de7 --- /dev/null +++ b/server/szurubooru/api/snapshot_api.py @@ -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') diff --git a/server/szurubooru/api/tag_api.py b/server/szurubooru/api/tag_api.py index 748cc0b..98227bd 100644 --- a/server/szurubooru/api/tag_api.py +++ b/server/szurubooru/api/tag_api.py @@ -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') diff --git a/server/szurubooru/api/user_api.py b/server/szurubooru/api/user_api.py index f035ad1..d1ee389 100644 --- a/server/szurubooru/api/user_api.py +++ b/server/szurubooru/api/user_api.py @@ -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') diff --git a/server/szurubooru/app.py b/server/szurubooru/app.py index b10e53d..8a5f902 100644 --- a/server/szurubooru/app.py +++ b/server/szurubooru/app.py @@ -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 diff --git a/server/szurubooru/func/snapshots.py b/server/szurubooru/func/snapshots.py index 100f823..3caf8cd 100644 --- a/server/szurubooru/func/snapshots.py +++ b/server/szurubooru/func/snapshots.py @@ -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, diff --git a/server/szurubooru/search/__init__.py b/server/szurubooru/search/__init__.py index 1f913cc..a6c6e7f 100644 --- a/server/szurubooru/search/__init__.py +++ b/server/szurubooru/search/__init__.py @@ -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 diff --git a/server/szurubooru/search/base_search_config.py b/server/szurubooru/search/base_search_config.py index 5a4c2ab..fcfdfe3 100644 --- a/server/szurubooru/search/base_search_config.py +++ b/server/szurubooru/search/base_search_config.py @@ -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): diff --git a/server/szurubooru/search/search_executor.py b/server/szurubooru/search/search_executor.py index 73d32af..b72d61c 100644 --- a/server/szurubooru/search/search_executor.py +++ b/server/szurubooru/search/search_executor.py @@ -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() \ diff --git a/server/szurubooru/search/snapshot_search_config.py b/server/szurubooru/search/snapshot_search_config.py new file mode 100644 index 0000000..4136766 --- /dev/null +++ b/server/szurubooru/search/snapshot_search_config.py @@ -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), + } diff --git a/server/szurubooru/tests/api/test_snapshot_retrieving.py b/server/szurubooru/tests/api/test_snapshot_retrieving.py new file mode 100644 index 0000000..c6b4367 --- /dev/null +++ b/server/szurubooru/tests/api/test_snapshot_retrieving.py @@ -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')))