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

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