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(),
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.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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
@ -130,4 +130,26 @@
text-decoration: none;
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;
}
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);

View file

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

View file

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

View file

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

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" %}
{% 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'] %}

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

View file

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