aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--API.md131
-rw-r--r--server/szurubooru/api/comment_api.py5
-rw-r--r--server/szurubooru/api/password_reset_api.py3
-rw-r--r--server/szurubooru/api/post_api.py3
-rw-r--r--server/szurubooru/api/tag_api.py6
-rw-r--r--server/szurubooru/api/tag_category_api.py3
-rw-r--r--server/szurubooru/api/user_api.py3
-rw-r--r--server/szurubooru/db/comment.py1
-rw-r--r--server/szurubooru/db/post.py1
-rw-r--r--server/szurubooru/db/tag.py1
-rw-r--r--server/szurubooru/db/tag_category.py1
-rw-r--r--server/szurubooru/db/user.py5
-rw-r--r--server/szurubooru/func/comments.py1
-rw-r--r--server/szurubooru/func/posts.py1
-rw-r--r--server/szurubooru/func/tag_categories.py1
-rw-r--r--server/szurubooru/func/tags.py1
-rw-r--r--server/szurubooru/func/users.py3
-rw-r--r--server/szurubooru/func/util.py11
-rw-r--r--server/szurubooru/migrations/versions/7f6baf38c27c_add_versions.py26
-rw-r--r--server/szurubooru/tests/api/test_comment_deleting.py13
-rw-r--r--server/szurubooru/tests/api/test_comment_updating.py14
-rw-r--r--server/szurubooru/tests/api/test_post_deleting.py1
-rw-r--r--server/szurubooru/tests/api/test_post_updating.py12
-rw-r--r--server/szurubooru/tests/api/test_tag_category_creating.py1
-rw-r--r--server/szurubooru/tests/api/test_tag_category_deleting.py7
-rw-r--r--server/szurubooru/tests/api/test_tag_category_retrieving.py1
-rw-r--r--server/szurubooru/tests/api/test_tag_category_updating.py12
-rw-r--r--server/szurubooru/tests/api/test_tag_creating.py1
-rw-r--r--server/szurubooru/tests/api/test_tag_deleting.py3
-rw-r--r--server/szurubooru/tests/api/test_tag_merging.py23
-rw-r--r--server/szurubooru/tests/api/test_tag_retrieving.py1
-rw-r--r--server/szurubooru/tests/api/test_tag_updating.py21
-rw-r--r--server/szurubooru/tests/api/test_user_creating.py1
-rw-r--r--server/szurubooru/tests/api/test_user_deleting.py13
-rw-r--r--server/szurubooru/tests/api/test_user_retrieving.py1
-rw-r--r--server/szurubooru/tests/api/test_user_updating.py47
-rw-r--r--server/szurubooru/tests/func/test_posts.py1
37 files changed, 313 insertions, 67 deletions
diff --git a/API.md b/API.md
index 8c62313..44654be 100644
--- a/API.md
+++ b/API.md
@@ -11,6 +11,7 @@
- [File uploads](#file-uploads)
- [Error handling](#error-handling)
- [Field selecting](#field-selecting)
+ - [Versioning](#versioning)
2. [API reference](#api-reference)
@@ -140,6 +141,29 @@ should send a `GET` query like this:
GET /posts/?fields=id,tags
```
+## Versioning
+
+To prevent problems with concurrent resource modification, szurubooru
+implements optimistic locks using resource versions. Each modifiable resource
+has its `version` returned to the client with `GET` requests. At the same time,
+each `PUT` and `DELETE` request sent by the client must present the same
+`version` field to the server with value as it was given in `GET`.
+
+For example, given `GET /post/1`, the server responds like this:
+
+```
+{
+ ...,
+ "version": 2
+}
+```
+
+This means the client must then send `{"version": 2}` back too. If the client
+fails to do so, the server will reject the request notifying about missing
+parameter. If someone has edited the post in the mean time, the server will
+reject the request as well, in which case the client is encouraged to notify
+the user about the situation.
+
# API reference
@@ -211,8 +235,9 @@ data.
```json5
{
- "name": <name>, // optional
- "color": <color>, // optional
+ "version": <version>,
+ "name": <name>, // optional
+ "color": <color>, // optional
}
```
@@ -222,6 +247,7 @@ data.
- **Errors**
+ - the version is outdated
- the tag category does not exist
- the name is used by an existing tag category (names are case insensitive)
- the name is invalid
@@ -231,8 +257,9 @@ data.
- **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.
+ match `tag_category_name_regex` from server's configuration. All fields
+ except the [`version`](#versioning) are optional - update concerns only
+ provided fields.
## Getting tag category
- **Request**
@@ -257,6 +284,14 @@ data.
`DELETE /tag-category/<name>`
+- **Input**
+
+ ```json5
+ {
+ "version": <version>
+ }
+ ```
+
- **Output**
```json5
@@ -265,6 +300,7 @@ data.
- **Errors**
+ - the version is outdated
- the tag category does not exist
- the tag category is used by some tags
- the tag category is the last tag category available
@@ -436,6 +472,7 @@ data.
- **Errors**
+ - the version is outdated
- the tag does not exist
- any name is used by an existing tag (names are case insensitive)
- any name, implication or suggestion name is invalid
@@ -452,8 +489,9 @@ data.
[`<tag-category>` resource](#tag-category). 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 tag category found. All fields are
- optional - update concerns only provided fields.
+ their category is set to the first tag category found. All fields except
+ the [`version`](#versioning) are optional - update concerns only provided
+ fields.
## Getting tag
- **Request**
@@ -478,6 +516,14 @@ data.
`DELETE /tag/<name>`
+- **Input**
+
+ ```json5
+ {
+ "version": <version>
+ }
+ ```
+
- **Output**
```json5
@@ -486,6 +532,7 @@ data.
- **Errors**
+ - the version is outdated
- the tag does not exist
- privileges are too low
@@ -502,8 +549,10 @@ data.
```json5
{
- "remove": <source-tag-name>,
- "mergeTo": <target-tag-name>
+ "removeVersion": <source-tag-version>,
+ "remove": <source-tag-name>,
+ "mergeToVersion": <target-tag-version>,
+ "mergeTo": <target-tag-name>
}
```
@@ -513,6 +562,7 @@ data.
- **Errors**
+ - the version of either tag is outdated
- the source or target tag does not exist
- the source tag is the same as the target tag
- privileges are too low
@@ -713,9 +763,9 @@ data.
post to use default thumbnail. If `anonymous` is set to truthy value, the
uploader name won't be recorded (privilege verification still applies; it's
possible to disallow anonymous uploads completely from config.) All fields
- are optional - update concerns only provided fields. For details how to
- pass `content` and `thumbnail`, see [file uploads](#file-uploads) for
- details.
+ except the [`version`](#versioning) are optional - update concerns only
+ provided fields. For details how to pass `content` and `thumbnail`, see
+ [file uploads](#file-uploads) for details.
## Updating post
- **Request**
@@ -726,6 +776,7 @@ data.
```json5
{
+ "version": <version>,
"tags": [<tag1>, <tag2>, <tag3>], // optional
"safety": <safety>, // optional
"source": <source>, // optional
@@ -746,6 +797,7 @@ data.
- **Errors**
+ - the version is outdated
- tags have invalid names
- safety, notes or flags are invalid
- relations refer to non-existing posts
@@ -760,7 +812,9 @@ data.
must contain valid post IDs. `<flag>` currently can be only `"loop"` to
enable looping for video posts. Sending empty `thumbnail` will reset the
post thumbnail to default. For details how to pass `content` and
- `thumbnail`, see [file uploads](#file-uploads) for details.
+ `thumbnail`, see [file uploads](#file-uploads) for details. All fields
+ except the [`version`](#versioning) are optional - update concerns only
+ provided fields.
## Getting post
- **Request**
@@ -785,6 +839,14 @@ data.
`DELETE /post/<id>`
+- **Input**
+
+ ```json5
+ {
+ "version": <version>
+ }
+ ```
+
- **Output**
```json5
@@ -793,6 +855,7 @@ data.
- **Errors**
+ - the version is outdated
- the post does not exist
- privileges are too low
@@ -1005,7 +1068,8 @@ data.
```json5
{
- "text": <new-text> // mandatory
+ "version": <version>,
+ "text": <new-text> // mandatory
}
```
@@ -1015,6 +1079,7 @@ data.
- **Errors**
+ - the version is outdated
- the comment does not exist
- new comment text is empty
- privileges are too low
@@ -1046,6 +1111,14 @@ data.
`DELETE /comment/<id>`
+- **Input**
+
+ ```json5
+ {
+ "version": <version>
+ }
+ ```
+
- **Output**
```json5
@@ -1054,6 +1127,7 @@ data.
- **Errors**
+ - the version is outdated
- the comment does not exist
- privileges are too low
@@ -1194,6 +1268,7 @@ data.
```json5
{
+ "version": <version>,
"name": <user-name>, // optional
"password": <user-password>, // optional
"email": <email>, // optional
@@ -1212,6 +1287,7 @@ data.
- **Errors**
+ - the version is outdated
- the user does not exist
- a user with new name already exists (names are case insensitive)
- either user name, password, email or rank are invalid
@@ -1228,7 +1304,9 @@ data.
provided fields. To update last login time, see
[authentication](#authentication). Avatar style can be either `gravatar` or
`manual`. `manual` avatar style requires client to pass also `avatar`
- file - see [file uploads](#file-uploads) for details.
+ file - see [file uploads](#file-uploads) for details. All fields except the
+ [`version`](#versioning) are optional - update concerns only provided
+ fields.
## Getting user
- **Request**
@@ -1253,6 +1331,14 @@ data.
`DELETE /user/<name>`
+- **Input**
+
+ ```json5
+ {
+ "version": <version>
+ }
+ ```
+
- **Output**
```json5
@@ -1261,6 +1347,7 @@ data.
- **Errors**
+ - the version is outdated
- the user does not exist
- privileges are too low
@@ -1413,6 +1500,7 @@ A single user.
```json5
{
+ "version": <version>,
"name": <name>,
"email": <email>,
"rank": <rank>,
@@ -1429,6 +1517,7 @@ A single user.
```
**Field meaning**
+- `<version>`: resource version. See [versioning](#versioning).
- `<name>`: the user name.
- `<email>`: the user email. It is available only if the request is
authenticated by the same user, or the authenticated user can change the
@@ -1480,9 +1569,10 @@ experience.
```json5
{
- "name": <name>,
- "color": <color>,
- "usages": <usages>
+ "version": <version>,
+ "name": <name>,
+ "color": <color>,
+ "usages": <usages>
"default": <is-default>,
"snapshots": [
<snapshot>,
@@ -1494,6 +1584,7 @@ experience.
**Field meaning**
+- `<version>`: resource version. See [versioning](#versioning).
- `<name>`: the category name.
- `<color>`: the category color.
- `<usages>`: how many tags is the given category used with.
@@ -1510,6 +1601,7 @@ A single tag. Tags are used to let users search for posts.
```json5
{
+ "version": <version>,
"names": <names>,
"category": <category>,
"implications": <implications>,
@@ -1528,6 +1620,7 @@ A single tag. Tags are used to let users search for posts.
**Field meaning**
+- `<version>`: resource version. See [versioning](#versioning).
- `<names>`: a list of tag names (aliases). Tagging a post with any name will
automatically assign the first name from this list.
- `<category>`: the name of the category the given tag belongs to.
@@ -1552,6 +1645,7 @@ One file together with its metadata posted to the site.
```json5
{
+ "version": <version>,
"id": <id>,
"creationTime": <creation-time>,
"lastEditTime": <last-edit-time>,
@@ -1596,6 +1690,7 @@ One file together with its metadata posted to the site.
**Field meaning**
+- `<version>`: resource version. See [versioning](#versioning).
- `<id>`: the post identifier.
- `<creation-time>`: time the tag was created, formatted as per RFC 3339.
- `<last-edit-time>`: time the tag was edited, formatted as per RFC 3339.
@@ -1689,6 +1784,7 @@ A comment under a post.
```json5
{
+ "version": <version>,
"id": <id>,
"postId": <post-id>,
"user": <author>
@@ -1701,6 +1797,7 @@ A comment under a post.
```
**Field meaning**
+- `<version>`: resource version. See [versioning](#versioning).
- `<id>`: the comment identifier.
- `<post-id>`: an id of the post the comment is for.
- `<text>`: the comment content. The client should render is as Markdown.
diff --git a/server/szurubooru/api/comment_api.py b/server/szurubooru/api/comment_api.py
index b7baa71..d536204 100644
--- a/server/szurubooru/api/comment_api.py
+++ b/server/szurubooru/api/comment_api.py
@@ -39,16 +39,19 @@ class CommentDetailApi(BaseApi):
def put(self, ctx, comment_id):
comment = comments.get_comment_by_id(comment_id)
+ util.verify_version(comment, ctx)
infix = 'own' if ctx.user.user_id == comment.user_id else 'any'
text = ctx.get_param_as_string('text', required=True)
auth.verify_privilege(ctx.user, 'comments:edit:%s' % infix)
- comment.last_edit_time = datetime.datetime.utcnow()
comments.update_comment_text(comment, text)
+ util.bump_version(comment)
+ comment.last_edit_time = datetime.datetime.utcnow()
ctx.session.commit()
return _serialize(ctx, comment)
def delete(self, ctx, comment_id):
comment = comments.get_comment_by_id(comment_id)
+ util.verify_version(comment, ctx)
infix = 'own' if ctx.user.user_id == comment.user_id else 'any'
auth.verify_privilege(ctx.user, 'comments:delete:%s' % infix)
ctx.session.delete(comment)
diff --git a/server/szurubooru/api/password_reset_api.py b/server/szurubooru/api/password_reset_api.py
index 5ff3684..00a8d48 100644
--- a/server/szurubooru/api/password_reset_api.py
+++ b/server/szurubooru/api/password_reset_api.py
@@ -1,6 +1,6 @@
from szurubooru import config, errors
from szurubooru.api.base_api import BaseApi
-from szurubooru.func import auth, mailer, users
+from szurubooru.func import auth, mailer, users, util
MAIL_SUBJECT = 'Password reset for {name}'
MAIL_BODY = \
@@ -34,5 +34,6 @@ class PasswordResetApi(BaseApi):
if token != good_token:
raise errors.ValidationError('Invalid password reset token.')
new_password = users.reset_user_password(user)
+ util.bump_version(user)
ctx.session.commit()
return {'password': new_password}
diff --git a/server/szurubooru/api/post_api.py b/server/szurubooru/api/post_api.py
index c98e205..68a9835 100644
--- a/server/szurubooru/api/post_api.py
+++ b/server/szurubooru/api/post_api.py
@@ -62,6 +62,7 @@ class PostDetailApi(BaseApi):
def put(self, ctx, post_id):
post = posts.get_post_by_id(post_id)
+ util.verify_version(post, ctx)
if ctx.has_file('content'):
auth.verify_privilege(ctx.user, 'posts:edit:content')
posts.update_post_content(post, ctx.get_file('content'))
@@ -90,6 +91,7 @@ class PostDetailApi(BaseApi):
if ctx.has_file('thumbnail'):
auth.verify_privilege(ctx.user, 'posts:edit:thumbnail')
posts.update_post_thumbnail(post, ctx.get_file('thumbnail'))
+ util.bump_version(post)
post.last_edit_time = datetime.datetime.utcnow()
ctx.session.flush()
snapshots.save_entity_modification(post, ctx.user)
@@ -100,6 +102,7 @@ class PostDetailApi(BaseApi):
def delete(self, ctx, post_id):
auth.verify_privilege(ctx.user, 'posts:delete')
post = posts.get_post_by_id(post_id)
+ util.verify_version(post, ctx)
snapshots.save_entity_deletion(post, ctx.user)
posts.delete(post)
ctx.session.commit()
diff --git a/server/szurubooru/api/tag_api.py b/server/szurubooru/api/tag_api.py
index c532266..4e9ec14 100644
--- a/server/szurubooru/api/tag_api.py
+++ b/server/szurubooru/api/tag_api.py
@@ -60,6 +60,7 @@ class TagDetailApi(BaseApi):
def put(self, ctx, tag_name):
tag = tags.get_tag_by_name(tag_name)
+ util.verify_version(tag, ctx)
if ctx.has_param('names'):
auth.verify_privilege(ctx.user, 'tags:edit:names')
tags.update_tag_names(tag, ctx.get_param_as_list('names'))
@@ -81,6 +82,7 @@ class TagDetailApi(BaseApi):
implications = ctx.get_param_as_list('implications')
_create_if_needed(implications, ctx.user)
tags.update_tag_implications(tag, implications)
+ util.bump_version(tag)
tag.last_edit_time = datetime.datetime.utcnow()
ctx.session.flush()
snapshots.save_entity_modification(tag, ctx.user)
@@ -90,6 +92,7 @@ class TagDetailApi(BaseApi):
def delete(self, ctx, tag_name):
tag = tags.get_tag_by_name(tag_name)
+ util.verify_version(tag, ctx)
auth.verify_privilege(ctx.user, 'tags:delete')
snapshots.save_entity_deletion(tag, ctx.user)
tags.delete(tag)
@@ -103,11 +106,14 @@ class TagMergeApi(BaseApi):
target_tag_name = ctx.get_param_as_string('mergeTo', required=True) or ''
source_tag = tags.get_tag_by_name(source_tag_name)
target_tag = tags.get_tag_by_name(target_tag_name)
+ util.verify_version(source_tag, ctx, 'removeVersion')
+ util.verify_version(target_tag, ctx, 'mergeToVersion')
if source_tag.tag_id == target_tag.tag_id:
raise tags.InvalidTagRelationError('Cannot merge tag with itself.')
auth.verify_privilege(ctx.user, 'tags:merge')
snapshots.save_entity_deletion(source_tag, ctx.user)
tags.merge_tags(source_tag, target_tag)
+ util.bump_version(target_tag)
ctx.session.commit()
tags.export_to_json()
return _serialize(ctx, target_tag)
diff --git a/server/szurubooru/api/tag_category_api.py b/server/szurubooru/api/tag_category_api.py
index 04b4fa1..153ec0f 100644
--- a/server/szurubooru/api/tag_category_api.py
+++ b/server/szurubooru/api/tag_category_api.py
@@ -33,6 +33,7 @@ class TagCategoryDetailApi(BaseApi):
def put(self, ctx, category_name):
category = tag_categories.get_category_by_name(category_name)
+ util.verify_version(category, ctx)
if ctx.has_param('name'):
auth.verify_privilege(ctx.user, 'tag_categories:edit:name')
tag_categories.update_category_name(
@@ -41,6 +42,7 @@ class TagCategoryDetailApi(BaseApi):
auth.verify_privilege(ctx.user, 'tag_categories:edit:color')
tag_categories.update_category_color(
category, ctx.get_param_as_string('color'))
+ util.bump_version(category)
ctx.session.flush()
snapshots.save_entity_modification(category, ctx.user)
ctx.session.commit()
@@ -49,6 +51,7 @@ class TagCategoryDetailApi(BaseApi):
def delete(self, ctx, category_name):
category = tag_categories.get_category_by_name(category_name)
+ util.verify_version(category, ctx)
auth.verify_privilege(ctx.user, 'tag_categories:delete')
if len(tag_categories.get_all_category_names()) == 1:
raise tag_categories.TagCategoryIsInUseError(
diff --git a/server/szurubooru/api/user_api.py b/server/szurubooru/api/user_api.py
index 094b94c..706e5d7 100644
--- a/server/szurubooru/api/user_api.py
+++ b/server/szurubooru/api/user_api.py
@@ -47,6 +47,7 @@ class UserDetailApi(BaseApi):
def put(self, ctx, user_name):
user = users.get_user_by_name(user_name)
+ util.verify_version(user, ctx)
infix = 'self' if ctx.user.user_id == user.user_id else 'any'
if ctx.has_param('name'):
auth.verify_privilege(ctx.user, 'users:edit:%s:name' % infix)
@@ -68,11 +69,13 @@ class UserDetailApi(BaseApi):
user,
ctx.get_param_as_string('avatarStyle'),
ctx.get_file('avatar'))
+ util.bump_version(user)
ctx.session.commit()
return _serialize(ctx, user)
def delete(self, ctx, user_name):
user = users.get_user_by_name(user_name)
+ util.verify_version(user, ctx)
infix = 'self' if ctx.user.user_id == user.user_id else 'any'
auth.verify_privilege(ctx.user, 'users:delete:%s' % infix)
ctx.session.delete(user)
diff --git a/server/szurubooru/db/comment.py b/server/szurubooru/db/comment.py
index fdccf7a..b099aef 100644
--- a/server/szurubooru/db/comment.py
+++ b/server/szurubooru/db/comment.py
@@ -26,6 +26,7 @@ class Comment(Base):
'post_id', Integer, ForeignKey('post.id'), index=True, nullable=False)
user_id = Column(
'user_id', Integer, ForeignKey('user.id'), index=True)
+ version = Column('version', Integer, default=1, nullable=False)
creation_time = Column('creation_time', DateTime, nullable=False)
last_edit_time = Column('last_edit_time', DateTime)
text = Column('text', UnicodeText, default=None)
diff --git a/server/szurubooru/db/post.py b/server/szurubooru/db/post.py
index 0b7ef11..0b160eb 100644
--- a/server/szurubooru/db/post.py
+++ b/server/szurubooru/db/post.py
@@ -101,6 +101,7 @@ class Post(Base):
# basic meta
post_id = Column('id', Integer, primary_key=True)
user_id = Column('user_id', Integer, ForeignKey('user.id'), index=True)
+ version = Column('version', Integer, default=1, nullable=False)
creation_time = Column('creation_time', DateTime, nullable=False)
last_edit_time = Column('last_edit_time', DateTime)
safety = Column('safety', Unicode(32), nullable=False)
diff --git a/server/szurubooru/db/tag.py b/server/szurubooru/db/tag.py
index 8ba7dbe..a1f5057 100644
--- a/server/szurubooru/db/tag.py
+++ b/server/szurubooru/db/tag.py
@@ -44,6 +44,7 @@ class Tag(Base):
tag_id = Column('id', Integer, primary_key=True)
category_id = Column(
'category_id', Integer, ForeignKey('tag_category.id'), nullable=False, index=True)
+ version = Column('version', Integer, default=1, nullable=False)
creation_time = Column('creation_time', DateTime, nullable=False)
last_edit_time = Column('last_edit_time', DateTime)
description = Column('description', UnicodeText, default=None)
diff --git a/server/szurubooru/db/tag_category.py b/server/szurubooru/db/tag_category.py
index 43f07ea..d6610bd 100644
--- a/server/szurubooru/db/tag_category.py
+++ b/server/szurubooru/db/tag_category.py
@@ -8,6 +8,7 @@ class TagCategory(Base):
__tablename__ = 'tag_category'
tag_category_id = Column('id', Integer, primary_key=True)
+ version = Column('version', Integer, default=1, nullable=False)
name = Column('name', Unicode(32), nullable=False)
color = Column('color', Unicode(32), nullable=False, default='#000000')
default = Column('default', Boolean, nullable=False, default=False)
diff --git a/server/szurubooru/db/user.py b/server/szurubooru/db/user.py
index 40b9b06..1439aeb 100644
--- a/server/szurubooru/db/user.py
+++ b/server/szurubooru/db/user.py
@@ -20,13 +20,14 @@ class User(Base):
RANK_NOBODY = 'nobody' # used for privileges: "nobody can be higher than admin"
user_id = Column('id', Integer, primary_key=True)
+ creation_time = Column('creation_time', DateTime, nullable=False)
+ last_login_time = Column('last_login_time', DateTime)
+ version = Column('version', Integer, default=1, nullable=False)
name = Column('name', Unicode(50), nullable=False, unique=True)
password_hash = Column('password_hash', Unicode(64), nullable=False)
password_salt = Column('password_salt', Unicode(32))
email = Column('email', Unicode(64), nullable=True)
rank = Column('rank', Unicode(32), nullable=False)
- creation_time = Column('creation_time', DateTime, nullable=False)
- last_login_time = Column('last_login_time', DateTime)
avatar_style = Column(
'avatar_style', Unicode(32), nullable=False, default=AVATAR_GRAVATAR)
diff --git a/server/szurubooru/func/comments.py b/server/szurubooru/func/comments.py
index 46896a4..65f9edf 100644
--- a/server/szurubooru/func/comments.py
+++ b/server/szurubooru/func/comments.py
@@ -12,6 +12,7 @@ def serialize_comment(comment, authenticated_user, options=None):
'id': lambda: comment.comment_id,
'user': lambda: users.serialize_micro_user(comment.user),
'postId': lambda: comment.post.post_id,
+ 'version': lambda: comment.version,
'text': lambda: comment.text,
'creationTime': lambda: comment.creation_time,
'lastEditTime': lambda: comment.last_edit_time,
diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py
index 1705260..1d9204b 100644
--- a/server/szurubooru/func/posts.py
+++ b/server/szurubooru/func/posts.py
@@ -67,6 +67,7 @@ def serialize_post(post, authenticated_user, options=None):
post,
{
'id': lambda: post.post_id,
+ 'version': lambda: post.version,
'creationTime': lambda: post.creation_time,
'lastEditTime': lambda: post.last_edit_time,
'safety': lambda: SAFETY_MAP[post.safety],
diff --git a/server/szurubooru/func/tag_categories.py b/server/szurubooru/func/tag_categories.py
index e09ffb9..68efaa4 100644
--- a/server/szurubooru/func/tag_categories.py
+++ b/server/szurubooru/func/tag_categories.py
@@ -20,6 +20,7 @@ def serialize_category(category, options=None):
category,
{
'name': lambda: category.name,
+ 'version': lambda: category.version,
'color': lambda: category.color,
'usages': lambda: category.tag_count,
'default': lambda: category.default,
diff --git a/server/szurubooru/func/tags.py b/server/szurubooru/func/tags.py
index 90cc7a9..4456ad1 100644
--- a/server/szurubooru/func/tags.py
+++ b/server/szurubooru/func/tags.py
@@ -53,6 +53,7 @@ def serialize_tag(tag, options=None):
{
'names': lambda: [tag_name.name for tag_name in tag.names],
'category': lambda: tag.category.name,
+ 'version': lambda: tag.version,
'description': lambda: tag.description,
'creationTime': lambda: tag.creation_time,
'lastEditTime': lambda: tag.last_edit_time,
diff --git a/server/szurubooru/func/users.py b/server/szurubooru/func/users.py
index f127d2f..6771b89 100644
--- a/server/szurubooru/func/users.py
+++ b/server/szurubooru/func/users.py
@@ -46,9 +46,10 @@ def serialize_user(user, authenticated_user, options=None, force_show_email=Fals
user,
{
'name': lambda: user.name,
- 'rank': lambda: user.rank,
'creationTime': lambda: user.creation_time,
'lastLoginTime': lambda: user.last_login_time,
+ 'version': lambda: user.version,
+ 'rank': lambda: user.rank,
'avatarStyle': lambda: user.avatar_style,
'avatarUrl': lambda: _get_avatar_url(user),
'commentCount': lambda: user.comment_count,
diff --git a/server/szurubooru/func/util.py b/server/szurubooru/func/util.py
index ff6c385..f1e8f8f 100644
--- a/server/szurubooru/func/util.py
+++ b/server/szurubooru/func/util.py
@@ -139,3 +139,14 @@ def value_exceeds_column_size(value, column):
if max_length is None:
return False
return len(value) > max_length
+
+def verify_version(entity, context, field_name='version'):
+ actual_version = context.get_param_as_int(field_name, required=True)
+ expected_version = entity.version
+ if actual_version != expected_version:
+ raise errors.InvalidParameterError(
+ 'Someone else modified this in the meantime. ' +
+ 'Please try again.')
+
+def bump_version(entity):
+ entity.version += 1
diff --git a/server/szurubooru/migrations/versions/7f6baf38c27c_add_versions.py b/server/szurubooru/migrations/versions/7f6baf38c27c_add_versions.py
new file mode 100644
index 0000000..c74bc5a
--- /dev/null
+++ b/server/szurubooru/migrations/versions/7f6baf38c27c_add_versions.py
@@ -0,0 +1,26 @@
+'''
+Add entity versions
+
+Revision ID: 7f6baf38c27c
+Created at: 2016-08-06 22:26:58.111763
+'''
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = '7f6baf38c27c'
+down_revision = '4c526f869323'
+branch_labels = None
+depends_on = None
+
+tables = ['tag_category', 'tag', 'user', 'post', 'comment']
+
+def upgrade():
+ for table in tables:
+ op.add_column(table, sa.Column('version', sa.Integer(), nullable=True))
+ op.execute(sa.table(table, sa.column('version')).update().values(version=1))
+ op.alter_column(table, 'version', nullable=False)
+
+def downgrade():
+ for table in tables:
+ op.drop_column(table, 'version')
diff --git a/server/szurubooru/tests/api/test_comment_deleting.py b/server/szurubooru/tests/api/test_comment_deleting.py
index fe64328..ecc200a 100644
--- a/server/szurubooru/tests/api/test_comment_deleting.py
+++ b/server/szurubooru/tests/api/test_comment_deleting.py
@@ -24,7 +24,8 @@ def test_deleting_own_comment(test_ctx):
db.session.add(comment)
db.session.commit()
result = test_ctx.api.delete(
- test_ctx.context_factory(user=user), comment.comment_id)
+ test_ctx.context_factory(input={'version': 1}, user=user),
+ comment.comment_id)
assert result == {}
assert db.session.query(db.Comment).count() == 0
@@ -35,7 +36,8 @@ def test_deleting_someones_else_comment(test_ctx):
db.session.add(comment)
db.session.commit()
result = test_ctx.api.delete(
- test_ctx.context_factory(user=user2), comment.comment_id)
+ test_ctx.context_factory(input={'version': 1}, user=user2),
+ comment.comment_id)
assert db.session.query(db.Comment).count() == 0
def test_trying_to_delete_someones_else_comment_without_privileges(test_ctx):
@@ -46,11 +48,14 @@ def test_trying_to_delete_someones_else_comment_without_privileges(test_ctx):
db.session.commit()
with pytest.raises(errors.AuthError):
test_ctx.api.delete(
- test_ctx.context_factory(user=user2), comment.comment_id)
+ test_ctx.context_factory(input={'version': 1}, user=user2),
+ comment.comment_id)
assert db.session.query(db.Comment).count() == 1
def test_trying_to_delete_non_existing(test_ctx):
with pytest.raises(comments.CommentNotFoundError):
test_ctx.api.delete(
test_ctx.context_factory(
- user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), 1)
+ input={'version': 1},
+ user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
+ 1)
diff --git a/server/szurubooru/tests/api/test_comment_updating.py b/server/szurubooru/tests/api/test_comment_updating.py
index d52c8d5..e679c00 100644
--- a/server/szurubooru/tests/api/test_comment_updating.py
+++ b/server/szurubooru/tests/api/test_comment_updating.py
@@ -31,7 +31,8 @@ def test_simple_updating(test_ctx, fake_datetime):
db.session.commit()
with fake_datetime('1997-12-01'):
result = test_ctx.api.put(
- test_ctx.context_factory(input={'text': 'new text'}, user=user),
+ test_ctx.context_factory(
+ input={'text': 'new text', 'version': 1}, user=user),
comment.comment_id)
assert result['text'] == 'new text'
comment = db.session.query(db.Comment).one()
@@ -53,7 +54,8 @@ def test_trying_to_pass_invalid_input(test_ctx, input, expected_exception):
db.session.commit()
with pytest.raises(expected_exception):
test_ctx.api.put(
- test_ctx.context_factory(input=input, user=user),
+ test_ctx.context_factory(
+ input={**input, **{'version': 1}}, user=user),
comment.comment_id)
def test_trying_to_omit_mandatory_field(test_ctx):
@@ -63,7 +65,7 @@ def test_trying_to_omit_mandatory_field(test_ctx):
db.session.commit()
with pytest.raises(errors.ValidationError):
test_ctx.api.put(
- test_ctx.context_factory(input={}, user=user),
+ test_ctx.context_factory(input={'version': 1}, user=user),
comment.comment_id)
def test_trying_to_update_non_existing(test_ctx):
@@ -82,7 +84,8 @@ def test_trying_to_update_someones_comment_without_privileges(test_ctx):
db.session.commit()
with pytest.raises(errors.AuthError):
test_ctx.api.put(
- test_ctx.context_factory(input={'text': 'new text'}, user=user2),
+ test_ctx.context_factory(
+ input={'text': 'new text', 'version': 1}, user=user2),
comment.comment_id)
def test_updating_someones_comment_with_privileges(test_ctx):
@@ -93,7 +96,8 @@ def test_updating_someones_comment_with_privileges(test_ctx):
db.session.commit()
try:
test_ctx.api.put(
- test_ctx.context_factory(input={'text': 'new text'}, user=user2),
+ test_ctx.context_factory(
+ input={'text': 'new text', 'version': 1}, user=user2),
comment.comment_id)
except:
pytest.fail()
diff --git a/server/szurubooru/tests/api/test_post_deleting.py b/server/szurubooru/tests/api/test_post_deleting.py
index 0fa1c92..1b11639 100644
--- a/server/szurubooru/tests/api/test_post_deleting.py
+++ b/server/szurubooru/tests/api/test_post_deleting.py
@@ -25,6 +25,7 @@ def test_deleting(test_ctx):
db.session.commit()
result = test_ctx.api.delete(
test_ctx.context_factory(
+ input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
1)
assert result == {}
diff --git a/server/szurubooru/tests/api/test_post_updating.py b/server/szurubooru/tests/api/test_post_updating.py
index bfe05fc..89e582d 100644
--- a/server/szurubooru/tests/api/test_post_updating.py
+++ b/server/szurubooru/tests/api/test_post_updating.py
@@ -43,6 +43,7 @@ def test_post_updating(
result = api.PostDetailApi().put(
context_factory(
input={
+ 'version': 1,
'safety': 'safe',
'tags': ['tag1', 'tag2'],
'relations': [1, 2],
@@ -89,7 +90,7 @@ def test_uploading_from_url_saves_source(
net.download.return_value = b'content'
api.PostDetailApi().put(
context_factory(
- input={'contentUrl': 'example.com'},
+ input={'contentUrl': 'example.com', 'version': 1},
user=user_factory(rank=db.User.RANK_REGULAR)),
post.post_id)
net.download.assert_called_once_with('example.com')
@@ -116,7 +117,10 @@ def test_uploading_from_url_with_source_specified(
net.download.return_value = b'content'
api.PostDetailApi().put(
context_factory(
- input={'contentUrl': 'example.com', 'source': 'example2.com'},
+ input={
+ 'contentUrl': 'example.com',
+ 'source': 'example2.com',
+ 'version': 1},
user=user_factory(rank=db.User.RANK_REGULAR)),
post.post_id)
net.download.assert_called_once_with('example.com')
@@ -158,7 +162,7 @@ def test_trying_to_update_field_without_privileges(
with pytest.raises(errors.AuthError):
api.PostDetailApi().put(
context_factory(
- input=input,
+ input={**input, **{'version': 1}},
files=files,
user=user_factory(rank=db.User.RANK_ANONYMOUS)),
post.post_id)
@@ -179,6 +183,6 @@ def test_trying_to_create_tags_without_privileges(
posts.update_post_tags.return_value = ['new-tag']
api.PostDetailApi().put(
context_factory(
- input={'tags': ['tag1', 'tag2']},
+ input={'tags': ['tag1', 'tag2'], 'version': 1},
user=user_factory(rank=db.User.RANK_REGULAR)),
post.post_id)
diff --git a/server/szurubooru/tests/api/test_tag_category_creating.py b/server/szurubooru/tests/api/test_tag_category_creating.py
index 18b57dc..f2f61a5 100644
--- a/server/szurubooru/tests/api/test_tag_category_creating.py
+++ b/server/szurubooru/tests/api/test_tag_category_creating.py
@@ -28,6 +28,7 @@ def test_creating_category(test_ctx):
'color': 'black',
'usages': 0,
'default': True,
+ 'version': 1,
}
category = db.session.query(db.TagCategory).one()
assert category.name == 'meta'
diff --git a/server/szurubooru/tests/api/test_tag_category_deleting.py b/server/szurubooru/tests/api/test_tag_category_deleting.py
index a9e04f4..8986ec7 100644
--- a/server/szurubooru/tests/api/test_tag_category_deleting.py
+++ b/server/szurubooru/tests/api/test_tag_category_deleting.py
@@ -32,6 +32,7 @@ def test_deleting(test_ctx):
db.session.commit()
result = test_ctx.api.delete(
test_ctx.context_factory(
+ input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'category')
assert result == {}
@@ -49,6 +50,7 @@ def test_trying_to_delete_used(test_ctx, tag_factory):
with pytest.raises(tag_categories.TagCategoryIsInUseError):
test_ctx.api.delete(
test_ctx.context_factory(
+ input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'category')
assert db.session.query(db.TagCategory).count() == 1
@@ -59,6 +61,7 @@ def test_trying_to_delete_last(test_ctx, tag_factory):
with pytest.raises(tag_categories.TagCategoryIsInUseError):
result = test_ctx.api.delete(
test_ctx.context_factory(
+ input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'root')
@@ -66,7 +69,8 @@ def test_trying_to_delete_non_existing(test_ctx):
with pytest.raises(tag_categories.TagCategoryNotFoundError):
test_ctx.api.delete(
test_ctx.context_factory(
- user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), 'bad')
+ user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
+ 'bad')
def test_trying_to_delete_without_privileges(test_ctx):
db.session.add(test_ctx.tag_category_factory(name='category'))
@@ -74,6 +78,7 @@ def test_trying_to_delete_without_privileges(test_ctx):
with pytest.raises(errors.AuthError):
test_ctx.api.delete(
test_ctx.context_factory(
+ input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)),
'category')
assert db.session.query(db.TagCategory).count() == 1
diff --git a/server/szurubooru/tests/api/test_tag_category_retrieving.py b/server/szurubooru/tests/api/test_tag_category_retrieving.py
index 5644685..0ef5e75 100644
--- a/server/szurubooru/tests/api/test_tag_category_retrieving.py
+++ b/server/szurubooru/tests/api/test_tag_category_retrieving.py
@@ -43,6 +43,7 @@ def test_retrieving_single(test_ctx):
'usages': 0,
'default': False,
'snapshots': [],
+ 'version': 1,
}
def test_trying_to_retrieve_single_non_existing(test_ctx):
diff --git a/server/szurubooru/tests/api/test_tag_category_updating.py b/server/szurubooru/tests/api/test_tag_category_updating.py
index 045faa2..c1bdefe 100644
--- a/server/szurubooru/tests/api/test_tag_category_updating.py
+++ b/server/szurubooru/tests/api/test_tag_category_updating.py
@@ -34,6 +34,7 @@ def test_simple_updating(test_ctx):
input={
'name': 'changed',
'color': 'white',
+ 'version': 1,
},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'name')
@@ -44,6 +45,7 @@ def test_simple_updating(test_ctx):
'color': 'white',
'usages': 0,
'default': False,
+ 'version': 2,
}
assert tag_categories.try_get_category_by_name('name') is None
category = tag_categories.get_category_by_name('changed')
@@ -66,7 +68,7 @@ def test_trying_to_pass_invalid_input(test_ctx, input):
with pytest.raises(tag_categories.InvalidTagCategoryNameError):
test_ctx.api.put(
test_ctx.context_factory(
- input=input,
+ input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'meta')
@@ -81,7 +83,7 @@ def test_omitting_optional_field(test_ctx, field):
del input[field]
result = test_ctx.api.put(
test_ctx.context_factory(
- input=input,
+ input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'name')
assert result is not None
@@ -100,7 +102,7 @@ def test_reusing_own_name(test_ctx, new_name):
db.session.commit()
result = test_ctx.api.put(
test_ctx.context_factory(
- input={'name': new_name},
+ input={'name': new_name, 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'cat')
assert result['name'] == new_name
@@ -116,7 +118,7 @@ def test_trying_to_use_existing_name(test_ctx, dup_name):
with pytest.raises(tag_categories.TagCategoryAlreadyExistsError):
test_ctx.api.put(
test_ctx.context_factory(
- input={'name': dup_name},
+ input={'name': dup_name, 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'cat2')
@@ -130,6 +132,6 @@ def test_trying_to_update_without_privileges(test_ctx, input):
with pytest.raises(errors.AuthError):
test_ctx.api.put(
test_ctx.context_factory(
- input=input,
+ input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)),
'dummy')
diff --git a/server/szurubooru/tests/api/test_tag_creating.py b/server/szurubooru/tests/api/test_tag_creating.py
index 24d7682..817fe56 100644
--- a/server/szurubooru/tests/api/test_tag_creating.py
+++ b/server/szurubooru/tests/api/test_tag_creating.py
@@ -50,6 +50,7 @@ def test_creating_simple_tags(test_ctx, fake_datetime):
'creationTime': datetime.datetime(1997, 12, 1),
'lastEditTime': None,
'usages': 0,
+ 'version': 1,
}
tag = tags.get_tag_by_name('tag1')
assert [tag_name.name for tag_name in tag.names] == ['tag1', 'tag2']
diff --git a/server/szurubooru/tests/api/test_tag_deleting.py b/server/szurubooru/tests/api/test_tag_deleting.py
index bb7cd49..eab7c17 100644
--- a/server/szurubooru/tests/api/test_tag_deleting.py
+++ b/server/szurubooru/tests/api/test_tag_deleting.py
@@ -25,6 +25,7 @@ def test_deleting(test_ctx):
db.session.commit()
result = test_ctx.api.delete(
test_ctx.context_factory(
+ input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag')
assert result == {}
@@ -39,6 +40,7 @@ def test_deleting_used(test_ctx, post_factory):
db.session.commit()
test_ctx.api.delete(
test_ctx.context_factory(
+ input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag')
db.session.refresh(post)
@@ -57,6 +59,7 @@ def test_trying_to_delete_without_privileges(test_ctx):
with pytest.raises(errors.AuthError):
test_ctx.api.delete(
test_ctx.context_factory(
+ input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)),
'tag')
assert db.session.query(db.Tag).count() == 1
diff --git a/server/szurubooru/tests/api/test_tag_merging.py b/server/szurubooru/tests/api/test_tag_merging.py
index 7b03f11..6be9ab8 100644
--- a/server/szurubooru/tests/api/test_tag_merging.py
+++ b/server/szurubooru/tests/api/test_tag_merging.py
@@ -29,6 +29,8 @@ def test_merging_without_usages(test_ctx, fake_datetime):
result = test_ctx.api.post(
test_ctx.context_factory(
input={
+ 'removeVersion': 1,
+ 'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
},
@@ -44,6 +46,7 @@ def test_merging_without_usages(test_ctx, fake_datetime):
'creationTime': datetime.datetime(1996, 1, 1),
'lastEditTime': None,
'usages': 0,
+ 'version': 2,
}
assert tags.try_get_tag_by_name('source') is None
tag = tags.get_tag_by_name('target')
@@ -67,6 +70,8 @@ def test_merging_with_usages(test_ctx, fake_datetime, post_factory):
result = test_ctx.api.post(
test_ctx.context_factory(
input={
+ 'removeVersion': 1,
+ 'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
},
@@ -90,6 +95,8 @@ def test_merging_when_related(test_ctx, fake_datetime):
result = test_ctx.api.post(
test_ctx.context_factory(
input={
+ 'removeVersion': 1,
+ 'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
},
@@ -113,6 +120,8 @@ def test_merging_when_target_exists(test_ctx, fake_datetime, post_factory):
result = test_ctx.api.post(
test_ctx.context_factory(
input={
+ 'removeVersion': 1,
+ 'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
},
@@ -134,6 +143,8 @@ def test_trying_to_pass_invalid_input(test_ctx, input, expected_exception):
db.session.add_all([source_tag, target_tag])
db.session.commit()
real_input = {
+ 'removeVersion': 1,
+ 'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
}
@@ -146,7 +157,7 @@ def test_trying_to_pass_invalid_input(test_ctx, input, expected_exception):
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)))
@pytest.mark.parametrize(
- 'field', ['remove', 'mergeTo'])
+ 'field', ['remove', 'mergeTo', 'removeVersion', 'mergeToVersion'])
def test_trying_to_omit_mandatory_field(test_ctx, field):
db.session.add_all([
test_ctx.tag_factory(names=['source'], category_name='meta'),
@@ -154,6 +165,8 @@ def test_trying_to_omit_mandatory_field(test_ctx, field):
])
db.session.commit()
input = {
+ 'removeVersion': 1,
+ 'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
}
@@ -184,7 +197,11 @@ def test_trying_to_merge_to_itself(test_ctx):
with pytest.raises(tags.InvalidTagRelationError):
test_ctx.api.post(
test_ctx.context_factory(
- input={'remove': 'good', 'mergeTo': 'good'},
+ input={
+ 'removeVersion': 1,
+ 'mergeToVersion': 1,
+ 'remove': 'good',
+ 'mergeTo': 'good'},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)))
@pytest.mark.parametrize('input', [
@@ -203,6 +220,8 @@ def test_trying_to_merge_without_privileges(test_ctx, input):
test_ctx.api.post(
test_ctx.context_factory(
input={
+ 'removeVersion': 1,
+ 'mergeToVersion': 1,
'remove': 'source',
'mergeTo': 'target',
},
diff --git a/server/szurubooru/tests/api/test_tag_retrieving.py b/server/szurubooru/tests/api/test_tag_retrieving.py
index 5c0dc3d..a81b9cd 100644
--- a/server/szurubooru/tests/api/test_tag_retrieving.py
+++ b/server/szurubooru/tests/api/test_tag_retrieving.py
@@ -57,6 +57,7 @@ def test_retrieving_single(test_ctx):
'implications': [],
'usages': 0,
'snapshots': [],
+ 'version': 1,
}
def test_trying_to_retrieve_single_non_existing(test_ctx):
diff --git a/server/szurubooru/tests/api/test_tag_updating.py b/server/szurubooru/tests/api/test_tag_updating.py
index a0b275d..163852c 100644
--- a/server/szurubooru/tests/api/test_tag_updating.py
+++ b/server/szurubooru/tests/api/test_tag_updating.py
@@ -42,6 +42,7 @@ def test_simple_updating(test_ctx, fake_datetime):
result = test_ctx.api.put(
test_ctx.context_factory(
input={
+ 'version': 1,
'names': ['tag3'],
'category': 'character',
'description': 'desc',
@@ -59,6 +60,7 @@ def test_simple_updating(test_ctx, fake_datetime):
'creationTime': datetime.datetime(1996, 1, 1),
'lastEditTime': datetime.datetime(1997, 12, 1),
'usages': 0,
+ 'version': 2,
}
assert tags.try_get_tag_by_name('tag1') is None
assert tags.try_get_tag_by_name('tag2') is None
@@ -89,7 +91,7 @@ def test_trying_to_pass_invalid_input(test_ctx, input, expected_exception):
with pytest.raises(expected_exception):
test_ctx.api.put(
test_ctx.context_factory(
- input=input,
+ input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag1')
@@ -108,7 +110,7 @@ def test_omitting_optional_field(test_ctx, field):
del input[field]
result = test_ctx.api.put(
test_ctx.context_factory(
- input=input,
+ input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag')
assert result is not None
@@ -128,7 +130,7 @@ def test_reusing_own_name(test_ctx, dup_name):
db.session.commit()
result = test_ctx.api.put(
test_ctx.context_factory(
- input={'names': [dup_name, 'tag3']},
+ input={'names': [dup_name, 'tag3'], 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag1')
assert result['names'] == ['tag1', 'tag3']
@@ -143,7 +145,7 @@ def test_duplicating_names(test_ctx):
test_ctx.tag_factory(names=['tag1', 'tag2'], category_name='meta'))
result = test_ctx.api.put(
test_ctx.context_factory(
- input={'names': ['tag3', 'TAG3']},
+ input={'names': ['tag3', 'TAG3'], 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag1')
assert result['names'] == ['tag3']
@@ -162,7 +164,7 @@ def test_trying_to_use_existing_name(test_ctx, dup_name):
with pytest.raises(tags.TagAlreadyExistsError):
test_ctx.api.put(
test_ctx.context_factory(
- input={'names': [dup_name]},
+ input={'names': [dup_name], 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag3')
@@ -195,7 +197,8 @@ def test_updating_new_suggestions_and_implications(
db.session.commit()
result = test_ctx.api.put(
test_ctx.context_factory(
- input=input, user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
+ input={**input, **{'version': 1}},
+ user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'main')
assert result['suggestions'] == expected_suggestions
assert result['implications'] == expected_implications
@@ -219,6 +222,7 @@ def test_reusing_suggestions_and_implications(test_ctx):
'category': 'meta',
'suggestions': ['TAG2'],
'implications': ['tag1'],
+ 'version': 1,
},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag4')
@@ -249,7 +253,8 @@ def test_trying_to_relate_tag_to_itself(test_ctx, input):
with pytest.raises(tags.InvalidTagRelationError):
test_ctx.api.put(
test_ctx.context_factory(
- input=input, user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
+ input={**input, **{'version': 1}},
+ user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag1')
@pytest.mark.parametrize('input', [
@@ -264,6 +269,6 @@ def test_trying_to_update_without_privileges(test_ctx, input):
with pytest.raises(errors.AuthError):
test_ctx.api.put(
test_ctx.context_factory(
- input=input,
+ input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)),
'tag')
diff --git a/server/szurubooru/tests/api/test_user_creating.py b/server/szurubooru/tests/api/test_user_creating.py
index 9162534..0909af2 100644
--- a/server/szurubooru/tests/api/test_user_creating.py
+++ b/server/szurubooru/tests/api/test_user_creating.py
@@ -50,6 +50,7 @@ def test_creating_user(test_ctx, fake_datetime):
'dislikedPostCount': 0,
'favoritePostCount': 0,
'uploadedPostCount': 0,
+ 'version': 1,
}
user = users.get_user_by_name('chewie1')
assert user.name == 'chewie1'
diff --git a/server/szurubooru/tests/api/test_user_deleting.py b/server/szurubooru/tests/api/test_user_deleting.py
index 1dc9be9..3e54b28 100644
--- a/server/szurubooru/tests/api/test_user_deleting.py
+++ b/server/szurubooru/tests/api/test_user_deleting.py
@@ -21,7 +21,8 @@ def test_deleting_oneself(test_ctx):
user = test_ctx.user_factory(name='u', rank=db.User.RANK_REGULAR)
db.session.add(user)
db.session.commit()
- result = test_ctx.api.delete(test_ctx.context_factory(user=user), 'u')
+ result = test_ctx.api.delete(
+ test_ctx.context_factory(input={'version': 1}, user=user), 'u')
assert result == {}
assert db.session.query(db.User).count() == 0
@@ -30,7 +31,8 @@ def test_deleting_someone_else(test_ctx):
user2 = test_ctx.user_factory(name='u2', rank=db.User.RANK_MODERATOR)
db.session.add_all([user1, user2])
db.session.commit()
- test_ctx.api.delete(test_ctx.context_factory(user=user2), 'u1')
+ test_ctx.api.delete(
+ test_ctx.context_factory(input={'version': 1}, user=user2), 'u1')
assert db.session.query(db.User).count() == 1
def test_trying_to_delete_someone_else_without_privileges(test_ctx):
@@ -39,11 +41,14 @@ def test_trying_to_delete_someone_else_without_privileges(test_ctx):
db.session.add_all([user1, user2])
db.session.commit()
with pytest.raises(errors.AuthError):
- test_ctx.api.delete(test_ctx.context_factory(user=user2), 'u1')
+ test_ctx.api.delete(
+ test_ctx.context_factory(input={'version': 1}, user=user2), 'u1')
assert db.session.query(db.User).count() == 2
def test_trying_to_delete_non_existing(test_ctx):
with pytest.raises(users.UserNotFoundError):
test_ctx.api.delete(
test_ctx.context_factory(
- user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), 'bad')
+ input={'version': 1},
+ user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
+ 'bad')
diff --git a/server/szurubooru/tests/api/test_user_retrieving.py b/server/szurubooru/tests/api/test_user_retrieving.py
index c1f9bbd..2720a3c 100644
--- a/server/szurubooru/tests/api/test_user_retrieving.py
+++ b/server/szurubooru/tests/api/test_user_retrieving.py
@@ -61,6 +61,7 @@ def test_retrieving_single(test_ctx):
'dislikedPostCount': False,
'favoritePostCount': 0,
'uploadedPostCount': 0,
+ 'version': 1,
}
assert result['email'] is False
assert result['likedPostCount'] is False
diff --git a/server/szurubooru/tests/api/test_user_updating.py b/server/szurubooru/tests/api/test_user_updating.py
index 201e5c7..7ea0b81 100644
--- a/server/szurubooru/tests/api/test_user_updating.py
+++ b/server/szurubooru/tests/api/test_user_updating.py
@@ -42,6 +42,7 @@ def test_updating_user(test_ctx):
result = test_ctx.api.put(
test_ctx.context_factory(
input={
+ 'version': 1,
'name': 'chewie',
'email': 'asd@asd.asd',
'password': 'oks',
@@ -64,6 +65,7 @@ def test_updating_user(test_ctx):
'dislikedPostCount': 0,
'favoritePostCount': 0,
'uploadedPostCount': 0,
+ 'version': 2,
}
user = users.get_user_by_name('chewie')
assert user.name == 'chewie'
@@ -98,7 +100,10 @@ def test_trying_to_pass_invalid_input(test_ctx, input, expected_exception):
db.session.add(user)
with pytest.raises(expected_exception):
test_ctx.api.put(
- test_ctx.context_factory(input=input, user=user), 'u1')
+ test_ctx.context_factory(
+ input={**input, **{'version': 1}},
+ user=user),
+ 'u1')
@pytest.mark.parametrize(
'field', ['name', 'email', 'password', 'rank', 'avatarStyle'])
@@ -115,7 +120,7 @@ def test_omitting_optional_field(test_ctx, field):
del input[field]
result = test_ctx.api.put(
test_ctx.context_factory(
- input=input,
+ input={**input, **{'version': 1}},
files={'avatar': EMPTY_PIXEL},
user=user),
'u1')
@@ -131,7 +136,8 @@ def test_removing_email(test_ctx):
user = test_ctx.user_factory(name='u1', rank=db.User.RANK_ADMINISTRATOR)
db.session.add(user)
test_ctx.api.put(
- test_ctx.context_factory(input={'email': ''}, user=user), 'u1')
+ test_ctx.context_factory(
+ input={'email': '', 'version': 1}, user=user), 'u1')
assert users.get_user_by_name('u1').email is None
@pytest.mark.parametrize('input', [
@@ -147,7 +153,10 @@ def test_trying_to_update_someone_else(test_ctx, input):
db.session.add_all([user1, user2])
with pytest.raises(errors.AuthError):
test_ctx.api.put(
- test_ctx.context_factory(input=input, user=user1), user2.name)
+ test_ctx.context_factory(
+ input={**input, **{'version': 1}},
+ user=user1),
+ user2.name)
def test_trying_to_become_someone_else(test_ctx):
user1 = test_ctx.user_factory(name='me', rank=db.User.RANK_REGULAR)
@@ -155,10 +164,14 @@ def test_trying_to_become_someone_else(test_ctx):
db.session.add_all([user1, user2])
with pytest.raises(users.UserAlreadyExistsError):
test_ctx.api.put(
- test_ctx.context_factory(input={'name': 'her'}, user=user1), 'me')
+ test_ctx.context_factory(
+ input={'name': 'her', 'version': 1}, user=user1),
+ 'me')
with pytest.raises(users.UserAlreadyExistsError):
test_ctx.api.put(
- test_ctx.context_factory(input={'name': 'HER'}, user=user1), 'me')
+ test_ctx.context_factory(
+ input={'name': 'HER', 'version': 1}, user=user1),
+ 'me')
def test_trying_to_make_someone_into_someone_else(test_ctx):
user1 = test_ctx.user_factory(name='him', rank=db.User.RANK_REGULAR)
@@ -167,25 +180,35 @@ def test_trying_to_make_someone_into_someone_else(test_ctx):
db.session.add_all([user1, user2, user3])
with pytest.raises(users.UserAlreadyExistsError):
test_ctx.api.put(
- test_ctx.context_factory(input={'name': 'her'}, user=user3), 'him')
+ test_ctx.context_factory(
+ input={'name': 'her', 'version': 1}, user=user3),
+ 'him')
with pytest.raises(users.UserAlreadyExistsError):
test_ctx.api.put(
- test_ctx.context_factory(input={'name': 'HER'}, user=user3), 'him')
+ test_ctx.context_factory(
+ input={'name': 'HER', 'version': 1}, user=user3),
+ 'him')
def test_renaming_someone_else(test_ctx):
user1 = test_ctx.user_factory(name='him', rank=db.User.RANK_REGULAR)
user2 = test_ctx.user_factory(name='me', rank=db.User.RANK_MODERATOR)
db.session.add_all([user1, user2])
test_ctx.api.put(
- test_ctx.context_factory(input={'name': 'himself'}, user=user2), 'him')
+ test_ctx.context_factory(
+ input={'name': 'himself', 'version': 1}, user=user2),
+ 'him')
test_ctx.api.put(
- test_ctx.context_factory(input={'name': 'HIMSELF'}, user=user2), 'himself')
+ test_ctx.context_factory(
+ input={'name': 'HIMSELF', 'version': 2}, user=user2),
+ 'himself')
def test_mods_trying_to_become_admin(test_ctx):
user1 = test_ctx.user_factory(name='u1', rank=db.User.RANK_MODERATOR)
user2 = test_ctx.user_factory(name='u2', rank=db.User.RANK_MODERATOR)
db.session.add_all([user1, user2])
- context = test_ctx.context_factory(input={'rank': 'administrator'}, user=user1)
+ context = test_ctx.context_factory(
+ input={'rank': 'administrator', 'version': 1},
+ user=user1)
with pytest.raises(errors.AuthError):
test_ctx.api.put(context, user1.name)
with pytest.raises(errors.AuthError):
@@ -196,7 +219,7 @@ def test_uploading_avatar(test_ctx):
db.session.add(user)
response = test_ctx.api.put(
test_ctx.context_factory(
- input={'avatarStyle': 'manual'},
+ input={'avatarStyle': 'manual', 'version': 1},
files={'avatar': EMPTY_PIXEL},
user=user),
'u1')
diff --git a/server/szurubooru/tests/func/test_posts.py b/server/szurubooru/tests/func/test_posts.py
index fa7be21..dde6f73 100644
--- a/server/szurubooru/tests/func/test_posts.py
+++ b/server/szurubooru/tests/func/test_posts.py
@@ -130,6 +130,7 @@ def test_serialize_post(
assert result == {
'id': 1,
+ 'version': 1,
'creationTime': datetime.datetime(1997, 1, 1),
'lastEditTime': datetime.datetime(1998, 1, 1),
'safety': 'safe',

© 2015 - 2026 Jakob L. Kreuze