Skip to content

Commit 45fd7e9

Browse files
More problem operations (#523)
* Add deleting problems * Refactor admin functions for multiple problems * Fix splitting ids arguments into digits issue * Add tests for deleting problems * Add tests for passing invalid ids * Use the built-in version of bootstrap * re_path -> path
1 parent 1d7c23f commit 45fd7e9

File tree

7 files changed

+264
-20
lines changed

7 files changed

+264
-20
lines changed

oioioi/contests/admin.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -382,25 +382,27 @@ class ProblemInstanceAdmin(admin.ModelAdmin):
382382
list_display = ('name_link', 'short_name_link', 'round', 'package', 'actions_field')
383383
readonly_fields = ('contest', 'problem')
384384
ordering = ('-round__start_date', 'short_name')
385-
actions = ['attach_problems_to_another_contest', 'assign_problems_to_a_round']
385+
actions = ['attach_problems_to_another_contest', 'assign_problems_to_a_round', 'delete_problems']
386386

387-
def attach_problems_to_another_contest(self, request, queryset):
387+
def _attach_problem_ids_to_url(self, queryset, url_name):
388+
"""Helper function to create a URL with problem ids as query parameters."""
388389
ids = [problem.id for problem in queryset]
389-
390390
# Attach problem ids as arguments to the URL
391-
base_url = reverse('reattach_problem_contest_list')
391+
base_url = reverse(url_name)
392392
query_string = urlencode({'ids': ','.join(str(i) for i in ids)}, doseq=True)
393+
return '%s?%s' % (base_url, query_string)
393394

394-
return redirect('%s?%s' % (base_url, query_string))
395+
def attach_problems_to_another_contest(self, request, queryset):
396+
return redirect(
397+
self._attach_problem_ids_to_url(queryset, 'reattach_problem_contest_list'))
395398

396399
def assign_problems_to_a_round(self, request, queryset):
397-
ids = [problem.id for problem in queryset]
398-
399-
# Attach problem ids as arguments to the URL
400-
base_url = reverse('assign_problems_to_a_round')
401-
query_string = urlencode({'ids': ','.join(str(i) for i in ids)}, doseq=True)
400+
return redirect(
401+
self._attach_problem_ids_to_url(queryset, 'assign_problems_to_a_round'))
402402

403-
return redirect('%s?%s' % (base_url, query_string))
403+
def delete_problems(self, request, queryset):
404+
return redirect(
405+
self._attach_problem_ids_to_url(queryset, 'delete_problems'))
404406

405407
def __init__(self, *args, **kwargs):
406408
# creating a thread local variable to store the request
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[
2+
{
3+
"pk": 10,
4+
"model": "contests.probleminstance",
5+
"fields": {
6+
"problem": 1,
7+
"round": 2,
8+
"short_name": "zad1",
9+
"contest": "c1"
10+
}
11+
},
12+
{
13+
"pk": 20,
14+
"model": "contests.probleminstance",
15+
"fields": {
16+
"problem": 1,
17+
"round": 3,
18+
"short_name": "zad2"
19+
}
20+
}
21+
]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[
2+
{
3+
"pk": 12345,
4+
"model": "contests.probleminstance",
5+
"fields": {
6+
"problem": 1,
7+
"round": 1,
8+
"short_name": "zad2",
9+
"contest": "c"
10+
}
11+
}
12+
]
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{% extends "base-with-menu.html" %}
2+
{% load i18n %}
3+
4+
{% block title %}{% trans "Confirm deletion" %}{% endblock %}
5+
6+
{% block main-content %}
7+
8+
<h1>{% trans "Confirm deletion" %}</h1>
9+
<p>
10+
{% blocktrans %}
11+
You are going to delete the following problems from the contest
12+
{% endblocktrans %}
13+
<strong> {{ contest.name }} </strong>
14+
<ul class="list-group d-inline-block">
15+
{% for problem in problem_instances %}
16+
<li class="list-group-item">{{ problem }}</li>
17+
{% endfor %}
18+
</ul>
19+
</p>
20+
21+
<button class="btn btn-danger mt-3" data-toggle="modal" data-target="#confirmDeletionModal">
22+
{% trans "Delete" %}
23+
</button>
24+
<button class="btn btn-outline-secondary mt-3" onclick="history.back()">
25+
{% trans "No, go back" %}
26+
</button>
27+
28+
<div class="modal fade" id="confirmDeletionModal" tabindex="-1" aria-labelledby="confirmDeletionLabel" aria-hidden="true">
29+
<div class="modal-dialog">
30+
<div class="modal-content">
31+
<form method="post">
32+
{% csrf_token %}
33+
<div class="modal-header">
34+
<h5 class="modal-title" id="confirmDeletionLabel">{% trans "Confirm Deletion" %}</h5>
35+
</div>
36+
<div class="modal-body">
37+
{% blocktrans %}Are you sure you want to delete the selected problems? This action cannot be undone.{% endblocktrans %}
38+
</div>
39+
<div class="modal-footer">
40+
<button type="button" class="btn btn-secondary" data-dismiss="modal">{% trans "Cancel" %}</button>
41+
<button type="submit" class="btn btn-danger">{% trans "Confirm Deletion" %}</button>
42+
</div>
43+
</form>
44+
</div>
45+
</div>
46+
</div>
47+
{% endblock %}
48+

oioioi/contests/tests/tests.py

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3213,6 +3213,7 @@ class TestReattachingProblems(TestCase):
32133213
'test_extra_problem',
32143214
'test_permissions',
32153215
'test_problem_site',
3216+
'test_problem_with_long_id'
32163217
]
32173218

32183219
def test_reattaching_problem(self):
@@ -3237,10 +3238,12 @@ def test_reattaching_problem(self):
32373238
self.assertContains(response, u'Sum\u017cyce')
32383239
self.assertContains(response, "Attach")
32393240

3241+
prev_problem_count = ProblemInstance.objects.count()
3242+
32403243
response = self.client.post(url, data={'submit': True}, follow=True)
32413244
self.assertEqual(response.status_code, 200)
32423245
self.assertContains(response, 'c2')
3243-
self.assertEqual(ProblemInstance.objects.count(), 3)
3246+
self.assertEqual(ProblemInstance.objects.count(), prev_problem_count + 1)
32443247
self.assertContains(response, ' added successfully.')
32453248
self.assertContains(response, u'Sum\u017cyce')
32463249
self.assertTrue(ProblemInstance.objects.filter(contest__id='c2').exists())
@@ -3279,10 +3282,12 @@ def test_reattaching_problems(self):
32793282
self.assertContains(response, u'Sum\u017cyce')
32803283
self.assertContains(response, "Attach")
32813284

3285+
prev_problem_count = ProblemInstance.objects.count()
3286+
32823287
response = self.client.post(url, data={'submit': True}, follow=True)
32833288
self.assertEqual(response.status_code, 200)
32843289
self.assertContains(response, 'c2')
3285-
self.assertEqual(ProblemInstance.objects.count(), 4)
3290+
self.assertEqual(ProblemInstance.objects.count(), prev_problem_count + 2)
32863291
self.assertContains(response, ' added successfully.')
32873292
self.assertContains(response, u'Sum\u017cyce')
32883293
self.assertTrue(ProblemInstance.objects.filter(contest__id='c2').exists())
@@ -3312,6 +3317,37 @@ def test_permissions(self):
33123317
response = self.client.get(url)
33133318
self.assertEqual(response.status_code, 403)
33143319

3320+
# Makes sure that ids are correctly forwarded in links from
3321+
# reattach_problem_contest_list to reattach_problem_confirm.
3322+
def test_correctly_passing_problem_ids(self):
3323+
self.assertTrue(self.client.login(username='test_admin'))
3324+
pi_id1 = ProblemInstance.objects.get(id=1).id
3325+
pi_id2 = ProblemInstance.objects.get(id=12345).id
3326+
3327+
self.client.get('/c/c/') # 'c' becomes the current contest
3328+
3329+
url = reverse('reattach_problem_contest_list') + "?ids={}".format(pi_id1)
3330+
response = self.client.get(url, follow=True)
3331+
self.assertEqual(response.status_code, 200)
3332+
self.assertContains(response, "c/c/reattach/c/confirm/?ids=1")
3333+
3334+
url = reverse('reattach_problem_contest_list') + "?ids={}".format(pi_id2)
3335+
response = self.client.get(url, follow=True)
3336+
self.assertEqual(response.status_code, 200)
3337+
self.assertContains(response, "c/c/reattach/c/confirm/?ids=12345")
3338+
3339+
url = reverse('reattach_problem_contest_list') + "?ids={},{}".format(pi_id1, pi_id2)
3340+
response = self.client.get(url, follow=True)
3341+
self.assertEqual(response.status_code, 200)
3342+
self.assertContains(response, "c/c/reattach/c/confirm/?ids=1%2C12345")
3343+
3344+
url = reverse('reattach_problem_contest_list') + "?ids={},{}".format(pi_id2, pi_id1)
3345+
response = self.client.get(url, follow=True)
3346+
self.assertEqual(response.status_code, 200)
3347+
self.assertContains(response, "c/c/reattach/c/confirm/?ids=12345%2C1")
3348+
3349+
3350+
33153351
# Testing whether a user with admin permission for one contest
33163352
# can manage problems belonging to another contest using
33173353
# the problem manager interface.
@@ -3332,6 +3368,7 @@ def test_managing(self):
33323368
reverse('reattach_problem_contest_list') + "?ids={}".format(problem_id),
33333369
reverse('reattach_problem_confirm', args=('available_contest',)) + "?ids={}".format(problem_id),
33343370
reverse('assign_problems_to_a_round') + "?ids={}".format(problem_id),
3371+
reverse('delete_problems') + "?ids={}".format(problem_id),
33353372
]
33363373

33373374
for url in get_urls:
@@ -3341,6 +3378,7 @@ def test_managing(self):
33413378
post_urls_and_data = [
33423379
(reverse('reattach_problem_confirm', args=('available_contest',)) + "?ids={}".format(problem_id), {}),
33433380
(reverse('assign_problems_to_a_round') + "?ids={}".format(problem_id), {'round': 100}),
3381+
(reverse('delete_problems') + "?ids={}".format(problem_id), {}),
33443382
]
33453383

33463384
for url, data in post_urls_and_data:
@@ -3554,6 +3592,82 @@ def test_permissions(self):
35543592
Contest.objects.get(id='one-round').id
35553593
)
35563594

3595+
class TestDeletingProblems(TestCase):
3596+
fixtures = [
3597+
'test_users',
3598+
'test_contest',
3599+
'test_full_package',
3600+
'test_problem_instance',
3601+
'test_extra_problem',
3602+
'test_permissions',
3603+
'test_problem_site',
3604+
'test_extra_contests',
3605+
'test_problem_instance_with_and_without_contests',
3606+
]
3607+
3608+
def test_deleting_problem(self):
3609+
pi_id = ProblemInstance.objects.get(id=1).id
3610+
3611+
self.assertTrue(self.client.login(username='test_admin'))
3612+
self.client.get('/c/c/') # 'c' becomes the current contest
3613+
3614+
url = reverse('delete_problems') + "?ids={}".format(pi_id)
3615+
response = self.client.get(url, follow=True)
3616+
3617+
self.assertEqual(response.status_code, 200)
3618+
self.assertContains(response, "Are you sure you want to delete the selected problems?")
3619+
self.assertContains(response, ProblemInstance.objects.get(id=pi_id).problem.name)
3620+
3621+
response = self.client.post(url, data={}, follow=True)
3622+
self.assertEqual(response.status_code, 200)
3623+
self.assertContains(response, "Problems deleted successfully")
3624+
self.assertFalse(ProblemInstance.objects.filter(id=pi_id).exists())
3625+
3626+
def test_deleting_problems(self):
3627+
pi_id1 = ProblemInstance.objects.get(id=1).id
3628+
pi_id2 = ProblemInstance.objects.get(id=2).id
3629+
3630+
self.assertTrue(self.client.login(username='test_admin'))
3631+
self.client.get('/c/c/') # 'c' becomes the current contest
3632+
3633+
url = reverse('delete_problems') + "?ids={}%2C{}".format(pi_id1, pi_id2)
3634+
response = self.client.get(url, follow=True)
3635+
3636+
self.assertEqual(response.status_code, 200)
3637+
self.assertContains(response, "Are you sure you want to delete the selected problems?")
3638+
self.assertContains(response, ProblemInstance.objects.get(id=pi_id1).problem.name)
3639+
self.assertContains(response, ProblemInstance.objects.get(id=pi_id2).problem.name)
3640+
3641+
response = self.client.post(url, data={}, follow=True)
3642+
self.assertEqual(response.status_code, 200)
3643+
self.assertContains(response, "Problems deleted successfully")
3644+
self.assertFalse(ProblemInstance.objects.filter(id=pi_id1).exists())
3645+
self.assertFalse(ProblemInstance.objects.filter(id=pi_id2).exists())
3646+
3647+
def test_bad_problem_ids(self):
3648+
self.assertTrue(self.client.login(username='test_admin'))
3649+
3650+
self.client.get('/c/c/') # 'c' becomes the current contest
3651+
3652+
# Non-existent problem id
3653+
url = reverse('assign_problems_to_a_round') + "?ids={}".format(30)
3654+
response = self.client.get(url, follow=True)
3655+
self.assertEqual(response.status_code, 400)
3656+
3657+
# Non-numeric problem id
3658+
url = reverse('assign_problems_to_a_round') + "?ids=A,30"
3659+
response = self.client.get(url, follow=True)
3660+
self.assertEqual(response.status_code, 400)
3661+
3662+
# ProblemInstance which belongs to another contest
3663+
url = reverse('assign_problems_to_a_round') + "?ids={}".format(10)
3664+
response = self.client.get(url, follow=True)
3665+
self.assertEqual(response.status_code, 400)
3666+
3667+
# ProblemInstance which does not belong to any contest
3668+
url = reverse('assign_problems_to_a_round') + "?ids={}".format(20)
3669+
response = self.client.get(url, follow=True)
3670+
self.assertEqual(response.status_code, 400)
35573671

35583672
class TestModifyContest(TestCase):
35593673
fixtures = ['test_users']

oioioi/contests/urls.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,13 @@ def glob_namespaced_patterns(namespace):
158158
re_path(r'^admin/', admin.contest_site.urls),
159159
path('archive/confirm', views.confirm_archive_contest, name='confirm_archive_contest'),
160160
path('unarchive/', views.unarchive_contest, name='unarchive_contest'),
161-
re_path(
162-
r'^assign_problems_to_a_round/$',
161+
path('assign_problems_to_a_round/',
163162
views.assign_problems_to_a_round_view,
164163
name='assign_problems_to_a_round',
164+
),
165+
path('delete_problems/',
166+
views.delete_problems_confirm_view,
167+
name='delete_problems',
165168
)
166169
]
167170

oioioi/contests/views.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -866,7 +866,7 @@ def reattach_problem_contest_list_view(request, full_list=False):
866866
'problem_instances': problem_instances,
867867
'contest_list': contests,
868868
'full_list': full_list,
869-
'problem_ids': '%2C'.join(str(i) for i in problem_ids), # Separate the problem ids with a comma (%2C)
869+
'problem_ids': '%2C'.join(str(i) for i in problem_ids.split(',')), # Separate the problem ids with a comma (%2C)
870870
},
871871
)
872872

@@ -946,6 +946,7 @@ def assign_problems_to_a_round_view(request):
946946
if not request.contest:
947947
raise SuspiciousOperation("Invalid contest")
948948

949+
# Check if the problem instances belong to the contest in the request
949950
if not _check_if_problem_instances_belong_to_contest(problem_instances, request.contest.id):
950951
raise SuspiciousOperation("Invalid problem instances")
951952

@@ -954,10 +955,6 @@ def assign_problems_to_a_round_view(request):
954955
messages.error(request, _("The contest has no rounds."))
955956
return redirect('oioioiadmin:contests_probleminstance_changelist')
956957

957-
# Check if the problem instances belong to the contest in the request
958-
if not all(pi.contest and pi.contest.id == request.contest.id for pi in problem_instances):
959-
raise SuspiciousOperation("Invalid problem instances")
960-
961958
if request.method == 'POST':
962959
form = RoundSelectionForm(request.POST, contest=request.contest)
963960
# Round is optional in the form, so we need to check if it is selected
@@ -993,6 +990,53 @@ def assign_problems_to_a_round_view(request):
993990
},
994991
)
995992

993+
@enforce_condition(contest_exists & is_contest_basicadmin)
994+
def delete_problems_confirm_view(request):
995+
"""
996+
Handles the assignment of problem instances to a specific round within a contest.
997+
This view retrieves problem instances based on the provided IDs in the request,
998+
validates their association with the current contest, and allows the user to assign
999+
them to a specific round within the contest. If the assignment is successful, the
1000+
user is redirected to the problem instance changelist page.
1001+
Args:
1002+
request (HttpRequest): The HTTP request object containing GET or POST data.
1003+
Raises:
1004+
SuspiciousOperation: If the contest in the request is invalid, or if the problem
1005+
instances or selected round do not belong to the contest.
1006+
"""
1007+
1008+
problem_ids = request.GET.get('ids')
1009+
1010+
# Get the problems instances from the request
1011+
problem_instances = _get_problem_instances_from_problem_ids(problem_ids)
1012+
1013+
if not request.contest:
1014+
raise SuspiciousOperation("Invalid contest")
1015+
1016+
if not _check_if_problem_instances_belong_to_contest(problem_instances, request.contest.id):
1017+
raise SuspiciousOperation("Invalid problem instances")
1018+
1019+
if request.method == 'POST':
1020+
for problem_instance in problem_instances:
1021+
problem_instance.delete()
1022+
messages.success(request, _("Problems deleted successfully."))
1023+
return safe_redirect(
1024+
request,
1025+
reverse(
1026+
'oioioiadmin:contests_probleminstance_changelist',
1027+
kwargs={'contest_id': request.contest.id},
1028+
),
1029+
)
1030+
1031+
return TemplateResponse(
1032+
request,
1033+
'contests/delete_problems_confirm.html',
1034+
{
1035+
'problem_instances': problem_instances,
1036+
},
1037+
)
1038+
1039+
9961040
@enforce_condition(contest_exists & is_contest_basicadmin)
9971041
def confirm_archive_contest(request):
9981042
if request.method == 'POST':

0 commit comments

Comments
 (0)