Add contest to course (#126)
This commit is contained in:
parent
72eada0a4e
commit
3d67fb274e
22 changed files with 1258 additions and 433 deletions
15
dmoj/urls.py
15
dmoj/urls.py
|
@ -569,6 +569,21 @@ urlpatterns = [
|
||||||
course.CourseStudentResultsLesson.as_view(),
|
course.CourseStudentResultsLesson.as_view(),
|
||||||
name="course_grades_lesson",
|
name="course_grades_lesson",
|
||||||
),
|
),
|
||||||
|
url(
|
||||||
|
r"^/add_contest$",
|
||||||
|
course.AddCourseContest.as_view(),
|
||||||
|
name="add_course_contest",
|
||||||
|
),
|
||||||
|
url(
|
||||||
|
r"^/edit_contest/(?P<contest>\w+)$",
|
||||||
|
course.EditCourseContest.as_view(),
|
||||||
|
name="edit_course_contest",
|
||||||
|
),
|
||||||
|
url(
|
||||||
|
r"^/contests$",
|
||||||
|
course.CourseContestList.as_view(),
|
||||||
|
name="course_contest_list",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
67
judge/migrations/0194_course_contest.py
Normal file
67
judge/migrations/0194_course_contest.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
# Generated by Django 3.2.21 on 2024-09-30 22:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("judge", "0193_remove_old_course_problems"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="contest",
|
||||||
|
name="is_in_course",
|
||||||
|
field=models.BooleanField(default=False, verbose_name="contest in course"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="courselesson",
|
||||||
|
name="is_visible",
|
||||||
|
field=models.BooleanField(default=True, verbose_name="publicly visible"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="courselesson",
|
||||||
|
name="content",
|
||||||
|
field=models.TextField(verbose_name="lesson content"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="courselesson",
|
||||||
|
name="title",
|
||||||
|
field=models.TextField(verbose_name="lesson title"),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CourseContest",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("order", models.IntegerField(default=0, verbose_name="order")),
|
||||||
|
("points", models.IntegerField(verbose_name="points")),
|
||||||
|
(
|
||||||
|
"contest",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="course",
|
||||||
|
to="judge.contest",
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"course",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="contests",
|
||||||
|
to="judge.course",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -61,7 +61,13 @@ from judge.models.ticket import Ticket, TicketMessage
|
||||||
from judge.models.volunteer import VolunteerProblemVote
|
from judge.models.volunteer import VolunteerProblemVote
|
||||||
from judge.models.pagevote import PageVote, PageVoteVoter
|
from judge.models.pagevote import PageVote, PageVoteVoter
|
||||||
from judge.models.bookmark import BookMark, MakeBookMark
|
from judge.models.bookmark import BookMark, MakeBookMark
|
||||||
from judge.models.course import Course, CourseRole, CourseLesson, CourseLessonProblem
|
from judge.models.course import (
|
||||||
|
Course,
|
||||||
|
CourseRole,
|
||||||
|
CourseLesson,
|
||||||
|
CourseLessonProblem,
|
||||||
|
CourseContest,
|
||||||
|
)
|
||||||
from judge.models.notification import Notification, NotificationProfile
|
from judge.models.notification import Notification, NotificationProfile
|
||||||
from judge.models.test_formatter import TestFormatterModel
|
from judge.models.test_formatter import TestFormatterModel
|
||||||
|
|
||||||
|
|
|
@ -246,6 +246,10 @@ class Contest(models.Model, PageVotable, Bookmarkable):
|
||||||
verbose_name=_("organizations"),
|
verbose_name=_("organizations"),
|
||||||
help_text=_("If private, only these organizations may see the contest"),
|
help_text=_("If private, only these organizations may see the contest"),
|
||||||
)
|
)
|
||||||
|
is_in_course = models.BooleanField(
|
||||||
|
verbose_name=_("contest in course"),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
og_image = models.CharField(
|
og_image = models.CharField(
|
||||||
verbose_name=_("OpenGraph image"), default="", max_length=150, blank=True
|
verbose_name=_("OpenGraph image"), default="", max_length=150, blank=True
|
||||||
)
|
)
|
||||||
|
@ -561,6 +565,14 @@ class Contest(models.Model, PageVotable, Bookmarkable):
|
||||||
if not self.is_visible:
|
if not self.is_visible:
|
||||||
raise self.Inaccessible()
|
raise self.Inaccessible()
|
||||||
|
|
||||||
|
if self.is_in_course:
|
||||||
|
from judge.models import Course, CourseContest
|
||||||
|
|
||||||
|
course_contest = CourseContest.objects.filter(contest=self).first()
|
||||||
|
if Course.is_accessible_by(course_contest.course, user.profile):
|
||||||
|
return
|
||||||
|
raise self.Inaccessible()
|
||||||
|
|
||||||
# Contest is not private
|
# Contest is not private
|
||||||
if not self.is_private and not self.is_organization_private:
|
if not self.is_private and not self.is_organization_private:
|
||||||
return
|
return
|
||||||
|
@ -612,7 +624,10 @@ class Contest(models.Model, PageVotable, Bookmarkable):
|
||||||
if not user.is_authenticated:
|
if not user.is_authenticated:
|
||||||
return (
|
return (
|
||||||
cls.objects.filter(
|
cls.objects.filter(
|
||||||
is_visible=True, is_organization_private=False, is_private=False
|
is_visible=True,
|
||||||
|
is_organization_private=False,
|
||||||
|
is_private=False,
|
||||||
|
is_in_course=False,
|
||||||
)
|
)
|
||||||
.defer("description")
|
.defer("description")
|
||||||
.distinct()
|
.distinct()
|
||||||
|
@ -626,7 +641,7 @@ class Contest(models.Model, PageVotable, Bookmarkable):
|
||||||
)
|
)
|
||||||
or show_own_contests_only
|
or show_own_contests_only
|
||||||
):
|
):
|
||||||
q = Q(is_visible=True)
|
q = Q(is_visible=True, is_in_course=False)
|
||||||
q &= (
|
q &= (
|
||||||
Q(view_contest_scoreboard=user.profile)
|
Q(view_contest_scoreboard=user.profile)
|
||||||
| Q(is_organization_private=False, is_private=False)
|
| Q(is_organization_private=False, is_private=False)
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.utils.translation import gettext, gettext_lazy as _
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from judge.models import BlogPost, Problem
|
from judge.models import BlogPost, Problem, Contest
|
||||||
from judge.models.profile import Organization, Profile
|
from judge.models.profile import Organization, Profile
|
||||||
|
|
||||||
|
|
||||||
|
@ -160,10 +160,11 @@ class CourseLesson(models.Model):
|
||||||
related_name="lessons",
|
related_name="lessons",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
title = models.TextField(verbose_name=_("course title"))
|
title = models.TextField(verbose_name=_("lesson title"))
|
||||||
content = models.TextField(verbose_name=_("course content"))
|
content = models.TextField(verbose_name=_("lesson content"))
|
||||||
order = models.IntegerField(verbose_name=_("order"), default=0)
|
order = models.IntegerField(verbose_name=_("order"), default=0)
|
||||||
points = models.IntegerField(verbose_name=_("points"))
|
points = models.IntegerField(verbose_name=_("points"))
|
||||||
|
is_visible = models.BooleanField(verbose_name=_("publicly visible"), default=True)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
return reverse(
|
||||||
|
@ -182,3 +183,19 @@ class CourseLessonProblem(models.Model):
|
||||||
problem = models.ForeignKey(Problem, on_delete=models.CASCADE)
|
problem = models.ForeignKey(Problem, on_delete=models.CASCADE)
|
||||||
order = models.IntegerField(verbose_name=_("order"), default=0)
|
order = models.IntegerField(verbose_name=_("order"), default=0)
|
||||||
score = models.IntegerField(verbose_name=_("score"), default=0)
|
score = models.IntegerField(verbose_name=_("score"), default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class CourseContest(models.Model):
|
||||||
|
course = models.ForeignKey(
|
||||||
|
Course, on_delete=models.CASCADE, related_name="contests"
|
||||||
|
)
|
||||||
|
contest = models.ForeignKey(
|
||||||
|
Contest, unique=True, on_delete=models.CASCADE, related_name="course"
|
||||||
|
)
|
||||||
|
order = models.IntegerField(verbose_name=_("order"), default=0)
|
||||||
|
points = models.IntegerField(verbose_name=_("points"))
|
||||||
|
|
||||||
|
def get_course_of_contest(contest):
|
||||||
|
course_contest = contest.course.get()
|
||||||
|
course = course_contest.course
|
||||||
|
return course
|
||||||
|
|
32
judge/utils/contest.py
Normal file
32
judge/utils/contest.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
from django.db import transaction
|
||||||
|
from judge.tasks import rescore_contest
|
||||||
|
from judge.models import (
|
||||||
|
Contest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_trigger_contest_rescore(form, contest):
|
||||||
|
if any(
|
||||||
|
f in form.changed_data
|
||||||
|
for f in (
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"time_limit",
|
||||||
|
"format_config",
|
||||||
|
"format_name",
|
||||||
|
"freeze_after",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
transaction.on_commit(rescore_contest.s(contest.key).delay)
|
||||||
|
|
||||||
|
if any(
|
||||||
|
f in form.changed_data
|
||||||
|
for f in (
|
||||||
|
"authors",
|
||||||
|
"curators",
|
||||||
|
"testers",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
Contest._author_ids.dirty(contest)
|
||||||
|
Contest._curator_ids.dirty(contest)
|
||||||
|
Contest._tester_ids.dirty(contest)
|
|
@ -534,6 +534,14 @@ class ContestDetail(
|
||||||
)
|
)
|
||||||
context["editable_organizations"] = self.get_editable_organizations()
|
context["editable_organizations"] = self.get_editable_organizations()
|
||||||
context["is_clonable"] = is_contest_clonable(self.request, self.object)
|
context["is_clonable"] = is_contest_clonable(self.request, self.object)
|
||||||
|
|
||||||
|
if self.object.is_in_course:
|
||||||
|
from judge.models import Course, CourseContest
|
||||||
|
|
||||||
|
course = CourseContest.get_course_of_contest(self.object)
|
||||||
|
if Course.is_editable_by(course, self.request.profile):
|
||||||
|
context["editable_course"] = course
|
||||||
|
|
||||||
if self.request.in_contest:
|
if self.request.in_contest:
|
||||||
context["current_contest"] = self.request.participation.contest
|
context["current_contest"] = self.request.participation.contest
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -12,28 +12,42 @@ from django.forms import (
|
||||||
)
|
)
|
||||||
from django.views.generic.edit import FormView
|
from django.views.generic.edit import FormView
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy, reverse
|
||||||
from django.db.models import Max, F
|
from django.db.models import Max, F, Sum
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
|
||||||
from judge.models import (
|
from judge.models import (
|
||||||
Course,
|
Course,
|
||||||
|
Contest,
|
||||||
CourseLesson,
|
CourseLesson,
|
||||||
Submission,
|
Submission,
|
||||||
Profile,
|
Profile,
|
||||||
CourseRole,
|
CourseRole,
|
||||||
CourseLessonProblem,
|
CourseLessonProblem,
|
||||||
|
CourseContest,
|
||||||
|
ContestProblem,
|
||||||
|
ContestParticipation,
|
||||||
)
|
)
|
||||||
from judge.models.course import RoleInCourse
|
from judge.models.course import RoleInCourse
|
||||||
from judge.widgets import (
|
from judge.widgets import (
|
||||||
HeavyPreviewPageDownWidget,
|
HeavyPreviewPageDownWidget,
|
||||||
HeavySelect2MultipleWidget,
|
HeavySelect2MultipleWidget,
|
||||||
HeavySelect2Widget,
|
HeavySelect2Widget,
|
||||||
|
DateTimePickerWidget,
|
||||||
|
Select2MultipleWidget,
|
||||||
|
Select2Widget,
|
||||||
|
)
|
||||||
|
from judge.forms import (
|
||||||
|
ContestProblemFormSet,
|
||||||
)
|
)
|
||||||
from judge.utils.problems import (
|
from judge.utils.problems import (
|
||||||
user_attempted_ids,
|
user_attempted_ids,
|
||||||
user_completed_ids,
|
user_completed_ids,
|
||||||
)
|
)
|
||||||
|
from judge.utils.contest import (
|
||||||
|
maybe_trigger_contest_rescore,
|
||||||
|
)
|
||||||
|
from reversion import revisions
|
||||||
|
|
||||||
|
|
||||||
def max_case_points_per_problem(profile, problems):
|
def max_case_points_per_problem(profile, problems):
|
||||||
|
@ -85,6 +99,65 @@ def calculate_lessons_progress(profile, lessons):
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_contests_progress(profile, course_contests):
|
||||||
|
res = {}
|
||||||
|
total_achieved_points = total_contest_points = 0
|
||||||
|
for course_contest in course_contests:
|
||||||
|
contest = course_contest.contest
|
||||||
|
|
||||||
|
achieved_points = 0
|
||||||
|
participation = ContestParticipation.objects.filter(
|
||||||
|
contest=contest, user=profile, virtual=0
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if participation:
|
||||||
|
achieved_points = participation.score
|
||||||
|
|
||||||
|
total_points = (
|
||||||
|
ContestProblem.objects.filter(contest=contest).aggregate(Sum("points"))[
|
||||||
|
"points__sum"
|
||||||
|
]
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
res[course_contest.id] = {
|
||||||
|
"achieved_points": achieved_points,
|
||||||
|
"total_points": total_points,
|
||||||
|
"percentage": achieved_points / total_points * 100 if total_points else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_points:
|
||||||
|
total_achieved_points += (
|
||||||
|
achieved_points / total_points * course_contest.points
|
||||||
|
)
|
||||||
|
total_contest_points += course_contest.points
|
||||||
|
|
||||||
|
res["total"] = {
|
||||||
|
"achieved_points": total_achieved_points,
|
||||||
|
"total_points": total_contest_points,
|
||||||
|
"percentage": total_achieved_points / total_contest_points * 100
|
||||||
|
if total_contest_points
|
||||||
|
else 0,
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_total_progress(profile, lesson_progress, contest_progress):
|
||||||
|
lesson_total = lesson_progress["total"]
|
||||||
|
contest_total = contest_progress["total"]
|
||||||
|
total_achieved_points = (
|
||||||
|
lesson_total["achieved_points"] + contest_total["achieved_points"]
|
||||||
|
)
|
||||||
|
total_points = lesson_total["total_points"] + contest_total["total_points"]
|
||||||
|
|
||||||
|
res = {
|
||||||
|
"achieved_points": total_achieved_points,
|
||||||
|
"total_points": total_points,
|
||||||
|
"percentage": total_achieved_points / total_points * 100 if total_points else 0,
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
class CourseList(ListView):
|
class CourseList(ListView):
|
||||||
model = Course
|
model = Course
|
||||||
template_name = "course/list.html"
|
template_name = "course/list.html"
|
||||||
|
@ -130,13 +203,34 @@ class CourseDetail(CourseDetailMixin, DetailView):
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(CourseDetail, self).get_context_data(**kwargs)
|
context = super(CourseDetail, self).get_context_data(**kwargs)
|
||||||
lessons = self.course.lessons.prefetch_related("lesson_problems").all()
|
lessons = (
|
||||||
|
self.course.lessons.filter(is_visible=True)
|
||||||
|
.order_by("order")
|
||||||
|
.prefetch_related("lesson_problems")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
course_contests = (
|
||||||
|
self.course.contests.select_related("contest")
|
||||||
|
.filter(contest__is_visible=True)
|
||||||
|
.order_by("order")
|
||||||
|
)
|
||||||
context["title"] = self.course.name
|
context["title"] = self.course.name
|
||||||
context["page_type"] = "home"
|
context["page_type"] = "home"
|
||||||
context["lessons"] = lessons
|
context["lessons"] = lessons
|
||||||
context["lesson_progress"] = calculate_lessons_progress(
|
context["lesson_progress"] = calculate_lessons_progress(
|
||||||
self.request.profile, lessons
|
self.request.profile, lessons
|
||||||
)
|
)
|
||||||
|
context["course_contests"] = course_contests
|
||||||
|
context["contest_progress"] = calculate_contests_progress(
|
||||||
|
self.request.profile, course_contests
|
||||||
|
)
|
||||||
|
|
||||||
|
context["total_progress"] = calculate_total_progress(
|
||||||
|
self.request.profile,
|
||||||
|
context["lesson_progress"],
|
||||||
|
context["contest_progress"],
|
||||||
|
)
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@ -149,6 +243,11 @@ class CourseLessonDetail(CourseDetailMixin, DetailView):
|
||||||
self.lesson = CourseLesson.objects.get(
|
self.lesson = CourseLesson.objects.get(
|
||||||
course=self.course, id=self.kwargs["id"]
|
course=self.course, id=self.kwargs["id"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_editable = Course.is_editable_by(self.course, self.request.profile)
|
||||||
|
if not self.lesson.is_visible and not is_editable:
|
||||||
|
raise Http404()
|
||||||
|
|
||||||
return self.lesson
|
return self.lesson
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
@ -190,7 +289,7 @@ class CourseLessonDetail(CourseDetailMixin, DetailView):
|
||||||
class CourseLessonForm(forms.ModelForm):
|
class CourseLessonForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CourseLesson
|
model = CourseLesson
|
||||||
fields = ["order", "title", "points", "content"]
|
fields = ["order", "title", "is_visible", "points", "content"]
|
||||||
widgets = {
|
widgets = {
|
||||||
"title": forms.TextInput(),
|
"title": forms.TextInput(),
|
||||||
"content": HeavyPreviewPageDownWidget(preview=reverse_lazy("blog_preview")),
|
"content": HeavyPreviewPageDownWidget(preview=reverse_lazy("blog_preview")),
|
||||||
|
@ -312,9 +411,29 @@ class CourseStudentResults(CourseEditableMixin, DetailView):
|
||||||
def get_grades(self):
|
def get_grades(self):
|
||||||
students = self.course.get_students()
|
students = self.course.get_students()
|
||||||
students.sort(key=lambda u: u.username.lower())
|
students.sort(key=lambda u: u.username.lower())
|
||||||
lessons = self.course.lessons.prefetch_related("lesson_problems").all()
|
lessons = (
|
||||||
grades = {s: calculate_lessons_progress(s, lessons) for s in students}
|
self.course.lessons.filter(is_visible=True)
|
||||||
return grades
|
.prefetch_related("lesson_problems")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
course_contests = (
|
||||||
|
self.course.contests.select_related("contest")
|
||||||
|
.filter(contest__is_visible=True)
|
||||||
|
.order_by("order")
|
||||||
|
)
|
||||||
|
|
||||||
|
grade_lessons = {}
|
||||||
|
grade_contests = {}
|
||||||
|
grade_total = {}
|
||||||
|
for s in students:
|
||||||
|
grade_lessons[s] = lesson_progress = calculate_lessons_progress(s, lessons)
|
||||||
|
grade_contests[s] = contest_progress = calculate_contests_progress(
|
||||||
|
s, course_contests
|
||||||
|
)
|
||||||
|
grade_total[s] = calculate_total_progress(
|
||||||
|
s, lesson_progress, contest_progress
|
||||||
|
)
|
||||||
|
return grade_lessons, grade_contests, grade_total
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(CourseStudentResults, self).get_context_data(**kwargs)
|
context = super(CourseStudentResults, self).get_context_data(**kwargs)
|
||||||
|
@ -329,7 +448,19 @@ class CourseStudentResults(CourseEditableMixin, DetailView):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
context["page_type"] = "grades"
|
context["page_type"] = "grades"
|
||||||
context["grades"] = self.get_grades()
|
(
|
||||||
|
context["grade_lessons"],
|
||||||
|
context["grade_contests"],
|
||||||
|
context["grade_total"],
|
||||||
|
) = self.get_grades()
|
||||||
|
context["lessons"] = self.course.lessons.filter(is_visible=True).order_by(
|
||||||
|
"order"
|
||||||
|
)
|
||||||
|
context["course_contests"] = (
|
||||||
|
self.course.contests.select_related("contest")
|
||||||
|
.filter(contest__is_visible=True)
|
||||||
|
.order_by("order")
|
||||||
|
)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@ -392,3 +523,255 @@ class CourseStudentResultsLesson(CourseEditableMixin, DetailView):
|
||||||
context["page_type"] = "grades"
|
context["page_type"] = "grades"
|
||||||
context["grades"] = self.get_lesson_grades()
|
context["grades"] = self.get_lesson_grades()
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class AddCourseContestForm(forms.ModelForm):
|
||||||
|
order = forms.IntegerField(label=_("Order"))
|
||||||
|
points = forms.IntegerField(label=_("Points"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Contest
|
||||||
|
fields = [
|
||||||
|
"order",
|
||||||
|
"points",
|
||||||
|
"key",
|
||||||
|
"name",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"problems",
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
"start_time": DateTimePickerWidget(),
|
||||||
|
"end_time": DateTimePickerWidget(),
|
||||||
|
"problems": HeavySelect2MultipleWidget(data_view="problem_select2"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(self, course, profile, commit=True):
|
||||||
|
contest = super().save(commit=False)
|
||||||
|
contest.is_in_course = True
|
||||||
|
|
||||||
|
old_save_m2m = self.save_m2m
|
||||||
|
|
||||||
|
def save_m2m():
|
||||||
|
for i, problem in enumerate(self.cleaned_data["problems"]):
|
||||||
|
contest_problem = ContestProblem(
|
||||||
|
contest=contest, problem=problem, points=100, order=i + 1
|
||||||
|
)
|
||||||
|
contest_problem.save()
|
||||||
|
contest.contest_problems.add(contest_problem)
|
||||||
|
contest.authors.add(profile)
|
||||||
|
old_save_m2m()
|
||||||
|
|
||||||
|
self.save_m2m = save_m2m
|
||||||
|
contest.save()
|
||||||
|
self.save_m2m()
|
||||||
|
|
||||||
|
CourseContest.objects.create(
|
||||||
|
course=course,
|
||||||
|
contest=contest,
|
||||||
|
order=self.cleaned_data["order"],
|
||||||
|
points=self.cleaned_data["points"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return contest
|
||||||
|
|
||||||
|
|
||||||
|
class AddCourseContest(CourseEditableMixin, FormView):
|
||||||
|
template_name = "course/add_contest.html"
|
||||||
|
form_class = AddCourseContestForm
|
||||||
|
|
||||||
|
def get_title(self):
|
||||||
|
return _("Add contest")
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["title"] = self.get_title()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
with revisions.create_revision():
|
||||||
|
revisions.set_comment(_("Added from course") + " " + self.course.name)
|
||||||
|
revisions.set_user(self.request.user)
|
||||||
|
|
||||||
|
self.contest = form.save(course=self.course, profile=self.request.profile)
|
||||||
|
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse(
|
||||||
|
"edit_course_contest",
|
||||||
|
args=[self.course.slug, self.contest.key],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CourseContestList(CourseEditableMixin, ListView):
|
||||||
|
template_name = "course/contest_list.html"
|
||||||
|
context_object_name = "course_contests"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["title"] = _("Contest list")
|
||||||
|
context["page_type"] = "contests"
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.course.contests.select_related("contest").all().order_by("order")
|
||||||
|
|
||||||
|
|
||||||
|
class EditCourseContestForm(ModelForm):
|
||||||
|
order = forms.IntegerField(label=_("Order"))
|
||||||
|
points = forms.IntegerField(label=_("Points"))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Contest
|
||||||
|
fields = (
|
||||||
|
"order",
|
||||||
|
"points",
|
||||||
|
"is_visible",
|
||||||
|
"key",
|
||||||
|
"name",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"format_name",
|
||||||
|
"authors",
|
||||||
|
"curators",
|
||||||
|
"testers",
|
||||||
|
"time_limit",
|
||||||
|
"freeze_after",
|
||||||
|
"use_clarifications",
|
||||||
|
"hide_problem_tags",
|
||||||
|
"public_scoreboard",
|
||||||
|
"scoreboard_visibility",
|
||||||
|
"points_precision",
|
||||||
|
"rate_limit",
|
||||||
|
"description",
|
||||||
|
"access_code",
|
||||||
|
"private_contestants",
|
||||||
|
"view_contest_scoreboard",
|
||||||
|
"banned_users",
|
||||||
|
)
|
||||||
|
widgets = {
|
||||||
|
"authors": HeavySelect2MultipleWidget(data_view="profile_select2"),
|
||||||
|
"curators": HeavySelect2MultipleWidget(data_view="profile_select2"),
|
||||||
|
"testers": HeavySelect2MultipleWidget(data_view="profile_select2"),
|
||||||
|
"private_contestants": HeavySelect2MultipleWidget(
|
||||||
|
data_view="profile_select2"
|
||||||
|
),
|
||||||
|
"banned_users": HeavySelect2MultipleWidget(data_view="profile_select2"),
|
||||||
|
"view_contest_scoreboard": HeavySelect2MultipleWidget(
|
||||||
|
data_view="profile_select2"
|
||||||
|
),
|
||||||
|
"tags": Select2MultipleWidget,
|
||||||
|
"description": HeavyPreviewPageDownWidget(
|
||||||
|
preview=reverse_lazy("contest_preview")
|
||||||
|
),
|
||||||
|
"start_time": DateTimePickerWidget(),
|
||||||
|
"end_time": DateTimePickerWidget(),
|
||||||
|
"format_name": Select2Widget(),
|
||||||
|
"scoreboard_visibility": Select2Widget(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.course_contest_instance = kwargs.pop("course_contest_instance", None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if self.course_contest_instance:
|
||||||
|
self.fields["order"].initial = self.course_contest_instance.order
|
||||||
|
self.fields["points"].initial = self.course_contest_instance.points
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
contest = super().save(commit=commit)
|
||||||
|
|
||||||
|
if self.course_contest_instance:
|
||||||
|
self.course_contest_instance.order = self.cleaned_data["order"]
|
||||||
|
self.course_contest_instance.points = self.cleaned_data["points"]
|
||||||
|
if commit:
|
||||||
|
self.course_contest_instance.save()
|
||||||
|
|
||||||
|
return contest
|
||||||
|
|
||||||
|
|
||||||
|
class EditCourseContest(CourseEditableMixin, FormView):
|
||||||
|
template_name = "course/edit_contest.html"
|
||||||
|
form_class = EditCourseContestForm
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
self.contest = get_object_or_404(Contest, key=self.kwargs["contest"])
|
||||||
|
res = super().dispatch(request, *args, **kwargs)
|
||||||
|
if not self.contest.is_in_course:
|
||||||
|
raise Http404()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
self.course_contest = get_object_or_404(
|
||||||
|
CourseContest, course=self.course, contest=self.contest
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = super().get_form_kwargs()
|
||||||
|
kwargs["instance"] = self.contest
|
||||||
|
kwargs["course_contest_instance"] = self.course_contest
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
problem_formset = self.get_problem_formset(True)
|
||||||
|
if problem_formset.is_valid():
|
||||||
|
for problem_form in problem_formset:
|
||||||
|
if problem_form.cleaned_data.get("DELETE") and problem_form.instance.pk:
|
||||||
|
problem_form.instance.delete()
|
||||||
|
|
||||||
|
for problem_form in problem_formset.save(commit=False):
|
||||||
|
if problem_form:
|
||||||
|
problem_form.contest = self.contest
|
||||||
|
problem_form.save()
|
||||||
|
|
||||||
|
return super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
|
self.object = self.contest
|
||||||
|
return self.render_to_response(
|
||||||
|
self.get_context_data(
|
||||||
|
problems_form=problem_formset,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_title(self):
|
||||||
|
return _("Edit contest")
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
with revisions.create_revision():
|
||||||
|
revisions.set_comment(_("Edited from course") + " " + self.course.name)
|
||||||
|
revisions.set_user(self.request.user)
|
||||||
|
|
||||||
|
maybe_trigger_contest_rescore(form, self.contest)
|
||||||
|
|
||||||
|
form.save()
|
||||||
|
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_problem_formset(self, post=False):
|
||||||
|
return ContestProblemFormSet(
|
||||||
|
data=self.request.POST if post else None,
|
||||||
|
prefix="problems",
|
||||||
|
queryset=ContestProblem.objects.filter(contest=self.contest).order_by(
|
||||||
|
"order"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["title"] = self.get_title()
|
||||||
|
context["content_title"] = mark_safe(
|
||||||
|
_("Edit <a href='%(url)s'>%(contest_name)s</a>")
|
||||||
|
% {
|
||||||
|
"contest_name": self.contest.name,
|
||||||
|
"url": self.contest.get_absolute_url(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if "problems_form" not in context:
|
||||||
|
context["problems_form"] = self.get_problem_formset()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse(
|
||||||
|
"edit_course_contest",
|
||||||
|
args=[self.course.slug, self.contest.key],
|
||||||
|
)
|
||||||
|
|
|
@ -69,6 +69,7 @@ from judge.utils.views import (
|
||||||
DiggPaginatorMixin,
|
DiggPaginatorMixin,
|
||||||
)
|
)
|
||||||
from judge.utils.problems import user_attempted_ids, user_completed_ids
|
from judge.utils.problems import user_attempted_ids, user_completed_ids
|
||||||
|
from judge.utils.contest import maybe_trigger_contest_rescore
|
||||||
from judge.views.problem import ProblemList
|
from judge.views.problem import ProblemList
|
||||||
from judge.views.contests import ContestList
|
from judge.views.contests import ContestList
|
||||||
from judge.views.submission import SubmissionsListBase
|
from judge.views.submission import SubmissionsListBase
|
||||||
|
@ -1038,30 +1039,8 @@ class EditOrganizationContest(
|
||||||
self.object.is_organization_private = True
|
self.object.is_organization_private = True
|
||||||
self.object.save()
|
self.object.save()
|
||||||
|
|
||||||
if any(
|
maybe_trigger_contest_rescore(form, self.object)
|
||||||
f in form.changed_data
|
|
||||||
for f in (
|
|
||||||
"start_time",
|
|
||||||
"end_time",
|
|
||||||
"time_limit",
|
|
||||||
"format_config",
|
|
||||||
"format_name",
|
|
||||||
"freeze_after",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
transaction.on_commit(rescore_contest.s(self.object.key).delay)
|
|
||||||
|
|
||||||
if any(
|
|
||||||
f in form.changed_data
|
|
||||||
for f in (
|
|
||||||
"authors",
|
|
||||||
"curators",
|
|
||||||
"testers",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
Contest._author_ids.dirty(self.object)
|
|
||||||
Contest._curator_ids.dirty(self.object)
|
|
||||||
Contest._tester_ids.dirty(self.object)
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def get_problem_formset(self, post=False):
|
def get_problem_formset(self, post=False):
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -21,6 +21,9 @@ msgstr "Nhóm"
|
||||||
msgid "About"
|
msgid "About"
|
||||||
msgstr "Giới thiệu"
|
msgstr "Giới thiệu"
|
||||||
|
|
||||||
|
msgid "Status"
|
||||||
|
msgstr "Máy chấm"
|
||||||
|
|
||||||
msgid "Courses"
|
msgid "Courses"
|
||||||
msgstr "Khóa học"
|
msgstr "Khóa học"
|
||||||
|
|
||||||
|
@ -39,9 +42,6 @@ msgstr "Đăng ký tên"
|
||||||
msgid "Report"
|
msgid "Report"
|
||||||
msgstr "Báo cáo tiêu cực"
|
msgstr "Báo cáo tiêu cực"
|
||||||
|
|
||||||
msgid "Bug Report"
|
|
||||||
msgstr "Báo cáo lỗi"
|
|
||||||
|
|
||||||
msgid "2sat"
|
msgid "2sat"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -597,8 +597,8 @@ msgstr ""
|
||||||
msgid "z-function"
|
msgid "z-function"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#~ msgid "Status"
|
#~ msgid "Bug Report"
|
||||||
#~ msgstr "Máy chấm"
|
#~ msgstr "Báo cáo lỗi"
|
||||||
|
|
||||||
#~ msgid "Insert Image"
|
#~ msgid "Insert Image"
|
||||||
#~ msgstr "Chèn hình ảnh"
|
#~ msgstr "Chèn hình ảnh"
|
||||||
|
|
|
@ -66,7 +66,7 @@
|
||||||
box-shadow: 0 2px 4px #ccc;
|
box-shadow: 0 2px 4px #ccc;
|
||||||
}
|
}
|
||||||
.lesson-title {
|
.lesson-title {
|
||||||
font-size: 1.5em;
|
font-size: 1.25em;
|
||||||
margin-left: 1em;
|
margin-left: 1em;
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
color: initial;
|
color: initial;
|
||||||
|
@ -88,7 +88,7 @@
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
background: $theme_color;
|
background: forestgreen;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
line-height: 10px;
|
line-height: 10px;
|
||||||
|
@ -131,3 +131,25 @@
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.course-contest-card {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 1.2em;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 5px 0;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,7 +9,7 @@ html {
|
||||||
color-scheme: dark !important;
|
color-scheme: dark !important;
|
||||||
}
|
}
|
||||||
iframe {
|
iframe {
|
||||||
color-scheme: initial;
|
color-scheme: dark !important;
|
||||||
}
|
}
|
||||||
html, body, input, textarea, select, button, dialog {
|
html, body, input, textarea, select, button, dialog {
|
||||||
background-color: #181a1b;
|
background-color: #181a1b;
|
||||||
|
@ -36,21 +36,8 @@ select:-webkit-autofill {
|
||||||
background-color: #404400 !important;
|
background-color: #404400 !important;
|
||||||
color: #e8e6e3 !important;
|
color: #e8e6e3 !important;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar {
|
* {
|
||||||
background-color: #202324;
|
scrollbar-color: #454a4d #202324;
|
||||||
color: #aba499;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #454a4d;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background-color: #575e62;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:active {
|
|
||||||
background-color: #484e51;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-corner {
|
|
||||||
background-color: #181a1b;
|
|
||||||
}
|
}
|
||||||
::selection {
|
::selection {
|
||||||
background-color: #004daa !important;
|
background-color: #004daa !important;
|
||||||
|
@ -63,14 +50,14 @@ select:-webkit-autofill {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Invert Style */
|
/* Invert Style */
|
||||||
.jfk-bubble.gtx-bubble, .captcheck_answer_label > input + img, span#closed_text > img[src^="https://www.gstatic.com/images/branding/googlelogo"], span[data-href^="https://www.hcaptcha.com/"] > #icon, ::-webkit-calendar-picker-indicator, img.Wirisformula {
|
.jfk-bubble.gtx-bubble, .captcheck_answer_label > input + img, span#closed_text > img[src^="https://www.gstatic.com/images/branding/googlelogo"], span[data-href^="https://www.hcaptcha.com/"] > #icon, img.Wirisformula {
|
||||||
filter: invert(100%) hue-rotate(180deg) contrast(90%) !important;
|
filter: invert(100%) hue-rotate(180deg) contrast(90%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Variables Style */
|
/* Variables Style */
|
||||||
:root {
|
:root {
|
||||||
--darkreader-neutral-background: #131516;
|
--darkreader-neutral-background: #181a1b;
|
||||||
--darkreader-neutral-text: #d8d4cf;
|
--darkreader-neutral-text: #e8e6e3;
|
||||||
--darkreader-selection-background: #004daa;
|
--darkreader-selection-background: #004daa;
|
||||||
--darkreader-selection-text: #e8e6e3;
|
--darkreader-selection-text: #e8e6e3;
|
||||||
}
|
}
|
||||||
|
@ -1843,7 +1830,7 @@ input::placeholder {
|
||||||
border-color: rgb(62, 68, 70);
|
border-color: rgb(62, 68, 70);
|
||||||
}
|
}
|
||||||
.table th {
|
.table th {
|
||||||
background-color: rgb(0, 0, 100);
|
background-color: rgb(174, 132, 26);
|
||||||
border-color: rgb(62, 68, 70);
|
border-color: rgb(62, 68, 70);
|
||||||
color: rgb(232, 230, 227);
|
color: rgb(232, 230, 227);
|
||||||
}
|
}
|
||||||
|
@ -2185,7 +2172,7 @@ svg.rate-box.rate-target circle:last-child {
|
||||||
color: rgb(232, 230, 227);
|
color: rgb(232, 230, 227);
|
||||||
}
|
}
|
||||||
#users-table th a:hover {
|
#users-table th a:hover {
|
||||||
color: rgb(26, 255, 26);
|
color: rgb(255, 211, 147);
|
||||||
}
|
}
|
||||||
#users-table tr:hover {
|
#users-table tr:hover {
|
||||||
background-color: rgb(36, 39, 40);
|
background-color: rgb(36, 39, 40);
|
||||||
|
@ -3396,7 +3383,7 @@ div.dmmd-preview-stale {
|
||||||
background-image: initial;
|
background-image: initial;
|
||||||
}
|
}
|
||||||
.lesson-list .progress-bar {
|
.lesson-list .progress-bar {
|
||||||
background-color: rgb(125, 44, 5);
|
background-color: rgb(27, 111, 27);
|
||||||
background-image: initial;
|
background-image: initial;
|
||||||
color: rgb(232, 230, 227);
|
color: rgb(232, 230, 227);
|
||||||
}
|
}
|
||||||
|
@ -3411,6 +3398,16 @@ div.dmmd-preview-stale {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration-color: initial;
|
text-decoration-color: initial;
|
||||||
}
|
}
|
||||||
|
.course-contest-card {
|
||||||
|
border-color: rgb(58, 62, 65);
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.1) 2px 2px 10px;
|
||||||
|
}
|
||||||
|
.course-contest-card h5 {
|
||||||
|
color: rgb(200, 195, 188);
|
||||||
|
}
|
||||||
|
.course-contest-card p {
|
||||||
|
color: rgb(178, 172, 162);
|
||||||
|
}
|
||||||
.fa-border {
|
.fa-border {
|
||||||
border: var(--darkreader-border--fa-border-width, .08em) var(--darkreader-border--fa-border-style, solid) var(--darkreader-border--fa-border-color, #35393b);
|
border: var(--darkreader-border--fa-border-width, .08em) var(--darkreader-border--fa-border-style, solid) var(--darkreader-border--fa-border-color, #35393b);
|
||||||
}
|
}
|
||||||
|
@ -3721,12 +3718,6 @@ div.dmmd-preview-stale {
|
||||||
border-style: initial;
|
border-style: initial;
|
||||||
border-width: 0px;
|
border-width: 0px;
|
||||||
}
|
}
|
||||||
.recently-attempted ul {
|
|
||||||
list-style-image: initial;
|
|
||||||
}
|
|
||||||
.organization-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.katex * {
|
.katex * {
|
||||||
border-color: currentcolor;
|
border-color: currentcolor;
|
||||||
}
|
}
|
||||||
|
@ -3758,9 +3749,9 @@ div.dmmd-preview-stale {
|
||||||
|
|
||||||
/* Override Style */
|
/* Override Style */
|
||||||
.vimvixen-hint {
|
.vimvixen-hint {
|
||||||
background-color: #7b5300 !important;
|
background-color: #684b00 !important;
|
||||||
border-color: #d8b013 !important;
|
border-color: #9e7e00 !important;
|
||||||
color: #f3e8c8 !important;
|
color: #d7d4cf !important;
|
||||||
}
|
}
|
||||||
#vimvixen-console-frame {
|
#vimvixen-console-frame {
|
||||||
color-scheme: light !important;
|
color-scheme: light !important;
|
||||||
|
@ -3774,7 +3765,7 @@ div.dmmd-preview-stale {
|
||||||
color: var(--darkreader-neutral-text) !important;
|
color: var(--darkreader-neutral-text) !important;
|
||||||
}
|
}
|
||||||
gr-main-header {
|
gr-main-header {
|
||||||
background-color: #0f3a48 !important;
|
background-color: #1b4958 !important;
|
||||||
}
|
}
|
||||||
.tou-z65h9k,
|
.tou-z65h9k,
|
||||||
.tou-mignzq,
|
.tou-mignzq,
|
||||||
|
@ -3783,7 +3774,7 @@ gr-main-header {
|
||||||
background-color: var(--darkreader-neutral-background) !important;
|
background-color: var(--darkreader-neutral-background) !important;
|
||||||
}
|
}
|
||||||
.tou-75mvi {
|
.tou-75mvi {
|
||||||
background-color: #032029 !important;
|
background-color: #0f3a47 !important;
|
||||||
}
|
}
|
||||||
.tou-ta9e87,
|
.tou-ta9e87,
|
||||||
.tou-1w3fhi0,
|
.tou-1w3fhi0,
|
||||||
|
@ -3792,13 +3783,13 @@ gr-main-header {
|
||||||
.tou-1lpmd9d,
|
.tou-1lpmd9d,
|
||||||
.tou-1frrtv8,
|
.tou-1frrtv8,
|
||||||
.tou-17ezmgn {
|
.tou-17ezmgn {
|
||||||
background-color: #0a0a0a !important;
|
background-color: #1e2021 !important;
|
||||||
}
|
}
|
||||||
.tou-uknfeu {
|
.tou-uknfeu {
|
||||||
background-color: #231603 !important;
|
background-color: #432c09 !important;
|
||||||
}
|
}
|
||||||
.tou-6i3zyv {
|
.tou-6i3zyv {
|
||||||
background-color: #19576c !important;
|
background-color: #245d70 !important;
|
||||||
}
|
}
|
||||||
div.mermaid-viewer-control-panel .btn {
|
div.mermaid-viewer-control-panel .btn {
|
||||||
background-color: var(--darkreader-neutral-background);
|
background-color: var(--darkreader-neutral-background);
|
||||||
|
|
|
@ -34,20 +34,12 @@
|
||||||
|
|
||||||
thead th {
|
thead th {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
border-top-left-radius: $table_header_rounding;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-top-right-radius: $table_header_rounding;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
height: 2em;
|
height: 2em;
|
||||||
color: white;
|
color: black;
|
||||||
background-color: $widget_black;
|
background-color: #DAA520;
|
||||||
border-color: #cccccc;
|
border-color: #cccccc;
|
||||||
border-width: 1px 1px 0 0;
|
border-width: 1px 1px 0 0;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
|
@ -56,14 +48,6 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
border-top-left-radius: $table_header_rounding;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-top-right-radius: $table_header_rounding;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
|
@ -74,19 +58,6 @@
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monkey-patches for awkward table rounding
|
|
||||||
tr:not(:first-child) th {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child th {
|
|
||||||
border-bottom-left-radius: $table_header_rounding;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead tr th {
|
|
||||||
border-bottom-left-radius: 0 !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#users-table th a {
|
#users-table th a {
|
||||||
|
|
|
@ -55,11 +55,11 @@ th.header.rank {
|
||||||
|
|
||||||
#users-table {
|
#users-table {
|
||||||
th a, th a:link, th a:visited {
|
th a, th a:link, th a:visited {
|
||||||
color: white;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
th a:hover {
|
th a:hover {
|
||||||
color: #0F0;
|
color: navajowhite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.about-column {
|
.about-column {
|
||||||
|
|
|
@ -87,11 +87,16 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if editable_organizations or is_clonable %}
|
{% if editable_organizations or is_clonable or editable_course %}
|
||||||
<div style="display: flex; gap: 0.5em;">
|
<div style="display: flex; gap: 0.5em;">
|
||||||
{% for org in editable_organizations %}
|
{% for org in editable_organizations %}
|
||||||
<span> [<a href="{{ url('organization_contest_edit', org.id , org.slug , contest.key) }}">{{ _('Edit in') }} {{org.slug}}</a>]</span>
|
<span> [<a href="{{ url('organization_contest_edit', org.id , org.slug , contest.key) }}">{{ _('Edit in') }} {{org.slug}}</a>]</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% if editable_course %}
|
||||||
|
<span>
|
||||||
|
[<a href="{{url('edit_course_contest', editable_course.slug, contest.key)}}"}}>{{ _('Edit in') }} {{editable_course.slug}}</a>]
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
{% if is_clonable %}
|
{% if is_clonable %}
|
||||||
<span>
|
<span>
|
||||||
[<a href="{{url('contest_clone', contest.key)}}"}}>{{_('Clone')}}</a>]
|
[<a href="{{url('contest_clone', contest.key)}}"}}>{{_('Clone')}}</a>]
|
||||||
|
|
13
templates/course/add_contest.html
Normal file
13
templates/course/add_contest.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{% extends "course/base.html" %}
|
||||||
|
|
||||||
|
{% block js_media %}
|
||||||
|
{{ form.media.js }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block two_col_media %}
|
||||||
|
{{ form.media.css }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block middle_content %}
|
||||||
|
{% include "organization/form.html" %}
|
||||||
|
{% endblock %}
|
35
templates/course/contest_list.html
Normal file
35
templates/course/contest_list.html
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
{% extends "course/base.html" %}
|
||||||
|
|
||||||
|
{% block two_col_media %}
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block middle_content %}
|
||||||
|
<div class="container">
|
||||||
|
{% if course_contests %}
|
||||||
|
{% for course_contest in course_contests %}
|
||||||
|
<div class="course-contest-card">
|
||||||
|
<div>
|
||||||
|
<h5><a href="{{url('contest_view', course_contest.contest.key)}}">{{ loop.index }}. {{ course_contest.contest.name }}</a></h5>
|
||||||
|
<p><strong>{{_("Order")}}:</strong> {{ course_contest.order }}</p>
|
||||||
|
<p><strong>{{_("Points")}}:</strong> {{ course_contest.points }}</p>
|
||||||
|
<p><strong>{{_("Start")}}:</strong> {{ course_contest.contest.start_time|date(_("H:i d/m/Y")) }}</p>
|
||||||
|
<p><strong>{{_("End")}}:</strong> {{ course_contest.contest.end_time|date(_("H:i d/m/Y")) }}</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{url('edit_course_contest', course.slug, course_contest.contest.key)}}" class="button">{{ _('Edit') }}</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p style="text-align: center;">{{_("No contests available")}}.</p>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{url('add_course_contest', course.slug)}}">
|
||||||
|
<button>{{ _('Add') }}</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
|
@ -1,11 +1,35 @@
|
||||||
{% extends "course/base.html" %}
|
{% extends "course/base.html" %}
|
||||||
|
|
||||||
|
{% block two_col_media %}
|
||||||
|
<style type="text/css">
|
||||||
|
.contest-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
.contest-details {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block js_media %}
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(document).ready(function () {
|
||||||
|
$('.time-remaining').each(function () {
|
||||||
|
count_down($(this));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block middle_content %}
|
{% block middle_content %}
|
||||||
<center><h2>{{title}}</h2></center>
|
<center><h2>{{title}}</h2></center>
|
||||||
<h3 class="course-content-title">{{_("About")}}</h3>
|
<h3 class="course-content-title">{{_("About")}}</h3>
|
||||||
<div>
|
<div>
|
||||||
{{ course.about|markdown|reference|str|safe }}
|
{{ course.about|markdown|reference|str|safe }}
|
||||||
</div>
|
</div>
|
||||||
|
{% if lessons %}
|
||||||
|
<br>
|
||||||
<h3 class="course-content-title">{{_("Lessons")}}</h3>
|
<h3 class="course-content-title">{{_("Lessons")}}</h3>
|
||||||
<ul class="lesson-list">
|
<ul class="lesson-list">
|
||||||
{% for lesson in lessons %}
|
{% for lesson in lessons %}
|
||||||
|
@ -29,8 +53,66 @@
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% if course_contests %}
|
||||||
|
<br>
|
||||||
|
<h3 class="course-content-title">{{_("Contests")}}</h3>
|
||||||
|
<br>
|
||||||
|
<table class="table striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{_("Name")}}</th>
|
||||||
|
<th>{{_("Start")}}</th>
|
||||||
|
<th>{{_("End")}}</th>
|
||||||
|
<th>{{_("Length")}}</th>
|
||||||
|
<th>{{_("Score")}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for course_contest in course_contests %}
|
||||||
|
{% set contest = course_contest.contest %}
|
||||||
|
{% set progress = contest_progress[course_contest.id] %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url('contest_view', contest.key) }}" class="contest-name">{{ contest.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ contest.start_time|date(_("H:i d/m/Y")) }}
|
||||||
|
<div class="contest-details">
|
||||||
|
{% if contest.time_before_start %}
|
||||||
|
<span class="time">{{ _('Starting in %(countdown)s.', countdown=contest.start_time|as_countdown) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ contest.end_time|date(_("H:i d/m/Y"))}}
|
||||||
|
<div class="contest-details">
|
||||||
|
{% if contest.time_before_end %}
|
||||||
|
<span class="time">{% trans countdown=contest.end_time|as_countdown %}Ends in {{ countdown }}{% endtrans %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if contest.time_limit %}
|
||||||
|
{% trans time_limit=contest.time_limit|timedelta('localized-no-seconds') %}{{ time_limit }}{% endtrans %}
|
||||||
|
{% else %}
|
||||||
|
{% trans duration=contest.contest_window_length|timedelta('localized-no-seconds') %}{{ duration }}{% endtrans %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if progress['total_points'] %}
|
||||||
|
{{ (progress['achieved_points'] / progress['total_points'] * course_contest.points) | floatformat(1) }} / {{ course_contest.points }}
|
||||||
|
{% else %}
|
||||||
|
0 / {{ course_contest.points }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
<br>
|
||||||
<h3 class="course-content-title">
|
<h3 class="course-content-title">
|
||||||
{% set total_progress = lesson_progress['total'] %}
|
|
||||||
{% set achieved_points = total_progress['achieved_points'] %}
|
{% set achieved_points = total_progress['achieved_points'] %}
|
||||||
{% set total_points = total_progress['total_points'] %}
|
{% set total_points = total_progress['total_points'] %}
|
||||||
{% set percentage = total_progress['percentage'] %}
|
{% set percentage = total_progress['percentage'] %}
|
||||||
|
|
92
templates/course/edit_contest.html
Normal file
92
templates/course/edit_contest.html
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
{% extends "course/base.html" %}
|
||||||
|
|
||||||
|
{% block js_media %}
|
||||||
|
{{ form.media.js }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block two_col_media %}
|
||||||
|
{{ form.media.css }}
|
||||||
|
<style>
|
||||||
|
#org-field-wrapper-order,
|
||||||
|
#org-field-wrapper-points,
|
||||||
|
#org-field-wrapper-scoreboard_visibility,
|
||||||
|
#org-field-wrapper-points_precision,
|
||||||
|
#org-field-wrapper-start_time,
|
||||||
|
#org-field-wrapper-end_time,
|
||||||
|
#org-field-wrapper-time_limit,
|
||||||
|
#org-field-wrapper-format_name,
|
||||||
|
#org-field-wrapper-freeze_after,
|
||||||
|
#org-field-wrapper-rate_limit {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.problems-problem {
|
||||||
|
max-width: 60vh;
|
||||||
|
}
|
||||||
|
input[type=number] {
|
||||||
|
width: 5em;
|
||||||
|
}
|
||||||
|
.middle-content {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
#three-col-container {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block middle_content %}
|
||||||
|
<center><h2>{{content_title}}</h2></center>
|
||||||
|
<form action="" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form.errors or problems_form.errors %}
|
||||||
|
<div class="alert alert-danger alert-dismissable">
|
||||||
|
<a href="#" class="close">x</a>
|
||||||
|
{{_("Please fix below errors")}}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% for field in form %}
|
||||||
|
{% if not field.is_hidden %}
|
||||||
|
<div style="margin-bottom: 1em;">
|
||||||
|
{{ field.errors }}
|
||||||
|
<label for="{{field.id_for_label }}"><b>{{ field.label }}{% if field.field.required %}<span class="red"> * </span>{% endif %}:</b> </label>
|
||||||
|
<div class="org-field-wrapper" id="org-field-wrapper-{{field.html_name }}">
|
||||||
|
{{ field }}
|
||||||
|
</div>
|
||||||
|
{% if field.help_text %}
|
||||||
|
<small class="org-help-text"><i class="fa fa-exclamation-circle"></i> {{ field.help_text|safe }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<hr><br>
|
||||||
|
{{ problems_form.management_form }}
|
||||||
|
<i>{{_('If you run out of rows, click Save')}}</i>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for field in problems_form[0] %}
|
||||||
|
{% if not field.is_hidden %}
|
||||||
|
<th class="problems-{{field.name}}">
|
||||||
|
{{field.label}}
|
||||||
|
</th>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for form in problems_form %}
|
||||||
|
<tr>
|
||||||
|
{% for field in form %}
|
||||||
|
<td class="problems-{{field.name}}" title="
|
||||||
|
{{ field.help_text|safe if field.help_text }}"
|
||||||
|
style="{{ 'display:none' if field.is_hidden }}"
|
||||||
|
>{{field}}<div class="red">{{field.errors}}</div></td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button type="submit">{{ _('Save') }}</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
|
@ -75,32 +75,41 @@
|
||||||
|
|
||||||
{% block middle_content %}
|
{% block middle_content %}
|
||||||
<center><h2>{{content_title}}</h2></center>
|
<center><h2>{{content_title}}</h2></center>
|
||||||
{% set lessons = course.lessons.order_by('order') %}
|
|
||||||
{{_("Sort by")}}:
|
{{_("Sort by")}}:
|
||||||
<select id="sortSelect">
|
<select id="sortSelect">
|
||||||
<option value="username">{{_("Username")}}</option>
|
<option value="username">{{_("Username")}}</option>
|
||||||
<option value="total">{{_("Score")}}</option>
|
<option value="total">{{_("Score")}}</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" id="search-input" placeholder="{{_('Search')}}" autofocus>
|
<input type="text" id="search-input" placeholder="{{_('Search')}}" autofocus>
|
||||||
|
<div style="overflow-x: auto; margin-top: 1em">
|
||||||
<table class="table striped" id="users-table">
|
<table class="table striped" id="users-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{_('Student')}}</th>
|
<th>{{_('Student')}}</th>
|
||||||
{% if grades|length > 0 %}
|
<th>{{_('Total')}}</th>
|
||||||
{% for lesson in lessons %}
|
{% for lesson in lessons %}
|
||||||
<th class="points">
|
<th class="points" title="{{lesson.title}}">
|
||||||
<a href="{{url('course_grades_lesson', course.slug, lesson.id)}}">
|
<a href="{{url('course_grades_lesson', course.slug, lesson.id)}}">
|
||||||
{{ lesson.title }}
|
L{{ loop.index0 + 1 }}
|
||||||
<div class="point-denominator">{{lesson.points}}</div>
|
<div class="point-denominator">{{lesson.points}}</div>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% for course_contest in course_contests %}
|
||||||
<th>{{_('Total')}}</th>
|
<th class="points" title="{{course_contest.contest.name}}">
|
||||||
|
<a href="{{url('contest_ranking', course_contest.contest.key)}}">
|
||||||
|
C{{ loop.index0 + 1 }}
|
||||||
|
<div class="point-denominator">{{course_contest.points}}</div>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for student, grade in grades.items() %}
|
{% for student in grade_total.keys() %}
|
||||||
|
{% set grade_lessons_student = grade_lessons.get(student) %}
|
||||||
|
{% set grade_contests_student = grade_contests.get(student) %}
|
||||||
|
{% set grade_total_student = grade_total.get(student) %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="user-name">
|
<td class="user-name">
|
||||||
<div>
|
<div>
|
||||||
|
@ -110,8 +119,15 @@
|
||||||
{{student.first_name}}
|
{{student.first_name}}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td style="font-weight: bold">
|
||||||
|
{% if grade_total_student %}
|
||||||
|
{{ grade_total_student['percentage'] | floatformat(0) }}%
|
||||||
|
{% else %}
|
||||||
|
0
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
{% for lesson in lessons %}
|
{% for lesson in lessons %}
|
||||||
{% set val = grade.get(lesson.id) %}
|
{% set val = grade_lessons_student.get(lesson.id) %}
|
||||||
<td class="partial-score">
|
<td class="partial-score">
|
||||||
<a href="{{url('course_lesson_detail', course.slug, lesson.id)}}?user={{student.username}}">
|
<a href="{{url('course_lesson_detail', course.slug, lesson.id)}}?user={{student.username}}">
|
||||||
{% if val and val['total_points'] %}
|
{% if val and val['total_points'] %}
|
||||||
|
@ -122,11 +138,21 @@
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<td style="font-weight: bold">
|
{% for course_contest in course_contests %}
|
||||||
{{ grade['total']['percentage'] | floatformat(0) }}%
|
{% set val = grade_contests_student.get(course_contest.id) %}
|
||||||
|
<td class="partial-score">
|
||||||
|
<a href="{{url('contest_ranking', course_contest.contest.key)}}#!{{student.username}}">
|
||||||
|
{% if val and val['total_points'] %}
|
||||||
|
{{ (val['achieved_points'] / val['total_points'] * course_contest.points) | floatformat(0) }}
|
||||||
|
{% else %}
|
||||||
|
0
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
|
@ -2,6 +2,7 @@
|
||||||
{{ make_tab_item('home', 'fa fa-home', course.get_absolute_url(), _('Home')) }}
|
{{ make_tab_item('home', 'fa fa-home', course.get_absolute_url(), _('Home')) }}
|
||||||
{% if is_editable %}
|
{% if is_editable %}
|
||||||
{{ make_tab_item('edit_lesson', 'fa fa-edit', url('edit_course_lessons', course.slug), _('Edit lessons')) }}
|
{{ make_tab_item('edit_lesson', 'fa fa-edit', url('edit_course_lessons', course.slug), _('Edit lessons')) }}
|
||||||
|
{{ make_tab_item('contests', 'fa fa-ranking-star', url('course_contest_list', course.slug), _('Contests')) }}
|
||||||
{{ make_tab_item('grades', 'fa fa-check-square', url('course_grades', course.slug), _('Grades')) }}
|
{{ make_tab_item('grades', 'fa fa-check-square', url('course_grades', course.slug), _('Grades')) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.judge.change_course %}
|
{% if perms.judge.change_course %}
|
||||||
|
|
Loading…
Reference in a new issue