track user statistics like post count

This commit is contained in:
rimu 2024-09-13 11:08:04 +12:00
parent 1a658d007f
commit 51f2b3e40e
7 changed files with 116 additions and 24 deletions

View file

@ -1104,6 +1104,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
post.delete_dependencies() post.delete_dependencies()
announce_activity_to_followers(post.community, post.author, request_json) announce_activity_to_followers(post.community, post.author, request_json)
post.deleted = True post.deleted = True
post.author.post_count -= 1
db.session.commit() db.session.commit()
activity_log.result = 'success' activity_log.result = 'success'
else: else:
@ -1119,6 +1120,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
reply.post.reply_count -= 1 reply.post.reply_count -= 1
reply.deleted = True reply.deleted = True
announce_activity_to_followers(reply.community, reply.author, request_json) announce_activity_to_followers(reply.community, reply.author, request_json)
reply.author.post_reply_count -= 1
db.session.commit() db.session.commit()
activity_log.result = 'success' activity_log.result = 'success'
else: else:

View file

@ -543,6 +543,7 @@ def refresh_user_profile_task(user_id):
user.cover = cover user.cover = cover
db.session.add(cover) db.session.add(cover)
cover_changed = True cover_changed = True
user.recalculate_post_stats()
db.session.commit() db.session.commit()
if user.avatar_id and avatar_changed: if user.avatar_id and avatar_changed:
make_image_sizes(user.avatar_id, 40, 250, 'users') make_image_sizes(user.avatar_id, 40, 250, 'users')
@ -1310,11 +1311,14 @@ def downvote_post(post, user):
post.down_votes += 1 post.down_votes += 1
# Make 'hot' sort more spicy by amplifying the effect of early downvotes # Make 'hot' sort more spicy by amplifying the effect of early downvotes
if post.up_votes + post.down_votes <= 30: 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: elif post.up_votes + post.down_votes <= 60:
post.score -= current_app.config['SPICY_UNDER_60'] effect = current_app.config['SPICY_UNDER_60']
else: 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, vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id,
effect=effect) effect=effect)
post.author.reputation += effect post.author.reputation += effect
@ -1349,7 +1353,9 @@ def downvote_post_reply(comment, user):
if not existing_vote: if not existing_vote:
effect = -1.0 effect = -1.0
comment.down_votes += 1 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, vote = PostReplyVote(user_id=user.id, post_reply_id=comment.id,
author_id=comment.author.id, effect=effect) author_id=comment.author.id, effect=effect)
comment.author.reputation += effect comment.author.reputation += effect
@ -1381,6 +1387,8 @@ def upvote_post_reply(comment, user):
effect = instance_weight(user.ap_domain) effect = instance_weight(user.ap_domain)
existing_vote = PostReplyVote.query.filter_by(user_id=user.id, existing_vote = PostReplyVote.query.filter_by(user_id=user.id,
post_reply_id=comment.id).first() post_reply_id=comment.id).first()
if user.cannot_vote():
effect = 0
if not existing_vote: if not existing_vote:
comment.up_votes += 1 comment.up_votes += 1
comment.score += effect comment.score += effect
@ -1424,6 +1432,8 @@ def upvote_post(post, user):
spicy_effect = effect * current_app.config['SPICY_UNDER_30'] spicy_effect = effect * current_app.config['SPICY_UNDER_30']
elif post.up_votes + post.down_votes <= 60: elif post.up_votes + post.down_votes <= 60:
spicy_effect = effect * current_app.config['SPICY_UNDER_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() existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=post.id).first()
if not existing_vote: if not existing_vote:
post.up_votes += 1 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.delete_dependencies()
to_delete.deleted = True to_delete.deleted = True
community.post_count -= 1 community.post_count -= 1
to_delete.author.post_count -= 1
db.session.commit() db.session.commit()
if to_delete.author.id != deletor.id: if to_delete.author.id != deletor.id:
add_to_modlog_activitypub('delete_post', deletor, community_id=community.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: else:
to_delete.delete_dependencies() to_delete.delete_dependencies()
to_delete.deleted = True to_delete.deleted = True
to_delete.author.post_reply_count -= 1
db.session.commit() db.session.commit()
if to_delete.author.id != deletor.id: if to_delete.author.id != deletor.id:
add_to_modlog_activitypub('delete_post_reply', deletor, community_id=community.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() # TODO: restore_dependencies()
to_restore.deleted = False to_restore.deleted = False
community.post_count += 1 community.post_count += 1
to_restore.author.post_count += 1
db.session.commit() db.session.commit()
if to_restore.author.id != restorer.id: if to_restore.author.id != restorer.id:
add_to_modlog_activitypub('restore_post', restorer, community_id=community.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() community.last_active = post.last_active = utcnow()
activity_log.result = 'success' activity_log.result = 'success'
post_reply.ranking = confidence(post_reply.up_votes, post_reply.down_votes) post_reply.ranking = confidence(post_reply.up_votes, post_reply.down_votes)
user.post_reply_count += 1
db.session.commit() db.session.commit()
# send notification to the post/comment being replied to # 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.post_count += 1
community.last_active = utcnow() community.last_active = utcnow()
activity_log.result = 'success' activity_log.result = 'success'
user.post_count += 1
db.session.commit() db.session.commit()
# Polls need to be processed quite late because they need a post_id to refer to # Polls need to be processed quite late because they need a post_id to refer to

View file

@ -584,6 +584,7 @@ def add_post(actor, type):
post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1)
save_post(form, post, post_type) save_post(form, post, post_type)
community.post_count += 1 community.post_count += 1
current_user.post_count += 1
community.last_active = g.site.last_active = utcnow() community.last_active = g.site.last_active = utcnow()
db.session.commit() db.session.commit()
post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}" post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}"

View file

@ -637,6 +637,8 @@ class User(UserMixin, db.Model):
timezone = db.Column(db.String(20)) timezone = db.Column(db.String(20))
reputation = db.Column(db.Float, default=0.0) 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 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_customer_id = db.Column(db.String(50))
stripe_subscription_id = db.Column(db.String(50)) stripe_subscription_id = db.Column(db.String(50))
searchable = db.Column(db.Boolean, default=True) searchable = db.Column(db.Boolean, default=True)
@ -805,6 +807,11 @@ class User(UserMixin, db.Model):
return False return False
return True 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: def link(self) -> str:
if self.is_local(): if self.is_local():
return self.user_name return self.user_name
@ -843,24 +850,21 @@ class User(UserMixin, db.Model):
return self.expires < datetime(2019, 9, 1) return self.expires < datetime(2019, 9, 1)
def recalculate_attitude(self): 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'), upvotes = downvotes = 0
{'user_id': self.id}).scalar() last_50_votes = PostVote.query.filter(PostVote.user_id == self.id).order_by(-PostVote.id).limit(50)
downvotes = db.session.execute(text('SELECT COUNT(id) as c FROM "post_vote" WHERE user_id = :user_id AND effect < 0'), for vote in last_50_votes:
{'user_id': self.id}).scalar() if vote.effect > 0:
if upvotes is None: upvotes += 1
upvotes = 0 if vote.effect < 0:
if downvotes is None: downvotes += 1
downvotes = 0
comment_upvotes = db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply_vote" WHERE user_id = :user_id AND effect > 0'), comment_upvotes = comment_downvotes = 0
{'user_id': self.id}).scalar() last_50_votes = PostReplyVote.query.filter(PostReplyVote.user_id == self.id).order_by(-PostReplyVote.id).limit(50)
comment_downvotes = db.session.execute(text('SELECT COUNT(id) as c FROM "post_reply_vote" WHERE user_id = :user_id AND effect < 0'), for vote in last_50_votes:
{'user_id': self.id}).scalar() if vote.effect > 0:
comment_upvotes += 1
if comment_upvotes is None: if vote.effect < 0:
comment_upvotes = 0 comment_downvotes += 1
if comment_downvotes is None:
comment_downvotes = 0
total_upvotes = upvotes + comment_upvotes total_upvotes = upvotes + comment_upvotes
total_downvotes = downvotes + comment_downvotes total_downvotes = downvotes + comment_downvotes
@ -873,6 +877,14 @@ class User(UserMixin, db.Model):
else: else:
self.attitude = 1.0 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: def subscribed(self, community_id: int) -> int:
if community_id is None: if community_id is None:
return False return False

View file

@ -15,7 +15,7 @@ from app.community.util import save_post, send_to_remote_instance
from app.inoculation import inoculation from app.inoculation import inoculation
from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm
from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm, CreateVideoForm, CreatePollForm, EditImageForm 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 generate_archive_link, body_has_no_archive_link
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_LINK, \ from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_LINK, \
POST_TYPE_IMAGE, \ POST_TYPE_IMAGE, \
@ -111,6 +111,7 @@ def show_post(post_id: int):
post.last_active = community.last_active = utcnow() post.last_active = community.last_active = utcnow()
post.reply_count += 1 post.reply_count += 1
community.post_reply_count += 1 community.post_reply_count += 1
current_user.post_reply_count += 1
current_user.language_id = form.language_id.data current_user.language_id = form.language_id.data
db.session.add(reply) db.session.add(reply)
@ -737,8 +738,9 @@ def add_reply(post_id: int, comment_id: int):
elif current_user.reputation < -100: elif current_user.reputation < -100:
reply.score -= 1 reply.score -= 1
reply.ranking -= 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() post.last_active = post.community.last_active = utcnow()
current_user.post_reply_count += 1
db.session.commit() db.session.commit()
form.body.data = '' form.body.data = ''
flash('Your comment has been added.') 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) ocp.cross_posts.remove(post.id)
post.delete_dependencies() post.delete_dependencies()
post.deleted = True post.deleted = True
post.author.post_count -= 1
community.post_count -= 1
if hasattr(g, 'site'): # g.site is invalid when running from cli if hasattr(g, 'site'): # g.site is invalid when running from cli
g.site.last_active = community.last_active = utcnow() g.site.last_active = community.last_active = utcnow()
flash(_('Post deleted.')) flash(_('Post deleted.'))
@ -1266,6 +1270,8 @@ def post_restore(post_id: int):
post = Post.query.get_or_404(post_id) post = Post.query.get_or_404(post_id)
if post.community.is_moderator() or post.community.is_owner() or current_user.is_admin(): if post.community.is_moderator() or post.community.is_owner() or current_user.is_admin():
post.deleted = False post.deleted = False
post.author.post_count += 1
post.community.post_count += 1
db.session.commit() db.session.commit()
# Federate un-delete # Federate un-delete
@ -1781,6 +1787,7 @@ def post_reply_delete(post_id: int, comment_id: int):
post_reply.delete_dependencies() post_reply.delete_dependencies()
post_reply.deleted = True post_reply.deleted = True
g.site.last_active = community.last_active = utcnow() g.site.last_active = community.last_active = utcnow()
post_reply.author.post_reply_count -= 1
db.session.commit() db.session.commit()
flash(_('Comment deleted.')) flash(_('Comment deleted.'))
# federate delete # federate delete

View file

@ -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 # 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'), 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() {'post_id': post_id}).scalar()

View file

@ -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 ###