Add order and score for course problems (#124)

* Add order and grade for course problems

* Fix delete problems bug
This commit is contained in:
Phuoc Anh Kha Le 2024-09-03 21:26:20 +07:00 committed by GitHub
parent 67888bcd27
commit c833dc06d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 483 additions and 102 deletions

View file

@ -564,6 +564,11 @@ urlpatterns = [
course.CourseStudentResults.as_view(),
name="course_grades",
),
url(
r"^/grades/lesson/(?P<id>\d+)$",
course.CourseStudentResultsLesson.as_view(),
name="course_grades_lesson",
),
]
),
),

View file

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

View file

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

View file

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

View file

@ -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</a>") % {
"course_name": self.course.name,
}
context["content_title"] = mark_safe(
_("Grades in <a href='%(url)s'>%(course_name)s</a>")
% {
"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</a> in %(course_name)s</a>") % {
"course_name": self.course.name,
"lesson_name": self.lesson.title,
}
context["content_title"] = mark_safe(
_("Grades of %(lesson_name)s</a> in <a href='%(url)s'>%(course_name)s</a>")
% {
"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

View file

@ -15,7 +15,11 @@
<div class="lesson-title">
{{ lesson.title }}
<div class="lesson-points">
{{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 %}
</div>
</div>
<div class="progress-container">
@ -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")}}:
<span style="float: right; font-weight: normal;">
{{ achieved_points | floatformat(2) }} / {{ total_points }} ({{percentage|floatformat(1)}}%)

View file

@ -1,67 +1,109 @@
{% extends "course/base.html" %}
{% block two_col_media %}
{{ form.media.css }}
<style type="text/css">
.field-order, .field-title, .field-points {
display: inline-flex;
}
.form-header {
margin-bottom: 0.5em;
}
</style>
{% endblock %}
{% block js_media %}
{{ form.media.js }}
<script>
$(function() {
setTimeout(function() {
if ('DjangoPagedown' in window) {
DjangoPagedown.init();
}
}, 3000);
});
</script>
{% endblock %}
{% block middle_content %}
<form method="post">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<h3 class="toggle {{'open' if form.errors else 'closed'}} form-header"><i class="fa fa-chevron-right fa-fw"></i>
{% if form.title.value() %}
{{form.order.value()}}. {{form.title.value()}}
{% else %}
+ {{_("Add new")}}
{% endif %}
</h3>
<div class="toggled" style="{{'display: none;' if not form.errors}} margin-bottom: 1em">
{{form.id}}
{% if 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 field-{{field.name}}" 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/>
</div>
{% endfor %}
<input type="submit" value="{{_('Save')}}" style="float: right">
</form>
{% extends "course/base.html" %}
{% block two_col_media %}
{{ form.media.css }}
<style type="text/css">
.field-order, .field-title, .field-points {
display: inline-flex;
}
.form-header {
margin-bottom: 0.5em;
}
</style>
{% endblock %}
{% block js_media %}
{{ form.media.js }}
<script>
$(function() {
setTimeout(function() {
if ('DjangoPagedown' in window) {
DjangoPagedown.init();
}
}, 3000);
});
</script>
{% endblock %}
{% block middle_content %}
<form method="post">
{% 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 %}
<h3 class="toggle {{'open' if lesson_form.errors or ns.problem_formset_has_error else 'closed'}} form-header">
<i class="fa fa-chevron-right fa-fw"></i>
{% if lesson_form.title.value() %}
{{lesson_form.order.value()}}. {{lesson_form.title.value()}}
{% else %}
+ {{_("Add new")}}
{% endif %}
</h3>
<div class="toggled" style="{{'display: none;' if not lesson_form.errors and not ns.problem_formset_has_error}} margin-bottom: 1em">
{{lesson_form.id}}
{% if lesson_form.errors %}
<div class="alert alert-danger alert-dismissable">
<a href="#" class="close">x</a>
{{_("Please fix below errors")}}
</div>
{% endif %}
{% for field in lesson_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 field-{{field.name}}" 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 %}
<!-- Problems Table -->
{% if problem_formset %}
{{ problem_formset.management_form }}
<table class="table">
<thead>
<tr>
{% for field in problem_formset.forms.0 %}
{% if not field.is_hidden %}
<th class="problems-{{field.name}}">
{{field.label}}
</th>
{% endif %}
{% endfor %}
</tr>
</thead>
<tbody>
{% for form in problem_formset %}
<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>
{% endif %}
<hr/>
</div>
{% endfor %}
<input type="submit" value="{{_('Save')}}" style="float: right">
</form>
{% endblock %}

View file

@ -74,8 +74,8 @@
{% endblock %}
{% block middle_content %}
<center><h2>{{title}}</h2></center>
{% set lessons = course.lessons.all() %}
<center><h2>{{content_title}}</h2></center>
{% set lessons = course.lessons.order_by('order') %}
{{_("Sort by")}}:
<select id="sortSelect">
<option value="username">{{_("Username")}}</option>
@ -89,7 +89,7 @@
{% if grades|length > 0 %}
{% for lesson in lessons %}
<th class="points">
<a href="{{url('course_lesson_detail', course.slug, lesson.id)}}">
<a href="{{url('course_grades_lesson', course.slug, lesson.id)}}">
{{ lesson.title }}
<div class="point-denominator">{{lesson.points}}</div>
</a>
@ -111,9 +111,14 @@
</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}}">
{{ grade[lesson.id]['percentage'] | floatformat(0) }}%
{% if val and val['total_points'] %}
{{ (val['achieved_points'] / val['total_points'] * lesson.points) | floatformat(0) }}
{% else %}
0
{% endif %}
</a>
</td>
{% endfor %}

View file

@ -0,0 +1,132 @@
{% extends "course/base.html" %}
{% block two_col_media %}
<style type="text/css">
table {
font-size: 15px;
}
td {
height: 2.5em;
}
.user-name {
padding-left: 1em !important;
}
#search-input {
float: right;
margin-bottom: 1em;
}
</style>
{% endblock %}
{% block js_media %}
<script>
$(document).ready(function(){
var $searchInput = $("#search-input");
var $usersTable = $("#users-table");
var tableBorderColor = $('#users-table td').css('border-bottom-color');
$searchInput.on("keyup", function() {
var value = $(this).val().toLowerCase();
$("#users-table tbody tr").filter(function() {
$(this).toggle($(this).text().toLowerCase().indexOf(value) > -1)
});
if(value) {
$('#users-table').css('border-bottom', '1px solid ' + tableBorderColor);
} else {
$('#users-table').css('border-bottom', '');
}
});
$('#sortSelect').select2({
minimumResultsForSearch: -1,
width: "10em",
});
$('#sortSelect').on('change', function() {
var rows = $('#users-table tbody tr').get();
var sortBy = $(this).val();
rows.sort(function(a, b) {
var keyA = $(a).find(sortBy === 'username' ? '.user-name' : 'td:last-child').text().trim();
var keyB = $(b).find(sortBy === 'username' ? '.user-name' : 'td:last-child').text().trim();
if(sortBy === 'total') {
// Convert percentage string to number for comparison
keyA = -parseFloat(keyA.replace('%', ''));
keyB = -parseFloat(keyB.replace('%', ''));
}
else {
keyA = keyA.toLowerCase();
keyB = keyB.toLowerCase();
}
if(keyA < keyB) return -1;
if(keyA > keyB) return 1;
return 0;
});
$.each(rows, function(index, row) {
$('#users-table tbody').append(row);
});
});
});
</script>
{% endblock %}
{% block middle_content %}
<center><h2>{{content_title}}</h2></center>
{% set lesson_problems = lesson.lesson_problems.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 %}
{% for lesson_problem in lesson_problems %}
<th class="points">
<a href="{{url('problem_detail', lesson_problem.problem.code)}}">
{{ lesson_problem.problem.name }}
<div class="point-denominator">{{lesson_problem.score}}</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_problem in lesson_problems %}
{% set val = grade.get(lesson_problem.problem.id) %}
<td class="partial-score">
<a href="{{url('user_submissions', lesson_problem.problem.code, student.username)}}">
{% if val and val['case_total'] %}
{{ (val['case_points'] / val['case_total'] * lesson_problem.score) | floatformat(0) }}
{% else %}
0
{% endif %}
</a>
</td>
{% endfor %}
<td style="font-weight: bold">
{{ grade['total']['percentage'] | floatformat(0) }}%
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -6,14 +6,15 @@
{% endblock %}
{% block middle_content %}
<center><h2>{{title}}</h2></center>
<center><h2>{{title}} - {{profile.username}}</h2></center>
<h3 class="course-content-title">{{_("Content")}}</h3>
<div>
{{ lesson.content|markdown|reference|str|safe }}
</div>
<h3 class="course-content-title">{{_("Problems")}}</h3>
<ul class="course-problem-list">
{% for problem in lesson.problems.all() %}
{% for lesson_problem in lesson.lesson_problems.order_by('order') %}
{% set problem = lesson_problem.problem %}
<a href="{{url('problem_detail', problem.code)}}">
<li>
{% if problem.id in completed_problem_ids %}
@ -26,10 +27,10 @@
<span class="problem-name">{{problem.name}}</span>
{% set pp = problem_points[problem.id] %}
<span class="score">
{% if pp %}
{{pp.case_points|floatformat(1)}} / {{pp.case_total|floatformat(0)}}
{% if pp and pp.case_total %}
{{(pp.case_points / pp.case_total * lesson_problem.score) |floatformat(1)}} / {{lesson_problem.score|floatformat(0)}}
{% else %}
0
0 / {{lesson_problem.score|floatformat(0)}}
{% endif %}
</span>
</li>