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(),
|
||||
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.pagevote import PageVote, PageVoteVoter
|
||||
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.test_formatter import TestFormatterModel
|
||||
|
||||
|
|
|
@ -246,6 +246,10 @@ class Contest(models.Model, PageVotable, Bookmarkable):
|
|||
verbose_name=_("organizations"),
|
||||
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(
|
||||
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:
|
||||
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
|
||||
if not self.is_private and not self.is_organization_private:
|
||||
return
|
||||
|
@ -612,7 +624,10 @@ class Contest(models.Model, PageVotable, Bookmarkable):
|
|||
if not user.is_authenticated:
|
||||
return (
|
||||
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")
|
||||
.distinct()
|
||||
|
@ -626,7 +641,7 @@ class Contest(models.Model, PageVotable, Bookmarkable):
|
|||
)
|
||||
or show_own_contests_only
|
||||
):
|
||||
q = Q(is_visible=True)
|
||||
q = Q(is_visible=True, is_in_course=False)
|
||||
q &= (
|
||||
Q(view_contest_scoreboard=user.profile)
|
||||
| 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.db.models import Q
|
||||
|
||||
from judge.models import BlogPost, Problem
|
||||
from judge.models import BlogPost, Problem, Contest
|
||||
from judge.models.profile import Organization, Profile
|
||||
|
||||
|
||||
|
@ -160,10 +160,11 @@ class CourseLesson(models.Model):
|
|||
related_name="lessons",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
title = models.TextField(verbose_name=_("course title"))
|
||||
content = models.TextField(verbose_name=_("course content"))
|
||||
title = models.TextField(verbose_name=_("lesson title"))
|
||||
content = models.TextField(verbose_name=_("lesson content"))
|
||||
order = models.IntegerField(verbose_name=_("order"), default=0)
|
||||
points = models.IntegerField(verbose_name=_("points"))
|
||||
is_visible = models.BooleanField(verbose_name=_("publicly visible"), default=True)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
|
@ -182,3 +183,19 @@ class CourseLessonProblem(models.Model):
|
|||
problem = models.ForeignKey(Problem, on_delete=models.CASCADE)
|
||||
order = models.IntegerField(verbose_name=_("order"), 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["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:
|
||||
context["current_contest"] = self.request.participation.contest
|
||||
else:
|
||||
|
|
|
@ -12,28 +12,42 @@ from django.forms import (
|
|||
)
|
||||
from django.views.generic.edit import FormView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse_lazy
|
||||
from django.db.models import Max, F
|
||||
from django.urls import reverse_lazy, reverse
|
||||
from django.db.models import Max, F, Sum
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from judge.models import (
|
||||
Course,
|
||||
Contest,
|
||||
CourseLesson,
|
||||
Submission,
|
||||
Profile,
|
||||
CourseRole,
|
||||
CourseLessonProblem,
|
||||
CourseContest,
|
||||
ContestProblem,
|
||||
ContestParticipation,
|
||||
)
|
||||
from judge.models.course import RoleInCourse
|
||||
from judge.widgets import (
|
||||
HeavyPreviewPageDownWidget,
|
||||
HeavySelect2MultipleWidget,
|
||||
HeavySelect2Widget,
|
||||
DateTimePickerWidget,
|
||||
Select2MultipleWidget,
|
||||
Select2Widget,
|
||||
)
|
||||
from judge.forms import (
|
||||
ContestProblemFormSet,
|
||||
)
|
||||
from judge.utils.problems import (
|
||||
user_attempted_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):
|
||||
|
@ -85,6 +99,65 @@ def calculate_lessons_progress(profile, lessons):
|
|||
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):
|
||||
model = Course
|
||||
template_name = "course/list.html"
|
||||
|
@ -130,13 +203,34 @@ class CourseDetail(CourseDetailMixin, DetailView):
|
|||
|
||||
def get_context_data(self, **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["page_type"] = "home"
|
||||
context["lessons"] = lessons
|
||||
context["lesson_progress"] = calculate_lessons_progress(
|
||||
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
|
||||
|
||||
|
||||
|
@ -149,6 +243,11 @@ class CourseLessonDetail(CourseDetailMixin, DetailView):
|
|||
self.lesson = CourseLesson.objects.get(
|
||||
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
|
||||
except ObjectDoesNotExist:
|
||||
raise Http404()
|
||||
|
@ -190,7 +289,7 @@ class CourseLessonDetail(CourseDetailMixin, DetailView):
|
|||
class CourseLessonForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = CourseLesson
|
||||
fields = ["order", "title", "points", "content"]
|
||||
fields = ["order", "title", "is_visible", "points", "content"]
|
||||
widgets = {
|
||||
"title": forms.TextInput(),
|
||||
"content": HeavyPreviewPageDownWidget(preview=reverse_lazy("blog_preview")),
|
||||
|
@ -312,9 +411,29 @@ class CourseStudentResults(CourseEditableMixin, DetailView):
|
|||
def get_grades(self):
|
||||
students = self.course.get_students()
|
||||
students.sort(key=lambda u: u.username.lower())
|
||||
lessons = self.course.lessons.prefetch_related("lesson_problems").all()
|
||||
grades = {s: calculate_lessons_progress(s, lessons) for s in students}
|
||||
return grades
|
||||
lessons = (
|
||||
self.course.lessons.filter(is_visible=True)
|
||||
.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):
|
||||
context = super(CourseStudentResults, self).get_context_data(**kwargs)
|
||||
|
@ -329,7 +448,19 @@ class CourseStudentResults(CourseEditableMixin, DetailView):
|
|||
}
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
|
@ -392,3 +523,255 @@ class CourseStudentResultsLesson(CourseEditableMixin, DetailView):
|
|||
context["page_type"] = "grades"
|
||||
context["grades"] = self.get_lesson_grades()
|
||||
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,
|
||||
)
|
||||
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.contests import ContestList
|
||||
from judge.views.submission import SubmissionsListBase
|
||||
|
@ -1038,30 +1039,8 @@ class EditOrganizationContest(
|
|||
self.object.is_organization_private = True
|
||||
self.object.save()
|
||||
|
||||
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(self.object.key).delay)
|
||||
maybe_trigger_contest_rescore(form, self.object)
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
msgstr "Giới thiệu"
|
||||
|
||||
msgid "Status"
|
||||
msgstr "Máy chấm"
|
||||
|
||||
msgid "Courses"
|
||||
msgstr "Khóa học"
|
||||
|
||||
|
@ -39,9 +42,6 @@ msgstr "Đăng ký tên"
|
|||
msgid "Report"
|
||||
msgstr "Báo cáo tiêu cực"
|
||||
|
||||
msgid "Bug Report"
|
||||
msgstr "Báo cáo lỗi"
|
||||
|
||||
msgid "2sat"
|
||||
msgstr ""
|
||||
|
||||
|
@ -597,8 +597,8 @@ msgstr ""
|
|||
msgid "z-function"
|
||||
msgstr ""
|
||||
|
||||
#~ msgid "Status"
|
||||
#~ msgstr "Máy chấm"
|
||||
#~ msgid "Bug Report"
|
||||
#~ msgstr "Báo cáo lỗi"
|
||||
|
||||
#~ msgid "Insert Image"
|
||||
#~ msgstr "Chèn hình ảnh"
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
box-shadow: 0 2px 4px #ccc;
|
||||
}
|
||||
.lesson-title {
|
||||
font-size: 1.5em;
|
||||
font-size: 1.25em;
|
||||
margin-left: 1em;
|
||||
margin-right: 1em;
|
||||
color: initial;
|
||||
|
@ -88,7 +88,7 @@
|
|||
margin-top: 10px;
|
||||
}
|
||||
.progress-bar {
|
||||
background: $theme_color;
|
||||
background: forestgreen;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
line-height: 10px;
|
||||
|
@ -131,3 +131,25 @@
|
|||
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;
|
||||
}
|
||||
iframe {
|
||||
color-scheme: initial;
|
||||
color-scheme: dark !important;
|
||||
}
|
||||
html, body, input, textarea, select, button, dialog {
|
||||
background-color: #181a1b;
|
||||
|
@ -36,21 +36,8 @@ select:-webkit-autofill {
|
|||
background-color: #404400 !important;
|
||||
color: #e8e6e3 !important;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
background-color: #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;
|
||||
* {
|
||||
scrollbar-color: #454a4d #202324;
|
||||
}
|
||||
::selection {
|
||||
background-color: #004daa !important;
|
||||
|
@ -63,14 +50,14 @@ select:-webkit-autofill {
|
|||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Variables Style */
|
||||
:root {
|
||||
--darkreader-neutral-background: #131516;
|
||||
--darkreader-neutral-text: #d8d4cf;
|
||||
--darkreader-neutral-background: #181a1b;
|
||||
--darkreader-neutral-text: #e8e6e3;
|
||||
--darkreader-selection-background: #004daa;
|
||||
--darkreader-selection-text: #e8e6e3;
|
||||
}
|
||||
|
@ -1843,7 +1830,7 @@ input::placeholder {
|
|||
border-color: rgb(62, 68, 70);
|
||||
}
|
||||
.table th {
|
||||
background-color: rgb(0, 0, 100);
|
||||
background-color: rgb(174, 132, 26);
|
||||
border-color: rgb(62, 68, 70);
|
||||
color: rgb(232, 230, 227);
|
||||
}
|
||||
|
@ -2185,7 +2172,7 @@ svg.rate-box.rate-target circle:last-child {
|
|||
color: rgb(232, 230, 227);
|
||||
}
|
||||
#users-table th a:hover {
|
||||
color: rgb(26, 255, 26);
|
||||
color: rgb(255, 211, 147);
|
||||
}
|
||||
#users-table tr:hover {
|
||||
background-color: rgb(36, 39, 40);
|
||||
|
@ -3396,7 +3383,7 @@ div.dmmd-preview-stale {
|
|||
background-image: initial;
|
||||
}
|
||||
.lesson-list .progress-bar {
|
||||
background-color: rgb(125, 44, 5);
|
||||
background-color: rgb(27, 111, 27);
|
||||
background-image: initial;
|
||||
color: rgb(232, 230, 227);
|
||||
}
|
||||
|
@ -3411,6 +3398,16 @@ div.dmmd-preview-stale {
|
|||
color: inherit;
|
||||
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 {
|
||||
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-width: 0px;
|
||||
}
|
||||
.recently-attempted ul {
|
||||
list-style-image: initial;
|
||||
}
|
||||
.organization-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.katex * {
|
||||
border-color: currentcolor;
|
||||
}
|
||||
|
@ -3758,9 +3749,9 @@ div.dmmd-preview-stale {
|
|||
|
||||
/* Override Style */
|
||||
.vimvixen-hint {
|
||||
background-color: #7b5300 !important;
|
||||
border-color: #d8b013 !important;
|
||||
color: #f3e8c8 !important;
|
||||
background-color: #684b00 !important;
|
||||
border-color: #9e7e00 !important;
|
||||
color: #d7d4cf !important;
|
||||
}
|
||||
#vimvixen-console-frame {
|
||||
color-scheme: light !important;
|
||||
|
@ -3774,7 +3765,7 @@ div.dmmd-preview-stale {
|
|||
color: var(--darkreader-neutral-text) !important;
|
||||
}
|
||||
gr-main-header {
|
||||
background-color: #0f3a48 !important;
|
||||
background-color: #1b4958 !important;
|
||||
}
|
||||
.tou-z65h9k,
|
||||
.tou-mignzq,
|
||||
|
@ -3783,7 +3774,7 @@ gr-main-header {
|
|||
background-color: var(--darkreader-neutral-background) !important;
|
||||
}
|
||||
.tou-75mvi {
|
||||
background-color: #032029 !important;
|
||||
background-color: #0f3a47 !important;
|
||||
}
|
||||
.tou-ta9e87,
|
||||
.tou-1w3fhi0,
|
||||
|
@ -3792,13 +3783,13 @@ gr-main-header {
|
|||
.tou-1lpmd9d,
|
||||
.tou-1frrtv8,
|
||||
.tou-17ezmgn {
|
||||
background-color: #0a0a0a !important;
|
||||
background-color: #1e2021 !important;
|
||||
}
|
||||
.tou-uknfeu {
|
||||
background-color: #231603 !important;
|
||||
background-color: #432c09 !important;
|
||||
}
|
||||
.tou-6i3zyv {
|
||||
background-color: #19576c !important;
|
||||
background-color: #245d70 !important;
|
||||
}
|
||||
div.mermaid-viewer-control-panel .btn {
|
||||
background-color: var(--darkreader-neutral-background);
|
||||
|
|
|
@ -34,20 +34,12 @@
|
|||
|
||||
thead th {
|
||||
vertical-align: middle;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: $table_header_rounding;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: $table_header_rounding;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
height: 2em;
|
||||
color: white;
|
||||
background-color: $widget_black;
|
||||
color: black;
|
||||
background-color: #DAA520;
|
||||
border-color: #cccccc;
|
||||
border-width: 1px 1px 0 0;
|
||||
border-style: solid;
|
||||
|
@ -56,14 +48,6 @@
|
|||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: $table_header_rounding;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: $table_header_rounding;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
|
@ -74,19 +58,6 @@
|
|||
vertical-align: middle;
|
||||
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 {
|
||||
|
|
|
@ -55,11 +55,11 @@ th.header.rank {
|
|||
|
||||
#users-table {
|
||||
th a, th a:link, th a:visited {
|
||||
color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
th a:hover {
|
||||
color: #0F0;
|
||||
color: navajowhite;
|
||||
}
|
||||
|
||||
.about-column {
|
||||
|
|
|
@ -87,11 +87,16 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if editable_organizations or is_clonable %}
|
||||
{% if editable_organizations or is_clonable or editable_course %}
|
||||
<div style="display: flex; gap: 0.5em;">
|
||||
{% for org in editable_organizations %}
|
||||
<span> [<a href="{{ url('organization_contest_edit', org.id , org.slug , contest.key) }}">{{ _('Edit in') }} {{org.slug}}</a>]</span>
|
||||
{% 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 %}
|
||||
<span>
|
||||
[<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,36 +1,118 @@
|
|||
{% 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 %}
|
||||
<center><h2>{{title}}</h2></center>
|
||||
<h3 class="course-content-title">{{_("About")}}</h3>
|
||||
<div>
|
||||
{{ course.about|markdown|reference|str|safe }}
|
||||
</div>
|
||||
<h3 class="course-content-title">{{_("Lessons")}}</h3>
|
||||
<ul class="lesson-list">
|
||||
{% for lesson in lessons %}
|
||||
<a href="{{url('course_lesson_detail', course.slug, lesson.id)}}">
|
||||
{% set progress = lesson_progress[lesson.id] %}
|
||||
<li>
|
||||
<div class="lesson-title">
|
||||
{{ lesson.title }}
|
||||
<div class="lesson-points">
|
||||
{% if progress['total_points'] %}
|
||||
{{(progress['achieved_points'] / progress['total_points'] * lesson.points) | floatformat(1)}} / {{lesson.points}}
|
||||
{% else %}
|
||||
0 / {{lesson.points}}
|
||||
{% endif %}
|
||||
{% if lessons %}
|
||||
<br>
|
||||
<h3 class="course-content-title">{{_("Lessons")}}</h3>
|
||||
<ul class="lesson-list">
|
||||
{% for lesson in lessons %}
|
||||
<a href="{{url('course_lesson_detail', course.slug, lesson.id)}}">
|
||||
{% set progress = lesson_progress[lesson.id] %}
|
||||
<li>
|
||||
<div class="lesson-title">
|
||||
{{ lesson.title }}
|
||||
<div class="lesson-points">
|
||||
{% if progress['total_points'] %}
|
||||
{{(progress['achieved_points'] / progress['total_points'] * lesson.points) | floatformat(1)}} / {{lesson.points}}
|
||||
{% else %}
|
||||
0 / {{lesson.points}}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" style="width: {{progress['percentage']}}%;">{{progress['percentage']|floatformat(0)}}%</div>
|
||||
</div>
|
||||
</li>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" style="width: {{progress['percentage']}}%;">{{progress['percentage']|floatformat(0)}}%</div>
|
||||
</div>
|
||||
</li>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</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">
|
||||
{% set total_progress = lesson_progress['total'] %}
|
||||
{% set achieved_points = total_progress['achieved_points'] %}
|
||||
{% set total_points = total_progress['total_points'] %}
|
||||
{% 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,58 +75,84 @@
|
|||
|
||||
{% block middle_content %}
|
||||
<center><h2>{{content_title}}</h2></center>
|
||||
{% set lessons = course.lessons.order_by('order') %}
|
||||
{{_("Sort by")}}:
|
||||
<select id="sortSelect">
|
||||
<option value="username">{{_("Username")}}</option>
|
||||
<option value="total">{{_("Score")}}</option>
|
||||
</select>
|
||||
<input type="text" id="search-input" placeholder="{{_('Search')}}" autofocus>
|
||||
<table class="table striped" id="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{_('Student')}}</th>
|
||||
{% if grades|length > 0 %}
|
||||
<div style="overflow-x: auto; margin-top: 1em">
|
||||
<table class="table striped" id="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{_('Student')}}</th>
|
||||
<th>{{_('Total')}}</th>
|
||||
{% for lesson in lessons %}
|
||||
<th class="points">
|
||||
<th class="points" title="{{lesson.title}}">
|
||||
<a href="{{url('course_grades_lesson', course.slug, lesson.id)}}">
|
||||
{{ lesson.title }}
|
||||
L{{ loop.index0 + 1 }}
|
||||
<div class="point-denominator">{{lesson.points}}</div>
|
||||
</a>
|
||||
</th>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<th>{{_('Total')}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for student, grade in grades.items() %}
|
||||
<tr>
|
||||
<td class="user-name">
|
||||
<div>
|
||||
{{link_user(student)}}
|
||||
</div>
|
||||
<div>
|
||||
{{student.first_name}}
|
||||
</div>
|
||||
</td>
|
||||
{% for lesson in lessons %}
|
||||
{% set val = grade.get(lesson.id) %}
|
||||
<td class="partial-score">
|
||||
<a href="{{url('course_lesson_detail', course.slug, lesson.id)}}?user={{student.username}}">
|
||||
{% if val and val['total_points'] %}
|
||||
{{ (val['achieved_points'] / val['total_points'] * lesson.points) | floatformat(0) }}
|
||||
{% else %}
|
||||
0
|
||||
{% endif %}
|
||||
{% for course_contest in course_contests %}
|
||||
<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>
|
||||
</td>
|
||||
</th>
|
||||
{% endfor %}
|
||||
<td style="font-weight: bold">
|
||||
{{ grade['total']['percentage'] | floatformat(0) }}%
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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>
|
||||
<td class="user-name">
|
||||
<div>
|
||||
{{link_user(student)}}
|
||||
</div>
|
||||
<div>
|
||||
{{student.first_name}}
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-weight: bold">
|
||||
{% if grade_total_student %}
|
||||
{{ grade_total_student['percentage'] | floatformat(0) }}%
|
||||
{% else %}
|
||||
0
|
||||
{% endif %}
|
||||
</td>
|
||||
{% for lesson in lessons %}
|
||||
{% set val = grade_lessons_student.get(lesson.id) %}
|
||||
<td class="partial-score">
|
||||
<a href="{{url('course_lesson_detail', course.slug, lesson.id)}}?user={{student.username}}">
|
||||
{% if val and val['total_points'] %}
|
||||
{{ (val['achieved_points'] / val['total_points'] * lesson.points) | floatformat(0) }}
|
||||
{% else %}
|
||||
0
|
||||
{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
{% endfor %}
|
||||
{% for course_contest in course_contests %}
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -2,6 +2,7 @@
|
|||
{{ make_tab_item('home', 'fa fa-home', course.get_absolute_url(), _('Home')) }}
|
||||
{% if is_editable %}
|
||||
{{ 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')) }}
|
||||
{% endif %}
|
||||
{% if perms.judge.change_course %}
|
||||
|
|
Loading…
Reference in a new issue