diff --git a/client/css/pool-list-view.styl b/client/css/pool-list-view.styl index 2333a372..2daf6941 100644 --- a/client/css/pool-list-view.styl +++ b/client/css/pool-list-view.styl @@ -14,7 +14,7 @@ white-space: nowrap background: $top-navigation-color .names - width: 28% + width: 84% .usages text-align: center width: 8% @@ -33,7 +33,7 @@ &:not(:last-child):after content: ', ' @media (max-width: 800px) - .implications, .suggestions + .posts display: none .pool-list-header diff --git a/client/html/help_search_posts.tpl b/client/html/help_search_posts.tpl index d1097b79..30a986f0 100644 --- a/client/html/help_search_posts.tpl +++ b/client/html/help_search_posts.tpl @@ -20,7 +20,7 @@ uploader - uploaded by given use (accepts wildcards)r + uploaded by given user (accepts wildcards) upload @@ -42,6 +42,10 @@ source having given source URL (accepts wildcards) + + pool + belonging to given pool ID + tag-count having given number of tags diff --git a/client/html/pool_create.tpl b/client/html/pool_create.tpl index fec95ef4..bf84dc92 100644 --- a/client/html/pool_create.tpl +++ b/client/html/pool_create.tpl @@ -22,6 +22,12 @@ value: '', }) %> +
  • + <%= ctx.makeTextInput({ + text: 'Posts', + value: '', + }) %> +
  • <% if (ctx.canCreate) { %> diff --git a/client/html/pool_edit.tpl b/client/html/pool_edit.tpl index 0a0e1b01..1cbb62e3 100644 --- a/client/html/pool_edit.tpl +++ b/client/html/pool_edit.tpl @@ -28,6 +28,14 @@ }) %> <% } %> +
  • + <% if (ctx.canEditPosts) { %> + <%= ctx.makeTextInput({ + text: 'Posts', + value: ctx.pool.posts.map(post => post.id).join(' ') + }) %> + <% } %> +
  • <% if (ctx.canEditAnything) { %> diff --git a/client/html/pool_merge.tpl b/client/html/pool_merge.tpl index ce0bf925..ea5776ae 100644 --- a/client/html/pool_merge.tpl +++ b/client/html/pool_merge.tpl @@ -6,7 +6,7 @@
  • -

    Posts between the two pools will be combined. +

    Posts in the two pools will be combined. Category needs to be handled manually.

    <%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this pool.'}) %> diff --git a/client/html/pool_summary.tpl b/client/html/pool_summary.tpl index 7b7c7636..8f4e27d0 100644 --- a/client/html/pool_summary.tpl +++ b/client/html/pool_summary.tpl @@ -9,7 +9,7 @@ Aliases:
    @@ -18,6 +18,6 @@

    <%= ctx.makeMarkdown(ctx.pool.description || 'This pool has no description yet.') %> -

    This pool has '><%- ctx.pool.postCount %> post(s).

    +

    This pool has '><%- ctx.pool.postCount %> post(s).

    diff --git a/client/html/pools_page.tpl b/client/html/pools_page.tpl index 19f3f2a2..6b394839 100644 --- a/client/html/pools_page.tpl +++ b/client/html/pools_page.tpl @@ -30,12 +30,12 @@ - '><%- pool.postCount %> + '><%- pool.postCount %> <%= ctx.makeRelativeTime(pool.creationTime) %> diff --git a/client/js/controllers/pool_controller.js b/client/js/controllers/pool_controller.js index 8623cf84..99e2e9bc 100644 --- a/client/js/controllers/pool_controller.js +++ b/client/js/controllers/pool_controller.js @@ -5,6 +5,7 @@ const api = require('../api.js'); const misc = require('../util/misc.js'); const uri = require('../util/uri.js'); const Pool = require('../models/pool.js'); +const Post = require('../models/post.js'); const PoolCategoryList = require('../models/pool_category_list.js'); const topNavigation = require('../models/top_navigation.js'); const PoolView = require('../views/pool_view.js'); @@ -42,6 +43,7 @@ class PoolController { canEditNames: api.hasPrivilege('pools:edit:names'), canEditCategory: api.hasPrivilege('pools:edit:category'), canEditDescription: api.hasPrivilege('pools:edit:description'), + canEditPosts: api.hasPrivilege('pools:edit:posts'), canMerge: api.hasPrivilege('pools:merge'), canDelete: api.hasPrivilege('pools:delete'), categories: categories, @@ -84,6 +86,12 @@ class PoolController { if (e.detail.description !== undefined) { e.detail.pool.description = e.detail.description; } + if (e.detail.posts !== undefined) { + e.detail.pool.posts.clear() + for (let post_id of e.detail.posts) { + e.detail.pool.posts.add(Post.fromResponse({ id: parseInt(post_id) })) + } + } e.detail.pool.save().then(() => { this._view.showSuccess('Pool saved.'); this._view.enableForm(); diff --git a/client/js/controllers/pool_create_controller.js b/client/js/controllers/pool_create_controller.js index 0aa3c764..ef6dded2 100644 --- a/client/js/controllers/pool_create_controller.js +++ b/client/js/controllers/pool_create_controller.js @@ -39,7 +39,7 @@ class PoolCreateController { _evtCreate(e) { this._view.clearMessages(); this._view.disableForm(); - e.detail.pool.save() + api.post(uri.formatApiLink('pool'), e.detail) .then(() => { this._view.clearMessages(); misc.disableExitConfirmation(); @@ -50,10 +50,6 @@ class PoolCreateController { this._view.enableForm(); }); } - - _evtChange(e) { - misc.enableExitConfirmation(); - } } module.exports = router => { diff --git a/client/js/controllers/pool_list_controller.js b/client/js/controllers/pool_list_controller.js index 626dc1ec..1de3926c 100644 --- a/client/js/controllers/pool_list_controller.js +++ b/client/js/controllers/pool_list_controller.js @@ -13,26 +13,26 @@ const EmptyView = require('../views/empty_view.js'); const fields = [ 'id', 'names', - /* 'suggestions', - * 'implications', */ + 'posts', 'creationTime', 'postCount', 'category']; class PoolListController { constructor(ctx) { + this._pageController = new PageController(); + if (!api.hasPrivilege('pools:list')) { this._view = new EmptyView(); this._view.showError('You don\'t have privileges to view pools.'); return; } + this._ctx = ctx; + topNavigation.activate('pools'); topNavigation.setTitle('Listing pools'); - this._ctx = ctx; - this._pageController = new PageController(); - this._headerView = new PoolsHeaderView({ hostNode: this._pageController.view.pageHeaderHolderNode, parameters: ctx.parameters, diff --git a/client/js/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js index fd1adfea..c79824af 100644 --- a/client/js/controllers/post_list_controller.js +++ b/client/js/controllers/post_list_controller.js @@ -17,18 +17,19 @@ const fields = [ class PostListController { constructor(ctx) { + this._pageController = new PageController(); + if (!api.hasPrivilege('posts:list')) { this._view = new EmptyView(); this._view.showError('You don\'t have privileges to view posts.'); return; } + this._ctx = ctx; + topNavigation.activate('posts'); topNavigation.setTitle('Listing posts'); - this._ctx = ctx; - this._pageController = new PageController(); - this._headerView = new PostsHeaderView({ hostNode: this._pageController.view.pageHeaderHolderNode, parameters: ctx.parameters, diff --git a/client/js/controllers/tag_list_controller.js b/client/js/controllers/tag_list_controller.js index 8bc7dbba..e82461d0 100644 --- a/client/js/controllers/tag_list_controller.js +++ b/client/js/controllers/tag_list_controller.js @@ -20,18 +20,19 @@ const fields = [ class TagListController { constructor(ctx) { + this._pageController = new PageController(); + if (!api.hasPrivilege('tags:list')) { this._view = new EmptyView(); this._view.showError('You don\'t have privileges to view tags.'); return; } + this._ctx = ctx; + topNavigation.activate('tags'); topNavigation.setTitle('Listing tags'); - this._ctx = ctx; - this._pageController = new PageController(); - this._headerView = new TagsHeaderView({ hostNode: this._pageController.view.pageHeaderHolderNode, parameters: ctx.parameters, diff --git a/client/js/controllers/user_list_controller.js b/client/js/controllers/user_list_controller.js index fa878d85..5d666f8a 100644 --- a/client/js/controllers/user_list_controller.js +++ b/client/js/controllers/user_list_controller.js @@ -12,6 +12,8 @@ const EmptyView = require('../views/empty_view.js'); class UserListController { constructor(ctx) { + this._pageController = new PageController(); + if (!api.hasPrivilege('users:list')) { this._view = new EmptyView(); this._view.showError('You don\'t have privileges to view users.'); @@ -22,7 +24,6 @@ class UserListController { topNavigation.setTitle('Listing users'); this._ctx = ctx; - this._pageController = new PageController(); this._headerView = new UsersHeaderView({ hostNode: this._pageController.view.pageHeaderHolderNode, diff --git a/client/js/controls/pool_auto_complete_control.js b/client/js/controls/pool_auto_complete_control.js index 3c4bff7c..0230c41d 100644 --- a/client/js/controls/pool_auto_complete_control.js +++ b/client/js/controls/pool_auto_complete_control.js @@ -9,10 +9,6 @@ function _poolListToMatches(pools, options) { return pool2.postCount - pool1.postCount; }).map(pool => { let cssName = misc.makeCssName(pool.category, 'pool'); - // TODO - if (options.isPooledWith(pool.id)) { - cssName += ' disabled'; - } const caption = ( '' + misc.escapeHtml(pool.names[0] + ' (' + pool.postCount + ')') @@ -28,10 +24,6 @@ class PoolAutoCompleteControl extends AutoCompleteControl { constructor(input, options) { const minLengthForPartialSearch = 3; - options = Object.assign({ - isPooledWith: poolId => false, - }, options); - options.getMatches = text => { const term = misc.escapeSearchTerm(text); const query = ( diff --git a/client/js/models/pool.js b/client/js/models/pool.js index e51832b7..8099d2bd 100644 --- a/client/js/models/pool.js +++ b/client/js/models/pool.js @@ -7,15 +7,13 @@ const misc = require('../util/misc.js'); class Pool extends events.EventTarget { constructor() { - // const PoolList = require('./pool_list.js'); + const PostList = require('./post_list.js'); super(); this._orig = {}; for (let obj of [this, this._orig]) { - // TODO - // obj._suggestions = new PoolList(); - // obj._implications = new PoolList(); + obj._posts = new PostList(); } this._updateFromResponse({}); @@ -25,8 +23,7 @@ class Pool extends events.EventTarget { get names() { return this._names; } get category() { return this._category; } get description() { return this._description; } - /* get suggestions() { return this._suggestions; } - * get implications() { return this._implications; } */ + get posts() { return this._posts; } get postCount() { return this._postCount; } get creationTime() { return this._creationTime; } get lastEditTime() { return this._lastEditTime; } @@ -61,15 +58,9 @@ class Pool extends events.EventTarget { if (this._description !== this._orig._description) { detail.description = this._description; } - // TODO - // if (misc.arraysDiffer(this._implications, this._orig._implications)) { - // detail.implications = this._implications.map( - // relation => relation.names[0]); - // } - // if (misc.arraysDiffer(this._suggestions, this._orig._suggestions)) { - // detail.suggestions = this._suggestions.map( - // relation => relation.names[0]); - // } + if (misc.arraysDiffer(this._posts, this._orig._posts)) { + detail.posts = this._posts.map(post => post.id); + } let promise = this._id ? api.put(uri.formatApiLink('pool', this._id), detail) : @@ -138,13 +129,11 @@ class Pool extends events.EventTarget { _description: response.description, _creationTime: response.creationTime, _lastEditTime: response.lastEditTime, - _postCount: response.usages || 0, + _postCount: response.postCount || 0, }; for (let obj of [this, this._orig]) { - // TODO - // obj._suggestions.sync(response.suggestions); - // obj._implications.sync(response.implications); + obj._posts.sync(response.posts); } Object.assign(this, map); diff --git a/client/js/util/misc.js b/client/js/util/misc.js index 5911a363..aa62097d 100644 --- a/client/js/util/misc.js +++ b/client/js/util/misc.js @@ -163,11 +163,6 @@ function escapeHtml(unsafe) { } function arraysDiffer(source1, source2, orderImportant) { - if ((source1 instanceof Array && source2 === undefined) - || (source1 === undefined && source2 instanceof Array)) { - return true - } - source1 = [...source1]; source2 = [...source2]; if (orderImportant === true) { diff --git a/client/js/util/views.js b/client/js/util/views.js index 38020d82..1a4d03db 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -221,20 +221,20 @@ function makeTagLink(name, includeHash, includeCount, tag) { misc.escapeHtml(text)); } -function makePoolLink(pool, includeHash, includeCount, name) { - const category = pool.category; +function makePoolLink(id, includeHash, includeCount, pool, name) { + const category = pool ? pool.category : 'unknown'; let text = name ? name : pool.names[0]; if (includeHash === true) { text = '#' + text; } if (includeCount === true) { - text += ' (' + pool.postCount + ')'; + text += ' (' + (pool ? pool.postCount : 0) + ')'; } return api.hasPrivilege('pools:view') ? makeElement( 'a', { - href: uri.formatClientLink('pool', pool.id), + href: uri.formatClientLink('pool', id), class: misc.makeCssName(category, 'pool'), }, misc.escapeHtml(text)) : diff --git a/client/js/views/pool_create_view.js b/client/js/views/pool_create_view.js index 20218e53..26fca59c 100644 --- a/client/js/views/pool_create_view.js +++ b/client/js/views/pool_create_view.js @@ -22,8 +22,13 @@ class PoolCreateView extends events.EventTarget { 'input', e => this._evtNameInput(e)); } + if (this._postsFieldNode) { + this._postsFieldNode.addEventListener( + 'input', e => this._evtPostsInput(e)); + } + for (let node of this._formNode.querySelectorAll( - 'input, select, textarea')) { + 'input, select, textarea, posts')) { node.addEventListener( 'change', e => { this.dispatchEvent(new CustomEvent('change')); @@ -74,16 +79,31 @@ class PoolCreateView extends events.EventTarget { this._namesFieldNode.setCustomValidity(''); } + _evtPostsInput(e) { + const regex = /^\d+$/; + const list = misc.splitByWhitespace(this._postsFieldNode.value); + + for (let item of list) { + if (!regex.test(item)) { + this._postsFieldNode.setCustomValidity( + `Pool ID "${item}" is not an integer.`); + return; + } + } + + this._postsFieldNode.setCustomValidity(''); + } + _evtSubmit(e) { e.preventDefault(); - let pool = new Pool() - pool.names = misc.splitByWhitespace(this._namesFieldNode.value); - pool.category = this._categoryFieldNode.value; - pool.description = this._descriptionFieldNode.value; this.dispatchEvent(new CustomEvent('submit', { detail: { - pool: pool, + names: misc.splitByWhitespace(this._namesFieldNode.value), + category: this._categoryFieldNode.value, + description: this._descriptionFieldNode.value, + posts: misc.splitByWhitespace(this._postsFieldNode.value) + .map(i => parseInt(i, 10)) }, })); } @@ -103,6 +123,10 @@ class PoolCreateView extends events.EventTarget { get _descriptionFieldNode() { return this._formNode.querySelector('.description textarea'); } + + get _postsFieldNode() { + return this._formNode.querySelector('.posts input'); + } } module.exports = PoolCreateView; diff --git a/client/js/views/pool_edit_view.js b/client/js/views/pool_edit_view.js index fac84307..7cd8bc9e 100644 --- a/client/js/views/pool_edit_view.js +++ b/client/js/views/pool_edit_view.js @@ -4,6 +4,7 @@ const events = require('../events.js'); const api = require('../api.js'); const misc = require('../util/misc.js'); const views = require('../util/views.js'); +const Post = require('../models/post.js'); const template = views.getTemplate('pool-edit'); @@ -22,8 +23,13 @@ class PoolEditView extends events.EventTarget { 'input', e => this._evtNameInput(e)); } + if (this._postsFieldNode) { + this._postsFieldNode.addEventListener( + 'input', e => this._evtPostsInput(e)); + } + for (let node of this._formNode.querySelectorAll( - 'input, select, textarea')) { + 'input, select, textarea, posts')) { node.addEventListener( 'change', e => { this.dispatchEvent(new CustomEvent('change')); @@ -74,6 +80,21 @@ class PoolEditView extends events.EventTarget { this._namesFieldNode.setCustomValidity(''); } + _evtPostsInput(e) { + const regex = /^\d+$/; + const list = misc.splitByWhitespace(this._postsFieldNode.value); + + for (let item of list) { + if (!regex.test(item)) { + this._postsFieldNode.setCustomValidity( + `Pool ID "${item}" is not an integer.`); + return; + } + } + + this._postsFieldNode.setCustomValidity(''); + } + _evtSubmit(e) { e.preventDefault(); this.dispatchEvent(new CustomEvent('submit', { @@ -91,6 +112,10 @@ class PoolEditView extends events.EventTarget { description: this._descriptionFieldNode ? this._descriptionFieldNode.value : undefined, + + posts: this._postsFieldNode ? + misc.splitByWhitespace(this._postsFieldNode.value) : + undefined, }, })); } @@ -110,6 +135,10 @@ class PoolEditView extends events.EventTarget { get _descriptionFieldNode() { return this._formNode.querySelector('.description textarea'); } + + get _postsFieldNode() { + return this._formNode.querySelector('.posts input'); + } } module.exports = PoolEditView; diff --git a/client/js/views/snapshots_page_view.js b/client/js/views/snapshots_page_view.js index 77fbe132..e7ea264e 100644 --- a/client/js/views/snapshots_page_view.js +++ b/client/js/views/snapshots_page_view.js @@ -36,6 +36,8 @@ function _makeResourceLink(type, id) { return views.makeTagLink(id, true); } else if (type === 'tag_category') { return 'category "' + id + '"'; + } else if (type === 'pool') { + return views.makePoolLink(id, true); } } @@ -113,6 +115,19 @@ function _makeItemModification(type, data) { if (diff.flags) { _extend(lines, ['Changed flags']); } + + } else if (type === 'pool') { + if (diff.names) { + _extend(lines, _formatBasicChange(diff.names, 'names')); + } + if (diff.category) { + _extend( + lines, _formatBasicChange(diff.category, 'category')); + } + if (diff.posts) { + _extend( + lines, _formatBasicChange(diff.posts, 'posts')); + } } return lines.join('
    '); diff --git a/server/config.yaml.dist b/server/config.yaml.dist index 68076239..baf3a34e 100644 --- a/server/config.yaml.dist +++ b/server/config.yaml.dist @@ -129,6 +129,10 @@ privileges: 'tag_categories:set_default': moderator 'pools:create': regular + 'pools:edit:names': power + 'pools:edit:category': power + 'pools:edit:description': power + 'pools:edit:posts': power 'pools:list': regular 'pools:view': anonymous 'pools:merge': moderator diff --git a/server/szurubooru/api/pool_api.py b/server/szurubooru/api/pool_api.py index b2ec11b9..69b99515 100644 --- a/server/szurubooru/api/pool_api.py +++ b/server/szurubooru/api/pool_api.py @@ -16,17 +16,6 @@ def _get_pool(params: Dict[str, str]) -> model.Pool: return pools.get_pool_by_id(params['pool_id']) -# def _create_if_needed(pool_names: List[str], user: model.User) -> None: -# if not pool_names: -# return -# _existing_pools, new_pools = pools.get_or_create_pools_by_names(pool_names) -# if len(new_pools): -# auth.verify_privilege(user, 'pools:create') -# db.session.flush() -# for pool in new_pools: -# snapshots.create(pool, user) - - @rest.routes.get('/pools/?') def get_pools(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: auth.verify_privilege(ctx.user, 'pools:list') @@ -34,7 +23,7 @@ def get_pools(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: ctx, lambda pool: _serialize(ctx, pool)) -@rest.routes.post('/pools/?') +@rest.routes.post('/pool/?') def create_pool( ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response: auth.verify_privilege(ctx.user, 'pools:create') @@ -42,14 +31,9 @@ def create_pool( names = ctx.get_param_as_string_list('names') category = ctx.get_param_as_string('category') description = ctx.get_param_as_string('description', default='') - # TODO - # suggestions = ctx.get_param_as_string_list('suggestions', default=[]) - # implications = ctx.get_param_as_string_list('implications', default=[]) + posts = ctx.get_param_as_int_list('posts', default=[]) - # _create_if_needed(suggestions, ctx.user) - # _create_if_needed(implications, ctx.user) - - pool = pools.create_pool(names, category) + pool = pools.create_pool(names, category, posts) pools.update_pool_description(pool, description) ctx.session.add(pool) ctx.session.flush() @@ -81,17 +65,10 @@ def update_pool(ctx: rest.Context, params: Dict[str, str]) -> rest.Response: auth.verify_privilege(ctx.user, 'pools:edit:description') pools.update_pool_description( pool, ctx.get_param_as_string('description')) - # TODO - # if ctx.has_param('suggestions'): - # auth.verify_privilege(ctx.user, 'pools:edit:suggestions') - # suggestions = ctx.get_param_as_string_list('suggestions') - # _create_if_needed(suggestions, ctx.user) - # pools.update_pool_suggestions(pool, suggestions) - # if ctx.has_param('implications'): - # auth.verify_privilege(ctx.user, 'pools:edit:implications') - # implications = ctx.get_param_as_string_list('implications') - # _create_if_needed(implications, ctx.user) - # pools.update_pool_implications(pool, implications) + if ctx.has_param('posts'): + auth.verify_privilege(ctx.user, 'pools:edit:posts') + posts = ctx.get_param_as_int_list('posts') + pools.update_pool_posts(pool, posts) pool.last_edit_time = datetime.utcnow() ctx.session.flush() snapshots.modify(pool, ctx.user) diff --git a/server/szurubooru/func/pools.py b/server/szurubooru/func/pools.py index d35fd496..4fc4d36d 100644 --- a/server/szurubooru/func/pools.py +++ b/server/szurubooru/func/pools.py @@ -3,7 +3,7 @@ from typing import Any, Optional, Tuple, List, Dict, Callable from datetime import datetime import sqlalchemy as sa from szurubooru import config, db, model, errors, rest -from szurubooru.func import util, pool_categories, serialization +from szurubooru.func import util, pool_categories, serialization, posts @@ -23,7 +23,7 @@ class InvalidPoolNameError(errors.ValidationError): pass -class InvalidPoolRelationError(errors.ValidationError): +class InvalidPoolDuplicateError(errors.ValidationError): pass @@ -60,6 +60,10 @@ def _check_name_intersection( return len(set(names1).intersection(names2)) > 0 +def _check_post_duplication(post_ids: List[int]) -> bool: + return len(post_ids) != len(set(post_ids)) + + def sort_pools(pools: List[model.Pool]) -> List[model.Pool]: default_category_name = pool_categories.get_default_category_name() return sorted( @@ -84,7 +88,8 @@ class PoolSerializer(serialization.BaseSerializer): 'description': self.serialize_description, 'creationTime': self.serialize_creation_time, 'lastEditTime': self.serialize_last_edit_time, - 'postCount': self.serialize_post_count + 'postCount': self.serialize_post_count, + 'posts': self.serialize_posts } def serialize_id(self) -> Any: @@ -111,6 +116,13 @@ class PoolSerializer(serialization.BaseSerializer): def serialize_post_count(self) -> Any: return self.pool.post_count + def serialize_posts(self) -> Any: + return [ + { + 'id': post.post_id + } + for post in self.pool.posts] + def serialize_pool( pool: model.Pool, options: List[str] = []) -> Optional[rest.Response]: @@ -180,7 +192,8 @@ def get_or_create_pools_by_names( if not found: new_pool = create_pool( names=[name], - category_name=pool_category_name) + category_name=pool_category_name, + post_ids=[]) db.session.add(new_pool) new_pools.append(new_pool) return existing_pools, new_pools @@ -245,11 +258,13 @@ def merge_pools(source_pool: model.Pool, target_pool: model.Pool) -> None: def create_pool( names: List[str], - category_name: str) -> model.Pool: + category_name: str, + post_ids: List[int]) -> model.Pool: pool = model.Pool() pool.creation_time = datetime.utcnow() update_pool_names(pool, names) update_pool_category_name(pool, category_name) + update_pool_posts(pool, post_ids) return pool @@ -299,4 +314,13 @@ def update_pool_description(pool: model.Pool, description: str) -> None: if util.value_exceeds_column_size(description, model.Pool.description): raise InvalidPoolDescriptionError('Description is too long.') pool.description = description or None - + + + +def update_pool_posts(pool: model.Pool, post_ids: List[int]) -> None: + assert pool + if _check_post_duplication(post_ids): + raise InvalidPoolDuplicateError('Duplicate post in pool.') + pool.posts.clear() + for post in posts.get_posts_by_ids(post_ids): + pool.posts.append(post) diff --git a/server/szurubooru/func/posts.py b/server/szurubooru/func/posts.py index f0224b40..64b00c99 100644 --- a/server/szurubooru/func/posts.py +++ b/server/szurubooru/func/posts.py @@ -334,6 +334,22 @@ def get_post_by_id(post_id: int) -> model.Post: return post +def get_posts_by_ids(ids: List[int]) -> List[model.Pool]: + if len(ids) == 0: + return [] + posts = ( + db.session.query(model.Post) + .filter( + sa.sql.or_( + model.Post.post_id == post_id + for post_id in ids)) + .all()) + id_order = { + v: k for k, v in enumerate(ids) + } + return sorted(posts, key=lambda post: id_order.get(post.post_id)) + + def try_get_current_post_feature() -> Optional[model.PostFeature]: return ( db.session diff --git a/server/szurubooru/func/snapshots.py b/server/szurubooru/func/snapshots.py index e9ce07c9..66464679 100644 --- a/server/szurubooru/func/snapshots.py +++ b/server/szurubooru/func/snapshots.py @@ -38,7 +38,7 @@ def get_pool_snapshot(pool: model.Pool) -> Dict[str, Any]: return { 'names': [pool_name.name for pool_name in pool.names], 'category': pool.category.name, - # TODO + 'posts': [post.post_id for post in pool.posts] } diff --git a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py index 9b663ac6..be87340a 100644 --- a/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py +++ b/server/szurubooru/migrations/versions/6a2f424ec9d2_create_pool_tables.py @@ -45,8 +45,18 @@ def upgrade(): sa.PrimaryKeyConstraint('pool_name_id'), sa.UniqueConstraint('name')) + op.create_table( + 'pool_post', + sa.Column('pool_id', sa.Integer(), nullable=False), + sa.Column('post_id', sa.Integer(), nullable=False), + sa.Column('ord', sa.Integer(), nullable=False, index=True), + sa.ForeignKeyConstraint(['pool_id'], ['pool.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['post_id'], ['post.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('pool_id', 'post_id')) + def downgrade(): op.drop_index(op.f('ix_pool_name_ord'), table_name='pool_name') + op.drop_table('pool_post') op.drop_table('pool_name') op.drop_table('pool') op.drop_table('pool_category') diff --git a/server/szurubooru/model/__init__.py b/server/szurubooru/model/__init__.py index cf18b0f7..4f6cb2a6 100644 --- a/server/szurubooru/model/__init__.py +++ b/server/szurubooru/model/__init__.py @@ -11,7 +11,7 @@ from szurubooru.model.post import ( PostNote, PostFeature, PostSignature) -from szurubooru.model.pool import Pool, PoolName +from szurubooru.model.pool import Pool, PoolName, PoolPost from szurubooru.model.pool_category import PoolCategory from szurubooru.model.comment import Comment, CommentScore from szurubooru.model.snapshot import Snapshot diff --git a/server/szurubooru/model/pool.py b/server/szurubooru/model/pool.py index 2505150e..ecd6522c 100644 --- a/server/szurubooru/model/pool.py +++ b/server/szurubooru/model/pool.py @@ -1,5 +1,8 @@ import sqlalchemy as sa +from sqlalchemy.ext.orderinglist import ordering_list +from sqlalchemy.ext.associationproxy import association_proxy from szurubooru.model.base import Base +import szurubooru.model as model class PoolName(Base): @@ -18,6 +21,32 @@ class PoolName(Base): def __init__(self, name: str, order: int) -> None: self.name = name self.order = order + + +class PoolPost(Base): + __tablename__ = 'pool_post' + + pool_id = sa.Column( + 'pool_id', + sa.Integer, + sa.ForeignKey('pool.id'), + nullable=False, + primary_key=True, + index=True) + post_id = sa.Column( + 'post_id', + sa.Integer, + sa.ForeignKey('post.id'), + nullable=False, + primary_key=True, + index=True) + order = sa.Column('ord', sa.Integer, nullable=False, index=True) + + pool = sa.orm.relationship('Pool', back_populates='_posts') + post = sa.orm.relationship('Post') + + def __init__(self, post: model.Post) -> None: + self.post_id = post.post_id class Pool(Base): __tablename__ = 'pool' @@ -40,18 +69,23 @@ class Pool(Base): cascade='all,delete-orphan', lazy='joined', order_by='PoolName.order') + _posts = sa.orm.relationship( + 'PoolPost', + back_populates='pool', + cascade='all,delete-orphan', + lazy='joined', + order_by='PoolPost.order', + collection_class=ordering_list('order')) + posts = association_proxy('_posts', 'post') - # post_count = sa.orm.column_property( - # sa.sql.expression.select( - # [sa.sql.expression.func.count(PostPool.post_id)]) - # .where(PostPool.pool_id == pool_id) - # .correlate_except(PostPool)) - # TODO - from random import randint post_count = sa.orm.column_property( - sa.sql.expression.select([randint(1, 1000)]) - .limit(1) - .as_scalar()) + ( + sa.sql.expression.select( + [sa.sql.expression.func.count(PoolPost.post_id)]) + .where(PoolPost.pool_id == pool_id) + .as_scalar() + ), + deferred=True) first_name = sa.orm.column_property( ( @@ -63,7 +97,6 @@ class Pool(Base): ), deferred=True) - __mapper_args__ = { 'version_id_col': version, 'version_id_generator': False, diff --git a/server/szurubooru/search/configs/post_search_config.py b/server/szurubooru/search/configs/post_search_config.py index 9c8de2eb..281826fa 100644 --- a/server/szurubooru/search/configs/post_search_config.py +++ b/server/szurubooru/search/configs/post_search_config.py @@ -104,6 +104,18 @@ def _note_filter( search_util.create_str_filter)(query, criterion, negated) +def _pool_filter( + query: SaQuery, + criterion: Optional[criteria.BaseCriterion], + negated: bool) -> SaQuery: + assert criterion + return search_util.create_subquery_filter( + model.Post.post_id, + model.PoolPost.post_id, + model.PoolPost.pool_id, + search_util.create_num_filter)(query, criterion, negated) + + class PostSearchConfig(BaseSearchConfig): def __init__(self) -> None: self.user = None # type: Optional[model.User] @@ -350,6 +362,11 @@ class PostSearchConfig(BaseSearchConfig): search_util.create_str_filter( model.Post.flags_string, _flag_transformer) ), + + ( + ['pool'], + _pool_filter + ), ]) @property