Add contest to course (#126)

This commit is contained in:
Phuoc Anh Kha Le 2024-10-02 15:06:33 -05:00 committed by GitHub
parent 72eada0a4e
commit 3d67fb274e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1258 additions and 433 deletions

View file

@ -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",
),
] ]
), ),
), ),

View 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",
),
),
],
),
]

View file

@ -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

View file

@ -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)

View file

@ -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
View 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)

View file

@ -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:

View file

@ -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],
)

View file

@ -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

View file

@ -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"

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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 {

View file

@ -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 {

View file

@ -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>]

View 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 %}

View 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 %}

View file

@ -1,36 +1,118 @@
{% 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>
<h3 class="course-content-title">{{_("Lessons")}}</h3> {% if lessons %}
<ul class="lesson-list"> <br>
{% for lesson in lessons %} <h3 class="course-content-title">{{_("Lessons")}}</h3>
<a href="{{url('course_lesson_detail', course.slug, lesson.id)}}"> <ul class="lesson-list">
{% set progress = lesson_progress[lesson.id] %} {% for lesson in lessons %}
<li> <a href="{{url('course_lesson_detail', course.slug, lesson.id)}}">
<div class="lesson-title"> {% set progress = lesson_progress[lesson.id] %}
{{ lesson.title }} <li>
<div class="lesson-points"> <div class="lesson-title">
{% if progress['total_points'] %} {{ lesson.title }}
{{(progress['achieved_points'] / progress['total_points'] * lesson.points) | floatformat(1)}} / {{lesson.points}} <div class="lesson-points">
{% else %} {% if progress['total_points'] %}
0 / {{lesson.points}} {{(progress['achieved_points'] / progress['total_points'] * lesson.points) | floatformat(1)}} / {{lesson.points}}
{% endif %} {% else %}
0 / {{lesson.points}}
{% endif %}
</div>
</div> </div>
</div> <div class="progress-container">
<div class="progress-container"> <div class="progress-bar" style="width: {{progress['percentage']}}%;">{{progress['percentage']|floatformat(0)}}%</div>
<div class="progress-bar" style="width: {{progress['percentage']}}%;">{{progress['percentage']|floatformat(0)}}%</div> </div>
</div> </li>
</li> </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'] %}

View 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 %}

View file

@ -75,58 +75,84 @@
{% 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>
<table class="table striped" id="users-table"> <div style="overflow-x: auto; margin-top: 1em">
<thead> <table class="table striped" id="users-table">
<tr> <thead>
<th>{{_('Student')}}</th> <tr>
{% if grades|length > 0 %} <th>{{_('Student')}}</th>
<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}}">
</tr> <a href="{{url('contest_ranking', course_contest.contest.key)}}">
</thead> C{{ loop.index0 + 1 }}
<tbody> <div class="point-denominator">{{course_contest.points}}</div>
{% 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 %}
</a> </a>
</td> </th>
{% endfor %} {% endfor %}
<td style="font-weight: bold">
{{ grade['total']['percentage'] | floatformat(0) }}%
</td>
</tr> </tr>
{% endfor %} </thead>
</tbody> <tbody>
</table> {% 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 %} {% endblock %}

View file

@ -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 %}