diff --git a/oioioi/contests/admin.py b/oioioi/contests/admin.py index f39f094c4..53a2386f3 100644 --- a/oioioi/contests/admin.py +++ b/oioioi/contests/admin.py @@ -200,7 +200,7 @@ class ContestLinkInline(admin.TabularInline): class ContestAdmin(admin.ModelAdmin): inlines = [RoundInline, AttachmentInline, ContestLinkInline] - readonly_fields = ['creation_date'] + readonly_fields = ['creation_date', 'school_year'] prepopulated_fields = {'id': ('name',)} list_display = ['name', 'id', 'creation_date'] list_display_links = ['id', 'name'] @@ -218,6 +218,7 @@ def get_fields(self, request, obj=None): fields = [ 'name', 'id', + 'school_year', 'controller_name', 'default_submissions_limit', 'contact_email' diff --git a/oioioi/contests/forms.py b/oioioi/contests/forms.py index 982c08065..c6145a5e1 100644 --- a/oioioi/contests/forms.py +++ b/oioioi/contests/forms.py @@ -1,6 +1,7 @@ import json from django import forms +from django.core.validators import RegexValidator from django.contrib.admin import widgets from django.contrib.auth.models import User from django.forms import ValidationError @@ -31,7 +32,7 @@ class Meta(object): # form should not be on the 'name' field, otherwise the 'id' field, # as prepopulated with 'name' in ContestAdmin model, is cleared by # javascript with prepopulated fields functionality. - fields = ['controller_name', 'name', 'id'] + fields = ['controller_name', 'name', 'id', 'school_year'] start_date = forms.SplitDateTimeField( label=_("Start date"), widget=widgets.AdminSplitDateTime() @@ -43,6 +44,23 @@ class Meta(object): required=False, label=_("Results date"), widget=widgets.AdminSplitDateTime() ) + def validate_years(year): + year1 = int(year[:4]) + year2 = int(year[5:]) + if year1+1 != year2: + raise ValidationError("The selected years must be consecutive.") + + school_year = forms.CharField( + required=False, label=_("School year"), validators=[ + RegexValidator( + regex=r'^[0-9]{4}[/][0-9]{4}$', + message="Enter a valid school year in the format 2021/2022.", + code="invalid_school_year", + ), + validate_years, + ] + ) + def _generate_default_dates(self): now = timezone.now() self.initial['start_date'] = now diff --git a/oioioi/contests/migrations/0020_contest_school_year.py b/oioioi/contests/migrations/0020_contest_school_year.py new file mode 100644 index 000000000..c97b06915 --- /dev/null +++ b/oioioi/contests/migrations/0020_contest_school_year.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.19 on 2025-04-09 14:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contests', '0019_submissionmessage'), + ] + + operations = [ + migrations.AddField( + model_name='contest', + name='school_year', + field=models.CharField(default='', max_length=10, verbose_name='school year'), + ), + ] diff --git a/oioioi/contests/models.py b/oioioi/contests/models.py index 7f7a85f17..e0837c298 100644 --- a/oioioi/contests/models.py +++ b/oioioi/contests/models.py @@ -108,6 +108,9 @@ class Contest(models.Model): verbose_name=_("is archived"), default=False ) + school_year = models.CharField( + max_length=10, verbose_name=_("school year"), default="" + ) # Part of szkopul backporting. # This is a hack for situation where contest controller is empty, diff --git a/oioioi/contests/templates/contests/select_contest.html b/oioioi/contests/templates/contests/select_contest.html index 02c0bc03e..8baf7bd4b 100644 --- a/oioioi/contests/templates/contests/select_contest.html +++ b/oioioi/contests/templates/contests/select_contest.html @@ -8,7 +8,30 @@ <article> <h1>{% trans "Select contest" %}</h1> - <div class="table-responsive-md"> + <div style="width: 20%; margin-bottom: 1rem;"> + <form method="GET" action="{% url 'filter_contests' filter_value='PLACEHOLDER' %}" id="filter_form"> + <div class="input-group"> + <input type="text" id="filter_input" class="form-control search-query" style="width: 20%;" placeholder="Search" name="filter_field" value="{{ filter }}"> + <span class="input-group-btn"> + <button type="submit" class="btn btn-outline-secondary " name="submit_button"> <i class="fa-solid fa-magnifying-glass"> </i> </button> + </span> + </div> + </form> + <script> + document.getElementById('filter_form').addEventListener('submit', function(event) { + event.preventDefault(); + let filterValue = document.getElementById('filter_input').value; + let actionUrl = ""; + if(filterValue == "") { + actionUrl = "{% url 'select_contest'%}"; + } else { + actionUrl = "{% url 'filter_contests' filter_value='PLACEHOLDER' %}".replace('PLACEHOLDER', encodeURIComponent(filterValue)); + } + window.location.href = actionUrl; + }); + </script> + </div> + <div class="table-responsive-md"> <table class="table"> <thead> <tr> diff --git a/oioioi/contests/tests/tests.py b/oioioi/contests/tests/tests.py index a6c4fd587..9f6f950bb 100755 --- a/oioioi/contests/tests/tests.py +++ b/oioioi/contests/tests/tests.py @@ -4499,3 +4499,30 @@ def test_score_badge(self): self.assertIn('badge-success', self._get_badge_for_problem(response.content, 'zad1')) self.assertIn('badge-warning', self._get_badge_for_problem(response.content, 'zad2')) self.assertIn('badge-danger', self._get_badge_for_problem(response.content, 'zad3')) + +class TestContestListFiltering(TestCase): + fixtures = [ + 'test_contest', + 'test_extra_contests', + ] + + def setUp(self): + self.c = Contest.objects.get(id='c') + self.c1 = Contest.objects.get(id='c1') + self.c2 = Contest.objects.get(id='c2') + + return super().setUp() + + def test_simple_filter(self): + self.url = reverse('filter_contests', kwargs={'filter_value':'test'}) + response = self.client.get(self.url, follow=True) + self.assertContains(response, self.c.name) + self.assertContains(response, self.c1.name) + self.assertContains(response, self.c2.name) + + def extra_filter(self): + self.url = reverse('filter_contests', kwargs={'filter_value':'ExTrA'}) + response = self.client.get(self.url, follow=True) + self.assertNotContains(response, self.c.name) + self.assertContains(response, self.c1.name) + self.assertContains(response, self.c2.name) diff --git a/oioioi/contests/urls.py b/oioioi/contests/urls.py index 8dad6c635..173145c86 100644 --- a/oioioi/contests/urls.py +++ b/oioioi/contests/urls.py @@ -193,6 +193,11 @@ def glob_namespaced_patterns(namespace): views.reattach_problem_confirm_view, name='reattach_problem_confirm', ), + re_path( + r'^contest/query/(?P<filter_value>.+)/$', + views.filter_contests_view, + name='filter_contests', + ), ] if settings.USE_API: diff --git a/oioioi/contests/utils.py b/oioioi/contests/utils.py index 05afdffd9..0447a239c 100755 --- a/oioioi/contests/utils.py +++ b/oioioi/contests/utils.py @@ -397,10 +397,8 @@ def used_controllers(): by contests on this instance. """ return Contest.objects.values_list('controller_name', flat=True).distinct() - - -@request_cached -def visible_contests(request): + +def visible_contests_query(request): """Returns materialized set of contests visible to the logged in user.""" if request.GET.get('living', 'safely') == 'dangerously': visible_query = Contest.objects.none() @@ -423,8 +421,18 @@ def visible_contests(request): visible_query |= Q( controller_name=controller_name ) & controller.registration_controller().visible_contests_query(request) - return set(Contest.objects.filter(visible_query).distinct()) + return Contest.objects.filter(visible_query).distinct() +@request_cached +def visible_contests(request): + contests = visible_contests_query(request) + return set(contests) + +@request_cached_complex +def visible_contests_queryset(request, filter_value): + contests = visible_contests_query(request) + contests = contests.filter(Q(name__icontains=filter_value) | Q(id__icontains=filter_value) | Q(school_year=filter_value)) + return set(contests) @request_cached def administered_contests(request): diff --git a/oioioi/contests/views.py b/oioioi/contests/views.py index 69f5ba0bb..56a829b97 100755 --- a/oioioi/contests/views.py +++ b/oioioi/contests/views.py @@ -52,6 +52,7 @@ is_contest_basicadmin, is_contest_observer, visible_contests, + visible_contests_queryset, visible_problem_instances, visible_rounds, get_files_message, @@ -838,3 +839,14 @@ def unarchive_contest(request): contest.is_archived = False contest.save() return redirect('default_contest_view', contest_id=contest.id) + +def filter_contests_view(request, filter_value=""): + contests = visible_contests_queryset(request, filter_value) + contests = sorted(contests, key=lambda x: x.creation_date, reverse=True) + + context = { + 'contests' : contests, + } + return TemplateResponse( + request, 'contests/select_contest.html', context + ) \ No newline at end of file