From 33648a316c8577f5136e944f86a4a132b95f6a42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 19:24:17 +0000 Subject: [PATCH 1/4] Initial plan From 514b5511b88a383c7fcebb8e83f79e2e9ecc0baf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 19:32:05 +0000 Subject: [PATCH 2/4] Add enhanced scoring system with streak tracking and monthly leaderboards Co-authored-by: kushb007 <26575576+kushb007@users.noreply.github.com> --- bemo/models.py | 29 +++- bemo/routes.py | 66 +++++++- bemo/scoring.py | 281 ++++++++++++++++++++++++++++++++ bemo/templates/dashboard.html | 27 ++- bemo/templates/home.html | 28 +++- bemo/templates/layout.html | 5 + bemo/templates/leaderboard.html | 189 +++++++++++++++++++++ migrate_scoring_system.py | 133 +++++++++++++++ 8 files changed, 740 insertions(+), 18 deletions(-) create mode 100644 bemo/scoring.py create mode 100644 bemo/templates/leaderboard.html create mode 100644 migrate_scoring_system.py diff --git a/bemo/models.py b/bemo/models.py index d94b9c7e..08c94e73 100644 --- a/bemo/models.py +++ b/bemo/models.py @@ -3,7 +3,8 @@ solves = db.Table('solves', db.Column('user_id', db.Integer, db.ForeignKey('user.id'), primary_key=True), - db.Column('problem_id', db.Integer, db.ForeignKey('problem.id'), primary_key=True) + db.Column('problem_id', db.Integer, db.ForeignKey('problem.id'), primary_key=True), + db.Column('solved_at', db.DateTime, nullable=False, default=datetime.utcnow) ) class User(db.Model): @@ -21,6 +22,13 @@ class User(db.Model): stripe_account_id = db.Column(db.String(120), nullable=True) first_solves = db.Column(db.Integer, nullable=False, default=0) last_milestone_paid = db.Column(db.Integer, nullable=False, default=0) + # Streak tracking + current_streak = db.Column(db.Integer, nullable=False, default=0) + longest_streak = db.Column(db.Integer, nullable=False, default=0) + last_solve_date = db.Column(db.Date, nullable=True) + # Monthly leaderboard tracking + monthly_score = db.Column(db.Integer, nullable=False, default=0) + last_monthly_reset = db.Column(db.Date, nullable=True) solved_problems = db.relationship('Problem', secondary=solves, lazy='subquery', backref=db.backref('solvers', lazy=True)) @@ -51,7 +59,24 @@ class Submission(db.Model): #json formatted tokens tokens = db.Column(db.Text, nullable=False, default='[]') status = db.Column(db.Text, nullable=False, default='[]') + submitted_at = db.Column(db.DateTime, nullable=False, default=datetime.now(timezone.utc)) def __repr__(self): - return f"User('{self.status}','{self.id}')" \ No newline at end of file + return f"User('{self.status}','{self.id}')" + +class MonthlyLeaderboard(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey(User.id), nullable=False) + year = db.Column(db.Integer, nullable=False) + month = db.Column(db.Integer, nullable=False) + rank = db.Column(db.Integer, nullable=False) + score = db.Column(db.Integer, nullable=False) + reward_paid = db.Column(db.Boolean, nullable=False, default=False) + created_at = db.Column(db.DateTime, nullable=False, default=datetime.now(timezone.utc)) + + # Composite unique constraint for user, year, month + __table_args__ = (db.UniqueConstraint('user_id', 'year', 'month', name='_user_month_uc'),) + + def __repr__(self): + return f"MonthlyLeaderboard(user_id={self.user_id}, rank={self.rank}, {self.year}-{self.month})" \ No newline at end of file diff --git a/bemo/routes.py b/bemo/routes.py index 5409bd37..e6518a0a 100644 --- a/bemo/routes.py +++ b/bemo/routes.py @@ -11,7 +11,15 @@ from urllib.parse import quote_plus, urlencode from bemo import app, db, session, oauth, cache from bemo.forms import Confirm, Picture, Code, PayoutSettings -from bemo.models import User, Problem, Submission +from bemo.models import User, Problem, Submission, MonthlyLeaderboard +from bemo.scoring import ( + update_user_streak, + calculate_problem_score, + update_monthly_score, + get_current_monthly_leaderboard, + get_historical_monthly_leaderboard, + archive_monthly_leaderboard +) import random import http.client import base64 @@ -101,10 +109,11 @@ def home(): print(user) page = request.args.get('page', 1, type=int) problems = Problem.query.order_by(Problem.date_posted.desc()).paginate(page=page, per_page=5) - topsolvers = User.query.order_by(User.score).limit(5).all() - topcontributors = User.query.order_by(User.contribution).limit(5).all() + topsolvers = User.query.order_by(User.score.desc()).limit(5).all() + topcontributors = User.query.order_by(User.contribution.desc()).limit(5).all() + monthly_leaders = get_current_monthly_leaderboard(limit=5) randommessage = random.choice(["Welcome to Bemo!","Solve problems and earn points!","Join the community!","Compete with others!","Improve your coding skills!","Get started now!"]) - return render_template('home.html', problems=problems, user=user, page=page, solvs=topsolvers,conts=topcontributors, randommessage=randommessage) + return render_template('home.html', problems=problems, user=user, page=page, solvs=topsolvers,conts=topcontributors, monthly_leaders=monthly_leaders, randommessage=randommessage) #list out problems in table format @app.route("/problems") @@ -115,8 +124,8 @@ def problems(): user = User.query.filter_by(id=session['id']).first() page = request.args.get('page', 1, type=int) problems = Problem.query.order_by(Problem.date_posted.desc()).paginate(page=page, per_page=5) - topcontributors = User.query.order_by(User.contribution).limit(5).all() - topsolvers = User.query.order_by(User.score).limit(5).all() + topcontributors = User.query.order_by(User.contribution.desc()).limit(5).all() + topsolvers = User.query.order_by(User.score.desc()).limit(5).all() return render_template('problems.html', problems=problems, user=user, page=page, conts=topcontributors, solvs=topsolvers) @@ -201,6 +210,27 @@ def dashboard(): solved_str = json.dumps(solved_titles) return render_template('dashboard.html', user=user,solved=solved_str) +@app.route('/leaderboard') +def leaderboard(): + user = None + if 'id' in session: + user = User.query.filter_by(id=session['id']).first() + + # Get all-time leaderboard + alltime_leaders = User.query.order_by(User.score.desc()).limit(20).all() + + # Get monthly leaderboard + monthly_leaders = get_current_monthly_leaderboard(limit=20) + + # Get streak leaders + streak_leaders = User.query.order_by(User.longest_streak.desc()).limit(20).all() + + return render_template('leaderboard.html', + user=user, + alltime_leaders=alltime_leaders, + monthly_leaders=monthly_leaders, + streak_leaders=streak_leaders) + @app.route('/payment') @requires_auth def payment(): @@ -506,17 +536,37 @@ def show_sub(sub_id): user = User.query.filter_by(id=session['id']).first() if sub.correct == sub.cases and user.id==sub.user_id: if problem not in user.solved_problems: + # Record solve with timestamp user.solved_problems.append(problem) problem.solved += 1 - if problem.solved == 1: + # Determine if this is the first solve globally + is_first_solve = (problem.solved == 1) + + # Calculate base problem score + problem_score = calculate_problem_score(problem, is_first_solve) + + # Update streak and get streak bonus + streak_bonus = update_user_streak(user) + + # Total points for this solve + total_points = problem_score + streak_bonus + + # Update overall score + user.score += total_points + + # Update monthly score + update_monthly_score(user, total_points) + + # Track first solves (for existing milestone rewards) + if is_first_solve: user.first_solves += 1 db.session.commit() check_milestones_and_pay(user) else: db.session.commit() - print("Accepted") + print(f"Accepted: +{problem_score} problem points, +{streak_bonus} streak bonus = {total_points} total points") return render_template('submission.html',submission=sub,problem=problem,user=user,msg1=cases_string,msg2=sub.status) diff --git a/bemo/scoring.py b/bemo/scoring.py new file mode 100644 index 00000000..bc96ea24 --- /dev/null +++ b/bemo/scoring.py @@ -0,0 +1,281 @@ +""" +Scoring system utilities for the Bemo competitive programming platform. + +This module handles: +- Streak tracking and rewards +- Monthly leaderboard management +- Score calculations +""" + +from datetime import date, datetime, timezone, timedelta +from bemo import db +from bemo.models import User, MonthlyLeaderboard, Problem +import calendar + +# Scoring constants +STREAK_BONUS_BASE = 10 # Base points per day of streak +STREAK_BONUS_MULTIPLIER = 1.5 # Multiplier for longer streaks (every 7 days) +PROBLEM_BASE_SCORE = 100 # Base score for solving a problem +MONTHLY_TOP_3_REWARDS = { + 1: 5000, # $50.00 for 1st place + 2: 2500, # $25.00 for 2nd place + 3: 1000 # $10.00 for 3rd place +} + + +def update_user_streak(user, solve_date=None): + """ + Update user's streak when they solve a problem. + + Args: + user: User object + solve_date: Date of solve (defaults to today) + + Returns: + int: Streak bonus points awarded + """ + if solve_date is None: + solve_date = date.today() + + # Convert datetime to date if needed + if isinstance(solve_date, datetime): + solve_date = solve_date.date() + + if user.last_solve_date is None: + # First solve ever + user.current_streak = 1 + user.longest_streak = 1 + user.last_solve_date = solve_date + return 0 # No bonus for first solve + + # Convert last_solve_date to date if it's datetime + last_date = user.last_solve_date + if isinstance(last_date, datetime): + last_date = last_date.date() + + days_diff = (solve_date - last_date).days + + if days_diff == 0: + # Same day, no streak update needed + return 0 + elif days_diff == 1: + # Consecutive day - increment streak + user.current_streak += 1 + user.longest_streak = max(user.longest_streak, user.current_streak) + user.last_solve_date = solve_date + elif days_diff > 1: + # Streak broken - reset to 1 + user.current_streak = 1 + user.last_solve_date = solve_date + return 0 # No bonus when streak breaks + else: + # Solve date is before last solve (shouldn't happen normally) + return 0 + + # Calculate streak bonus + streak_bonus = calculate_streak_bonus(user.current_streak) + return streak_bonus + + +def calculate_streak_bonus(streak_days): + """ + Calculate bonus points for maintaining a streak. + + Formula: Base points + (weeks completed * multiplier * base) + """ + if streak_days < 2: + return 0 + + weeks_completed = streak_days // 7 + base_bonus = STREAK_BONUS_BASE * streak_days + week_multiplier = weeks_completed * STREAK_BONUS_MULTIPLIER + + total_bonus = int(base_bonus + (base_bonus * week_multiplier)) + return total_bonus + + +def calculate_problem_score(problem, is_first_solve=False): + """ + Calculate score for solving a problem. + + Args: + problem: Problem object + is_first_solve: Boolean indicating if this is the first solve globally + + Returns: + int: Points awarded for solving this problem + """ + base_score = PROBLEM_BASE_SCORE + + # Factor in problem rating/difficulty + if problem.rating > 0: + difficulty_multiplier = problem.rating / 1000.0 + base_score = int(base_score * difficulty_multiplier) + + # Bonus for being first to solve + if is_first_solve: + base_score = int(base_score * 2) # Double points for first solve + + return base_score + + +def update_monthly_score(user, points): + """ + Update user's monthly score and check for month reset. + + Args: + user: User object + points: Points to add to monthly score + """ + current_date = date.today() + + # Check if we need to reset monthly score + if user.last_monthly_reset is None: + user.last_monthly_reset = current_date + user.monthly_score = 0 + else: + last_reset = user.last_monthly_reset + if isinstance(last_reset, datetime): + last_reset = last_reset.date() + + # Reset if we're in a new month + if current_date.month != last_reset.month or current_date.year != last_reset.year: + # Archive the previous month's ranking before reset + archive_monthly_leaderboard(last_reset.year, last_reset.month) + user.monthly_score = 0 + user.last_monthly_reset = current_date + + user.monthly_score += points + + +def archive_monthly_leaderboard(year, month): + """ + Archive the monthly leaderboard and determine top 3 winners. + + Args: + year: Year to archive + month: Month to archive + """ + # Get top users by monthly_score + top_users = User.query.order_by(User.monthly_score.desc()).limit(10).all() + + if not top_users: + return + + # Create or update leaderboard entries for top 3 + for rank, user in enumerate(top_users[:3], start=1): + if user.monthly_score > 0: # Only archive if they have points + # Check if entry already exists + existing = MonthlyLeaderboard.query.filter_by( + user_id=user.id, + year=year, + month=month + ).first() + + if existing: + existing.rank = rank + existing.score = user.monthly_score + else: + entry = MonthlyLeaderboard( + user_id=user.id, + year=year, + month=month, + rank=rank, + score=user.monthly_score, + reward_paid=False + ) + db.session.add(entry) + + try: + db.session.commit() + except Exception as e: + print(f"Error archiving monthly leaderboard: {e}") + db.session.rollback() + + +def check_and_pay_monthly_rewards(): + """ + Check for unpaid monthly rewards and process payments. + This should be run periodically (e.g., at the start of each month). + """ + import stripe + from flask import current_app + + # Get unpaid rewards + unpaid_entries = MonthlyLeaderboard.query.filter_by(reward_paid=False).all() + + for entry in unpaid_entries: + user = User.query.get(entry.user_id) + if not user or not user.stripe_account_id: + continue + + reward_amount = MONTHLY_TOP_3_REWARDS.get(entry.rank) + if not reward_amount: + continue + + try: + # Create a transfer to the connected account + transfer = stripe.Transfer.create( + amount=reward_amount, + currency="usd", + destination=user.stripe_account_id, + description=f"Rank #{entry.rank} reward for {calendar.month_name[entry.month]} {entry.year}" + ) + + entry.reward_paid = True + db.session.commit() + print(f"Paid ${reward_amount/100:.2f} to {user.username} for rank {entry.rank} ({entry.year}-{entry.month:02d})") + except Exception as e: + print(f"Failed to pay monthly reward to {user.username}: {e}") + db.session.rollback() + + +def get_current_monthly_leaderboard(limit=10): + """ + Get the current month's leaderboard. + + Args: + limit: Number of top users to return + + Returns: + list: List of (rank, user, score) tuples + """ + top_users = User.query.order_by(User.monthly_score.desc()).limit(limit).all() + return [(rank, user, user.monthly_score) for rank, user in enumerate(top_users, start=1)] + + +def get_historical_monthly_leaderboard(year, month, limit=10): + """ + Get a historical monthly leaderboard. + + Args: + year: Year to retrieve + month: Month to retrieve + limit: Number of entries to return + + Returns: + list: List of MonthlyLeaderboard entries + """ + return MonthlyLeaderboard.query.filter_by( + year=year, + month=month + ).order_by(MonthlyLeaderboard.rank.asc()).limit(limit).all() + + +def get_user_monthly_history(user_id, limit=12): + """ + Get a user's monthly leaderboard history. + + Args: + user_id: User ID + limit: Number of months to return + + Returns: + list: List of MonthlyLeaderboard entries + """ + return MonthlyLeaderboard.query.filter_by( + user_id=user_id + ).order_by( + MonthlyLeaderboard.year.desc(), + MonthlyLeaderboard.month.desc() + ).limit(limit).all() diff --git a/bemo/templates/dashboard.html b/bemo/templates/dashboard.html index 2aaf6318..596f39d6 100644 --- a/bemo/templates/dashboard.html +++ b/bemo/templates/dashboard.html @@ -11,7 +11,7 @@