Skip to content

Commit 12c1c49

Browse files
committed
Add archived problems feature
Closes #221
1 parent a1531c1 commit 12c1c49

File tree

14 files changed

+272
-57
lines changed

14 files changed

+272
-57
lines changed

src/application.py

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ def get_asset(filename):
184184
@login_required
185185
def dl_file(problem_id):
186186
problem = db.execute("SELECT * FROM problems WHERE id=?", problem_id)
187-
if len(problem) == 0 or (problem[0]["draft"] and not
187+
if len(problem) == 0 or (problem[0]["status"] == PROBLEM_STAT["DRAFT"] and not
188188
check_perm(["ADMIN", "SUPERADMIN", "PROBLEM_MANAGER", "CONTENT_MANAGER"])):
189189
return abort(404)
190190
return send_from_directory("dl/", f"{problem_id}.zip", as_attachment=True)
@@ -202,7 +202,7 @@ def dl_contest(contest_id, problem_id):
202202
return abort(404)
203203
problem = db.execute(("SELECT * FROM contest_problems WHERE contest_id=? "
204204
"AND problem_id=?"), contest_id, problem_id)
205-
if len(problem) == 0 or (problem[0]["draft"] and
205+
if len(problem) == 0 or (problem[0]["status"] == PROBLEM_STAT["DRAFT"] and
206206
not check_perm(["ADMIN", "SUPERADMIN"])):
207207
return abort(404)
208208
return send_from_directory("dl/", f"{contest_id}/{problem_id}.zip",
@@ -654,20 +654,20 @@ def problems():
654654
data = db.execute(
655655
("SELECT problems.*, COUNT(DISTINCT problem_solved.user_id) AS sols "
656656
"FROM problems LEFT JOIN problem_solved ON "
657-
"problems.id=problem_solved.problem_id WHERE (draft=0 AND category=?)"
657+
"problems.id=problem_solved.problem_id WHERE (status=0 AND category=?)"
658658
"GROUP BY problems.id ORDER BY id ASC LIMIT 50 OFFSET ?"),
659659
category, page)
660660
length = db.execute(("SELECT COUNT(*) AS cnt FROM problems WHERE "
661-
"draft=0 AND category=?"), category)[0]["cnt"]
661+
"status=0 AND category=?"), category)[0]["cnt"]
662662
else:
663663
data = db.execute(
664664
("SELECT problems.*, COUNT(DISTINCT problem_solved.user_id) AS sols "
665665
"FROM problems LEFT JOIN problem_solved ON "
666-
"problems.id=problem_solved.problem_id WHERE draft=0 "
666+
"problems.id=problem_solved.problem_id WHERE status=0 "
667667
"GROUP BY problems.id ORDER BY id ASC LIMIT 50 OFFSET ?"), page)
668-
length = db.execute("SELECT COUNT(*) AS cnt FROM problems WHERE draft=0")[0]["cnt"] # noqa E501
668+
length = db.execute("SELECT COUNT(*) AS cnt FROM problems WHERE status=0")[0]["cnt"] # noqa E501
669669

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

673673
is_ongoing_contest = len(db.execute(
@@ -680,6 +680,49 @@ def problems():
680680
is_ongoing_contest=is_ongoing_contest)
681681

682682

683+
@app.route('/problems/archived')
684+
def archived_problems():
685+
page = request.args.get("page")
686+
if not page:
687+
page = "1"
688+
page = (int(page) - 1) * 50
689+
690+
category = request.args.get("category")
691+
if not category:
692+
category = None
693+
694+
solved = set()
695+
if session.get("user_id"):
696+
solved_db = db.execute("SELECT problem_id FROM problem_solved WHERE user_id=:uid",
697+
uid=session["user_id"])
698+
for row in solved_db:
699+
solved.add(row["problem_id"])
700+
701+
if category is not None:
702+
data = db.execute(
703+
("SELECT problems.*, COUNT(DISTINCT problem_solved.user_id) AS sols "
704+
"FROM problems LEFT JOIN problem_solved ON "
705+
"problems.id=problem_solved.problem_id WHERE (status=2 AND category=?)"
706+
"GROUP BY problems.id ORDER BY id ASC LIMIT 50 OFFSET ?"),
707+
category, page)
708+
length = db.execute(("SELECT COUNT(*) AS cnt FROM problems WHERE "
709+
"status=0 AND category=?"), category)[0]["cnt"]
710+
else:
711+
data = db.execute(
712+
("SELECT problems.*, COUNT(DISTINCT problem_solved.user_id) AS sols "
713+
"FROM problems LEFT JOIN problem_solved ON "
714+
"problems.id=problem_solved.problem_id WHERE status=2 "
715+
"GROUP BY problems.id ORDER BY id ASC LIMIT 50 OFFSET ?"), page)
716+
length = db.execute("SELECT COUNT(*) AS cnt FROM problems WHERE status=2")[0]["cnt"] # noqa E501
717+
718+
categories = db.execute("SELECT DISTINCT category FROM problems WHERE status=2")
719+
categories.sort(key=lambda x: x['category'])
720+
721+
return render_template('problem/archived_list.html',
722+
data=data, solved=solved, length=-(-length // 50),
723+
categories=categories, selected=category)
724+
725+
683726
@app.route("/problems/create", methods=["GET", "POST"])
684727
@perm_required(["ADMIN", "SUPERADMIN", "PROBLEM_MANAGER", "CONTENT_MANAGER"])
685728
def create_problem():
@@ -722,11 +765,10 @@ def create_problem():
722765

723766
# Create & ensure problem doesn't already exist
724767
try:
725-
db.execute(("INSERT INTO problems (id, name, point_value, category, flag, draft, "
726-
"flag_hint, instanced) VALUES (:id, :name, :point_value, :category, "
727-
":flag, :draft, :fhint, :inst)"),
728-
id=problem_id, name=name, point_value=point_value, category=category,
729-
flag=flag, draft=draft, fhint=flag_hint, inst=instanced)
768+
db.execute(("INSERT INTO problems (id, name, point_value, category, flag, "
769+
"status, flag_hint, instanced) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
770+
problem_id, name, point_value, category, flag, draft, flag_hint,
771+
instanced)
730772
except ValueError:
731773
flash('A problem with this name or ID already exists', 'danger')
732774
return render_template("problem/create.html"), 400
@@ -757,8 +799,8 @@ def draft_problems():
757799
page = "1"
758800
page = (int(page) - 1) * 50
759801

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

763805
return render_template('problem/draft_problems.html',
764806
data=data, length=-(-length // 50))

src/assets/css/style.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ td {
206206

207207
.pagination {
208208
overflow-x: auto;
209+
margin-bottom: 0;
209210
}
210211

211212
.hidden {

src/helpers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
"CONTENT_MANAGER": 3,
2626
}
2727

28+
PROBLEM_STAT = {
29+
"PUBLISHED": 0,
30+
"DRAFT": 1,
31+
"ARCHIVED": 2,
32+
}
33+
2834

2935
def sha256sum(string):
3036
return hashlib.sha256(string.encode("utf-8")).hexdigest()

src/migrate.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,40 @@
1616

1717
db = cs50.SQL("sqlite:///database.db")
1818
db.execute("BEGIN")
19+
20+
db.execute("""CREATE TABLE 'problems_migration' (
21+
'id' varchar(64) NOT NULL UNIQUE,
22+
'name' varchar(256) NOT NULL,
23+
'point_value' integer NOT NULL DEFAULT(0),
24+
'category' varchar(64),
25+
'flag' varchar(256) NOT NULL,
26+
'status' integer NOT NULL DEFAULT(0),
27+
'flag_hint' varchar(256) NOT NULL DEFAULT(''),
28+
'instanced' boolean NOT NULL DEFAULT(0)
29+
)""")
30+
db.execute("INSERT INTO problems_migration SELECT id, name, point_value, category, flag, draft, flag_hint, instanced FROM problems")
31+
db.execute("DROP TABLE problems")
32+
db.execute("ALTER TABLE problems_migration RENAME TO problems")
33+
34+
db.execute("""CREATE TABLE 'contest_problems_migration' (
35+
'contest_id' varchar(32) NOT NULL,
36+
'problem_id' varchar(64) NOT NULL,
37+
'name' varchar(256) NOT NULL,
38+
'point_value' integer NOT NULL DEFAULT(0),
39+
'category' varchar(64),
40+
'flag' varchar(256) NOT NULL,
41+
'status' integer NOT NULL DEFAULT(0),
42+
'score_min' integer NOT NULL DEFAULT(0),
43+
'score_max' integer NOT NULL DEFAULT(0),
44+
'score_users' integer NOT NULL DEFAULT(-1),
45+
'flag_hint' varchar(256) NOT NULL DEFAULT(''),
46+
'instanced' boolean NOT NULL DEFAULT(0),
47+
UNIQUE(contest_id, problem_id) ON CONFLICT ABORT
48+
)""")
49+
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")
50+
db.execute("DROP TABLE contest_problems")
51+
db.execute("ALTER TABLE contest_problems_migration RENAME TO contest_problems")
52+
1953
db.execute("COMMIT")
2054

2155
with open('settings.py', 'a') as f:

src/schema.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ CREATE TABLE 'problems' (
3232
'point_value' integer NOT NULL DEFAULT(0),
3333
'category' varchar(64),
3434
'flag' varchar(256) NOT NULL,
35-
'draft' boolean NOT NULL DEFAULT(0),
35+
'status' integer NOT NULL DEFAULT(0), -- see helpers.py
3636
'flag_hint' varchar(256) NOT NULL DEFAULT(''),
3737
'instanced' boolean NOT NULL DEFAULT(0)
3838
);
@@ -75,7 +75,7 @@ CREATE TABLE 'contest_problems' (
7575
'point_value' integer NOT NULL DEFAULT(0),
7676
'category' varchar(64),
7777
'flag' varchar(256) NOT NULL,
78-
'draft' boolean NOT NULL DEFAULT(0),
78+
'status' integer NOT NULL DEFAULT(0), -- 0: published, 1: draft
7979
'score_min' integer NOT NULL DEFAULT(0),
8080
'score_max' integer NOT NULL DEFAULT(0),
8181
'score_users' integer NOT NULL DEFAULT(-1),

src/templates/contest/contest_problem.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ <h3 class="card-title">Live Instance</h3>
163163
</a>
164164
<br><a href="{{ request.path }}/edit">Edit problem</a>
165165
<br><a href="{{ request.path }}/download">Download problem</a>
166-
{% if data["draft"] %}
166+
{% if data["status"] == 1 %}
167167
<br><a href="#" id="btn-publish" onclick="">
168168
Publish problem
169169
</a>
@@ -201,7 +201,7 @@ <h3 class="card-title">Live Instance</h3>
201201
document.getElementById("hint").classList.toggle("hidden");
202202
});
203203
</script>
204-
{% if data["draft"] %}
204+
{% if data["status"] == 1 %}
205205
<script>
206206
document.getElementById("btn-publish").addEventListener("click", function() {
207207
document.getElementById("confirm").style.display = "block";
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
{% extends "layout.html" %}
2+
3+
{% block title %}Archived Problems{% endblock %}
4+
{% block active %}Practice{% endblock %}
5+
6+
{% block main %}
7+
<h1>Archived Problems</h1>
8+
<div class="alert alert-warning alert-dismissible fade show" role="alert">
9+
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.
10+
They may also be archived because they are too low quality, but users who have solved them previously still retain the points.
11+
<button type="button"
12+
class="btn-close"
13+
data-bs-dismiss="alert"
14+
aria-label="Close"></button>
15+
</div>
16+
<div id="pagination" style="display: inline-block; min-height: 42px" data-pages="{{ length }}"></div>
17+
<div id="category-selector-container">
18+
<label for="category-selector">Category</label>
19+
<select class="form-control form-select"
20+
id="category-selector"
21+
onchange="selectCategory(this.value)">
22+
<option value="">All</option>
23+
<option disabled>----------</option>
24+
{% for category in categories %}
25+
{% if selected == category['category'] %}
26+
<option selected>{{ category['category'] }}</option>
27+
{% else %}
28+
<option>{{ category['category'] }}</option>
29+
{% endif %}
30+
{% endfor %}
31+
</select>
32+
</div>
33+
<div class="mb-1"><a href="/problems">Back to unarchived problems</a></div>
34+
<div style="overflow-x: auto;">
35+
<table class="table table-hover table-full-width">
36+
<thead class="table-dark">
37+
<tr>
38+
<th scope="col" style="width: 10%;">Status</th>
39+
<th scope="col" style="width: 60%;">Name</th>
40+
<th scope="col" style="width: 10%;">Category</th>
41+
<th scope="col" style="width: 10%;">Value</th>
42+
<th scope="col" style="width: 10%;">Users</th>
43+
</tr>
44+
</thead>
45+
<tbody>
46+
{% for row in data %}
47+
<tr>
48+
<td>
49+
{% if row["id"] in solved %}
50+
<img class="svg-green icon"
51+
src="/assets/images/check.svg"
52+
alt="Solved"
53+
onerror="this.src='/assets/images/check.png'">
54+
{% else %}
55+
<img class="svg-red icon"
56+
src="/assets/images/times.svg"
57+
alt="Solved"
58+
onerror="this.src='/assets/images/times.png'">
59+
{% endif %}
60+
</td>
61+
<td><a href="/problem/{{ row["id"] }}">{{ row["name"] }}</a></td>
62+
<td>{{ row["category"] }}</td>
63+
<td>{{ row["point_value"] }}</td>
64+
{% if check_perm(["ADMIN", "SUPERADMIN", "CONTENT_MANAGER"]) %}
65+
<td><a href="/admin/submissions?problem_id={{ row['id'] }}&correct=AC">
66+
{{ row["sols"] }}
67+
</a></td>
68+
{% else %}
69+
<td>{{ row["sols"] }}</td>
70+
{% endif %}
71+
</tr>
72+
{% endfor %}
73+
</tbody>
74+
</table>
75+
</div>
76+
<script>
77+
document.getElementById("category-selector").addEventListener("change", function() {
78+
let value = this.value;
79+
let kvp = document.location.search.substr(1).split('&');
80+
let i = 0;
81+
for (; i < kvp.length; i++) {
82+
if (kvp[i].startsWith('category=')) {
83+
let pair = kvp[i].split('=');
84+
pair[1] = value;
85+
kvp[i] = pair.join('=');
86+
break;
87+
}
88+
}
89+
90+
if (i >= kvp.length) {
91+
kvp[kvp.length] = ["category", value].join('=');
92+
}
93+
for (let i = 0; i < kvp.length; i++) {
94+
if (kvp[i].startsWith('page')) {
95+
let pair = kvp[i].split('=');
96+
pair[1] = "1";
97+
kvp[i] = pair.join('=');
98+
break;
99+
}
100+
}
101+
document.location.search = kvp.join('&');
102+
});
103+
</script>
104+
{% endblock %}

src/templates/problem/problem.html

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ <h1>
2020
<form method="post" style="margin-bottom: 1rem;" action="">
2121
<input class="btn btn-danger" type="submit" value="">
2222
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
23+
<input type="hidden" name="status" id="confirm-status" value=""/>
2324
</form>
2425
</div>
2526
{% endif %}
@@ -165,9 +166,12 @@ <h3 class="card-title">Live Instance</h3>
165166
<br><a href="{{ request.path }}/edit">Edit problem</a>
166167
<br><a href="{{ request.path }}/download">Download problem</a>
167168
<br><a href="#" class="btn-delete">Delete problem</a>
168-
{% if data["draft"] %}
169-
<br><a href="#" class="btn-publish">Publish draft</a>
170-
{% endif %}
169+
<br>Change problem type to <select id="select-status">
170+
<option disabled selected>----------</option>
171+
{% for k, v in stats.items() %}
172+
<option>{{ k }}</option>
173+
{% endfor %}
174+
</select>
171175
{% endif %}
172176
</div>
173177
</div>
@@ -205,17 +209,14 @@ <h3 class="card-title">Live Instance</h3>
205209
.setAttribute("value", "Are you sure you want to delete this problem? " +
206210
"Click here to confirm.");
207211
});
208-
</script>
209-
{% endif %}
210-
{% if data["draft"] %}
211-
<script>
212-
document.querySelector(".btn-publish").addEventListener("click", function () {
212+
document.querySelector("#select-status").addEventListener("change", function (e) {
213213
document.getElementById("confirm").style.display = "";
214214
document.querySelector("#confirm form")
215-
.setAttribute("action", window.location.pathname + "/publish");
215+
.setAttribute("action", window.location.pathname + "/changestat");
216+
document.querySelector("#confirm-status").value = e.target.value;
216217
document.querySelector("#confirm form .btn")
217-
.setAttribute("value", "Are you sure you want to publish this problem? " +
218-
"Click here to confirm.");
218+
.setAttribute("value", "Are you sure you want to make this problem " +
219+
e.target.value + "? Click here to confirm.");
219220
});
220221
</script>
221222
{% endif %}

src/templates/problem/problems.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ <h1>Problems</h1>
3232
{% endfor %}
3333
</select>
3434
</div>
35+
<div class="mb-1"><a href="/problems/archived">View archived problems</a></div>
3536
<div style="overflow-x: auto;">
3637
<table class="table table-hover table-full-width">
3738
<thead class="table-dark">

src/tests/test_dl.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,9 @@ def test_dl(client, database):
108108
}, follow_redirects=True)
109109
assert result.status_code == 200
110110

111-
result = client.post('/problem/helloworldtesting/publish', follow_redirects=True)
111+
result = client.post('/problem/helloworldtesting/changestat', data={
112+
'status': 'PUBLISHED'
113+
}, follow_redirects=True)
112114
assert result.status_code == 200
113115

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

0 commit comments

Comments
 (0)