Repository: CTFd/CTFd

Issue:
Bell Curve Graph / Normal Distribution
There's some idea in statistics about bell curves and a normal distribution. 

We should take this idea and apply it to CTF scores. For a given CTF we should take the total score, break this up into brackets and then see how many people are in each score bracket. Then overlay this onto a graph of the normal distribution. 

This would be extremely valuable in allowing organizers to understand how hard the CTF was. 


Discussion:

Resolving patch:
diff --git a/CTFd/admin/statistics.py b/CTFd/admin/statistics.py
--- a/CTFd/admin/statistics.py
+++ b/CTFd/admin/statistics.py
@@ -31,6 +31,13 @@ def statistics():
 
     challenge_count = Challenges.query.count()
 
+    total_points = (
+        Challenges.query.with_entities(db.func.sum(Challenges.value).label("sum"))
+        .filter_by(state="visible")
+        .first()
+        .sum
+    ) or 0
+
     ip_count = Tracking.query.with_entities(Tracking.ip).distinct().count()
 
     solves_sub = (
@@ -73,6 +80,7 @@ def statistics():
         wrong_count=wrong_count,
         solve_count=solve_count,
         challenge_count=challenge_count,
+        total_points=total_points,
         solve_data=solve_data,
         most_solved=most_solved,
         least_solved=least_solved,
diff --git a/CTFd/api/v1/statistics/__init__.py b/CTFd/api/v1/statistics/__init__.py
--- a/CTFd/api/v1/statistics/__init__.py
+++ b/CTFd/api/v1/statistics/__init__.py
@@ -8,3 +8,4 @@
 from CTFd.api.v1.statistics import submissions  # noqa: F401
 from CTFd.api.v1.statistics import teams  # noqa: F401
 from CTFd.api.v1.statistics import users  # noqa: F401
+from CTFd.api.v1.statistics import scores  # noqa: F401
diff --git a/CTFd/api/v1/statistics/scores.py b/CTFd/api/v1/statistics/scores.py
new file mode 100644
--- /dev/null
+++ b/CTFd/api/v1/statistics/scores.py
@@ -0,0 +1,43 @@
+from collections import defaultdict
+
+from flask_restx import Resource
+
+from CTFd.api.v1.statistics import statistics_namespace
+from CTFd.models import db, Challenges
+from CTFd.utils.decorators import admins_only
+from CTFd.utils.scores import get_standings
+
+
+@statistics_namespace.route("/scores/distribution")
+class ScoresDistribution(Resource):
+    @admins_only
+    def get(self):
+        challenge_count = Challenges.query.count() or 1
+        total_points = (
+            Challenges.query.with_entities(db.func.sum(Challenges.value).label("sum"))
+            .filter_by(state="visible")
+            .first()
+            .sum
+        ) or 0
+        # Convert Decimal() to int in some database backends for Python 2
+        total_points = int(total_points)
+
+        # Divide score by challenges to get brackets with explicit floor division
+        bracket_size = total_points // challenge_count
+
+        # Get standings
+        standings = get_standings(admin=True)
+
+        # Iterate over standings and increment the count for each bracket for each standing within that bracket
+        bottom, top = 0, bracket_size
+        count = 1
+        brackets = defaultdict(lambda: 0)
+        for t in reversed(standings):
+            if ((t.score >= bottom) and (t.score <= top)) or t.score <= 0:
+                brackets[top] += 1
+            else:
+                count += 1
+                bottom, top = (bracket_size, (bracket_size * count))
+                brackets[top] += 1
+
+        return {"success": True, "data": {"brackets": brackets}}


Test patch:
diff --git a/tests/api/v1/statistics/__init__.py b/tests/api/v1/statistics/__init__.py
new file mode 100644
diff --git a/tests/api/v1/statistics/test_scores.py b/tests/api/v1/statistics/test_scores.py
new file mode 100644
--- /dev/null
+++ b/tests/api/v1/statistics/test_scores.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+from CTFd.models import Users
+from tests.helpers import (
+    create_ctfd,
+    destroy_ctfd,
+    login_as_user,
+    register_user,
+    simulate_user_activity,
+)
+
+
+def test_api_statistics_score_distribution():
+    app = create_ctfd()
+    with app.app_context():
+        # Handle zero data case
+        client = login_as_user(app, name="admin", password="password")
+        r = client.get("/api/v1/statistics/scores/distribution")
+        resp = r.get_json()
+        assert resp["data"]["brackets"] == {}
+
+        # Add user data
+        register_user(app)
+        user = Users.query.filter_by(email="user@ctfd.io").first()
+        simulate_user_activity(app.db, user=user)
+
+        # Test again
+        r = client.get("/api/v1/statistics/scores/distribution")
+        resp = r.get_json()
+        assert resp["data"]["brackets"]
+    destroy_ctfd(app)
diff --git a/tests/api/v1/user/test_admin_access.py b/tests/api/v1/user/test_admin_access.py
--- a/tests/api/v1/user/test_admin_access.py
+++ b/tests/api/v1/user/test_admin_access.py
@@ -15,6 +15,7 @@ def test_api_hint_404():
         "/api/v1/statistics/users/{}",
         "/api/v1/configs",
         "/api/v1/statistics/challenges/solves/percentages",
+        "/api/v1/statistics/scores/distribution",
         "/api/v1/tags/{}",
         "/api/v1/pages",
         "/api/v1/files/{}",