Skip to content

Commit

Permalink
Add archived problems feature
Browse files Browse the repository at this point in the history
Closes #221
  • Loading branch information
jdabtieu committed May 23, 2024
1 parent a1531c1 commit 12c1c49
Show file tree
Hide file tree
Showing 14 changed files with 272 additions and 57 deletions.
70 changes: 56 additions & 14 deletions src/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ def get_asset(filename):
@login_required
def dl_file(problem_id):
problem = db.execute("SELECT * FROM problems WHERE id=?", problem_id)
if len(problem) == 0 or (problem[0]["draft"] and not
if len(problem) == 0 or (problem[0]["status"] == PROBLEM_STAT["DRAFT"] and not
check_perm(["ADMIN", "SUPERADMIN", "PROBLEM_MANAGER", "CONTENT_MANAGER"])):

Check failure on line 188 in src/application.py

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

E501 line too long (104 > 90 characters)

Check failure on line 188 in src/application.py

View workflow job for this annotation

GitHub Actions / build (windows-latest)

E501 line too long (104 > 90 characters)
return abort(404)
return send_from_directory("dl/", f"{problem_id}.zip", as_attachment=True)
Expand All @@ -202,7 +202,7 @@ def dl_contest(contest_id, problem_id):
return abort(404)
problem = db.execute(("SELECT * FROM contest_problems WHERE contest_id=? "
"AND problem_id=?"), contest_id, problem_id)
if len(problem) == 0 or (problem[0]["draft"] and
if len(problem) == 0 or (problem[0]["status"] == PROBLEM_STAT["DRAFT"] and

Check warning on line 205 in src/application.py

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

W504 line break after binary operator

Check warning on line 205 in src/application.py

View workflow job for this annotation

GitHub Actions / build (windows-latest)

W504 line break after binary operator
not check_perm(["ADMIN", "SUPERADMIN"])):
return abort(404)
return send_from_directory("dl/", f"{contest_id}/{problem_id}.zip",
Expand Down Expand Up @@ -654,20 +654,20 @@ def problems():
data = db.execute(
("SELECT problems.*, COUNT(DISTINCT problem_solved.user_id) AS sols "
"FROM problems LEFT JOIN problem_solved ON "
"problems.id=problem_solved.problem_id WHERE (draft=0 AND category=?)"
"problems.id=problem_solved.problem_id WHERE (status=0 AND category=?)"
"GROUP BY problems.id ORDER BY id ASC LIMIT 50 OFFSET ?"),
category, page)
length = db.execute(("SELECT COUNT(*) AS cnt FROM problems WHERE "
"draft=0 AND category=?"), category)[0]["cnt"]
"status=0 AND category=?"), category)[0]["cnt"]
else:
data = db.execute(
("SELECT problems.*, COUNT(DISTINCT problem_solved.user_id) AS sols "
"FROM problems LEFT JOIN problem_solved ON "
"problems.id=problem_solved.problem_id WHERE draft=0 "
"problems.id=problem_solved.problem_id WHERE status=0 "
"GROUP BY problems.id ORDER BY id ASC LIMIT 50 OFFSET ?"), page)
length = db.execute("SELECT COUNT(*) AS cnt FROM problems WHERE draft=0")[0]["cnt"] # noqa E501
length = db.execute("SELECT COUNT(*) AS cnt FROM problems WHERE status=0")[0]["cnt"] # noqa E501

categories = db.execute("SELECT DISTINCT category FROM problems WHERE draft=0")
categories = db.execute("SELECT DISTINCT category FROM problems WHERE status=0")
categories.sort(key=lambda x: x['category'])

is_ongoing_contest = len(db.execute(
Expand All @@ -680,6 +680,49 @@ def problems():
is_ongoing_contest=is_ongoing_contest)


@app.route('/problems/archived')
def archived_problems():
page = request.args.get("page")
if not page:
page = "1"
page = (int(page) - 1) * 50

category = request.args.get("category")
if not category:
category = None

solved = set()
if session.get("user_id"):
solved_db = db.execute("SELECT problem_id FROM problem_solved WHERE user_id=:uid",
uid=session["user_id"])
for row in solved_db:
solved.add(row["problem_id"])

if category is not None:
data = db.execute(
("SELECT problems.*, COUNT(DISTINCT problem_solved.user_id) AS sols "
"FROM problems LEFT JOIN problem_solved ON "
"problems.id=problem_solved.problem_id WHERE (status=2 AND category=?)"
"GROUP BY problems.id ORDER BY id ASC LIMIT 50 OFFSET ?"),
category, page)
length = db.execute(("SELECT COUNT(*) AS cnt FROM problems WHERE "
"status=0 AND category=?"), category)[0]["cnt"]
else:
data = db.execute(
("SELECT problems.*, COUNT(DISTINCT problem_solved.user_id) AS sols "
"FROM problems LEFT JOIN problem_solved ON "
"problems.id=problem_solved.problem_id WHERE status=2 "
"GROUP BY problems.id ORDER BY id ASC LIMIT 50 OFFSET ?"), page)
length = db.execute("SELECT COUNT(*) AS cnt FROM problems WHERE status=2")[0]["cnt"] # noqa E501

categories = db.execute("SELECT DISTINCT category FROM problems WHERE status=2")
categories.sort(key=lambda x: x['category'])

return render_template('problem/archived_list.html',
data=data, solved=solved, length=-(-length // 50),
categories=categories, selected=category)


@app.route("/problems/create", methods=["GET", "POST"])
@perm_required(["ADMIN", "SUPERADMIN", "PROBLEM_MANAGER", "CONTENT_MANAGER"])
def create_problem():
Expand Down Expand Up @@ -722,11 +765,10 @@ def create_problem():

# Create & ensure problem doesn't already exist
try:
db.execute(("INSERT INTO problems (id, name, point_value, category, flag, draft, "
"flag_hint, instanced) VALUES (:id, :name, :point_value, :category, "
":flag, :draft, :fhint, :inst)"),
id=problem_id, name=name, point_value=point_value, category=category,
flag=flag, draft=draft, fhint=flag_hint, inst=instanced)
db.execute(("INSERT INTO problems (id, name, point_value, category, flag, "
"status, flag_hint, instanced) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
problem_id, name, point_value, category, flag, draft, flag_hint,
instanced)
except ValueError:
flash('A problem with this name or ID already exists', 'danger')
return render_template("problem/create.html"), 400
Expand Down Expand Up @@ -757,8 +799,8 @@ def draft_problems():
page = "1"
page = (int(page) - 1) * 50

data = db.execute("SELECT * FROM problems WHERE draft=1 LIMIT 50 OFFSET ?", page)
length = db.execute("SELECT COUNT(*) AS cnt FROM problems WHERE draft=1")[0]["cnt"]
data = db.execute("SELECT * FROM problems WHERE status=1 LIMIT 50 OFFSET ?", page)
length = db.execute("SELECT COUNT(*) AS cnt FROM problems WHERE status=1")[0]["cnt"]

return render_template('problem/draft_problems.html',
data=data, length=-(-length // 50))
Expand Down
1 change: 1 addition & 0 deletions src/assets/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ td {

.pagination {
overflow-x: auto;
margin-bottom: 0;
}

.hidden {
Expand Down
6 changes: 6 additions & 0 deletions src/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
"CONTENT_MANAGER": 3,
}

PROBLEM_STAT = {
"PUBLISHED": 0,
"DRAFT": 1,
"ARCHIVED": 2,
}


def sha256sum(string):
return hashlib.sha256(string.encode("utf-8")).hexdigest()
Expand Down
34 changes: 34 additions & 0 deletions src/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,40 @@

db = cs50.SQL("sqlite:///database.db")
db.execute("BEGIN")

db.execute("""CREATE TABLE 'problems_migration' (
'id' varchar(64) NOT NULL UNIQUE,
'name' varchar(256) NOT NULL,
'point_value' integer NOT NULL DEFAULT(0),
'category' varchar(64),
'flag' varchar(256) NOT NULL,
'status' integer NOT NULL DEFAULT(0),
'flag_hint' varchar(256) NOT NULL DEFAULT(''),
'instanced' boolean NOT NULL DEFAULT(0)
)""")
db.execute("INSERT INTO problems_migration SELECT id, name, point_value, category, flag, draft, flag_hint, instanced FROM problems")
db.execute("DROP TABLE problems")
db.execute("ALTER TABLE problems_migration RENAME TO problems")

db.execute("""CREATE TABLE 'contest_problems_migration' (
'contest_id' varchar(32) NOT NULL,
'problem_id' varchar(64) NOT NULL,
'name' varchar(256) NOT NULL,
'point_value' integer NOT NULL DEFAULT(0),
'category' varchar(64),
'flag' varchar(256) NOT NULL,
'status' integer NOT NULL DEFAULT(0),
'score_min' integer NOT NULL DEFAULT(0),
'score_max' integer NOT NULL DEFAULT(0),
'score_users' integer NOT NULL DEFAULT(-1),
'flag_hint' varchar(256) NOT NULL DEFAULT(''),
'instanced' boolean NOT NULL DEFAULT(0),
UNIQUE(contest_id, problem_id) ON CONFLICT ABORT
)""")
db.execute("INSERT INTO contest_problems_migration SELECT contest_id, problem_id, name, point_value, category, flag, draft, score_min, score_max, score_users, flag_hint, instanced FROM contest_problems")
db.execute("DROP TABLE contest_problems")
db.execute("ALTER TABLE contest_problems_migration RENAME TO contest_problems")

db.execute("COMMIT")

with open('settings.py', 'a') as f:
Expand Down
4 changes: 2 additions & 2 deletions src/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ CREATE TABLE 'problems' (
'point_value' integer NOT NULL DEFAULT(0),
'category' varchar(64),
'flag' varchar(256) NOT NULL,
'draft' boolean NOT NULL DEFAULT(0),
'status' integer NOT NULL DEFAULT(0), -- see helpers.py
'flag_hint' varchar(256) NOT NULL DEFAULT(''),
'instanced' boolean NOT NULL DEFAULT(0)
);
Expand Down Expand Up @@ -75,7 +75,7 @@ CREATE TABLE 'contest_problems' (
'point_value' integer NOT NULL DEFAULT(0),
'category' varchar(64),
'flag' varchar(256) NOT NULL,
'draft' boolean NOT NULL DEFAULT(0),
'status' integer NOT NULL DEFAULT(0), -- 0: published, 1: draft
'score_min' integer NOT NULL DEFAULT(0),
'score_max' integer NOT NULL DEFAULT(0),
'score_users' integer NOT NULL DEFAULT(-1),
Expand Down
4 changes: 2 additions & 2 deletions src/templates/contest/contest_problem.html
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ <h3 class="card-title">Live Instance</h3>
</a>
<br><a href="{{ request.path }}/edit">Edit problem</a>
<br><a href="{{ request.path }}/download">Download problem</a>
{% if data["draft"] %}
{% if data["status"] == 1 %}
<br><a href="#" id="btn-publish" onclick="">
Publish problem
</a>
Expand Down Expand Up @@ -201,7 +201,7 @@ <h3 class="card-title">Live Instance</h3>
document.getElementById("hint").classList.toggle("hidden");
});
</script>
{% if data["draft"] %}
{% if data["status"] == 1 %}
<script>
document.getElementById("btn-publish").addEventListener("click", function() {
document.getElementById("confirm").style.display = "block";
Expand Down
104 changes: 104 additions & 0 deletions src/templates/problem/archived_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
{% extends "layout.html" %}

{% block title %}Archived Problems{% endblock %}
{% block active %}Practice{% endblock %}

{% block main %}
<h1>Archived Problems</h1>
<div class="alert alert-warning alert-dismissible fade show" role="alert">
These problems are archived. That means they used to be solveable, but may no longer be anymore due to servers shutting down or other reasons.
They may also be archived because they are too low quality, but users who have solved them previously still retain the points.
<button type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"></button>
</div>
<div id="pagination" style="display: inline-block; min-height: 42px" data-pages="{{ length }}"></div>
<div id="category-selector-container">
<label for="category-selector">Category</label>
<select class="form-control form-select"
id="category-selector"
onchange="selectCategory(this.value)">
<option value="">All</option>
<option disabled>----------</option>
{% for category in categories %}
{% if selected == category['category'] %}
<option selected>{{ category['category'] }}</option>
{% else %}
<option>{{ category['category'] }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="mb-1"><a href="/problems">Back to unarchived problems</a></div>
<div style="overflow-x: auto;">
<table class="table table-hover table-full-width">
<thead class="table-dark">
<tr>
<th scope="col" style="width: 10%;">Status</th>
<th scope="col" style="width: 60%;">Name</th>
<th scope="col" style="width: 10%;">Category</th>
<th scope="col" style="width: 10%;">Value</th>
<th scope="col" style="width: 10%;">Users</th>
</tr>
</thead>
<tbody>
{% for row in data %}
<tr>
<td>
{% if row["id"] in solved %}
<img class="svg-green icon"
src="/assets/images/check.svg"
alt="Solved"
onerror="this.src='/assets/images/check.png'">
{% else %}
<img class="svg-red icon"
src="/assets/images/times.svg"
alt="Solved"
onerror="this.src='/assets/images/times.png'">
{% endif %}
</td>
<td><a href="/problem/{{ row["id"] }}">{{ row["name"] }}</a></td>
<td>{{ row["category"] }}</td>
<td>{{ row["point_value"] }}</td>
{% if check_perm(["ADMIN", "SUPERADMIN", "CONTENT_MANAGER"]) %}
<td><a href="/admin/submissions?problem_id={{ row['id'] }}&correct=AC">
{{ row["sols"] }}
</a></td>
{% else %}
<td>{{ row["sols"] }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
document.getElementById("category-selector").addEventListener("change", function() {
let value = this.value;
let kvp = document.location.search.substr(1).split('&');
let i = 0;
for (; i < kvp.length; i++) {
if (kvp[i].startsWith('category=')) {
let pair = kvp[i].split('=');
pair[1] = value;
kvp[i] = pair.join('=');
break;
}
}

if (i >= kvp.length) {
kvp[kvp.length] = ["category", value].join('=');
}
for (let i = 0; i < kvp.length; i++) {
if (kvp[i].startsWith('page')) {
let pair = kvp[i].split('=');
pair[1] = "1";
kvp[i] = pair.join('=');
break;
}
}
document.location.search = kvp.join('&');
});
</script>
{% endblock %}
23 changes: 12 additions & 11 deletions src/templates/problem/problem.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ <h1>
<form method="post" style="margin-bottom: 1rem;" action="">
<input class="btn btn-danger" type="submit" value="">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="status" id="confirm-status" value=""/>
</form>
</div>
{% endif %}
Expand Down Expand Up @@ -165,9 +166,12 @@ <h3 class="card-title">Live Instance</h3>
<br><a href="{{ request.path }}/edit">Edit problem</a>
<br><a href="{{ request.path }}/download">Download problem</a>
<br><a href="#" class="btn-delete">Delete problem</a>
{% if data["draft"] %}
<br><a href="#" class="btn-publish">Publish draft</a>
{% endif %}
<br>Change problem type to <select id="select-status">
<option disabled selected>----------</option>
{% for k, v in stats.items() %}
<option>{{ k }}</option>
{% endfor %}
</select>
{% endif %}
</div>
</div>
Expand Down Expand Up @@ -205,17 +209,14 @@ <h3 class="card-title">Live Instance</h3>
.setAttribute("value", "Are you sure you want to delete this problem? " +
"Click here to confirm.");
});
</script>
{% endif %}
{% if data["draft"] %}
<script>
document.querySelector(".btn-publish").addEventListener("click", function () {
document.querySelector("#select-status").addEventListener("change", function (e) {
document.getElementById("confirm").style.display = "";
document.querySelector("#confirm form")
.setAttribute("action", window.location.pathname + "/publish");
.setAttribute("action", window.location.pathname + "/changestat");
document.querySelector("#confirm-status").value = e.target.value;
document.querySelector("#confirm form .btn")
.setAttribute("value", "Are you sure you want to publish this problem? " +
"Click here to confirm.");
.setAttribute("value", "Are you sure you want to make this problem " +
e.target.value + "? Click here to confirm.");
});
</script>
{% endif %}
Expand Down
1 change: 1 addition & 0 deletions src/templates/problem/problems.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ <h1>Problems</h1>
{% endfor %}
</select>
</div>
<div class="mb-1"><a href="/problems/archived">View archived problems</a></div>
<div style="overflow-x: auto;">
<table class="table table-hover table-full-width">
<thead class="table-dark">
Expand Down
4 changes: 3 additions & 1 deletion src/tests/test_dl.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ def test_dl(client, database):
}, follow_redirects=True)
assert result.status_code == 200

result = client.post('/problem/helloworldtesting/publish', follow_redirects=True)
result = client.post('/problem/helloworldtesting/changestat', data={
'status': 'PUBLISHED'
}, follow_redirects=True)
assert result.status_code == 200

client.post('/login', data={'username': 'normal_user', 'password': 'CTFOJadmin'})
Expand Down
Loading

0 comments on commit 12c1c49

Please sign in to comment.