From 51f2b3e40ef9cd165863d26218f57ffccadf96dd Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Fri, 13 Sep 2024 11:08:04 +1200 Subject: [PATCH] track user statistics like post count --- app/activitypub/routes.py | 2 + app/activitypub/util.py | 23 ++++++-- app/community/routes.py | 1 + app/models.py | 46 ++++++++++------ app/post/routes.py | 11 +++- app/post/util.py | 2 +- .../versions/fdaeb0b2c078_user_stats.py | 55 +++++++++++++++++++ 7 files changed, 116 insertions(+), 24 deletions(-) create mode 100644 migrations/versions/fdaeb0b2c078_user_stats.py diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index fa06fe0a..a57a1757 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -1104,6 +1104,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): post.delete_dependencies() announce_activity_to_followers(post.community, post.author, request_json) post.deleted = True + post.author.post_count -= 1 db.session.commit() activity_log.result = 'success' else: @@ -1119,6 +1120,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): reply.post.reply_count -= 1 reply.deleted = True announce_activity_to_followers(reply.community, reply.author, request_json) + reply.author.post_reply_count -= 1 db.session.commit() activity_log.result = 'success' else: diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 74a17758..0b484d0c 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -543,6 +543,7 @@ def refresh_user_profile_task(user_id): user.cover = cover db.session.add(cover) cover_changed = True + user.recalculate_post_stats() db.session.commit() if user.avatar_id and avatar_changed: make_image_sizes(user.avatar_id, 40, 250, 'users') @@ -1310,11 +1311,14 @@ def downvote_post(post, user): post.down_votes += 1 # Make 'hot' sort more spicy by amplifying the effect of early downvotes if post.up_votes + post.down_votes <= 30: - post.score -= current_app.config['SPICY_UNDER_30'] + effect = current_app.config['SPICY_UNDER_30'] elif post.up_votes + post.down_votes <= 60: - post.score -= current_app.config['SPICY_UNDER_60'] + effect = current_app.config['SPICY_UNDER_60'] else: - post.score -= 1.0 + effect = -1.0 + if user.cannot_vote(): + effect = 0 + post.score -= effect vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id, effect=effect) post.author.reputation += effect @@ -1349,7 +1353,9 @@ def downvote_post_reply(comment, user): if not existing_vote: effect = -1.0 comment.down_votes += 1 - comment.score -= 1.0 + if user.cannot_vote(): + effect = 0 + comment.score -= effect vote = PostReplyVote(user_id=user.id, post_reply_id=comment.id, author_id=comment.author.id, effect=effect) comment.author.reputation += effect @@ -1381,6 +1387,8 @@ def upvote_post_reply(comment, user): effect = instance_weight(user.ap_domain) existing_vote = PostReplyVote.query.filter_by(user_id=user.id, post_reply_id=comment.id).first() + if user.cannot_vote(): + effect = 0 if not existing_vote: comment.up_votes += 1 comment.score += effect @@ -1424,6 +1432,8 @@ def upvote_post(post, user): spicy_effect = effect * current_app.config['SPICY_UNDER_30'] elif post.up_votes + post.down_votes <= 60: spicy_effect = effect * current_app.config['SPICY_UNDER_60'] + if user.cannot_vote(): + effect = spicy_effect = 0 existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=post.id).first() if not existing_vote: post.up_votes += 1 @@ -1474,6 +1484,7 @@ def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id to_delete.delete_dependencies() to_delete.deleted = True community.post_count -= 1 + to_delete.author.post_count -= 1 db.session.commit() if to_delete.author.id != deletor.id: add_to_modlog_activitypub('delete_post', deletor, community_id=community.id, @@ -1487,6 +1498,7 @@ def delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id else: to_delete.delete_dependencies() to_delete.deleted = True + to_delete.author.post_reply_count -= 1 db.session.commit() if to_delete.author.id != deletor.id: add_to_modlog_activitypub('delete_post_reply', deletor, community_id=community.id, @@ -1513,6 +1525,7 @@ def restore_post_or_comment_task(object_json): # TODO: restore_dependencies() to_restore.deleted = False community.post_count += 1 + to_restore.author.post_count += 1 db.session.commit() if to_restore.author.id != restorer.id: add_to_modlog_activitypub('restore_post', restorer, community_id=community.id, @@ -1816,6 +1829,7 @@ def create_post_reply(activity_log: ActivityPubLog, community: Community, in_rep community.last_active = post.last_active = utcnow() activity_log.result = 'success' post_reply.ranking = confidence(post_reply.up_votes, post_reply.down_votes) + user.post_reply_count += 1 db.session.commit() # send notification to the post/comment being replied to @@ -2014,6 +2028,7 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json community.post_count += 1 community.last_active = utcnow() activity_log.result = 'success' + user.post_count += 1 db.session.commit() # Polls need to be processed quite late because they need a post_id to refer to diff --git a/app/community/routes.py b/app/community/routes.py index d9ce698c..465b9880 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -584,6 +584,7 @@ def add_post(actor, type): post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) save_post(form, post, post_type) community.post_count += 1 + current_user.post_count += 1 community.last_active = g.site.last_active = utcnow() db.session.commit() post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}" diff --git a/app/models.py b/app/models.py index 683eefa7..bf71f054 100644 --- a/app/models.py +++ b/app/models.py @@ -637,6 +637,8 @@ class User(UserMixin, db.Model): timezone = db.Column(db.String(20)) reputation = db.Column(db.Float, default=0.0) attitude = db.Column(db.Float, default=1.0) # (upvotes cast - downvotes cast) / (upvotes + downvotes). A number between 1 and -1 is the ratio between up and down votes they cast + post_count = db.Column(db.Integer, default=0) + post_reply_count = db.Column(db.Integer, default=0) stripe_customer_id = db.Column(db.String(50)) stripe_subscription_id = db.Column(db.String(50)) searchable = db.Column(db.Boolean, default=True) @@ -805,6 +807,11 @@ class User(UserMixin, db.Model): return False return True + def cannot_vote(self): + if self.is_local(): + return False + return self.post_count == 0 and self.post_reply_count == 0 and len(self.user_name) == 8 # most vote manipulation bots have 8 character user names and never post any content + def link(self) -> str: if self.is_local(): return self.user_name @@ -843,24 +850,21 @@ class User(UserMixin, db.Model): return self.expires < datetime(2019, 9, 1) def recalculate_attitude(self): - upvotes = db.session.execute(text('SELECT COUNT(id) as c FROM "post_vote" WHERE user_id = :user_id AND effect > 0'), - {'user_id': self.id}).scalar() - downvotes = db.session.execute(text('SELECT COUNT(id) as c FROM "post_vote" WHERE user_id = :user_id AND effect < 0'), - {'user_id': self.id}).scalar() - if upvotes is None: - upvotes = 0 - if downvotes is None: - downvotes = 0 + upvotes = downvotes = 0 + last_50_votes = PostVote.query.filter(PostVote.user_id == self.id).order_by(-PostVote.id).limit(50) + for vote in last_50_votes: + if vote.effect > 0: + upvotes += 1 + if vote.effect < 0: + downvotes += 1 - comment_upvotes = db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply_vote" WHERE user_id = :user_id AND effect > 0'), - {'user_id': self.id}).scalar() - comment_downvotes = db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply_vote" WHERE user_id = :user_id AND effect < 0'), - {'user_id': self.id}).scalar() - - if comment_upvotes is None: - comment_upvotes = 0 - if comment_downvotes is None: - comment_downvotes = 0 + comment_upvotes = comment_downvotes = 0 + last_50_votes = PostReplyVote.query.filter(PostReplyVote.user_id == self.id).order_by(-PostReplyVote.id).limit(50) + for vote in last_50_votes: + if vote.effect > 0: + comment_upvotes += 1 + if vote.effect < 0: + comment_downvotes += 1 total_upvotes = upvotes + comment_upvotes total_downvotes = downvotes + comment_downvotes @@ -873,6 +877,14 @@ class User(UserMixin, db.Model): else: self.attitude = 1.0 + def recalculate_post_stats(self, posts=True, replies=True): + if posts: + self.post_count = db.session.execute(text('SELECT COUNT(id) as c FROM "post" WHERE user_id = :user_id AND deleted = false'), + {'user_id': self.id}).scalar() + if replies: + self.post_reply_count = db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE user_id = :user_id AND deleted = false'), + {'user_id': self.id}).scalar() + def subscribed(self, community_id: int) -> int: if community_id is None: return False diff --git a/app/post/routes.py b/app/post/routes.py index ef968e9b..c20d0ec7 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -15,7 +15,7 @@ from app.community.util import save_post, send_to_remote_instance from app.inoculation import inoculation from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm, CreateVideoForm, CreatePollForm, EditImageForm -from app.post.util import post_replies, get_comment_branch, post_reply_count, tags_to_string, url_needs_archive, \ +from app.post.util import post_replies, get_comment_branch, get_post_reply_count, tags_to_string, url_needs_archive, \ generate_archive_link, body_has_no_archive_link from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_LINK, \ POST_TYPE_IMAGE, \ @@ -111,6 +111,7 @@ def show_post(post_id: int): post.last_active = community.last_active = utcnow() post.reply_count += 1 community.post_reply_count += 1 + current_user.post_reply_count += 1 current_user.language_id = form.language_id.data db.session.add(reply) @@ -737,8 +738,9 @@ def add_reply(post_id: int, comment_id: int): elif current_user.reputation < -100: reply.score -= 1 reply.ranking -= 1 - post.reply_count = post_reply_count(post.id) + post.reply_count = get_post_reply_count(post.id) post.last_active = post.community.last_active = utcnow() + current_user.post_reply_count += 1 db.session.commit() form.body.data = '' flash('Your comment has been added.') @@ -1201,6 +1203,8 @@ def post_delete_post(community: Community, post: Post, user_id: int, federate_al ocp.cross_posts.remove(post.id) post.delete_dependencies() post.deleted = True + post.author.post_count -= 1 + community.post_count -= 1 if hasattr(g, 'site'): # g.site is invalid when running from cli g.site.last_active = community.last_active = utcnow() flash(_('Post deleted.')) @@ -1266,6 +1270,8 @@ def post_restore(post_id: int): post = Post.query.get_or_404(post_id) if post.community.is_moderator() or post.community.is_owner() or current_user.is_admin(): post.deleted = False + post.author.post_count += 1 + post.community.post_count += 1 db.session.commit() # Federate un-delete @@ -1781,6 +1787,7 @@ def post_reply_delete(post_id: int, comment_id: int): post_reply.delete_dependencies() post_reply.deleted = True g.site.last_active = community.last_active = utcnow() + post_reply.author.post_reply_count -= 1 db.session.commit() flash(_('Comment deleted.')) # federate delete diff --git a/app/post/util.py b/app/post/util.py index e050721d..150d9977 100644 --- a/app/post/util.py +++ b/app/post/util.py @@ -76,7 +76,7 @@ def get_comment_branch(post_id: int, comment_id: int, sort_by: str) -> List[Post # The number of replies a post has -def post_reply_count(post_id) -> int: +def get_post_reply_count(post_id) -> int: return db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply" WHERE post_id = :post_id AND deleted is false'), {'post_id': post_id}).scalar() diff --git a/migrations/versions/fdaeb0b2c078_user_stats.py b/migrations/versions/fdaeb0b2c078_user_stats.py new file mode 100644 index 00000000..313ebf61 --- /dev/null +++ b/migrations/versions/fdaeb0b2c078_user_stats.py @@ -0,0 +1,55 @@ +"""user stats + +Revision ID: fdaeb0b2c078 +Revises: 2cae414cbc7a +Create Date: 2024-09-13 09:37:24.847306 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fdaeb0b2c078' +down_revision = '2cae414cbc7a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('post_count', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('post_reply_count', sa.Integer(), nullable=True)) + + # ### end Alembic commands ### + op.execute(sa.DDL('UPDATE "user" SET post_count = 0, post_reply_count = 0')) + + op.execute(sa.DDL("""UPDATE "user" + SET post_count = subquery.post_count + FROM ( + SELECT user_id, COUNT(*) as post_count + FROM post + WHERE deleted = false + GROUP BY user_id + ) AS subquery + WHERE "user".id = subquery.user_id;""")) + + op.execute(sa.DDL("""UPDATE "user" + SET post_reply_count = subquery.post_count + FROM ( + SELECT user_id, COUNT(*) as post_count + FROM post_reply + WHERE deleted = false + GROUP BY user_id + ) AS subquery + WHERE "user".id = subquery.user_id;""")) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('post_reply_count') + batch_op.drop_column('post_count') + + # ### end Alembic commands ###