diff --git a/dmoj/urls.py b/dmoj/urls.py index 72cb56f..278b815 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -564,6 +564,11 @@ urlpatterns = [ course.CourseStudentResults.as_view(), name="course_grades", ), + url( + r"^/grades/lesson/(?P\d+)$", + course.CourseStudentResultsLesson.as_view(), + name="course_grades_lesson", + ), ] ), ), diff --git a/judge/migrations/0192_course_lesson_problem.py b/judge/migrations/0192_course_lesson_problem.py new file mode 100644 index 0000000..89a1185 --- /dev/null +++ b/judge/migrations/0192_course_lesson_problem.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.21 on 2024-09-02 05:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("judge", "0191_deprecate_old_org_image"), + ] + + operations = [ + migrations.CreateModel( + name="CourseLessonProblem", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("order", models.IntegerField(default=0, verbose_name="order")), + ("score", models.IntegerField(default=0, verbose_name="score")), + ( + "lesson", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="lesson_problems", + to="judge.courselesson", + ), + ), + ( + "problem", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="judge.problem" + ), + ), + ], + ), + ] diff --git a/judge/models/__init__.py b/judge/models/__init__.py index 0c69602..b4467a3 100644 --- a/judge/models/__init__.py +++ b/judge/models/__init__.py @@ -61,7 +61,7 @@ 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 +from judge.models.course import Course, CourseRole, CourseLesson, CourseLessonProblem from judge.models.notification import Notification, NotificationProfile from judge.models.test_formatter import TestFormatterModel diff --git a/judge/models/course.py b/judge/models/course.py index caee007..9b02d63 100644 --- a/judge/models/course.py +++ b/judge/models/course.py @@ -165,3 +165,12 @@ class CourseLesson(models.Model): problems = models.ManyToManyField(Problem, verbose_name=_("problem"), blank=True) order = models.IntegerField(verbose_name=_("order"), default=0) points = models.IntegerField(verbose_name=_("points")) + + +class CourseLessonProblem(models.Model): + lesson = models.ForeignKey( + CourseLesson, on_delete=models.CASCADE, related_name="lesson_problems" + ) + problem = models.ForeignKey(Problem, on_delete=models.CASCADE) + order = models.IntegerField(verbose_name=_("order"), default=0) + score = models.IntegerField(verbose_name=_("score"), default=0) diff --git a/judge/views/course.py b/judge/views/course.py index fbda7bd..ce74d4a 100644 --- a/judge/views/course.py +++ b/judge/views/course.py @@ -4,16 +4,32 @@ from django.views.generic import ListView, DetailView, View from django.utils.translation import gettext, gettext_lazy as _ from django.http import Http404 from django import forms -from django.forms import inlineformset_factory +from django.forms import ( + inlineformset_factory, + ModelForm, + modelformset_factory, + BaseModelFormSet, +) 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.core.exceptions import ObjectDoesNotExist -from judge.models import Course, CourseLesson, Submission, Profile, CourseRole +from judge.models import ( + Course, + CourseLesson, + Submission, + Profile, + CourseRole, + CourseLessonProblem, +) from judge.models.course import RoleInCourse -from judge.widgets import HeavyPreviewPageDownWidget, HeavySelect2MultipleWidget +from judge.widgets import ( + HeavyPreviewPageDownWidget, + HeavySelect2MultipleWidget, + HeavySelect2Widget, +) from judge.utils.problems import ( user_attempted_ids, user_completed_ids, @@ -36,32 +52,35 @@ def max_case_points_per_problem(profile, problems): def calculate_lessons_progress(profile, lessons): res = {} - total_achieved_points = 0 - total_points = 0 + total_achieved_points = total_lesson_points = 0 for lesson in lessons: - problems = list(lesson.problems.all()) - if not problems: - res[lesson.id] = {"achieved_points": 0, "percentage": 0} - total_points += lesson.points - continue + problems = lesson.lesson_problems.values_list("problem", flat=True) problem_points = max_case_points_per_problem(profile, problems) - num_problems = len(problems) - percentage = 0 - for val in problem_points.values(): - if val["case_total"] > 0: - score = val["case_points"] / val["case_total"] - percentage += score / num_problems + achieved_points = total_points = 0 + + for lesson_problem in lesson.lesson_problems.all(): + val = problem_points.get(lesson_problem.problem.id) + if val and val["case_total"]: + achieved_points += ( + val["case_points"] / val["case_total"] * lesson_problem.score + ) + total_points += lesson_problem.score + res[lesson.id] = { - "achieved_points": percentage * lesson.points, - "percentage": percentage * 100, + "achieved_points": achieved_points, + "total_points": total_points, + "percentage": achieved_points / total_points * 100 if total_points else 0, } - total_achieved_points += percentage * lesson.points - total_points += lesson.points + if total_points: + total_achieved_points += achieved_points / total_points * lesson.points + total_lesson_points += lesson.points res["total"] = { "achieved_points": total_achieved_points, - "total_points": total_points, - "percentage": total_achieved_points / total_points * 100 if total_points else 0, + "total_points": total_lesson_points, + "percentage": total_achieved_points / total_lesson_points * 100 + if total_lesson_points + else 0, } return res @@ -157,12 +176,13 @@ class CourseLessonDetail(CourseDetailMixin, DetailView): def get_context_data(self, **kwargs): context = super(CourseLessonDetail, self).get_context_data(**kwargs) profile = self.get_profile() + context["profile"] = profile context["title"] = self.lesson.title context["lesson"] = self.lesson context["completed_problem_ids"] = user_completed_ids(profile) context["attempted_problems"] = user_attempted_ids(profile) context["problem_points"] = max_case_points_per_problem( - profile, self.lesson.problems.all() + profile, self.lesson.lesson_problems.values_list("problem", flat=True) ) return context @@ -183,20 +203,65 @@ CourseLessonFormSet = inlineformset_factory( ) +class CourseLessonProblemForm(ModelForm): + class Meta: + model = CourseLessonProblem + fields = ["order", "problem", "score", "lesson"] + widgets = { + "problem": HeavySelect2Widget( + data_view="problem_select2", attrs={"style": "width: 100%"} + ), + "lesson": forms.HiddenInput(), + } + + +CourseLessonProblemFormSet = modelformset_factory( + CourseLessonProblem, form=CourseLessonProblemForm, extra=5, can_delete=True +) + + class EditCourseLessonsView(CourseEditableMixin, FormView): template_name = "course/edit_lesson.html" form_class = CourseLessonFormSet + def get_problem_formset(self, post=False, lesson=None): + formset = CourseLessonProblemFormSet( + data=self.request.POST if post else None, + prefix=f"problems_{lesson.id}" if lesson else "problems", + queryset=CourseLessonProblem.objects.filter(lesson=lesson).order_by( + "order" + ), + ) + if lesson: + for form in formset: + form.fields["lesson"].initial = lesson + return formset + def get_context_data(self, **kwargs): context = super(EditCourseLessonsView, self).get_context_data(**kwargs) if self.request.method == "POST": context["formset"] = self.form_class( self.request.POST, self.request.FILES, instance=self.course ) + context["problem_formsets"] = { + lesson.instance.id: self.get_problem_formset( + post=True, lesson=lesson.instance + ) + for lesson in context["formset"].forms + if lesson.instance.id + } else: context["formset"] = self.form_class( instance=self.course, queryset=self.course.lessons.order_by("order") ) + context["problem_formsets"] = { + lesson.instance.id: self.get_problem_formset( + post=False, lesson=lesson.instance + ) + for lesson in context["formset"].forms + if lesson.instance.id + } + context["title"] = _("Edit lessons for %(course_name)s") % { "course_name": self.course.name } @@ -213,8 +278,22 @@ class EditCourseLessonsView(CourseEditableMixin, FormView): def post(self, request, *args, **kwargs): formset = self.form_class(request.POST, instance=self.course) + problem_formsets = [ + self.get_problem_formset(post=True, lesson=lesson.instance) + for lesson in formset.forms + if lesson.instance.id + ] + for pf in problem_formsets: + if not pf.is_valid(): + return self.form_invalid(pf) + if formset.is_valid(): formset.save() + for problem_formset in problem_formsets: + problem_formset.save() + for obj in problem_formset.deleted_objects: + if obj.pk is not None: + obj.delete() return self.form_valid(formset) else: return self.form_invalid(formset) @@ -239,7 +318,10 @@ class CourseStudentResults(CourseEditableMixin, DetailView): def get_context_data(self, **kwargs): context = super(CourseStudentResults, self).get_context_data(**kwargs) - context["title"] = mark_safe( + context["title"] = _("Grades in %(course_name)s") % { + "course_name": self.course.name, + } + context["content_title"] = mark_safe( _("Grades in %(course_name)s") % { "course_name": self.course.name, @@ -249,3 +331,61 @@ class CourseStudentResults(CourseEditableMixin, DetailView): context["page_type"] = "grades" context["grades"] = self.get_grades() return context + + +class CourseStudentResultsLesson(CourseEditableMixin, DetailView): + model = CourseLesson + template_name = "course/grades_lesson.html" + + def get_object(self): + try: + self.lesson = CourseLesson.objects.get( + course=self.course, id=self.kwargs["id"] + ) + return self.lesson + except ObjectDoesNotExist: + raise Http404() + + def get_lesson_grades(self): + students = self.course.get_students() + students.sort(key=lambda u: u.username.lower()) + problems = self.lesson.lesson_problems.values_list("problem", flat=True) + lesson_problems = self.lesson.lesson_problems.all() + grades = {} + for s in students: + grades[s] = problem_points = max_case_points_per_problem(s, problems) + achieved_points = total_points = 0 + for lesson_problem in lesson_problems: + val = problem_points.get(lesson_problem.problem.id) + if val and val["case_total"]: + achieved_points += ( + val["case_points"] / val["case_total"] * lesson_problem.score + ) + total_points += lesson_problem.score + grades[s]["total"] = { + "achieved_points": achieved_points, + "total_points": total_points, + "percentage": achieved_points / total_points * 100 + if total_points + else 0, + } + return grades + + def get_context_data(self, **kwargs): + context = super(CourseStudentResultsLesson, self).get_context_data(**kwargs) + context["lesson"] = self.lesson + context["title"] = _("Grades of %(lesson_name)s in %(course_name)s") % { + "course_name": self.course.name, + "lesson_name": self.lesson.title, + } + context["content_title"] = mark_safe( + _("Grades of %(lesson_name)s in %(course_name)s") + % { + "course_name": self.course.name, + "lesson_name": self.lesson.title, + "url": self.course.get_absolute_url(), + } + ) + context["page_type"] = "grades" + context["grades"] = self.get_lesson_grades() + return context diff --git a/templates/course/course.html b/templates/course/course.html index 9b65f4d..f5ba8c8 100644 --- a/templates/course/course.html +++ b/templates/course/course.html @@ -15,7 +15,11 @@
{{ lesson.title }}
- {{progress['achieved_points'] | floatformat(1)}} / {{lesson.points}} + {% if progress['total_points'] %} + {{(progress['achieved_points'] / progress['total_points'] * lesson.points) | floatformat(1)}} / {{lesson.points}} + {% else %} + 0 / {{lesson.points}} + {% endif %}
@@ -30,7 +34,6 @@ {% set achieved_points = total_progress['achieved_points'] %} {% set total_points = total_progress['total_points'] %} {% set percentage = total_progress['percentage'] %} - {{_("Total achieved points")}}: {{ achieved_points | floatformat(2) }} / {{ total_points }} ({{percentage|floatformat(1)}}%) diff --git a/templates/course/edit_lesson.html b/templates/course/edit_lesson.html index b8e4e41..7426b8d 100644 --- a/templates/course/edit_lesson.html +++ b/templates/course/edit_lesson.html @@ -1,67 +1,109 @@ -{% extends "course/base.html" %} - -{% block two_col_media %} - {{ form.media.css }} - -{% endblock %} - -{% block js_media %} - {{ form.media.js }} - -{% endblock %} - -{% block middle_content %} -
- {% csrf_token %} - {{ formset.management_form }} - {% for form in formset %} -

- {% if form.title.value() %} - {{form.order.value()}}. {{form.title.value()}} - {% else %} - + {{_("Add new")}} - {% endif %} -

-
- {{form.id}} - {% if form.errors %} -
- x - {{_("Please fix below errors")}} -
- {% endif %} - {% for field in form %} - {% if not field.is_hidden %} -
- {{ field.errors }} - -
- {{ field }} -
- {% if field.help_text %} - {{ field.help_text|safe }} - {% endif %} -
- {% endif %} - {% endfor %} -
-
- {% endfor %} - -
+{% extends "course/base.html" %} + +{% block two_col_media %} + {{ form.media.css }} + +{% endblock %} + +{% block js_media %} + {{ form.media.js }} + +{% endblock %} + +{% block middle_content %} +
+ {% csrf_token %} + {{ formset.management_form }} + + {% for lesson_form in formset %} + {% set ns = namespace(problem_formset_has_error=false) %} + + {% if lesson_form.instance.id %} + {% set problem_formset = problem_formsets[lesson_form.instance.id] %} + {% for form in problem_formset %} + {% if form.errors %} + {% set ns.problem_formset_has_error = true %} + {% break %} + {% endif %} + {% endfor %} + {% endif %} +

+ + {% if lesson_form.title.value() %} + {{lesson_form.order.value()}}. {{lesson_form.title.value()}} + {% else %} + + {{_("Add new")}} + {% endif %} +

+
+ {{lesson_form.id}} + {% if lesson_form.errors %} +
+ x + {{_("Please fix below errors")}} +
+ {% endif %} + {% for field in lesson_form %} + {% if not field.is_hidden %} +
+ {{ field.errors }} + +
+ {{ field }} +
+ {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
+ {% endif %} + {% endfor %} + + + {% if problem_formset %} + {{ problem_formset.management_form }} + + + + {% for field in problem_formset.forms.0 %} + {% if not field.is_hidden %} + + {% endif %} + {% endfor %} + + + + {% for form in problem_formset %} + + {% for field in form %} + + {% endfor %} + + {% endfor %} + +
+ {{field.label}} +
+ {{field}}
{{field.errors}}
+
+ {% endif %} +
+
+ {% endfor %} + +
{% endblock %} \ No newline at end of file diff --git a/templates/course/grades.html b/templates/course/grades.html index a560cc0..ee39ad5 100644 --- a/templates/course/grades.html +++ b/templates/course/grades.html @@ -74,8 +74,8 @@ {% endblock %} {% block middle_content %} -

{{title}}

- {% set lessons = course.lessons.all() %} +

{{content_title}}

+ {% set lessons = course.lessons.order_by('order') %} {{_("Sort by")}}: + + + + + + + + + {% if grades|length > 0 %} + {% for lesson_problem in lesson_problems %} + + {% endfor %} + {% endif %} + + + + + {% for student, grade in grades.items() %} + + + {% for lesson_problem in lesson_problems %} + {% set val = grade.get(lesson_problem.problem.id) %} + + {% endfor %} + + + {% endfor %} + +
{{_('Student')}} + + {{ lesson_problem.problem.name }} +
{{lesson_problem.score}}
+
+
{{_('Total')}}
+
+ {{link_user(student)}} +
+
+ {{student.first_name}} +
+
+ + {% if val and val['case_total'] %} + {{ (val['case_points'] / val['case_total'] * lesson_problem.score) | floatformat(0) }} + {% else %} + 0 + {% endif %} + + + {{ grade['total']['percentage'] | floatformat(0) }}% +
+{% endblock %} \ No newline at end of file diff --git a/templates/course/lesson.html b/templates/course/lesson.html index 537d562..a5c634c 100644 --- a/templates/course/lesson.html +++ b/templates/course/lesson.html @@ -6,14 +6,15 @@ {% endblock %} {% block middle_content %} -

{{title}}

+

{{title}} - {{profile.username}}

{{_("Content")}}

{{ lesson.content|markdown|reference|str|safe }}

{{_("Problems")}}