Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamic Course Configurable EC calculation #584

Merged
merged 10 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 146 additions & 10 deletions dojo_plugin/api/v1/discord.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import hmac
import datetime
import json

from flask import request
from flask_restx import Namespace, Resource
from sqlalchemy import and_
from CTFd.cache import cache
from CTFd.models import db
from CTFd.utils.decorators import authed_only
from CTFd.utils.user import get_current_user

from ...config import DISCORD_CLIENT_SECRET
from ...models import DiscordUsers
from ...utils.discord import get_discord_member
from ...models import DiscordUsers, DiscordUserActivity
from ...utils.discord import get_discord_member, get_discord_member_by_discord_id
from ...utils.dojo import get_current_dojo_challenge


discord_namespace = Namespace("discord", description="Endpoint to manage discord")

def auth_check(authorization):
if not authorization or not authorization.startswith("Bearer "):
return {"success": False, "error": "Unauthorized"}, 401

token = authorization.split(" ")[1]
if not hmac.compare_digest(token, DISCORD_CLIENT_SECRET):
return {"success": False, "error": "Unauthorized"}, 401

return None, None

@discord_namespace.route("")
class Discord(Resource):
Expand All @@ -31,12 +42,9 @@ def delete(self):
class DiscordActivity(Resource):
def get(self, discord_id):
authorization = request.headers.get("Authorization")
if not authorization or not authorization.startswith("Bearer "):
return {"success": False, "error": "Unauthorized"}, 401

token = authorization.split(" ")[1]
if not hmac.compare_digest(token, DISCORD_CLIENT_SECRET):
return {"success": False, "error": "Unauthorized"}, 401
res, code = auth_check(authorization)
if res:
return res, code

discord_user = DiscordUsers.query.filter_by(discord_id=discord_id).first()
if not discord_user:
Expand All @@ -56,5 +64,133 @@ def get(self, discord_id):
"reference_id": dojo_challenge.reference_id,
}
}

return {"success": True, "activity": activity}


def get_user_activity_prop(discord_id, activity, start=None, end=None):
user = DiscordUsers.query.filter_by(discord_id=discord_id).first()

if activity == "thanks":
count = user.thanks_count(start, end) if user else 0
elif activity == "memes":
count = user.meme_count(start, end) if user else 0

return {"success": True, activity: count}

def get_user_activity(discord_id, activity, request):
authorization = request.headers.get("Authorization")
res, code = auth_check(authorization)
if res:
return res, code

start_stamp = request.args.get("start")
end_stamp = request.args.get("end")
start = None
end = None

if start_stamp:
try:
start = datetime.fromisoformat(start_stamp)
except:
return {"success": False, "error": "invalid start format"}, 400
if end_stamp:
try:
end = datetime.fromisoformat(start_stamp)
except:
return {"success": False, "error": "invalid end format"}, 400

user = DiscordUsers.query.filter_by(discord_id=discord_id).first()

return get_user_activity_prop(discord_id, activity, start, end)

def post_user_activity(discord_id, activity, request):
authorization = request.headers.get("Authorization")
res, code = auth_check(authorization)
if res:
return res, code

data = request.get_json()

expected_vals = ['source_user_id',
'guild_id',
'channel_id',
'message_id',
'message_timestamp',
]

for ev in expected_vals:
if ev not in data:
return {"success": False, "error": f"Invalid JSON data - {ev} not found!"}, 400

kwargs = {
'user_id' : discord_id,
'source_user_id': data.get("source_user_id", ""),
'guild_id': data.get("guild_id"),
'channel_id': data.get("channel_id"),
'message_id': data.get("message_id"),
'timestamp': data.get("timestamp"),
'message_timestamp': datetime.datetime.fromisoformat(data.get("message_timestamp")),
'type': activity
}
entry = DiscordUserActivity(**kwargs)
db.session.add(entry)
db.session.commit()

return get_user_activity_prop(discord_id, activity), 200

@discord_namespace.route("/memes/user/<discord_id>", methods=["GET", "POST"])
class DiscordMemes(Resource):
def get(self, discord_id):
return get_user_activity(discord_id, "memes", request)

def post(self, discord_id):
return post_user_activity(discord_id, "memes", request)

@discord_namespace.route("/thanks/user/<discord_id>", methods=["GET", "POST"])
class DiscordThanks(Resource):
def get(self, discord_id):
return get_user_activity(discord_id, "thanks", request)

def post(self, discord_id):
return post_user_activity(discord_id, "thanks", request)


@discord_namespace.route("/thanks/leaderboard", methods=["GET"])
class GetDiscordLeaderBoard(Resource):
def get(self):
start_stamp = request.args.get("start")

def year_stamp():
year = datetime.datetime.now().year
return datetime.datetime(year, 1, 1)

try:
if start_stamp is None:
start = year_stamp()
else:
start = datetime.datetime.fromisoformat(start_stamp)
except:
return {"success": False, "error": "invalid start format"}, 400

thanks_scores = DiscordUserActivity.query.with_entities(DiscordUserActivity.user_id, db.func.count(db.func.distinct(DiscordUserActivity.message_id))
).filter(and_(DiscordUserActivity.message_timestamp >= start),
DiscordUserActivity.type == "thanks"
).group_by(DiscordUserActivity.user_id
).order_by(db.func.count(DiscordUserActivity.user_id).desc())[:100]

def get_name(discord_id):
try:
response = get_discord_member_by_discord_id(discord_id)
if not response:
return "Unknown"
except:
return "Unknown"

return response['user']['global_name'] if response else "Unknown"

results = [[get_name(res[0]), res[1]] for res in thanks_scores]

results = [mem for mem in results if mem[0] != 'Unknown'][:20]


return {"success": True, "leaderboard": json.dumps(results)}, 200
55 changes: 54 additions & 1 deletion dojo_plugin/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@ def awards(self):
def completed(self, user):
return self.solves(user=user, ignore_visibility=True, ignore_admins=False).count() == len(self.challenges)

def start_date(self):
module_starts = [m.visibility.start for m in self._modules if m.visibility and m.visibility.start]
return min(module_starts) if module_starts else datetime.datetime(2024, 8, 22, 0)
robwaz marked this conversation as resolved.
Show resolved Hide resolved

def is_admin(self, user=None):
if user is None:
user = get_current_user()
Expand Down Expand Up @@ -748,15 +752,64 @@ class SSHKeys(db.Model):
__repr__ = columns_repr(["user", "value"])


class DiscordUserActivity(db.Model):
__tablename__ = "discord_user_activity"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.BigInteger)
source_user_id = db.Column(db.BigInteger)
timestamp = db.Column(db.DateTime, default=datetime.datetime.utcnow)
type = db.Column(db.String(80), index=True)
guild_id = db.Column(db.BigInteger)
channel_id = db.Column(db.BigInteger)
message_id = db.Column(db.BigInteger)
message_timestamp = db.Column(db.DateTime, default=datetime.datetime.utcnow)

class DiscordUsers(db.Model):
__tablename__ = "discord_users"
user_id = db.Column(
db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
discord_id = db.Column(db.Text, unique=True)
discord_id = db.Column(db.Integer, unique=True)

user = db.relationship("Users")

def thanks_count(self, start=None, end=None):
count = DiscordUserActivity.query.filter(and_(DiscordUserActivity.user_id == self.discord_id),
DiscordUserActivity.message_timestamp >= start if start else True,
DiscordUserActivity.message_timestamp <= end if end else True,
DiscordUserActivity.type == "thanks"
).with_entities(db.func.distinct(DiscordUserActivity.message_id)).count()
return count

def meme_count(self, start=None, end=None, weekly=True):
if not weekly:
return DiscordUserActivity.query.filter(and_(DiscordUserActivity.user_id == self.discord_id),
DiscordUserActivity.message_timestamp >= start if start else True,
DiscordUserActivity.message_timestamp <= end if end else True,
DiscordUserActivity.type == "memes"
).with_entities(db.func.distinct(DiscordUserActivity.message_id)).count()

meme_weeks = self.meme_dates(start, end)
return len(meme_weeks)

def meme_dates(self, start=None, end=None):
memes = DiscordUserActivity.query.filter(and_(DiscordUserActivity.user_id == self.discord_id),
DiscordUserActivity.timestamp >= start if start else True,
DiscordUserActivity.timestamp <= end if end else True,
DiscordUserActivity.type == "memes"
).order_by(DiscordUserActivity.timestamp).all()

start = memes[0].timestamp if not start else start
end = memes[-1].timestamp if not end else end

def valid_week(week, memes):
return bool([m for m in memes if m.timestamp >= week[0] and m.timestamp <= week[1]])

week_count = (end - start) // datetime.timedelta(days=7)
class_weeks = [(start + datetime.timedelta(days=7 * i), start + datetime.timedelta(days= 7 * (i + 1))) for i in range(week_count)]

return [w for w in class_weeks if valid_week(w, memes)]

__repr__ = columns_repr(["user", "discord_id"])


Expand Down
82 changes: 80 additions & 2 deletions dojo_plugin/pages/course.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import collections
import datetime
import math
import re

from flask import Blueprint, Response, render_template, request, abort, stream_with_context
Expand All @@ -9,7 +10,7 @@
from CTFd.utils.user import get_current_user, is_admin
from CTFd.utils.decorators import authed_only, admins_only, ratelimit

from ..models import DiscordUsers, DojoChallenges, DojoUsers, DojoStudents, DojoModules, DojoStudents
from ..models import DiscordUsers, DojoChallenges, DojoUsers, DojoStudents, DojoModules, DojoStudents, DiscordUserActivity
from ..utils import is_dojo_admin
from ..utils.dojo import dojo_route
from ..utils.discord import add_role, get_discord_member
Expand All @@ -24,6 +25,17 @@ def get_letter_grade(dojo, grade):
return letter_grade
return "?"

def clamp_ec(limit):
global_limit = [limit]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I confused? Why is this a list?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is some insane python thing with the decorator instance holding the limiting value across functions. If it is not a list the value isn't captured in the decorator.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how scoping works inside python, what you're looking for is the nonlocal keyword.

I believe this is what you are trying to express:

def clamp_ec(limit):
    def decorator(func):
        def wrapper(*args, **kwargs):
            nonlocal limit
            result = func(*args, **kwargs)
            clamped_result = min(result, limit)
            limit -= clamped_result
            return clamped_result
        return wrapper
    return decorator

def decorator(func):
def wrapper(*args, **kwargs):
limit = global_limit[0]
result = func(*args, **kwargs)
clamped_result = min(result, limit)
global_limit[0] -= clamped_result
return clamped_result
return wrapper
return decorator

def assessment_name(dojo, assessment):
module_names = {module.id: module.name for module in dojo.modules}
Expand Down Expand Up @@ -109,9 +121,58 @@ def query(module_id):

module_solves = {}


def get_meme_progress(dojo, user_id):
discord_user = DiscordUsers.query.where(DiscordUsers.user_id == user_id).first()
if not discord_user:
return ""
course_start = dojo.start_date()
meme_weeks = discord_user.meme_dates(start=dojo.start_date(), end=dojo.start_date() + datetime.timedelta(weeks=21))
week_ranges = ' '.join([f"{w[0].month}/{w[0].day}-{w[1].month}/{w[1].day}" for w in meme_weeks])
return week_ranges

def get_thanks_progress(dojo, user_id):
discord_user = DiscordUsers.query.where(DiscordUsers.user_id == user_id).first()
if not discord_user:
return 0
thanks_count = discord_user.thanks_count(start=dojo.start_date())

return thanks_count

def result(user_id):
assessment_grades = []

ec_limit = dojo.course.get("ec_limit") or 1.00
robwaz marked this conversation as resolved.
Show resolved Hide resolved
ec_clamp = clamp_ec(ec_limit)

@ec_clamp
def get_thanks_credit(dojo, user_id, method, max_credit):
discord_user = DiscordUsers.query.where(DiscordUsers.user_id == user_id).first()
if not discord_user:
return 0
thanks_count = discord_user.thanks_count(start=dojo.start_date())

if method == 'log50':
return max_credit * math.log(thanks_count, 50) if thanks_count else 0
elif method == '1337log2':
return min(1.337 ** math.log(thanks_count,2) / 100, max_credit) if thanks_count else 0
return 0

@ec_clamp
def get_meme_credit(dojo, user_id, max_credit, meme_value=0.005):
discord_user = DiscordUsers.query.where(DiscordUsers.user_id == user_id).first()
if not discord_user:
return 0
course_start = dojo.start_date()
meme_count = discord_user.meme_count(start=course_start, end=course_start + datetime.timedelta(weeks=16))

return min(meme_count * meme_value, max_credit)

@ec_clamp
def clamp_extra():
return (assessment.get("credit") or {}).get(str(user_id), 0.0)


for assessment in assessments:
type = assessment.get("type")

Expand Down Expand Up @@ -204,13 +265,30 @@ def result(user_id):
assessment_grades.append(dict(
name=assessment_name(dojo, assessment),
progress=(assessment.get("progress") or {}).get(str(user_id), ""),
credit=(assessment.get("credit") or {}).get(str(user_id), 0.0),
credit=clamp_extra(user_id)
))
if type == "helpfulness":
method = assessment.get("method")
max_credit = assessment.get("max_credit") or 1.00
assessment_grades.append(dict(
name=assessment.get("name") or "Discord Helpfulness",
progress=get_thanks_progress(dojo, user_id),
credit=get_thanks_credit(dojo, user_id, method, max_credit)
))
if type == "memes":
max_credit = assessment.get("max_credit") or 1.00
credit = get_meme_credit(dojo, user_id, max_credit)
assessment_grades.append(dict(
name=assessment.get("name") or "Discord Memes",
progress=get_meme_progress(dojo, user_id),
credit=credit
))

overall_grade = (
sum(grade["credit"] * grade["weight"] for grade in assessment_grades if "weight" in grade) /
sum(grade["weight"] for grade in assessment_grades if "weight" in grade)
) if assessment_grades else 1.0

extra_credit = (
sum(grade["credit"] for grade in assessment_grades if "weight" not in grade)
)
Expand Down
Loading
Loading