diff --git a/CTFd/admin/statistics.py b/CTFd/admin/statistics.py index 6c54a21..ebbc2f7 100644 --- 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 index aff8781..d7ac50c 100644 --- a/CTFd/api/v1/statistics/__init__.py +++ b/CTFd/api/v1/statistics/__init__.py @@ -8,3 +8,4 @@ from CTFd.api.v1.statistics import challenges # noqa: F401 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 index 0000000..f02b1a3 --- /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}} diff --git a/CTFd/themes/admin/assets/js/pages/statistics.js b/CTFd/themes/admin/assets/js/pages/statistics.js index 2e51bed..e6f1faa 100644 --- a/CTFd/themes/admin/assets/js/pages/statistics.js +++ b/CTFd/themes/admin/assets/js/pages/statistics.js @@ -177,6 +177,59 @@ const graph_configs = { annotations ]; } + }, + + "#score-distribution-graph": { + layout: annotations => ({ + title: "Score Distribution", + xaxis: { + title: "Score Bracket", + showticklabels: true, + type: "category" + }, + yaxis: { + title: "Number of {0}".format( + CTFd.config.userMode.charAt(0).toUpperCase() + + CTFd.config.userMode.slice(1) + ) + }, + annotations: annotations + }), + data: () => + CTFd.fetch("/api/v1/statistics/scores/distribution").then(function( + response + ) { + return response.json(); + }), + fn: () => + "CTFd_score_distribution_" + new Date().toISOString().slice(0, 19), + format: response => { + const data = response.data.brackets; + const keys = []; + const brackets = []; + const sizes = []; + + for (let key in data) { + keys.push(parseInt(key)); + } + keys.sort((a, b) => a - b); + + let start = "<0"; + keys.map(key => { + brackets.push("{0} - {1}".format(start, key)); + sizes.push(data[key]); + start = key; + }); + + return [ + { + type: "bar", + x: brackets, + y: sizes, + orientation: "v" + } + ]; + } } }; diff --git a/CTFd/themes/admin/static/js/pages/statistics.dev.js b/CTFd/themes/admin/static/js/pages/statistics.dev.js index 6a7cbc2..aa3730d 100644 --- a/CTFd/themes/admin/static/js/pages/statistics.dev.js +++ b/CTFd/themes/admin/static/js/pages/statistics.dev.js @@ -162,7 +162,7 @@ /***/ (function(module, exports, __webpack_require__) { ; -eval("\n\n__webpack_require__(/*! ./main */ \"./CTFd/themes/admin/assets/js/pages/main.js\");\n\n__webpack_require__(/*! core/utils */ \"./CTFd/themes/core/assets/js/utils.js\");\n\nvar _CTFd = _interopRequireDefault(__webpack_require__(/*! core/CTFd */ \"./CTFd/themes/core/assets/js/CTFd.js\"));\n\nvar _jquery = _interopRequireDefault(__webpack_require__(/*! jquery */ \"./node_modules/jquery/dist/jquery.js\"));\n\nvar _plotly = _interopRequireDefault(__webpack_require__(/*! plotly.js-basic-dist */ \"./node_modules/plotly.js-basic-dist/plotly-basic.js\"));\n\nvar _graphs = __webpack_require__(/*! core/graphs */ \"./CTFd/themes/core/assets/js/graphs.js\");\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nfunction _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); }\n\nfunction _nonIterableRest() { throw new TypeError(\"Invalid attempt to destructure non-iterable instance\"); }\n\nfunction _iterableToArrayLimit(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i[\"return\"] != null) _i[\"return\"](); } finally { if (_d) throw _e; } } return _arr; }\n\nfunction _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }\n\nvar graph_configs = {\n \"#solves-graph\": {\n layout: function layout(annotations) {\n return {\n title: \"Solve Counts\",\n annotations: annotations,\n xaxis: {\n title: \"Challenge Name\"\n },\n yaxis: {\n title: \"Amount of Solves\"\n }\n };\n },\n fn: function fn() {\n return \"CTFd_solves_\" + new Date().toISOString().slice(0, 19);\n },\n data: function data() {\n return _CTFd.default.api.get_challenge_solve_statistics();\n },\n format: function format(response) {\n var data = response.data;\n var chals = [];\n var counts = [];\n var annotations = [];\n var solves = {};\n\n for (var c = 0; c < data.length; c++) {\n solves[data[c][\"id\"]] = {\n name: data[c][\"name\"],\n solves: data[c][\"solves\"]\n };\n }\n\n var solves_order = Object.keys(solves).sort(function (a, b) {\n return solves[b].solves - solves[a].solves;\n });\n\n _jquery.default.each(solves_order, function (key, value) {\n chals.push(solves[value].name);\n counts.push(solves[value].solves);\n var result = {\n x: solves[value].name,\n y: solves[value].solves,\n text: solves[value].solves,\n xanchor: \"center\",\n yanchor: \"bottom\",\n showarrow: false\n };\n annotations.push(result);\n });\n\n return [{\n type: \"bar\",\n x: chals,\n y: counts,\n text: counts,\n orientation: \"v\"\n }, annotations];\n }\n },\n \"#keys-pie-graph\": {\n layout: function layout() {\n return {\n title: \"Submission Percentages\"\n };\n },\n fn: function fn() {\n return \"CTFd_submissions_\" + new Date().toISOString().slice(0, 19);\n },\n data: function data() {\n return _CTFd.default.api.get_submission_property_counts({\n column: \"type\"\n });\n },\n format: function format(response) {\n var data = response.data;\n var solves = data[\"correct\"];\n var fails = data[\"incorrect\"];\n return [{\n values: [solves, fails],\n labels: [\"Correct\", \"Incorrect\"],\n marker: {\n colors: [\"rgb(0, 209, 64)\", \"rgb(207, 38, 0)\"]\n },\n text: [\"Solves\", \"Fails\"],\n hole: 0.4,\n type: \"pie\"\n }, null];\n }\n },\n \"#categories-pie-graph\": {\n layout: function layout() {\n return {\n title: \"Category Breakdown\"\n };\n },\n data: function data() {\n return _CTFd.default.api.get_challenge_property_counts({\n column: \"category\"\n });\n },\n fn: function fn() {\n return \"CTFd_categories_\" + new Date().toISOString().slice(0, 19);\n },\n format: function format(response) {\n var data = response.data;\n var categories = [];\n var count = [];\n\n for (var category in data) {\n if (data.hasOwnProperty(category)) {\n categories.push(category);\n count.push(data[category]);\n }\n }\n\n for (var i = 0; i < data.length; i++) {\n categories.push(data[i].category);\n count.push(data[i].count);\n }\n\n return [{\n values: count,\n labels: categories,\n hole: 0.4,\n type: \"pie\"\n }, null];\n }\n },\n \"#solve-percentages-graph\": {\n layout: function layout(annotations) {\n return {\n title: \"Solve Percentages per Challenge\",\n xaxis: {\n title: \"Challenge Name\"\n },\n yaxis: {\n title: \"Percentage of {0} (%)\".format(_CTFd.default.config.userMode.charAt(0).toUpperCase() + _CTFd.default.config.userMode.slice(1)),\n range: [0, 100]\n },\n annotations: annotations\n };\n },\n data: function data() {\n return _CTFd.default.api.get_challenge_solve_percentages();\n },\n fn: function fn() {\n return \"CTFd_challenge_percentages_\" + new Date().toISOString().slice(0, 19);\n },\n format: function format(response) {\n var data = response.data;\n var names = [];\n var percents = [];\n var annotations = [];\n\n for (var key in data) {\n names.push(data[key].name);\n percents.push(data[key].percentage * 100);\n var result = {\n x: data[key].name,\n y: data[key].percentage * 100,\n text: Math.round(data[key].percentage * 100) + \"%\",\n xanchor: \"center\",\n yanchor: \"bottom\",\n showarrow: false\n };\n annotations.push(result);\n }\n\n return [{\n type: \"bar\",\n x: names,\n y: percents,\n orientation: \"v\"\n }, annotations];\n }\n }\n};\nvar config = {\n displaylogo: false,\n responsive: true\n};\n\nvar createGraphs = function createGraphs() {\n var _loop = function _loop(key) {\n var cfg = graph_configs[key];\n var $elem = (0, _jquery.default)(key);\n $elem.empty();\n $elem[0].fn = cfg.fn();\n cfg.data().then(cfg.format).then(function (_ref) {\n var _ref2 = _slicedToArray(_ref, 2),\n data = _ref2[0],\n annotations = _ref2[1];\n\n _plotly.default.newPlot($elem[0], [data], cfg.layout(annotations), config);\n });\n };\n\n for (var key in graph_configs) {\n _loop(key);\n }\n};\n\nfunction updateGraphs() {\n var _loop2 = function _loop2(key) {\n var cfg = graph_configs[key];\n var $elem = (0, _jquery.default)(key);\n cfg.data().then(cfg.format).then(function (_ref3) {\n var _ref4 = _slicedToArray(_ref3, 2),\n data = _ref4[0],\n annotations = _ref4[1];\n\n // FIXME: Pass annotations\n _plotly.default.react($elem[0], [data], cfg.layout(annotations), config);\n });\n };\n\n for (var key in graph_configs) {\n _loop2(key);\n }\n}\n\n(0, _jquery.default)(function () {\n createGraphs();\n setInterval(updateGraphs, 300000);\n});\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/pages/statistics.js?"); +eval("\n\n__webpack_require__(/*! ./main */ \"./CTFd/themes/admin/assets/js/pages/main.js\");\n\n__webpack_require__(/*! core/utils */ \"./CTFd/themes/core/assets/js/utils.js\");\n\nvar _CTFd = _interopRequireDefault(__webpack_require__(/*! core/CTFd */ \"./CTFd/themes/core/assets/js/CTFd.js\"));\n\nvar _jquery = _interopRequireDefault(__webpack_require__(/*! jquery */ \"./node_modules/jquery/dist/jquery.js\"));\n\nvar _plotly = _interopRequireDefault(__webpack_require__(/*! plotly.js-basic-dist */ \"./node_modules/plotly.js-basic-dist/plotly-basic.js\"));\n\nvar _graphs = __webpack_require__(/*! core/graphs */ \"./CTFd/themes/core/assets/js/graphs.js\");\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nfunction _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); }\n\nfunction _nonIterableRest() { throw new TypeError(\"Invalid attempt to destructure non-iterable instance\"); }\n\nfunction _iterableToArrayLimit(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i[\"return\"] != null) _i[\"return\"](); } finally { if (_d) throw _e; } } return _arr; }\n\nfunction _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }\n\nvar graph_configs = {\n \"#solves-graph\": {\n layout: function layout(annotations) {\n return {\n title: \"Solve Counts\",\n annotations: annotations,\n xaxis: {\n title: \"Challenge Name\"\n },\n yaxis: {\n title: \"Amount of Solves\"\n }\n };\n },\n fn: function fn() {\n return \"CTFd_solves_\" + new Date().toISOString().slice(0, 19);\n },\n data: function data() {\n return _CTFd.default.api.get_challenge_solve_statistics();\n },\n format: function format(response) {\n var data = response.data;\n var chals = [];\n var counts = [];\n var annotations = [];\n var solves = {};\n\n for (var c = 0; c < data.length; c++) {\n solves[data[c][\"id\"]] = {\n name: data[c][\"name\"],\n solves: data[c][\"solves\"]\n };\n }\n\n var solves_order = Object.keys(solves).sort(function (a, b) {\n return solves[b].solves - solves[a].solves;\n });\n\n _jquery.default.each(solves_order, function (key, value) {\n chals.push(solves[value].name);\n counts.push(solves[value].solves);\n var result = {\n x: solves[value].name,\n y: solves[value].solves,\n text: solves[value].solves,\n xanchor: \"center\",\n yanchor: \"bottom\",\n showarrow: false\n };\n annotations.push(result);\n });\n\n return [{\n type: \"bar\",\n x: chals,\n y: counts,\n text: counts,\n orientation: \"v\"\n }, annotations];\n }\n },\n \"#keys-pie-graph\": {\n layout: function layout() {\n return {\n title: \"Submission Percentages\"\n };\n },\n fn: function fn() {\n return \"CTFd_submissions_\" + new Date().toISOString().slice(0, 19);\n },\n data: function data() {\n return _CTFd.default.api.get_submission_property_counts({\n column: \"type\"\n });\n },\n format: function format(response) {\n var data = response.data;\n var solves = data[\"correct\"];\n var fails = data[\"incorrect\"];\n return [{\n values: [solves, fails],\n labels: [\"Correct\", \"Incorrect\"],\n marker: {\n colors: [\"rgb(0, 209, 64)\", \"rgb(207, 38, 0)\"]\n },\n text: [\"Solves\", \"Fails\"],\n hole: 0.4,\n type: \"pie\"\n }, null];\n }\n },\n \"#categories-pie-graph\": {\n layout: function layout() {\n return {\n title: \"Category Breakdown\"\n };\n },\n data: function data() {\n return _CTFd.default.api.get_challenge_property_counts({\n column: \"category\"\n });\n },\n fn: function fn() {\n return \"CTFd_categories_\" + new Date().toISOString().slice(0, 19);\n },\n format: function format(response) {\n var data = response.data;\n var categories = [];\n var count = [];\n\n for (var category in data) {\n if (data.hasOwnProperty(category)) {\n categories.push(category);\n count.push(data[category]);\n }\n }\n\n for (var i = 0; i < data.length; i++) {\n categories.push(data[i].category);\n count.push(data[i].count);\n }\n\n return [{\n values: count,\n labels: categories,\n hole: 0.4,\n type: \"pie\"\n }, null];\n }\n },\n \"#solve-percentages-graph\": {\n layout: function layout(annotations) {\n return {\n title: \"Solve Percentages per Challenge\",\n xaxis: {\n title: \"Challenge Name\"\n },\n yaxis: {\n title: \"Percentage of {0} (%)\".format(_CTFd.default.config.userMode.charAt(0).toUpperCase() + _CTFd.default.config.userMode.slice(1)),\n range: [0, 100]\n },\n annotations: annotations\n };\n },\n data: function data() {\n return _CTFd.default.api.get_challenge_solve_percentages();\n },\n fn: function fn() {\n return \"CTFd_challenge_percentages_\" + new Date().toISOString().slice(0, 19);\n },\n format: function format(response) {\n var data = response.data;\n var names = [];\n var percents = [];\n var annotations = [];\n\n for (var key in data) {\n names.push(data[key].name);\n percents.push(data[key].percentage * 100);\n var result = {\n x: data[key].name,\n y: data[key].percentage * 100,\n text: Math.round(data[key].percentage * 100) + \"%\",\n xanchor: \"center\",\n yanchor: \"bottom\",\n showarrow: false\n };\n annotations.push(result);\n }\n\n return [{\n type: \"bar\",\n x: names,\n y: percents,\n orientation: \"v\"\n }, annotations];\n }\n },\n \"#score-distribution-graph\": {\n layout: function layout(annotations) {\n return {\n title: \"Score Distribution\",\n xaxis: {\n title: \"Score Bracket\",\n showticklabels: true,\n type: \"category\"\n },\n yaxis: {\n title: \"Number of {0}\".format(_CTFd.default.config.userMode.charAt(0).toUpperCase() + _CTFd.default.config.userMode.slice(1))\n },\n annotations: annotations\n };\n },\n data: function data() {\n return _CTFd.default.fetch(\"/api/v1/statistics/scores/distribution\").then(function (response) {\n return response.json();\n });\n },\n fn: function fn() {\n return \"CTFd_score_distribution_\" + new Date().toISOString().slice(0, 19);\n },\n format: function format(response) {\n var data = response.data.brackets;\n var keys = [];\n var brackets = [];\n var sizes = [];\n\n for (var key in data) {\n keys.push(parseInt(key));\n }\n\n keys.sort(function (a, b) {\n return a - b;\n });\n var start = \"<0\";\n keys.map(function (key) {\n brackets.push(\"{0} - {1}\".format(start, key));\n sizes.push(data[key]);\n start = key;\n });\n return [{\n type: \"bar\",\n x: brackets,\n y: sizes,\n orientation: \"v\"\n }];\n }\n }\n};\nvar config = {\n displaylogo: false,\n responsive: true\n};\n\nvar createGraphs = function createGraphs() {\n var _loop = function _loop(key) {\n var cfg = graph_configs[key];\n var $elem = (0, _jquery.default)(key);\n $elem.empty();\n $elem[0].fn = cfg.fn();\n cfg.data().then(cfg.format).then(function (_ref) {\n var _ref2 = _slicedToArray(_ref, 2),\n data = _ref2[0],\n annotations = _ref2[1];\n\n _plotly.default.newPlot($elem[0], [data], cfg.layout(annotations), config);\n });\n };\n\n for (var key in graph_configs) {\n _loop(key);\n }\n};\n\nfunction updateGraphs() {\n var _loop2 = function _loop2(key) {\n var cfg = graph_configs[key];\n var $elem = (0, _jquery.default)(key);\n cfg.data().then(cfg.format).then(function (_ref3) {\n var _ref4 = _slicedToArray(_ref3, 2),\n data = _ref4[0],\n annotations = _ref4[1];\n\n // FIXME: Pass annotations\n _plotly.default.react($elem[0], [data], cfg.layout(annotations), config);\n });\n };\n\n for (var key in graph_configs) {\n _loop2(key);\n }\n}\n\n(0, _jquery.default)(function () {\n createGraphs();\n setInterval(updateGraphs, 300000);\n});\n\n//# sourceURL=webpack:///./CTFd/themes/admin/assets/js/pages/statistics.js?"); /***/ }) diff --git a/CTFd/themes/admin/static/js/pages/statistics.min.js b/CTFd/themes/admin/static/js/pages/statistics.min.js index c67d7cf..dbb4b22 100644 --- a/CTFd/themes/admin/static/js/pages/statistics.min.js +++ b/CTFd/themes/admin/static/js/pages/statistics.min.js @@ -1 +1 @@ -!function(l){function e(e){for(var t,o,n=e[0],s=e[1],a=e[2],i=0,r=[];i".concat(e.body,"

")):o.find(".modal-body").append((0,r.default)(e.body));var n=(0,r.default)(c.format(e.button));return e.success&&(0,r.default)(n).click(function(){e.success()}),e.large&&o.find(".modal-dialog").addClass("modal-lg"),o.find(".modal-footer").append(n),(0,r.default)("main").append(o),o.modal("show"),(0,r.default)(o).on("hidden.bs.modal",function(){(0,r.default)(this).modal("dispose")}),o}function f(e){(0,r.default)("#ezq--notifications-toast-container").length||(0,r.default)("body").append((0,r.default)("
").attr({id:"ezq--notifications-toast-container"}).css({position:"fixed",bottom:"0",right:"0","min-width":"20%"}));var t=l.format(e.title,e.body),o=(0,r.default)(t);if(e.onclose&&(0,r.default)(o).find("button[data-dismiss=toast]").click(function(){e.onclose()}),e.onclick){var n=(0,r.default)(o).find(".toast-body");n.addClass("cursor-pointer"),n.click(function(){e.onclick()})}var s=!1!==e.autohide,a=!1!==e.animation,i=e.delay||1e4;return(0,r.default)("#ezq--notifications-toast-container").prepend(o),o.toast({autohide:s,delay:i,animation:a}),o.toast("show"),o}function j(e){var t=a.format(e.title),o=(0,r.default)(t);"string"==typeof e.body?o.find(".modal-body").append("

".concat(e.body,"

")):o.find(".modal-body").append((0,r.default)(e.body));var n=(0,r.default)(m),s=(0,r.default)(u);return o.find(".modal-footer").append(s),o.find(".modal-footer").append(n),(0,r.default)("main").append(o),(0,r.default)(o).on("hidden.bs.modal",function(){(0,r.default)(this).modal("dispose")}),(0,r.default)(n).click(function(){e.success()}),o.modal("show"),o}function h(e){if(e.target){var t=(0,r.default)(e.target);return t.find(".progress-bar").css("width",e.width+"%"),t}var o=i.format(e.width),n=a.format(e.title),s=(0,r.default)(n);return s.find(".modal-body").append((0,r.default)(o)),(0,r.default)("main").append(s),s.modal("show")}function _(e){var t={success:d,error:s}[e.type].format(e.body);return(0,r.default)(t)}var g={ezAlert:p,ezToast:f,ezQuery:j,ezProgressBar:h,ezBadge:_};t.default=g},"./CTFd/themes/core/assets/js/fetch.js":function(e,t,o){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0,o("./node_modules/whatwg-fetch/fetch.js");var n,s=(n=o("./CTFd/themes/core/assets/js/config.js"))&&n.__esModule?n:{default:n};var a=window.fetch;t.default=function(e,t){return void 0===t&&(t={method:"GET",credentials:"same-origin",headers:{}}),e=s.default.urlRoot+e,void 0===t.headers&&(t.headers={}),t.credentials="same-origin",t.headers.Accept="application/json",t.headers["Content-Type"]="application/json",t.headers["CSRF-Token"]=s.default.csrfNonce,a(e,t)}},"./CTFd/themes/core/assets/js/graphs.js":function(e,t,o){Object.defineProperty(t,"__esModule",{value:!0}),t.createGraph=function(e,t,o,n,s,a,i){var r=f[e],l=(0,c.default)(t);if(l.empty(),void 0===l[0])return void console.log("Couldn't find graph target: "+t);l[0].fn=r.fn(n,s,a,i);var d=r.format(n,s,a,i,o);u.default.newPlot(l[0],d,r.layout,j)},t.updateGraph=function(e,t,o,n,s,a,i){var r=f[e],l=(0,c.default)(t),d=r.format(n,s,a,i,o);u.default.update(l[0],d,r.layout,j)};var c=n(o("./node_modules/jquery/dist/jquery.js")),u=n(o("./node_modules/plotly.js-basic-dist/plotly-basic.js")),m=n(o("./node_modules/moment/moment.js")),p=o("./CTFd/themes/core/assets/js/utils.js");function n(e){return e&&e.__esModule?e:{default:e}}var f={score_graph:{layout:{title:"Score over Time",paper_bgcolor:"rgba(0,0,0,0)",plot_bgcolor:"rgba(0,0,0,0)",hovermode:"closest",xaxis:{showgrid:!1,showspikes:!0},yaxis:{showgrid:!1,showspikes:!0},legend:{orientation:"h"}},fn:function(e,t,o,n){return"CTFd_score_".concat(e,"_").concat(o,"_").concat(t,"_").concat((new Date).toISOString().slice(0,19))},format:function(e,t,o,n,s){var a=[],i=[],r=s[0].data,l=s[2].data,d=r.concat(l);d.sort(function(e,t){return new Date(e.date)-new Date(t.date)});for(var c=0;c>8*s&255).toString(16)).substr(-2)}return n},t.htmlEntities=function(e){return(0,a.default)("
").text(e).html()},t.cumulativeSum=function(e){for(var t=e.concat(),o=0;o".concat(e.body,"

")):o.find(".modal-body").append((0,r.default)(e.body));var n=(0,r.default)(c.format(e.button));return e.success&&(0,r.default)(n).click(function(){e.success()}),e.large&&o.find(".modal-dialog").addClass("modal-lg"),o.find(".modal-footer").append(n),(0,r.default)("main").append(o),o.modal("show"),(0,r.default)(o).on("hidden.bs.modal",function(){(0,r.default)(this).modal("dispose")}),o}function f(e){(0,r.default)("#ezq--notifications-toast-container").length||(0,r.default)("body").append((0,r.default)("
").attr({id:"ezq--notifications-toast-container"}).css({position:"fixed",bottom:"0",right:"0","min-width":"20%"}));var t=l.format(e.title,e.body),o=(0,r.default)(t);if(e.onclose&&(0,r.default)(o).find("button[data-dismiss=toast]").click(function(){e.onclose()}),e.onclick){var n=(0,r.default)(o).find(".toast-body");n.addClass("cursor-pointer"),n.click(function(){e.onclick()})}var s=!1!==e.autohide,a=!1!==e.animation,i=e.delay||1e4;return(0,r.default)("#ezq--notifications-toast-container").prepend(o),o.toast({autohide:s,delay:i,animation:a}),o.toast("show"),o}function j(e){var t=a.format(e.title),o=(0,r.default)(t);"string"==typeof e.body?o.find(".modal-body").append("

".concat(e.body,"

")):o.find(".modal-body").append((0,r.default)(e.body));var n=(0,r.default)(m),s=(0,r.default)(u);return o.find(".modal-footer").append(s),o.find(".modal-footer").append(n),(0,r.default)("main").append(o),(0,r.default)(o).on("hidden.bs.modal",function(){(0,r.default)(this).modal("dispose")}),(0,r.default)(n).click(function(){e.success()}),o.modal("show"),o}function h(e){if(e.target){var t=(0,r.default)(e.target);return t.find(".progress-bar").css("width",e.width+"%"),t}var o=i.format(e.width),n=a.format(e.title),s=(0,r.default)(n);return s.find(".modal-body").append((0,r.default)(o)),(0,r.default)("main").append(s),s.modal("show")}function _(e){var t={success:d,error:s}[e.type].format(e.body);return(0,r.default)(t)}var g={ezAlert:p,ezToast:f,ezQuery:j,ezProgressBar:h,ezBadge:_};t.default=g},"./CTFd/themes/core/assets/js/fetch.js":function(e,t,o){Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0,o("./node_modules/whatwg-fetch/fetch.js");var n,s=(n=o("./CTFd/themes/core/assets/js/config.js"))&&n.__esModule?n:{default:n};var a=window.fetch;t.default=function(e,t){return void 0===t&&(t={method:"GET",credentials:"same-origin",headers:{}}),e=s.default.urlRoot+e,void 0===t.headers&&(t.headers={}),t.credentials="same-origin",t.headers.Accept="application/json",t.headers["Content-Type"]="application/json",t.headers["CSRF-Token"]=s.default.csrfNonce,a(e,t)}},"./CTFd/themes/core/assets/js/graphs.js":function(e,t,o){Object.defineProperty(t,"__esModule",{value:!0}),t.createGraph=function(e,t,o,n,s,a,i){var r=f[e],l=(0,c.default)(t);if(l.empty(),void 0===l[0])return void console.log("Couldn't find graph target: "+t);l[0].fn=r.fn(n,s,a,i);var d=r.format(n,s,a,i,o);u.default.newPlot(l[0],d,r.layout,j)},t.updateGraph=function(e,t,o,n,s,a,i){var r=f[e],l=(0,c.default)(t),d=r.format(n,s,a,i,o);u.default.update(l[0],d,r.layout,j)};var c=n(o("./node_modules/jquery/dist/jquery.js")),u=n(o("./node_modules/plotly.js-basic-dist/plotly-basic.js")),m=n(o("./node_modules/moment/moment.js")),p=o("./CTFd/themes/core/assets/js/utils.js");function n(e){return e&&e.__esModule?e:{default:e}}var f={score_graph:{layout:{title:"Score over Time",paper_bgcolor:"rgba(0,0,0,0)",plot_bgcolor:"rgba(0,0,0,0)",hovermode:"closest",xaxis:{showgrid:!1,showspikes:!0},yaxis:{showgrid:!1,showspikes:!0},legend:{orientation:"h"}},fn:function(e,t,o,n){return"CTFd_score_".concat(e,"_").concat(o,"_").concat(t,"_").concat((new Date).toISOString().slice(0,19))},format:function(e,t,o,n,s){var a=[],i=[],r=s[0].data,l=s[2].data,d=r.concat(l);d.sort(function(e,t){return new Date(e.date)-new Date(t.date)});for(var c=0;c>8*s&255).toString(16)).substr(-2)}return n},t.htmlEntities=function(e){return(0,a.default)("
").text(e).html()},t.cumulativeSum=function(e){for(var t=e.concat(),o=0;o{{ ip_count }} IP addresses
+
{{ total_points }} total possible points
{{ challenge_count }} challenges
{% if most_solved %}
{{ most_solved }} has the most solves with
{{ solve_data[most_solved] }} solves
@@ -35,6 +36,18 @@
+
+
+
+
+ +
+
+
+
+ +
+
diff --git a/tests/api/v1/statistics/__init__.py b/tests/api/v1/statistics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/v1/statistics/test_scores.py b/tests/api/v1/statistics/test_scores.py new file mode 100644 index 0000000..fb2a085 --- /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 index 45a79ad..fb71208 100644 --- 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/{}",