diff --git a/source/web/main/admin.py b/source/web/main/admin.py
index 7bbd866..11cb27c 100644
--- a/source/web/main/admin.py
+++ b/source/web/main/admin.py
@@ -1,24 +1,24 @@
import json
from django.contrib import admin
from main.models import (
- Profile,
- Achievment,
- UserAchievments,
- TaskType,
- Task,
- Test,
- Verdict,
- AnswerOption,
- Compiler,
- AnswerCode,
- Answer,
- Category,
- ContestType,
- Contest,
- TaskOnContest,
+ Profile,
+ Achievment,
+ UserAchievments,
+ TaskType,
+ Task,
+ Test,
+ Verdict,
+ AnswerOption,
+ Compiler,
+ AnswerCode,
+ Answer,
+ Category,
+ ContestType,
+ Contest,
+ TaskOnContest,
CategoryOnContest,
- CompilerOnContest,
- ContestRole,
+ CompilerOnContest,
+ ContestRole,
UserToContest,
Checker,
)
@@ -142,6 +142,7 @@ class AnswerAdmin(BaseAdmin):
'id',
'task',
'verdict',
+ 'contest',
]
actions = [
diff --git a/source/web/main/migrations/0013_alter_answer_contest.py b/source/web/main/migrations/0013_alter_answer_contest.py
new file mode 100644
index 0000000..755fb83
--- /dev/null
+++ b/source/web/main/migrations/0013_alter_answer_contest.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.1.3 on 2024-12-21 21:12
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('main', '0012_answer_contest'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='answer',
+ name='contest',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='main.contest'),
+ ),
+ ]
diff --git a/source/web/main/migrations/0014_alter_answer_verdict.py b/source/web/main/migrations/0014_alter_answer_verdict.py
new file mode 100644
index 0000000..d992f8c
--- /dev/null
+++ b/source/web/main/migrations/0014_alter_answer_verdict.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.1.3 on 2024-12-21 21:37
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('main', '0013_alter_answer_contest'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='answer',
+ name='verdict',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='main.verdict'),
+ ),
+ ]
diff --git a/source/web/main/mixins.py b/source/web/main/mixins.py
new file mode 100644
index 0000000..eedd92b
--- /dev/null
+++ b/source/web/main/mixins.py
@@ -0,0 +1,25 @@
+from django.utils.translation import gettext_lazy as _
+from django.contrib import messages
+from django.contrib.auth.mixins import AccessMixin
+from django.shortcuts import redirect
+
+from main.models import Contest
+
+
+class UserContestMixin(AccessMixin):
+
+ def dispatch(self, request, *args, **kwargs):
+ if not request.user.is_authenticated:
+ return self.handle_no_permission()
+ if request.user.is_authenticated:
+ contest:Contest = self.get_contest(kwargs['id'])
+ if (
+ request.user.profile != contest.author and
+ request.user.profile not in contest.users.all()
+ ):
+ if contest.status == Contest.ContestStatus.OPENED:
+ messages.info(request, _("MustBeRegistered"))
+ return redirect('contest_register', contest.pk)
+ messages.error(request, _("NoContestAccess"))
+ return redirect('index')
+ return super().dispatch(request, *args, **kwargs)
\ No newline at end of file
diff --git a/source/web/main/models.py b/source/web/main/models.py
index 0b117ba..175c2d6 100644
--- a/source/web/main/models.py
+++ b/source/web/main/models.py
@@ -140,9 +140,9 @@ class Answer(BaseModel):
task = models.ForeignKey(Task, on_delete=models.CASCADE)
answer_option = models.ForeignKey(AnswerOption, null=True, blank=True, on_delete=models.SET_NULL)
answer_code = models.ForeignKey(AnswerCode, null=True, blank=True, on_delete=models.SET_NULL)
- verdict = models.ForeignKey(Verdict, null=True, on_delete=models.SET_NULL)
+ verdict = models.ForeignKey(Verdict, null=True, blank=True, on_delete=models.SET_NULL)
user = models.ForeignKey(Profile, on_delete=models.CASCADE)
- contest = models.ForeignKey("Contest", on_delete=models.CASCADE)
+ contest = models.ForeignKey("Contest", on_delete=models.CASCADE, null=True, blank=True)
def __str__(self):
return f"Answer #{self.id} for {self.task}"
@@ -217,6 +217,14 @@ def ordered_tasks(self):
res.append((item.order, item.task))
return res
+ @property
+ def participant_count(self):
+ users_to_contest = UserToContest.objects.filter(
+ contest=self,
+ role=ContestRole.objects.get(name="Participant")
+ )
+ return users_to_contest.count()
+
class TaskOnContest(BaseModel):
order = models.IntegerField()
diff --git a/source/web/main/services.py b/source/web/main/services.py
new file mode 100644
index 0000000..0fdd512
--- /dev/null
+++ b/source/web/main/services.py
@@ -0,0 +1,18 @@
+from typing import Optional
+
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import User
+from django.db.models import QuerySet
+
+from main.models import Answer, Task, Contest
+
+
+def get_user_answers(user: User, task: Task, contest: Optional[Contest]=None) -> QuerySet[Answer]:
+ if not user.is_authenticated:
+ return Answer.objects.none()
+
+ return Answer.objects.filter(
+ user=user.profile,
+ task=task,
+ contest=contest,
+ )
diff --git a/source/web/main/views.py b/source/web/main/views.py
index 805def8..5628b2e 100644
--- a/source/web/main/views.py
+++ b/source/web/main/views.py
@@ -3,7 +3,9 @@
from django.shortcuts import get_object_or_404, redirect, render
from django.contrib.auth import get_user_model
from django.contrib.auth.views import LoginView
+from django.utils.translation import gettext_lazy as _
+from main.mixins import UserContestMixin
from main.forms import LoginForm, SignUpForm
from main.models import (
Answer,
@@ -14,6 +16,7 @@
TaskOnContest,
)
from main.standings import create_standings
+from main.services import get_user_answers
User = get_user_model()
@@ -23,7 +26,7 @@ class IndexView(TemplateView):
template_name = 'main/index.html'
def get(self, request):
- opened_contests = Contest.objects.opened_contests()
+ opened_contests = Contest.objects.opened_contests()[:3]
return render(request, self.template_name, {
'opened_contests': opened_contests,
})
@@ -42,7 +45,9 @@ def post(self, request):
if form.cleaned_data['password'] != form.cleaned_data['password_confirm']:
form.add_error('password_confirm', 'Пароли не совпадают')
return render(request, self.template_name, {'form': form})
-
+ if User.objects.filter(username=form.cleaned_data['username']).count():
+ form.add_error('username', _("ObjectWithFieldExists"))
+ return render(request, self.template_name, {'form': form})
user = User.objects.create_user(
username=form.cleaned_data['username'],
password=form.cleaned_data['password'],
@@ -89,7 +94,7 @@ def get_task(self, task_id):
def get(self, request, *args, **kwargs):
task = self.get_task(kwargs['id'])
- answers = Answer.objects.filter(task=task, user=request.user.profile).order_by('-created_at')
+ answers = get_user_answers(request.user, task)
return render(
request,
@@ -146,7 +151,7 @@ def post(self, request, *args, **kwargs):
return render(request, self.template_name, {'contest': contest})
-class ContestDetailView(TemplateView):
+class ContestDetailView(UserContestMixin, TemplateView):
template_name = 'contests/contest_detail.html'
def get_contest(self, contest_id):
@@ -164,9 +169,12 @@ def get(self, request, *args, **kwargs):
)
-class ContestTaskView(TemplateView):
+class ContestTaskView(UserContestMixin, TemplateView):
template_name = 'contests/contest_task.html'
+ def get_contest(self, contest_id):
+ return get_object_or_404(Contest, pk=contest_id)
+
def get(self, request, *args, **kwargs):
task_on_contest = get_object_or_404(
TaskOnContest,
@@ -178,6 +186,8 @@ def get(self, request, *args, **kwargs):
template_name = f'tasks/{task.task_type.tester_name}.html'
+ answers = get_user_answers(request.user, task, contest)
+
return render(
request,
self.template_name,
@@ -185,6 +195,7 @@ def get(self, request, *args, **kwargs):
'task_template': template_name,
'task': task,
'contest': contest,
+ 'answers': answers,
}
)
@@ -197,7 +208,7 @@ def get_contest(self, contest_id):
def get(self, request, *args, **kwargs):
- _contest = self.get_contest(Contest, pk = kwargs["id"])
+ _contest = self.get_contest(kwargs["id"])
_data = create_standings(kwargs['id'])
return render(
diff --git a/source/web/static/css/index.css b/source/web/static/css/index.css
index ec56d2c..85d72c0 100644
--- a/source/web/static/css/index.css
+++ b/source/web/static/css/index.css
@@ -1,8 +1,56 @@
+:root{
+ --accent-color: #fe7373;
+ --color-dark: #252525;
+ --color-white: #ffffff;
+ --color-gray: #7d7d7d;
+ --color-primary: #6b6b6b;
+ --color-subheader: #3d3d3d;
+ --bg-color: #f1f1f1;
+ --color-light-gray: #E7E7E7;
+ --default-radius: 15px;
+ --default-padding: 25px;
+}
+
body{
+ background: var(--bg-color);
+ padding: 0;
+ margin: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
justify-content: space-between;
+
+ font-family: Ubuntu, serif;
+}
+
+@media (min-width: 980px) {
+ .mobile-screen{
+ display: none;
+ }
+}
+
+@media (max-width: 980px) {
+ .mobile-screen{
+ display: block;
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100vh;
+ width: 100vw;
+ background: var(--color-subheader);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ }
+}
+
+.card-dark{
+ background: var(--color-dark);
+ border-radius: var(--default-radius);
+ padding: var(--default-padding);
+ color: var(--color-white);
+ text-align: center;
}
main{
@@ -10,7 +58,376 @@ main{
min-height: 90vh;
}
+.container{
+ max-width: 980px;
+ margin: auto;
+ height: 100%;
+}
+
+header{
+ background: var(--color-dark);
+ color: var(--color-white);
+ height: 50px;
+}
+
+header > .container{
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+}
+
+header span.title{
+ font-size: 20pt;
+}
+
+header a{
+ text-decoration: none;
+ color: var(--color-white);
+}
+
+header a:visited,
+header a:active{
+ text-decoration: none;
+ color: var(--color-white);
+}
+
+header a:hover{
+ text-decoration: none;
+ color: var(--color-gray);
+}
+
+header .nav-links{
+ list-style-type: none;
+ display: flex;
+ flex-direction: row;
+}
+
+.nav-links > li{
+ margin: 0 10px;
+}
+
+header .slogan{
+ color: var(--color-primary);
+}
+
+.active-link,
+.active-link:visited,
+.active-link:active
+{
+ color: var(--accent-color);
+}
+
+.active-link:hover{
+ color: var(--color-gray);
+}
+
+.active-text{
+ color: var(--accent-color);
+}
+
+nav.subheader{
+ height: 30px;
+ background: var(--color-subheader);
+}
+
+nav.subheader ul{
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: row;
+ list-style-type: none;
+ align-items: center;
+ height: 100%;
+}
+
+nav.subheader ul>*{
+ padding: 0 15px;
+ height: 100%;
+ display: flex;
+ align-items: center;
+}
+
+nav.subheader ul>li:hover{
+ background: var(--accent-color);
+}
+
+nav.subheader ul>li>a{
+ display: block;
+}
+
+nav.subheader ul>:first-child{
+ margin-left: 0;
+}
+nav.subheader ul>:last-child{
+ margin-right: 0;
+}
+
+nav a,
+nav a:visited{
+ color: var(--color-white);
+ text-decoration: none;
+}
+
+nav li>a:hover,
+nav li>a:active{
+ background: var(--accent-color);
+ color: var(--color-white);
+}
+
footer{
display: flex;
justify-content: center;
+}
+
+.contest-slider{
+ z-index: 50;
+}
+
+.contest-card{
+ display: inline-block;
+ background: var(--color-white);
+ padding: 15px;
+ border-radius: 15px;
+ max-width: 270px;
+ min-width: 270px;
+ margin-right: 20px;
+ height: 150px;
+}
+
+.contest-card > .contest-name{
+ display: block;
+ font-size: 18pt;
+ text-overflow: ellipsis;
+ margin-bottom: 10px;
+}
+
+.contest-card .contest-info{
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+}
+
+.contest-card > .contest-footer{
+ margin-top: 10px;
+}
+
+.contest-info > li{
+ margin: 3px 0;
+}
+
+.contest-block > .container{
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+}
+
+.contest-sidebar{
+ width: 330px;
+}
+
+.contest-content{
+ width: 630px;
+}
+
+.card{
+ background: var(--color-white);
+ border-radius: var(--default-radius);
+ padding: var(--default-padding);
+ margin: 15px 0;
+}
+
+.card-title{
+ font-size: 18pt;
+ margin: 0;
+ font-weight: bold;
+}
+
+.tasks-list ul{
+ list-style-type: none;
+ padding: 0;
+}
+
+table{
+ width: 100%;
+ text-align: center;
+ border-collapse: collapse;
+}
+
+table tr{
+ height: 35px;
+}
+
+table tbody tr:nth-child(2n + 1){
+ background: #fe737333;
+}
+
+table td{
+ border: none;
+ padding: 0;
+ margin: 0;
+ border-spacing: 0;
+}
+
+.submission-verdict{
+ border: 1px solid black;
+ padding: 3px;
+ border-radius: 5px;
+}
+
+.submission-verdict[type='ok']{
+ background: #4c765850;
+ border-color: #295f38;
+}
+
+.submission-verdict:not([type='ok']){
+ background: #85404050;
+ border-color: #632727;
+}
+
+.statement-block > h3{
+ margin: 0;
+ margin-top: 30px;
+ padding: 0;
+}
+
+.statement-block > p{
+ margin: 0;
+ margin-top: 10px;
+}
+
+.code-area{
+ width: calc(100% - 24px);
+ min-height: 300px;
+ max-height: 500px;
+ resize: none;
+ padding: 0;
+ border: 0;
+ border: 2px solid var(--color-light-gray);
+ border-radius: 7px;
+ padding: 10px;
+}
+
+.form-footer{
+ margin-top: 15px;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+}
+
+select{
+ width: 200px;
+ height: 40px;
+ border: none;
+ padding-left: 10px;
+ padding-right: 10px;
+ border: 2px solid var(--color-light-gray);
+ border-radius: 7px;
+ font-size: 12pt;
+}
+
+hr{
+ border-color: var(--color-light-gray);
+ border-style: solid;
+ margin: 0;
+}
+
+.statement{
+ margin-bottom: 25px;
+}
+
+button{
+ border: none;
+ color: white;
+ background: var(--accent-color);
+ height: 40px;
+ min-width: 120px;
+ border-radius: 10px;
+ font-size: 12pt;
+}
+
+.answer-options{
+ margin: 15px 0;
+}
+
+.system-messages{
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+ margin-right: 20px;
+}
+
+.system-messages > li{
+ border: 1px solid black;
+ background-color: var(--color-gray);
+ color: black;
+ border-radius: 7px;
+ min-width: 400px;
+ min-height: 20px;
+ padding: 15px;
+ margin: 5px 0;
+}
+
+.system-messages>li[type='warning']{
+ color: #ffda6a;
+ background: #332701e0;
+ border-color: #997404;
+
+}
+.system-messages>li[type='error']{
+ color: #ea868f;
+ background: #2c0b0ee0;
+ border-color: #842029;
+}
+.system-messages>li[type='info']{
+ color: #6ea8fe;
+ background: #031633e0;
+ border-color: #084298;
+}
+.system-messages>li[type='success']{
+ color: #75b798;
+ background: #051b11e0;
+ border-color: #0f5132;
+}
+.system-messages>li[type='debug']{
+ color: #f8f9fa;
+ background: #343a40e0;
+ border-color: #495057;
+}
+
+.form-control{
+ display: flex;
+ flex-direction: row;
+ max-width: 500px;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 15px;
+}
+
+input[type='text'],
+input[type='password'],
+input[type='number'],
+input[type='email']
+{
+ font-size: 11pt;
+ padding: 0 5px;
+ width: 300px;
+ height: 30px;
+ border-radius: 7px;
+ border: 1px solid var(--color-light-gray);
+}
+
+input[type='text']:focus-within,
+input[type='password']:focus-within,
+input[type='number']:focus-within,
+input[type='email']:focus-within
+{
+ width: 300px;
+ height: 30px;
+ border-radius: 7px;
+ outline: none;
+ border: 2px solid var(--color-gray);
}
\ No newline at end of file
diff --git a/source/web/templates/contests/contest_detail.html b/source/web/templates/contests/contest_detail.html
index f85cc57..0853ee7 100644
--- a/source/web/templates/contests/contest_detail.html
+++ b/source/web/templates/contests/contest_detail.html
@@ -6,6 +6,8 @@
{% endblock %}
{% block contest_content %}
+
{{contest.name}}
Contest description: ...
+
{% endblock %}
\ No newline at end of file
diff --git a/source/web/templates/contests/contest_standings.html b/source/web/templates/contests/contest_standings.html
index 7324ebf..41b02f9 100644
--- a/source/web/templates/contests/contest_standings.html
+++ b/source/web/templates/contests/contest_standings.html
@@ -1,4 +1,4 @@
-{% extends "wrapper.html" %}
+{% extends "contests/contest_wrapper.html" %}
{% load i18n %}
{% block title %}
@@ -6,29 +6,38 @@
{% endblock %}
{% block content %}
-
-
-
- num |
- name |
- point |
- penalty |
- {% for task in contest.tasks.all %}
- {{forloop.counter}} |
- {% endfor %}
-
- {% for standing in results %}
-
- {{forloop.counter}} |
- {{ standing.user }} |
- {{ standing.total_points }} |
- {{ standing.penalty }} |
- {% for task in standing.task_results %}
- {{ task }} |
- {% endfor %}
-
- {% endfor %}
-
+
+
+
+
+
+ # |
+ {% trans "Username" %} |
+ {% trans "Points" %} |
+ {% trans "Penalty" %} |
+ {% for task in contest.tasks.all %}
+ {{forloop.counter}} |
+ {% endfor %}
+
+
+
+ {% if results %}
+ {% for standing in results %}
+
+ {{forloop.counter}} |
+ {{ standing.user.user.username }} |
+ {{ standing.total_points }} |
+ {{ standing.penalty }} |
+ {% for task in standing.task_results %}
+ {{ task }} |
+ {% endfor %}
+
+ {% endfor %}
+ {% else %}
+ {% trans "Empty" %}
+ {% endif %}
+
+
+
+
{% endblock %}
\ No newline at end of file
diff --git a/source/web/templates/contests/contest_task.html b/source/web/templates/contests/contest_task.html
index bf127db..7ac687f 100644
--- a/source/web/templates/contests/contest_task.html
+++ b/source/web/templates/contests/contest_task.html
@@ -2,10 +2,17 @@
{% load i18n %}
{% block contest_content %}
-
-
+
+
+
{{task.name}}
{% include task_template %}
- {% include "tasks/theta_solutions.html" %}
+{% if request.user.is_authenticated %}
+
+
+ {% include "tasks/theta_solutions.html" %}
+
+
+{% endif %}
{% endblock %}
\ No newline at end of file
diff --git a/source/web/templates/contests/contest_wrapper.html b/source/web/templates/contests/contest_wrapper.html
index 31ef8d0..454fb53 100644
--- a/source/web/templates/contests/contest_wrapper.html
+++ b/source/web/templates/contests/contest_wrapper.html
@@ -1,29 +1,37 @@
{% extends "wrapper.html" %}
+{% load i18n %}
{% block title %}
{% endblock title %}
+{% block subheader %}
+ {% include "contests/includes/contest_subheader.html" %}
+{% endblock %}
+
{% block content %}
-
-
-
+
+
+
+
{% block contest_content %}
{% endblock contest_content %}
-
- {% if contest.tasks %}
-
- {% endif %}
-
diff --git a/source/web/templates/contests/contests_list.html b/source/web/templates/contests/contests_list.html
index 33e272f..5fb42fd 100644
--- a/source/web/templates/contests/contests_list.html
+++ b/source/web/templates/contests/contests_list.html
@@ -7,8 +7,8 @@
{% block content %}
-
-
+
+
# |
diff --git a/source/web/templates/contests/includes/contest_subheader.html b/source/web/templates/contests/includes/contest_subheader.html
new file mode 100644
index 0000000..4683186
--- /dev/null
+++ b/source/web/templates/contests/includes/contest_subheader.html
@@ -0,0 +1,11 @@
+{% load i18n %}
+
\ No newline at end of file
diff --git a/source/web/templates/contests/registration.html b/source/web/templates/contests/registration.html
index ca2f4c9..155857e 100644
--- a/source/web/templates/contests/registration.html
+++ b/source/web/templates/contests/registration.html
@@ -1,14 +1,19 @@
{% extends "wrapper.html" %}
+{% load i18n %}
{% block title %}
-Contest registration
+{% trans "ContestRegister" %}
{% endblock %}
{% block content %}
- Contest registration
- Rules for contest:
-
+
+
+
{% trans "ContestRegistration" %}
+
{% trans "RulesForContest" %}: {{contest.rules}}
+
+
+
{% endblock %}
\ No newline at end of file
diff --git a/source/web/templates/includes/header.html b/source/web/templates/includes/header.html
index d259bd2..769aab9 100644
--- a/source/web/templates/includes/header.html
+++ b/source/web/templates/includes/header.html
@@ -1,47 +1,25 @@
{% load i18n %}
-