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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user