From a65296587545f58dc65a86cc64470e80da051527 Mon Sep 17 00:00:00 2001 From: Charmander <~@charmander.me> Date: Thu, 18 Jan 2024 21:15:06 -0800 Subject: [PATCH] Add post counts to user tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counts respect the viewer’s rating preference. Sets up for some future changes: * determining whether SFW mode has an effect when the configured rating preference is the same between SFW mode and non, so we can avoid confusingly showing a toggle that does nothing * making blocked tag filtering a visible operation on pages of results (which itself sets up for better pagination and performance improvements through caching and lightened database load) I’d like to provide counts for favorites and individual folders too, but they’re more involved. Ideally, counts would be stored in the database and updated eagerly; PostgreSQL makes it hard. --- assets/scss/components/_user-tabs.scss | 85 ++++++++++++++++++++++++++ assets/scss/site.scss | 48 +-------------- weasyl/character.py | 4 ++ weasyl/collection.py | 2 + weasyl/controllers/profile.py | 72 ++++++++++++++++++---- weasyl/define.py | 59 ++++++++++++++++++ weasyl/journal.py | 4 ++ weasyl/moderation.py | 12 ++++ weasyl/submission.py | 15 +++++ weasyl/templates/common/user_tabs.html | 21 ++++--- weasyl/templates/user/characters.html | 4 +- weasyl/templates/user/collections.html | 4 +- weasyl/templates/user/favorites.html | 4 +- weasyl/templates/user/followed.html | 4 +- weasyl/templates/user/following.html | 4 +- weasyl/templates/user/friends.html | 4 +- weasyl/templates/user/journals.html | 4 +- weasyl/templates/user/profile.html | 4 +- weasyl/templates/user/shouts.html | 4 +- weasyl/templates/user/submissions.html | 4 +- 20 files changed, 274 insertions(+), 88 deletions(-) create mode 100644 assets/scss/components/_user-tabs.scss diff --git a/assets/scss/components/_user-tabs.scss b/assets/scss/components/_user-tabs.scss new file mode 100644 index 000000000..8713f26ee --- /dev/null +++ b/assets/scss/components/_user-tabs.scss @@ -0,0 +1,85 @@ +#user-nav > li { + width: 50%; + float: left; +} + +#user-nav > li > a { + display: block; + padding: 0.5em 0; + font-size: 14px; + font-size: 0.9rem; +} + +.user-nav-with-count { + position: relative; +} + +.user-nav-with-count:hover { + text-decoration: none; +} + +.user-nav-with-count:hover > .user-nav-label { + text-decoration: underline; +} + +$count-selected-background: #006080; + +.user-nav-count { + background-color: #2a2e2f; + border-radius: 4px; + color: #999; + line-height: 1; + margin-left: 6px; + margin-top: calc(-0.5em - 4px); + padding: 4px 6px; + text-decoration: none; + top: 50%; + + .current > & { + background-color: $count-selected-background; + color: scale-color($count-selected-background, $lightness: 75%); + } +} + +@media (min-width: 45em) { + #user-nav > li { + width: 25%; + } +} + +@media (min-width: 52em) { + #user-nav > li { + width: auto; + float: left; + } + + #user-nav > * + li { + padding-left: 2.5em; + } + + #user-nav > li > a { + padding: 0; + height: 48px; + height: 3rem; + line-height: 48px; + line-height: 3rem; + } +} + +@media (min-width: 94em) { + #user-nav > li { + width: 14.28%; + } + + #user-nav > * + li { + padding-left: 0; + } + + #user-nav > li > a { + text-align: center; + } + + .user-nav-count { + position: absolute; + } +} diff --git a/assets/scss/site.scss b/assets/scss/site.scss index f1ffa9dd8..faa051e5e 100644 --- a/assets/scss/site.scss +++ b/assets/scss/site.scss @@ -1,6 +1,7 @@ @import 'reset'; @import 'components/option'; @import 'components/text-post-list'; +@import 'components/user-tabs'; @import 'pages/marketplace'; /* Basic styles @@ -2545,18 +2546,6 @@ $notifs-color-active: #b9b9b9; padding-top: 1rem; } -#user-nav li { - width: 50%; - float: left; -} - -#user-nav li a { - display: block; - padding: 0.5em 0; - font-size: 14px; - font-size: 0.9rem; -} - #uf-image { text-align: center; } @@ -2664,10 +2653,6 @@ $notifs-color-active: #b9b9b9; margin-left: 64px; } - #user-nav li { - width: 25%; - } - #user-stats dl { column-count: 2; } @@ -2853,25 +2838,6 @@ $notifs-color-active: #b9b9b9; } } -@media (min-width: 48em) { - #user-nav li { - width: auto; - float: left; - } - - #user-nav li + li { - padding-left: 2.5em; - } - - #user-nav li a { - padding: 0; - height: 48px; - height: 3rem; - line-height: 48px; - line-height: 3rem; - } -} - @media (min-width: 53em) { #user-info .avatar { left: 32px; @@ -2882,18 +2848,6 @@ $notifs-color-active: #b9b9b9; right: 32px; right: 2rem; } - - #user-nav li { - width: 14.28%; - } - - #user-nav li + li { - padding-left: 0; - } - - #user-nav li a { - text-align: center; - } } @media (min-width: 60em) { diff --git a/weasyl/character.py b/weasyl/character.py index c9b9471ec..2bff06678 100644 --- a/weasyl/character.py +++ b/weasyl/character.py @@ -135,6 +135,7 @@ def create(userid, character, friends, tags, thumbfile, submitfile): files.clear_temporary(userid) define.metric('increment', 'characters') + define.cached_posts_count.invalidate(userid) return charid @@ -396,6 +397,8 @@ def edit(userid, character, friends_only): userid, query.userid, 'The following character was edited:', '- ' + text.markdown_link(character.char_name, '/character/%s?anyway=true' % (character.charid,))) + define.cached_posts_count.invalidate(query.userid) + def remove(userid, charid): ownerid = define.get_ownerid(charid=charid) @@ -410,6 +413,7 @@ def remove(userid, charid): if result.rowcount != 0: welcome.character_remove(charid) + define.cached_posts_count.invalidate(ownerid) return ownerid diff --git a/weasyl/collection.py b/weasyl/collection.py index 88c92d14b..372ce1266 100644 --- a/weasyl/collection.py +++ b/weasyl/collection.py @@ -224,6 +224,7 @@ def pending_accept(userid, submissions): welcome.collectrequest_remove(userid, s[1], s[0]) d._page_header_info.invalidate(userid) + d.cached_posts_count.invalidate(userid) def pending_reject(userid, submissions): @@ -250,3 +251,4 @@ def remove(userid, submissions): """, user=userid, submissions=submissions) welcome.collection_remove(userid, submissions) + d.cached_posts_count.invalidate(userid) diff --git a/weasyl/controllers/profile.py b/weasyl/controllers/profile.py index 8f3b17e67..83e5cdeae 100644 --- a/weasyl/controllers/profile.py +++ b/weasyl/controllers/profile.py @@ -12,6 +12,21 @@ from weasyl.error import WeasylError +def _get_post_counts_by_type(userid, *, friends: bool, rating): + result = { + "submission": 0, + "journal": 0, + "character": 0, + "collection": 0, + } + + for key, count in define.posts_count(userid, friends=friends).items(): + if key.rating <= rating: + result[key.post_type] += count + + return result + + # Profile browsing functions def profile_(request): name = request.params.get('name', '') @@ -90,6 +105,8 @@ def profile_(request): statistics, show_statistics = profile.select_statistics(otherid) + relation = profile.select_relation(request.userid, otherid) + return Response(define.webpage( request.userid, "user/profile.html", @@ -100,7 +117,7 @@ def profile_(request): # User information profile.select_userinfo(otherid, config=userprofile['config']), # Relationship - profile.select_relation(request.userid, otherid), + relation, # Myself profile.select_myself(request.userid), # Recent submissions @@ -121,6 +138,7 @@ def profile_(request): # Friends lambda: frienduser.has_friends(otherid), is_unverified, + _get_post_counts_by_type(otherid, friends=relation["friend"], rating=rating), ), twitter_card=twitter_meta, ogp=ogp, @@ -171,19 +189,22 @@ def submissions_(request): nextid=define.get_int(nextid), profile_page_filter=not folderid, count_limit=submission.COUNT_LIMIT) + relation = profile.select_relation(request.userid, otherid) + page.append(define.render('user/submissions.html', [ # Profile information userprofile, # User information profile.select_userinfo(otherid, config=userprofile['config']), # Relationship - profile.select_relation(request.userid, otherid), + relation, # Recent submissions result, # Folders folder.select_list(otherid), # Current folder folderid, + _get_post_counts_by_type(otherid, friends=relation["friend"], rating=rating), ])) return Response(define.common_page_end(request.userid, page)) @@ -214,15 +235,18 @@ def collections_(request): collection.select_list, collection.select_count, 'submitid', url_format, request.userid, rating, limit=66, otherid=otherid, backid=define.get_int(backid), nextid=define.get_int(nextid)) + relation = profile.select_relation(request.userid, otherid) + page.append(define.render('user/collections.html', [ # Profile information userprofile, # User information profile.select_userinfo(otherid, config=userprofile['config']), # Relationship - profile.select_relation(request.userid, otherid), + relation, # Collections result, + _get_post_counts_by_type(otherid, friends=relation["friend"], rating=rating), ])) return Response(define.common_page_end(request.userid, page)) @@ -245,15 +269,18 @@ def journals_(request): page_title = u"%s's journals" % (userprofile['full_name'] if has_fullname else userprofile['username'],) page = define.common_page_start(request.userid, title=page_title) + relation = profile.select_relation(request.userid, otherid) + page.append(define.render('user/journals.html', [ # Profile information userprofile, # User information profile.select_userinfo(otherid, config=userprofile['config']), # Relationship - profile.select_relation(request.userid, otherid), + relation, # Journals list journal.select_list(request.userid, rating, otherid=otherid), + _get_post_counts_by_type(otherid, friends=relation["friend"], rating=rating), ])) return Response(define.common_page_end(request.userid, page)) @@ -287,15 +314,18 @@ def characters_(request): otherid=otherid, backid=define.get_int(backid), nextid=define.get_int(nextid)) + relation = profile.select_relation(request.userid, otherid) + page.append(define.render('user/characters.html', [ # Profile information userprofile, # User information profile.select_userinfo(otherid, config=userprofile['config']), # Relationship - profile.select_relation(request.userid, otherid), + relation, # Characters list result, + _get_post_counts_by_type(otherid, friends=relation["friend"], rating=rating), ])) return Response(define.common_page_end(request.userid, page)) @@ -330,19 +360,23 @@ def shouts_(request): page_title = u"%s's shouts" % (userprofile['full_name'] if has_fullname else userprofile['username'],) page = define.common_page_start(request.userid, title=page_title) + relation = profile.select_relation(request.userid, otherid) + rating = define.get_rating(request.userid) + page.append(define.render('user/shouts.html', [ # Profile information userprofile, # User information profile.select_userinfo(otherid, config=userprofile['config']), # Relationship - profile.select_relation(request.userid, otherid), + relation, # Myself profile.select_myself(request.userid), # Comments shout.select(request.userid, ownerid=otherid), # Feature "shouts", + _get_post_counts_by_type(otherid, friends=relation["friend"], rating=rating), ])) return Response(define.common_page_end(request.userid, page)) @@ -365,19 +399,23 @@ def staffnotes_(request): userinfo['reportstats'] = reportstats userinfo['reporttotal'] = sum(reportstats.values()) + relation = profile.select_relation(request.userid, otherid) + rating = define.get_rating(request.userid) + page.append(define.render('user/shouts.html', [ # Profile information userprofile, # User information userinfo, # Relationship - profile.select_relation(request.userid, otherid), + relation, # Myself profile.select_myself(request.userid), # Comments shout.select(request.userid, ownerid=otherid, staffnotes=True), # Feature "staffnotes", + _get_post_counts_by_type(otherid, friends=relation["friend"], rating=rating), ])) return Response(define.common_page_end(request.userid, page)) @@ -437,17 +475,20 @@ def favorites_(request): "journal": favorite.select_journal(request.userid, rating, 22, otherid=otherid), } + relation = profile.select_relation(request.userid, otherid) + page.append(define.render('user/favorites.html', [ # Profile information userprofile, # User information profile.select_userinfo(otherid, config=userprofile['config']), # Relationship - profile.select_relation(request.userid, otherid), + relation, # Feature feature, # Favorites faves, + _get_post_counts_by_type(otherid, friends=relation["friend"], rating=rating), ])) return Response(define.common_page_end(request.userid, page)) @@ -468,6 +509,8 @@ def friends_(request): raise WeasylError('noGuests') userprofile = profile.select_profile(otherid, viewer=request.userid) + relation = profile.select_relation(request.userid, otherid) + rating = define.get_rating(request.userid) return Response(define.webpage(request.userid, "user/friends.html", [ # Profile information @@ -475,10 +518,11 @@ def friends_(request): # User information profile.select_userinfo(otherid, config=userprofile['config']), # Relationship - profile.select_relation(request.userid, otherid), + relation, # Friends frienduser.select_friends(request.userid, otherid, limit=44, backid=define.get_int(backid), nextid=define.get_int(nextid)), + _get_post_counts_by_type(otherid, friends=relation["friend"], rating=rating), ])) @@ -497,6 +541,8 @@ def following_(request): raise WeasylError('noGuests') userprofile = profile.select_profile(otherid, viewer=request.userid) + relation = profile.select_relation(request.userid, otherid) + rating = define.get_rating(request.userid) return Response(define.webpage(request.userid, "user/following.html", [ # Profile information @@ -504,10 +550,11 @@ def following_(request): # User information profile.select_userinfo(otherid, config=userprofile['config']), # Relationship - profile.select_relation(request.userid, otherid), + relation, # Following followuser.select_following(request.userid, otherid, limit=44, backid=define.get_int(backid), nextid=define.get_int(nextid)), + _get_post_counts_by_type(otherid, friends=relation["friend"], rating=rating), ])) @@ -526,6 +573,8 @@ def followed_(request): raise WeasylError('noGuests') userprofile = profile.select_profile(otherid, viewer=request.userid) + relation = profile.select_relation(request.userid, otherid) + rating = define.get_rating(request.userid) return Response(define.webpage(request.userid, "user/followed.html", [ # Profile information @@ -533,8 +582,9 @@ def followed_(request): # User information profile.select_userinfo(otherid, config=userprofile['config']), # Relationship - profile.select_relation(request.userid, otherid), + relation, # Followed followuser.select_followed(request.userid, otherid, limit=44, backid=define.get_int(backid), nextid=define.get_int(nextid)), + _get_post_counts_by_type(otherid, friends=relation["friend"], rating=rating), ])) diff --git a/weasyl/define.py b/weasyl/define.py index c173af348..7d22e1a88 100644 --- a/weasyl/define.py +++ b/weasyl/define.py @@ -6,6 +6,9 @@ import numbers import datetime import pkgutil +from collections import defaultdict +from dataclasses import dataclass +from typing import Literal from urllib.parse import urlencode, urljoin import arrow @@ -177,6 +180,7 @@ def _compile(template_name): "PATH": _get_path, "arrow": arrow, "constants": libweasyl.constants, + "format": format, "getattr": getattr, "json": json, "sorted": sorted, @@ -590,6 +594,61 @@ def user_type(userid): return None +def _posts_count_query_basic(table): + return ( + f"SELECT '{table}' AS post_type, rating, friends_only, count(*) FROM {table}" + " WHERE userid = %(userid)s" + " AND NOT hidden" + " GROUP BY rating, friends_only" + ) + + +_POSTS_COUNT_QUERY = " UNION ALL ".join(( + _posts_count_query_basic("submission"), + _posts_count_query_basic("journal"), + _posts_count_query_basic("character"), + ( + "SELECT 'collection' AS post_type, rating, friends_only, count(*)" + " FROM collection INNER JOIN submission USING (submitid)" + " WHERE collection.userid = %(userid)s" + " AND NOT hidden" + " AND collection.settings !~ '[pr]'" + " GROUP BY rating, friends_only" + ), +)) + + +@dataclass(frozen=True, slots=True) +class PostsCountKey: + post_type: Literal["submission", "journal", "character", "collection"] + rating: Literal[10, 30, 40] + + +@region.cache_on_arguments() +def cached_posts_count(userid): + return [*map(dict, engine.execute(_POSTS_COUNT_QUERY, userid=userid))] + + +def cached_posts_count_invalidate_multi(userids): + namespace = None + cache_keys = [*map(region.function_key_generator(namespace, cached_posts_count), userids)] + region.delete_multi(cache_keys) + + +def posts_count(userid, *, friends: bool): + result = defaultdict(int) + + for row in cached_posts_count(userid): + if friends or not row["friends_only"]: + key = PostsCountKey( + post_type=row["post_type"], + rating=row["rating"], + ) + result[key] += row["count"] + + return result + + @region.cache_on_arguments(expiration_time=180) @record_timing def _page_header_info(userid): diff --git a/weasyl/journal.py b/weasyl/journal.py index 910267dc6..db86ce1a2 100644 --- a/weasyl/journal.py +++ b/weasyl/journal.py @@ -59,6 +59,7 @@ def create(userid, journal, friends_only=False, tags=None): friends_only=friends_only) d.metric('increment', 'journals') + d.cached_posts_count.invalidate(userid) return journalid @@ -298,6 +299,8 @@ def edit(userid, journal, friends_only=False): userid, query[0], 'The following journal was edited:', '- ' + text.markdown_link(journal.title, '/journal/%s?anyway=true' % (journal.journalid,))) + d.cached_posts_count.invalidate(query[0]) + def remove(userid, journalid): ownerid = d.get_ownerid(journalid=journalid) @@ -312,5 +315,6 @@ def remove(userid, journalid): if result.rowcount != 0: welcome.journal_remove(journalid) + d.cached_posts_count.invalidate(ownerid) return ownerid diff --git a/weasyl/moderation.py b/weasyl/moderation.py index 70a081bbf..5f909c34e 100644 --- a/weasyl/moderation.py +++ b/weasyl/moderation.py @@ -782,6 +782,18 @@ def action(tbl): else: copyable.append('- %s' % (title,)) + cached_posts_count_invalidate_userids = list(affected.keys()) + if submissions: + # bulk add collectors; see `weasyl.collection.find_owners` + cached_posts_count_invalidate_userids.extend( + row.userid + for row in d.engine.execute( + "SELECT DISTINCT userid FROM collection WHERE submitid = ANY (%(submissions)s) AND settings !~ '[pr]'", + submissions=submissions, + ) + ) + d.cached_posts_count_invalidate_multi(cached_posts_count_invalidate_userids) + now = arrow.utcnow() values = [] for target, target_affected in affected.items(): diff --git a/weasyl/submission.py b/weasyl/submission.py index ab50c9a59..3b2e87e8b 100644 --- a/weasyl/submission.py +++ b/weasyl/submission.py @@ -110,6 +110,7 @@ def create_generic(userid, submission, **kwargs): if newid: p = d.meta.tables['profile'] d.connect().execute(p.update().where(p.c.userid == userid).values(latest_submission_time=arrow.utcnow())) + d.cached_posts_count.invalidate(userid) return newid return create_generic @@ -944,6 +945,14 @@ def select_near(userid, rating, limit, otherid, folderid, submitid): } +def _invalidate_collectors_posts_count(submitid): + """ + Invalidate the cached post counts of users who have as a collection the submission being edited or deleted. + """ + owners = collection.find_owners(submitid) + d.cached_posts_count_invalidate_multi(owners) + + def edit(userid, submission, embedlink=None, friends_only=False, critique=False): query = d.engine.execute( "SELECT userid, subtype, hidden, embed_type FROM submission WHERE submitid = %(id)s", @@ -1004,6 +1013,10 @@ def edit(userid, submission, embedlink=None, friends_only=False, critique=False) userid, query[0], 'The following submission was edited:', '- ' + text.markdown_link(submission.title, '/submission/%s?anyway=true' % (submission.submitid,))) + # possible rating change + d.cached_posts_count.invalidate(query[0]) + _invalidate_collectors_posts_count(submission.submitid) + def remove(userid, submitid): ownerid = d.get_ownerid(submitid=submitid) @@ -1016,6 +1029,8 @@ def remove(userid, submitid): if query: welcome.submission_remove(submitid) + d.cached_posts_count.invalidate(ownerid) + _invalidate_collectors_posts_count(submitid) return ownerid diff --git a/weasyl/templates/common/user_tabs.html b/weasyl/templates/common/user_tabs.html index 28c54170b..c3ec292cf 100644 --- a/weasyl/templates/common/user_tabs.html +++ b/weasyl/templates/common/user_tabs.html @@ -1,15 +1,16 @@ -$def with (username, current, show_favorites) +$def with (username, current, show_favorites, post_counts_by_type) + $def count(key): ${format(post_counts_by_type[key], ",")} $code: - def _CURRENT_(x, y): - return "class=\"current\" " if x == y else "" + def _CURRENT(x): + return "current" if x == current else "" $ username = LOGIN(username) diff --git a/weasyl/templates/user/characters.html b/weasyl/templates/user/characters.html index 84315ba2d..2ad7b577e 100644 --- a/weasyl/templates/user/characters.html +++ b/weasyl/templates/user/characters.html @@ -1,9 +1,9 @@ -$def with (profile, userinfo, relationship, result) +$def with (profile, userinfo, relationship, result, post_counts_by_type) $ _GRID_ITEM = COMPILE("common/thumbnail_grid_item.html")