server/posts: add post creating
127
API.md
|
@ -29,7 +29,7 @@
|
||||||
- [Listing tag siblings](#listing-tag-siblings)
|
- [Listing tag siblings](#listing-tag-siblings)
|
||||||
- Posts
|
- Posts
|
||||||
- ~~Listing posts~~
|
- ~~Listing posts~~
|
||||||
- ~~Creating post~~
|
- [Creating post](#creating-post)
|
||||||
- ~~Updating post~~
|
- ~~Updating post~~
|
||||||
- [Getting post](#getting-post)
|
- [Getting post](#getting-post)
|
||||||
- [Deleting post](#deleting-post)
|
- [Deleting post](#deleting-post)
|
||||||
|
@ -69,6 +69,7 @@
|
||||||
- [Detailed tag](#detailed-tag)
|
- [Detailed tag](#detailed-tag)
|
||||||
- [Post](#post)
|
- [Post](#post)
|
||||||
- [Detailed post](#detailed-post)
|
- [Detailed post](#detailed-post)
|
||||||
|
- [Note](#note)
|
||||||
- [Comment](#comment)
|
- [Comment](#comment)
|
||||||
- [Detailed comment](#detailed-comment)
|
- [Detailed comment](#detailed-comment)
|
||||||
- [Snapshot](#snapshot)
|
- [Snapshot](#snapshot)
|
||||||
|
@ -125,7 +126,6 @@ Depending on the deployment, the URLs might be relative to some base path such
|
||||||
as `/api/`. Values denoted with diamond braces (`<like this>`) signify variable
|
as `/api/`. Values denoted with diamond braces (`<like this>`) signify variable
|
||||||
data.
|
data.
|
||||||
|
|
||||||
|
|
||||||
## Listing tag categories
|
## Listing tag categories
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -150,7 +150,6 @@ data.
|
||||||
caching. The data directory and its URL are controlled with `data_dir` and
|
caching. The data directory and its URL are controlled with `data_dir` and
|
||||||
`data_url` variables in server's configuration.
|
`data_url` variables in server's configuration.
|
||||||
|
|
||||||
|
|
||||||
## Creating tag category
|
## Creating tag category
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -181,7 +180,6 @@ data.
|
||||||
Creates a new tag category using specified parameters. Name must match
|
Creates a new tag category using specified parameters. Name must match
|
||||||
`tag_category_name_regex` from server's configuration.
|
`tag_category_name_regex` from server's configuration.
|
||||||
|
|
||||||
|
|
||||||
## Updating tag category
|
## Updating tag category
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -214,7 +212,6 @@ data.
|
||||||
match `tag_category_name_regex` from server's configuration. All fields are
|
match `tag_category_name_regex` from server's configuration. All fields are
|
||||||
optional - update concerns only provided fields.
|
optional - update concerns only provided fields.
|
||||||
|
|
||||||
|
|
||||||
## Getting tag category
|
## Getting tag category
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -233,7 +230,6 @@ data.
|
||||||
|
|
||||||
Retrieves information about an existing tag category.
|
Retrieves information about an existing tag category.
|
||||||
|
|
||||||
|
|
||||||
## Deleting tag category
|
## Deleting tag category
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -257,7 +253,6 @@ data.
|
||||||
Deletes existing tag category. The tag category to be deleted must have no
|
Deletes existing tag category. The tag category to be deleted must have no
|
||||||
usages.
|
usages.
|
||||||
|
|
||||||
|
|
||||||
## Listing tags
|
## Listing tags
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -327,7 +322,6 @@ data.
|
||||||
|
|
||||||
None.
|
None.
|
||||||
|
|
||||||
|
|
||||||
## Creating tag
|
## Creating tag
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -370,7 +364,6 @@ data.
|
||||||
first tag category found. If there are no tag categories established yet,
|
first tag category found. If there are no tag categories established yet,
|
||||||
an error will be thrown.
|
an error will be thrown.
|
||||||
|
|
||||||
|
|
||||||
## Updating tag
|
## Updating tag
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -412,7 +405,6 @@ data.
|
||||||
their category is set to the first tag category found. All fields are
|
their category is set to the first tag category found. All fields are
|
||||||
optional - update concerns only provided fields.
|
optional - update concerns only provided fields.
|
||||||
|
|
||||||
|
|
||||||
## Getting tag
|
## Getting tag
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -431,7 +423,6 @@ data.
|
||||||
|
|
||||||
Retrieves information about an existing tag.
|
Retrieves information about an existing tag.
|
||||||
|
|
||||||
|
|
||||||
## Deleting tag
|
## Deleting tag
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -453,7 +444,6 @@ data.
|
||||||
|
|
||||||
Deletes existing tag. The tag to be deleted must have no usages.
|
Deletes existing tag. The tag to be deleted must have no usages.
|
||||||
|
|
||||||
|
|
||||||
## Merging tags
|
## Merging tags
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -485,7 +475,6 @@ data.
|
||||||
and are discarded. The target tag effectively remains unchanged with the
|
and are discarded. The target tag effectively remains unchanged with the
|
||||||
exception of the set of posts it's used in.
|
exception of the set of posts it's used in.
|
||||||
|
|
||||||
|
|
||||||
## Listing tag siblings
|
## Listing tag siblings
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -520,6 +509,48 @@ data.
|
||||||
appears with given tag. Results are sorted by occurrences count and the
|
appears with given tag. Results are sorted by occurrences count and the
|
||||||
list is truncated to the first 50 elements. Doesn't use paging.
|
list is truncated to the first 50 elements. Doesn't use paging.
|
||||||
|
|
||||||
|
## Creating post
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`POST /posts/`
|
||||||
|
|
||||||
|
- **Input**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"tags": [<tag1>, <tag2>, <tag3>],
|
||||||
|
"safety": <safety>,
|
||||||
|
"source": <source>, // optional
|
||||||
|
"relations": [<post1>, <post2>, <post3>], // optional
|
||||||
|
"notes": [<note1>, <note2>, <note3>], // optional
|
||||||
|
"flags": [<flag1>, <flag2>] // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Files**
|
||||||
|
|
||||||
|
- `content` - the content of the content.
|
||||||
|
- `thumbnail` - the content of custom thumbnail (optional).
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
A [detailed post resource](#detailed-post).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- tags have invalid names
|
||||||
|
- safety is invalid
|
||||||
|
- relations refer to non-existing posts
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Creates a new post. If specified 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. Safety must be any of `"safe"`, `"sketchy"` or `"unsafe"`. `<flag>`
|
||||||
|
currently can be only `"loop"` to enable looping for video posts. Sending
|
||||||
|
empty `thumbnail` will cause the post to use default thumbnail.
|
||||||
|
|
||||||
## Getting post
|
## Getting post
|
||||||
- **Request**
|
- **Request**
|
||||||
|
@ -539,7 +570,6 @@ data.
|
||||||
|
|
||||||
Retrieves information about an existing post.
|
Retrieves information about an existing post.
|
||||||
|
|
||||||
|
|
||||||
## Deleting post
|
## Deleting post
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -560,7 +590,6 @@ data.
|
||||||
|
|
||||||
Deletes existing post. Related posts and tags are kept.
|
Deletes existing post. Related posts and tags are kept.
|
||||||
|
|
||||||
|
|
||||||
## Rating post
|
## Rating post
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -589,7 +618,6 @@ data.
|
||||||
Updates score of authenticated user for given post. Valid scores are -1, 0
|
Updates score of authenticated user for given post. Valid scores are -1, 0
|
||||||
and 1.
|
and 1.
|
||||||
|
|
||||||
|
|
||||||
## Adding post to favorites
|
## Adding post to favorites
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -608,7 +636,6 @@ data.
|
||||||
|
|
||||||
Marks the post as favorite for authenticated user.
|
Marks the post as favorite for authenticated user.
|
||||||
|
|
||||||
|
|
||||||
## Removing post from favorites
|
## Removing post from favorites
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -627,7 +654,6 @@ data.
|
||||||
|
|
||||||
Unmarks the post as favorite for authenticated user.
|
Unmarks the post as favorite for authenticated user.
|
||||||
|
|
||||||
|
|
||||||
## Getting featured post
|
## Getting featured post
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -647,7 +673,6 @@ data.
|
||||||
client. If no post is featured, `<post>` is null and `snapshots` array is
|
client. If no post is featured, `<post>` is null and `snapshots` array is
|
||||||
empty.
|
empty.
|
||||||
|
|
||||||
|
|
||||||
## Featuring post
|
## Featuring post
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -666,7 +691,6 @@ data.
|
||||||
|
|
||||||
Features a post on the main page in web client.
|
Features a post on the main page in web client.
|
||||||
|
|
||||||
|
|
||||||
## Listing comments
|
## Listing comments
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -722,7 +746,6 @@ data.
|
||||||
|
|
||||||
None.
|
None.
|
||||||
|
|
||||||
|
|
||||||
## Creating comment
|
## Creating comment
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -751,7 +774,6 @@ data.
|
||||||
|
|
||||||
Creates a new comment under given post.
|
Creates a new comment under given post.
|
||||||
|
|
||||||
|
|
||||||
## Updating comment
|
## Updating comment
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -779,7 +801,6 @@ data.
|
||||||
|
|
||||||
Updates an existing comment text.
|
Updates an existing comment text.
|
||||||
|
|
||||||
|
|
||||||
## Getting comment
|
## Getting comment
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -798,7 +819,6 @@ data.
|
||||||
|
|
||||||
Retrieves information about an existing comment.
|
Retrieves information about an existing comment.
|
||||||
|
|
||||||
|
|
||||||
## Deleting comment
|
## Deleting comment
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -819,7 +839,6 @@ data.
|
||||||
|
|
||||||
Deletes existing comment.
|
Deletes existing comment.
|
||||||
|
|
||||||
|
|
||||||
## Rating comment
|
## Rating comment
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -848,7 +867,6 @@ data.
|
||||||
Updates score of authenticated user for given comment. Valid scores are -1,
|
Updates score of authenticated user for given comment. Valid scores are -1,
|
||||||
0 and 1.
|
0 and 1.
|
||||||
|
|
||||||
|
|
||||||
## Listing users
|
## Listing users
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -900,7 +918,6 @@ data.
|
||||||
|
|
||||||
None.
|
None.
|
||||||
|
|
||||||
|
|
||||||
## Creating user
|
## Creating user
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -947,7 +964,6 @@ data.
|
||||||
administrator, whereas subsequent users will be given the rank indicated by
|
administrator, whereas subsequent users will be given the rank indicated by
|
||||||
`default_rank` in the server's configuration.
|
`default_rank` in the server's configuration.
|
||||||
|
|
||||||
|
|
||||||
## Updating user
|
## Updating user
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -993,7 +1009,6 @@ data.
|
||||||
`manual`. `manual` avatar style requires client to pass also `avatar`
|
`manual`. `manual` avatar style requires client to pass also `avatar`
|
||||||
file - see [file uploads](#file-uploads) for details.
|
file - see [file uploads](#file-uploads) for details.
|
||||||
|
|
||||||
|
|
||||||
## Getting user
|
## Getting user
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -1012,7 +1027,6 @@ data.
|
||||||
|
|
||||||
Retrieves information about an existing user.
|
Retrieves information about an existing user.
|
||||||
|
|
||||||
|
|
||||||
## Deleting user
|
## Deleting user
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -1033,7 +1047,6 @@ data.
|
||||||
|
|
||||||
Deletes existing user.
|
Deletes existing user.
|
||||||
|
|
||||||
|
|
||||||
## Password reset - step 1: mail request
|
## Password reset - step 1: mail request
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -1058,7 +1071,6 @@ data.
|
||||||
mailbox, which is a strong indication they are the rightful owner of the
|
mailbox, which is a strong indication they are the rightful owner of the
|
||||||
account.
|
account.
|
||||||
|
|
||||||
|
|
||||||
## Password reset - step 2: confirmation
|
## Password reset - step 2: confirmation
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -1091,7 +1103,6 @@ data.
|
||||||
Generates a new password for given user. Password is sent as plain-text, so
|
Generates a new password for given user. Password is sent as plain-text, so
|
||||||
it is recommended to connect through HTTPS.
|
it is recommended to connect through HTTPS.
|
||||||
|
|
||||||
|
|
||||||
## Listing snapshots
|
## Listing snapshots
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -1133,7 +1144,6 @@ data.
|
||||||
|
|
||||||
None.
|
None.
|
||||||
|
|
||||||
|
|
||||||
## Getting global info
|
## Getting global info
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
|
@ -1325,29 +1335,34 @@ One file together with its metadata posted to the site.
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
"id": <id>,
|
"id": <id>,
|
||||||
|
"creationTime": <creation-time>,
|
||||||
|
"lastEditTime": <last-edit-time>,
|
||||||
"safety": <safety>,
|
"safety": <safety>,
|
||||||
|
"source": <source>,
|
||||||
"type": <type>,
|
"type": <type>,
|
||||||
"checksum": <checksum>,
|
"checksum": <checksum>,
|
||||||
"source": <source>,
|
|
||||||
"canvasWidth": <canvas-width>,
|
"canvasWidth": <canvas-width>,
|
||||||
"canvasHeight": <canvas-height>,
|
"canvasHeight": <canvas-height>,
|
||||||
|
"contentUrl": <content-url>,
|
||||||
|
"thumbnailUrl": <thumbnail-url>,
|
||||||
"flags": <flags>,
|
"flags": <flags>,
|
||||||
"tags": <tags>,
|
"tags": <tags>,
|
||||||
"relations": <relations>,
|
"relations": <relations>,
|
||||||
"creationTime": <creation-time>,
|
"notes": <notes>,
|
||||||
"lastEditTime": <last-edit-time>,
|
|
||||||
"user": <user>,
|
"user": <user>,
|
||||||
"score": <score>,
|
"score": <score>,
|
||||||
"ownScore": <own-score>,
|
"ownScore": <own-score>,
|
||||||
"favoritedBy": <favorited-by>,
|
|
||||||
"featureCount": <feature-count>,
|
"featureCount": <feature-count>,
|
||||||
"lastFeatureTime": <last-feature-time>
|
"lastFeatureTime": <last-feature-time>,
|
||||||
|
"favoritedBy": <favorited-by>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Field meaning**
|
**Field meaning**
|
||||||
|
|
||||||
- `<id>`: the post identifier.
|
- `<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.
|
||||||
- `<safety>`: whether the post is safe for work.
|
- `<safety>`: whether the post is safe for work.
|
||||||
|
|
||||||
Available values:
|
Available values:
|
||||||
|
@ -1356,6 +1371,7 @@ One file together with its metadata posted to the site.
|
||||||
- `"sketchy"`
|
- `"sketchy"`
|
||||||
- `"unsafe"`
|
- `"unsafe"`
|
||||||
|
|
||||||
|
- `<source>`: where the post was grabbed form, supplied by the user.
|
||||||
- `<type>`: the type of the post.
|
- `<type>`: the type of the post.
|
||||||
|
|
||||||
Available values:
|
Available values:
|
||||||
|
@ -1368,24 +1384,25 @@ One file together with its metadata posted to the site.
|
||||||
|
|
||||||
- `<checksum>`: the file checksum. Used in snapshots to signify changes of the
|
- `<checksum>`: the file checksum. Used in snapshots to signify changes of the
|
||||||
post content.
|
post content.
|
||||||
- `<source>`: where the post was grabbed form, supplied by the user.
|
|
||||||
- `<canvas-width>` and `<canvas-height>`: the original width and height of the
|
- `<canvas-width>` and `<canvas-height>`: the original width and height of the
|
||||||
post content.
|
post content.
|
||||||
|
- `<content-url>`: where the post content is located.
|
||||||
|
- `<thumbnail-url>`: where the post thumbnail is located.
|
||||||
- `<flags>`: various flags such as whether the post is looped, represented as
|
- `<flags>`: various flags such as whether the post is looped, represented as
|
||||||
array of plain strings.
|
array of plain strings.
|
||||||
- `<tags>`: list of tag names the post is tagged with.
|
- `<tags>`: list of tag names the post is tagged with.
|
||||||
- `<relations>`: a list of related post IDs. Links to related posts are shown
|
- `<relations>`: a list of related post IDs. Links to related posts are shown
|
||||||
to the user by the web client.
|
to the user by the web client.
|
||||||
- `<creation-time>`: time the tag was created, formatted as per RFC 3339.
|
- `<notes>`: a list of post annotations, serialized as list of [note
|
||||||
- `<last-edit-time>`: time the tag was edited, formatted as per RFC 3339.
|
resources](#note).
|
||||||
- `<user>`: who created the post, serialized as [user resource](#user).
|
- `<user>`: who created the post, serialized as [user resource](#user).
|
||||||
- `<score>`: the collective score (+1/-1 rating) of the given post.
|
- `<score>`: the collective score (+1/-1 rating) of the given post.
|
||||||
- `<own-score>`: the score (+1/-1 rating) of the given post by the
|
- `<own-score>`: the score (+1/-1 rating) of the given post by the
|
||||||
authenticated user.
|
authenticated user.
|
||||||
- `<favorited-by>`: list of users, serialized as [user resources](#user).
|
|
||||||
- `<feature-count>`: how many times has the post been featured.
|
- `<feature-count>`: how many times has the post been featured.
|
||||||
- `<last-feature-time>`: the last time the post was featured, formatted as per
|
- `<last-feature-time>`: the last time the post was featured, formatted as per
|
||||||
RFC 3339.
|
RFC 3339.
|
||||||
|
- `<favorited-by>`: list of users, serialized as [user resources](#user).
|
||||||
|
|
||||||
## Detailed post
|
## Detailed post
|
||||||
**Description**
|
**Description**
|
||||||
|
@ -1416,6 +1433,27 @@ A post with extra information.
|
||||||
earlier versions.
|
earlier versions.
|
||||||
- `<comment>`: a [comment resource](#comment) for given post.
|
- `<comment>`: a [comment resource](#comment) for given post.
|
||||||
|
|
||||||
|
## Note
|
||||||
|
**Description**
|
||||||
|
|
||||||
|
A text annotation rendered on top of the post.
|
||||||
|
|
||||||
|
**Structure**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"polygon": <list-of-points>,
|
||||||
|
"text": <text>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field meaning**
|
||||||
|
- `<list-of-points>`: where to draw the annotation. Each point must have
|
||||||
|
coordinates within 0 to 1. For example, `[[0,0],[0,1],[1,1],[1,0]]` will draw
|
||||||
|
the annotation on the whole post, whereas `[[0,0],[0,0.5],[0.5,0.5],[0.5,0]]`
|
||||||
|
will draw it inside the post's upper left quarter.
|
||||||
|
- `<text>`: the annotation text. The client should render is as Markdown.
|
||||||
|
|
||||||
## Comment
|
## Comment
|
||||||
**Description**
|
**Description**
|
||||||
|
|
||||||
|
@ -1439,6 +1477,7 @@ A comment under a post.
|
||||||
**Field meaning**
|
**Field meaning**
|
||||||
- `<id>`: the comment identifier.
|
- `<id>`: the comment identifier.
|
||||||
- `<post>`: a post resource the post is linked with.
|
- `<post>`: a post resource the post is linked with.
|
||||||
|
- `<text>`: the comment content. The client should render is as Markdown.
|
||||||
- `<author>`: a user resource the post is created by.
|
- `<author>`: a user resource the post is created by.
|
||||||
- `<creation-time>`: time the comment was created, formatted as per RFC 3339.
|
- `<creation-time>`: time the comment was created, formatted as per RFC 3339.
|
||||||
- `<last-edit-time>`: time the comment was edited, formatted as per RFC 3339.
|
- `<last-edit-time>`: time the comment was edited, formatted as per RFC 3339.
|
||||||
|
@ -1542,7 +1581,7 @@ A snapshot is a version of a database resource.
|
||||||
"checksum": "deadbeef",
|
"checksum": "deadbeef",
|
||||||
"tags": ["tag1", "tag2"],
|
"tags": ["tag1", "tag2"],
|
||||||
"relations": [1, 2],
|
"relations": [1, 2],
|
||||||
"notes": [{"polygon": [[1,1],[200,1],[200,200],[1,200]], "text": "..."}],
|
"notes": [<note1>, <note2>, <note3>],
|
||||||
"flags": ["loop"],
|
"flags": ["loop"],
|
||||||
"featured": false
|
"featured": false
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ from szurubooru.api.comment_api import (
|
||||||
CommentDetailApi,
|
CommentDetailApi,
|
||||||
CommentScoreApi)
|
CommentScoreApi)
|
||||||
from szurubooru.api.post_api import (
|
from szurubooru.api.post_api import (
|
||||||
|
PostListApi,
|
||||||
PostDetailApi,
|
PostDetailApi,
|
||||||
PostFeatureApi,
|
PostFeatureApi,
|
||||||
PostScoreApi,
|
PostScoreApi,
|
||||||
|
|
|
@ -12,8 +12,16 @@ class Context(object):
|
||||||
def has_param(self, name):
|
def has_param(self, name):
|
||||||
return name in self.input
|
return name in self.input
|
||||||
|
|
||||||
def get_file(self, name):
|
def has_file(self, name):
|
||||||
return self.files.get(name, None)
|
return name in self.files
|
||||||
|
|
||||||
|
def get_file(self, name, required=False):
|
||||||
|
if name in self.files:
|
||||||
|
return self.files[name]
|
||||||
|
if not required:
|
||||||
|
return None
|
||||||
|
raise errors.MissingRequiredFileError(
|
||||||
|
'Required file %r is missing.' % name)
|
||||||
|
|
||||||
def get_param_as_list(self, name, required=False, default=None):
|
def get_param_as_list(self, name, required=False, default=None):
|
||||||
if name in self.input:
|
if name in self.input:
|
||||||
|
@ -23,7 +31,8 @@ class Context(object):
|
||||||
return param
|
return param
|
||||||
if not required:
|
if not required:
|
||||||
return default
|
return default
|
||||||
raise errors.ValidationError('Required paramter %r is missing.' % name)
|
raise errors.MissingRequiredParameterError(
|
||||||
|
'Required paramter %r is missing.' % name)
|
||||||
|
|
||||||
def get_param_as_string(self, name, required=False, default=None):
|
def get_param_as_string(self, name, required=False, default=None):
|
||||||
if name in self.input:
|
if name in self.input:
|
||||||
|
@ -32,12 +41,14 @@ class Context(object):
|
||||||
try:
|
try:
|
||||||
param = ','.join(param)
|
param = ','.join(param)
|
||||||
except:
|
except:
|
||||||
raise errors.ValidationError(
|
raise errors.InvalidParameterError(
|
||||||
'Parameter %r is invalid - expected simple string.' % name)
|
'Parameter %r is invalid - expected simple string.'
|
||||||
|
% name)
|
||||||
return param
|
return param
|
||||||
if not required:
|
if not required:
|
||||||
return default
|
return default
|
||||||
raise errors.ValidationError('Required paramter %r is missing.' % name)
|
raise errors.MissingRequiredParameterError(
|
||||||
|
'Required paramter %r is missing.' % name)
|
||||||
|
|
||||||
# pylint: disable=redefined-builtin,too-many-arguments
|
# pylint: disable=redefined-builtin,too-many-arguments
|
||||||
def get_param_as_int(
|
def get_param_as_int(
|
||||||
|
@ -47,21 +58,21 @@ class Context(object):
|
||||||
try:
|
try:
|
||||||
val = int(val)
|
val = int(val)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
raise errors.ValidationError(
|
raise errors.InvalidParameterError(
|
||||||
'Parameter %r is invalid: the value must be an integer.'
|
'Parameter %r is invalid: the value must be an integer.'
|
||||||
% name)
|
% name)
|
||||||
if min is not None and val < min:
|
if min is not None and val < min:
|
||||||
raise errors.ValidationError(
|
raise errors.InvalidParameterError(
|
||||||
'Parameter %r is invalid: the value must be at least %r.'
|
'Parameter %r is invalid: the value must be at least %r.'
|
||||||
% (name, min))
|
% (name, min))
|
||||||
if max is not None and val > max:
|
if max is not None and val > max:
|
||||||
raise errors.ValidationError(
|
raise errors.InvalidParameterError(
|
||||||
'Parameter %r is invalid: the value may not exceed %r.'
|
'Parameter %r is invalid: the value may not exceed %r.'
|
||||||
% (name, max))
|
% (name, max))
|
||||||
return val
|
return val
|
||||||
if not required:
|
if not required:
|
||||||
return default
|
return default
|
||||||
raise errors.ValidationError(
|
raise errors.MissingRequiredParameterError(
|
||||||
'Required parameter %r is missing.' % name)
|
'Required parameter %r is missing.' % name)
|
||||||
|
|
||||||
class Request(falcon.Request):
|
class Request(falcon.Request):
|
||||||
|
|
|
@ -1,6 +1,29 @@
|
||||||
from szurubooru.api.base_api import BaseApi
|
from szurubooru.api.base_api import BaseApi
|
||||||
from szurubooru.func import auth, tags, posts, snapshots, favorites, scores
|
from szurubooru.func import auth, tags, posts, snapshots, favorites, scores
|
||||||
|
|
||||||
|
class PostListApi(BaseApi):
|
||||||
|
def post(self, ctx):
|
||||||
|
auth.verify_privilege(ctx.user, 'posts:create')
|
||||||
|
content = ctx.get_file('content', required=True)
|
||||||
|
tag_names = ctx.get_param_as_list('tags', required=True)
|
||||||
|
safety = ctx.get_param_as_string('safety', required=True)
|
||||||
|
source = ctx.get_param_as_string('source', required=False, default=None)
|
||||||
|
relations = ctx.get_param_as_list('relations', required=False) or []
|
||||||
|
notes = ctx.get_param_as_list('notes', required=False) or []
|
||||||
|
flags = ctx.get_param_as_list('flags', required=False) or []
|
||||||
|
|
||||||
|
post = posts.create_post(content, tag_names, ctx.user)
|
||||||
|
posts.update_post_safety(post, safety)
|
||||||
|
posts.update_post_source(post, source)
|
||||||
|
posts.update_post_relations(post, relations)
|
||||||
|
posts.update_post_notes(post, notes)
|
||||||
|
posts.update_post_flags(post, flags)
|
||||||
|
ctx.session.add(post)
|
||||||
|
snapshots.save_entity_creation(post, ctx.user)
|
||||||
|
ctx.session.commit()
|
||||||
|
tags.export_to_json()
|
||||||
|
return posts.serialize_post_with_details(post, ctx.user)
|
||||||
|
|
||||||
class PostDetailApi(BaseApi):
|
class PostDetailApi(BaseApi):
|
||||||
def get(self, ctx, post_id):
|
def get(self, ctx, post_id):
|
||||||
auth.verify_privilege(ctx.user, 'posts:view')
|
auth.verify_privilege(ctx.user, 'posts:view')
|
||||||
|
|
|
@ -65,6 +65,7 @@ def create_app():
|
||||||
app.add_route('/tag-merge/', api.TagMergeApi())
|
app.add_route('/tag-merge/', api.TagMergeApi())
|
||||||
app.add_route('/tag-siblings/{tag_name}', api.TagSiblingsApi())
|
app.add_route('/tag-siblings/{tag_name}', api.TagSiblingsApi())
|
||||||
|
|
||||||
|
app.add_route('/posts/', api.PostListApi())
|
||||||
app.add_route('/post/{post_id}', api.PostDetailApi())
|
app.add_route('/post/{post_id}', api.PostDetailApi())
|
||||||
app.add_route('/post/{post_id}/score', api.PostScoreApi())
|
app.add_route('/post/{post_id}/score', api.PostScoreApi())
|
||||||
app.add_route('/post/{post_id}/favorite', api.PostFavoriteApi())
|
app.add_route('/post/{post_id}/favorite', api.PostFavoriteApi())
|
||||||
|
|
|
@ -71,26 +71,29 @@ class Post(Base):
|
||||||
SAFETY_SAFE = 'safe'
|
SAFETY_SAFE = 'safe'
|
||||||
SAFETY_SKETCHY = 'sketchy'
|
SAFETY_SKETCHY = 'sketchy'
|
||||||
SAFETY_UNSAFE = 'unsafe'
|
SAFETY_UNSAFE = 'unsafe'
|
||||||
TYPE_IMAGE = 'anim'
|
TYPE_IMAGE = 'image'
|
||||||
TYPE_ANIMATION = 'anim'
|
TYPE_ANIMATION = 'animation'
|
||||||
TYPE_FLASH = 'flash'
|
|
||||||
TYPE_VIDEO = 'video'
|
TYPE_VIDEO = 'video'
|
||||||
TYPE_YOUTUBE = 'youtube'
|
TYPE_FLASH = 'flash'
|
||||||
FLAG_LOOP_VIDEO = 1
|
|
||||||
|
|
||||||
|
# basic meta
|
||||||
post_id = Column('id', Integer, primary_key=True)
|
post_id = Column('id', Integer, primary_key=True)
|
||||||
user_id = Column('user_id', Integer, ForeignKey('user.id'))
|
user_id = Column('user_id', Integer, ForeignKey('user.id'))
|
||||||
creation_time = Column('creation_time', DateTime, nullable=False)
|
creation_time = Column('creation_time', DateTime, nullable=False)
|
||||||
last_edit_time = Column('last_edit_time', DateTime)
|
last_edit_time = Column('last_edit_time', DateTime)
|
||||||
safety = Column('safety', String(32), nullable=False)
|
safety = Column('safety', String(32), nullable=False)
|
||||||
|
source = Column('source', String(200))
|
||||||
|
flags = Column('flags', PickleType, default=None)
|
||||||
|
|
||||||
|
# content description
|
||||||
type = Column('type', String(32), nullable=False)
|
type = Column('type', String(32), nullable=False)
|
||||||
checksum = Column('checksum', String(64), nullable=False)
|
checksum = Column('checksum', String(64), nullable=False)
|
||||||
source = Column('source', String(200))
|
|
||||||
file_size = Column('file_size', Integer)
|
file_size = Column('file_size', Integer)
|
||||||
canvas_width = Column('image_width', Integer)
|
canvas_width = Column('image_width', Integer)
|
||||||
canvas_height = Column('image_height', Integer)
|
canvas_height = Column('image_height', Integer)
|
||||||
flags = Column('flags', PickleType, default=None)
|
mime_type = Column('mime-type', String(32), nullable=False)
|
||||||
|
|
||||||
|
# foreign tables
|
||||||
user = relationship('User')
|
user = relationship('User')
|
||||||
tags = relationship('Tag', backref='posts', secondary='post_tag')
|
tags = relationship('Tag', backref='posts', secondary='post_tag')
|
||||||
relations = relationship(
|
relations = relationship(
|
||||||
|
@ -106,8 +109,9 @@ class Post(Base):
|
||||||
'PostFavorite', cascade='all, delete-orphan', lazy='joined')
|
'PostFavorite', cascade='all, delete-orphan', lazy='joined')
|
||||||
notes = relationship(
|
notes = relationship(
|
||||||
'PostNote', cascade='all, delete-orphan', lazy='joined')
|
'PostNote', cascade='all, delete-orphan', lazy='joined')
|
||||||
|
|
||||||
comments = relationship('Comment')
|
comments = relationship('Comment')
|
||||||
|
|
||||||
|
# dynamic columns
|
||||||
tag_count = column_property(
|
tag_count = column_property(
|
||||||
select([func.count(PostTag.tag_id)]) \
|
select([func.count(PostTag.tag_id)]) \
|
||||||
.where(PostTag.post_id == post_id) \
|
.where(PostTag.post_id == post_id) \
|
||||||
|
|
|
@ -5,3 +5,7 @@ class ValidationError(RuntimeError): pass
|
||||||
class SearchError(RuntimeError): pass
|
class SearchError(RuntimeError): pass
|
||||||
class NotFoundError(RuntimeError): pass
|
class NotFoundError(RuntimeError): pass
|
||||||
class ProcessingError(RuntimeError): pass
|
class ProcessingError(RuntimeError): pass
|
||||||
|
|
||||||
|
class MissingRequiredFileError(ValidationError): pass
|
||||||
|
class MissingRequiredParameterError(ValidationError): pass
|
||||||
|
class InvalidParameterError(ValidationError): pass
|
||||||
|
|
|
@ -1,8 +1,23 @@
|
||||||
import os
|
import os
|
||||||
from szurubooru import config
|
from szurubooru import config
|
||||||
|
|
||||||
|
def _get_full_path(path):
|
||||||
|
return os.path.join(config.config['data_dir'], path)
|
||||||
|
|
||||||
|
def delete(path):
|
||||||
|
full_path = _get_full_path(path)
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
os.unlink(full_path)
|
||||||
|
|
||||||
|
def get(path):
|
||||||
|
full_path = _get_full_path(path)
|
||||||
|
if not os.path.exists(full_path):
|
||||||
|
return None
|
||||||
|
with open(full_path, 'rb') as handle:
|
||||||
|
return handle.read()
|
||||||
|
|
||||||
def save(path, content):
|
def save(path, content):
|
||||||
full_path = os.path.join(config.config['data_dir'], path)
|
full_path = _get_full_path(path)
|
||||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||||
with open(full_path, 'wb') as handle:
|
with open(full_path, 'wb') as handle:
|
||||||
handle.write(content)
|
handle.write(content)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
from szurubooru import errors
|
from szurubooru import errors
|
||||||
|
|
||||||
|
@ -7,6 +8,19 @@ _SCALE_FIT_FMT = \
|
||||||
class Image(object):
|
class Image(object):
|
||||||
def __init__(self, content):
|
def __init__(self, content):
|
||||||
self.content = content
|
self.content = content
|
||||||
|
self._reload_info()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def width(self):
|
||||||
|
return self.info['streams'][0]['width']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def height(self):
|
||||||
|
return self.info['streams'][0]['height']
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frames(self):
|
||||||
|
return self.info['streams'][0]['nb_read_frames']
|
||||||
|
|
||||||
def resize_fill(self, width, height):
|
def resize_fill(self, width, height):
|
||||||
self.content = self._execute([
|
self.content = self._execute([
|
||||||
|
@ -17,6 +31,7 @@ class Image(object):
|
||||||
'-vcodec', 'png',
|
'-vcodec', 'png',
|
||||||
'-',
|
'-',
|
||||||
])
|
])
|
||||||
|
self._reload_info()
|
||||||
|
|
||||||
def to_png(self):
|
def to_png(self):
|
||||||
return self._execute([
|
return self._execute([
|
||||||
|
@ -36,9 +51,9 @@ class Image(object):
|
||||||
'-',
|
'-',
|
||||||
])
|
])
|
||||||
|
|
||||||
def _execute(self, cli):
|
def _execute(self, cli, program='ffmpeg'):
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
['ffmpeg', '-loglevel', '24'] + cli,
|
[program, '-loglevel', '24'] + cli,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE)
|
stderr=subprocess.PIPE)
|
||||||
|
@ -47,3 +62,15 @@ class Image(object):
|
||||||
raise errors.ProcessingError(
|
raise errors.ProcessingError(
|
||||||
'Error while processing image.\n' + err.decode('utf-8'))
|
'Error while processing image.\n' + err.decode('utf-8'))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def _reload_info(self):
|
||||||
|
self.info = json.loads(self._execute([
|
||||||
|
'-of', 'json',
|
||||||
|
'-select_streams', 'v',
|
||||||
|
'-show_streams',
|
||||||
|
'-count_frames',
|
||||||
|
'-i', '-',
|
||||||
|
], program='ffprobe').decode('utf-8'))
|
||||||
|
assert 'streams' in self.info
|
||||||
|
if len(self.info['streams']) != 1:
|
||||||
|
raise errors.ProcessingError('Multiple video streams detected.')
|
||||||
|
|
|
@ -2,7 +2,7 @@ import re
|
||||||
|
|
||||||
def get_mime_type(content):
|
def get_mime_type(content):
|
||||||
if not content:
|
if not content:
|
||||||
return None
|
return 'application/octet-stream'
|
||||||
|
|
||||||
if content[0:3] in (b'CWS', b'FWS', b'ZWS'):
|
if content[0:3] in (b'CWS', b'FWS', b'ZWS'):
|
||||||
return 'application/x-shockwave-flash'
|
return 'application/x-shockwave-flash'
|
||||||
|
@ -33,7 +33,7 @@ def get_extension(mime_type):
|
||||||
'video/mp4': 'mp4',
|
'video/mp4': 'mp4',
|
||||||
'video/webm': 'webm',
|
'video/webm': 'webm',
|
||||||
}
|
}
|
||||||
return extension_map.get(mime_type.strip().lower(), None)
|
return extension_map.get((mime_type or '').strip().lower(), None)
|
||||||
|
|
||||||
def is_flash(mime_type):
|
def is_flash(mime_type):
|
||||||
return mime_type.lower() == 'application/x-shockwave-flash'
|
return mime_type.lower() == 'application/x-shockwave-flash'
|
||||||
|
|
|
@ -1,10 +1,62 @@
|
||||||
import datetime
|
import datetime
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from szurubooru import db, errors
|
from szurubooru import config, db, errors
|
||||||
from szurubooru.func import users, snapshots, scores, comments
|
from szurubooru.func import (
|
||||||
|
users, snapshots, scores, comments, tags, util, mime, images, files)
|
||||||
|
|
||||||
|
EMPTY_PIXEL = \
|
||||||
|
b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00' \
|
||||||
|
b'\xff\xff\xff\x21\xf9\x04\x01\x00\x00\x01\x00\x2c\x00\x00\x00\x00' \
|
||||||
|
b'\x01\x00\x01\x00\x00\x02\x02\x4c\x01\x00\x3b'
|
||||||
|
|
||||||
class PostNotFoundError(errors.NotFoundError): pass
|
class PostNotFoundError(errors.NotFoundError): pass
|
||||||
class PostAlreadyFeaturedError(errors.ValidationError): pass
|
class PostAlreadyFeaturedError(errors.ValidationError): pass
|
||||||
|
class PostAlreadyUploadedError(errors.ValidationError): pass
|
||||||
|
class InvalidPostSafetyError(errors.ValidationError): pass
|
||||||
|
class InvalidPostSourceError(errors.ValidationError): pass
|
||||||
|
class InvalidPostContentError(errors.ValidationError): pass
|
||||||
|
class InvalidPostRelationError(errors.ValidationError): pass
|
||||||
|
class InvalidPostNoteError(errors.ValidationError): pass
|
||||||
|
class InvalidPostFlagError(errors.ValidationError): pass
|
||||||
|
|
||||||
|
SAFETY_MAP = {
|
||||||
|
db.Post.SAFETY_SAFE: 'safe',
|
||||||
|
db.Post.SAFETY_SKETCHY: 'sketchy',
|
||||||
|
db.Post.SAFETY_UNSAFE: 'unsafe',
|
||||||
|
}
|
||||||
|
TYPE_MAP = {
|
||||||
|
db.Post.TYPE_IMAGE: 'image',
|
||||||
|
db.Post.TYPE_ANIMATION: 'animation',
|
||||||
|
db.Post.TYPE_VIDEO: 'video',
|
||||||
|
db.Post.TYPE_FLASH: 'flash',
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_post_content_url(post):
|
||||||
|
return '%s/posts/%d.%s' % (
|
||||||
|
config.config['data_url'].rstrip('/'),
|
||||||
|
post.post_id,
|
||||||
|
mime.get_extension(post.mime_type) or 'dat')
|
||||||
|
|
||||||
|
def get_post_thumbnail_url(post):
|
||||||
|
return '%s/generated-thumbnails/%d.jpg' % (
|
||||||
|
config.config['data_url'].rstrip('/'),
|
||||||
|
post.post_id)
|
||||||
|
|
||||||
|
def get_post_content_path(post):
|
||||||
|
return 'posts/%d.%s' % (
|
||||||
|
post.post_id, mime.get_extension(post.mime_type) or 'dat')
|
||||||
|
|
||||||
|
def get_post_thumbnail_path(post):
|
||||||
|
return 'generated-thumbnails/%d.jpg' % (post.post_id)
|
||||||
|
|
||||||
|
def get_post_thumbnail_backup_path(post):
|
||||||
|
return 'posts/custom-thumbnails/%d.dat' % (post.post_id)
|
||||||
|
|
||||||
|
def serialize_note(note):
|
||||||
|
return {
|
||||||
|
'polygon': note.path,
|
||||||
|
'text': note.text,
|
||||||
|
}
|
||||||
|
|
||||||
def serialize_post(post, authenticated_user):
|
def serialize_post(post, authenticated_user):
|
||||||
if not post:
|
if not post:
|
||||||
|
@ -14,20 +66,19 @@ def serialize_post(post, authenticated_user):
|
||||||
'id': post.post_id,
|
'id': post.post_id,
|
||||||
'creationTime': post.creation_time,
|
'creationTime': post.creation_time,
|
||||||
'lastEditTime': post.last_edit_time,
|
'lastEditTime': post.last_edit_time,
|
||||||
'safety': post.safety,
|
'safety': SAFETY_MAP[post.safety],
|
||||||
'type': post.type,
|
|
||||||
'checksum': post.checksum,
|
|
||||||
'source': post.source,
|
'source': post.source,
|
||||||
|
'type': TYPE_MAP[post.type],
|
||||||
|
'checksum': post.checksum,
|
||||||
'fileSize': post.file_size,
|
'fileSize': post.file_size,
|
||||||
'canvasWidth': post.canvas_width,
|
'canvasWidth': post.canvas_width,
|
||||||
'canvasHeight': post.canvas_height,
|
'canvasHeight': post.canvas_height,
|
||||||
|
'contentUrl': get_post_content_url(post),
|
||||||
|
'thumbnailUrl': get_post_thumbnail_url(post),
|
||||||
'flags': post.flags,
|
'flags': post.flags,
|
||||||
'tags': [tag.first_name for tag in post.tags],
|
'tags': [tag.first_name for tag in post.tags],
|
||||||
'relations': [rel.post_id for rel in post.relations],
|
'relations': [rel.post_id for rel in post.relations],
|
||||||
'notes': sorted([{
|
'notes': sorted(serialize_note(note) for note in post.notes),
|
||||||
'path': note.path,
|
|
||||||
'text': note.text,
|
|
||||||
} for note in post.notes]),
|
|
||||||
'user': users.serialize_user(post.user, authenticated_user),
|
'user': users.serialize_user(post.user, authenticated_user),
|
||||||
'score': post.score,
|
'score': post.score,
|
||||||
'featureCount': post.feature_count,
|
'featureCount': post.feature_count,
|
||||||
|
@ -75,6 +126,140 @@ def try_get_featured_post():
|
||||||
.first()
|
.first()
|
||||||
return post_feature.post if post_feature else None
|
return post_feature.post if post_feature else None
|
||||||
|
|
||||||
|
def create_post(content, tag_names, user):
|
||||||
|
post = db.Post()
|
||||||
|
post.safety = db.Post.SAFETY_SAFE
|
||||||
|
post.user = user
|
||||||
|
post.creation_time = datetime.datetime.now()
|
||||||
|
post.flags = []
|
||||||
|
|
||||||
|
# we'll need post ID
|
||||||
|
post.type = ''
|
||||||
|
post.checksum = ''
|
||||||
|
post.mime_type = ''
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
update_post_content(post, content)
|
||||||
|
update_post_tags(post, tag_names)
|
||||||
|
return post
|
||||||
|
|
||||||
|
def update_post_safety(post, safety):
|
||||||
|
safety = util.flip(SAFETY_MAP).get(safety, None)
|
||||||
|
if not safety:
|
||||||
|
raise InvalidPostSafetyError(
|
||||||
|
'Safety can be either of %r.', list(SAFETY_MAP.values()))
|
||||||
|
post.safety = safety
|
||||||
|
|
||||||
|
def update_post_source(post, source):
|
||||||
|
if util.value_exceeds_column_size(source, db.Post.source):
|
||||||
|
raise InvalidPostSourceError('Source is too long.')
|
||||||
|
post.source = source
|
||||||
|
|
||||||
|
def update_post_content(post, content):
|
||||||
|
if not content:
|
||||||
|
raise InvalidPostContentError('Post content missing.')
|
||||||
|
post.mime_type = mime.get_mime_type(content)
|
||||||
|
if mime.is_flash(post.mime_type):
|
||||||
|
post.type = db.Post.TYPE_FLASH
|
||||||
|
elif mime.is_image(post.mime_type):
|
||||||
|
if mime.is_animated_gif(content):
|
||||||
|
post.type = db.Post.TYPE_ANIMATION
|
||||||
|
else:
|
||||||
|
post.type = db.Post.TYPE_IMAGE
|
||||||
|
elif mime.is_video(post.mime_type):
|
||||||
|
post.type = db.Post.TYPE_VIDEO
|
||||||
|
else:
|
||||||
|
raise InvalidPostContentError('Unhandled file type: %r' % post.mime_type)
|
||||||
|
|
||||||
|
post.checksum = util.get_md5(content)
|
||||||
|
other_post = db.session \
|
||||||
|
.query(db.Post) \
|
||||||
|
.filter(db.Post.checksum == post.checksum) \
|
||||||
|
.filter(db.Post.post_id != post.post_id) \
|
||||||
|
.one_or_none()
|
||||||
|
if other_post:
|
||||||
|
raise PostAlreadyUploadedError(
|
||||||
|
'Post already uploaded (%d)' % other_post.post_id)
|
||||||
|
|
||||||
|
post.file_size = len(content)
|
||||||
|
try:
|
||||||
|
image = images.Image(content)
|
||||||
|
post.canvas_width = image.width
|
||||||
|
post.canvas_height = image.height
|
||||||
|
except errors.ProcessingError:
|
||||||
|
post.canvas_width = None
|
||||||
|
post.canvas_height = None
|
||||||
|
files.save(get_post_content_path(post), content)
|
||||||
|
update_post_thumbnail(post, content=None, delete=False)
|
||||||
|
|
||||||
|
def update_post_thumbnail(post, content=None, delete=True):
|
||||||
|
if content is None:
|
||||||
|
content = files.get(get_post_content_path(post))
|
||||||
|
if delete:
|
||||||
|
files.delete(get_post_thumbnail_backup_path(post))
|
||||||
|
else:
|
||||||
|
files.save(get_post_thumbnail_backup_path(post), content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
image = images.Image(content)
|
||||||
|
image.resize_fill(
|
||||||
|
int(config.config['thumbnails']['post_width']),
|
||||||
|
int(config.config['thumbnails']['post_height']))
|
||||||
|
files.save(get_post_thumbnail_path(post), image.to_jpeg())
|
||||||
|
except errors.ProcessingError:
|
||||||
|
files.save(get_post_thumbnail_path(post), EMPTY_PIXEL)
|
||||||
|
|
||||||
|
def update_post_tags(post, tag_names):
|
||||||
|
existing_tags, new_tags = tags.get_or_create_tags_by_names(tag_names)
|
||||||
|
post.tags = existing_tags + new_tags
|
||||||
|
|
||||||
|
def update_post_relations(post, post_ids):
|
||||||
|
relations = db.session \
|
||||||
|
.query(db.Post) \
|
||||||
|
.filter(db.Post.post_id.in_(post_ids)) \
|
||||||
|
.all()
|
||||||
|
if len(relations) != len(post_ids):
|
||||||
|
raise InvalidPostRelationError('One of relations does not exist.')
|
||||||
|
post.relations = relations
|
||||||
|
|
||||||
|
def update_post_notes(post, notes):
|
||||||
|
post.notes = []
|
||||||
|
for note in notes:
|
||||||
|
for field in ('polygon', 'text'):
|
||||||
|
if field not in note:
|
||||||
|
raise InvalidPostNoteError('Note is missing %r field.' % field)
|
||||||
|
if not note['text']:
|
||||||
|
raise InvalidPostNoteError('A note\'s text cannot be empty.')
|
||||||
|
if len(note['polygon']) < 3:
|
||||||
|
raise InvalidPostNoteError(
|
||||||
|
'A note\'s polygon must have at least 3 points.')
|
||||||
|
for point in note['polygon']:
|
||||||
|
if len(point) != 2:
|
||||||
|
raise InvalidPostNoteError(
|
||||||
|
'A point in note\'s polygon must have two coordinates.')
|
||||||
|
try:
|
||||||
|
pos_x = float(point[0])
|
||||||
|
pos_y = float(point[1])
|
||||||
|
if not 0 <= pos_x <= 1 or not 0 <= pos_y <= 1:
|
||||||
|
raise InvalidPostNoteError(
|
||||||
|
'A point in note\'s polygon must be in 0..1 range.')
|
||||||
|
except ValueError:
|
||||||
|
raise InvalidPostNoteError(
|
||||||
|
'A point in note\'s polygon must be numeric.')
|
||||||
|
if util.value_exceeds_column_size(note['text'], db.PostNote.text):
|
||||||
|
raise InvalidPostNoteError('Note text is too long.')
|
||||||
|
post.notes.append(
|
||||||
|
db.PostNote(polygon=note['polygon'], text=note['text']))
|
||||||
|
|
||||||
|
def update_post_flags(post, flags):
|
||||||
|
available_flags = ('loop',)
|
||||||
|
for flag in flags:
|
||||||
|
if flag not in available_flags:
|
||||||
|
raise InvalidPostFlagError(
|
||||||
|
'Flag must be one of %r.' % available_flags)
|
||||||
|
post.flags = flags
|
||||||
|
|
||||||
def feature_post(post, user):
|
def feature_post(post, user):
|
||||||
post_feature = db.PostFeature()
|
post_feature = db.PostFeature()
|
||||||
post_feature.time = datetime.datetime.now()
|
post_feature.time = datetime.datetime.now()
|
||||||
|
|
|
@ -77,7 +77,7 @@ def try_get_default_category():
|
||||||
.query(db.TagCategory) \
|
.query(db.TagCategory) \
|
||||||
.order_by(db.TagCategory.tag_category_id.asc()) \
|
.order_by(db.TagCategory.tag_category_id.asc()) \
|
||||||
.limit(1) \
|
.limit(1) \
|
||||||
.one()
|
.first()
|
||||||
|
|
||||||
def get_default_category():
|
def get_default_category():
|
||||||
category = try_get_default_category()
|
category = try_get_default_category()
|
||||||
|
|
|
@ -12,6 +12,9 @@ class TagIsInUseError(errors.ValidationError): pass
|
||||||
class InvalidTagNameError(errors.ValidationError): pass
|
class InvalidTagNameError(errors.ValidationError): pass
|
||||||
class InvalidTagRelationError(errors.ValidationError): pass
|
class InvalidTagRelationError(errors.ValidationError): pass
|
||||||
|
|
||||||
|
DEFAULT_CATEGORY_NAME = 'Default'
|
||||||
|
DEFAULT_CATEGORY_COLOR = 'default'
|
||||||
|
|
||||||
def _verify_name_validity(name):
|
def _verify_name_validity(name):
|
||||||
name_regex = config.config['tag_name_regex']
|
name_regex = config.config['tag_name_regex']
|
||||||
if not re.match(name_regex, name):
|
if not re.match(name_regex, name):
|
||||||
|
@ -26,6 +29,13 @@ def _lower_list(names):
|
||||||
def _check_name_intersection(names1, names2):
|
def _check_name_intersection(names1, names2):
|
||||||
return len(set(_lower_list(names1)).intersection(_lower_list(names2))) > 0
|
return len(set(_lower_list(names1)).intersection(_lower_list(names2))) > 0
|
||||||
|
|
||||||
|
def _get_default_category_name():
|
||||||
|
tag_category = tag_categories.try_get_default_category()
|
||||||
|
if tag_category:
|
||||||
|
return tag_category.name
|
||||||
|
else:
|
||||||
|
return DEFAULT_CATEGORY_NAME
|
||||||
|
|
||||||
def serialize_tag(tag):
|
def serialize_tag(tag):
|
||||||
return {
|
return {
|
||||||
'names': [tag_name.name for tag_name in tag.names],
|
'names': [tag_name.name for tag_name in tag.names],
|
||||||
|
@ -104,23 +114,24 @@ def get_or_create_tags_by_names(names):
|
||||||
names = util.icase_unique(names)
|
names = util.icase_unique(names)
|
||||||
for name in names:
|
for name in names:
|
||||||
_verify_name_validity(name)
|
_verify_name_validity(name)
|
||||||
related_tags = get_tags_by_names(names)
|
existing_tags = get_tags_by_names(names)
|
||||||
new_tags = []
|
new_tags = []
|
||||||
|
tag_category_name = _get_default_category_name()
|
||||||
for name in names:
|
for name in names:
|
||||||
found = False
|
found = False
|
||||||
for related_tag in related_tags:
|
for existing_tag in existing_tags:
|
||||||
if _check_name_intersection(_get_plain_names(related_tag), [name]):
|
if _check_name_intersection(_get_plain_names(existing_tag), [name]):
|
||||||
found = True
|
found = True
|
||||||
break
|
break
|
||||||
if not found:
|
if not found:
|
||||||
new_tag = create_tag(
|
new_tag = create_tag(
|
||||||
names=[name],
|
names=[name],
|
||||||
category_name=tag_categories.get_default_category().name,
|
category_name=tag_category_name,
|
||||||
suggestions=[],
|
suggestions=[],
|
||||||
implications=[])
|
implications=[])
|
||||||
db.session.add(new_tag)
|
db.session.add(new_tag)
|
||||||
new_tags.append(new_tag)
|
new_tags.append(new_tag)
|
||||||
return related_tags, new_tags
|
return existing_tags, new_tags
|
||||||
|
|
||||||
def get_tag_siblings(tag):
|
def get_tag_siblings(tag):
|
||||||
tag_alias = sqlalchemy.orm.aliased(db.Tag)
|
tag_alias = sqlalchemy.orm.aliased(db.Tag)
|
||||||
|
@ -159,7 +170,8 @@ def update_tag_category_name(tag, category_name):
|
||||||
.filter(db.TagCategory.name == category_name) \
|
.filter(db.TagCategory.name == category_name) \
|
||||||
.first()
|
.first()
|
||||||
if not category:
|
if not category:
|
||||||
category = tag_categories.create_category(category_name, 'default')
|
category = tag_categories.create_category(
|
||||||
|
category_name, DEFAULT_CATEGORY_COLOR)
|
||||||
db.session.add(category)
|
db.session.add(category)
|
||||||
tag.category = category
|
tag.category = category
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import datetime
|
import datetime
|
||||||
import hashlib
|
|
||||||
import re
|
import re
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from szurubooru import config, db, errors
|
from szurubooru import config, db, errors
|
||||||
|
@ -28,11 +27,9 @@ def serialize_user(user, authenticated_user, force_show_email=False):
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.avatar_style == user.AVATAR_GRAVATAR:
|
if user.avatar_style == user.AVATAR_GRAVATAR:
|
||||||
md5 = hashlib.md5()
|
|
||||||
md5.update((user.email or user.name).lower().encode('utf-8'))
|
|
||||||
digest = md5.hexdigest()
|
|
||||||
ret['avatarUrl'] = 'http://gravatar.com/avatar/%s?d=retro&s=%d' % (
|
ret['avatarUrl'] = 'http://gravatar.com/avatar/%s?d=retro&s=%d' % (
|
||||||
digest, config.config['thumbnails']['avatar_width'])
|
util.get_md5((user.email or user.name).lower()),
|
||||||
|
config.config['thumbnails']['avatar_width'])
|
||||||
else:
|
else:
|
||||||
ret['avatarUrl'] = '%s/avatars/%s.jpg' % (
|
ret['avatarUrl'] = '%s/avatars/%s.jpg' % (
|
||||||
config.config['data_url'].rstrip('/'), user.name.lower())
|
config.config['data_url'].rstrip('/'), user.name.lower())
|
||||||
|
|
|
@ -1,8 +1,19 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
from sqlalchemy.inspection import inspect
|
from sqlalchemy.inspection import inspect
|
||||||
from szurubooru.errors import ValidationError
|
from szurubooru.errors import ValidationError
|
||||||
|
|
||||||
|
def get_md5(source):
|
||||||
|
if not isinstance(source, bytes):
|
||||||
|
source = source.encode('utf-8')
|
||||||
|
md5 = hashlib.md5()
|
||||||
|
md5.update(source)
|
||||||
|
return md5.hexdigest()
|
||||||
|
|
||||||
|
def flip(source):
|
||||||
|
return {v: k for k, v in source.items()}
|
||||||
|
|
||||||
def get_resource_info(entity):
|
def get_resource_info(entity):
|
||||||
serializers = {
|
serializers = {
|
||||||
'tag': lambda tag: tag.first_name,
|
'tag': lambda tag: tag.first_name,
|
||||||
|
@ -96,4 +107,9 @@ def icase_unique(source):
|
||||||
return target
|
return target
|
||||||
|
|
||||||
def value_exceeds_column_size(value, column):
|
def value_exceeds_column_size(value, column):
|
||||||
return len(value) > column.property.columns[0].type.length
|
if not value:
|
||||||
|
return False
|
||||||
|
max_length = column.property.columns[0].type.length
|
||||||
|
if max_length is None:
|
||||||
|
return False
|
||||||
|
return len(value) > max_length
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
'''
|
||||||
|
Add mime type to posts
|
||||||
|
|
||||||
|
Revision ID: 23abaf4a0a4b
|
||||||
|
Created at: 2016-05-02 00:02:33.024885
|
||||||
|
'''
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '23abaf4a0a4b'
|
||||||
|
down_revision = 'ed6dd16a30f3'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('post', sa.Column('mime-type', sa.String(length=32), nullable=False))
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('post', 'mime-type')
|
|
@ -6,6 +6,7 @@ from szurubooru.func import util, posts
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_ctx(config_injector, context_factory, post_factory, user_factory):
|
def test_ctx(config_injector, context_factory, post_factory, user_factory):
|
||||||
config_injector({
|
config_injector({
|
||||||
|
'data_url': 'http://example.com',
|
||||||
'ranks': ['anonymous', 'regular_user'],
|
'ranks': ['anonymous', 'regular_user'],
|
||||||
'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'},
|
'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'},
|
||||||
'privileges': {'comments:create': 'regular_user'},
|
'privileges': {'comments:create': 'regular_user'},
|
||||||
|
|
|
@ -6,6 +6,7 @@ from szurubooru.func import util, comments, scores
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_ctx(config_injector, context_factory, user_factory, comment_factory):
|
def test_ctx(config_injector, context_factory, user_factory, comment_factory):
|
||||||
config_injector({
|
config_injector({
|
||||||
|
'data_url': 'http://example.com',
|
||||||
'ranks': ['anonymous', 'regular_user', 'mod'],
|
'ranks': ['anonymous', 'regular_user', 'mod'],
|
||||||
'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'},
|
'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'},
|
||||||
'privileges': {
|
'privileges': {
|
||||||
|
|
|
@ -6,6 +6,7 @@ from szurubooru.func import util, comments
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_ctx(context_factory, config_injector, user_factory, comment_factory):
|
def test_ctx(context_factory, config_injector, user_factory, comment_factory):
|
||||||
config_injector({
|
config_injector({
|
||||||
|
'data_url': 'http://example.com',
|
||||||
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
|
'ranks': ['anonymous', 'regular_user', 'mod', 'admin'],
|
||||||
'rank_names': {'regular_user': 'Peasant'},
|
'rank_names': {'regular_user': 'Peasant'},
|
||||||
'privileges': {
|
'privileges': {
|
||||||
|
|
|
@ -6,6 +6,7 @@ from szurubooru.func import util, comments
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_ctx(config_injector, context_factory, user_factory, comment_factory):
|
def test_ctx(config_injector, context_factory, user_factory, comment_factory):
|
||||||
config_injector({
|
config_injector({
|
||||||
|
'data_url': 'http://example.com',
|
||||||
'ranks': ['anonymous', 'regular_user', 'mod'],
|
'ranks': ['anonymous', 'regular_user', 'mod'],
|
||||||
'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord', 'mod': 'King'},
|
'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord', 'mod': 'King'},
|
||||||
'privileges': {
|
'privileges': {
|
||||||
|
|
133
server/szurubooru/tests/api/test_post_creating.py
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import unittest.mock
|
||||||
|
import pytest
|
||||||
|
from szurubooru import api, db, errors
|
||||||
|
from szurubooru.func import posts, tags, snapshots
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def inject_config(config_injector):
|
||||||
|
config_injector({
|
||||||
|
'ranks': ['anonymous', 'regular_user'],
|
||||||
|
'privileges': {'posts:create': 'regular_user'},
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_creating_minimal_posts(
|
||||||
|
context_factory, post_factory, user_factory):
|
||||||
|
auth_user = user_factory(rank='regular_user')
|
||||||
|
post = post_factory()
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
with unittest.mock.patch('szurubooru.func.posts.create_post'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.update_post_safety'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.update_post_source'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.update_post_relations'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.update_post_notes'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.update_post_flags'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.serialize_post_with_details'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.tags.export_to_json'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.snapshots.save_entity_creation'):
|
||||||
|
|
||||||
|
posts.create_post.return_value = post
|
||||||
|
posts.serialize_post_with_details.return_value = 'serialized post'
|
||||||
|
|
||||||
|
result = api.PostListApi().post(
|
||||||
|
context_factory(
|
||||||
|
input={
|
||||||
|
'safety': 'safe',
|
||||||
|
'tags': ['tag1', 'tag2'],
|
||||||
|
},
|
||||||
|
files={
|
||||||
|
'content': 'post-content',
|
||||||
|
},
|
||||||
|
user=auth_user))
|
||||||
|
|
||||||
|
assert result == 'serialized post'
|
||||||
|
posts.create_post.assert_called_once_with(
|
||||||
|
'post-content', ['tag1', 'tag2'], auth_user)
|
||||||
|
posts.update_post_safety.assert_called_once_with(post, 'safe')
|
||||||
|
posts.update_post_source.assert_called_once_with(post, None)
|
||||||
|
posts.update_post_relations.assert_called_once_with(post, [])
|
||||||
|
posts.update_post_notes.assert_called_once_with(post, [])
|
||||||
|
posts.update_post_flags.assert_called_once_with(post, [])
|
||||||
|
posts.serialize_post_with_details.assert_called_once_with(post, auth_user)
|
||||||
|
tags.export_to_json.assert_called_once_with()
|
||||||
|
snapshots.save_entity_creation.assert_called_once_with(post, auth_user)
|
||||||
|
|
||||||
|
def test_creating_full_posts(context_factory, post_factory, user_factory):
|
||||||
|
auth_user = user_factory(rank='regular_user')
|
||||||
|
post = post_factory()
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
with unittest.mock.patch('szurubooru.func.posts.create_post'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.update_post_safety'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.update_post_source'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.update_post_relations'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.update_post_notes'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.update_post_flags'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.serialize_post_with_details'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.tags.export_to_json'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.snapshots.save_entity_creation'):
|
||||||
|
|
||||||
|
posts.create_post.return_value = post
|
||||||
|
posts.serialize_post_with_details.return_value = 'serialized post'
|
||||||
|
|
||||||
|
result = api.PostListApi().post(
|
||||||
|
context_factory(
|
||||||
|
input={
|
||||||
|
'safety': 'safe',
|
||||||
|
'tags': ['tag1', 'tag2'],
|
||||||
|
'relations': [1, 2],
|
||||||
|
'source': 'source',
|
||||||
|
'notes': ['note1', 'note2'],
|
||||||
|
'flags': ['flag1', 'flag2'],
|
||||||
|
},
|
||||||
|
files={
|
||||||
|
'content': 'post-content',
|
||||||
|
},
|
||||||
|
user=auth_user))
|
||||||
|
|
||||||
|
assert result == 'serialized post'
|
||||||
|
posts.create_post.assert_called_once_with(
|
||||||
|
'post-content', ['tag1', 'tag2'], auth_user)
|
||||||
|
posts.update_post_safety.assert_called_once_with(post, 'safe')
|
||||||
|
posts.update_post_source.assert_called_once_with(post, 'source')
|
||||||
|
posts.update_post_relations.assert_called_once_with(post, [1, 2])
|
||||||
|
posts.update_post_notes.assert_called_once_with(post, ['note1', 'note2'])
|
||||||
|
posts.update_post_flags.assert_called_once_with(post, ['flag1', 'flag2'])
|
||||||
|
posts.serialize_post_with_details.assert_called_once_with(post, auth_user)
|
||||||
|
tags.export_to_json.assert_called_once_with()
|
||||||
|
snapshots.save_entity_creation.assert_called_once_with(post, auth_user)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('field', ['tags', 'safety'])
|
||||||
|
def test_trying_to_omit_mandatory_field(context_factory, user_factory, field):
|
||||||
|
input = {
|
||||||
|
'safety': 'safe',
|
||||||
|
'tags': ['tag1', 'tag2'],
|
||||||
|
}
|
||||||
|
del input[field]
|
||||||
|
with pytest.raises(errors.MissingRequiredParameterError):
|
||||||
|
api.PostListApi().post(
|
||||||
|
context_factory(
|
||||||
|
input=input,
|
||||||
|
files={'content': '...'},
|
||||||
|
user=user_factory(rank='regular_user')))
|
||||||
|
|
||||||
|
def test_trying_to_omit_content(context_factory, user_factory):
|
||||||
|
with pytest.raises(errors.MissingRequiredFileError):
|
||||||
|
api.PostListApi().post(
|
||||||
|
context_factory(
|
||||||
|
input={
|
||||||
|
'safety': 'safe',
|
||||||
|
'tags': ['tag1', 'tag2'],
|
||||||
|
},
|
||||||
|
user=user_factory(rank='regular_user')))
|
||||||
|
|
||||||
|
def test_trying_to_create_without_privileges(context_factory, user_factory):
|
||||||
|
with pytest.raises(errors.AuthError):
|
||||||
|
api.PostListApi().post(
|
||||||
|
context_factory(
|
||||||
|
input={'name': 'meta', 'colro': 'black'},
|
||||||
|
user=user_factory(rank='anonymous')))
|
|
@ -6,6 +6,7 @@ from szurubooru.func import util, posts
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_ctx(config_injector, context_factory, user_factory, post_factory):
|
def test_ctx(config_injector, context_factory, user_factory, post_factory):
|
||||||
config_injector({
|
config_injector({
|
||||||
|
'data_url': 'http://example.com',
|
||||||
'ranks': ['anonymous', 'regular_user', 'mod'],
|
'ranks': ['anonymous', 'regular_user', 'mod'],
|
||||||
'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'},
|
'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'},
|
||||||
'privileges': {
|
'privileges': {
|
||||||
|
|
|
@ -6,6 +6,7 @@ from szurubooru.func import util, posts
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_ctx(context_factory, config_injector, user_factory, post_factory):
|
def test_ctx(context_factory, config_injector, user_factory, post_factory):
|
||||||
config_injector({
|
config_injector({
|
||||||
|
'data_url': 'http://example.com',
|
||||||
'privileges': {
|
'privileges': {
|
||||||
'posts:feature': 'regular_user',
|
'posts:feature': 'regular_user',
|
||||||
'posts:view': 'regular_user',
|
'posts:view': 'regular_user',
|
||||||
|
|
|
@ -5,6 +5,7 @@ from szurubooru.func import util, posts, scores
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_ctx(config_injector, context_factory, user_factory, post_factory):
|
def test_ctx(config_injector, context_factory, user_factory, post_factory):
|
||||||
config_injector({
|
config_injector({
|
||||||
|
'data_url': 'http://example.com',
|
||||||
'ranks': ['anonymous', 'regular_user'],
|
'ranks': ['anonymous', 'regular_user'],
|
||||||
'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'},
|
'rank_names': {'anonymous': 'Peasant', 'regular_user': 'Lord'},
|
||||||
'privileges': {'posts:score': 'regular_user'},
|
'privileges': {'posts:score': 'regular_user'},
|
||||||
|
|
|
@ -6,6 +6,7 @@ from szurubooru.func import util, posts
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def test_ctx(context_factory, config_injector, user_factory, post_factory):
|
def test_ctx(context_factory, config_injector, user_factory, post_factory):
|
||||||
config_injector({
|
config_injector({
|
||||||
|
'data_url': 'http://example.com',
|
||||||
'privileges': {
|
'privileges': {
|
||||||
'posts:list': 'regular_user',
|
'posts:list': 'regular_user',
|
||||||
'posts:view': 'regular_user',
|
'posts:view': 'regular_user',
|
||||||
|
|
Before Width: | Height: | Size: 14 B After Width: | Height: | Size: 43 B |
Before Width: | Height: | Size: 107 B After Width: | Height: | Size: 12 KiB |
BIN
server/szurubooru/tests/assets/png-broken.png
Normal file
After Width: | Height: | Size: 100 B |
Before Width: | Height: | Size: 67 B |
BIN
server/szurubooru/tests/assets/png.png
Normal file
After Width: | Height: | Size: 15 KiB |
|
@ -1,4 +1,5 @@
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import os
|
||||||
import datetime
|
import datetime
|
||||||
import uuid
|
import uuid
|
||||||
import pytest
|
import pytest
|
||||||
|
@ -136,6 +137,7 @@ def post_factory():
|
||||||
post.type = type
|
post.type = type
|
||||||
post.checksum = checksum
|
post.checksum = checksum
|
||||||
post.flags = []
|
post.flags = []
|
||||||
|
post.mime_type = 'application/octet-stream'
|
||||||
post.creation_time = datetime.datetime(1996, 1, 1)
|
post.creation_time = datetime.datetime(1996, 1, 1)
|
||||||
return post
|
return post
|
||||||
return factory
|
return factory
|
||||||
|
@ -156,3 +158,11 @@ def comment_factory(user_factory, post_factory):
|
||||||
comment.creation_time = datetime.datetime(1996, 1, 1)
|
comment.creation_time = datetime.datetime(1996, 1, 1)
|
||||||
return comment
|
return comment
|
||||||
return factory
|
return factory
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def read_asset():
|
||||||
|
def get(path):
|
||||||
|
path = os.path.join(os.path.dirname(__file__), 'assets', path)
|
||||||
|
with open(path, 'rb') as handle:
|
||||||
|
return handle.read()
|
||||||
|
return get
|
||||||
|
|
|
@ -13,6 +13,7 @@ def test_saving_post(post_factory, user_factory, tag_factory):
|
||||||
post.checksum = 'deadbeef'
|
post.checksum = 'deadbeef'
|
||||||
post.creation_time = datetime(1997, 1, 1)
|
post.creation_time = datetime(1997, 1, 1)
|
||||||
post.last_edit_time = datetime(1998, 1, 1)
|
post.last_edit_time = datetime(1998, 1, 1)
|
||||||
|
post.mime_type = 'application/whatever'
|
||||||
db.session.add_all([user, tag1, tag2, related_post1, related_post2, post])
|
db.session.add_all([user, tag1, tag2, related_post1, related_post2, post])
|
||||||
|
|
||||||
post.user = user
|
post.user = user
|
||||||
|
|
|
@ -6,7 +6,7 @@ from szurubooru.func import mime
|
||||||
('mp4.mp4', 'video/mp4'),
|
('mp4.mp4', 'video/mp4'),
|
||||||
('webm.webm', 'video/webm'),
|
('webm.webm', 'video/webm'),
|
||||||
('flash.swf', 'application/x-shockwave-flash'),
|
('flash.swf', 'application/x-shockwave-flash'),
|
||||||
('png-transparent.png', 'image/png'),
|
('png.png', 'image/png'),
|
||||||
('jpeg.jpg', 'image/jpeg'),
|
('jpeg.jpg', 'image/jpeg'),
|
||||||
('gif.gif', 'image/gif'),
|
('gif.gif', 'image/gif'),
|
||||||
])
|
])
|
||||||
|
|
463
server/szurubooru/tests/func/test_posts.py
Normal file
|
@ -0,0 +1,463 @@
|
||||||
|
import os
|
||||||
|
import datetime
|
||||||
|
import unittest.mock
|
||||||
|
import pytest
|
||||||
|
from szurubooru import db
|
||||||
|
from szurubooru.func import posts, users, comments, snapshots, tags, images
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input_mime_type,expected_url', [
|
||||||
|
('image/jpeg', 'http://example.com/posts/1.jpg'),
|
||||||
|
('image/gif', 'http://example.com/posts/1.gif'),
|
||||||
|
('totally/unknown', 'http://example.com/posts/1.dat'),
|
||||||
|
])
|
||||||
|
def test_get_post_url(input_mime_type, expected_url, config_injector):
|
||||||
|
config_injector({'data_url': 'http://example.com/'})
|
||||||
|
post = db.Post()
|
||||||
|
post.post_id = 1
|
||||||
|
post.mime_type = input_mime_type
|
||||||
|
assert posts.get_post_content_url(post) == expected_url
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input_mime_type', ['image/jpeg', 'image/gif'])
|
||||||
|
def test_get_post_thumbnail_url(input_mime_type, config_injector):
|
||||||
|
config_injector({'data_url': 'http://example.com/'})
|
||||||
|
post = db.Post()
|
||||||
|
post.post_id = 1
|
||||||
|
post.mime_type = input_mime_type
|
||||||
|
assert posts.get_post_thumbnail_url(post) \
|
||||||
|
== 'http://example.com/generated-thumbnails/1.jpg'
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input_mime_type,expected_path', [
|
||||||
|
('image/jpeg', 'posts/1.jpg'),
|
||||||
|
('image/gif', 'posts/1.gif'),
|
||||||
|
('totally/unknown', 'posts/1.dat'),
|
||||||
|
])
|
||||||
|
def test_get_post_content_path(input_mime_type, expected_path):
|
||||||
|
post = db.Post()
|
||||||
|
post.post_id = 1
|
||||||
|
post.mime_type = input_mime_type
|
||||||
|
assert posts.get_post_content_path(post) == expected_path
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input_mime_type', ['image/jpeg', 'image/gif'])
|
||||||
|
def test_get_post_thumbnail_path(input_mime_type):
|
||||||
|
post = db.Post()
|
||||||
|
post.post_id = 1
|
||||||
|
post.mime_type = input_mime_type
|
||||||
|
assert posts.get_post_thumbnail_path(post) == 'generated-thumbnails/1.jpg'
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input_mime_type', ['image/jpeg', 'image/gif'])
|
||||||
|
def test_get_post_thumbnail_backup_path(input_mime_type):
|
||||||
|
post = db.Post()
|
||||||
|
post.post_id = 1
|
||||||
|
post.mime_type = input_mime_type
|
||||||
|
assert posts.get_post_thumbnail_backup_path(post) \
|
||||||
|
== 'posts/custom-thumbnails/1.dat'
|
||||||
|
|
||||||
|
def test_serialize_note():
|
||||||
|
note = db.PostNote()
|
||||||
|
note.path = [[0, 1], [1, 1], [1, 0], [0, 0]]
|
||||||
|
note.text = '...'
|
||||||
|
assert posts.serialize_note(note) == {
|
||||||
|
'polygon': [[0, 1], [1, 1], [1, 0], [0, 0]],
|
||||||
|
'text': '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_serialize_empty_post():
|
||||||
|
assert posts.serialize_post(None, None) is None
|
||||||
|
|
||||||
|
def test_serialize_post(post_factory, user_factory, tag_factory):
|
||||||
|
with unittest.mock.patch('szurubooru.func.users.serialize_user'):
|
||||||
|
users.serialize_user.side_effect = lambda user, auth_user: user.name
|
||||||
|
|
||||||
|
auth_user = user_factory(name='auth user')
|
||||||
|
post = db.Post()
|
||||||
|
post.post_id = 1
|
||||||
|
post.creation_time = datetime.datetime(1997, 1, 1)
|
||||||
|
post.last_edit_time = datetime.datetime(1998, 1, 1)
|
||||||
|
post.tags = [
|
||||||
|
tag_factory(names=['tag1', 'tag2']),
|
||||||
|
tag_factory(names=['tag3'])
|
||||||
|
]
|
||||||
|
post.safety = db.Post.SAFETY_SAFE
|
||||||
|
post.source = '4gag'
|
||||||
|
post.type = db.Post.TYPE_IMAGE
|
||||||
|
post.checksum = 'deadbeef'
|
||||||
|
post.mime_type = 'image/jpeg'
|
||||||
|
post.file_size = 100
|
||||||
|
post.user = user_factory(name='post author')
|
||||||
|
post.canvas_width = 200
|
||||||
|
post.canvas_height = 300
|
||||||
|
post.flags = ['loop']
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add_all([
|
||||||
|
db.PostFavorite(
|
||||||
|
post=post,
|
||||||
|
user=user_factory(name='fav1'),
|
||||||
|
time=datetime.datetime(1800, 1, 1)),
|
||||||
|
db.PostFeature(
|
||||||
|
post=post,
|
||||||
|
user=user_factory(),
|
||||||
|
time=datetime.datetime(1999, 1, 1)),
|
||||||
|
db.PostScore(
|
||||||
|
post=post,
|
||||||
|
user=auth_user,
|
||||||
|
score=-1,
|
||||||
|
time=datetime.datetime(1800, 1, 1)),
|
||||||
|
db.PostScore(
|
||||||
|
post=post,
|
||||||
|
user=user_factory(),
|
||||||
|
score=1,
|
||||||
|
time=datetime.datetime(1800, 1, 1)),
|
||||||
|
db.PostScore(
|
||||||
|
post=post,
|
||||||
|
user=user_factory(),
|
||||||
|
score=1,
|
||||||
|
time=datetime.datetime(1800, 1, 1))])
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
result = posts.serialize_post(post, auth_user)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
'id': 1,
|
||||||
|
'creationTime': datetime.datetime(1997, 1, 1),
|
||||||
|
'lastEditTime': datetime.datetime(1998, 1, 1),
|
||||||
|
'safety': 'safe',
|
||||||
|
'source': '4gag',
|
||||||
|
'type': 'image',
|
||||||
|
'checksum': 'deadbeef',
|
||||||
|
'fileSize': 100,
|
||||||
|
'canvasWidth': 200,
|
||||||
|
'canvasHeight': 300,
|
||||||
|
'contentUrl': 'http://example.com/posts/1.jpg',
|
||||||
|
'thumbnailUrl': 'http://example.com/generated-thumbnails/1.jpg',
|
||||||
|
'flags': ['loop'],
|
||||||
|
'tags': ['tag1', 'tag3'],
|
||||||
|
'relations': [],
|
||||||
|
'notes': [],
|
||||||
|
'user': 'post author',
|
||||||
|
'score': 1,
|
||||||
|
'ownScore': -1,
|
||||||
|
'featureCount': 1,
|
||||||
|
'lastFeatureTime': datetime.datetime(1999, 1, 1),
|
||||||
|
'favoritedBy': ['fav1'],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_serialize_post_with_details(post_factory, comment_factory, user_factory):
|
||||||
|
with unittest.mock.patch('szurubooru.func.comments.serialize_comment'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.snapshots.get_serialized_history'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.serialize_post'):
|
||||||
|
comments.serialize_comment.side_effect \
|
||||||
|
= lambda comment, auth_user: comment.user.name
|
||||||
|
posts.serialize_post.side_effect \
|
||||||
|
= lambda post, auth_user: post.post_id
|
||||||
|
snapshots.get_serialized_history.return_value = 'snapshot history'
|
||||||
|
|
||||||
|
auth_user = user_factory(name='auth user')
|
||||||
|
post = post_factory()
|
||||||
|
post.comments = [
|
||||||
|
comment_factory(user=user_factory(name='commenter1')),
|
||||||
|
comment_factory(user=user_factory(name='commenter2')),
|
||||||
|
]
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
result = posts.serialize_post_with_details(post, auth_user)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
'post': post.post_id,
|
||||||
|
'snapshots': 'snapshot history',
|
||||||
|
'comments': ['commenter1', 'commenter2'],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_get_post_count(post_factory):
|
||||||
|
previous_count = posts.get_post_count()
|
||||||
|
db.session.add_all([post_factory(), post_factory()])
|
||||||
|
new_count = posts.get_post_count()
|
||||||
|
assert previous_count == 0
|
||||||
|
assert new_count == 2
|
||||||
|
|
||||||
|
def test_try_get_post_by_id(post_factory):
|
||||||
|
post = post_factory()
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
assert posts.try_get_post_by_id(post.post_id) == post
|
||||||
|
assert posts.try_get_post_by_id(post.post_id + 1) is None
|
||||||
|
|
||||||
|
def test_get_post_by_id(post_factory):
|
||||||
|
post = post_factory()
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
assert posts.get_post_by_id(post.post_id) == post
|
||||||
|
with pytest.raises(posts.PostNotFoundError):
|
||||||
|
posts.get_post_by_id(post.post_id + 1)
|
||||||
|
|
||||||
|
def test_create_post(user_factory, fake_datetime):
|
||||||
|
with unittest.mock.patch('szurubooru.func.posts.update_post_content'), \
|
||||||
|
unittest.mock.patch('szurubooru.func.posts.update_post_tags'), \
|
||||||
|
fake_datetime('1997-01-01'):
|
||||||
|
auth_user = user_factory()
|
||||||
|
post = posts.create_post('content', ['tag'], auth_user)
|
||||||
|
assert post.creation_time == datetime.datetime(1997, 1, 1)
|
||||||
|
assert post.last_edit_time is None
|
||||||
|
posts.update_post_tags.assert_called_once_with(post, ['tag'])
|
||||||
|
posts.update_post_content.assert_called_once_with(post, 'content')
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input_safety,expected_safety', [
|
||||||
|
('safe', db.Post.SAFETY_SAFE),
|
||||||
|
('sketchy', db.Post.SAFETY_SKETCHY),
|
||||||
|
('unsafe', db.Post.SAFETY_UNSAFE),
|
||||||
|
])
|
||||||
|
def test_update_post_safety(input_safety, expected_safety):
|
||||||
|
post = db.Post()
|
||||||
|
posts.update_post_safety(post, input_safety)
|
||||||
|
assert post.safety == expected_safety
|
||||||
|
|
||||||
|
def test_update_post_invalid_safety():
|
||||||
|
post = db.Post()
|
||||||
|
with pytest.raises(posts.InvalidPostSafetyError):
|
||||||
|
posts.update_post_safety(post, 'bad')
|
||||||
|
|
||||||
|
def test_update_post_source():
|
||||||
|
post = db.Post()
|
||||||
|
posts.update_post_source(post, 'x')
|
||||||
|
assert post.source == 'x'
|
||||||
|
|
||||||
|
def test_update_post_invalid_source():
|
||||||
|
post = db.Post()
|
||||||
|
with pytest.raises(posts.InvalidPostSourceError):
|
||||||
|
posts.update_post_source(post, 'x' * 1000)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'input_file,expected_mime_type,expected_type,output_file_name', [
|
||||||
|
('png.png', 'image/png', db.Post.TYPE_IMAGE, '1.png'),
|
||||||
|
('jpeg.jpg', 'image/jpeg', db.Post.TYPE_IMAGE, '1.jpg'),
|
||||||
|
('gif.gif', 'image/gif', db.Post.TYPE_IMAGE, '1.gif'),
|
||||||
|
('gif-animated.gif', 'image/gif', db.Post.TYPE_ANIMATION, '1.gif'),
|
||||||
|
('webm.webm', 'video/webm', db.Post.TYPE_VIDEO, '1.webm'),
|
||||||
|
('mp4.mp4', 'video/mp4', db.Post.TYPE_VIDEO, '1.mp4'),
|
||||||
|
('flash.swf', 'application/x-shockwave-flash', db.Post.TYPE_FLASH, '1.swf'),
|
||||||
|
])
|
||||||
|
def test_update_post_content(
|
||||||
|
tmpdir,
|
||||||
|
config_injector,
|
||||||
|
post_factory,
|
||||||
|
read_asset,
|
||||||
|
input_file,
|
||||||
|
expected_mime_type,
|
||||||
|
expected_type,
|
||||||
|
output_file_name):
|
||||||
|
with unittest.mock.patch('szurubooru.func.util.get_md5', return_value='crc'):
|
||||||
|
config_injector({
|
||||||
|
'data_dir': str(tmpdir.mkdir('data')),
|
||||||
|
'thumbnails': {
|
||||||
|
'post_width': 300,
|
||||||
|
'post_height': 300,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
post = post_factory(id=1)
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
posts.update_post_content(post, read_asset(input_file))
|
||||||
|
assert post.mime_type == expected_mime_type
|
||||||
|
assert post.type == expected_type
|
||||||
|
assert post.checksum == 'crc'
|
||||||
|
assert os.path.exists(str(tmpdir) + '/data/posts/' + output_file_name)
|
||||||
|
|
||||||
|
def test_update_post_content_to_existing_content(
|
||||||
|
tmpdir, config_injector, post_factory, read_asset):
|
||||||
|
config_injector({
|
||||||
|
'data_dir': str(tmpdir.mkdir('data')),
|
||||||
|
'thumbnails': {
|
||||||
|
'post_width': 300,
|
||||||
|
'post_height': 300,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
post = post_factory()
|
||||||
|
another_post = post_factory()
|
||||||
|
db.session.add_all([post, another_post])
|
||||||
|
db.session.flush()
|
||||||
|
posts.update_post_content(post, read_asset('png.png'))
|
||||||
|
with pytest.raises(posts.PostAlreadyUploadedError):
|
||||||
|
posts.update_post_content(another_post, read_asset('png.png'))
|
||||||
|
|
||||||
|
def test_update_post_content_broken_content(
|
||||||
|
tmpdir, config_injector, post_factory, read_asset):
|
||||||
|
# the rationale behind this behavior is to salvage user upload even if the
|
||||||
|
# server software thinks it's broken. chances are the server is wrong,
|
||||||
|
# especially about flash movies.
|
||||||
|
config_injector({
|
||||||
|
'data_dir': str(tmpdir.mkdir('data')),
|
||||||
|
'thumbnails': {
|
||||||
|
'post_width': 300,
|
||||||
|
'post_height': 300,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
post = post_factory()
|
||||||
|
another_post = post_factory()
|
||||||
|
db.session.add_all([post, another_post])
|
||||||
|
db.session.flush()
|
||||||
|
posts.update_post_content(post, read_asset('png-broken.png'))
|
||||||
|
assert post.canvas_width is None
|
||||||
|
assert post.canvas_height is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input_content', [None, b'not a media file'])
|
||||||
|
def test_update_post_invalid_content(input_content):
|
||||||
|
post = db.Post()
|
||||||
|
with pytest.raises(posts.InvalidPostContentError):
|
||||||
|
posts.update_post_content(post, input_content)
|
||||||
|
|
||||||
|
def test_update_post_thumbnail_to_new_one(
|
||||||
|
tmpdir, config_injector, read_asset, post_factory):
|
||||||
|
config_injector({
|
||||||
|
'data_dir': str(tmpdir.mkdir('data')),
|
||||||
|
'thumbnails': {
|
||||||
|
'post_width': 300,
|
||||||
|
'post_height': 300,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
post = post_factory(id=1)
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
posts.update_post_content(post, read_asset('png.png'))
|
||||||
|
posts.update_post_thumbnail(post, read_asset('jpeg.jpg'))
|
||||||
|
assert os.path.exists(str(tmpdir) + '/data/posts/custom-thumbnails/1.dat')
|
||||||
|
assert os.path.exists(str(tmpdir) + '/data/generated-thumbnails/1.jpg')
|
||||||
|
with open(str(tmpdir) + '/data/posts/custom-thumbnails/1.dat', 'rb') as handle:
|
||||||
|
assert handle.read() == read_asset('jpeg.jpg')
|
||||||
|
|
||||||
|
def test_update_post_thumbnail_to_default(
|
||||||
|
tmpdir, config_injector, read_asset, post_factory):
|
||||||
|
config_injector({
|
||||||
|
'data_dir': str(tmpdir.mkdir('data')),
|
||||||
|
'thumbnails': {
|
||||||
|
'post_width': 300,
|
||||||
|
'post_height': 300,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
post = post_factory(id=1)
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
posts.update_post_content(post, read_asset('png.png'))
|
||||||
|
posts.update_post_thumbnail(post, read_asset('jpeg.jpg'))
|
||||||
|
posts.update_post_thumbnail(post, None)
|
||||||
|
assert not os.path.exists(str(tmpdir) + '/data/posts/custom-thumbnails/1.dat')
|
||||||
|
assert os.path.exists(str(tmpdir) + '/data/generated-thumbnails/1.jpg')
|
||||||
|
|
||||||
|
def test_update_post_thumbnail_broken_thumbnail(
|
||||||
|
tmpdir, config_injector, read_asset, post_factory):
|
||||||
|
config_injector({
|
||||||
|
'data_dir': str(tmpdir.mkdir('data')),
|
||||||
|
'thumbnails': {
|
||||||
|
'post_width': 300,
|
||||||
|
'post_height': 300,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
post = post_factory(id=1)
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
posts.update_post_content(post, read_asset('png.png'))
|
||||||
|
posts.update_post_thumbnail(post, read_asset('png-broken.png'))
|
||||||
|
assert os.path.exists(str(tmpdir) + '/data/posts/custom-thumbnails/1.dat')
|
||||||
|
assert os.path.exists(str(tmpdir) + '/data/generated-thumbnails/1.jpg')
|
||||||
|
with open(str(tmpdir) + '/data/posts/custom-thumbnails/1.dat', 'rb') as handle:
|
||||||
|
assert handle.read() == read_asset('png-broken.png')
|
||||||
|
with open(str(tmpdir) + '/data/generated-thumbnails/1.jpg', 'rb') as handle:
|
||||||
|
image = images.Image(handle.read())
|
||||||
|
assert image.width == 1
|
||||||
|
assert image.height == 1
|
||||||
|
|
||||||
|
def test_update_post_content_leaves_custom_thumbnail(
|
||||||
|
tmpdir, config_injector, read_asset, post_factory):
|
||||||
|
config_injector({
|
||||||
|
'data_dir': str(tmpdir.mkdir('data')),
|
||||||
|
'thumbnails': {
|
||||||
|
'post_width': 300,
|
||||||
|
'post_height': 300,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
post = post_factory(id=1)
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
posts.update_post_content(post, read_asset('png.png'))
|
||||||
|
posts.update_post_thumbnail(post, read_asset('jpeg.jpg'))
|
||||||
|
posts.update_post_content(post, read_asset('png.png'))
|
||||||
|
assert os.path.exists(str(tmpdir) + '/data/posts/custom-thumbnails/1.dat')
|
||||||
|
assert os.path.exists(str(tmpdir) + '/data/generated-thumbnails/1.jpg')
|
||||||
|
|
||||||
|
def test_update_post_tags(tag_factory):
|
||||||
|
post = db.Post()
|
||||||
|
with unittest.mock.patch('szurubooru.func.tags.get_or_create_tags_by_names'):
|
||||||
|
tags.get_or_create_tags_by_names.side_effect \
|
||||||
|
= lambda tag_names: \
|
||||||
|
([tag_factory(names=[name]) for name in tag_names], [])
|
||||||
|
posts.update_post_tags(post, ['tag1', 'tag2'])
|
||||||
|
assert len(post.tags) == 2
|
||||||
|
assert post.tags[0].names[0].name == 'tag1'
|
||||||
|
assert post.tags[1].names[0].name == 'tag2'
|
||||||
|
|
||||||
|
def test_update_post_relations(post_factory):
|
||||||
|
relation1 = post_factory()
|
||||||
|
relation2 = post_factory()
|
||||||
|
db.session.add_all([relation1, relation2])
|
||||||
|
db.session.flush()
|
||||||
|
post = db.Post()
|
||||||
|
posts.update_post_relations(post, [relation1.post_id, relation2.post_id])
|
||||||
|
assert len(post.relations) == 2
|
||||||
|
assert post.relations[0].post_id == relation1.post_id
|
||||||
|
assert post.relations[1].post_id == relation2.post_id
|
||||||
|
|
||||||
|
def test_update_post_non_existing_relations():
|
||||||
|
post = db.Post()
|
||||||
|
with pytest.raises(posts.InvalidPostRelationError):
|
||||||
|
posts.update_post_relations(post, [100])
|
||||||
|
|
||||||
|
def test_update_post_notes():
|
||||||
|
post = db.Post()
|
||||||
|
posts.update_post_notes(
|
||||||
|
post,
|
||||||
|
[
|
||||||
|
{'polygon': [[0, 0], [0, 1], [1, 0], [0, 0]], 'text': 'text1'},
|
||||||
|
{'polygon': [[0, 0], [0, 1], [1, 0], [0, 0]], 'text': 'text2'},
|
||||||
|
])
|
||||||
|
assert len(post.notes) == 2
|
||||||
|
assert post.notes[0].polygon == [[0, 0], [0, 1], [1, 0], [0, 0]]
|
||||||
|
assert post.notes[0].text == 'text1'
|
||||||
|
assert post.notes[1].polygon == [[0, 0], [0, 1], [1, 0], [0, 0]]
|
||||||
|
assert post.notes[1].text == 'text2'
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('input', [
|
||||||
|
[{'polygon': [[0, 0]], 'text': '...'}],
|
||||||
|
[{'polygon': [[0, 0], [0, 0], [0, 2]], 'text': '...'}],
|
||||||
|
[{'polygon': [[0, 0], [0, 0], [0, '...']], 'text': '...'}],
|
||||||
|
[{'polygon': [[0, 0], [0, 0], [0, 0, 0]], 'text': '...'}],
|
||||||
|
[{'polygon': [[0, 0], [0, 0], [0]], 'text': '...'}],
|
||||||
|
[{'polygon': [[0, 0], [0, 0], [0, 1]], 'text': ''}],
|
||||||
|
[{'polygon': [[0, 0], [0, 0], [0, 1]], 'text': None}],
|
||||||
|
[{'text': '...'}],
|
||||||
|
[{'polygon': [[0, 0], [0, 0], [0, 1]]}],
|
||||||
|
])
|
||||||
|
def test_update_post_invalid_notes(input):
|
||||||
|
post = db.Post()
|
||||||
|
with pytest.raises(posts.InvalidPostNoteError):
|
||||||
|
posts.update_post_notes(post, input)
|
||||||
|
|
||||||
|
def test_update_post_flags():
|
||||||
|
post = db.Post()
|
||||||
|
posts.update_post_flags(post, ['loop'])
|
||||||
|
assert post.flags == ['loop']
|
||||||
|
|
||||||
|
def test_update_post_invalid_flags():
|
||||||
|
post = db.Post()
|
||||||
|
with pytest.raises(posts.InvalidPostFlagError):
|
||||||
|
posts.update_post_flags(post, ['invalid'])
|
||||||
|
|
||||||
|
def test_featuring_post(post_factory, user_factory):
|
||||||
|
post = post_factory()
|
||||||
|
user = user_factory()
|
||||||
|
|
||||||
|
previous_featured_post = posts.try_get_featured_post()
|
||||||
|
posts.feature_post(post, user)
|
||||||
|
new_featured_post = posts.try_get_featured_post()
|
||||||
|
|
||||||
|
assert previous_featured_post is None
|
||||||
|
assert new_featured_post == post
|