leaderboards: refactor leaderboards to their own class

right now, leaderboards are complex enough that they ought to be upgraded to at least a second class thing.
this commit provides an *okay* implementation of a per-request leaderboard
there are many things to be done, including caching, persistence, etc
i don't like this like 80 parameter __init__ but it's what i've got without overengineering it imo
this is potentially already overdoing it
master
justcool393 2022-10-28 03:42:02 -05:00
parent 0258bfb356
commit 8873171f5c
3 changed files with 121 additions and 122 deletions

View File

@ -0,0 +1,88 @@
from typing import Any, Callable, Optional, Tuple
from sqlalchemy import func
from .badges import Badge
from .marsey import Marsey
from .user import User
from .userblock import UserBlock
class Leaderboard:
"""Represents an request-context leaderboard. None of this is persisted yet,
although this is probably a good idea to do at some point.
"""
all_users = None
v_position = 0
v_value = None
value_func = None
def __init__(self, header_name:str, table_header_name:str, html_id:str, table_column_name:str,
user_relative_url:Optional[str], query_function:Callable[..., Tuple[Any, Any, Any]],
criteria, v:User, value_func:Optional[Callable[[User], int]], db, users, limit=25):
self.header_name = header_name
self.table_header_name = table_header_name
self.html_id = html_id
self.table_column_name = table_column_name
self.user_relative_url = user_relative_url
self.limit = limit
lb = query_function(criteria, v, db, users, limit)
self.all_users = lb[0]
self.v_position = lb[1]
self.v_value = lb[2]
if not self.v_value:
if value_func:
self.value_func = value_func
self.v_value = value_func(v)
else:
self.value_func = lambda u: u[1]
def get_simple_lb(cls, order_by, v:User, db, users, limit):
leaderboard = users.order_by(order_by.desc()).limit(limit).all()
position = None
if v not in leaderboard:
sq = db.query(User.id, func.rank().over(order_by=order_by.desc()).label("rank")).subquery()
position = db.query(sq.c.id, sq.c.rank).filter(sq.c.id == v.id).limit(1).one()[1]
return (leaderboard, position, None)
def count_and_label(cls, criteria):
return func.count(criteria).label("count")
def rank_filtered_rank_label_by_desc(cls, criteria):
return func.rank().over(order_by=func.count(criteria).desc()).label("rank")
def get_badge_marsey_lb(cls, lb_criteria, v:User, db, users:Any, limit):
sq = db.query(lb_criteria, cls.count_and_label(lb_criteria), cls.rank_filtered_rank_label_by_desc(lb_criteria)).group_by(lb_criteria).subquery()
sq_criteria = None
if lb_criteria == Badge.user_id:
sq_criteria = User.id == sq.c.user_id
elif lb_criteria == Marsey.author_id:
sq_criteria = User.id == sq.c.author_id
else:
raise ValueError("This leaderboard function only supports Badge.user_id and Marsey.author_id")
leaderboard = db.query(User, sq.c.count).join(sq, sq_criteria).order_by(sq.c.count.desc())
position = db.query(User.id, sq.c.rank, sq.c.count).join(sq, sq_criteria).filter(User.id == v.id).one_or_none()
if position: position = (position[1], position[2])
else: position = (leaderboard.count() + 1, 0)
leaderboard = leaderboard.limit(limit).all()
return (leaderboard, position[0], position[1])
def get_blockers_lb(cls, lb_criteria, v:User, db, users:Any, limit):
if lb_criteria != UserBlock.target_id:
raise ValueError("This leaderboard function only supports UserBlock.target_id")
sq = db.query(lb_criteria, cls.count_and_label(lb_criteria)).group_by(lb_criteria).subquery()
leaderboard = db.query(User, sq.c.count).join(User, User.id == sq.c.target_id).order_by(sq.c.count.desc())
sq = db.query(lb_criteria, cls.count_and_label(lb_criteria), cls.rank_filtered_rank_label_by_desc(lb_criteria)).group_by(lb_criteria).subquery()
position = db.query(sq.c.rank, sq.c.count).join(User, User.id == sq.c.target_id).filter(sq.c.target_id == v.id).limit(1).one_or_none()
if not position: position = (leaderboard.count() + 1, 0)
leaderboard = leaderboard.limit(limit).all()
return (leaderboard, position[0], position[1])
def get_hat_lb(cls, lb_criteria, v:User, db, users:Any, limit):
leaderboard = db.query(User.id, func.count(lb_criteria)).join(lb_criteria).group_by(User).order_by(func.count(lb_criteria).desc())
sq = db.query(User.id, cls.count_and_label(lb_criteria), cls.rank_filtered_rank_label_by_desc(lb_criteria)).join(lb_criteria).group_by(User).subquery()
position = db.query(sq.c.rank, sq.c.count).filter(sq.c.id == v.id).limit(1).one_or_none()
if not position: position = (leaderboard.count() + 1, 0)
leaderboard = leaderboard.limit(limit).all()
return (leaderboard, position[0], position[1])

View File

@ -2,6 +2,7 @@ import qrcode
import io
import time
import math
from files.classes.leaderboard import Leaderboard
from files.classes.views import *
from files.classes.transactions import *
from files.helpers.alerts import *
@ -344,80 +345,28 @@ def transfer_bux(v, username):
@app.get("/leaderboard")
@auth_required
def leaderboard(v):
LEADERBOARD_LIMIT = 25
users = g.db.query(User)
def get_leaderboard(order_by, limit=25):
leaderboard = users.order_by(order_by.desc()).limit(limit).all()
position = None
if v not in leaderboard:
sq = g.db.query(User.id, func.rank().over(order_by=order_by.desc()).label("rank")).subquery()
position = g.db.query(sq.c.id, sq.c.rank).filter(sq.c.id == v.id).limit(1).one()[1]
return (leaderboard, position)
coins = get_leaderboard(User.coins)
subscribers = get_leaderboard(User.stored_subscriber_count)
posts = get_leaderboard(User.post_count)
comments = get_leaderboard(User.comment_count)
received_awards = get_leaderboard(User.received_award_count)
coins_spent = get_leaderboard(User.coins_spent)
truecoins = get_leaderboard(User.truecoins)
coins = Leaderboard("Coins", "coins", "coins", "Coins", None, Leaderboard.get_simple_lb, User.coins, v, v.coins, g.db, users, LEADERBOARD_LIMIT)
subscribers = Leaderboard("Followers", "followers", "followers", "Followers", None, Leaderboard.get_simple_lb, User.stored_subscriber_count, v, v.stored_subs, g.db, users, LEADERBOARD_LIMIT)
posts = Leaderboard("Posts", "post count", "posts", "Posts", None, Leaderboard.get_simple_lb, User.post_count, v, v.post_count, users, LEADERBOARD_LIMIT)
comments = Leaderboard("Comments", "comment count", "comments", "Comments", None, Leaderboard.get_simple_lb, User.post_count, v, v.post_count, users, LEADERBOARD_LIMIT)
received_awards = Leaderboard("Awards", "received awards", "awards", "Awards", None, Leaderboard.get_simple_lb, User.received_award_count, v, v.received_award_count, db, users, LEADERBOARD_LIMIT)
coins_spent = Leaderboard("Spent in shop", "coins spent in shop", "spent", "Coins", None, Leaderboard.get_simple_lb, User.coins_spent, v, v.coins_spent, db, users, LEADERBOARD_LIMIT)
truecoins = Leaderboard("Truescore", "truescore", "truescore", "Truescore", None, Leaderboard.get_simple_lb, User.truecoins, v, v.truecoins, db, users, LEADERBOARD_LIMIT)
def count_and_label(criteria):
return func.count(criteria).label("count")
def rank_filtered_rank_label_by_desc(criteria):
return func.rank().over(order_by=func.count(criteria).desc()).label("rank")
badges = Leaderboard("Badges", "badges", "badges", "Badges", None, Leaderboard.get_badge_marsey_lb, Badge.user_id, v, None, db, None, LEADERBOARD_LIMIT)
marseys = Leaderboard("Marseys", "Marseys made", "marseys", "Marseys", None, Leaderboard.get_badge_marsey_lb, Marsey.author_id, v, None, db, None, LEADERBOARD_LIMIT) if SITE_NAME == 'rDrama' else None
def get_leaderboard_2(lb_criteria, limit=25):
sq = g.db.query(lb_criteria, count_and_label(lb_criteria), rank_filtered_rank_label_by_desc(lb_criteria)).group_by(lb_criteria).subquery()
sq_criteria = None
if lb_criteria == Badge.user_id:
sq_criteria = User.id == sq.c.user_id
elif lb_criteria == Marsey.author_id:
sq_criteria = User.id == sq.c.author_id
else:
raise ValueError("This leaderboard function only supports Badge.user_id and Marsey.author_id")
leaderboard = g.db.query(User, sq.c.count).join(sq, sq_criteria).order_by(sq.c.count.desc())
position = g.db.query(User.id, sq.c.rank, sq.c.count).join(sq, sq_criteria).filter(User.id == v.id).one_or_none()
if position: position = (position[1], position[2])
else: position = (leaderboard.count() + 1, 0)
leaderboard = leaderboard.limit(limit).all()
return (leaderboard, position)
blocks = Leaderboard("Blocked", "most blocked", "blocked", "Blocked By", "blockers", Leaderboard.get_blockers_lb, UserBlock.target_id, v, None, db, None, LEADERBOARD_LIMIT)
badges = get_leaderboard_2(Badge.user_id)
marseys = get_leaderboard_2(Marsey.author_id) if SITE_NAME == 'rDrama' else (None, None)
owned_hats = Leaderboard("Owned hats", "owned hats", "owned-hats", "Owned Hats", None, Leaderboard.get_hat_lb, User.owned_hats, v, None, db, None, LEADERBOARD_LIMIT)
designed_hats = Leaderboard("Designed hats", "designed hats", "designed-hats", "Designed Hats", None, Leaderboard.get_hat_lb, User.designed_hats, v, None, db, None, LEADERBOARD_LIMIT)
def get_leaderboard_3(lb_criteria, limit=25):
if lb_criteria != UserBlock.target_id:
raise ValueError("This leaderboard function only supports UserBlock.target_id")
sq = g.db.query(lb_criteria, count_and_label(lb_criteria)).group_by(lb_criteria).subquery()
leaderboard = g.db.query(User, sq.c.count).join(User, User.id == sq.c.target_id).order_by(sq.c.count.desc())
sq = g.db.query(lb_criteria, count_and_label(lb_criteria), rank_filtered_rank_label_by_desc(lb_criteria)).group_by(lb_criteria).subquery()
position = g.db.query(sq.c.rank, sq.c.count).join(User, User.id == sq.c.target_id).filter(sq.c.target_id == v.id).limit(1).one_or_none()
if not position: position = (leaderboard.count() + 1, 0)
leaderboard = leaderboard.limit(limit).all()
return (leaderboard, position)
leaderboards = [coins, coins_spent, truecoins, subscribers, posts, comments, received_awards, badges, marseys, blocks, owned_hats, designed_hats]
blocks = get_leaderboard_3(UserBlock.target_id)
def get_leaderboard_4(lb_criteria, limit=25):
leaderboard = g.db.query(User.id, func.count(lb_criteria)).join(lb_criteria).group_by(User).order_by(func.count(lb_criteria).desc())
sq = g.db.query(User.id, count_and_label(lb_criteria), rank_filtered_rank_label_by_desc(lb_criteria)).join(lb_criteria).group_by(User).subquery()
position = g.db.query(sq.c.rank, sq.c.count).filter(sq.c.id == v.id).limit(1).one_or_none()
if not position: position = (leaderboard.count() + 1, 0)
leaderboard = leaderboard.limit(limit).all()
return (leaderboard, position)
owned_hats = get_leaderboard_4(User.owned_hats)
designed_hats = get_leaderboard_4(User.designed_hats)
return render_template("leaderboard.html", v=v, users1=coins[0], pos1=coins[1], users2=subscribers[0], pos2=subscribers[1],
users3=posts[0], pos3=posts[1], users4=comments[0], pos4=comments[1], users5=received_awards[0], pos5=received_awards[1],
users7=coins_spent[0], pos7=coins_spent[1], users10=truecoins[0], pos10=truecoins[1], users11=badges[0], pos11=badges[1],
users12=marseys[0], pos12=marseys[1], users16=blocks[0], pos16=blocks[1], users17=owned_hats[0], pos17=owned_hats[1],
users18=designed_hats[0], pos18=designed_hats[1])
return render_template("leaderboard.html", v=v, leaderboards=leaderboards)
@app.get("/<id>/css")
def get_css(id):

View File

@ -1,16 +1,11 @@
{% extends "settings2.html" %}
{% block pagetitle %}Leaderboard{% endblock %}
{% block content %}
{%- set LEADERBOARDS = [
('coins', 'Coins', True, True), ('spent', 'Spent in shop', True, True), ('truescore', 'Truescore', True, True), ('followers', 'Followers', True, True),
('posts', 'Posts', True, True), ('comments', 'Comments', True, True), ('awards', 'Awards', True, True), ('badges', 'Badges', True, True),
('marseys', 'Marseys', SITE_NAME == 'rDrama', True), ('blocked', 'Blocked', True, True), ('owned-hats', 'Owned hats', True, True), ('designed-hats', 'Designed hats', True, False)
] -%}
<pre class="d-none d-md-inline-block"></pre>
<div id="leaderboard-contents" style="text-align: center; margin-bottom: 1.5rem; font-size: 1.2rem;">
{% for lb in LEADERBOARDS %}
{% if lb[2] %}
<a href="#leaderboard-{{lb[0]}}">{{lb[1]}}</a>{% if lb[3] %} &bull;{% endif %}
{% for lb in leaderboards %}
{% if lb %}
<a href="#leaderboard-{{lb.html_id}}">{{lb.header_name}}</a>{% if not loop.last %} &bull;{% endif %}
{% endif %}
{% endfor %}
</div>
@ -28,70 +23,37 @@
</tr>
{% endmacro %}
{% macro leaderboard_table_header(column_name) %}
{% macro leaderboard_table(lb, position, id, header_name, v_value) %}
<h5 class="font-weight-bolder text-center pt-2 pb-3"><a id="leaderboard-{{id}}">Top {{lb.limit}} by {{table_header_name}}</a></h5>
<div class="overflow-x-auto">
{# TODO: check at some point if the nesting divs are intentional #}
<table class="table table-striped mb-5">
<thead class="bg-primary text-white">
<tr>
<th>#</th>
<th>Name</th>
<th>{{column_name}}</th>
<th>{{lb.table_column_name}}</th>
</tr>
</thead>
{% endmacro %}
{% macro leaderboard_table(lb, position, id, total_count, header_name, column_name, attr_name) %}
<h5 class="font-weight-bolder text-center pt-2 pb-3"><a id="leaderboard-{{id}}">Top {{total_count}} by {{header_name}}</a></h5>
<div class="overflow-x-auto">
{# TODO: check at some point if the nesting divs are intentional #}
<table class="table table-striped mb-5">
{{leaderboard_table_header(column_name)}}
<tbody>
{% for user in lb %}
{% for user in lb.all_users %}
{% if v.id == user.id %}
{% set style="class=\"self\"" %}
{% endif %}
{{format_user_in_table(user, style, loop.index, user[attr_name])}}
{{format_user_in_table(user, style, loop.index, lb.value_func(user))}}
{% endfor %}
{% if position %}
{{format_user_in_table(v, "style=\"border-top:2px solid var(--primary)\"", position, v[attr_name])}}
{{format_user_in_table(v, "style=\"border-top:2px solid var(--primary)\"", position, v_value)}}
{% endif %}
</tbody>
</table>
{% endmacro %}
{{leaderboard_table(users1, pos1, 'coins', 25, 'coins', 'Coins', 'coins')}}
{{leaderboard_table(users7, pos7, 'spent', 25, 'coins spent in shop', 'Coins', 'coins_spent')}}
{{leaderboard_table(users10, pos10, 'truescore', 25, 'truescore', 'Truescore', 'truecoins')}}
{{leaderboard_table(users2, pos2, 'followers', 25, 'followers', 'Followers', 'stored_subscriber_count')}}
{{leaderboard_table(users3, pos3, 'posts', 25, 'post count', 'Posts', 'post_count')}}
{{leaderboard_table(users4, pos4, 'comments', 25, 'comment count', 'Comments', 'comment_count')}}
{{leaderboard_table(users5, pos5, 'awards', 25, 'received awards', 'Awards', 'awards')}}
{% macro leaderboard_table_2(lb, position, id, total_count, header_name, column_name, user_relative_url) %}
<h5 class="font-weight-bolder text-center pt-2 pb-3"><a id="leaderboard-{{id}}">Top {{total_count}} by {{header_name}}</a></h5>
<div class="overflow-x-auto">
<table class="table table-striped mb-5">
{{leaderboard_table_header(column_name)}}
<tbody>
{% for user, num in lb %}
{% if v.id == user.id %}
{% set style="class=\"self\"" %}
{% endif %}
{{format_user_in_table(user, style, loop.index, num, user_relative_url)}}
{% endfor %}
{% if position and (position[0] > total_count or not position[1]) %}
{{format_user_in_table(v, "style=\"border-top:2px solid var(--primary)\"", position[0], position[1], user_relative_url)}}
{% endif %}
</tbody>
</table>
{% endmacro %}
{{leaderboard_table_2(users11, pos11, 'badges', 25, 'badges', 'Badges')}}
{% if users12 %}
{{leaderboard_table_2(users12, pos12, 'marseys', 25, 'Marseys made', 'Marseys')}}
{% endif %}
{{leaderboard_table_2(users16, pos16, 'blocked', 25, 'most blocked', 'Blocked By', 'blockers')}}
{{leaderboard_table_2(users17, pos17, 'owned-hats', 25, 'owned hats', 'Owned Hats')}}
{{leaderboard_table_2(users18, pos18, 'designed-hats', 25, 'designed hats', 'Designed Hats')}}
{% for lb in leaderboards %}
{% if lb %}
{{leaderboard_table(lb, lb.v_position, lb.html_id, lb.header_name, lb.v_value)}}
{% endif %}
{% endfor %}
<a id="leader--top-btn" href="#leaderboard-contents" role="button"
style="position: fixed; bottom: 5rem; right: 2rem; font-size: 3rem;">