refactor voting code to enable api development #13

This commit is contained in:
rimu 2024-09-13 16:39:42 +12:00
parent 30dedd19c5
commit 740c42daea
4 changed files with 182 additions and 293 deletions

View file

@ -20,15 +20,14 @@ from app.models import User, Community, CommunityJoinRequest, CommunityMember, C
ChatMessage, Conversation, UserFollower, UserBlock, Poll, PollChoice
from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \
post_to_activity, find_actor_or_create, instance_blocked, find_reply_parent, find_liked_object, \
lemmy_site_data, instance_weight, is_activitypub_request, downvote_post_reply, downvote_post, upvote_post_reply, \
upvote_post, delete_post_or_comment, community_members, \
lemmy_site_data, is_activitypub_request, delete_post_or_comment, community_members, \
user_removed_from_remote_server, create_post, create_post_reply, update_post_reply_from_activity, \
update_post_from_activity, undo_vote, undo_downvote, post_to_page, get_redis_connection, find_reported_object, \
process_report, ensure_domains_match, can_edit, can_delete, remove_data_from_banned_user, resolve_remote_post, \
inform_followers_of_post_update, comment_model_to_json, restore_post_or_comment, ban_local_user, unban_local_user, \
lock_post
from app.utils import gibberish, get_setting, is_image_url, allowlist_html, render_template, \
domain_from_url, markdown_to_html, community_membership, ap_datetime, ip_address, can_downvote, \
from app.utils import gibberish, get_setting, render_template, \
community_membership, ap_datetime, ip_address, can_downvote, \
can_upvote, can_create_post, awaken_dormant_instance, shorten_string, can_create_post_reply, sha256_digest, \
community_moderators, lemmy_markdown_to_html
@ -780,11 +779,8 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
# insert into voted table
if liked is None:
activity_log.exception_message = 'Liked object not found'
elif liked is not None and isinstance(liked, Post):
upvote_post(liked, user)
activity_log.result = 'success'
elif liked is not None and isinstance(liked, PostReply):
upvote_post_reply(liked, user)
elif liked is not None and isinstance(liked, (Post, PostReply)):
liked.vote(user, 'upvote')
activity_log.result = 'success'
else:
activity_log.exception_message = 'Could not detect type of like'
@ -813,10 +809,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
if disliked is None:
activity_log.exception_message = 'Liked object not found'
elif isinstance(disliked, (Post, PostReply)):
if isinstance(disliked, Post):
downvote_post(disliked, user)
elif isinstance(disliked, PostReply):
downvote_post_reply(disliked, user)
disliked.vote(user, 'downvote')
activity_log.result = 'success'
# todo: recalculate 'hotness' of liked post/reply
else:
@ -1152,11 +1145,8 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
# insert into voted table
if liked is None:
activity_log.exception_message = 'Liked object not found'
elif liked is not None and isinstance(liked, Post):
upvote_post(liked, user)
activity_log.result = 'success'
elif liked is not None and isinstance(liked, PostReply):
upvote_post_reply(liked, user)
elif liked is not None and isinstance(liked, (Post, PostReply)):
liked.vote(user, 'upvote')
activity_log.result = 'success'
else:
activity_log.exception_message = 'Could not detect type of like'
@ -1186,10 +1176,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
if disliked is None:
activity_log.exception_message = 'Liked object not found'
elif isinstance(disliked, (Post, PostReply)):
if isinstance(disliked, Post):
downvote_post(disliked, user)
elif isinstance(disliked, PostReply):
downvote_post_reply(disliked, user)
disliked.vote(user, 'downvote')
activity_log.result = 'success'
else:
activity_log.exception_message = 'Could not detect type of like'

View file

@ -1302,169 +1302,6 @@ def is_activitypub_request():
return 'application/ld+json' in request.headers.get('Accept', '') or 'application/activity+json' in request.headers.get('Accept', '')
def downvote_post(post, user):
user.last_seen = utcnow()
user.recalculate_attitude()
existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=post.id).first()
if not existing_vote:
effect = -1.0
post.down_votes += 1
# Make 'hot' sort more spicy by amplifying the effect of early downvotes
if post.up_votes + post.down_votes <= 30:
effect = current_app.config['SPICY_UNDER_30']
elif post.up_votes + post.down_votes <= 60:
effect = current_app.config['SPICY_UNDER_60']
else:
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
db.session.add(vote)
else:
# remove previously cast upvote
if existing_vote.effect > 0:
post.author.reputation -= existing_vote.effect
post.up_votes -= 1
post.score -= existing_vote.effect
db.session.delete(existing_vote)
# apply down vote
effect = -1.0
post.down_votes += 1
post.score -= 1.0
vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id,
effect=effect)
post.author.reputation += effect
db.session.add(vote)
else:
pass # they have already downvoted this post
post.ranking = post_ranking(post.score, post.posted_at)
db.session.commit()
def downvote_post_reply(comment, user):
user.last_seen = utcnow()
user.recalculate_attitude()
existing_vote = PostReplyVote.query.filter_by(user_id=user.id,
post_reply_id=comment.id).first()
if not existing_vote:
effect = -1.0
comment.down_votes += 1
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
db.session.add(vote)
else:
# remove previously cast upvote
if existing_vote.effect > 0:
comment.author.reputation -= existing_vote.effect
comment.up_votes -= 1
comment.score -= existing_vote.effect
db.session.delete(existing_vote)
# apply down vote
effect = -1.0
comment.down_votes += 1
comment.score -= 1.0
vote = PostReplyVote(user_id=user.id, post_reply_id=comment.id,
author_id=comment.author.id, effect=effect)
comment.author.reputation += effect
db.session.add(vote)
else:
pass # they have already downvoted this reply
comment.ranking = confidence(comment.up_votes, comment.down_votes)
def upvote_post_reply(comment, user):
user.last_seen = utcnow()
user.recalculate_attitude()
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
vote = PostReplyVote(user_id=user.id, post_reply_id=comment.id,
author_id=comment.author.id, effect=effect)
if comment.community.low_quality and effect > 0:
effect = 0
comment.author.reputation += effect
db.session.add(vote)
else:
# remove previously cast downvote
if existing_vote.effect < 0:
comment.author.reputation -= existing_vote.effect
comment.down_votes -= 1
comment.score -= existing_vote.effect
db.session.delete(existing_vote)
# apply up vote
comment.up_votes += 1
comment.score += effect
vote = PostReplyVote(user_id=user.id, post_reply_id=comment.id,
author_id=comment.author.id, effect=effect)
if comment.community.low_quality and effect > 0:
effect = 0
comment.author.reputation += effect
db.session.add(vote)
else:
pass # they have already upvoted this reply
comment.ranking = confidence(comment.up_votes, comment.down_votes)
def upvote_post(post, user):
user.last_seen = utcnow()
user.recalculate_attitude()
effect = instance_weight(user.ap_domain)
# Make 'hot' sort more spicy by amplifying the effect of early upvotes
spicy_effect = effect
if post.up_votes + post.down_votes <= 10:
spicy_effect = effect * current_app.config['SPICY_UNDER_10']
elif post.up_votes + post.down_votes <= 30:
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
post.score += spicy_effect
vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id,
effect=effect)
if post.community.low_quality and effect > 0:
effect = 0
post.author.reputation += effect
db.session.add(vote)
else:
# remove previous cast downvote
if existing_vote.effect < 0:
post.author.reputation -= existing_vote.effect
post.down_votes -= 1
post.score -= existing_vote.effect
db.session.delete(existing_vote)
# apply up vote
post.up_votes += 1
post.score += effect
vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id,
effect=effect)
if post.community.low_quality and effect > 0:
effect = 0
post.author.reputation += effect
db.session.add(vote)
post.ranking = post_ranking(post.score, post.posted_at)
db.session.commit()
def delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id):
if current_app.debug:
delete_post_or_comment_task(user_ap_id, community_ap_id, to_be_deleted_ap_id)

View file

@ -101,6 +101,14 @@ class Instance(db.Model):
return db.session.execute(text('SELECT COUNT(id) as c FROM "user" WHERE instance_id = :instance_id'),
{'instance_id': self.id}).scalar()
@classmethod
def weight(cls, domain: str):
if domain:
instance = Instance.query.filter_by(domain=domain).first()
if instance:
return instance.vote_weight
return 1.0
def __repr__(self):
return '<Instance {}>'.format(self.domain)
@ -1154,6 +1162,96 @@ class Post(db.Model):
'name': f'#{tag.name}'})
return return_value
# All the following post/comment ranking math is explained at https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9
epoch = datetime(1970, 1, 1)
def epoch_seconds(self, date):
td = date - self.epoch
return td.days * 86400 + td.seconds + (float(td.microseconds) / 1000000)
# All the following post/comment ranking math is explained at https://medium.com/hacking-and-gonzo/how-reddit-ranking-algorithms-work-ef111e33d0d9
def post_ranking(self, score, date: datetime):
if date is None:
date = datetime.utcnow()
if score is None:
score = 1
order = math.log(max(abs(score), 1), 10)
sign = 1 if score > 0 else -1 if score < 0 else 0
seconds = self.epoch_seconds(date) - 1685766018
return round(sign * order + seconds / 45000, 7)
def vote(self, user: User, vote_direction: str):
assert vote_direction == 'upvote' or vote_direction == 'downvote'
existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=self.id).first()
undo = None
if existing_vote:
if not self.community.low_quality:
self.author.reputation -= existing_vote.effect
if existing_vote.effect > 0: # previous vote was up
if vote_direction == 'upvote': # new vote is also up, so remove it
db.session.delete(existing_vote)
self.up_votes -= 1
self.score -= existing_vote.effect
undo = 'Like'
else: # new vote is down while previous vote was up, so reverse their previous vote
existing_vote.effect = -1
self.up_votes -= 1
self.down_votes += 1
self.score -= existing_vote.effect * 2
else: # previous vote was down
if vote_direction == 'downvote': # new vote is also down, so remove it
db.session.delete(existing_vote)
self.down_votes -= 1
self.score += existing_vote.effect
undo = 'Dislike'
else: # new vote is up while previous vote was down, so reverse their previous vote
existing_vote.effect = 1
self.up_votes += 1
self.down_votes -= 1
self.score += existing_vote.effect * 2
else:
if vote_direction == 'upvote':
effect = Instance.weight(user.ap_domain)
spicy_effect = effect
# Make 'hot' sort more spicy by amplifying the effect of early upvotes
if self.up_votes + self.down_votes <= 10:
spicy_effect = effect * current_app.config['SPICY_UNDER_10']
elif self.up_votes + self.down_votes <= 30:
spicy_effect = effect * current_app.config['SPICY_UNDER_30']
elif self.up_votes + self.down_votes <= 60:
spicy_effect = effect * current_app.config['SPICY_UNDER_60']
if user.cannot_vote():
effect = spicy_effect = 0
self.up_votes += 1
self.score += spicy_effect
else:
effect = -1.0
self.down_votes += 1
# Make 'hot' sort more spicy by amplifying the effect of early downvotes
if self.up_votes + self.down_votes <= 30:
effect = current_app.config['SPICY_UNDER_30']
elif self.up_votes + self.down_votes <= 60:
effect = current_app.config['SPICY_UNDER_60']
else:
effect = -1.0
if user.cannot_vote():
effect = 0
self.score -= effect
vote = PostVote(user_id=user.id, post_id=self.id, author_id=self.author.id,
effect=effect)
# upvotes do not increase reputation in low quality communities
if self.community.low_quality and effect > 0:
effect = 0
self.author.reputation += effect
db.session.add(vote)
user.last_seen = utcnow()
if not user.banned:
self.ranking = self.post_ranking(self.score, self.created_at)
user.recalculate_attitude()
db.session.commit()
return undo
class PostReply(db.Model):
query_class = FullTextSearchQuery
@ -1274,6 +1372,79 @@ class PostReply(db.Model):
NotificationSubscription.type == NOTIF_REPLY).first()
return existing_notification is not None
# used for ranking comments
def _confidence(self, ups, downs):
n = ups + downs
if n == 0:
return 0.0
z = 1.281551565545
p = float(ups) / n
left = p + 1 / (2 * n) * z * z
right = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
under = 1 + 1 / n * z * z
return (left - right) / under
def confidence(self, ups, downs) -> float:
if ups is None or ups < 0:
ups = 0
if downs is None or downs < 0:
downs = 0
if ups + downs == 0:
return 0.0
else:
return self._confidence(ups, downs)
def vote(self, user: User, vote_direction: str):
existing_vote = PostReplyVote.query.filter_by(user_id=user.id, post_reply_id=self.id).first()
undo = None
if existing_vote:
if existing_vote.effect > 0: # previous vote was up
if vote_direction == 'upvote': # new vote is also up, so remove it
db.session.delete(existing_vote)
self.up_votes -= 1
self.score -= 1
undo = 'Like'
else: # new vote is down while previous vote was up, so reverse their previous vote
existing_vote.effect = -1
self.up_votes -= 1
self.down_votes += 1
self.score -= 2
else: # previous vote was down
if vote_direction == 'downvote': # new vote is also down, so remove it
db.session.delete(existing_vote)
self.down_votes -= 1
self.score += 1
undo = 'Dislike'
else: # new vote is up while previous vote was down, so reverse their previous vote
existing_vote.effect = 1
self.up_votes += 1
self.down_votes -= 1
self.score += 2
else:
if user.cannot_vote():
effect = 0
else:
effect = 1
if vote_direction == 'upvote':
self.up_votes += 1
else:
effect = effect * -1
self.down_votes += 1
self.score += effect
vote = PostReplyVote(user_id=user.id, post_reply_id=self.id, author_id=self.author.id,
effect=effect)
self.author.reputation += effect
db.session.add(vote)
current_user.last_seen = utcnow()
self.ranking = self.confidence(self.up_votes, self.down_votes)
user.recalculate_attitude()
db.session.commit()
return undo
class Domain(db.Model):
id = db.Column(db.Integer, primary_key=True)

View file

@ -338,62 +338,7 @@ def show_post(post_id: int):
@validation_required
def post_vote(post_id: int, vote_direction):
post = Post.query.get_or_404(post_id)
existing_vote = PostVote.query.filter_by(user_id=current_user.id, post_id=post.id).first()
undo = None
if existing_vote:
if not post.community.low_quality:
post.author.reputation -= existing_vote.effect
if existing_vote.effect > 0: # previous vote was up
if vote_direction == 'upvote': # new vote is also up, so remove it
db.session.delete(existing_vote)
post.up_votes -= 1
post.score -= 1
undo = 'Like'
else: # new vote is down while previous vote was up, so reverse their previous vote
existing_vote.effect = -1
post.up_votes -= 1
post.down_votes += 1
post.score -= 2
else: # previous vote was down
if vote_direction == 'downvote': # new vote is also down, so remove it
db.session.delete(existing_vote)
post.down_votes -= 1
post.score += 1
undo = 'Dislike'
else: # new vote is up while previous vote was down, so reverse their previous vote
existing_vote.effect = 1
post.up_votes += 1
post.down_votes -= 1
post.score += 2
else:
if vote_direction == 'upvote':
effect = 1
post.up_votes += 1
# Make 'hot' sort more spicy by amplifying the effect of early upvotes
if post.up_votes + post.down_votes <= 10:
post.score += current_app.config['SPICY_UNDER_10']
elif post.up_votes + post.down_votes <= 30:
post.score += current_app.config['SPICY_UNDER_30']
elif post.up_votes + post.down_votes <= 60:
post.score += current_app.config['SPICY_UNDER_60']
else:
post.score += 1
else:
effect = -1
post.down_votes += 1
if post.up_votes + post.down_votes <= 30:
post.score -= current_app.config['SPICY_UNDER_30']
elif post.up_votes + post.down_votes <= 60:
post.score -= current_app.config['SPICY_UNDER_60']
else:
post.score -= 1
vote = PostVote(user_id=current_user.id, post_id=post.id, author_id=post.author.id,
effect=effect)
# upvotes do not increase reputation in low quality communities
if post.community.low_quality and effect > 0:
effect = 0
post.author.reputation += effect
db.session.add(vote)
undo = post.vote(current_user, vote_direction)
if not post.community.local_only:
if undo:
@ -440,14 +385,6 @@ def post_vote(post_id: int, vote_direction):
post_request_in_background(post.community.ap_inbox_url, action_json, current_user.private_key,
current_user.public_url(not(post.community.instance.votes_are_public() and current_user.vote_privately())) + '#main-key')
current_user.last_seen = utcnow()
current_user.ip_address = ip_address()
if not current_user.banned:
post.ranking = post_ranking(post.score, post.created_at)
db.session.commit()
current_user.recalculate_attitude()
db.session.commit()
recently_upvoted = []
recently_downvoted = []
if vote_direction == 'upvote' and undo is None:
@ -467,43 +404,7 @@ def post_vote(post_id: int, vote_direction):
@validation_required
def comment_vote(comment_id, vote_direction):
comment = PostReply.query.get_or_404(comment_id)
existing_vote = PostReplyVote.query.filter_by(user_id=current_user.id, post_reply_id=comment.id).first()
undo = None
if existing_vote:
if existing_vote.effect > 0: # previous vote was up
if vote_direction == 'upvote': # new vote is also up, so remove it
db.session.delete(existing_vote)
comment.up_votes -= 1
comment.score -= 1
undo = 'Like'
else: # new vote is down while previous vote was up, so reverse their previous vote
existing_vote.effect = -1
comment.up_votes -= 1
comment.down_votes += 1
comment.score -= 2
else: # previous vote was down
if vote_direction == 'downvote': # new vote is also down, so remove it
db.session.delete(existing_vote)
comment.down_votes -= 1
comment.score += 1
undo = 'Dislike'
else: # new vote is up while previous vote was down, so reverse their previous vote
existing_vote.effect = 1
comment.up_votes += 1
comment.down_votes -= 1
comment.score += 2
else:
if vote_direction == 'upvote':
effect = 1
comment.up_votes += 1
comment.score += 1
else:
effect = -1
comment.down_votes += 1
comment.score -= 1
vote = PostReplyVote(user_id=current_user.id, post_reply_id=comment_id, author_id=comment.author.id, effect=effect)
comment.author.reputation += effect
db.session.add(vote)
undo = comment.vote(current_user, vote_direction)
if not comment.community.local_only:
if undo:
@ -550,13 +451,6 @@ def comment_vote(comment_id, vote_direction):
post_request_in_background(comment.community.ap_inbox_url, action_json, current_user.private_key,
current_user.public_url(not(comment.community.instance.votes_are_public() and current_user.vote_privately())) + '#main-key')
current_user.last_seen = utcnow()
current_user.ip_address = ip_address()
comment.ranking = confidence(comment.up_votes, comment.down_votes)
db.session.commit()
current_user.recalculate_attitude()
db.session.commit()
recently_upvoted = []
recently_downvoted = []
if vote_direction == 'upvote' and undo is None: