server/general: add entity versions
This commit is contained in:
parent
f4ea0d84ad
commit
8d04df38fd
131
API.md
131
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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 == {}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in New Issue