Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions oioioi/contests/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,34 @@ def export_entries(registry, values):
return result


STATUS_CLASSES = {
"OK100": "badge-success",
"OK": "badge-success",
"INI_OK": "badge-success",
"TESTRUN_OK": "badge-success",
"OK75": "badge-warning",
"OK50": "badge-warning",
"OK25": "badge-danger",
"OK0": "badge-danger",
"ERR": "badge-danger",
"WA": "badge-danger",
"TLE": "badge-danger",
"RE": "badge-danger",
"CE": "badge-danger",
"MLE": "badge-danger",
"OLE": "badge-danger",
"SE": "badge-danger",
"RV": "badge-danger",
"INI_ERR": "badge-danger",
"MSE": "badge-danger",
"MCE": "badge-danger",
}


def get_badge_class(display_type):
return STATUS_CLASSES.get(display_type, "badge-secondary")


def submission_template_context(request, submission):
pi = submission.problem_instance
controller = pi.controller
Expand Down Expand Up @@ -108,12 +136,15 @@ def submission_template_context(request, submission):
else:
display_type = submission.status

badge_class = get_badge_class(display_type)

return {
"submission": submission,
"can_see_status": can_see_status,
"can_see_score": can_see_score,
"can_see_comment": can_see_comment,
"display_type": display_type,
"badge_class": badge_class,
"message": message,
"link": link,
"valid_kinds_for_submission": valid_kinds_for_submission,
Expand Down
10 changes: 9 additions & 1 deletion oioioi/contests/templates/contests/problems_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
{% load i18n %}
{% load pagination_tags %}
{% load format_data_range %}
{% load humanize %}

{% block styles %}
{{ block.super }}
Expand Down Expand Up @@ -80,7 +81,7 @@ <h1>{% trans "Problems" %}</h1>
</tr>
</thead>
<tbody>
{% for pi, statement_visible, round_time, problem_limits, result, submissions_left, submissions_limit, can_submit in problem_instances %}
{% for pi, statement_visible, round_time, problem_limits, result, submissions_left, submissions_limit, can_submit, last_submission in problem_instances %}
{% if show_rounds %}
{% ifchanged pi.round %}
<tr class="problemlist-subheader">
Expand All @@ -100,6 +101,13 @@ <h1>{% trans "Problems" %}</h1>
{% else %}
{{ pi.problem.name }}
{% endif %}
{% if show_status %}
{% if last_submission and last_submission.can_see_status %}
<a href="{{ last_submission.link }}">
<span class="badge {{ last_submission.badge_class }}" title="{{ last_submission.submission.date|naturaltime }} ({{ last_submission.submission.date|date:'Y-m-d H:i:s' }})">{{ last_submission.message }}</span>
</a>
{% endif %}
{% endif %}
</td>
{% if show_problems_limits %}
<td class="text-right">
Expand Down
122 changes: 122 additions & 0 deletions oioioi/contests/tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3211,7 +3211,7 @@
for problem in ProblemInstance.objects.filter(contest__id="c2"):
self.assertEqual(problem.submissions_limit, 123)

for problem in Problem.objects.all():

Check failure on line 3214 in oioioi/contests/tests/tests.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "select_related" or "prefetch_related" to avoid additional queries inside this loop.

See more on https://sonarcloud.io/project/issues?id=sio2project_oioioi&issues=AZzpBzMkt5t5DmIVaFWF&open=AZzpBzMkt5t5DmIVaFWF&pullRequest=606
for test in problem.main_problem_instance.test_set.all():
test.delete()
self.assertTrue(Test.objects.count() > 0)
Expand Down Expand Up @@ -3858,6 +3858,128 @@
self.assertNotContains(response, expected_hyperlink)


class TestStatusOnProblemsList(TestCase):
fixtures = [
"test_users",
"test_contest",
"test_full_package",
"test_problem_instance",
"test_submission",
]

def test_status_hidden_for_anonymous(self):
see_status_on_problems_list(self, None, False)

def test_status_visible_for_user(self):
see_status_on_problems_list(self, "test_user", True, expected_status="OK")

def test_status_hidden_for_admin(self):
see_status_on_problems_list(self, "test_admin", False)


def see_status_on_problems_list(self, username, should_see, expected_status=None):
if username is None:
self.client.logout()
else:
self.assertTrue(self.client.login(username=username))

contest = Contest.objects.get(pk="c")
problems_url = reverse("problems_list", kwargs={"contest_id": contest.id})

response = self.client.get(problems_url, follow=True)
# badge presence
if should_see:
if expected_status:
self.assertContains(response, expected_status)
else:
# No status header for anonymous users
self.assertNotContains(response, '<th class="text-right">Status</th>')


Comment thread
m4teuk marked this conversation as resolved.
class TestStatusBadgeDoesNotLeakScore(TestCase):
"""Verify that the last-submission badge on the problems list reflects
the *initial* status, not the final score, when the contest hides scores.

ProgrammingContestController always exposes submission status
(can_see_submission_status -> True) but hides the score until
results_date. The badge colour must follow the status, never the
score.
"""

fixtures = [
"test_users",
"test_contest",
"test_full_package",
"test_problem_instance",
]

def _set_future_results_date(self):
"""Push results_date into the future so scores stay hidden."""
Round.objects.filter(pk=1).update(
results_date=datetime(2099, 1, 1, tzinfo=UTC)
)

def _create_submission_with_score(self, status, score_value, max_score_value):
"""Create a submission for test_user with given status and score.

Reports are set up so that the score-based display_type path
*would* produce a different badge if the visibility check were
ever bypassed — this is what makes the test meaningful.
"""
from oioioi.contests.models import ScoreReport, SubmissionReport

user = User.objects.get(username="test_user")
pi = ProblemInstance.objects.get(pk=1)

sub = Submission.objects.create(
problem_instance=pi,
user=user,
status=status,
score=IntegerScore(score_value),
kind="NORMAL",
date=datetime(2012, 7, 20, tzinfo=UTC),
)

report = SubmissionReport.objects.create(
submission=sub, status="ACTIVE", kind="INITIAL"
)
ScoreReport.objects.create(
submission_report=report,
status="OK",
score=IntegerScore(score_value),
max_score=IntegerScore(max_score_value),
)
return sub

def test_ini_ok_zero_points_badge_is_success(self):
"""INI_OK + 0 pts: badge must be green (status), not red (score)."""
self._set_future_results_date()
self._create_submission_with_score("INI_OK", 0, 100)

self.assertTrue(self.client.login(username="test_user"))
url = reverse("problems_list", kwargs={"contest_id": "c"})

with fake_time(datetime(2012, 8, 5, tzinfo=UTC)):
response = self.client.get(url, follow=True)

self.assertContains(response, "badge-success")
self.assertNotContains(response, "badge-danger")

def test_ini_err_full_points_badge_is_danger(self):
"""INI_ERR + 100 pts: badge must be red (status), not green (score)."""
self._set_future_results_date()
self._create_submission_with_score("INI_ERR", 100, 100)

self.assertTrue(self.client.login(username="test_user"))
url = reverse("problems_list", kwargs={"contest_id": "c"})

with fake_time(datetime(2012, 8, 5, tzinfo=UTC)):
response = self.client.get(url, follow=True)

self.assertContains(response, "badge-danger")
self.assertNotContains(response, "badge-success")


class TestSubmissionsLinksOnListView(TestCase):
fixtures = [
"test_users",
Expand Down Expand Up @@ -4322,19 +4444,19 @@
check_registration(self, 403, "NO")

def test_configured_registration_opened(self):
now = datetime.utcnow()

Check failure on line 4447 in oioioi/contests/tests/tests.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Don't use `datetime.datetime.utcnow` to create this datetime object.

See more on https://sonarcloud.io/project/issues?id=sio2project_oioioi&issues=AZzpBzMkt5t5DmIVaFWG&open=AZzpBzMkt5t5DmIVaFWG&pullRequest=606
available_from = now - timedelta(days=1)
available_to = now + timedelta(days=1)
check_registration(self, 200, "CONFIG", available_from, available_to)

def test_configured_registration_closed_before(self):
now = datetime.utcnow()

Check failure on line 4453 in oioioi/contests/tests/tests.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Don't use `datetime.datetime.utcnow` to create this datetime object.

See more on https://sonarcloud.io/project/issues?id=sio2project_oioioi&issues=AZzpBzMkt5t5DmIVaFWH&open=AZzpBzMkt5t5DmIVaFWH&pullRequest=606
available_from = now + timedelta(hours=1)
available_to = now + timedelta(days=1)
check_registration(self, 200, "CONFIG", available_from, available_to, "registration_not_open_yet")

def test_configured_registration_closed_after(self):
now = datetime.utcnow()

Check failure on line 4459 in oioioi/contests/tests/tests.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Don't use `datetime.datetime.utcnow` to create this datetime object.

See more on https://sonarcloud.io/project/issues?id=sio2project_oioioi&issues=AZzpBzMkt5t5DmIVaFWI&open=AZzpBzMkt5t5DmIVaFWI&pullRequest=606
available_from = now - timedelta(hours=1)
available_to = now - timedelta(minutes=5)
check_registration(self, 403, "CONFIG", available_from, available_to)
Expand Down
57 changes: 44 additions & 13 deletions oioioi/contests/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.contrib import messages
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied, SuspiciousOperation
from django.db.models import Q
from django.db.models import OuterRef, Q, Subquery
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
Expand Down Expand Up @@ -154,28 +154,57 @@ def problems_list_view(request):
# 6) submissions_limit
# 7) can_submit
# Sorted by (start_date, end_date, round name, problem name)
# Preload user-related data to avoid N+1 queries
results_map = {}
last_submission_map = {}
if request.user.is_authenticated:
# Bulk fetch UserResultForProblem objects. We only keep those for which
# the user can see the submission score.
user_results_qs = (
UserResultForProblem.objects.filter(
user=request.user,
problem_instance__in=problem_instances,
)
.select_related("submission_report__submission")
)
for r in user_results_qs:
# Some controllers may hide score even if UserResultForProblem exists
if r and r.submission_report and controller.can_see_submission_score(request, r.submission_report.submission):
results_map[r.problem_instance_id] = r

# For each problem instance, fetch only the single latest NORMAL
# submission by this user using a correlated subquery
latest_sub_id_sq = (
Submission.objects.filter(
user=request.user,
problem_instance=OuterRef("problem_instance"),
kind="NORMAL",
)
.order_by("-date")
.values("id")[:1]
)
last_submission_map = {
s.problem_instance_id: s
for s in Submission.objects.filter(
user=request.user,
problem_instance__in=problem_instances,
kind="NORMAL",
id=Subquery(latest_sub_id_sq),
)
}

problems_statements = sorted(
[
(
pi,
controller.can_see_statement(request, pi),
controller.get_round_times(request, pi.round),
problems_limits.get(pi.pk, None),
# Because this view can be accessed by an anynomous user we can't
# use `user=request.user` (it would cause TypeError). Surprisingly
# using request.user.id is ok since for AnynomousUser id is set
# to None.
next(
(
r
for r in UserResultForProblem.objects.filter(user__id=request.user.id, problem_instance=pi)
if r and r.submission_report and controller.can_see_submission_score(request, r.submission_report.submission)
),
None,
),
results_map.get(pi.id),
pi.controller.get_submissions_left(request, pi),
pi.controller.get_submissions_limit(request, pi),
controller.can_submit(request, pi) and not is_contest_archived(request),
submission_template_context(request, last_submission_map[pi.id]) if pi.id in last_submission_map else None,
)
for pi in problem_instances
],
Expand All @@ -185,6 +214,7 @@ def problems_list_view(request):
show_submissions_limit = any(p[6] for p in problems_statements)
show_submit_button = any(p[7] for p in problems_statements)
show_rounds = len(frozenset(pi.round_id for pi in problem_instances)) > 1
show_status = request.user.is_authenticated # Always show status for authenticated users
table_columns = 3 + int(show_problems_limits) + int(show_submissions_limit) + int(show_submit_button)

return TemplateResponse(
Expand All @@ -196,6 +226,7 @@ def problems_list_view(request):
"show_rounds": show_rounds,
"show_scores": request.user.is_authenticated,
"show_submissions_limit": show_submissions_limit,
"show_status": show_status,
"show_submit_button": show_submit_button,
"table_columns": table_columns,
"problems_on_page": getattr(settings, "PROBLEMS_ON_PAGE", 100),
Expand Down
Loading