Add API interface to mark submissions as correct (#2350)

* Add the `discard` type for submissions
* Add `PATCH /api/v1/submissions/[submission_id]` to mark submissions as correct
* Closes #181
This commit is contained in:
Kevin Chung
2023-07-04 03:21:35 -04:00
committed by GitHub
parent deae9e1941
commit c2eca90b05
16 changed files with 298 additions and 13 deletions

View File

@@ -80,7 +80,12 @@ class ChallengeSolvePercentages(Resource):
@admins_only
def get(self):
challenges = (
Challenges.query.add_columns("id", "name", "state", "max_attempts")
Challenges.query.add_columns(
Challenges.id,
Challenges.name,
Challenges.state,
Challenges.max_attempts,
)
.order_by(Challenges.value)
.all()
)

View File

@@ -1,5 +1,6 @@
from typing import List
from flask import request
from flask_restx import Namespace, Resource
from CTFd.api.v1.helpers.request import validate_args
@@ -10,7 +11,7 @@ from CTFd.api.v1.schemas import (
)
from CTFd.cache import clear_challenges, clear_standings
from CTFd.constants import RawEnum
from CTFd.models import Submissions, db
from CTFd.models import Solves, Submissions, db
from CTFd.schemas.submissions import SubmissionSchema
from CTFd.utils.decorators import admins_only
from CTFd.utils.helpers.models import build_model_filters
@@ -152,7 +153,7 @@ class SubmissionsList(Resource):
class Submission(Resource):
@admins_only
@submissions_namespace.doc(
description="Endpoint to get submission objects in bulk",
description="Endpoint to get a submission object",
responses={
200: ("Success", "SubmissionDetailedSuccessResponse"),
400: (
@@ -173,7 +174,51 @@ class Submission(Resource):
@admins_only
@submissions_namespace.doc(
description="Endpoint to get submission objects in bulk",
description="Endpoint to edit a submission object",
responses={
200: ("Success", "SubmissionDetailedSuccessResponse"),
400: (
"An error occured processing the provided or stored data",
"APISimpleErrorResponse",
),
},
)
def patch(self, submission_id):
submission = Submissions.query.filter_by(id=submission_id).first_or_404()
req = request.get_json()
submission_type = req.get("type")
if submission_type == "correct":
solve = Solves(
user_id=submission.user_id,
challenge_id=submission.challenge_id,
team_id=submission.team_id,
ip=submission.ip,
provided=submission.provided,
date=submission.date,
)
db.session.add(solve)
submission.type = "discard"
db.session.commit()
# Delete standings cache
clear_standings()
clear_challenges()
submission = solve
schema = SubmissionSchema()
response = schema.dump(submission)
if response.errors:
return {"success": False, "errors": response.errors}, 400
return {"success": True, "data": response.data}
@admins_only
@submissions_namespace.doc(
description="Endpoint to delete a submission object",
responses={
200: ("Success", "APISimpleSuccessResponse"),
400: (

View File

@@ -849,6 +849,10 @@ class Fails(Submissions):
__mapper_args__ = {"polymorphic_identity": "incorrect"}
class Discards(Submissions):
__mapper_args__ = {"polymorphic_identity": "discard"}
class Unlocks(db.Model):
__tablename__ = "unlocks"
id = db.Column(db.Integer, primary_key=True)

View File

@@ -61,6 +61,38 @@ function deleteSelectedSubmissions(_event) {
});
}
function correctSubmissions(_event) {
let submissionIDs = $("input[data-submission-id]:checked").map(function() {
return $(this).data("submission-id");
});
let target = submissionIDs.length === 1 ? "submission" : "submissions";
ezQuery({
title: "Correct Submissions",
body: `Are you sure you want to mark ${
submissionIDs.length
} ${target} correct?`,
success: function() {
const reqs = [];
for (var subId of submissionIDs) {
let req = CTFd.fetch(`/api/v1/submissions/${subId}`, {
method: "PATCH",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({ type: "correct" })
});
reqs.push(req);
}
Promise.all(reqs).then(_responses => {
window.location.reload();
});
}
});
}
function showFlagsToggle(_event) {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("full")) {
@@ -110,6 +142,7 @@ $(() => {
$("#show-short-flags-button").click(showFlagsToggle);
$(".show-flag").click(showFlag);
$(".copy-flag").click(copyFlag);
$("#correct-flags-button").click(correctSubmissions);
$(".delete-correct-submission").click(deleteCorrectSubmission);
$("#submission-delete-button").click(deleteSelectedSubmissions);
});

View File

@@ -112,6 +112,39 @@ function updateTeam(event) {
});
}
function correctSubmissions(_event) {
let submissions = $("input[data-submission-type=incorrect]:checked");
let submissionIDs = submissions.map(function() {
return $(this).data("submission-id");
});
let target = submissionIDs.length === 1 ? "submission" : "submissions";
ezQuery({
title: "Correct Submissions",
body: `Are you sure you want to mark ${
submissionIDs.length
} ${target} correct?`,
success: function() {
const reqs = [];
for (var subId of submissionIDs) {
let req = CTFd.fetch(`/api/v1/submissions/${subId}`, {
method: "PATCH",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({ type: "correct" })
});
reqs.push(req);
}
Promise.all(reqs).then(_responses => {
window.location.reload();
});
}
});
}
function deleteSelectedSubmissions(event, target) {
let submissions;
let type;
@@ -529,6 +562,8 @@ $(() => {
deleteSelectedSubmissions(e, "solves");
});
$("#correct-fail-button").click(correctSubmissions);
$("#fails-delete-button").click(function(e) {
deleteSelectedSubmissions(e, "fails");
});

View File

@@ -225,6 +225,39 @@ function emailUser(event) {
});
}
function correctSubmissions(_event) {
let submissions = $("input[data-submission-type=incorrect]:checked");
let submissionIDs = submissions.map(function() {
return $(this).data("submission-id");
});
let target = submissionIDs.length === 1 ? "submission" : "submissions";
ezQuery({
title: "Correct Submissions",
body: `Are you sure you want to mark ${
submissionIDs.length
} ${target} correct?`,
success: function() {
const reqs = [];
for (var subId of submissionIDs) {
let req = CTFd.fetch(`/api/v1/submissions/${subId}`, {
method: "PATCH",
credentials: "same-origin",
headers: {
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify({ type: "correct" })
});
reqs.push(req);
}
Promise.all(reqs).then(_responses => {
window.location.reload();
});
}
});
}
function deleteSelectedSubmissions(event, target) {
let submissions;
let type;
@@ -452,6 +485,8 @@ $(() => {
deleteSelectedSubmissions(e, "solves");
});
$("#correct-fail-button").click(correctSubmissions);
$("#fails-delete-button").click(function(e) {
deleteSelectedSubmissions(e, "fails");
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -48,6 +48,11 @@
<div class="col-md-12">
<div class="float-right pb-3">
<div class="btn-group" role="group">
{% if type == "incorrect" %}
<button type="button" class="btn btn-outline-success" data-toggle="tooltip" title="Mark submissions correct" id="correct-flags-button">
<i class="btn-fa fas fa-check"></i>
</button>
{% endif %}
{% if request.args.get("full") %}
<button type="button" class="btn btn-outline-primary" data-toggle="tooltip" title="Show truncated flags" id="show-short-flags-button">
<i class="btn-fa far fa-flag"></i>

View File

@@ -384,6 +384,9 @@
<div class="col-md-12">
<div class="float-right pb-3">
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-success" id="correct-fail-button">
<i class="btn-fa fas fa-check"></i>
</button>
<button type="button" class="btn btn-outline-danger" id="fails-delete-button">
<i class="btn-fa fas fa-trash-alt"></i>
</button>
@@ -409,7 +412,7 @@
</thead>
<tbody>
{% for fail in fails %}
<tr class="chal-wrong" data-href="{{ url_for("admin.challenges_detail", challenge_id=fail.challenge_id) }}">
<tr class="chal-wrong" data-href="{{ url_for('admin.challenges_detail', challenge_id=fail.challenge_id) }}">
<td class="border-right" data-checkbox>
<div class="form-check text-center">
<input type="checkbox" class="form-check-input" value="{{ fail.id }}" autocomplete="off"

View File

@@ -233,7 +233,7 @@
<tr class="chal-solve" data-href="{{ url_for("admin.challenges_detail", challenge_id=solve.challenge_id) }}">
<td class="border-right" data-checkbox>
<div class="form-check text-center">
<input type="checkbox" class="form-check-input" value="{{ solve.id }}" autocomplete="off"
<input type="checkbox" class="form-check-input" value="{{ solve.id }}" autocomplete="off"
data-submission-id="{{ solve.id }}"
data-submission-type="{{ solve.type }}"
data-submission-challenge="{{ solve.challenge.name }}">&nbsp;
@@ -264,6 +264,9 @@
<div class="col-md-12">
<div class="float-right pb-3">
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-success" id="correct-fail-button">
<i class="btn-fa fas fa-check"></i>
</button>
<button type="button" class="btn btn-outline-danger" id="fails-delete-button">
<i class="btn-fa fas fa-trash-alt"></i>
</button>
@@ -288,7 +291,7 @@
</thead>
<tbody>
{% for fail in fails %}
<tr class="chal-wrong" data-href="{{ url_for("admin.challenges_detail", challenge_id=fail.challenge_id) }}">
<tr class="chal-wrong" data-href="{{ url_for('admin.challenges_detail', challenge_id=fail.challenge_id) }}">
<td class="border-right" data-checkbox>
<div class="form-check text-center">
<input type="checkbox" class="form-check-input" value="{{ fail.id }}" autocomplete="off"

View File

@@ -1,12 +1,16 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from CTFd.models import Discards, Fails, Solves
from tests.helpers import (
create_ctfd,
destroy_ctfd,
gen_challenge,
gen_fail,
gen_solve,
gen_team,
login_as_user,
register_user,
)
@@ -91,3 +95,116 @@ def test_api_submission_delete_admin():
assert r.status_code == 200
assert r.get_json().get("data") is None
destroy_ctfd(app)
def test_api_submission_patch_correct():
"""Test that patching a submission to correct creates a solve"""
app = create_ctfd()
with app.app_context():
register_user(app)
gen_challenge(app.db)
gen_fail(app.db, challenge_id=1, user_id=2)
assert Solves.query.count() == 0
with login_as_user(app, "admin") as client:
r = client.patch("/api/v1/submissions/1", json={"type": "correct"})
assert r.status_code == 200
assert Fails.query.count() == 0
assert Solves.query.count() == 1
assert Discards.query.count() == 1
destroy_ctfd(app)
def test_api_submission_patch_correct_scoreboard():
"If we adjust a submission for someone the scoreboard should be correct accounting for the time of the adjusted submission"
app = create_ctfd()
with app.app_context():
# Create 2 test users
register_user(app, name="user1", email="user1@examplectf.com")
register_user(app, name="user2", email="user2@examplectf.com")
# Create 2 test challenges
gen_challenge(app.db, name="chal1")
gen_challenge(app.db, name="chal2")
# Give the first test user only fails
gen_fail(app.db, challenge_id=1, user_id=2)
gen_fail(app.db, challenge_id=2, user_id=2)
# Give the second test user only solves
gen_solve(app.db, challenge_id=1, user_id=3)
gen_solve(app.db, challenge_id=2, user_id=3)
with login_as_user(app, "admin") as client:
# user2 who has both solves should be considered on top
scoreboard = client.get("/api/v1/scoreboard").get_json()["data"]
assert len(scoreboard) == 1
assert scoreboard[0]["name"] == "user2"
# We mark user1's first solve as correct
# This should give them 100 points
# It should not place them above user2 who has 200 points
client.patch("/api/v1/submissions/1", json={"type": "correct"})
scoreboard = client.get("/api/v1/scoreboard").get_json()["data"]
assert len(scoreboard) == 2
assert scoreboard[0]["name"] == "user2"
assert scoreboard[1]["name"] == "user1"
assert scoreboard[1]["score"] == 100
# We mark user1's second solve as correct
# This should give them 200 points
# It should place them above user2 who has 200 points but was not the first to solve the challenge
# Based on time user1's attempts should be considered correct and first
client.patch("/api/v1/submissions/2", json={"type": "correct"})
scoreboard = client.get("/api/v1/scoreboard").get_json()["data"]
assert len(scoreboard) == 2
assert scoreboard[0]["name"] == "user1"
assert scoreboard[0]["score"] == 200
assert scoreboard[1]["name"] == "user2"
assert scoreboard[1]["score"] == 200
destroy_ctfd(app)
def test_api_submission_patch_correct_scoreboard_teams():
"If we adjust a submission for a team the scoreboard should be correct after the adjustment"
app = create_ctfd(user_mode="teams")
with app.app_context():
# Create 2 test teams each with only 1 user
gen_team(app.db, name="team1", email="team1@examplectf.com", member_count=1)
gen_team(app.db, name="team2", email="team2@examplectf.com", member_count=1)
# Create 2 test challenges
gen_challenge(app.db, name="chal1")
gen_challenge(app.db, name="chal2")
# Assign only fails to the user in team 1
gen_fail(app.db, challenge_id=1, user_id=2, team_id=1)
gen_fail(app.db, challenge_id=2, user_id=2, team_id=1)
# Assign only solves to the user in team 2
gen_solve(app.db, challenge_id=1, user_id=3, team_id=2)
gen_solve(app.db, challenge_id=2, user_id=3, team_id=2)
with login_as_user(app, "admin") as client:
# team2 who has both solves should be considered on top
scoreboard = client.get("/api/v1/scoreboard").get_json()["data"]
assert len(scoreboard) == 1
assert scoreboard[0]["name"] == "team2"
# We then convert the first submission which should give team1 100 points
# team1 should also now appear on the scoreboard in 2nd place
client.patch("/api/v1/submissions/1", json={"type": "correct"})
scoreboard = client.get("/api/v1/scoreboard").get_json()["data"]
assert len(scoreboard) == 2
assert scoreboard[0]["name"] == "team2"
assert scoreboard[1]["name"] == "team1"
assert scoreboard[1]["score"] == 100
# We mark team1's second solve as correct
# This should give them 200 points
# It should place them above team2 who has 200 points but was not the first to solve the challenge
# Based on time team1's attempts should be considered correct and first
client.patch("/api/v1/submissions/2", json={"type": "correct"})
scoreboard = client.get("/api/v1/scoreboard").get_json()["data"]
assert len(scoreboard) == 2
assert scoreboard[0]["name"] == "team1"
assert scoreboard[0]["score"] == 200
assert scoreboard[1]["name"] == "team2"
assert scoreboard[1]["score"] == 200
destroy_ctfd(app)