Add course
This commit is contained in:
parent
d409f0e9b4
commit
83579891b9
27 changed files with 1308 additions and 484 deletions
25
dmoj/urls.py
25
dmoj/urls.py
|
@ -559,7 +559,30 @@ urlpatterns = [
|
||||||
r"^contests/summary/(?P<key>\w+)/",
|
r"^contests/summary/(?P<key>\w+)/",
|
||||||
paged_list_view(contests.ContestsSummaryView, "contests_summary"),
|
paged_list_view(contests.ContestsSummaryView, "contests_summary"),
|
||||||
),
|
),
|
||||||
url(r"^course/", paged_list_view(course.CourseList, "course_list")),
|
url(r"^courses/", paged_list_view(course.CourseList, "course_list")),
|
||||||
|
url(
|
||||||
|
r"^course/(?P<slug>[\w-]*)",
|
||||||
|
include(
|
||||||
|
[
|
||||||
|
url(r"^$", course.CourseDetail.as_view(), name="course_detail"),
|
||||||
|
url(
|
||||||
|
r"^/lesson/(?P<id>\d+)$",
|
||||||
|
course.CourseLessonDetail.as_view(),
|
||||||
|
name="course_lesson_detail",
|
||||||
|
),
|
||||||
|
url(
|
||||||
|
r"^/edit_lessons$",
|
||||||
|
course.EditCourseLessonsView.as_view(),
|
||||||
|
name="edit_course_lessons",
|
||||||
|
),
|
||||||
|
url(
|
||||||
|
r"^/grades$",
|
||||||
|
course.CourseStudentResults.as_view(),
|
||||||
|
name="course_grades",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
url(
|
url(
|
||||||
r"^contests/(?P<year>\d+)/(?P<month>\d+)/$",
|
r"^contests/(?P<year>\d+)/(?P<month>\d+)/$",
|
||||||
contests.ContestCalendar.as_view(),
|
contests.ContestCalendar.as_view(),
|
||||||
|
|
|
@ -23,6 +23,7 @@ from judge.admin.submission import SubmissionAdmin
|
||||||
from judge.admin.taxon import ProblemGroupAdmin, ProblemTypeAdmin
|
from judge.admin.taxon import ProblemGroupAdmin, ProblemTypeAdmin
|
||||||
from judge.admin.ticket import TicketAdmin
|
from judge.admin.ticket import TicketAdmin
|
||||||
from judge.admin.volunteer import VolunteerProblemVoteAdmin
|
from judge.admin.volunteer import VolunteerProblemVoteAdmin
|
||||||
|
from judge.admin.course import CourseAdmin
|
||||||
from judge.models import (
|
from judge.models import (
|
||||||
BlogPost,
|
BlogPost,
|
||||||
Comment,
|
Comment,
|
||||||
|
@ -72,7 +73,7 @@ admin.site.register(Profile, ProfileAdmin)
|
||||||
admin.site.register(Submission, SubmissionAdmin)
|
admin.site.register(Submission, SubmissionAdmin)
|
||||||
admin.site.register(Ticket, TicketAdmin)
|
admin.site.register(Ticket, TicketAdmin)
|
||||||
admin.site.register(VolunteerProblemVote, VolunteerProblemVoteAdmin)
|
admin.site.register(VolunteerProblemVote, VolunteerProblemVoteAdmin)
|
||||||
admin.site.register(Course)
|
admin.site.register(Course, CourseAdmin)
|
||||||
admin.site.unregister(User)
|
admin.site.unregister(User)
|
||||||
admin.site.register(User, UserAdmin)
|
admin.site.register(User, UserAdmin)
|
||||||
admin.site.register(ContestsSummary, ContestsSummaryAdmin)
|
admin.site.register(ContestsSummary, ContestsSummaryAdmin)
|
||||||
|
|
52
judge/admin/course.py
Normal file
52
judge/admin/course.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.urls import reverse, reverse_lazy
|
||||||
|
from django.utils.translation import gettext, gettext_lazy as _, ungettext
|
||||||
|
from django.forms import ModelForm
|
||||||
|
|
||||||
|
from judge.models import Course, CourseRole
|
||||||
|
from judge.widgets import AdminSelect2MultipleWidget
|
||||||
|
from judge.widgets import (
|
||||||
|
AdminHeavySelect2MultipleWidget,
|
||||||
|
AdminHeavySelect2Widget,
|
||||||
|
HeavyPreviewAdminPageDownWidget,
|
||||||
|
AdminSelect2Widget,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CourseRoleInlineForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
widgets = {
|
||||||
|
"user": AdminHeavySelect2Widget(
|
||||||
|
data_view="profile_select2", attrs={"style": "width: 100%"}
|
||||||
|
),
|
||||||
|
"role": AdminSelect2Widget,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CourseRoleInline(admin.TabularInline):
|
||||||
|
model = CourseRole
|
||||||
|
extra = 1
|
||||||
|
form = CourseRoleInlineForm
|
||||||
|
|
||||||
|
|
||||||
|
class CourseForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
widgets = {
|
||||||
|
"organizations": AdminHeavySelect2MultipleWidget(
|
||||||
|
data_view="organization_select2"
|
||||||
|
),
|
||||||
|
"about": HeavyPreviewAdminPageDownWidget(
|
||||||
|
preview=reverse_lazy("blog_preview")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CourseAdmin(admin.ModelAdmin):
|
||||||
|
prepopulated_fields = {"slug": ("name",)}
|
||||||
|
inlines = [
|
||||||
|
CourseRoleInline,
|
||||||
|
]
|
||||||
|
list_display = ("name", "is_public", "is_open")
|
||||||
|
search_fields = ("name",)
|
||||||
|
form = CourseForm
|
|
@ -77,7 +77,18 @@ ALLOWED_TAGS = list(bleach.sanitizer.ALLOWED_TAGS) + [
|
||||||
"summary",
|
"summary",
|
||||||
]
|
]
|
||||||
|
|
||||||
ALLOWED_ATTRS = ["src", "width", "height", "href", "class", "open"]
|
ALLOWED_ATTRS = [
|
||||||
|
"src",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"href",
|
||||||
|
"class",
|
||||||
|
"open",
|
||||||
|
"title",
|
||||||
|
"frameborder",
|
||||||
|
"allow",
|
||||||
|
"allowfullscreen",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def markdown(value, lazy_load=False):
|
def markdown(value, lazy_load=False):
|
||||||
|
|
78
judge/migrations/0180_course.py
Normal file
78
judge/migrations/0180_course.py
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
# Generated by Django 3.2.18 on 2024-02-15 02:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("judge", "0179_submission_result_lang_index"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CourseLesson",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.TextField(verbose_name="course title")),
|
||||||
|
("content", models.TextField(verbose_name="course content")),
|
||||||
|
("order", models.IntegerField(default=0, verbose_name="order")),
|
||||||
|
("points", models.IntegerField(verbose_name="points")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="courseresource",
|
||||||
|
name="course",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="course",
|
||||||
|
name="ending_time",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="courserole",
|
||||||
|
name="course",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="judge.course",
|
||||||
|
verbose_name="course",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="CourseAssignment",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="CourseResource",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="courselesson",
|
||||||
|
name="course",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="judge.course",
|
||||||
|
verbose_name="course",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="courselesson",
|
||||||
|
name="problems",
|
||||||
|
field=models.ManyToManyField(to="judge.Problem"),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="courserole",
|
||||||
|
unique_together={("course", "user")},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="courselesson",
|
||||||
|
name="problems",
|
||||||
|
field=models.ManyToManyField(blank=True, to="judge.Problem"),
|
||||||
|
),
|
||||||
|
]
|
|
@ -59,7 +59,7 @@ from judge.models.ticket import Ticket, TicketMessage
|
||||||
from judge.models.volunteer import VolunteerProblemVote
|
from judge.models.volunteer import VolunteerProblemVote
|
||||||
from judge.models.pagevote import PageVote, PageVoteVoter
|
from judge.models.pagevote import PageVote, PageVoteVoter
|
||||||
from judge.models.bookmark import BookMark, MakeBookMark
|
from judge.models.bookmark import BookMark, MakeBookMark
|
||||||
from judge.models.course import Course
|
from judge.models.course import Course, CourseRole, CourseLesson
|
||||||
from judge.models.notification import Notification, NotificationProfile
|
from judge.models.notification import Notification, NotificationProfile
|
||||||
from judge.models.test_formatter import TestFormatterModel
|
from judge.models.test_formatter import TestFormatterModel
|
||||||
|
|
||||||
|
|
|
@ -1,18 +1,20 @@
|
||||||
from django.core.validators import RegexValidator
|
from django.core.validators import RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext, gettext_lazy as _
|
from django.utils.translation import gettext, gettext_lazy as _
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
from judge.models import Contest
|
from judge.models import BlogPost, Problem
|
||||||
from judge.models.profile import Organization, Profile
|
from judge.models.profile import Organization, Profile
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Course",
|
|
||||||
"CourseRole",
|
|
||||||
"CourseResource",
|
|
||||||
"CourseAssignment",
|
|
||||||
]
|
|
||||||
|
|
||||||
course_directory_file = ""
|
class RoleInCourse(models.TextChoices):
|
||||||
|
STUDENT = "ST", _("Student")
|
||||||
|
ASSISTANT = "AS", _("Assistant")
|
||||||
|
TEACHER = "TE", _("Teacher")
|
||||||
|
|
||||||
|
|
||||||
|
EDITABLE_ROLES = (RoleInCourse.TEACHER, RoleInCourse.ASSISTANT)
|
||||||
|
|
||||||
|
|
||||||
class Course(models.Model):
|
class Course(models.Model):
|
||||||
|
@ -20,10 +22,7 @@ class Course(models.Model):
|
||||||
max_length=128,
|
max_length=128,
|
||||||
verbose_name=_("course name"),
|
verbose_name=_("course name"),
|
||||||
)
|
)
|
||||||
about = models.TextField(verbose_name=_("organization description"))
|
about = models.TextField(verbose_name=_("course description"))
|
||||||
ending_time = models.DateTimeField(
|
|
||||||
verbose_name=_("ending time"),
|
|
||||||
)
|
|
||||||
is_public = models.BooleanField(
|
is_public = models.BooleanField(
|
||||||
verbose_name=_("publicly visible"),
|
verbose_name=_("publicly visible"),
|
||||||
default=False,
|
default=False,
|
||||||
|
@ -57,35 +56,50 @@ class Course(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("course_detail", args=(self.slug,))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_editable_by(course, profile):
|
def is_editable_by(cls, course, profile):
|
||||||
if profile.is_superuser:
|
try:
|
||||||
return True
|
course_role = CourseRole.objects.get(course=course, user=profile)
|
||||||
userquery = CourseRole.objects.filter(course=course, user=profile)
|
return course_role.role in EDITABLE_ROLES
|
||||||
if userquery.exists():
|
except CourseRole.DoesNotExist:
|
||||||
if userquery[0].role == "AS" or userquery[0].role == "TE":
|
|
||||||
return True
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_accessible_by(cls, course, profile):
|
def is_accessible_by(cls, course, profile):
|
||||||
userqueryset = CourseRole.objects.filter(course=course, user=profile)
|
if not profile:
|
||||||
if userqueryset.exists():
|
return False
|
||||||
|
try:
|
||||||
|
course_role = CourseRole.objects.get(course=course, user=profile)
|
||||||
|
if course_role.course.is_public:
|
||||||
return True
|
return True
|
||||||
else:
|
return course_role.role in EDITABLE_ROLES
|
||||||
|
except CourseRole.DoesNotExist:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_students(cls, course):
|
def get_accessible_courses(cls, profile):
|
||||||
return CourseRole.objects.filter(course=course, role="ST").values("user")
|
return Course.objects.filter(
|
||||||
|
Q(is_public=True) | Q(courserole__role__in=EDITABLE_ROLES),
|
||||||
|
courserole__user=profile,
|
||||||
|
).distinct()
|
||||||
|
|
||||||
@classmethod
|
def _get_users_by_role(self, role):
|
||||||
def get_assistants(cls, course):
|
course_roles = CourseRole.objects.filter(course=self, role=role).select_related(
|
||||||
return CourseRole.objects.filter(course=course, role="AS").values("user")
|
"user"
|
||||||
|
)
|
||||||
|
return [course_role.user for course_role in course_roles]
|
||||||
|
|
||||||
@classmethod
|
def get_students(self):
|
||||||
def get_teachers(cls, course):
|
return self._get_users_by_role(RoleInCourse.STUDENT)
|
||||||
return CourseRole.objects.filter(course=course, role="TE").values("user")
|
|
||||||
|
def get_assistants(self):
|
||||||
|
return self._get_users_by_role(RoleInCourse.ASSISTANT)
|
||||||
|
|
||||||
|
def get_teachers(self):
|
||||||
|
return self._get_users_by_role(RoleInCourse.TEACHER)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def add_student(cls, course, profiles):
|
def add_student(cls, course, profiles):
|
||||||
|
@ -104,7 +118,7 @@ class Course(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class CourseRole(models.Model):
|
class CourseRole(models.Model):
|
||||||
course = models.OneToOneField(
|
course = models.ForeignKey(
|
||||||
Course,
|
Course,
|
||||||
verbose_name=_("course"),
|
verbose_name=_("course"),
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
|
@ -114,14 +128,9 @@ class CourseRole(models.Model):
|
||||||
Profile,
|
Profile,
|
||||||
verbose_name=_("user"),
|
verbose_name=_("user"),
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name=_("user_of_course"),
|
related_name="course_roles",
|
||||||
)
|
)
|
||||||
|
|
||||||
class RoleInCourse(models.TextChoices):
|
|
||||||
STUDENT = "ST", _("Student")
|
|
||||||
ASSISTANT = "AS", _("Assistant")
|
|
||||||
TEACHER = "TE", _("Teacher")
|
|
||||||
|
|
||||||
role = models.CharField(
|
role = models.CharField(
|
||||||
max_length=2,
|
max_length=2,
|
||||||
choices=RoleInCourse.choices,
|
choices=RoleInCourse.choices,
|
||||||
|
@ -140,44 +149,19 @@ class CourseRole(models.Model):
|
||||||
couresrole.role = role
|
couresrole.role = role
|
||||||
couresrole.save()
|
couresrole.save()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ("course", "user")
|
||||||
|
|
||||||
class CourseResource(models.Model):
|
|
||||||
course = models.OneToOneField(
|
class CourseLesson(models.Model):
|
||||||
|
course = models.ForeignKey(
|
||||||
Course,
|
Course,
|
||||||
verbose_name=_("course"),
|
verbose_name=_("course"),
|
||||||
on_delete=models.CASCADE,
|
related_name="lessons",
|
||||||
db_index=True,
|
|
||||||
)
|
|
||||||
files = models.FileField(
|
|
||||||
verbose_name=_("course files"),
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
upload_to=course_directory_file,
|
|
||||||
)
|
|
||||||
description = models.CharField(
|
|
||||||
verbose_name=_("description"),
|
|
||||||
blank=True,
|
|
||||||
max_length=150,
|
|
||||||
)
|
|
||||||
order = models.IntegerField(null=True, default=None)
|
|
||||||
is_public = models.BooleanField(
|
|
||||||
verbose_name=_("publicly visible"),
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CourseAssignment(models.Model):
|
|
||||||
course = models.OneToOneField(
|
|
||||||
Course,
|
|
||||||
verbose_name=_("course"),
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
db_index=True,
|
|
||||||
)
|
|
||||||
contest = models.OneToOneField(
|
|
||||||
Contest,
|
|
||||||
verbose_name=_("contest"),
|
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
)
|
||||||
points = models.FloatField(
|
title = models.TextField(verbose_name=_("course title"))
|
||||||
verbose_name=_("points"),
|
content = models.TextField(verbose_name=_("course content"))
|
||||||
)
|
problems = models.ManyToManyField(Problem, verbose_name=_("problem"), blank=True)
|
||||||
|
order = models.IntegerField(verbose_name=_("order"), default=0)
|
||||||
|
points = models.IntegerField(verbose_name=_("points"))
|
||||||
|
|
|
@ -1,24 +1,68 @@
|
||||||
|
from django.utils.html import mark_safe
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from judge.models.course import Course
|
from django.views.generic import ListView, DetailView, View
|
||||||
from django.views.generic import ListView
|
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.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
|
||||||
|
|
||||||
__all__ = [
|
from judge.models import Course, CourseLesson, Submission, Profile, CourseRole
|
||||||
"CourseList",
|
from judge.models.course import RoleInCourse
|
||||||
"CourseDetail",
|
from judge.widgets import HeavyPreviewPageDownWidget, HeavySelect2MultipleWidget
|
||||||
"CourseResource",
|
from judge.utils.problems import (
|
||||||
"CourseResourceDetail",
|
user_attempted_ids,
|
||||||
"CourseStudentResults",
|
user_completed_ids,
|
||||||
"CourseEdit",
|
)
|
||||||
"CourseResourceDetailEdit",
|
|
||||||
"CourseResourceEdit",
|
|
||||||
]
|
|
||||||
|
|
||||||
course_directory_file = ""
|
|
||||||
|
|
||||||
|
|
||||||
class CourseListMixin(object):
|
def max_case_points_per_problem(profile, problems):
|
||||||
def get_queryset(self):
|
# return a dict {problem_id: {case_points, case_total}}
|
||||||
return Course.objects.filter(is_open="true").values()
|
q = (
|
||||||
|
Submission.objects.filter(user=profile, problem__in=problems)
|
||||||
|
.values("problem")
|
||||||
|
.annotate(case_points=Max("case_points"), case_total=F("case_total"))
|
||||||
|
.order_by("problem")
|
||||||
|
)
|
||||||
|
res = {}
|
||||||
|
for problem in q:
|
||||||
|
res[problem["problem"]] = problem
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_lessons_progress(profile, lessons):
|
||||||
|
res = {}
|
||||||
|
total_achieved_points = 0
|
||||||
|
total_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
|
||||||
|
problem_points = max_case_points_per_problem(profile, problems)
|
||||||
|
num_problems = len(problems)
|
||||||
|
percentage = 0
|
||||||
|
for val in problem_points.values():
|
||||||
|
score = val["case_points"] / val["case_total"]
|
||||||
|
percentage += score / num_problems
|
||||||
|
res[lesson.id] = {
|
||||||
|
"achieved_points": percentage * lesson.points,
|
||||||
|
"percentage": percentage * 100,
|
||||||
|
}
|
||||||
|
total_achieved_points += percentage * lesson.points
|
||||||
|
total_points += lesson.points
|
||||||
|
|
||||||
|
res["total"] = {
|
||||||
|
"achieved_points": total_achieved_points,
|
||||||
|
"total_points": total_points,
|
||||||
|
"percentage": total_achieved_points / total_points * 100,
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
class CourseList(ListView):
|
class CourseList(ListView):
|
||||||
|
@ -28,12 +72,179 @@ class CourseList(ListView):
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(CourseList, self).get_context_data(**kwargs)
|
context = super(CourseList, self).get_context_data(**kwargs)
|
||||||
available, enrolling = [], []
|
context["courses"] = Course.get_accessible_courses(self.request.profile)
|
||||||
for course in Course.objects.filter(is_public=True).filter(is_open=True):
|
context["title"] = _("Courses")
|
||||||
if Course.is_accessible_by(course, self.request.profile):
|
context["page_type"] = "list"
|
||||||
enrolling.append(course)
|
return context
|
||||||
else:
|
|
||||||
available.append(course)
|
|
||||||
context["available"] = available
|
class CourseDetailMixin(object):
|
||||||
context["enrolling"] = enrolling
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
self.course = get_object_or_404(Course, slug=self.kwargs["slug"])
|
||||||
|
if not Course.is_accessible_by(self.course, self.request.profile):
|
||||||
|
raise Http404()
|
||||||
|
self.is_editable = Course.is_editable_by(self.course, self.request.profile)
|
||||||
|
return super(CourseDetailMixin, self).dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super(CourseDetailMixin, self).get_context_data(**kwargs)
|
||||||
|
context["course"] = self.course
|
||||||
|
context["is_editable"] = self.is_editable
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class CourseEditableMixin(CourseDetailMixin):
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
res = super(CourseEditableMixin, self).dispatch(request, *args, **kwargs)
|
||||||
|
if not self.is_editable:
|
||||||
|
raise Http404()
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
class CourseDetail(CourseDetailMixin, DetailView):
|
||||||
|
model = Course
|
||||||
|
template_name = "course/course.html"
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.course
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super(CourseDetail, self).get_context_data(**kwargs)
|
||||||
|
lessons = self.course.lessons.prefetch_related("problems").all()
|
||||||
|
context["title"] = self.course.name
|
||||||
|
context["page_type"] = "home"
|
||||||
|
context["lessons"] = lessons
|
||||||
|
context["lesson_progress"] = calculate_lessons_progress(
|
||||||
|
self.request.profile, lessons
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class CourseLessonDetail(CourseDetailMixin, DetailView):
|
||||||
|
model = CourseLesson
|
||||||
|
template_name = "course/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_profile(self):
|
||||||
|
username = self.request.GET.get("user")
|
||||||
|
if not username:
|
||||||
|
return self.request.profile
|
||||||
|
|
||||||
|
is_editable = Course.is_editable_by(self.course, self.request.profile)
|
||||||
|
if not is_editable:
|
||||||
|
raise Http404()
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile = Profile.objects.get(user__username=username)
|
||||||
|
is_student = profile.course_roles.filter(
|
||||||
|
role=RoleInCourse.STUDENT, course=self.course
|
||||||
|
).exists()
|
||||||
|
if not is_student:
|
||||||
|
raise Http404()
|
||||||
|
return profile
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
raise Http404()
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super(CourseLessonDetail, self).get_context_data(**kwargs)
|
||||||
|
profile = self.get_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()
|
||||||
|
)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class CourseLessonForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = CourseLesson
|
||||||
|
fields = ["order", "title", "points", "content", "problems"]
|
||||||
|
widgets = {
|
||||||
|
"title": forms.TextInput(),
|
||||||
|
"content": HeavyPreviewPageDownWidget(preview=reverse_lazy("blog_preview")),
|
||||||
|
"problems": HeavySelect2MultipleWidget(data_view="problem_select2"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CourseLessonFormSet = inlineformset_factory(
|
||||||
|
Course, CourseLesson, form=CourseLessonForm, extra=1, can_delete=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EditCourseLessonsView(CourseEditableMixin, FormView):
|
||||||
|
template_name = "course/edit_lesson.html"
|
||||||
|
form_class = CourseLessonFormSet
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
context["formset"] = self.form_class(
|
||||||
|
instance=self.course, queryset=self.course.lessons.order_by("order")
|
||||||
|
)
|
||||||
|
context["title"] = _("Edit lessons for %(course_name)s") % {
|
||||||
|
"course_name": self.course.name
|
||||||
|
}
|
||||||
|
context["content_title"] = mark_safe(
|
||||||
|
_("Edit lessons for <a href='%(url)s'>%(course_name)s</a>")
|
||||||
|
% {
|
||||||
|
"course_name": self.course.name,
|
||||||
|
"url": self.course.get_absolute_url(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
context["page_type"] = "edit_lesson"
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
formset = self.form_class(request.POST, instance=self.course)
|
||||||
|
if formset.is_valid():
|
||||||
|
formset.save()
|
||||||
|
return self.form_valid(formset)
|
||||||
|
else:
|
||||||
|
return self.form_invalid(formset)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return self.request.path
|
||||||
|
|
||||||
|
|
||||||
|
class CourseStudentResults(CourseEditableMixin, DetailView):
|
||||||
|
model = Course
|
||||||
|
template_name = "course/grades.html"
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return self.course
|
||||||
|
|
||||||
|
def get_grades(self):
|
||||||
|
students = self.course.get_students()
|
||||||
|
students.sort(key=lambda u: u.username.lower())
|
||||||
|
lessons = self.course.lessons.prefetch_related("problems").all()
|
||||||
|
grades = {s: calculate_lessons_progress(s, lessons) for s in students}
|
||||||
|
return grades
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super(CourseStudentResults, self).get_context_data(**kwargs)
|
||||||
|
context["title"] = mark_safe(
|
||||||
|
_("Grades in <a href='%(url)s'>%(course_name)s</a>")
|
||||||
|
% {
|
||||||
|
"course_name": self.course.name,
|
||||||
|
"url": self.course.get_absolute_url(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
context["page_type"] = "grades"
|
||||||
|
context["grades"] = self.get_grades()
|
||||||
return context
|
return context
|
||||||
|
|
|
@ -131,6 +131,13 @@ class OrganizationMixin(OrganizationBase):
|
||||||
context["can_edit"] = self.can_edit_organization(self.organization)
|
context["can_edit"] = self.can_edit_organization(self.organization)
|
||||||
context["organization"] = self.organization
|
context["organization"] = self.organization
|
||||||
context["logo_override_image"] = self.organization.logo_override_image
|
context["logo_override_image"] = self.organization.logo_override_image
|
||||||
|
context["organization_subdomain"] = (
|
||||||
|
("http" if settings.DMOJ_SSL == 0 else "https")
|
||||||
|
+ "://"
|
||||||
|
+ self.organization.slug
|
||||||
|
+ "."
|
||||||
|
+ get_current_site(self.request).domain
|
||||||
|
)
|
||||||
if "organizations" in context:
|
if "organizations" in context:
|
||||||
context.pop("organizations")
|
context.pop("organizations")
|
||||||
return context
|
return context
|
||||||
|
@ -274,14 +281,6 @@ class OrganizationHome(OrganizationHomeView, FeedView):
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(OrganizationHome, self).get_context_data(**kwargs)
|
context = super(OrganizationHome, self).get_context_data(**kwargs)
|
||||||
context["title"] = self.organization.name
|
context["title"] = self.organization.name
|
||||||
http = "http" if settings.DMOJ_SSL == 0 else "https"
|
|
||||||
context["organization_subdomain"] = (
|
|
||||||
http
|
|
||||||
+ "://"
|
|
||||||
+ self.organization.slug
|
|
||||||
+ "."
|
|
||||||
+ get_current_site(self.request).domain
|
|
||||||
)
|
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
visible_contests = (
|
visible_contests = (
|
||||||
|
@ -737,7 +736,7 @@ class AddOrganizationMember(
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
new_users = form.cleaned_data["new_users"]
|
new_users = form.cleaned_data["new_users"]
|
||||||
self.object.members.add(*new_users)
|
self.object.members.add(*new_users)
|
||||||
with transaction.atomic(), revisions.create_revision():
|
with revisions.create_revision():
|
||||||
revisions.set_comment(_("Added members from site"))
|
revisions.set_comment(_("Added members from site"))
|
||||||
revisions.set_user(self.request.user)
|
revisions.set_user(self.request.user)
|
||||||
return super(AddOrganizationMember, self).form_valid(form)
|
return super(AddOrganizationMember, self).form_valid(form)
|
||||||
|
@ -804,7 +803,7 @@ class EditOrganization(
|
||||||
return form
|
return form
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
with transaction.atomic(), revisions.create_revision():
|
with revisions.create_revision():
|
||||||
revisions.set_comment(_("Edited from site"))
|
revisions.set_comment(_("Edited from site"))
|
||||||
revisions.set_user(self.request.user)
|
revisions.set_user(self.request.user)
|
||||||
return super(EditOrganization, self).form_valid(form)
|
return super(EditOrganization, self).form_valid(form)
|
||||||
|
@ -836,7 +835,7 @@ class AddOrganization(LoginRequiredMixin, TitleMixin, CreateView):
|
||||||
% settings.DMOJ_USER_MAX_ORGANIZATION_ADD,
|
% settings.DMOJ_USER_MAX_ORGANIZATION_ADD,
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
with transaction.atomic(), revisions.create_revision():
|
with revisions.create_revision():
|
||||||
revisions.set_comment(_("Added from site"))
|
revisions.set_comment(_("Added from site"))
|
||||||
revisions.set_user(self.request.user)
|
revisions.set_user(self.request.user)
|
||||||
res = super(AddOrganization, self).form_valid(form)
|
res = super(AddOrganization, self).form_valid(form)
|
||||||
|
@ -861,7 +860,7 @@ class AddOrganizationContest(
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
with transaction.atomic(), revisions.create_revision():
|
with revisions.create_revision():
|
||||||
revisions.set_comment(_("Added from site"))
|
revisions.set_comment(_("Added from site"))
|
||||||
revisions.set_user(self.request.user)
|
revisions.set_user(self.request.user)
|
||||||
|
|
||||||
|
@ -954,7 +953,7 @@ class EditOrganizationContest(
|
||||||
return self.contest
|
return self.contest
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
with transaction.atomic(), revisions.create_revision():
|
with revisions.create_revision():
|
||||||
revisions.set_comment(_("Edited from site"))
|
revisions.set_comment(_("Edited from site"))
|
||||||
revisions.set_user(self.request.user)
|
revisions.set_user(self.request.user)
|
||||||
res = super(EditOrganizationContest, self).form_valid(form)
|
res = super(EditOrganizationContest, self).form_valid(form)
|
||||||
|
@ -1015,7 +1014,7 @@ class AddOrganizationBlog(
|
||||||
return _("Add blog for %s") % self.organization.name
|
return _("Add blog for %s") % self.organization.name
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
with transaction.atomic(), revisions.create_revision():
|
with revisions.create_revision():
|
||||||
res = super(AddOrganizationBlog, self).form_valid(form)
|
res = super(AddOrganizationBlog, self).form_valid(form)
|
||||||
self.object.is_organization_private = True
|
self.object.is_organization_private = True
|
||||||
self.object.authors.add(self.request.profile)
|
self.object.authors.add(self.request.profile)
|
||||||
|
@ -1038,6 +1037,11 @@ class AddOrganizationBlog(
|
||||||
)
|
)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse(
|
||||||
|
"organization_home", args=[self.organization.id, self.organization.slug]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EditOrganizationBlog(
|
class EditOrganizationBlog(
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
|
@ -1115,13 +1119,18 @@ class EditOrganizationBlog(
|
||||||
make_notification(posible_users, action, html, self.request.profile)
|
make_notification(posible_users, action, html, self.request.profile)
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
with transaction.atomic(), revisions.create_revision():
|
with revisions.create_revision():
|
||||||
res = super(EditOrganizationBlog, self).form_valid(form)
|
res = super(EditOrganizationBlog, self).form_valid(form)
|
||||||
revisions.set_comment(_("Edited from site"))
|
revisions.set_comment(_("Edited from site"))
|
||||||
revisions.set_user(self.request.user)
|
revisions.set_user(self.request.user)
|
||||||
self.create_notification("Edit blog")
|
self.create_notification("Edit blog")
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse(
|
||||||
|
"organization_home", args=[self.organization.id, self.organization.slug]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PendingBlogs(
|
class PendingBlogs(
|
||||||
LoginRequiredMixin,
|
LoginRequiredMixin,
|
||||||
|
|
|
@ -409,7 +409,7 @@ def edit_profile(request):
|
||||||
request.POST, request.FILES, instance=profile, user=request.user
|
request.POST, request.FILES, instance=profile, user=request.user
|
||||||
)
|
)
|
||||||
if form_user.is_valid() and form.is_valid():
|
if form_user.is_valid() and form.is_valid():
|
||||||
with transaction.atomic(), revisions.create_revision():
|
with revisions.create_revision():
|
||||||
form_user.save()
|
form_user.save()
|
||||||
form.save()
|
form.save()
|
||||||
revisions.set_user(request.user)
|
revisions.set_user(request.user)
|
||||||
|
|
|
@ -19,7 +19,6 @@ def vote_problem(request):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
vote, _ = VolunteerProblemVote.objects.get_or_create(
|
vote, _ = VolunteerProblemVote.objects.get_or_create(
|
||||||
voter=request.profile,
|
voter=request.profile,
|
||||||
problem=problem,
|
problem=problem,
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -473,6 +473,8 @@ $(function() {
|
||||||
let $contentClone = $('#content').clone();
|
let $contentClone = $('#content').clone();
|
||||||
$contentClone.find('.select2').remove();
|
$contentClone.find('.select2').remove();
|
||||||
$contentClone.find('.select2-hidden-accessible').removeClass('select2-hidden-accessible');
|
$contentClone.find('.select2-hidden-accessible').removeClass('select2-hidden-accessible');
|
||||||
|
$contentClone.find('.noUi-base').remove();
|
||||||
|
$contentClone.find('.wmd-button-row').remove();
|
||||||
sessionStorage.setItem(key, JSON.stringify({
|
sessionStorage.setItem(key, JSON.stringify({
|
||||||
"html": $contentClone.html(),
|
"html": $contentClone.html(),
|
||||||
"page": window.page,
|
"page": window.page,
|
||||||
|
|
133
resources/course.scss
Normal file
133
resources/course.scss
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
@import "vars";
|
||||||
|
|
||||||
|
.course-content-title {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-list {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
.course-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.course-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.course-image {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
margin-right: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.course-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.course-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.course-name {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
li:hover {
|
||||||
|
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||||
|
background: #ffffe0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 2px 4px #ccc;
|
||||||
|
}
|
||||||
|
.lesson-title {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-left: 1em;
|
||||||
|
margin-right: 1em;
|
||||||
|
color: initial;
|
||||||
|
display: flex;
|
||||||
|
gap: 1em;
|
||||||
|
|
||||||
|
.lesson-points {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.9em;
|
||||||
|
align-self: flex-end;
|
||||||
|
color: #636363;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.progress-container {
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 3px;
|
||||||
|
height: 10px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
background: $theme_color;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
line-height: 10px;
|
||||||
|
color: white;
|
||||||
|
text-align: right;
|
||||||
|
font-size: smaller;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-problem-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: large;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.problem-name {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
.score {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,6 +17,7 @@
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid DarkGray;
|
border: 1px solid DarkGray;
|
||||||
font-family: $monospace-fonts;
|
font-family: $monospace-fonts;
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wmd-preview {
|
.wmd-preview {
|
||||||
|
|
|
@ -16,3 +16,4 @@
|
||||||
@import "ticket";
|
@import "ticket";
|
||||||
@import "pagedown_widget";
|
@import "pagedown_widget";
|
||||||
@import "dmmd-preview";
|
@import "dmmd-preview";
|
||||||
|
@import "course";
|
|
@ -1,12 +1,5 @@
|
||||||
<!DOCTYPE html>
|
{% extends "two-column-content.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Courses</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
</body>
|
{% block left_sidebar %}
|
||||||
</html>
|
{% include "course/left_sidebar.html" %}
|
||||||
|
{% endblock %}
|
39
templates/course/course.html
Normal file
39
templates/course/course.html
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
{% extends "course/base.html" %}
|
||||||
|
|
||||||
|
{% 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">
|
||||||
|
{{progress['achieved_points'] | floatformat(1)}} / {{lesson.points}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-bar" style="width: {{progress['percentage']}}%;">{{progress['percentage']|floatformat(0)}}%</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<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'] %}
|
||||||
|
|
||||||
|
{{_("Total achieved points")}}:
|
||||||
|
<span style="float: right; font-weight: normal;">
|
||||||
|
{{ achieved_points | floatformat(2) }} / {{ total_points }} ({{percentage|floatformat(1)}}%)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
{% endblock %}
|
58
templates/course/edit_lesson.html
Normal file
58
templates/course/edit_lesson.html
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
{% 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 two_col_js %}
|
||||||
|
{{ form.media.js }}
|
||||||
|
{% 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>
|
||||||
|
{% endblock %}
|
127
templates/course/grades.html
Normal file
127
templates/course/grades.html
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
{% 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 two_col_js %}
|
||||||
|
<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>{{title}}</h2></center>
|
||||||
|
{% set lessons = course.lessons.all() %}
|
||||||
|
{{_("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 in lessons %}
|
||||||
|
<th class="points">
|
||||||
|
<a href="{{url('course_lesson_detail', course.slug, lesson.id)}}">
|
||||||
|
{{ lesson.title }}
|
||||||
|
<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 %}
|
||||||
|
<td class="partial-score">
|
||||||
|
<a href="{{url('course_lesson_detail', course.slug, lesson.id)}}?user={{student.username}}">
|
||||||
|
{{ grade[lesson.id]['percentage'] | floatformat(0) }}%
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td style="font-weight: bold">
|
||||||
|
{{ grade['total']['percentage'] | floatformat(0) }}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
10
templates/course/left_sidebar.html
Normal file
10
templates/course/left_sidebar.html
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<div class="left-sidebar">
|
||||||
|
{{ 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('grades', 'fa fa-check-square-o', url('course_grades', course.slug), _('Grades')) }}
|
||||||
|
{% endif %}
|
||||||
|
{% if perms.judge.change_course %}
|
||||||
|
{{ make_tab_item('admin', 'fa fa-edit', url('admin:judge_course_change', course.id), _('Admin')) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
39
templates/course/lesson.html
Normal file
39
templates/course/lesson.html
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
{% extends "course/base.html" %}
|
||||||
|
|
||||||
|
{% block two_col_media %}
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block middle_content %}
|
||||||
|
<center><h2>{{title}}</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() %}
|
||||||
|
<a href="{{url('problem_detail', problem.code)}}">
|
||||||
|
<li>
|
||||||
|
{% if problem.id in completed_problem_ids %}
|
||||||
|
<i class="solved-problem-color fa fa-check-circle"></i>
|
||||||
|
{% elif problem.id in attempted_problems %}
|
||||||
|
<i class="attempted-problem-color fa fa-minus-circle"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="unsolved-problem-color fa fa-minus-circle"></i>
|
||||||
|
{% endif %}
|
||||||
|
<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)}}
|
||||||
|
{% else %}
|
||||||
|
0
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
|
@ -1,19 +1,29 @@
|
||||||
<!DOCTYPE html>
|
{% extends "two-column-content.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
{% block two_col_media %}
|
||||||
<meta charset="UTF-8">
|
{% endblock %}
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{% block left_sidebar %}
|
||||||
<title>Document</title>
|
<div class="left-sidebar">
|
||||||
</head>
|
{{ make_tab_item('list', 'fa fa-list', url('course_list'), _('Courses')) }}
|
||||||
<body>
|
</div>
|
||||||
<h1>Enrolling</h1>
|
{% endblock %}
|
||||||
{% for course in enrolling %}
|
|
||||||
<h2> {{ course }} </h2>
|
{% block middle_content %}
|
||||||
|
<div class="course-list">
|
||||||
|
{% for course in courses %}
|
||||||
|
<div class="course-item">
|
||||||
|
<div class="course-image">
|
||||||
|
<img src="{{course.image_url}}">
|
||||||
|
</div>
|
||||||
|
<div class="course-content">
|
||||||
|
<a href="{{url('course_detail', course.slug)}}" class="course-name">{{course.name}}</a>
|
||||||
|
{% set teachers = course.get_teachers() %}
|
||||||
|
{% if teachers %}
|
||||||
|
<div class="course-teachers">{{_('Teachers')}}: {{link_users(teachers)}}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<h1> Available </h1>
|
</div>
|
||||||
{% for course in available %}
|
{% endblock %}
|
||||||
<h2> {{ course }} </h2>
|
|
||||||
{% endfor %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -14,10 +14,5 @@
|
||||||
$(this).parent().submit();
|
$(this).parent().submit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#control-panel a').on('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
navigateTo($(this));
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
|
@ -10,16 +10,9 @@
|
||||||
{% block middle_title %}
|
{% block middle_title %}
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<div class="tabs" style="border: none;">
|
<div class="tabs" style="border: none;">
|
||||||
<h2><img src="{{logo_override_image}}" style="height: 3rem; vertical-align: middle">
|
<h2><img src="{{logo_override_image}}" style="height: 3rem; vertical-align: middle; border-radius: 5px;">
|
||||||
{{title}}
|
{{title}}
|
||||||
</h2>
|
</h2>
|
||||||
{% if is_member %}
|
|
||||||
<div>
|
|
||||||
<a href="{{organization_subdomain}}" target="_blank">
|
|
||||||
{{_('Subdomain')}}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
|
|
|
@ -53,6 +53,15 @@
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if is_member %}
|
||||||
|
<li>
|
||||||
|
<div>
|
||||||
|
<a href="{{ organization_subdomain }}" target="_blank">
|
||||||
|
{{_('Subdomain')}}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
{% if is_member and not is_admin %}
|
{% if is_member and not is_admin %}
|
||||||
<li>
|
<li>
|
||||||
<form method="post" action="{{ url('leave_organization', organization.id, organization.slug) }}">
|
<form method="post" action="{{ url('leave_organization', organization.id, organization.slug) }}">
|
||||||
|
|
|
@ -96,12 +96,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerNavigation() {
|
function registerNavigation() {
|
||||||
const links = ['.pagination a', '.tabs li a'];
|
const links = ['.pagination a', '.tabs li a', '#control-panel a'];
|
||||||
for (link of links) {
|
for (let linkSelector of links) {
|
||||||
$(link).on('click', function (e) {
|
$(linkSelector).each(function() {
|
||||||
|
if ($(this).attr('target') !== '_blank') {
|
||||||
|
$(this).on('click', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigateTo($(this));
|
navigateTo($(this));
|
||||||
})
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue