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+)/",
|
||||
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(
|
||||
r"^contests/(?P<year>\d+)/(?P<month>\d+)/$",
|
||||
contests.ContestCalendar.as_view(),
|
||||
|
|
|
@ -23,6 +23,7 @@ from judge.admin.submission import SubmissionAdmin
|
|||
from judge.admin.taxon import ProblemGroupAdmin, ProblemTypeAdmin
|
||||
from judge.admin.ticket import TicketAdmin
|
||||
from judge.admin.volunteer import VolunteerProblemVoteAdmin
|
||||
from judge.admin.course import CourseAdmin
|
||||
from judge.models import (
|
||||
BlogPost,
|
||||
Comment,
|
||||
|
@ -72,7 +73,7 @@ admin.site.register(Profile, ProfileAdmin)
|
|||
admin.site.register(Submission, SubmissionAdmin)
|
||||
admin.site.register(Ticket, TicketAdmin)
|
||||
admin.site.register(VolunteerProblemVote, VolunteerProblemVoteAdmin)
|
||||
admin.site.register(Course)
|
||||
admin.site.register(Course, CourseAdmin)
|
||||
admin.site.unregister(User)
|
||||
admin.site.register(User, UserAdmin)
|
||||
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",
|
||||
]
|
||||
|
||||
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):
|
||||
|
|
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.pagevote import PageVote, PageVoteVoter
|
||||
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.test_formatter import TestFormatterModel
|
||||
|
||||
|
|
|
@ -1,18 +1,20 @@
|
|||
from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
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
|
||||
|
||||
__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):
|
||||
|
@ -20,10 +22,7 @@ class Course(models.Model):
|
|||
max_length=128,
|
||||
verbose_name=_("course name"),
|
||||
)
|
||||
about = models.TextField(verbose_name=_("organization description"))
|
||||
ending_time = models.DateTimeField(
|
||||
verbose_name=_("ending time"),
|
||||
)
|
||||
about = models.TextField(verbose_name=_("course description"))
|
||||
is_public = models.BooleanField(
|
||||
verbose_name=_("publicly visible"),
|
||||
default=False,
|
||||
|
@ -57,35 +56,50 @@ class Course(models.Model):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def is_editable_by(course, profile):
|
||||
if profile.is_superuser:
|
||||
return True
|
||||
userquery = CourseRole.objects.filter(course=course, user=profile)
|
||||
if userquery.exists():
|
||||
if userquery[0].role == "AS" or userquery[0].role == "TE":
|
||||
return True
|
||||
return False
|
||||
def get_absolute_url(self):
|
||||
return reverse("course_detail", args=(self.slug,))
|
||||
|
||||
@classmethod
|
||||
def is_accessible_by(cls, course, profile):
|
||||
userqueryset = CourseRole.objects.filter(course=course, user=profile)
|
||||
if userqueryset.exists():
|
||||
return True
|
||||
else:
|
||||
def is_editable_by(cls, course, profile):
|
||||
try:
|
||||
course_role = CourseRole.objects.get(course=course, user=profile)
|
||||
return course_role.role in EDITABLE_ROLES
|
||||
except CourseRole.DoesNotExist:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_students(cls, course):
|
||||
return CourseRole.objects.filter(course=course, role="ST").values("user")
|
||||
def is_accessible_by(cls, course, profile):
|
||||
if not profile:
|
||||
return False
|
||||
try:
|
||||
course_role = CourseRole.objects.get(course=course, user=profile)
|
||||
if course_role.course.is_public:
|
||||
return True
|
||||
return course_role.role in EDITABLE_ROLES
|
||||
except CourseRole.DoesNotExist:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_assistants(cls, course):
|
||||
return CourseRole.objects.filter(course=course, role="AS").values("user")
|
||||
def get_accessible_courses(cls, profile):
|
||||
return Course.objects.filter(
|
||||
Q(is_public=True) | Q(courserole__role__in=EDITABLE_ROLES),
|
||||
courserole__user=profile,
|
||||
).distinct()
|
||||
|
||||
@classmethod
|
||||
def get_teachers(cls, course):
|
||||
return CourseRole.objects.filter(course=course, role="TE").values("user")
|
||||
def _get_users_by_role(self, role):
|
||||
course_roles = CourseRole.objects.filter(course=self, role=role).select_related(
|
||||
"user"
|
||||
)
|
||||
return [course_role.user for course_role in course_roles]
|
||||
|
||||
def get_students(self):
|
||||
return self._get_users_by_role(RoleInCourse.STUDENT)
|
||||
|
||||
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
|
||||
def add_student(cls, course, profiles):
|
||||
|
@ -104,7 +118,7 @@ class Course(models.Model):
|
|||
|
||||
|
||||
class CourseRole(models.Model):
|
||||
course = models.OneToOneField(
|
||||
course = models.ForeignKey(
|
||||
Course,
|
||||
verbose_name=_("course"),
|
||||
on_delete=models.CASCADE,
|
||||
|
@ -114,14 +128,9 @@ class CourseRole(models.Model):
|
|||
Profile,
|
||||
verbose_name=_("user"),
|
||||
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(
|
||||
max_length=2,
|
||||
choices=RoleInCourse.choices,
|
||||
|
@ -140,44 +149,19 @@ class CourseRole(models.Model):
|
|||
couresrole.role = role
|
||||
couresrole.save()
|
||||
|
||||
class Meta:
|
||||
unique_together = ("course", "user")
|
||||
|
||||
class CourseResource(models.Model):
|
||||
course = models.OneToOneField(
|
||||
|
||||
class CourseLesson(models.Model):
|
||||
course = models.ForeignKey(
|
||||
Course,
|
||||
verbose_name=_("course"),
|
||||
on_delete=models.CASCADE,
|
||||
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"),
|
||||
related_name="lessons",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
points = models.FloatField(
|
||||
verbose_name=_("points"),
|
||||
)
|
||||
title = models.TextField(verbose_name=_("course title"))
|
||||
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 judge.models.course import Course
|
||||
from django.views.generic import ListView
|
||||
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.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__ = [
|
||||
"CourseList",
|
||||
"CourseDetail",
|
||||
"CourseResource",
|
||||
"CourseResourceDetail",
|
||||
"CourseStudentResults",
|
||||
"CourseEdit",
|
||||
"CourseResourceDetailEdit",
|
||||
"CourseResourceEdit",
|
||||
]
|
||||
|
||||
course_directory_file = ""
|
||||
from judge.models import Course, CourseLesson, Submission, Profile, CourseRole
|
||||
from judge.models.course import RoleInCourse
|
||||
from judge.widgets import HeavyPreviewPageDownWidget, HeavySelect2MultipleWidget
|
||||
from judge.utils.problems import (
|
||||
user_attempted_ids,
|
||||
user_completed_ids,
|
||||
)
|
||||
|
||||
|
||||
class CourseListMixin(object):
|
||||
def get_queryset(self):
|
||||
return Course.objects.filter(is_open="true").values()
|
||||
def max_case_points_per_problem(profile, problems):
|
||||
# return a dict {problem_id: {case_points, case_total}}
|
||||
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):
|
||||
|
@ -28,12 +72,179 @@ class CourseList(ListView):
|
|||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(CourseList, self).get_context_data(**kwargs)
|
||||
available, enrolling = [], []
|
||||
for course in Course.objects.filter(is_public=True).filter(is_open=True):
|
||||
if Course.is_accessible_by(course, self.request.profile):
|
||||
enrolling.append(course)
|
||||
else:
|
||||
available.append(course)
|
||||
context["available"] = available
|
||||
context["enrolling"] = enrolling
|
||||
context["courses"] = Course.get_accessible_courses(self.request.profile)
|
||||
context["title"] = _("Courses")
|
||||
context["page_type"] = "list"
|
||||
return context
|
||||
|
||||
|
||||
class CourseDetailMixin(object):
|
||||
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
|
||||
|
|
|
@ -131,6 +131,13 @@ class OrganizationMixin(OrganizationBase):
|
|||
context["can_edit"] = self.can_edit_organization(self.organization)
|
||||
context["organization"] = self.organization
|
||||
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:
|
||||
context.pop("organizations")
|
||||
return context
|
||||
|
@ -274,14 +281,6 @@ class OrganizationHome(OrganizationHomeView, FeedView):
|
|||
def get_context_data(self, **kwargs):
|
||||
context = super(OrganizationHome, self).get_context_data(**kwargs)
|
||||
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()
|
||||
visible_contests = (
|
||||
|
@ -737,7 +736,7 @@ class AddOrganizationMember(
|
|||
def form_valid(self, form):
|
||||
new_users = form.cleaned_data["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_user(self.request.user)
|
||||
return super(AddOrganizationMember, self).form_valid(form)
|
||||
|
@ -804,7 +803,7 @@ class EditOrganization(
|
|||
return form
|
||||
|
||||
def form_valid(self, form):
|
||||
with transaction.atomic(), revisions.create_revision():
|
||||
with revisions.create_revision():
|
||||
revisions.set_comment(_("Edited from site"))
|
||||
revisions.set_user(self.request.user)
|
||||
return super(EditOrganization, self).form_valid(form)
|
||||
|
@ -836,7 +835,7 @@ class AddOrganization(LoginRequiredMixin, TitleMixin, CreateView):
|
|||
% settings.DMOJ_USER_MAX_ORGANIZATION_ADD,
|
||||
status=400,
|
||||
)
|
||||
with transaction.atomic(), revisions.create_revision():
|
||||
with revisions.create_revision():
|
||||
revisions.set_comment(_("Added from site"))
|
||||
revisions.set_user(self.request.user)
|
||||
res = super(AddOrganization, self).form_valid(form)
|
||||
|
@ -861,7 +860,7 @@ class AddOrganizationContest(
|
|||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
with transaction.atomic(), revisions.create_revision():
|
||||
with revisions.create_revision():
|
||||
revisions.set_comment(_("Added from site"))
|
||||
revisions.set_user(self.request.user)
|
||||
|
||||
|
@ -954,7 +953,7 @@ class EditOrganizationContest(
|
|||
return self.contest
|
||||
|
||||
def form_valid(self, form):
|
||||
with transaction.atomic(), revisions.create_revision():
|
||||
with revisions.create_revision():
|
||||
revisions.set_comment(_("Edited from site"))
|
||||
revisions.set_user(self.request.user)
|
||||
res = super(EditOrganizationContest, self).form_valid(form)
|
||||
|
@ -1015,7 +1014,7 @@ class AddOrganizationBlog(
|
|||
return _("Add blog for %s") % self.organization.name
|
||||
|
||||
def form_valid(self, form):
|
||||
with transaction.atomic(), revisions.create_revision():
|
||||
with revisions.create_revision():
|
||||
res = super(AddOrganizationBlog, self).form_valid(form)
|
||||
self.object.is_organization_private = True
|
||||
self.object.authors.add(self.request.profile)
|
||||
|
@ -1038,6 +1037,11 @@ class AddOrganizationBlog(
|
|||
)
|
||||
return res
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse(
|
||||
"organization_home", args=[self.organization.id, self.organization.slug]
|
||||
)
|
||||
|
||||
|
||||
class EditOrganizationBlog(
|
||||
LoginRequiredMixin,
|
||||
|
@ -1115,13 +1119,18 @@ class EditOrganizationBlog(
|
|||
make_notification(posible_users, action, html, self.request.profile)
|
||||
|
||||
def form_valid(self, form):
|
||||
with transaction.atomic(), revisions.create_revision():
|
||||
with revisions.create_revision():
|
||||
res = super(EditOrganizationBlog, self).form_valid(form)
|
||||
revisions.set_comment(_("Edited from site"))
|
||||
revisions.set_user(self.request.user)
|
||||
self.create_notification("Edit blog")
|
||||
return res
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse(
|
||||
"organization_home", args=[self.organization.id, self.organization.slug]
|
||||
)
|
||||
|
||||
|
||||
class PendingBlogs(
|
||||
LoginRequiredMixin,
|
||||
|
|
|
@ -409,7 +409,7 @@ def edit_profile(request):
|
|||
request.POST, request.FILES, instance=profile, user=request.user
|
||||
)
|
||||
if form_user.is_valid() and form.is_valid():
|
||||
with transaction.atomic(), revisions.create_revision():
|
||||
with revisions.create_revision():
|
||||
form_user.save()
|
||||
form.save()
|
||||
revisions.set_user(request.user)
|
||||
|
|
|
@ -19,15 +19,14 @@ def vote_problem(request):
|
|||
except Exception as e:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
with transaction.atomic():
|
||||
vote, _ = VolunteerProblemVote.objects.get_or_create(
|
||||
voter=request.profile,
|
||||
problem=problem,
|
||||
defaults={"knowledge_points": 0, "thinking_points": 0},
|
||||
)
|
||||
vote.knowledge_points = knowledge_points
|
||||
vote.thinking_points = thinking_points
|
||||
vote.feedback = feedback
|
||||
vote.types.set(types)
|
||||
vote.save()
|
||||
vote, _ = VolunteerProblemVote.objects.get_or_create(
|
||||
voter=request.profile,
|
||||
problem=problem,
|
||||
defaults={"knowledge_points": 0, "thinking_points": 0},
|
||||
)
|
||||
vote.knowledge_points = knowledge_points
|
||||
vote.thinking_points = thinking_points
|
||||
vote.feedback = feedback
|
||||
vote.types.set(types)
|
||||
vote.save()
|
||||
return JsonResponse({})
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -472,7 +472,9 @@ $(function() {
|
|||
let key = `oj-content-${window.location.href}`;
|
||||
let $contentClone = $('#content').clone();
|
||||
$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({
|
||||
"html": $contentClone.html(),
|
||||
"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;
|
||||
border: 1px solid DarkGray;
|
||||
font-family: $monospace-fonts;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.wmd-preview {
|
||||
|
|
|
@ -15,4 +15,5 @@
|
|||
@import "organization";
|
||||
@import "ticket";
|
||||
@import "pagedown_widget";
|
||||
@import "dmmd-preview";
|
||||
@import "dmmd-preview";
|
||||
@import "course";
|
|
@ -1,12 +1,5 @@
|
|||
<!DOCTYPE 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>
|
||||
</html>
|
||||
{% extends "two-column-content.html" %}
|
||||
|
||||
{% block left_sidebar %}
|
||||
{% 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>
|
||||
<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>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Enrolling</h1>
|
||||
{% for course in enrolling %}
|
||||
<h2> {{ course }} </h2>
|
||||
{% extends "two-column-content.html" %}
|
||||
|
||||
{% block two_col_media %}
|
||||
{% endblock %}
|
||||
|
||||
{% block left_sidebar %}
|
||||
<div class="left-sidebar">
|
||||
{{ make_tab_item('list', 'fa fa-list', url('course_list'), _('Courses')) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% 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 %}
|
||||
<h1> Available </h1>
|
||||
{% for course in available %}
|
||||
<h2> {{ course }} </h2>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -14,10 +14,5 @@
|
|||
$(this).parent().submit();
|
||||
}
|
||||
});
|
||||
|
||||
$('#control-panel a').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
navigateTo($(this));
|
||||
})
|
||||
});
|
||||
</script>
|
|
@ -10,16 +10,9 @@
|
|||
{% block middle_title %}
|
||||
<div class="page-title">
|
||||
<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}}
|
||||
</h2>
|
||||
{% if is_member %}
|
||||
<div>
|
||||
<a href="{{organization_subdomain}}" target="_blank">
|
||||
{{_('Subdomain')}}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="spacer"></span>
|
||||
|
||||
{% if request.user.is_authenticated %}
|
||||
|
|
|
@ -53,6 +53,15 @@
|
|||
</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if is_member %}
|
||||
<li>
|
||||
<div>
|
||||
<a href="{{ organization_subdomain }}" target="_blank">
|
||||
{{_('Subdomain')}}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if is_member and not is_admin %}
|
||||
<li>
|
||||
<form method="post" action="{{ url('leave_organization', organization.id, organization.slug) }}">
|
||||
|
|
|
@ -96,12 +96,16 @@
|
|||
}
|
||||
|
||||
function registerNavigation() {
|
||||
const links = ['.pagination a', '.tabs li a'];
|
||||
for (link of links) {
|
||||
$(link).on('click', function (e) {
|
||||
e.preventDefault();
|
||||
navigateTo($(this));
|
||||
})
|
||||
const links = ['.pagination a', '.tabs li a', '#control-panel a'];
|
||||
for (let linkSelector of links) {
|
||||
$(linkSelector).each(function() {
|
||||
if ($(this).attr('target') !== '_blank') {
|
||||
$(this).on('click', function(e) {
|
||||
e.preventDefault();
|
||||
navigateTo($(this));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue