8 Commits
2.0 ... 2.1

37 changed files with 1239 additions and 169 deletions

45
API.md
View File

@ -36,6 +36,7 @@
- [Updating post](#updating-post)
- [Getting post](#getting-post)
- [Deleting post](#deleting-post)
- [Merging posts](#merging-posts)
- [Rating post](#rating-post)
- [Adding post to favorites](#adding-post-to-favorites)
- [Removing post from favorites](#removing-post-from-favorites)
@ -617,10 +618,9 @@ data.
- **Description**
Removes source tag and merges all of its usages to the target tag. Source
tag properties such as category, tag relations etc. do not get transferred
and are discarded. The target tag effectively remains unchanged with the
exception of the set of posts it's used in.
Removes source tag and merges all of its usages, suggestions and
implications to the target tag. Other tag properties such as category and
aliases do not get transferred and are discarded.
## Listing tag siblings
- **Request**
@ -910,6 +910,43 @@ data.
Deletes existing post. Related posts and tags are kept.
## Merging posts
- **Request**
`POST /post-merge/`
- **Input**
```json5
{
"removeVersion": <source-post-version>,
"remove": <source-post-id>,
"mergeToVersion": <target-post-version>,
"mergeTo": <target-post-id>,
"replaceContent": <true-or-false>
}
```
- **Output**
A [post resource](#post) containing the merged post.
- **Errors**
- the version of either post is outdated
- the source or target post does not exist
- the source post is the same as the target post
- privileges are too low
- **Description**
Removes source post and merges all of its tags, relations, scores,
favorites and comments to the target post. If `replaceContent` is set to
true, content of the target post is replaced using the content of the
source post; otherwise it remains unchanged. Source post properties such as
its safety, source, whether to loop the video and other scalar values do
not get transferred and are discarded.
## Rating post
- **Request**

View File

@ -65,10 +65,9 @@ input[type=radio], input[type=checkbox]
.radio:before, .checkbox:before
transition: border-color 0.1s linear
position: absolute
top: 50%
left: 0
top: 0.15em
display: block
margin-top: -10px
width: 16px
height: 16px
background: $input-enabled-background-color
@ -79,10 +78,10 @@ input[type=radio], input[type=checkbox]
background: $main-color
transition: opacity 0.1s linear
position: absolute
top: 50%
left: 5px
top: 0.15em
margin-top: 5px
display: block
margin-top: -5px
width: 10px
height: 10px
border-radius: 50%
@ -92,10 +91,10 @@ input[type=radio], input[type=checkbox]
.checkbox:after
transition: opacity 0.1s linear
position: absolute
top: 50%
top: 0.15em
left: 6px
display: block
margin-top: -7px
margin-top: 3px
width: 5px
height: 9px
border-right: 3px solid $main-color

View File

@ -0,0 +1,33 @@
#post
width: 100%
max-width: 40em
h1
margin-top: 0
form
width: 100%
.buttons i
margin-right: 0.5em
.post-merge
.left-post-container
width: 47%
float: left
.right-post-container
width: 47%
float: right
input[type=text]
width: 8em
margin-top: -2px
.post-mirror
margin-bottom: 1em
&:after
display: block
height: 1px
content: ' '
clear: both
.post-thumbnail .thumbnail
width: 100%
height: 9em
.target-post .thumbnail
margin-right: 0.35em
.target-post, .target-post-content
margin: 1em 0

View File

@ -120,11 +120,12 @@
margin-bottom: 1em
.safety
display: flex
flex-wrap: wrap
label:not(.radio)
&>label
width: 100%
.radio
.radio-wrapper
display: flex
flex-wrap: wrap
.radio-wrapper label
flex-grow: 1
display: inline-block

View File

@ -39,20 +39,24 @@ $cancel-button-color = tomato
margin-top: 1em
.uploadables-container
line-height: 200%
li
margin: 0 0 1.2em 0
.uploadable
.file
margin: 0.3em 0
overflow: hidden
white-space: nowrap
text-align: left
text-overflow: ellipsis
.anonymous
margin: 0.3em 0
.safety
margin: 0.3em 0
label
display: inline-block
margin-right: 1em
.thumbnail-wrapper

View File

@ -0,0 +1,12 @@
<div class='content-wrapper' id='post'>
<h1>Post #<%- ctx.post.id %></h1>
<nav class='buttons'><!--
--><ul><!--
--><li><a href='/post/<%- ctx.post.id %>'><i class='fa fa-reply'></i> Main view</a></li><!--
--><% if (ctx.canMerge) { %><!--
--><li data-name='merge'><a href='/post/<%- ctx.post.id %>/merge'>Merge with&hellip;</a></li><!--
--><% } %><!--
--></ul><!--
--></nav>
<div class='post-content-holder'></div>
</div>

View File

@ -7,24 +7,26 @@
<% if (ctx.canEditPostSafety) { %>
<section class='safety'>
<label>Safety</label>
<%= ctx.makeRadio({
name: 'safety',
class: 'safety-safe',
value: 'safe',
selectedValue: ctx.post.safety,
text: 'Safe'}) %>
<%= ctx.makeRadio({
name: 'safety',
class: 'safety-sketchy',
value: 'sketchy',
selectedValue: ctx.post.safety,
text: 'Sketchy'}) %>
<%= ctx.makeRadio({
name: 'safety',
value: 'unsafe',
selectedValue: ctx.post.safety,
class: 'safety-unsafe',
text: 'Unsafe'}) %>
<div class='radio-wrapper'>
<%= ctx.makeRadio({
name: 'safety',
class: 'safety-safe',
value: 'safe',
selectedValue: ctx.post.safety,
text: 'Safe'}) %>
<%= ctx.makeRadio({
name: 'safety',
class: 'safety-sketchy',
value: 'sketchy',
selectedValue: ctx.post.safety,
text: 'Sketchy'}) %>
<%= ctx.makeRadio({
name: 'safety',
value: 'unsafe',
selectedValue: ctx.post.safety,
class: 'safety-unsafe',
text: 'Unsafe'}) %>
</div>
</section>
<% } %>
@ -82,12 +84,15 @@
</section>
<% } %>
<% if (ctx.canFeaturePosts) { %>
<% if (ctx.canFeaturePosts || ctx.canDeletePosts || ctx.canMergePosts) { %>
<section class='management'>
<ul>
<% if (ctx.canFeaturePosts) { %>
<li><a href class='feature'>Feature this post on main page</a></li>
<% } %>
<% if (ctx.canMergePosts) { %>
<li><a href class='merge'>Merge this post with another</a></li>
<% } %>
<% if (ctx.canDeletePosts) { %>
<li><a href class='delete'>Delete this post</a></li>
<% } %>

View File

@ -0,0 +1,23 @@
<div class='post-merge'>
<form>
<ul>
<li class='post-mirror'>
<div class='left-post-container'></div>
<div class='right-post-container'></div>
</li>
<li>
<p>Tags, relations, scores, favorites and comments will be
merged. All other properties need to be handled manually.</p>
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge these posts.'}) %>
</li>
</ul>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Merge posts'/>
</div>
</form>
</div>

View File

@ -0,0 +1,48 @@
<% if (ctx.editable) { %>
<p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>'/></p>
<% } else { %>
<p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>' readonly/></p>
<% } %>
<% if (ctx.post) { %>
<div class='post-thumbnail'>
<a rel='external' href='<%- ctx.post.contentUrl %>'>
<%= ctx.makeThumbnail(ctx.post.thumbnailUrl) %>
</a>
</div>
<div class='target-post'>
<%= ctx.makeRadio({
required: true,
text: 'Merge to this post<br/><small>' +
ctx.makeUserLink(ctx.post.user) +
', ' +
ctx.makeRelativeTime(ctx.post.creationTime) +
'</small>',
name: 'target-post',
value: ctx.name,
}) %>
</div>
<div class='target-post-content'>
<%= ctx.makeRadio({
required: true,
text: 'Use this file<br/><small>' +
ctx.makeFileSize(ctx.post.fileSize) + ' ' +
{
'image/gif': 'GIF',
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'video/webm': 'WEBM',
'application/x-shockwave-flash': 'SWF',
}[ctx.post.mimeType] +
' (' +
(ctx.post.canvasWidth ?
`${ctx.post.canvasWidth}x${ctx.post.canvasHeight}` :
'?') +
')</small>',
name: 'target-post-content',
value: ctx.name,
}) %>
<p>
</p>
</div>
<% } %>

View File

@ -1,14 +1,14 @@
<div class='tag-merge'>
<form>
<p>Proceeding will remove this tag and retag its posts with the tag
specified below. Aliases, suggestions and implications are discarded
and need to be handled manually.</p>
<ul>
<li class='target'>
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
</li>
<li class='confirm'>
<li>
<p>Usages in posts, suggestions and implications will be
merged. Category and aliases need to be handled manually.</p>
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this tag.'}) %>
</li>
</ul>

View File

@ -0,0 +1,20 @@
'use strict';
const api = require('../api.js');
const topNavigation = require('../models/top_navigation.js');
const EmptyView = require('../views/empty_view.js');
class BasePostController {
constructor(ctx) {
if (!api.hasPrivilege('posts:view')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view posts.');
return;
}
topNavigation.activate('posts');
topNavigation.setTitle('Post #' + ctx.parameters.id.toString());
}
}
module.exports = BasePostController;

View File

@ -0,0 +1,86 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const settings = require('../models/settings.js');
const Comment = require('../models/comment.js');
const Post = require('../models/post.js');
const PostList = require('../models/post_list.js');
const PostDetailView = require('../views/post_detail_view.js');
const BasePostController = require('./base_post_controller.js');
const EmptyView = require('../views/empty_view.js');
class PostDetailController extends BasePostController {
constructor(ctx, section) {
super(ctx);
Post.get(ctx.parameters.id).then(post => {
this._id = ctx.parameters.id;
post.addEventListener('change', e => this._evtSaved(e, section));
this._view = new PostDetailView({
post: post,
section: section,
canMerge: api.hasPrivilege('posts:merge'),
});
this._view.addEventListener('select', e => this._evtSelect(e));
this._view.addEventListener('merge', e => this._evtMerge(e));
}, errorMessage => {
this._view = new EmptyView();
this._view.showError(errorMessage);
});
}
showSuccess(message) {
this._view.showSuccess(message);
}
_evtSelect(e) {
this._view.clearMessages();
this._view.disableForm();
Post.get(e.detail.postId).then(post => {
this._view.selectPost(post);
this._view.enableForm();
}, errorMessage => {
this._view.showError(errorMessage);
this._view.enableForm();
});
}
_evtSaved(e, section) {
misc.disableExitConfirmation();
if (this._id !== e.detail.post.id) {
router.replace(
'/post/' + e.detail.post.id + '/' + section, null, false);
}
}
_evtMerge(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.post.merge(e.detail.targetPost.id, e.detail.useOldContent)
.then(() => {
this._view = new PostDetailView({
post: e.detail.targetPost,
section: 'merge',
canMerge: api.hasPrivilege('posts:merge'),
});
this._view.showSuccess('Post merged.');
router.replace(
'/post/' + e.detail.targetPost.id + '/merge', null, false);
}, errorMessage => {
this._view.showError(errorMessage);
this._view.enableForm();
});
}
}
module.exports = router => {
router.enter(
'/post/:id/merge',
(ctx, next) => {
ctx.controller = new PostDetailController(ctx, 'merge');
});
};

View File

@ -7,26 +7,19 @@ const settings = require('../models/settings.js');
const Comment = require('../models/comment.js');
const Post = require('../models/post.js');
const PostList = require('../models/post_list.js');
const topNavigation = require('../models/top_navigation.js');
const PostView = require('../views/post_view.js');
const PostMainView = require('../views/post_main_view.js');
const BasePostController = require('./base_post_controller.js');
const EmptyView = require('../views/empty_view.js');
class PostController {
constructor(id, editMode, ctx) {
if (!api.hasPrivilege('posts:view')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view posts.');
return;
}
topNavigation.activate('posts');
topNavigation.setTitle('Post #' + id.toString());
class PostMainController extends BasePostController {
constructor(ctx, editMode) {
super(ctx);
let parameters = ctx.parameters;
Promise.all([
Post.get(id),
Post.get(ctx.parameters.id),
PostList.getAround(
id, this._decorateSearchQuery(
ctx.parameters.id, this._decorateSearchQuery(
parameters ? parameters.query : '')),
]).then(responses => {
const [post, aroundResponse] = responses;
@ -36,13 +29,13 @@ class PostController {
if (parameters.query) {
ctx.state.parameters = parameters;
const url = editMode ?
'/post/' + id + '/edit' :
'/post/' + id;
'/post/' + ctx.parameters.id + '/edit' :
'/post/' + ctx.parameters.id;
router.replace(url, ctx.state, false);
}
this._post = post;
this._view = new PostView({
this._view = new PostMainView({
post: post,
editMode: editMode,
prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null,
@ -72,6 +65,8 @@ class PostController {
'feature', e => this._evtFeaturePost(e));
this._view.sidebarControl.addEventListener(
'delete', e => this._evtDeletePost(e));
this._view.sidebarControl.addEventListener(
'merge', e => this._evtMergePost(e));
}
if (this._view.commentFormControl) {
@ -128,6 +123,10 @@ class PostController {
});
}
_evtMergePost(e) {
router.show('/post/' + e.detail.post.id + '/merge');
}
_evtDeletePost(e) {
this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages();
@ -262,7 +261,7 @@ module.exports = router => {
if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters);
}
ctx.controller = new PostController(ctx.parameters.id, true, ctx);
ctx.controller = new PostMainController(ctx, true);
});
router.enter(
'/post/:id/:parameters(.*)?',
@ -272,6 +271,6 @@ module.exports = router => {
if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters);
}
ctx.controller = new PostController(ctx.parameters.id, false, ctx);
ctx.controller = new PostMainController(ctx, false);
});
};

View File

@ -36,6 +36,7 @@ class PostEditSidebarControl extends events.EventTarget {
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'),
canDeletePosts: api.hasPrivilege('posts:delete'),
canFeaturePosts: api.hasPrivilege('posts:feature'),
canMergePosts: api.hasPrivilege('posts:merge'),
}));
new ExpanderControl(
@ -108,6 +109,11 @@ class PostEditSidebarControl extends events.EventTarget {
'click', e => this._evtFeatureClick(e));
}
if (this._mergeLinkNode) {
this._mergeLinkNode.addEventListener(
'click', e => this._evtMergeClick(e));
}
if (this._deleteLinkNode) {
this._deleteLinkNode.addEventListener(
'click', e => this._evtDeleteClick(e));
@ -186,6 +192,15 @@ class PostEditSidebarControl extends events.EventTarget {
}
}
_evtMergeClick(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('merge', {
detail: {
post: this._post,
},
}));
}
_evtDeleteClick(e) {
e.preventDefault();
if (confirm('Are you sure you want to delete this post?')) {
@ -244,7 +259,7 @@ class PostEditSidebarControl extends events.EventTarget {
detail: {
post: this._post,
safety: this._safetyButtonNodes.legnth ?
safety: this._safetyButtonNodes.length ?
Array.from(this._safetyButtonNodes)
.filter(node => node.checked)[0]
.value.toLowerCase() :
@ -314,6 +329,10 @@ class PostEditSidebarControl extends events.EventTarget {
return this._formNode.querySelector('.management .feature');
}
get _mergeLinkNode() {
return this._formNode.querySelector('.management .merge');
}
get _deleteLinkNode() {
return this._formNode.querySelector('.management .delete');
}

View File

@ -34,7 +34,8 @@ controllers.push(require('./controllers/auth_controller.js'));
controllers.push(require('./controllers/password_reset_controller.js'));
controllers.push(require('./controllers/comments_controller.js'));
controllers.push(require('./controllers/snapshots_controller.js'));
controllers.push(require('./controllers/post_controller.js'));
controllers.push(require('./controllers/post_detail_controller.js'));
controllers.push(require('./controllers/post_main_controller.js'));
controllers.push(require('./controllers/post_list_controller.js'));
controllers.push(require('./controllers/post_upload_controller.js'));
controllers.push(require('./controllers/tag_controller.js'));

View File

@ -190,6 +190,31 @@ class Post extends events.EventTarget {
});
}
merge(targetId, useOldContent) {
return api.get('/post/' + encodeURIComponent(targetId))
.then(response => {
return api.post('/post-merge/', {
removeVersion: this._version,
remove: this._id,
mergeToVersion: response.version,
mergeTo: targetId,
replaceContent: useOldContent,
});
}, response => {
return Promise.reject(response);
}).then(response => {
this._updateFromResponse(response);
this.dispatchEvent(new CustomEvent('change', {
detail: {
post: this,
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
setScore(score) {
return api.put('/post/' + this._id + '/score', {score: score})
.then(response => {

View File

@ -53,35 +53,41 @@ function makeThumbnail(url) {
function makeRadio(options) {
_imbueId(options);
return makeVoidElement(
'input',
{
id: options.id,
name: options.name,
value: options.value,
type: 'radio',
checked: options.selectedValue === options.value,
disabled: options.readonly,
required: options.required,
}) +
_makeLabel(options, {class: 'radio'});
return makeNonVoidElement(
'label',
{for: options.id},
makeVoidElement(
'input',
{
id: options.id,
name: options.name,
value: options.value,
type: 'radio',
checked: options.selectedValue === options.value,
disabled: options.readonly,
required: options.required,
}) +
makeNonVoidElement('span', {class: 'radio'}, options.text));
}
function makeCheckbox(options) {
_imbueId(options);
return makeVoidElement(
'input',
{
id: options.id,
name: options.name,
value: options.value,
type: 'checkbox',
checked: options.checked !== undefined ?
options.checked : false,
disabled: options.readonly,
required: options.required,
}) +
_makeLabel(options, {class: 'checkbox'});
return makeNonVoidElement(
'label',
{for: options.id},
makeVoidElement(
'input',
{
id: options.id,
name: options.name,
value: options.value,
type: 'checkbox',
checked: options.checked !== undefined ?
options.checked : false,
disabled: options.readonly,
required: options.required,
}) +
makeNonVoidElement('span', {class: 'checkbox'}, options.text));
}
function makeSelect(options) {

View File

@ -0,0 +1,80 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
const PostMergeView = require('./post_merge_view.js');
const EmptyView = require('../views/empty_view.js');
const template = views.getTemplate('post-detail');
class PostDetailView extends events.EventTarget {
constructor(ctx) {
super();
this._ctx = ctx;
ctx.post.addEventListener('change', e => this._evtChange(e));
ctx.section = ctx.section || 'summary';
this._hostNode = document.getElementById('content-holder');
this._install();
}
_install() {
const ctx = this._ctx;
views.replaceContent(this._hostNode, template(ctx));
for (let item of this._hostNode.querySelectorAll('[data-name]')) {
item.classList.toggle(
'active', item.getAttribute('data-name') === ctx.section);
}
ctx.hostNode = this._hostNode.querySelector('.post-content-holder');
if (ctx.section === 'merge') {
if (!this._ctx.canMerge) {
this._view = new EmptyView();
this._view.showError(
'You don\'t have privileges to merge posts.');
} else {
this._view = new PostMergeView(ctx);
events.proxyEvent(this._view, this, 'select');
events.proxyEvent(this._view, this, 'submit', 'merge');
}
} else {
// this._view = new PostSummaryView(ctx);
}
views.syncScrollPosition();
}
clearMessages() {
this._view.clearMessages();
}
enableForm() {
this._view.enableForm();
}
disableForm() {
this._view.disableForm();
}
showSuccess(message) {
this._view.showSuccess(message);
}
showError(message) {
this._view.showError(message);
}
selectPost(post) {
this._view.selectPost(post);
}
_evtChange(e) {
this._ctx.post = e.detail.post;
this._install(this._ctx);
}
}
module.exports = PostDetailView;

View File

@ -13,9 +13,9 @@ const PostEditSidebarControl =
const CommentListControl = require('../controls/comment_list_control.js');
const CommentFormControl = require('../controls/comment_form_control.js');
const template = views.getTemplate('post');
const template = views.getTemplate('post-main');
class PostView {
class PostMainView {
constructor(ctx) {
this._hostNode = document.getElementById('content-holder');
@ -118,4 +118,4 @@ class PostView {
}
}
module.exports = PostView;
module.exports = PostMainView;

View File

@ -0,0 +1,129 @@
'use strict';
const config = require('../config.js');
const events = require('../events.js');
const views = require('../util/views.js');
const KEY_RETURN = 13;
const template = views.getTemplate('post-merge');
const sideTemplate = views.getTemplate('post-merge-side');
class PostMergeView extends events.EventTarget {
constructor(ctx) {
super();
this._ctx = ctx;
this._post = ctx.post;
this._hostNode = ctx.hostNode;
this._leftPost = ctx.post;
this._rightPost = null;
views.replaceContent(this._hostNode, template(this._ctx));
views.decorateValidator(this._formNode);
this._refreshLeftSide();
this._refreshRightSide();
this._formNode.addEventListener('submit', e => this._evtSubmit(e));
}
clearMessages() {
views.clearMessages(this._hostNode);
}
enableForm() {
views.enableForm(this._formNode);
}
disableForm() {
views.disableForm(this._formNode);
}
showSuccess(message) {
views.showSuccess(this._hostNode, message);
}
showError(message) {
views.showError(this._hostNode, message);
}
selectPost(post) {
this._rightPost = post;
this._refreshRightSide();
}
_refreshLeftSide() {
views.replaceContent(
this._leftSideNode,
sideTemplate(Object.assign({}, this._ctx, {
post: this._leftPost,
name: 'left',
editable: false})));
}
_refreshRightSide() {
views.replaceContent(
this._rightSideNode,
sideTemplate(Object.assign({}, this._ctx, {
post: this._rightPost,
name: 'right',
editable: true})));
if (this._targetPostFieldNode) {
this._targetPostFieldNode.addEventListener(
'keydown', e => this._evtTargetPostFieldKeyDown(e));
}
}
_evtSubmit(e) {
e.preventDefault();
const checkedTargetPost = this._formNode.querySelector(
'.target-post :checked').value;
const checkedTargetPostContent = this._formNode.querySelector(
'.target-post-content :checked').value;
this.dispatchEvent(new CustomEvent('submit', {
detail: {
post: checkedTargetPost == 'left' ?
this._rightPost :
this._leftPost,
targetPost: checkedTargetPost == 'left' ?
this._leftPost :
this._rightPost,
useOldContent: checkedTargetPostContent !== checkedTargetPost,
},
}));
}
_evtTargetPostFieldKeyDown(e) {
const key = e.which;
if (key !== KEY_RETURN) {
return;
}
e.target.blur();
e.preventDefault();
this.dispatchEvent(new CustomEvent('select', {
detail: {
postId: this._targetPostFieldNode.value,
},
}));
}
get _formNode() {
return this._hostNode.querySelector('form');
}
get _leftSideNode() {
return this._hostNode.querySelector('.left-post-container');
}
get _rightSideNode() {
return this._hostNode.querySelector('.right-post-container');
}
get _targetPostFieldNode() {
return this._formNode.querySelector(
'.post-mirror input:not([readonly])[type=text]');
}
}
module.exports = PostMergeView;

View File

@ -80,6 +80,7 @@ privileges:
'posts:feature': moderator
'posts:delete': moderator
'posts:score': regular
'posts:merge': moderator
'posts:favorite': regular
'tags:create': regular

View File

@ -124,6 +124,23 @@ def delete_post(ctx, params):
return {}
@routes.post('/post-merge/?')
def merge_posts(ctx, _params=None):
source_post_id = ctx.get_param_as_string('remove', required=True) or ''
target_post_id = ctx.get_param_as_string('mergeTo', required=True) or ''
replace_content = ctx.get_param_as_bool('replaceContent')
source_post = posts.get_post_by_id(source_post_id)
target_post = posts.get_post_by_id(target_post_id)
versions.verify_version(source_post, ctx, 'removeVersion')
versions.verify_version(target_post, ctx, 'mergeToVersion')
versions.bump_version(target_post)
auth.verify_privilege(ctx.user, 'posts:merge')
posts.merge_posts(source_post, target_post, replace_content)
snapshots.merge(source_post, target_post, ctx.user)
ctx.session.commit()
return _serialize_post(ctx, target_post)
@routes.get('/featured-post/?')
def get_featured_post(ctx, _params=None):
post = posts.try_get_featured_post()

View File

@ -1,14 +1,14 @@
from datetime import datetime
class LruCacheItem(object):
class LruCacheItem:
def __init__(self, key, value):
self.key = key
self.value = value
self.timestamp = datetime.utcnow()
class LruCache(object):
class LruCache:
def __init__(self, length, delta=None):
self.length = length
self.delta = delta

View File

@ -14,7 +14,7 @@ _SCALE_FIT_FMT = \
r'scale=iw*max({width}/iw\,{height}/ih):ih*max({width}/iw\,{height}/ih)'
class Image(object):
class Image:
def __init__(self, content):
self.content = content
self._reload_info()

View File

@ -440,3 +440,84 @@ def feature_post(post, user):
def delete(post):
assert post
db.session.delete(post)
def merge_posts(source_post, target_post, replace_content):
assert source_post
assert target_post
if source_post.post_id == target_post.post_id:
raise InvalidPostRelationError('Cannot merge post with itself.')
def merge_tables(table, anti_dup_func, source_post_id, target_post_id):
alias1 = table
alias2 = sqlalchemy.orm.util.aliased(table)
update_stmt = (sqlalchemy.sql.expression.update(alias1)
.where(alias1.post_id == source_post_id))
if anti_dup_func is not None:
update_stmt = (update_stmt
.where(~sqlalchemy.exists()
.where(anti_dup_func(alias1, alias2))
.where(alias2.post_id == target_post_id)))
update_stmt = update_stmt.values(post_id=target_post_id)
db.session.execute(update_stmt)
def merge_tags(source_post_id, target_post_id):
merge_tables(
db.PostTag,
lambda alias1, alias2: alias1.tag_id == alias2.tag_id,
source_post_id,
target_post_id)
def merge_scores(source_post_id, target_post_id):
merge_tables(
db.PostScore,
lambda alias1, alias2: alias1.user_id == alias2.user_id,
source_post_id,
target_post_id)
def merge_favorites(source_post_id, target_post_id):
merge_tables(
db.PostFavorite,
lambda alias1, alias2: alias1.user_id == alias2.user_id,
source_post_id,
target_post_id)
def merge_comments(source_post_id, target_post_id):
merge_tables(db.Comment, None, source_post_id, target_post_id)
def merge_relations(source_post_id, target_post_id):
alias1 = db.PostRelation
alias2 = sqlalchemy.orm.util.aliased(db.PostRelation)
update_stmt = (sqlalchemy.sql.expression.update(alias1)
.where(alias1.parent_id == source_post_id)
.where(alias1.child_id != target_post_id)
.where(~sqlalchemy.exists()
.where(alias2.child_id == alias1.child_id)
.where(alias2.parent_id == target_post_id))
.values(parent_id=target_post_id))
db.session.execute(update_stmt)
update_stmt = (sqlalchemy.sql.expression.update(alias1)
.where(alias1.child_id == source_post_id)
.where(alias1.parent_id != target_post_id)
.where(~sqlalchemy.exists()
.where(alias2.parent_id == alias1.parent_id)
.where(alias2.child_id == target_post_id))
.values(child_id=target_post_id))
db.session.execute(update_stmt)
merge_tags(source_post.post_id, target_post.post_id)
merge_comments(source_post.post_id, target_post.post_id)
merge_scores(source_post.post_id, target_post.post_id)
merge_favorites(source_post.post_id, target_post.post_id)
merge_relations(source_post.post_id, target_post.post_id)
delete(source_post)
db.session.flush()
if replace_content:
content = files.get(get_post_content_path(source_post))
update_post_content(target_post, content)

View File

@ -223,16 +223,49 @@ def merge_tags(source_tag, target_tag):
assert target_tag
if source_tag.tag_id == target_tag.tag_id:
raise InvalidTagRelationError('Cannot merge tag with itself.')
pt1 = db.PostTag
pt2 = sqlalchemy.orm.util.aliased(db.PostTag)
update_stmt = (sqlalchemy.sql.expression.update(pt1)
.where(db.PostTag.tag_id == source_tag.tag_id)
.where(~sqlalchemy.exists()
.where(pt2.post_id == pt1.post_id)
.where(pt2.tag_id == target_tag.tag_id))
.values(tag_id=target_tag.tag_id))
db.session.execute(update_stmt)
def merge_posts(source_tag_id, target_tag_id):
alias1 = db.PostTag
alias2 = sqlalchemy.orm.util.aliased(db.PostTag)
update_stmt = (sqlalchemy.sql.expression.update(alias1)
.where(alias1.tag_id == source_tag_id))
update_stmt = (update_stmt
.where(~sqlalchemy.exists()
.where(alias1.post_id == alias2.post_id)
.where(alias2.tag_id == target_tag_id)))
update_stmt = update_stmt.values(tag_id=target_tag_id)
db.session.execute(update_stmt)
def merge_relations(table, source_tag_id, target_tag_id):
alias1 = table
alias2 = sqlalchemy.orm.util.aliased(table)
update_stmt = (sqlalchemy.sql.expression.update(alias1)
.where(alias1.parent_id == source_tag_id)
.where(alias1.child_id != target_tag_id)
.where(~sqlalchemy.exists()
.where(alias2.child_id == alias1.child_id)
.where(alias2.parent_id == target_tag_id))
.values(parent_id=target_tag_id))
db.session.execute(update_stmt)
update_stmt = (sqlalchemy.sql.expression.update(alias1)
.where(alias1.child_id == source_tag_id)
.where(alias1.parent_id != target_tag_id)
.where(~sqlalchemy.exists()
.where(alias2.parent_id == alias1.parent_id)
.where(alias2.child_id == target_tag_id))
.values(child_id=target_tag_id))
db.session.execute(update_stmt)
def merge_suggestions(source_tag_id, target_tag_id):
merge_relations(db.TagSuggestion, source_tag_id, target_tag_id)
def merge_implications(source_tag_id, target_tag_id):
merge_relations(db.TagImplication, source_tag_id, target_tag_id)
merge_posts(source_tag.tag_id, target_tag.tag_id)
merge_suggestions(source_tag.tag_id, target_tag.tag_id)
merge_implications(source_tag.tag_id, target_tag.tag_id)
delete(source_tag)

View File

@ -25,7 +25,7 @@ def _param_wrapper(func):
return wrapper
class Context():
class Context:
def __init__(self, method, url, headers=None, params=None, files=None):
self.method = method
self.url = url

View File

@ -1,7 +1,7 @@
from szurubooru.search import tokens
class BaseSearchConfig(object):
class BaseSearchConfig:
SORT_ASC = tokens.SortToken.SORT_ASC
SORT_DESC = tokens.SortToken.SORT_DESC

View File

@ -1,4 +1,4 @@
class _BaseCriterion(object):
class _BaseCriterion:
def __init__(self, original_text):
self.original_text = original_text

View File

@ -20,7 +20,7 @@ def _get_order(order, default_order):
return order
class Executor(object):
class Executor:
'''
Class for search parsing and execution. Handles plaintext parsing and
delegates sqlalchemy filter decoration to SearchConfig instances.

View File

@ -67,7 +67,7 @@ def _parse_sort(value, negated):
return tokens.SortToken(value, order)
class SearchQuery():
class SearchQuery:
def __init__(self):
self.anonymous_tokens = []
self.named_tokens = []
@ -82,7 +82,7 @@ class SearchQuery():
tuple(self.sort_tokens)))
class Parser(object):
class Parser:
def parse(self, query_text):
query = SearchQuery()
for chunk in re.split(r'\s+', (query_text or '').lower()):

View File

@ -1,4 +1,4 @@
class AnonymousToken(object):
class AnonymousToken:
def __init__(self, criterion, negated):
self.criterion = criterion
self.negated = negated
@ -16,7 +16,7 @@ class NamedToken(AnonymousToken):
return hash((self.name, self.criterion, self.negated))
class SortToken(object):
class SortToken:
SORT_DESC = 'desc'
SORT_ASC = 'asc'
SORT_DEFAULT = 'default'
@ -30,7 +30,7 @@ class SortToken(object):
return hash((self.name, self.order))
class SpecialToken(object):
class SpecialToken:
def __init__(self, value, negated):
self.value = value
self.negated = negated

View File

@ -0,0 +1,89 @@
from unittest.mock import patch
import pytest
from szurubooru import api, db, errors
from szurubooru.func import posts, snapshots
@pytest.fixture(autouse=True)
def inject_config(config_injector):
config_injector({'privileges': {'posts:merge': db.User.RANK_REGULAR}})
def test_merging(user_factory, context_factory, post_factory):
auth_user = user_factory(rank=db.User.RANK_REGULAR)
source_post = post_factory()
target_post = post_factory()
db.session.add_all([source_post, target_post])
db.session.flush()
with patch('szurubooru.func.posts.serialize_post'), \
patch('szurubooru.func.posts.merge_posts'), \
patch('szurubooru.func.snapshots.merge'):
api.post_api.merge_posts(
context_factory(
params={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': source_post.post_id,
'mergeTo': target_post.post_id,
},
user=auth_user))
posts.merge_posts.called_once_with(source_post, target_post)
snapshots.merge.assert_called_once_with(
source_post, target_post, auth_user)
@pytest.mark.parametrize(
'field', ['remove', 'mergeTo', 'removeVersion', 'mergeToVersion'])
def test_trying_to_omit_mandatory_field(
user_factory, post_factory, context_factory, field):
source_post = post_factory()
target_post = post_factory()
db.session.add_all([source_post, target_post])
db.session.commit()
params = {
'removeVersion': 1,
'mergeToVersion': 1,
'remove': source_post.post_id,
'mergeTo': target_post.post_id,
}
del params[field]
with pytest.raises(errors.ValidationError):
api.post_api.merge_posts(
context_factory(
params=params,
user=user_factory(rank=db.User.RANK_REGULAR)))
def test_trying_to_merge_non_existing(
user_factory, post_factory, context_factory):
post = post_factory()
db.session.add(post)
db.session.commit()
with pytest.raises(posts.PostNotFoundError):
api.post_api.merge_posts(
context_factory(
params={'remove': post.post_id, 'mergeTo': 999},
user=user_factory(rank=db.User.RANK_REGULAR)))
with pytest.raises(posts.PostNotFoundError):
api.post_api.merge_posts(
context_factory(
params={'remove': 999, 'mergeTo': post.post_id},
user=user_factory(rank=db.User.RANK_REGULAR)))
def test_trying_to_merge_without_privileges(
user_factory, post_factory, context_factory):
source_post = post_factory()
target_post = post_factory()
db.session.add_all([source_post, target_post])
db.session.commit()
with pytest.raises(errors.AuthError):
api.post_api.merge_posts(
context_factory(
params={
'removeVersion': 1,
'mergeToVersion': 1,
'remove': source_post.post_id,
'mergeTo': target_post.post_id,
},
user=user_factory(rank=db.User.RANK_ANONYMOUS)))

View File

@ -10,7 +10,7 @@ import sqlalchemy
from szurubooru import config, db, rest
class QueryCounter(object):
class QueryCounter:
def __init__(self):
self._statements = []
@ -192,6 +192,30 @@ def comment_factory(user_factory, post_factory):
return factory
@pytest.fixture
def post_score_factory(user_factory, post_factory):
def factory(post=None, user=None, score=1):
if user is None:
user = user_factory()
if post is None:
post = post_factory()
return db.PostScore(
post=post, user=user, score=score, time=datetime(1999, 1, 1))
return factory
@pytest.fixture
def post_favorite_factory(user_factory, post_factory):
def factory(post=None, user=None):
if user is None:
user = user_factory()
if post is None:
post = post_factory()
return db.PostFavorite(
post=post, user=user, time=datetime(1999, 1, 1))
return factory
@pytest.fixture
def read_asset():
def get(path):

View File

@ -605,3 +605,251 @@ def test_delete(post_factory):
posts.delete(post)
db.session.flush()
assert posts.get_post_count() == 0
def test_merge_posts_deletes_source_post(post_factory):
source_post = post_factory()
target_post = post_factory()
db.session.add_all([source_post, target_post])
db.session.flush()
posts.merge_posts(source_post, target_post, False)
db.session.flush()
assert posts.try_get_post_by_id(source_post.post_id) is None
post = posts.get_post_by_id(target_post.post_id)
assert post is not None
def test_merge_posts_with_itself(post_factory):
source_post = post_factory()
db.session.add(source_post)
db.session.flush()
with pytest.raises(posts.InvalidPostRelationError):
posts.merge_posts(source_post, source_post, False)
def test_merge_posts_moves_tags(post_factory, tag_factory):
source_post = post_factory()
target_post = post_factory()
tag = tag_factory()
tag.posts = [source_post]
db.session.add_all([source_post, target_post, tag])
db.session.commit()
assert source_post.tag_count == 1
assert target_post.tag_count == 0
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).tag_count == 1
def test_merge_posts_doesnt_duplicate_tags(post_factory, tag_factory):
source_post = post_factory()
target_post = post_factory()
tag = tag_factory()
tag.posts = [source_post, target_post]
db.session.add_all([source_post, target_post, tag])
db.session.commit()
assert source_post.tag_count == 1
assert target_post.tag_count == 1
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).tag_count == 1
def test_merge_posts_moves_comments(post_factory, comment_factory):
source_post = post_factory()
target_post = post_factory()
comment = comment_factory(post=source_post)
db.session.add_all([source_post, target_post, comment])
db.session.commit()
assert source_post.comment_count == 1
assert target_post.comment_count == 0
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).comment_count == 1
def test_merge_posts_moves_scores(post_factory, post_score_factory):
source_post = post_factory()
target_post = post_factory()
score = post_score_factory(post=source_post, score=1)
db.session.add_all([source_post, target_post, score])
db.session.commit()
assert source_post.score == 1
assert target_post.score == 0
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).score == 1
def test_merge_posts_doesnt_duplicate_scores(
post_factory, user_factory, post_score_factory):
source_post = post_factory()
target_post = post_factory()
user = user_factory()
score1 = post_score_factory(post=source_post, score=1, user=user)
score2 = post_score_factory(post=target_post, score=1, user=user)
db.session.add_all([source_post, target_post, score1, score2])
db.session.commit()
assert source_post.score == 1
assert target_post.score == 1
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).score == 1
def test_merge_posts_moves_favorites(post_factory, post_favorite_factory):
source_post = post_factory()
target_post = post_factory()
favorite = post_favorite_factory(post=source_post)
db.session.add_all([source_post, target_post, favorite])
db.session.commit()
assert source_post.favorite_count == 1
assert target_post.favorite_count == 0
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).favorite_count == 1
def test_merge_posts_doesnt_duplicate_favorites(
post_factory, user_factory, post_favorite_factory):
source_post = post_factory()
target_post = post_factory()
user = user_factory()
favorite1 = post_favorite_factory(post=source_post, user=user)
favorite2 = post_favorite_factory(post=target_post, user=user)
db.session.add_all([source_post, target_post, favorite1, favorite2])
db.session.commit()
assert source_post.favorite_count == 1
assert target_post.favorite_count == 1
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).favorite_count == 1
def test_merge_posts_moves_child_relations(post_factory):
source_post = post_factory()
target_post = post_factory()
related_post = post_factory()
source_post.relations = [related_post]
db.session.add_all([source_post, target_post, related_post])
db.session.commit()
assert source_post.relation_count == 1
assert target_post.relation_count == 0
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).relation_count == 1
def test_merge_posts_doesnt_duplicate_child_relations(post_factory):
source_post = post_factory()
target_post = post_factory()
related_post = post_factory()
source_post.relations = [related_post]
target_post.relations = [related_post]
db.session.add_all([source_post, target_post, related_post])
db.session.commit()
assert source_post.relation_count == 1
assert target_post.relation_count == 1
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).relation_count == 1
def test_merge_posts_moves_parent_relations(post_factory):
source_post = post_factory()
target_post = post_factory()
related_post = post_factory()
related_post.relations = [source_post]
db.session.add_all([source_post, target_post, related_post])
db.session.commit()
assert source_post.relation_count == 1
assert target_post.relation_count == 0
assert related_post.relation_count == 1
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).relation_count == 1
assert posts.get_post_by_id(related_post.post_id).relation_count == 1
def test_merge_posts_doesnt_duplicate_parent_relations(post_factory):
source_post = post_factory()
target_post = post_factory()
related_post = post_factory()
related_post.relations = [source_post, target_post]
db.session.add_all([source_post, target_post, related_post])
db.session.commit()
assert source_post.relation_count == 1
assert target_post.relation_count == 1
assert related_post.relation_count == 2
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).relation_count == 1
assert posts.get_post_by_id(related_post.post_id).relation_count == 1
def test_merge_posts_doesnt_create_relation_loop_for_children(post_factory):
source_post = post_factory()
target_post = post_factory()
source_post.relations = [target_post]
db.session.add_all([source_post, target_post])
db.session.commit()
assert source_post.relation_count == 1
assert target_post.relation_count == 1
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).relation_count == 0
def test_merge_posts_doesnt_create_relation_loop_for_parents(post_factory):
source_post = post_factory()
target_post = post_factory()
target_post.relations = [source_post]
db.session.add_all([source_post, target_post])
db.session.commit()
assert source_post.relation_count == 1
assert target_post.relation_count == 1
posts.merge_posts(source_post, target_post, False)
db.session.commit()
assert posts.try_get_post_by_id(source_post.post_id) is None
assert posts.get_post_by_id(target_post.post_id).relation_count == 0
def test_merge_posts_replaces_content(
post_factory, config_injector, tmpdir, read_asset):
config_injector({
'data_dir': str(tmpdir.mkdir('data')),
'data_url': 'example.com',
'thumbnails': {
'post_width': 300,
'post_height': 300,
},
})
source_post = post_factory()
target_post = post_factory()
content = read_asset('png.png')
db.session.add_all([source_post, target_post])
db.session.commit()
posts.update_post_content(source_post, content)
db.session.flush()
assert os.path.exists(os.path.join(str(tmpdir), 'data/posts/1.png'))
assert not os.path.exists(os.path.join(str(tmpdir), 'data/posts/2.dat'))
assert not os.path.exists(os.path.join(str(tmpdir), 'data/posts/2.png'))
posts.merge_posts(source_post, target_post, True)
db.session.flush()
assert posts.try_get_post_by_id(source_post.post_id) is None
post = posts.get_post_by_id(target_post.post_id)
assert post is not None
assert os.path.exists(os.path.join(str(tmpdir), 'data/posts/1.png'))
assert os.path.exists(os.path.join(str(tmpdir), 'data/posts/2.png'))

View File

@ -310,7 +310,7 @@ def test_delete(tag_factory):
assert db.session.query(db.Tag).count() == 2
def test_merge_tags_without_usages(tag_factory):
def test_merge_tags_deletes_source_tag(tag_factory):
source_tag = tag_factory(names=['source'])
target_tag = tag_factory(names=['target'])
db.session.add_all([source_tag, target_tag])
@ -322,7 +322,15 @@ def test_merge_tags_without_usages(tag_factory):
assert tag is not None
def test_merge_tags_with_usages(tag_factory, post_factory):
def test_merge_tags_with_itself(tag_factory):
source_tag = tag_factory(names=['source'])
db.session.add(source_tag)
db.session.flush()
with pytest.raises(tags.InvalidTagRelationError):
tags.merge_tags(source_tag, source_tag)
def test_merge_tags_moves_usages(tag_factory, post_factory):
source_tag = tag_factory(names=['source'])
target_tag = tag_factory(names=['target'])
post = post_factory()
@ -337,62 +345,7 @@ def test_merge_tags_with_usages(tag_factory, post_factory):
assert tags.get_tag_by_name('target').post_count == 1
def test_merge_tags_with_itself(tag_factory):
source_tag = tag_factory(names=['source'])
db.session.add(source_tag)
db.session.flush()
with pytest.raises(tags.InvalidTagRelationError):
tags.merge_tags(source_tag, source_tag)
def test_merge_tags_with_its_child_relation(tag_factory, post_factory):
source_tag = tag_factory(names=['source'])
target_tag = tag_factory(names=['target'])
source_tag.suggestions = [target_tag]
source_tag.implications = [target_tag]
post = post_factory()
post.tags = [source_tag, target_tag]
db.session.add_all([source_tag, post])
db.session.flush()
tags.merge_tags(source_tag, target_tag)
db.session.flush()
assert tags.try_get_tag_by_name('source') is None
assert tags.get_tag_by_name('target').post_count == 1
def test_merge_tags_with_its_parent_relation(tag_factory, post_factory):
source_tag = tag_factory(names=['source'])
target_tag = tag_factory(names=['target'])
target_tag.suggestions = [source_tag]
target_tag.implications = [source_tag]
post = post_factory()
post.tags = [source_tag, target_tag]
db.session.add_all([source_tag, target_tag, post])
db.session.flush()
tags.merge_tags(source_tag, target_tag)
db.session.flush()
assert tags.try_get_tag_by_name('source') is None
assert tags.get_tag_by_name('target').post_count == 1
def test_merge_tags_clears_relations(tag_factory):
source_tag = tag_factory(names=['source'])
target_tag = tag_factory(names=['target'])
referring_tag = tag_factory(names=['parent'])
referring_tag.suggestions = [source_tag]
referring_tag.implications = [source_tag]
db.session.add_all([source_tag, target_tag, referring_tag])
db.session.flush()
assert tags.try_get_tag_by_name('parent').implications != []
assert tags.try_get_tag_by_name('parent').suggestions != []
tags.merge_tags(source_tag, target_tag)
db.session.commit()
assert tags.try_get_tag_by_name('source') is None
assert tags.try_get_tag_by_name('parent').implications == []
assert tags.try_get_tag_by_name('parent').suggestions == []
def test_merge_tags_when_target_exists(tag_factory, post_factory):
def test_merge_tags_doesnt_duplicate_usages(tag_factory, post_factory):
source_tag = tag_factory(names=['source'])
target_tag = tag_factory(names=['target'])
post = post_factory()
@ -407,6 +360,103 @@ def test_merge_tags_when_target_exists(tag_factory, post_factory):
assert tags.get_tag_by_name('target').post_count == 1
def test_merge_tags_moves_child_relations(tag_factory):
source_tag = tag_factory(names=['source'])
target_tag = tag_factory(names=['target'])
related_tag = tag_factory()
source_tag.suggestions = [related_tag]
source_tag.implications = [related_tag]
db.session.add_all([source_tag, target_tag, related_tag])
db.session.commit()
assert source_tag.suggestion_count == 1
assert source_tag.implication_count == 1
assert target_tag.suggestion_count == 0
assert target_tag.implication_count == 0
tags.merge_tags(source_tag, target_tag)
db.session.commit()
assert tags.try_get_tag_by_name('source') is None
assert tags.get_tag_by_name('target').suggestion_count == 1
assert tags.get_tag_by_name('target').implication_count == 1
def test_merge_tags_doesnt_duplicate_child_relations(tag_factory):
source_tag = tag_factory(names=['source'])
target_tag = tag_factory(names=['target'])
related_tag = tag_factory()
source_tag.suggestions = [related_tag]
source_tag.implications = [related_tag]
target_tag.suggestions = [related_tag]
target_tag.implications = [related_tag]
db.session.add_all([source_tag, target_tag, related_tag])
db.session.commit()
assert source_tag.suggestion_count == 1
assert source_tag.implication_count == 1
assert target_tag.suggestion_count == 1
assert target_tag.implication_count == 1
tags.merge_tags(source_tag, target_tag)
db.session.commit()
assert tags.try_get_tag_by_name('source') is None
assert tags.get_tag_by_name('target').suggestion_count == 1
assert tags.get_tag_by_name('target').implication_count == 1
def test_merge_tags_moves_parent_relations(tag_factory):
source_tag = tag_factory(names=['source'])
target_tag = tag_factory(names=['target'])
related_tag = tag_factory(names=['related'])
related_tag.suggestions = [related_tag]
related_tag.implications = [related_tag]
db.session.add_all([source_tag, target_tag, related_tag])
db.session.commit()
assert source_tag.suggestion_count == 0
assert source_tag.implication_count == 0
assert target_tag.suggestion_count == 0
assert target_tag.implication_count == 0
tags.merge_tags(source_tag, target_tag)
db.session.commit()
assert tags.try_get_tag_by_name('source') is None
assert tags.get_tag_by_name('related').suggestion_count == 1
assert tags.get_tag_by_name('related').suggestion_count == 1
assert tags.get_tag_by_name('target').suggestion_count == 0
assert tags.get_tag_by_name('target').implication_count == 0
def test_merge_tags_doesnt_create_relation_loop_for_children(tag_factory):
source_tag = tag_factory(names=['source'])
target_tag = tag_factory(names=['target'])
source_tag.suggestions = [target_tag]
source_tag.implications = [target_tag]
db.session.add_all([source_tag, target_tag])
db.session.commit()
assert source_tag.suggestion_count == 1
assert source_tag.implication_count == 1
assert target_tag.suggestion_count == 0
assert target_tag.implication_count == 0
tags.merge_tags(source_tag, target_tag)
db.session.commit()
assert tags.try_get_tag_by_name('source') is None
assert tags.get_tag_by_name('target').suggestion_count == 0
assert tags.get_tag_by_name('target').implication_count == 0
def test_merge_tags_doesnt_create_relation_loop_for_parents(tag_factory):
source_tag = tag_factory(names=['source'])
target_tag = tag_factory(names=['target'])
target_tag.suggestions = [source_tag]
target_tag.implications = [source_tag]
db.session.add_all([source_tag, target_tag])
db.session.commit()
assert source_tag.suggestion_count == 0
assert source_tag.implication_count == 0
assert target_tag.suggestion_count == 1
assert target_tag.implication_count == 1
tags.merge_tags(source_tag, target_tag)
db.session.commit()
assert tags.try_get_tag_by_name('source') is None
assert tags.get_tag_by_name('target').suggestion_count == 0
assert tags.get_tag_by_name('target').implication_count == 0
def test_create_tag(fake_datetime):
with patch('szurubooru.func.tags.update_tag_names'), \
patch('szurubooru.func.tags.update_tag_category_name'), \