server/general: add entity versions

This commit is contained in:
rr- 2016-08-06 21:16:39 +02:00
parent f4ea0d84ad
commit 8d04df38fd
37 changed files with 313 additions and 67 deletions

115
API.md
View File

@ -11,6 +11,7 @@
- [File uploads](#file-uploads) - [File uploads](#file-uploads)
- [Error handling](#error-handling) - [Error handling](#error-handling)
- [Field selecting](#field-selecting) - [Field selecting](#field-selecting)
- [Versioning](#versioning)
2. [API reference](#api-reference) 2. [API reference](#api-reference)
@ -140,6 +141,29 @@ should send a `GET` query like this:
GET /posts/?fields=id,tags 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 # API reference
@ -211,6 +235,7 @@ data.
```json5 ```json5
{ {
"version": <version>,
"name": <name>, // optional "name": <name>, // optional
"color": <color>, // optional "color": <color>, // optional
} }
@ -222,6 +247,7 @@ data.
- **Errors** - **Errors**
- the version is outdated
- the tag category does not exist - the tag category does not exist
- the name is used by an existing tag category (names are case insensitive) - the name is used by an existing tag category (names are case insensitive)
- the name is invalid - the name is invalid
@ -231,8 +257,9 @@ data.
- **Description** - **Description**
Updates an existing tag category using specified parameters. Name must Updates an existing tag category using specified parameters. Name must
match `tag_category_name_regex` from server's configuration. All fields are match `tag_category_name_regex` from server's configuration. All fields
optional - update concerns only provided fields. except the [`version`](#versioning) are optional - update concerns only
provided fields.
## Getting tag category ## Getting tag category
- **Request** - **Request**
@ -257,6 +284,14 @@ data.
`DELETE /tag-category/<name>` `DELETE /tag-category/<name>`
- **Input**
```json5
{
"version": <version>
}
```
- **Output** - **Output**
```json5 ```json5
@ -265,6 +300,7 @@ data.
- **Errors** - **Errors**
- the version is outdated
- the tag category does not exist - the tag category does not exist
- the tag category is used by some tags - the tag category is used by some tags
- the tag category is the last tag category available - the tag category is the last tag category available
@ -436,6 +472,7 @@ data.
- **Errors** - **Errors**
- the version is outdated
- the tag does not exist - the tag does not exist
- any name is used by an existing tag (names are case insensitive) - any name is used by an existing tag (names are case insensitive)
- any name, implication or suggestion name is invalid - any name, implication or suggestion name is invalid
@ -452,8 +489,9 @@ data.
[`<tag-category>` resource](#tag-category). If specified implied tags or [`<tag-category>` resource](#tag-category). If specified implied tags or
suggested tags do not exist yet, they will be automatically created. Tags suggested tags do not exist yet, they will be automatically created. Tags
created automatically have no implications, no suggestions, one name and created automatically have no implications, no suggestions, one name and
their category is set to the first tag category found. All fields are their category is set to the first tag category found. All fields except
optional - update concerns only provided fields. the [`version`](#versioning) are optional - update concerns only provided
fields.
## Getting tag ## Getting tag
- **Request** - **Request**
@ -478,6 +516,14 @@ data.
`DELETE /tag/<name>` `DELETE /tag/<name>`
- **Input**
```json5
{
"version": <version>
}
```
- **Output** - **Output**
```json5 ```json5
@ -486,6 +532,7 @@ data.
- **Errors** - **Errors**
- the version is outdated
- the tag does not exist - the tag does not exist
- privileges are too low - privileges are too low
@ -502,7 +549,9 @@ data.
```json5 ```json5
{ {
"removeVersion": <source-tag-version>,
"remove": <source-tag-name>, "remove": <source-tag-name>,
"mergeToVersion": <target-tag-version>,
"mergeTo": <target-tag-name> "mergeTo": <target-tag-name>
} }
``` ```
@ -513,6 +562,7 @@ data.
- **Errors** - **Errors**
- the version of either tag is outdated
- the source or target tag does not exist - the source or target tag does not exist
- the source tag is the same as the target tag - the source tag is the same as the target tag
- privileges are too low - privileges are too low
@ -713,9 +763,9 @@ data.
post to use default thumbnail. If `anonymous` is set to truthy value, the 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 uploader name won't be recorded (privilege verification still applies; it's
possible to disallow anonymous uploads completely from config.) All fields possible to disallow anonymous uploads completely from config.) All fields
are optional - update concerns only provided fields. For details how to except the [`version`](#versioning) are optional - update concerns only
pass `content` and `thumbnail`, see [file uploads](#file-uploads) for provided fields. For details how to pass `content` and `thumbnail`, see
details. [file uploads](#file-uploads) for details.
## Updating post ## Updating post
- **Request** - **Request**
@ -726,6 +776,7 @@ data.
```json5 ```json5
{ {
"version": <version>,
"tags": [<tag1>, <tag2>, <tag3>], // optional "tags": [<tag1>, <tag2>, <tag3>], // optional
"safety": <safety>, // optional "safety": <safety>, // optional
"source": <source>, // optional "source": <source>, // optional
@ -746,6 +797,7 @@ data.
- **Errors** - **Errors**
- the version is outdated
- tags have invalid names - tags have invalid names
- safety, notes or flags are invalid - safety, notes or flags are invalid
- relations refer to non-existing posts - relations refer to non-existing posts
@ -760,7 +812,9 @@ data.
must contain valid post IDs. `<flag>` currently can be only `"loop"` to must contain valid post IDs. `<flag>` currently can be only `"loop"` to
enable looping for video posts. Sending empty `thumbnail` will reset the enable looping for video posts. Sending empty `thumbnail` will reset the
post thumbnail to default. For details how to pass `content` and 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 ## Getting post
- **Request** - **Request**
@ -785,6 +839,14 @@ data.
`DELETE /post/<id>` `DELETE /post/<id>`
- **Input**
```json5
{
"version": <version>
}
```
- **Output** - **Output**
```json5 ```json5
@ -793,6 +855,7 @@ data.
- **Errors** - **Errors**
- the version is outdated
- the post does not exist - the post does not exist
- privileges are too low - privileges are too low
@ -1005,6 +1068,7 @@ data.
```json5 ```json5
{ {
"version": <version>,
"text": <new-text> // mandatory "text": <new-text> // mandatory
} }
``` ```
@ -1015,6 +1079,7 @@ data.
- **Errors** - **Errors**
- the version is outdated
- the comment does not exist - the comment does not exist
- new comment text is empty - new comment text is empty
- privileges are too low - privileges are too low
@ -1046,6 +1111,14 @@ data.
`DELETE /comment/<id>` `DELETE /comment/<id>`
- **Input**
```json5
{
"version": <version>
}
```
- **Output** - **Output**
```json5 ```json5
@ -1054,6 +1127,7 @@ data.
- **Errors** - **Errors**
- the version is outdated
- the comment does not exist - the comment does not exist
- privileges are too low - privileges are too low
@ -1194,6 +1268,7 @@ data.
```json5 ```json5
{ {
"version": <version>,
"name": <user-name>, // optional "name": <user-name>, // optional
"password": <user-password>, // optional "password": <user-password>, // optional
"email": <email>, // optional "email": <email>, // optional
@ -1212,6 +1287,7 @@ data.
- **Errors** - **Errors**
- the version is outdated
- the user does not exist - the user does not exist
- a user with new name already exists (names are case insensitive) - a user with new name already exists (names are case insensitive)
- either user name, password, email or rank are invalid - either user name, password, email or rank are invalid
@ -1228,7 +1304,9 @@ data.
provided fields. To update last login time, see provided fields. To update last login time, see
[authentication](#authentication). Avatar style can be either `gravatar` or [authentication](#authentication). Avatar style can be either `gravatar` or
`manual`. `manual` avatar style requires client to pass also `avatar` `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 ## Getting user
- **Request** - **Request**
@ -1253,6 +1331,14 @@ data.
`DELETE /user/<name>` `DELETE /user/<name>`
- **Input**
```json5
{
"version": <version>
}
```
- **Output** - **Output**
```json5 ```json5
@ -1261,6 +1347,7 @@ data.
- **Errors** - **Errors**
- the version is outdated
- the user does not exist - the user does not exist
- privileges are too low - privileges are too low
@ -1413,6 +1500,7 @@ A single user.
```json5 ```json5
{ {
"version": <version>,
"name": <name>, "name": <name>,
"email": <email>, "email": <email>,
"rank": <rank>, "rank": <rank>,
@ -1429,6 +1517,7 @@ A single user.
``` ```
**Field meaning** **Field meaning**
- `<version>`: resource version. See [versioning](#versioning).
- `<name>`: the user name. - `<name>`: the user name.
- `<email>`: the user email. It is available only if the request is - `<email>`: the user email. It is available only if the request is
authenticated by the same user, or the authenticated user can change the authenticated by the same user, or the authenticated user can change the
@ -1480,6 +1569,7 @@ experience.
```json5 ```json5
{ {
"version": <version>,
"name": <name>, "name": <name>,
"color": <color>, "color": <color>,
"usages": <usages> "usages": <usages>
@ -1494,6 +1584,7 @@ experience.
**Field meaning** **Field meaning**
- `<version>`: resource version. See [versioning](#versioning).
- `<name>`: the category name. - `<name>`: the category name.
- `<color>`: the category color. - `<color>`: the category color.
- `<usages>`: how many tags is the given category used with. - `<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 ```json5
{ {
"version": <version>,
"names": <names>, "names": <names>,
"category": <category>, "category": <category>,
"implications": <implications>, "implications": <implications>,
@ -1528,6 +1620,7 @@ A single tag. Tags are used to let users search for posts.
**Field meaning** **Field meaning**
- `<version>`: resource version. See [versioning](#versioning).
- `<names>`: a list of tag names (aliases). Tagging a post with any name will - `<names>`: a list of tag names (aliases). Tagging a post with any name will
automatically assign the first name from this list. automatically assign the first name from this list.
- `<category>`: the name of the category the given tag belongs to. - `<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 ```json5
{ {
"version": <version>,
"id": <id>, "id": <id>,
"creationTime": <creation-time>, "creationTime": <creation-time>,
"lastEditTime": <last-edit-time>, "lastEditTime": <last-edit-time>,
@ -1596,6 +1690,7 @@ One file together with its metadata posted to the site.
**Field meaning** **Field meaning**
- `<version>`: resource version. See [versioning](#versioning).
- `<id>`: the post identifier. - `<id>`: the post identifier.
- `<creation-time>`: time the tag was created, formatted as per RFC 3339. - `<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. - `<last-edit-time>`: time the tag was edited, formatted as per RFC 3339.
@ -1689,6 +1784,7 @@ A comment under a post.
```json5 ```json5
{ {
"version": <version>,
"id": <id>, "id": <id>,
"postId": <post-id>, "postId": <post-id>,
"user": <author> "user": <author>
@ -1701,6 +1797,7 @@ A comment under a post.
``` ```
**Field meaning** **Field meaning**
- `<version>`: resource version. See [versioning](#versioning).
- `<id>`: the comment identifier. - `<id>`: the comment identifier.
- `<post-id>`: an id of the post the comment is for. - `<post-id>`: an id of the post the comment is for.
- `<text>`: the comment content. The client should render is as Markdown. - `<text>`: the comment content. The client should render is as Markdown.

View File

@ -39,16 +39,19 @@ class CommentDetailApi(BaseApi):
def put(self, ctx, comment_id): def put(self, ctx, comment_id):
comment = comments.get_comment_by_id(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' infix = 'own' if ctx.user.user_id == comment.user_id else 'any'
text = ctx.get_param_as_string('text', required=True) text = ctx.get_param_as_string('text', required=True)
auth.verify_privilege(ctx.user, 'comments:edit:%s' % infix) auth.verify_privilege(ctx.user, 'comments:edit:%s' % infix)
comment.last_edit_time = datetime.datetime.utcnow()
comments.update_comment_text(comment, text) comments.update_comment_text(comment, text)
util.bump_version(comment)
comment.last_edit_time = datetime.datetime.utcnow()
ctx.session.commit() ctx.session.commit()
return _serialize(ctx, comment) return _serialize(ctx, comment)
def delete(self, ctx, comment_id): def delete(self, ctx, comment_id):
comment = comments.get_comment_by_id(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' infix = 'own' if ctx.user.user_id == comment.user_id else 'any'
auth.verify_privilege(ctx.user, 'comments:delete:%s' % infix) auth.verify_privilege(ctx.user, 'comments:delete:%s' % infix)
ctx.session.delete(comment) ctx.session.delete(comment)

View File

@ -1,6 +1,6 @@
from szurubooru import config, errors from szurubooru import config, errors
from szurubooru.api.base_api import BaseApi 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_SUBJECT = 'Password reset for {name}'
MAIL_BODY = \ MAIL_BODY = \
@ -34,5 +34,6 @@ class PasswordResetApi(BaseApi):
if token != good_token: if token != good_token:
raise errors.ValidationError('Invalid password reset token.') raise errors.ValidationError('Invalid password reset token.')
new_password = users.reset_user_password(user) new_password = users.reset_user_password(user)
util.bump_version(user)
ctx.session.commit() ctx.session.commit()
return {'password': new_password} return {'password': new_password}

View File

@ -62,6 +62,7 @@ class PostDetailApi(BaseApi):
def put(self, ctx, post_id): def put(self, ctx, post_id):
post = posts.get_post_by_id(post_id) post = posts.get_post_by_id(post_id)
util.verify_version(post, ctx)
if ctx.has_file('content'): if ctx.has_file('content'):
auth.verify_privilege(ctx.user, 'posts:edit:content') auth.verify_privilege(ctx.user, 'posts:edit:content')
posts.update_post_content(post, ctx.get_file('content')) posts.update_post_content(post, ctx.get_file('content'))
@ -90,6 +91,7 @@ class PostDetailApi(BaseApi):
if ctx.has_file('thumbnail'): if ctx.has_file('thumbnail'):
auth.verify_privilege(ctx.user, 'posts:edit:thumbnail') auth.verify_privilege(ctx.user, 'posts:edit:thumbnail')
posts.update_post_thumbnail(post, ctx.get_file('thumbnail')) posts.update_post_thumbnail(post, ctx.get_file('thumbnail'))
util.bump_version(post)
post.last_edit_time = datetime.datetime.utcnow() post.last_edit_time = datetime.datetime.utcnow()
ctx.session.flush() ctx.session.flush()
snapshots.save_entity_modification(post, ctx.user) snapshots.save_entity_modification(post, ctx.user)
@ -100,6 +102,7 @@ class PostDetailApi(BaseApi):
def delete(self, ctx, post_id): def delete(self, ctx, post_id):
auth.verify_privilege(ctx.user, 'posts:delete') auth.verify_privilege(ctx.user, 'posts:delete')
post = posts.get_post_by_id(post_id) post = posts.get_post_by_id(post_id)
util.verify_version(post, ctx)
snapshots.save_entity_deletion(post, ctx.user) snapshots.save_entity_deletion(post, ctx.user)
posts.delete(post) posts.delete(post)
ctx.session.commit() ctx.session.commit()

View File

@ -60,6 +60,7 @@ class TagDetailApi(BaseApi):
def put(self, ctx, tag_name): def put(self, ctx, tag_name):
tag = tags.get_tag_by_name(tag_name) tag = tags.get_tag_by_name(tag_name)
util.verify_version(tag, ctx)
if ctx.has_param('names'): if ctx.has_param('names'):
auth.verify_privilege(ctx.user, 'tags:edit:names') auth.verify_privilege(ctx.user, 'tags:edit:names')
tags.update_tag_names(tag, ctx.get_param_as_list('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') implications = ctx.get_param_as_list('implications')
_create_if_needed(implications, ctx.user) _create_if_needed(implications, ctx.user)
tags.update_tag_implications(tag, implications) tags.update_tag_implications(tag, implications)
util.bump_version(tag)
tag.last_edit_time = datetime.datetime.utcnow() tag.last_edit_time = datetime.datetime.utcnow()
ctx.session.flush() ctx.session.flush()
snapshots.save_entity_modification(tag, ctx.user) snapshots.save_entity_modification(tag, ctx.user)
@ -90,6 +92,7 @@ class TagDetailApi(BaseApi):
def delete(self, ctx, tag_name): def delete(self, ctx, tag_name):
tag = tags.get_tag_by_name(tag_name) tag = tags.get_tag_by_name(tag_name)
util.verify_version(tag, ctx)
auth.verify_privilege(ctx.user, 'tags:delete') auth.verify_privilege(ctx.user, 'tags:delete')
snapshots.save_entity_deletion(tag, ctx.user) snapshots.save_entity_deletion(tag, ctx.user)
tags.delete(tag) tags.delete(tag)
@ -103,11 +106,14 @@ class TagMergeApi(BaseApi):
target_tag_name = ctx.get_param_as_string('mergeTo', required=True) or '' target_tag_name = ctx.get_param_as_string('mergeTo', required=True) or ''
source_tag = tags.get_tag_by_name(source_tag_name) source_tag = tags.get_tag_by_name(source_tag_name)
target_tag = tags.get_tag_by_name(target_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: if source_tag.tag_id == target_tag.tag_id:
raise tags.InvalidTagRelationError('Cannot merge tag with itself.') raise tags.InvalidTagRelationError('Cannot merge tag with itself.')
auth.verify_privilege(ctx.user, 'tags:merge') auth.verify_privilege(ctx.user, 'tags:merge')
snapshots.save_entity_deletion(source_tag, ctx.user) snapshots.save_entity_deletion(source_tag, ctx.user)
tags.merge_tags(source_tag, target_tag) tags.merge_tags(source_tag, target_tag)
util.bump_version(target_tag)
ctx.session.commit() ctx.session.commit()
tags.export_to_json() tags.export_to_json()
return _serialize(ctx, target_tag) return _serialize(ctx, target_tag)

View File

@ -33,6 +33,7 @@ class TagCategoryDetailApi(BaseApi):
def put(self, ctx, category_name): def put(self, ctx, category_name):
category = tag_categories.get_category_by_name(category_name) category = tag_categories.get_category_by_name(category_name)
util.verify_version(category, ctx)
if ctx.has_param('name'): if ctx.has_param('name'):
auth.verify_privilege(ctx.user, 'tag_categories:edit:name') auth.verify_privilege(ctx.user, 'tag_categories:edit:name')
tag_categories.update_category_name( tag_categories.update_category_name(
@ -41,6 +42,7 @@ class TagCategoryDetailApi(BaseApi):
auth.verify_privilege(ctx.user, 'tag_categories:edit:color') auth.verify_privilege(ctx.user, 'tag_categories:edit:color')
tag_categories.update_category_color( tag_categories.update_category_color(
category, ctx.get_param_as_string('color')) category, ctx.get_param_as_string('color'))
util.bump_version(category)
ctx.session.flush() ctx.session.flush()
snapshots.save_entity_modification(category, ctx.user) snapshots.save_entity_modification(category, ctx.user)
ctx.session.commit() ctx.session.commit()
@ -49,6 +51,7 @@ class TagCategoryDetailApi(BaseApi):
def delete(self, ctx, category_name): def delete(self, ctx, category_name):
category = tag_categories.get_category_by_name(category_name) category = tag_categories.get_category_by_name(category_name)
util.verify_version(category, ctx)
auth.verify_privilege(ctx.user, 'tag_categories:delete') auth.verify_privilege(ctx.user, 'tag_categories:delete')
if len(tag_categories.get_all_category_names()) == 1: if len(tag_categories.get_all_category_names()) == 1:
raise tag_categories.TagCategoryIsInUseError( raise tag_categories.TagCategoryIsInUseError(

View File

@ -47,6 +47,7 @@ class UserDetailApi(BaseApi):
def put(self, ctx, user_name): def put(self, ctx, user_name):
user = users.get_user_by_name(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' infix = 'self' if ctx.user.user_id == user.user_id else 'any'
if ctx.has_param('name'): if ctx.has_param('name'):
auth.verify_privilege(ctx.user, 'users:edit:%s:name' % infix) auth.verify_privilege(ctx.user, 'users:edit:%s:name' % infix)
@ -68,11 +69,13 @@ class UserDetailApi(BaseApi):
user, user,
ctx.get_param_as_string('avatarStyle'), ctx.get_param_as_string('avatarStyle'),
ctx.get_file('avatar')) ctx.get_file('avatar'))
util.bump_version(user)
ctx.session.commit() ctx.session.commit()
return _serialize(ctx, user) return _serialize(ctx, user)
def delete(self, ctx, user_name): def delete(self, ctx, user_name):
user = users.get_user_by_name(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' infix = 'self' if ctx.user.user_id == user.user_id else 'any'
auth.verify_privilege(ctx.user, 'users:delete:%s' % infix) auth.verify_privilege(ctx.user, 'users:delete:%s' % infix)
ctx.session.delete(user) ctx.session.delete(user)

View File

@ -26,6 +26,7 @@ class Comment(Base):
'post_id', Integer, ForeignKey('post.id'), index=True, nullable=False) 'post_id', Integer, ForeignKey('post.id'), index=True, nullable=False)
user_id = Column( user_id = Column(
'user_id', Integer, ForeignKey('user.id'), index=True) 'user_id', Integer, ForeignKey('user.id'), index=True)
version = Column('version', Integer, default=1, nullable=False)
creation_time = Column('creation_time', DateTime, nullable=False) creation_time = Column('creation_time', DateTime, nullable=False)
last_edit_time = Column('last_edit_time', DateTime) last_edit_time = Column('last_edit_time', DateTime)
text = Column('text', UnicodeText, default=None) text = Column('text', UnicodeText, default=None)

View File

@ -101,6 +101,7 @@ class Post(Base):
# basic meta # basic meta
post_id = Column('id', Integer, primary_key=True) post_id = Column('id', Integer, primary_key=True)
user_id = Column('user_id', Integer, ForeignKey('user.id'), index=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) creation_time = Column('creation_time', DateTime, nullable=False)
last_edit_time = Column('last_edit_time', DateTime) last_edit_time = Column('last_edit_time', DateTime)
safety = Column('safety', Unicode(32), nullable=False) safety = Column('safety', Unicode(32), nullable=False)

View File

@ -44,6 +44,7 @@ class Tag(Base):
tag_id = Column('id', Integer, primary_key=True) tag_id = Column('id', Integer, primary_key=True)
category_id = Column( category_id = Column(
'category_id', Integer, ForeignKey('tag_category.id'), nullable=False, index=True) '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) creation_time = Column('creation_time', DateTime, nullable=False)
last_edit_time = Column('last_edit_time', DateTime) last_edit_time = Column('last_edit_time', DateTime)
description = Column('description', UnicodeText, default=None) description = Column('description', UnicodeText, default=None)

View File

@ -8,6 +8,7 @@ class TagCategory(Base):
__tablename__ = 'tag_category' __tablename__ = 'tag_category'
tag_category_id = Column('id', Integer, primary_key=True) tag_category_id = Column('id', Integer, primary_key=True)
version = Column('version', Integer, default=1, nullable=False)
name = Column('name', Unicode(32), nullable=False) name = Column('name', Unicode(32), nullable=False)
color = Column('color', Unicode(32), nullable=False, default='#000000') color = Column('color', Unicode(32), nullable=False, default='#000000')
default = Column('default', Boolean, nullable=False, default=False) default = Column('default', Boolean, nullable=False, default=False)

View File

@ -20,13 +20,14 @@ class User(Base):
RANK_NOBODY = 'nobody' # used for privileges: "nobody can be higher than admin" RANK_NOBODY = 'nobody' # used for privileges: "nobody can be higher than admin"
user_id = Column('id', Integer, primary_key=True) 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) name = Column('name', Unicode(50), nullable=False, unique=True)
password_hash = Column('password_hash', Unicode(64), nullable=False) password_hash = Column('password_hash', Unicode(64), nullable=False)
password_salt = Column('password_salt', Unicode(32)) password_salt = Column('password_salt', Unicode(32))
email = Column('email', Unicode(64), nullable=True) email = Column('email', Unicode(64), nullable=True)
rank = Column('rank', Unicode(32), nullable=False) 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 = Column(
'avatar_style', Unicode(32), nullable=False, default=AVATAR_GRAVATAR) 'avatar_style', Unicode(32), nullable=False, default=AVATAR_GRAVATAR)

View File

@ -12,6 +12,7 @@ def serialize_comment(comment, authenticated_user, options=None):
'id': lambda: comment.comment_id, 'id': lambda: comment.comment_id,
'user': lambda: users.serialize_micro_user(comment.user), 'user': lambda: users.serialize_micro_user(comment.user),
'postId': lambda: comment.post.post_id, 'postId': lambda: comment.post.post_id,
'version': lambda: comment.version,
'text': lambda: comment.text, 'text': lambda: comment.text,
'creationTime': lambda: comment.creation_time, 'creationTime': lambda: comment.creation_time,
'lastEditTime': lambda: comment.last_edit_time, 'lastEditTime': lambda: comment.last_edit_time,

View File

@ -67,6 +67,7 @@ def serialize_post(post, authenticated_user, options=None):
post, post,
{ {
'id': lambda: post.post_id, 'id': lambda: post.post_id,
'version': lambda: post.version,
'creationTime': lambda: post.creation_time, 'creationTime': lambda: post.creation_time,
'lastEditTime': lambda: post.last_edit_time, 'lastEditTime': lambda: post.last_edit_time,
'safety': lambda: SAFETY_MAP[post.safety], 'safety': lambda: SAFETY_MAP[post.safety],

View File

@ -20,6 +20,7 @@ def serialize_category(category, options=None):
category, category,
{ {
'name': lambda: category.name, 'name': lambda: category.name,
'version': lambda: category.version,
'color': lambda: category.color, 'color': lambda: category.color,
'usages': lambda: category.tag_count, 'usages': lambda: category.tag_count,
'default': lambda: category.default, 'default': lambda: category.default,

View File

@ -53,6 +53,7 @@ def serialize_tag(tag, options=None):
{ {
'names': lambda: [tag_name.name for tag_name in tag.names], 'names': lambda: [tag_name.name for tag_name in tag.names],
'category': lambda: tag.category.name, 'category': lambda: tag.category.name,
'version': lambda: tag.version,
'description': lambda: tag.description, 'description': lambda: tag.description,
'creationTime': lambda: tag.creation_time, 'creationTime': lambda: tag.creation_time,
'lastEditTime': lambda: tag.last_edit_time, 'lastEditTime': lambda: tag.last_edit_time,

View File

@ -46,9 +46,10 @@ def serialize_user(user, authenticated_user, options=None, force_show_email=Fals
user, user,
{ {
'name': lambda: user.name, 'name': lambda: user.name,
'rank': lambda: user.rank,
'creationTime': lambda: user.creation_time, 'creationTime': lambda: user.creation_time,
'lastLoginTime': lambda: user.last_login_time, 'lastLoginTime': lambda: user.last_login_time,
'version': lambda: user.version,
'rank': lambda: user.rank,
'avatarStyle': lambda: user.avatar_style, 'avatarStyle': lambda: user.avatar_style,
'avatarUrl': lambda: _get_avatar_url(user), 'avatarUrl': lambda: _get_avatar_url(user),
'commentCount': lambda: user.comment_count, 'commentCount': lambda: user.comment_count,

View File

@ -139,3 +139,14 @@ def value_exceeds_column_size(value, column):
if max_length is None: if max_length is None:
return False return False
return len(value) > max_length 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

View File

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

View File

@ -24,7 +24,8 @@ def test_deleting_own_comment(test_ctx):
db.session.add(comment) db.session.add(comment)
db.session.commit() db.session.commit()
result = test_ctx.api.delete( 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 result == {}
assert db.session.query(db.Comment).count() == 0 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.add(comment)
db.session.commit() db.session.commit()
result = test_ctx.api.delete( 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 assert db.session.query(db.Comment).count() == 0
def test_trying_to_delete_someones_else_comment_without_privileges(test_ctx): 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() db.session.commit()
with pytest.raises(errors.AuthError): with pytest.raises(errors.AuthError):
test_ctx.api.delete( 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 assert db.session.query(db.Comment).count() == 1
def test_trying_to_delete_non_existing(test_ctx): def test_trying_to_delete_non_existing(test_ctx):
with pytest.raises(comments.CommentNotFoundError): with pytest.raises(comments.CommentNotFoundError):
test_ctx.api.delete( test_ctx.api.delete(
test_ctx.context_factory( 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)

View File

@ -31,7 +31,8 @@ def test_simple_updating(test_ctx, fake_datetime):
db.session.commit() db.session.commit()
with fake_datetime('1997-12-01'): with fake_datetime('1997-12-01'):
result = test_ctx.api.put( 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) comment.comment_id)
assert result['text'] == 'new text' assert result['text'] == 'new text'
comment = db.session.query(db.Comment).one() 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() db.session.commit()
with pytest.raises(expected_exception): with pytest.raises(expected_exception):
test_ctx.api.put( 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) comment.comment_id)
def test_trying_to_omit_mandatory_field(test_ctx): 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() db.session.commit()
with pytest.raises(errors.ValidationError): with pytest.raises(errors.ValidationError):
test_ctx.api.put( test_ctx.api.put(
test_ctx.context_factory(input={}, user=user), test_ctx.context_factory(input={'version': 1}, user=user),
comment.comment_id) comment.comment_id)
def test_trying_to_update_non_existing(test_ctx): 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() db.session.commit()
with pytest.raises(errors.AuthError): with pytest.raises(errors.AuthError):
test_ctx.api.put( 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) comment.comment_id)
def test_updating_someones_comment_with_privileges(test_ctx): 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() db.session.commit()
try: try:
test_ctx.api.put( 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) comment.comment_id)
except: except:
pytest.fail() pytest.fail()

View File

@ -25,6 +25,7 @@ def test_deleting(test_ctx):
db.session.commit() db.session.commit()
result = test_ctx.api.delete( result = test_ctx.api.delete(
test_ctx.context_factory( test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
1) 1)
assert result == {} assert result == {}

View File

@ -43,6 +43,7 @@ def test_post_updating(
result = api.PostDetailApi().put( result = api.PostDetailApi().put(
context_factory( context_factory(
input={ input={
'version': 1,
'safety': 'safe', 'safety': 'safe',
'tags': ['tag1', 'tag2'], 'tags': ['tag1', 'tag2'],
'relations': [1, 2], 'relations': [1, 2],
@ -89,7 +90,7 @@ def test_uploading_from_url_saves_source(
net.download.return_value = b'content' net.download.return_value = b'content'
api.PostDetailApi().put( api.PostDetailApi().put(
context_factory( context_factory(
input={'contentUrl': 'example.com'}, input={'contentUrl': 'example.com', 'version': 1},
user=user_factory(rank=db.User.RANK_REGULAR)), user=user_factory(rank=db.User.RANK_REGULAR)),
post.post_id) post.post_id)
net.download.assert_called_once_with('example.com') 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' net.download.return_value = b'content'
api.PostDetailApi().put( api.PostDetailApi().put(
context_factory( 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)), user=user_factory(rank=db.User.RANK_REGULAR)),
post.post_id) post.post_id)
net.download.assert_called_once_with('example.com') 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): with pytest.raises(errors.AuthError):
api.PostDetailApi().put( api.PostDetailApi().put(
context_factory( context_factory(
input=input, input={**input, **{'version': 1}},
files=files, files=files,
user=user_factory(rank=db.User.RANK_ANONYMOUS)), user=user_factory(rank=db.User.RANK_ANONYMOUS)),
post.post_id) post.post_id)
@ -179,6 +183,6 @@ def test_trying_to_create_tags_without_privileges(
posts.update_post_tags.return_value = ['new-tag'] posts.update_post_tags.return_value = ['new-tag']
api.PostDetailApi().put( api.PostDetailApi().put(
context_factory( context_factory(
input={'tags': ['tag1', 'tag2']}, input={'tags': ['tag1', 'tag2'], 'version': 1},
user=user_factory(rank=db.User.RANK_REGULAR)), user=user_factory(rank=db.User.RANK_REGULAR)),
post.post_id) post.post_id)

View File

@ -28,6 +28,7 @@ def test_creating_category(test_ctx):
'color': 'black', 'color': 'black',
'usages': 0, 'usages': 0,
'default': True, 'default': True,
'version': 1,
} }
category = db.session.query(db.TagCategory).one() category = db.session.query(db.TagCategory).one()
assert category.name == 'meta' assert category.name == 'meta'

View File

@ -32,6 +32,7 @@ def test_deleting(test_ctx):
db.session.commit() db.session.commit()
result = test_ctx.api.delete( result = test_ctx.api.delete(
test_ctx.context_factory( test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'category') 'category')
assert result == {} assert result == {}
@ -49,6 +50,7 @@ def test_trying_to_delete_used(test_ctx, tag_factory):
with pytest.raises(tag_categories.TagCategoryIsInUseError): with pytest.raises(tag_categories.TagCategoryIsInUseError):
test_ctx.api.delete( test_ctx.api.delete(
test_ctx.context_factory( test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'category') 'category')
assert db.session.query(db.TagCategory).count() == 1 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): with pytest.raises(tag_categories.TagCategoryIsInUseError):
result = test_ctx.api.delete( result = test_ctx.api.delete(
test_ctx.context_factory( test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'root') 'root')
@ -66,7 +69,8 @@ def test_trying_to_delete_non_existing(test_ctx):
with pytest.raises(tag_categories.TagCategoryNotFoundError): with pytest.raises(tag_categories.TagCategoryNotFoundError):
test_ctx.api.delete( test_ctx.api.delete(
test_ctx.context_factory( 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): def test_trying_to_delete_without_privileges(test_ctx):
db.session.add(test_ctx.tag_category_factory(name='category')) 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): with pytest.raises(errors.AuthError):
test_ctx.api.delete( test_ctx.api.delete(
test_ctx.context_factory( test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)), user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)),
'category') 'category')
assert db.session.query(db.TagCategory).count() == 1 assert db.session.query(db.TagCategory).count() == 1

View File

@ -43,6 +43,7 @@ def test_retrieving_single(test_ctx):
'usages': 0, 'usages': 0,
'default': False, 'default': False,
'snapshots': [], 'snapshots': [],
'version': 1,
} }
def test_trying_to_retrieve_single_non_existing(test_ctx): def test_trying_to_retrieve_single_non_existing(test_ctx):

View File

@ -34,6 +34,7 @@ def test_simple_updating(test_ctx):
input={ input={
'name': 'changed', 'name': 'changed',
'color': 'white', 'color': 'white',
'version': 1,
}, },
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'name') 'name')
@ -44,6 +45,7 @@ def test_simple_updating(test_ctx):
'color': 'white', 'color': 'white',
'usages': 0, 'usages': 0,
'default': False, 'default': False,
'version': 2,
} }
assert tag_categories.try_get_category_by_name('name') is None assert tag_categories.try_get_category_by_name('name') is None
category = tag_categories.get_category_by_name('changed') 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): with pytest.raises(tag_categories.InvalidTagCategoryNameError):
test_ctx.api.put( test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input=input, input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'meta') 'meta')
@ -81,7 +83,7 @@ def test_omitting_optional_field(test_ctx, field):
del input[field] del input[field]
result = test_ctx.api.put( result = test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input=input, input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'name') 'name')
assert result is not None assert result is not None
@ -100,7 +102,7 @@ def test_reusing_own_name(test_ctx, new_name):
db.session.commit() db.session.commit()
result = test_ctx.api.put( result = test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input={'name': new_name}, input={'name': new_name, 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'cat') 'cat')
assert result['name'] == new_name 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): with pytest.raises(tag_categories.TagCategoryAlreadyExistsError):
test_ctx.api.put( test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input={'name': dup_name}, input={'name': dup_name, 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'cat2') 'cat2')
@ -130,6 +132,6 @@ def test_trying_to_update_without_privileges(test_ctx, input):
with pytest.raises(errors.AuthError): with pytest.raises(errors.AuthError):
test_ctx.api.put( test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input=input, input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)), user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)),
'dummy') 'dummy')

View File

@ -50,6 +50,7 @@ def test_creating_simple_tags(test_ctx, fake_datetime):
'creationTime': datetime.datetime(1997, 12, 1), 'creationTime': datetime.datetime(1997, 12, 1),
'lastEditTime': None, 'lastEditTime': None,
'usages': 0, 'usages': 0,
'version': 1,
} }
tag = tags.get_tag_by_name('tag1') tag = tags.get_tag_by_name('tag1')
assert [tag_name.name for tag_name in tag.names] == ['tag1', 'tag2'] assert [tag_name.name for tag_name in tag.names] == ['tag1', 'tag2']

View File

@ -25,6 +25,7 @@ def test_deleting(test_ctx):
db.session.commit() db.session.commit()
result = test_ctx.api.delete( result = test_ctx.api.delete(
test_ctx.context_factory( test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag') 'tag')
assert result == {} assert result == {}
@ -39,6 +40,7 @@ def test_deleting_used(test_ctx, post_factory):
db.session.commit() db.session.commit()
test_ctx.api.delete( test_ctx.api.delete(
test_ctx.context_factory( test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag') 'tag')
db.session.refresh(post) db.session.refresh(post)
@ -57,6 +59,7 @@ def test_trying_to_delete_without_privileges(test_ctx):
with pytest.raises(errors.AuthError): with pytest.raises(errors.AuthError):
test_ctx.api.delete( test_ctx.api.delete(
test_ctx.context_factory( test_ctx.context_factory(
input={'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)), user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)),
'tag') 'tag')
assert db.session.query(db.Tag).count() == 1 assert db.session.query(db.Tag).count() == 1

View File

@ -29,6 +29,8 @@ def test_merging_without_usages(test_ctx, fake_datetime):
result = test_ctx.api.post( result = test_ctx.api.post(
test_ctx.context_factory( test_ctx.context_factory(
input={ input={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source', 'remove': 'source',
'mergeTo': 'target', 'mergeTo': 'target',
}, },
@ -44,6 +46,7 @@ def test_merging_without_usages(test_ctx, fake_datetime):
'creationTime': datetime.datetime(1996, 1, 1), 'creationTime': datetime.datetime(1996, 1, 1),
'lastEditTime': None, 'lastEditTime': None,
'usages': 0, 'usages': 0,
'version': 2,
} }
assert tags.try_get_tag_by_name('source') is None assert tags.try_get_tag_by_name('source') is None
tag = tags.get_tag_by_name('target') 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( result = test_ctx.api.post(
test_ctx.context_factory( test_ctx.context_factory(
input={ input={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source', 'remove': 'source',
'mergeTo': 'target', 'mergeTo': 'target',
}, },
@ -90,6 +95,8 @@ def test_merging_when_related(test_ctx, fake_datetime):
result = test_ctx.api.post( result = test_ctx.api.post(
test_ctx.context_factory( test_ctx.context_factory(
input={ input={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source', 'remove': 'source',
'mergeTo': 'target', 'mergeTo': 'target',
}, },
@ -113,6 +120,8 @@ def test_merging_when_target_exists(test_ctx, fake_datetime, post_factory):
result = test_ctx.api.post( result = test_ctx.api.post(
test_ctx.context_factory( test_ctx.context_factory(
input={ input={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source', 'remove': 'source',
'mergeTo': 'target', '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.add_all([source_tag, target_tag])
db.session.commit() db.session.commit()
real_input = { real_input = {
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source', 'remove': 'source',
'mergeTo': 'target', '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))) user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)))
@pytest.mark.parametrize( @pytest.mark.parametrize(
'field', ['remove', 'mergeTo']) 'field', ['remove', 'mergeTo', 'removeVersion', 'mergeToVersion'])
def test_trying_to_omit_mandatory_field(test_ctx, field): def test_trying_to_omit_mandatory_field(test_ctx, field):
db.session.add_all([ db.session.add_all([
test_ctx.tag_factory(names=['source'], category_name='meta'), 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() db.session.commit()
input = { input = {
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source', 'remove': 'source',
'mergeTo': 'target', 'mergeTo': 'target',
} }
@ -184,7 +197,11 @@ def test_trying_to_merge_to_itself(test_ctx):
with pytest.raises(tags.InvalidTagRelationError): with pytest.raises(tags.InvalidTagRelationError):
test_ctx.api.post( test_ctx.api.post(
test_ctx.context_factory( 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))) user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)))
@pytest.mark.parametrize('input', [ @pytest.mark.parametrize('input', [
@ -203,6 +220,8 @@ def test_trying_to_merge_without_privileges(test_ctx, input):
test_ctx.api.post( test_ctx.api.post(
test_ctx.context_factory( test_ctx.context_factory(
input={ input={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': 'source', 'remove': 'source',
'mergeTo': 'target', 'mergeTo': 'target',
}, },

View File

@ -57,6 +57,7 @@ def test_retrieving_single(test_ctx):
'implications': [], 'implications': [],
'usages': 0, 'usages': 0,
'snapshots': [], 'snapshots': [],
'version': 1,
} }
def test_trying_to_retrieve_single_non_existing(test_ctx): def test_trying_to_retrieve_single_non_existing(test_ctx):

View File

@ -42,6 +42,7 @@ def test_simple_updating(test_ctx, fake_datetime):
result = test_ctx.api.put( result = test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input={ input={
'version': 1,
'names': ['tag3'], 'names': ['tag3'],
'category': 'character', 'category': 'character',
'description': 'desc', 'description': 'desc',
@ -59,6 +60,7 @@ def test_simple_updating(test_ctx, fake_datetime):
'creationTime': datetime.datetime(1996, 1, 1), 'creationTime': datetime.datetime(1996, 1, 1),
'lastEditTime': datetime.datetime(1997, 12, 1), 'lastEditTime': datetime.datetime(1997, 12, 1),
'usages': 0, 'usages': 0,
'version': 2,
} }
assert tags.try_get_tag_by_name('tag1') is None assert tags.try_get_tag_by_name('tag1') is None
assert tags.try_get_tag_by_name('tag2') 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): with pytest.raises(expected_exception):
test_ctx.api.put( test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input=input, input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag1') 'tag1')
@ -108,7 +110,7 @@ def test_omitting_optional_field(test_ctx, field):
del input[field] del input[field]
result = test_ctx.api.put( result = test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input=input, input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag') 'tag')
assert result is not None assert result is not None
@ -128,7 +130,7 @@ def test_reusing_own_name(test_ctx, dup_name):
db.session.commit() db.session.commit()
result = test_ctx.api.put( result = test_ctx.api.put(
test_ctx.context_factory( 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)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag1') 'tag1')
assert result['names'] == ['tag1', 'tag3'] 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')) test_ctx.tag_factory(names=['tag1', 'tag2'], category_name='meta'))
result = test_ctx.api.put( result = test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input={'names': ['tag3', 'TAG3']}, input={'names': ['tag3', 'TAG3'], 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag1') 'tag1')
assert result['names'] == ['tag3'] assert result['names'] == ['tag3']
@ -162,7 +164,7 @@ def test_trying_to_use_existing_name(test_ctx, dup_name):
with pytest.raises(tags.TagAlreadyExistsError): with pytest.raises(tags.TagAlreadyExistsError):
test_ctx.api.put( test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input={'names': [dup_name]}, input={'names': [dup_name], 'version': 1},
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag3') 'tag3')
@ -195,7 +197,8 @@ def test_updating_new_suggestions_and_implications(
db.session.commit() db.session.commit()
result = test_ctx.api.put( result = test_ctx.api.put(
test_ctx.context_factory( 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') 'main')
assert result['suggestions'] == expected_suggestions assert result['suggestions'] == expected_suggestions
assert result['implications'] == expected_implications assert result['implications'] == expected_implications
@ -219,6 +222,7 @@ def test_reusing_suggestions_and_implications(test_ctx):
'category': 'meta', 'category': 'meta',
'suggestions': ['TAG2'], 'suggestions': ['TAG2'],
'implications': ['tag1'], 'implications': ['tag1'],
'version': 1,
}, },
user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)), user=test_ctx.user_factory(rank=db.User.RANK_REGULAR)),
'tag4') 'tag4')
@ -249,7 +253,8 @@ def test_trying_to_relate_tag_to_itself(test_ctx, input):
with pytest.raises(tags.InvalidTagRelationError): with pytest.raises(tags.InvalidTagRelationError):
test_ctx.api.put( test_ctx.api.put(
test_ctx.context_factory( 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') 'tag1')
@pytest.mark.parametrize('input', [ @pytest.mark.parametrize('input', [
@ -264,6 +269,6 @@ def test_trying_to_update_without_privileges(test_ctx, input):
with pytest.raises(errors.AuthError): with pytest.raises(errors.AuthError):
test_ctx.api.put( test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input=input, input={**input, **{'version': 1}},
user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)), user=test_ctx.user_factory(rank=db.User.RANK_ANONYMOUS)),
'tag') 'tag')

View File

@ -50,6 +50,7 @@ def test_creating_user(test_ctx, fake_datetime):
'dislikedPostCount': 0, 'dislikedPostCount': 0,
'favoritePostCount': 0, 'favoritePostCount': 0,
'uploadedPostCount': 0, 'uploadedPostCount': 0,
'version': 1,
} }
user = users.get_user_by_name('chewie1') user = users.get_user_by_name('chewie1')
assert user.name == 'chewie1' assert user.name == 'chewie1'

View File

@ -21,7 +21,8 @@ def test_deleting_oneself(test_ctx):
user = test_ctx.user_factory(name='u', rank=db.User.RANK_REGULAR) user = test_ctx.user_factory(name='u', rank=db.User.RANK_REGULAR)
db.session.add(user) db.session.add(user)
db.session.commit() 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 result == {}
assert db.session.query(db.User).count() == 0 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) user2 = test_ctx.user_factory(name='u2', rank=db.User.RANK_MODERATOR)
db.session.add_all([user1, user2]) db.session.add_all([user1, user2])
db.session.commit() 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 assert db.session.query(db.User).count() == 1
def test_trying_to_delete_someone_else_without_privileges(test_ctx): 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.add_all([user1, user2])
db.session.commit() db.session.commit()
with pytest.raises(errors.AuthError): 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 assert db.session.query(db.User).count() == 2
def test_trying_to_delete_non_existing(test_ctx): def test_trying_to_delete_non_existing(test_ctx):
with pytest.raises(users.UserNotFoundError): with pytest.raises(users.UserNotFoundError):
test_ctx.api.delete( test_ctx.api.delete(
test_ctx.context_factory( 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')

View File

@ -61,6 +61,7 @@ def test_retrieving_single(test_ctx):
'dislikedPostCount': False, 'dislikedPostCount': False,
'favoritePostCount': 0, 'favoritePostCount': 0,
'uploadedPostCount': 0, 'uploadedPostCount': 0,
'version': 1,
} }
assert result['email'] is False assert result['email'] is False
assert result['likedPostCount'] is False assert result['likedPostCount'] is False

View File

@ -42,6 +42,7 @@ def test_updating_user(test_ctx):
result = test_ctx.api.put( result = test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input={ input={
'version': 1,
'name': 'chewie', 'name': 'chewie',
'email': 'asd@asd.asd', 'email': 'asd@asd.asd',
'password': 'oks', 'password': 'oks',
@ -64,6 +65,7 @@ def test_updating_user(test_ctx):
'dislikedPostCount': 0, 'dislikedPostCount': 0,
'favoritePostCount': 0, 'favoritePostCount': 0,
'uploadedPostCount': 0, 'uploadedPostCount': 0,
'version': 2,
} }
user = users.get_user_by_name('chewie') user = users.get_user_by_name('chewie')
assert user.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) db.session.add(user)
with pytest.raises(expected_exception): with pytest.raises(expected_exception):
test_ctx.api.put( 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( @pytest.mark.parametrize(
'field', ['name', 'email', 'password', 'rank', 'avatarStyle']) 'field', ['name', 'email', 'password', 'rank', 'avatarStyle'])
@ -115,7 +120,7 @@ def test_omitting_optional_field(test_ctx, field):
del input[field] del input[field]
result = test_ctx.api.put( result = test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input=input, input={**input, **{'version': 1}},
files={'avatar': EMPTY_PIXEL}, files={'avatar': EMPTY_PIXEL},
user=user), user=user),
'u1') 'u1')
@ -131,7 +136,8 @@ def test_removing_email(test_ctx):
user = test_ctx.user_factory(name='u1', rank=db.User.RANK_ADMINISTRATOR) user = test_ctx.user_factory(name='u1', rank=db.User.RANK_ADMINISTRATOR)
db.session.add(user) db.session.add(user)
test_ctx.api.put( 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 assert users.get_user_by_name('u1').email is None
@pytest.mark.parametrize('input', [ @pytest.mark.parametrize('input', [
@ -147,7 +153,10 @@ def test_trying_to_update_someone_else(test_ctx, input):
db.session.add_all([user1, user2]) db.session.add_all([user1, user2])
with pytest.raises(errors.AuthError): with pytest.raises(errors.AuthError):
test_ctx.api.put( 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): def test_trying_to_become_someone_else(test_ctx):
user1 = test_ctx.user_factory(name='me', rank=db.User.RANK_REGULAR) 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]) db.session.add_all([user1, user2])
with pytest.raises(users.UserAlreadyExistsError): with pytest.raises(users.UserAlreadyExistsError):
test_ctx.api.put( 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): with pytest.raises(users.UserAlreadyExistsError):
test_ctx.api.put( 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): def test_trying_to_make_someone_into_someone_else(test_ctx):
user1 = test_ctx.user_factory(name='him', rank=db.User.RANK_REGULAR) 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]) db.session.add_all([user1, user2, user3])
with pytest.raises(users.UserAlreadyExistsError): with pytest.raises(users.UserAlreadyExistsError):
test_ctx.api.put( 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): with pytest.raises(users.UserAlreadyExistsError):
test_ctx.api.put( 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): def test_renaming_someone_else(test_ctx):
user1 = test_ctx.user_factory(name='him', rank=db.User.RANK_REGULAR) user1 = test_ctx.user_factory(name='him', rank=db.User.RANK_REGULAR)
user2 = test_ctx.user_factory(name='me', rank=db.User.RANK_MODERATOR) user2 = test_ctx.user_factory(name='me', rank=db.User.RANK_MODERATOR)
db.session.add_all([user1, user2]) db.session.add_all([user1, user2])
test_ctx.api.put( 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.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): def test_mods_trying_to_become_admin(test_ctx):
user1 = test_ctx.user_factory(name='u1', rank=db.User.RANK_MODERATOR) user1 = test_ctx.user_factory(name='u1', rank=db.User.RANK_MODERATOR)
user2 = test_ctx.user_factory(name='u2', rank=db.User.RANK_MODERATOR) user2 = test_ctx.user_factory(name='u2', rank=db.User.RANK_MODERATOR)
db.session.add_all([user1, user2]) 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): with pytest.raises(errors.AuthError):
test_ctx.api.put(context, user1.name) test_ctx.api.put(context, user1.name)
with pytest.raises(errors.AuthError): with pytest.raises(errors.AuthError):
@ -196,7 +219,7 @@ def test_uploading_avatar(test_ctx):
db.session.add(user) db.session.add(user)
response = test_ctx.api.put( response = test_ctx.api.put(
test_ctx.context_factory( test_ctx.context_factory(
input={'avatarStyle': 'manual'}, input={'avatarStyle': 'manual', 'version': 1},
files={'avatar': EMPTY_PIXEL}, files={'avatar': EMPTY_PIXEL},
user=user), user=user),
'u1') 'u1')

View File

@ -130,6 +130,7 @@ def test_serialize_post(
assert result == { assert result == {
'id': 1, 'id': 1,
'version': 1,
'creationTime': datetime.datetime(1997, 1, 1), 'creationTime': datetime.datetime(1997, 1, 1),
'lastEditTime': datetime.datetime(1998, 1, 1), 'lastEditTime': datetime.datetime(1998, 1, 1),
'safety': 'safe', 'safety': 'safe',